import React from "react"; import {createRoot} from "react-dom/client"; import MapLibreGl, {type LayerSpecification, type LngLat, type Map, type MapOptions, type SourceSpecification, type StyleSpecification} from "maplibre-gl"; import MaplibreInspect from "@maplibre/maplibre-gl-inspect"; import colors from "@maplibre/maplibre-gl-inspect/lib/colors"; import MapMaplibreGlLayerPopup from "./MapMaplibreGlLayerPopup"; import MapMaplibreGlFeaturePropertyPopup, { type InspectFeature } from "./MapMaplibreGlFeaturePropertyPopup"; import Color from "color"; import ZoomControl from "../libs/zoomcontrol"; import { type HighlightedLayer, colorHighlightedLayer } from "../libs/highlight"; import "maplibre-gl/dist/maplibre-gl.css"; import "../maplibregl.css"; import "../libs/maplibre-rtl"; import MaplibreGeocoder, { type MaplibreGeocoderApi, type MaplibreGeocoderApiConfig } from "@maplibre/maplibre-gl-geocoder"; import "@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css"; import { withTranslation, type WithTranslation } from "react-i18next"; import i18next from "i18next"; import { Protocol } from "pmtiles"; function buildInspectStyle(originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[], highlightedLayer?: HighlightedLayer) { const backgroundLayer = { "id": "background", "type": "background", "paint": { "background-color": "#1c1f24", } } as LayerSpecification; const layer = colorHighlightedLayer(highlightedLayer); if(layer) { coloredLayers.push(layer); } const sources: {[key:string]: SourceSpecification} = {}; Object.keys(originalMapStyle.sources).forEach(sourceId => { const source = originalMapStyle.sources[sourceId]; if(source.type !== "raster" && source.type !== "raster-dem") { sources[sourceId] = source; } }); const inspectStyle = { ...originalMapStyle, sources: sources, layers: [backgroundLayer].concat(coloredLayers as LayerSpecification[]) }; return inspectStyle; } type MapMaplibreGlInternalProps = { onDataChange?(event: {map: Map | null}): unknown onLayerSelect(index: number): void mapStyle: StyleSpecification mapView: { zoom: number, center: { lng: number, lat: number, }, _from: "map" | "app" }; inspectModeEnabled: boolean highlightedLayer?: HighlightedLayer options?: Partial & { showTileBoundaries?: boolean showCollisionBoxes?: boolean showOverdrawInspector?: boolean } replaceAccessTokens(mapStyle: StyleSpecification): StyleSpecification onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown } & WithTranslation; type MapMaplibreGlState = { map: Map | null; inspect: MaplibreInspect | null; geocoder: MaplibreGeocoder | null; zoomControl: ZoomControl | null; zoom?: number; }; class MapMaplibreGlInternal extends React.Component { static defaultProps = { onMapLoaded: () => {}, onDataChange: () => {}, onLayerSelect: () => {}, onChange: () => {}, options: {} as MapOptions, }; container: HTMLDivElement | null = null; constructor(props: MapMaplibreGlInternalProps) { super(props); this.state = { map: null, inspect: null, geocoder: null, zoomControl: null, }; i18next.on("languageChanged", () => { this.forceUpdate(); }); } shouldComponentUpdate(nextProps: MapMaplibreGlInternalProps, nextState: MapMaplibreGlState) { let should = false; try { should = JSON.stringify(this.props) !== JSON.stringify(nextProps) || JSON.stringify(this.state) !== JSON.stringify(nextState); } catch(_e) { // no biggie, carry on } return should; } componentDidUpdate() { const map = this.state.map; const styleWithTokens = this.props.replaceAccessTokens(this.props.mapStyle); if (map) { // Maplibre GL now does diffing natively so we don't need to calculate // the necessary operations ourselves! // We also need to update the style for inspect to work properly map.setStyle(styleWithTokens, {diff: true}); map.showTileBoundaries = this.props.options?.showTileBoundaries!; map.showCollisionBoxes = this.props.options?.showCollisionBoxes!; map.showOverdrawInspector = this.props.options?.showOverdrawInspector!; // set the map view when the prop was updated from outside if (this.props.mapView._from === "app") { map.jumpTo(this.props.mapView); } } if(this.state.inspect && this.props.inspectModeEnabled !== this.state.inspect._showInspectMap) { this.state.inspect.toggleInspector(); } if (this.state.inspect && this.props.inspectModeEnabled) { this.state.inspect.setOriginalStyle(styleWithTokens); // In case the sources are the same, there's a need to refresh the style setTimeout(() => { this.state.inspect!.render(); }, 500); } } componentDidMount() { const mapOpts = { ...this.props.options, container: this.container!, style: this.props.mapStyle, hash: true, maxZoom: 24, // make root relative urls in stylefiles work as maplibre gl js does // not support this for everything: // https://github.com/maplibre/maplibre-gl-js/issues/6818 transformRequest: (url) => { if (url.startsWith("/")) { url = `${window.location.origin}${url}`; } return { url }; }, // setting to always load glyphs of CJK fonts from server // https://maplibre.org/maplibre-gl-js/docs/examples/local-ideographs/ localIdeographFontFamily: false } satisfies MapOptions; const protocol = new Protocol({metadata: true}); MapLibreGl.addProtocol("pmtiles",protocol.tile); const map = new MapLibreGl.Map(mapOpts); const mapViewChange = () => { const center = map.getCenter(); const zoom = map.getZoom(); this.props.onChange({center, zoom, _from: "map"}); }; mapViewChange(); map.showTileBoundaries = mapOpts.showTileBoundaries!; map.showCollisionBoxes = mapOpts.showCollisionBoxes!; map.showOverdrawInspector = mapOpts.showOverdrawInspector!; const geocoder = this.initGeocoder(map); const zoomControl = new ZoomControl(); map.addControl(zoomControl, "top-right"); const nav = new MapLibreGl.NavigationControl({visualizePitch:true}); map.addControl(nav, "top-right"); const tmpNode = document.createElement("div"); const root = createRoot(tmpNode); const inspectPopup = new MapLibreGl.Popup({ closeOnClick: false }); const inspect = new MaplibreInspect({ popup: inspectPopup, showMapPopup: true, showMapPopupOnHover: false, showInspectMapPopupOnHover: true, showInspectButton: false, blockHoverPopupOnClick: true, assignLayerColor: (layerId: string, alpha: number) => { return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string(); }, buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer), renderPopup: (features: InspectFeature[]) => { if(this.props.inspectModeEnabled) { inspectPopup.once("open", () => { root.render(); }); return tmpNode; } else { inspectPopup.once("open", () => { root.render(,); }); return tmpNode; } } }); map.addControl(inspect); map.on("style.load", () => { this.setState({ map, inspect, geocoder, zoomControl, zoom: map.getZoom() }); }); map.on("data", e => { if(e.dataType !== "tile") return; this.props.onDataChange!({ map: this.state.map }); }); map.on("error", e => { console.log("ERROR", e); }); map.on("zoom", _e => { this.setState({ zoom: map.getZoom() }); }); map.on("dragend", mapViewChange); map.on("zoomend", mapViewChange); } onLayerSelectById = (id: string) => { const index = this.props.mapStyle.layers.findIndex(layer => layer.id === id); this.props.onLayerSelect(index); }; initGeocoder(map: Map) { const geocoderConfig = { forwardGeocode: async (config: MaplibreGeocoderApiConfig) => { const features = []; try { const request = `https://nominatim.openstreetmap.org/search?q=${config.query}&format=geojson&polygon_geojson=1&addressdetails=1`; const response = await fetch(request); const geojson = await response.json(); for (const feature of geojson.features) { const center = [ feature.bbox[0] + (feature.bbox[2] - feature.bbox[0]) / 2, feature.bbox[1] + (feature.bbox[3] - feature.bbox[1]) / 2 ]; const point = { type: "Feature", geometry: { type: "Point", coordinates: center }, place_name: feature.properties.display_name, properties: feature.properties, text: feature.properties.display_name, place_type: ["place"], center }; features.push(point); } } catch (e) { console.error(`Failed to forwardGeocode with error: ${e}`); } return { features }; }, } as unknown as MaplibreGeocoderApi; const geocoder = new MaplibreGeocoder(geocoderConfig, { placeholder: this.props.t("Search"), maplibregl: MapLibreGl, }); map.addControl(geocoder, "top-left"); return geocoder; } render() { const t = this.props.t; this.state.geocoder?.setPlaceholder(t("Search")); this.state.zoomControl?.setLabel(t("Zoom:")); return
{this.container = x;}} data-wd-key="maplibre:map" >
; } } const MapMaplibreGl = withTranslation()(MapMaplibreGlInternal); export default MapMaplibreGl;