mirror of
https://github.com/maputnik/editor.git
synced 2026-04-22 17:30:02 +00:00
## Launch Checklist closes https://github.com/maplibre/maputnik/issues/1546 - [x] Link to related issues. https://github.com/maplibre/maputnik/issues/1546 - [x] Write tests for all new functionality. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Harel M <harel.mazor@gmail.com>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
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<MapOptions> & {
|
|
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<MapMaplibreGlInternalProps, MapMaplibreGlState> {
|
|
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(<MapMaplibreGlFeaturePropertyPopup features={features} />);
|
|
});
|
|
return tmpNode;
|
|
} else {
|
|
inspectPopup.once("open", () => {
|
|
root.render(<MapMaplibreGlLayerPopup
|
|
features={features}
|
|
onLayerSelect={this.onLayerSelectById}
|
|
zoom={this.state.zoom}
|
|
/>,);
|
|
});
|
|
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 <div
|
|
className="maputnik-map__map"
|
|
role="region"
|
|
aria-label={t("Map view")}
|
|
ref={x => {this.container = x;}}
|
|
data-wd-key="maplibre:map"
|
|
></div>;
|
|
}
|
|
}
|
|
|
|
const MapMaplibreGl = withTranslation()(MapMaplibreGlInternal);
|
|
export default MapMaplibreGl;
|