Files
jikimo_sf/web_map/static/src/map_view/map_renderer.js
2023-04-14 17:42:23 +08:00

395 lines
13 KiB
JavaScript

/** @odoo-module **/
/*global L*/
import { renderToString } from "@web/core/utils/render";
const { Component, onWillUnmount, onWillUpdateProps, useEffect, useRef, useState } = owl;
const apiTilesRouteWithToken =
"https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}";
const apiTilesRouteWithoutToken = "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png";
const colors = [
"#F06050",
"#6CC1ED",
"#F7CD1F",
"#814968",
"#30C381",
"#D6145F",
"#475577",
"#F4A460",
"#EB7E7F",
"#2C8397",
];
const mapTileAttribution = `
© <a href="https://www.mapbox.com/about/maps/">Mapbox</a>
© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>
<strong>
<a href="https://www.mapbox.com/map-feedback/" target="_blank">
Improve this map
</a>
</strong>`;
export class MapRenderer extends Component {
setup() {
this.leafletMap = null;
this.markers = [];
this.polylines = [];
this.mapContainerRef = useRef("mapContainer");
this.state = useState({
closedGroupIds: [],
});
this.nextId = 1;
useEffect(
() => {
this.leafletMap = L.map(this.mapContainerRef.el, {
maxBounds: [L.latLng(180, -180), L.latLng(-180, 180)],
});
L.tileLayer(this.apiTilesRoute, {
attribution: mapTileAttribution,
tileSize: 512,
zoomOffset: -1,
minZoom: 2,
maxZoom: 19,
id: "mapbox/streets-v11",
accessToken: this.props.model.metaData.mapBoxToken,
}).addTo(this.leafletMap);
},
() => []
);
useEffect(() => {
this.updateMap();
});
onWillUpdateProps(this.onWillUpdateProps);
onWillUnmount(this.onWillUnmount);
}
/**
* Update group opened/closed state.
*/
async onWillUpdateProps(nextProps) {
if (this.props.model.data.groupByKey !== nextProps.model.data.groupByKey) {
this.state.closedGroupIds = [];
}
}
/**
* Remove map and the listeners on its markers and routes.
*/
onWillUnmount() {
this.removeMarkers();
this.removeRoutes();
if (this.leafletMap) {
this.leafletMap.remove();
}
}
/**
* Return the route to the tiles api with or without access token.
*
* @returns {string}
*/
get apiTilesRoute() {
return this.props.model.data.useMapBoxAPI
? apiTilesRouteWithToken
: apiTilesRouteWithoutToken;
}
/**
* If there's located records, adds the corresponding marker on the map.
* Binds events to the created markers.
*/
addMarkers() {
this.removeMarkers();
const markersInfo = {};
let records = this.props.model.data.records;
if (this.props.model.data.isGrouped) {
records = Object.entries(this.props.model.data.recordGroups)
.filter(([key]) => !this.state.closedGroupIds.includes(key))
.flatMap(([groupId, value]) => value.records.map((elem) => ({ ...elem, groupId })));
}
const pinInSamePlace = {};
for (const record of records) {
const partner = record.partner;
if (partner && partner.partner_latitude && partner.partner_longitude) {
const lat_long = `${partner.partner_latitude}-${partner.partner_longitude}`;
const group = this.props.model.data.recordGroups ? `-${record.groupId}` : "";
const key = `${lat_long}${group}`;
if (key in markersInfo) {
markersInfo[key].record = record;
markersInfo[key].ids.push(record.id);
} else {
pinInSamePlace[lat_long] = ++pinInSamePlace[lat_long] || 0;
markersInfo[key] = {
record: record,
ids: [record.id],
pinInSamePlace: pinInSamePlace[lat_long],
};
}
}
}
for (const markerInfo of Object.values(markersInfo)) {
const params = {
count: markerInfo.ids.length,
isMulti: markerInfo.ids.length > 1,
number: this.props.model.data.records.indexOf(markerInfo.record) + 1,
numbering: this.props.model.metaData.numbering,
};
if (this.props.model.data.isGrouped) {
const groupId = markerInfo.record.groupId;
params.color = this.getGroupColor(groupId);
params.number = this.props.model.data.recordGroups[groupId].records.findIndex(
(record) => {
return record.id === markerInfo.record.id;
}
);
}
// Icon creation
const iconInfo = {
className: "o-map-renderer--marker",
html: renderToString("web_map.marker", params),
};
const offset = markerInfo.pinInSamePlace * 0.000025;
// Attach marker with icon and popup
const marker = L.marker(
[
markerInfo.record.partner.partner_latitude + offset,
markerInfo.record.partner.partner_longitude - offset,
],
{ icon: L.divIcon(iconInfo) }
);
marker.addTo(this.leafletMap);
marker.on("click", () => {
this.createMarkerPopup(markerInfo, offset);
});
this.markers.push(marker);
}
}
/**
* If there are computed routes, create polylines and add them to the map.
* each element of this.props.routeInfo[0].legs array represent the route between
* two waypoints thus each of these must be a polyline.
*/
addRoutes() {
this.removeRoutes();
if (!this.props.model.data.useMapBoxAPI || !this.props.model.data.routes.length) {
return;
}
for (const leg of this.props.model.data.routes[0].legs) {
const latLngs = [];
for (const step of leg.steps) {
for (const coordinate of step.geometry.coordinates) {
latLngs.push(L.latLng(coordinate[1], coordinate[0]));
}
}
const polyline = L.polyline(latLngs, {
color: "blue",
weight: 5,
opacity: 0.3,
}).addTo(this.leafletMap);
const polylines = this.polylines;
polyline.on("click", function () {
for (const polyline of polylines) {
polyline.setStyle({ color: "blue", opacity: 0.3 });
}
this.setStyle({ color: "darkblue", opacity: 1.0 });
});
this.polylines.push(polyline);
}
}
/**
* Create a popup for the specified marker.
*
* @param {Object} markerInfo
* @param {Number} latLongOffset
*/
createMarkerPopup(markerInfo, latLongOffset = 0) {
const popupFields = this.getMarkerPopupFields(markerInfo);
const partner = markerInfo.record.partner;
const popupHtml = renderToString("web_map.markerPopup", {
fields: popupFields,
hasFormView: this.props.model.metaData.hasFormView,
url: `https://www.google.com/maps/dir/?api=1&destination=${partner.partner_latitude},${partner.partner_longitude}`,
});
const popup = L.popup({ offset: [0, -30] })
.setLatLng([
partner.partner_latitude + latLongOffset,
partner.partner_longitude - latLongOffset,
])
.setContent(popupHtml)
.openOn(this.leafletMap);
const openBtn = popup
.getElement()
.querySelector("button.o-map-renderer--popup-buttons-open");
if (openBtn) {
openBtn.onclick = () => {
this.props.onMarkerClick(markerInfo.ids);
};
}
return popup;
}
/**
* @param {Number} groupId
*/
getGroupColor(groupId) {
const index = Object.keys(this.props.model.data.recordGroups).indexOf(groupId);
return colors[index % colors.length];
}
/**
* Creates an array of latLng objects if there is located records.
*
* @returns {latLngBounds|boolean} objects containing the coordinates that
* allows all the records to be shown on the map or returns false
* if the records does not contain any located record.
*/
getLatLng() {
const tabLatLng = [];
for (const record of this.props.model.data.records) {
const partner = record.partner;
if (partner && partner.partner_latitude && partner.partner_longitude) {
tabLatLng.push(L.latLng(partner.partner_latitude, partner.partner_longitude));
}
}
if (!tabLatLng.length) {
return false;
}
return L.latLngBounds(tabLatLng);
}
/**
* Get the fields' name and value to display in the popup.
*
* @param {Object} markerInfo
* @returns {Object} value contains the value of the field and string
* contains the value of the xml's string attribute
*/
getMarkerPopupFields(markerInfo) {
const record = markerInfo.record;
const fieldsView = [];
// Only display address in multi coordinates marker popup
if (markerInfo.ids.length > 1) {
if (!this.props.model.metaData.hideAddress) {
fieldsView.push({
id: this.nextId++,
value: record.partner.contact_address_complete,
string: this.env._t("Address"),
});
}
return fieldsView;
}
if (!this.props.model.metaData.hideName) {
fieldsView.push({
id: this.nextId++,
value: record.display_name,
string: this.env._t("Name"),
});
}
if (!this.props.model.metaData.hideAddress) {
fieldsView.push({
id: this.nextId++,
value: record.partner.contact_address_complete,
string: this.env._t("Address"),
});
}
for (const field of this.props.model.metaData.fieldNamesMarkerPopup) {
if (record[field.fieldName]) {
const fieldName =
record[field.fieldName] instanceof Array
? record[field.fieldName][1]
: record[field.fieldName];
fieldsView.push({
id: this.nextId++,
value: fieldName,
string: field.string,
});
}
}
return fieldsView;
}
/**
* Remove the markers from the map and empty the markers array.
*/
removeMarkers() {
for (const marker of this.markers) {
marker.off("click");
this.leafletMap.removeLayer(marker);
}
this.markers = [];
}
/**
* Remove the routes from the map and empty the the polyline array.
*/
removeRoutes() {
for (const polyline of this.polylines) {
polyline.off("click");
this.leafletMap.removeLayer(polyline);
}
this.polylines = [];
}
/**
* Update position in the map, markers and routes.
*/
updateMap() {
if (this.props.model.data.shouldUpdatePosition) {
const initialCoord = this.getLatLng();
if (initialCoord) {
this.leafletMap.flyToBounds(initialCoord, { animate: false });
} else {
this.leafletMap.fitWorld();
}
this.leafletMap.closePopup();
}
this.addMarkers();
this.addRoutes();
}
/**
* Center the map on a certain pin and open the popup linked to it.
*
* @param {Object} record
*/
centerAndOpenPin(record) {
const popup = this.createMarkerPopup({
record: record,
ids: [record.id],
});
const px = this.leafletMap.project([
record.partner.partner_latitude,
record.partner.partner_longitude,
]);
const popupHeight = popup.getElement().offsetHeight;
px.y -= popupHeight / 2;
const latlng = this.leafletMap.unproject(px);
this.leafletMap.panTo(latlng, { animate: true });
}
/**
* @param {Number} id
*/
toggleGroup(id) {
if (this.state.closedGroupIds.includes(id)) {
const index = this.state.closedGroupIds.indexOf(id);
this.state.closedGroupIds.splice(index, 1);
} else {
this.state.closedGroupIds.push(id);
}
}
}
MapRenderer.template = "web_map.MapRenderer";
MapRenderer.props = {
model: Object,
onMarkerClick: Function,
};