app: viewer improvements [#555] (#557)

* add line labels
* allow loading remote and local tilesets without clearing the current tileset
This commit is contained in:
Brandon Liu
2025-04-22 14:33:33 +08:00
committed by GitHub
parent 731f03d325
commit 26c857ff40
3 changed files with 228 additions and 168 deletions

View File

@@ -12,10 +12,12 @@ import {
} from "maplibre-gl"; } from "maplibre-gl";
import { Compression, type Entry, tileIdToZxy, tileTypeExt } from "pmtiles"; import { Compression, type Entry, tileIdToZxy, tileTypeExt } from "pmtiles";
import { import {
type Accessor,
For, For,
type Setter, type Setter,
Show, Show,
createEffect, createEffect,
createMemo,
createResource, createResource,
createSignal, createSignal,
onMount, onMount,
@@ -343,16 +345,18 @@ function DirectoryTable(props: {
); );
} }
function ArchiveView(props: { genericTileset: Tileset }) { function ArchiveView(props: { genericTileset: Accessor<Tileset> }) {
const tileset = () => { const tileset = createMemo(() => {
if (props.genericTileset instanceof PMTilesTileset) { console.log("memo!");
return props.genericTileset as PMTilesTileset; const g = props.genericTileset();
if (g instanceof PMTilesTileset) {
return g as PMTilesTileset;
} }
alert("This isn't a PMTiles archive!"); alert("This isn't a PMTiles archive!");
throw "This isn't a PMTiles tileset"; throw "This isn't a PMTiles tileset";
}; });
const [header] = createResource(tileset(), async (t) => { const [header] = createResource(tileset, async (t) => {
return await t.archive.getHeader(); return await t.archive.getHeader();
}); });
@@ -438,7 +442,7 @@ function ArchiveView(props: { genericTileset: Tileset }) {
<DirectoryTable <DirectoryTable
entries={rootEntries() || []} entries={rootEntries() || []}
stateUrl={props.genericTileset.getStateUrl()} stateUrl={props.genericTileset().getStateUrl()}
setHoveredTile={setHoveredTile} setHoveredTile={setHoveredTile}
setOpenedLeaf={setOpenedLeaf} setOpenedLeaf={setOpenedLeaf}
/> />
@@ -449,7 +453,7 @@ function ArchiveView(props: { genericTileset: Tileset }) {
<div class="w-full flex flex-1 overflow-hidden"> <div class="w-full flex flex-1 overflow-hidden">
<DirectoryTable <DirectoryTable
entries={l()} entries={l()}
stateUrl={props.genericTileset.getStateUrl()} stateUrl={props.genericTileset().getStateUrl()}
setHoveredTile={setHoveredTile} setHoveredTile={setHoveredTile}
setOpenedLeaf={setOpenedLeaf} setOpenedLeaf={setOpenedLeaf}
/> />
@@ -521,7 +525,7 @@ function PageArchive() {
when={tileset()} when={tileset()}
fallback={<ExampleChooser setTileset={setTileset} />} fallback={<ExampleChooser setTileset={setTileset} />}
> >
{(t) => <ArchiveView genericTileset={t()} />} {(t) => <ArchiveView genericTileset={t} />}
</Show> </Show>
</Frame> </Frame>
); );

View File

@@ -41,7 +41,7 @@ declare module "solid-js" {
} }
function MapView(props: { function MapView(props: {
tileset: Tileset; tileset: Accessor<Tileset>;
showMetadata: Accessor<boolean>; showMetadata: Accessor<boolean>;
setShowMetadata: Setter<boolean>; setShowMetadata: Setter<boolean>;
showTileBoundaries: Accessor<boolean>; showTileBoundaries: Accessor<boolean>;
@@ -83,178 +83,51 @@ function MapView(props: {
addProtocol("pmtiles", protocol.tile); addProtocol("pmtiles", protocol.tile);
let map: MaplibreMap; let map: MaplibreMap;
let initialLoad = true;
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);
}
}
}
});
const roundZoom = () => { const roundZoom = () => {
map.zoomTo(Math.round(map.getZoom())); map.zoomTo(Math.round(map.getZoom()));
}; };
onMount(async () => { const fitToBounds = async () => {
if (!mapContainer) { const bounds = await props.tileset().getBounds();
console.error("Could not mount map element"); map.fitBounds(
return; [
} [bounds[0], bounds[1]],
[bounds[2], bounds[3]],
],
{ animate: false },
);
};
const archiveForProtocol = props.tileset.archiveForProtocol(); const removeTileset = () => {
for (const layer of map.getStyle().layers) {
if ("source" in layer && layer.source === "tileset") {
map.removeLayer(layer.id);
}
}
map.removeSource("tileset");
};
const addTileset = async (tileset: Tileset) => {
const archiveForProtocol = tileset.archiveForProtocol();
if (archiveForProtocol) { if (archiveForProtocol) {
protocol.add(archiveForProtocol); protocol.add(archiveForProtocol);
} }
if (getRTLTextPluginStatus() === "unavailable") {
setRTLTextPlugin(
"https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js",
true,
);
}
let flavor = "white"; let flavor = "white";
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) { if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
flavor = "black"; flavor = "black";
} }
if (await tileset.isOverlay()) {
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/${flavor}`,
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(flavor), { lang: "en" }).map(
(l) => {
if (!("layout" in l)) {
l.layout = {};
}
if (l.layout) l.layout.visibility = "none";
return l;
},
),
},
});
createEffect(() => {
map.showTileBoundaries = props.showTileBoundaries();
});
createEffect(() => {
if (props.inspectFeatures()) {
setFrozen(false);
} else {
for (const hoveredFeature of hoveredFeatures()) {
map.setFeatureState(hoveredFeature, { hover: false });
}
popup.remove();
}
});
map.addControl(new NavigationControl({}), "top-left");
map.addControl(new AttributionControl({ compact: false }), "bottom-right");
if (!props.mapHashPassed) {
fitToBounds();
}
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()) {
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) {
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"
target="_blank"
rel="noreferrer"
href={tileInspectUrl(props.tileset.getStateUrl(), [
z,
tileX,
tileY,
])}
>
Tile {z}/{tileX}/{tileY}
</a>
</div>
),
hiddenRef,
);
popup.setHTML(hiddenRef.innerHTML);
popup.setLngLat(e.lngLat);
popup.addTo(map);
}
});
map.on("click", () => {
setFrozen(!frozen());
});
map.on("load", async () => {
if (await props.tileset.isOverlay()) {
setBasemap(true); setBasemap(true);
} }
if (await props.tileset.isVector()) { if (await tileset.isVector()) {
map.addSource("tileset", { map.addSource("tileset", {
type: "vector", type: "vector",
url: props.tileset.getMaplibreSourceUrl(), url: tileset.getMaplibreSourceUrl(),
}); });
const vectorLayers = await props.tileset.getVectorLayers(); const vectorLayers = await tileset.getVectorLayers();
setLayerVisibility(vectorLayers.map((v) => ({ id: v, visible: true }))); setLayerVisibility(vectorLayers.map((v) => ({ id: v, visible: true })));
for (const [i, vectorLayer] of vectorLayers.entries()) { for (const [i, vectorLayer] of vectorLayers.entries()) {
map.addLayer({ map.addLayer({
@@ -309,6 +182,24 @@ function MapView(props: {
}); });
} }
for (const [i, vectorLayer] of vectorLayers.entries()) { 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": flavor,
"text-halo-width": 2,
},
filter: ["==", ["geometry-type"], "LineString"],
});
map.addLayer({ map.addLayer({
id: `tileset_point_label_${vectorLayer}`, id: `tileset_point_label_${vectorLayer}`,
type: "symbol", type: "symbol",
@@ -331,7 +222,7 @@ function MapView(props: {
} else { } else {
map.addSource("tileset", { map.addSource("tileset", {
type: "raster", type: "raster",
url: props.tileset.getMaplibreSourceUrl(), url: tileset.getMaplibreSourceUrl(),
}); });
map.addLayer({ map.addLayer({
source: "tileset", source: "tileset",
@@ -339,20 +230,46 @@ function MapView(props: {
type: "raster", type: "raster",
}); });
} }
map.resize(); };
});
createEffect(() => {
const tileset = props.tileset();
if (initialLoad) {
initialLoad = false;
return;
}
removeTileset();
addTileset(tileset);
}); });
const fitToBounds = async () => { createEffect(() => {
const bounds = await props.tileset.getBounds(); const visibility = basemap() ? "visible" : "none";
map.fitBounds( if (map) {
[ for (const layer of map.getStyle().layers) {
[bounds[0], bounds[1]], if ("source" in layer && layer.source === "basemap") {
[bounds[2], bounds[3]], map.setLayoutProperty(layer.id, "visibility", visibility);
], }
{ animate: false }, }
); }
}; });
createEffect(() => {
const show = props.showTileBoundaries();
if (map) {
map.showTileBoundaries = show;
}
});
createEffect(() => {
if (props.inspectFeatures()) {
setFrozen(false);
} else {
for (const hoveredFeature of hoveredFeatures()) {
map.setFeatureState(hoveredFeature, { hover: false });
}
popup.remove();
}
});
createEffect(() => { createEffect(() => {
const setVisibility = (layerName: string, visibility: string) => { const setVisibility = (layerName: string, visibility: string) => {
@@ -370,6 +287,137 @@ function MapView(props: {
} }
}); });
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,
);
}
let flavor = "white";
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
flavor = "black";
}
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/${flavor}`,
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(flavor), { 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();
}
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()) {
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) {
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"
target="_blank"
rel="noreferrer"
href={tileInspectUrl(props.tileset().getStateUrl(), [
z,
tileX,
tileY,
])}
>
Tile {z}/{tileX}/{tileY}
</a>
</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 ( return (
<div class="flex flex-col md:flex-row w-full h-full"> <div class="flex flex-col md:flex-row w-full h-full">
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
@@ -458,9 +506,9 @@ function MapView(props: {
); );
} }
const JsonView = (props: { tileset: Tileset }) => { const JsonView = (props: { tileset: Accessor<Tileset> }) => {
const [data] = createResource(async () => { const [data] = createResource(async () => {
return await props.tileset.getMetadata(); return await props.tileset().getMetadata();
}); });
return <json-viewer data={data()} />; return <json-viewer data={data()} />;
@@ -517,7 +565,7 @@ function PageMap() {
> >
{(t) => ( {(t) => (
<MapView <MapView
tileset={t()} tileset={t}
showMetadata={showMetadata} showMetadata={showMetadata}
setShowMetadata={setShowMetadata} setShowMetadata={setShowMetadata}
showTileBoundaries={showTileBoundaries} showTileBoundaries={showTileBoundaries}

View File

@@ -18,6 +18,7 @@ import {
type Setter, type Setter,
Show, Show,
createEffect, createEffect,
createMemo,
createResource, createResource,
createSignal, createSignal,
onMount, onMount,
@@ -104,7 +105,7 @@ function layerFeatureCounts(
function ZoomableTile(props: { function ZoomableTile(props: {
zxy: Accessor<[number, number, number]>; zxy: Accessor<[number, number, number]>;
tileset: Tileset; tileset: Accessor<Tileset>;
}) { }) {
let containerRef: HTMLDivElement | undefined; let containerRef: HTMLDivElement | undefined;
let svg: Selection<SVGSVGElement, unknown, null, undefined>; let svg: Selection<SVGSVGElement, unknown, null, undefined>;
@@ -211,20 +212,27 @@ function ZoomableTile(props: {
} }
}); });
const [parsedTile] = createResource(props.zxy, async (zxy) => { const inputs = createMemo(() => ({
const tileset = props.tileset; zxy: props.zxy(),
tileset: props.tileset(),
}));
const [parsedTile] = createResource(inputs, async (i) => {
const tileset = i.tileset;
const zxy = i.zxy;
if (await tileset.isVector()) { if (await tileset.isVector()) {
const data = await tileset.getZxy(zxy[0], zxy[1], zxy[2]); const data = await tileset.getZxy(zxy[0], zxy[1], zxy[2]);
if (!data) return; if (!data) return;
const vectorLayers = await props.tileset.getVectorLayers(); const vectorLayers = await props.tileset().getVectorLayers();
return parseTile(data, vectorLayers); return parseTile(data, vectorLayers);
} }
return await tileset.getZxy(zxy[0], zxy[1], zxy[2]); return await tileset.getZxy(zxy[0], zxy[1], zxy[2]);
}); });
onMount(async () => { createEffect(async () => {
if (await props.tileset.isVector()) { const tileset = props.tileset();
const vectorLayers = await props.tileset.getVectorLayers(); if (await tileset.isVector()) {
const vectorLayers = await tileset.getVectorLayers();
setLayerVisibility(vectorLayers.map((v) => ({ id: v, visible: true }))); setLayerVisibility(vectorLayers.map((v) => ({ id: v, visible: true })));
} }
}); });
@@ -315,7 +323,7 @@ function ZoomableTile(props: {
} }
function TileView(props: { function TileView(props: {
tileset: Tileset; tileset: Accessor<Tileset>;
zxy: Accessor<[number, number, number] | undefined>; zxy: Accessor<[number, number, number] | undefined>;
setZxy: Setter<[number, number, number] | undefined>; setZxy: Setter<[number, number, number] | undefined>;
}) { }) {
@@ -523,7 +531,7 @@ function PageTile() {
when={tileset()} when={tileset()}
fallback={<ExampleChooser setTileset={setTileset} />} fallback={<ExampleChooser setTileset={setTileset} />}
> >
{(t) => <TileView tileset={t()} zxy={zxy} setZxy={setZxy} />} {(t) => <TileView tileset={t} zxy={zxy} setZxy={setZxy} />}
</Show> </Show>
</Frame> </Frame>
); );