@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
361 lines (320 loc) • 12.6 kB
JavaScript
import Map from "./Map";
import { COLORS, QUALITYSCORE_VALUES } from "../../utils/utils";
import { TILES_PICTURES_ZOOM, MAP_EXPR_QUALITYSCORE, switchCoefValue } from "../../utils/map";
const MAP_THEMES = {
DEFAULT: "default",
AGE: "age",
TYPE: "type",
SCORE: "score",
};
export const MAP_FILTERS = [ "minDate", "maxDate", "pic_type", "camera", "theme", "qualityscore"];
/**
* MapMore is a more complete version of [Map UI component](#Panoramax.components.ui.Map).
*
* It offers advanced features like themes, filters and more.
*
* Note that all functions of [MapLibre GL JS class Map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/) are also available.
*
* ⚠️ This class doesn't inherit from [EventTarget](https://developer.mozilla.org/fr/docs/Web/API/EventTarget), so it doesn't have `addEventListener` and `dispatchEvent` functions.
* It uses instead [`on`](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#on) and `fire` functions from MapLibre Map class.
* `fire` function doesn't take directly [`Event`](https://developer.mozilla.org/fr/docs/Web/API/Event) objects, but a string and object data.
* @class Panoramax.components.ui.MapMore
* @extends Panoramax.components.ui.Map
* @param {Panoramax.components.core.Basic} parent The parent view
* @param {Element} container The DOM element to create into
* @param {object} [options] The map options (any of [MapLibre GL settings](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/) or any supplementary option defined here)
* @param {object} [options.raster] The MapLibre raster source for aerial background. This must be a JSON object following [MapLibre raster source definition](https://maplibre.org/maplibre-style-spec/sources/#raster).
* @param {string} [options.background=streets] Choose default map background to display (streets or aerial, if raster aerial background available). Defaults to streets.
* @param {string} [options.theme=default] The map theme (default, age, score, type)
* @fires Panoramax.components.ui.Map#background-changed
* @fires Panoramax.components.ui.Map#users-changed
* @fires Panoramax.components.ui.Map#sequence-hover
* @fires Panoramax.components.ui.Map#sequence-click
* @fires Panoramax.components.ui.Map#picture-click
* @fires Panoramax.components.ui.MapMore#filters-changed
* @example
* const map = new Panoramax.components.ui.MapMore(viewer, mapNode, {center: {lat: 48.7, lng: -1.7}});
*/
export default class MapMore extends Map {
constructor(parent, container, options = {}) {
super(parent, container, options);
// Map theme
this._mapFilters = {};
if(this._options.theme) { this._mapFilters = { theme: this._options.theme }; }
}
reloadLayersStyles() {
super.reloadLayersStyles();
// Also handle the grid stats
if(this._hasGridStats()) {
let newType = "coef";
if(this._mapFilters.pic_type) {
newType = this._mapFilters.pic_type == "flat" ? "coef_flat_pictures" : "coef_360_pictures";
}
this.getStyle().layers
.filter(l => l.id.endsWith("_grid"))
.forEach(l => {
const newl = switchCoefValue(l, newType);
for(let p in newl.layout) {
this.setLayoutProperty(l.id, p, newl.layout[p]);
}
for(let p in newl.paint) {
this.setPaintProperty(l.id, p, newl.paint[p]);
}
});
}
}
/**
* Computes dates to use for map theme by picture/sequence age
* @private
*/
_getDatesForLayerColors() {
const oneDay = 24 * 60 * 60 * 1000;
const d0 = Date.now();
const d1 = d0 - 30 * oneDay;
const d2 = d0 - 365 * oneDay;
const d3 = d0 - 2 * 365 * oneDay;
return [d1, d2, d3].map(d => new Date(d).toISOString().split("T")[0]);
}
/**
* Retrieve map layer color scheme according to selected theme.
* @private
*/
_getLayerColorStyle(layer) {
// Hidden style
const s = ["case",
["==", ["get", "hidden"], true], COLORS.HIDDEN
];
// Selected sequence style
const picId = this._parent.psv?._myVTour?.state?.loadingNode || this._parent.psv?._myVTour?.state?.currentNode?.id;
const seqId = picId ? this._parent.psv?._picturesSequences[picId] : null;
if(layer == "sequences" && seqId) {
s.push(["==", ["get", "id"], seqId], COLORS.SELECTED);
}
else if(layer == "pictures" && seqId) {
s.push(["in", seqId, ["get", "sequences"]], COLORS.SELECTED);
}
// Themes styles
if(this._mapFilters.theme == MAP_THEMES.AGE) {
const prop = layer == "sequences" ? "date" : "ts";
const dt = this._getDatesForLayerColors();
s.push(
["!", ["has", prop]], COLORS.BASE,
[">=", ["get", prop], dt[0]], COLORS.PALETTE_4,
[">=", ["get", prop], dt[1]], COLORS.PALETTE_3,
[">=", ["get", prop], dt[2]], COLORS.PALETTE_2,
COLORS.PALETTE_1
);
}
else if(this._mapFilters.theme == MAP_THEMES.TYPE) {
s.push(
["!", ["has", "type"]], COLORS.BASE,
["==", ["get", "type"], "equirectangular"], COLORS.QUALI_1,
COLORS.QUALI_2
);
}
else if(this._mapFilters.theme == MAP_THEMES.SCORE) {
s.push(
["==", MAP_EXPR_QUALITYSCORE, 5], QUALITYSCORE_VALUES[0].color,
["==", MAP_EXPR_QUALITYSCORE, 4], QUALITYSCORE_VALUES[1].color,
["==", MAP_EXPR_QUALITYSCORE, 3], QUALITYSCORE_VALUES[2].color,
["==", MAP_EXPR_QUALITYSCORE, 2], QUALITYSCORE_VALUES[3].color,
QUALITYSCORE_VALUES[4].color,
);
}
else {
s.push(COLORS.BASE);
}
return s;
}
/**
* Retrieve map sort key according to selected theme.
* @private
*/
_getLayerSortStyle(layer) {
// Values
// - 100 : on top / selected feature
// - 90 : hidden feature
// - 20-80 : custom ranges
// - 10 : basic feature
// - 0 : on bottom / feature with undefined property
// Hidden style
const s = ["case",
["==", ["get", "hidden"], true], 90
];
// Selected sequence style
const picId = this._parent.psv?._myVTour?.state?.loadingNode || this._parent.psv?._myVTour?.state?.currentNode?.id;
const seqId = picId ? this._parent.psv?._picturesSequences[picId] : null;
if(layer == "sequences" && seqId) {
s.push(["==", ["get", "id"], seqId], 100);
}
else if(layer == "pictures" && seqId) {
s.push(["in", seqId, ["get", "sequences"]], 100);
}
// Themes styles
if(this._mapFilters.theme == MAP_THEMES.AGE) {
const prop = layer == "sequences" ? "date" : "ts";
const dt = this._getDatesForLayerColors();
s.push(
["!", ["has", prop]], 0,
[">=", ["get", prop], dt[0]], 50,
[">=", ["get", prop], dt[1]], 49,
[">=", ["get", prop], dt[2]], 48,
);
}
else if(this._mapFilters.theme == MAP_THEMES.TYPE) {
s.push(
["!", ["has", "type"]], 0,
["==", ["get", "type"], "equirectangular"], 50,
);
}
else if(this._mapFilters.theme == MAP_THEMES.SCORE) {
s.push(
["==", MAP_EXPR_QUALITYSCORE, 5], 80,
["==", MAP_EXPR_QUALITYSCORE, 4], 65,
["==", MAP_EXPR_QUALITYSCORE, 3], 50,
["==", MAP_EXPR_QUALITYSCORE, 2], 35,
["==", MAP_EXPR_QUALITYSCORE, 1], 20,
);
}
s.push(10);
return s;
}
/** @private */
_mapFiltersToLayersFilters(filters) {
let mapFilters = {};
let mapSeqFilters = [];
let mapPicFilters = [];
let reloadMapStyle = false;
if(filters.minDate && filters.minDate !== "") {
mapFilters.minDate = filters.minDate;
mapSeqFilters.push([">=", ["get", "date"], filters.minDate]);
mapPicFilters.push([">=", ["get", "ts"], filters.minDate]);
}
if(filters.maxDate && filters.maxDate !== "") {
mapFilters.maxDate = filters.maxDate;
mapSeqFilters.push(["<=", ["get", "date"], filters.maxDate]);
// Get tomorrow date for pictures filtering
// (because ts is date+time, so comparing date only string would fail otherwise)
let d = new Date(filters.maxDate);
d.setDate(d.getDate() + 1);
d = d.toISOString().split("T")[0];
mapPicFilters.push(["<=", ["get", "ts"], d]);
}
if(filters.pic_type && filters.pic_type !== "") {
mapFilters.pic_type = filters.pic_type === "flat" ? "flat" : "equirectangular";
mapSeqFilters.push(["==", ["get", "type"], mapFilters.pic_type]);
mapPicFilters.push(["==", ["get", "type"], mapFilters.pic_type]);
}
if(this._hasGridStats()) {
reloadMapStyle = true;
}
if(filters.camera && filters.camera !== "") {
mapFilters.camera = filters.camera;
// low/high model hack : to enable fuzzy filtering of camera make and model
const lowModel = filters.camera.toLowerCase().trim() + " ";
const highModel = filters.camera.toLowerCase().trim() + "zzzzzzzzzzzzzzzzzzzz";
const collator = ["collator", { "case-sensitive": false, "diacritic-sensitive": false } ];
mapSeqFilters.push([">=", ["get", "model"], lowModel, collator]);
mapSeqFilters.push(["<=", ["get", "model"], highModel, collator]);
mapPicFilters.push([">=", ["get", "model"], lowModel, collator]);
mapPicFilters.push(["<=", ["get", "model"], highModel, collator]);
}
if(filters.qualityscore && filters.qualityscore.length > 0) {
mapFilters.qualityscore = filters.qualityscore;
mapSeqFilters.push(["in", MAP_EXPR_QUALITYSCORE, ["literal", mapFilters.qualityscore]]);
mapPicFilters.push(["in", MAP_EXPR_QUALITYSCORE, ["literal", mapFilters.qualityscore]]);
}
if(filters.theme && Object.values(MAP_THEMES).includes(filters.theme)) {
mapFilters.theme = filters.theme;
reloadMapStyle = true;
}
if(mapSeqFilters.length == 0) { mapSeqFilters = null; }
else {
mapSeqFilters.unshift("all");
}
if(mapPicFilters.length == 0) { mapPicFilters = null; }
else {
mapPicFilters.unshift("all");
mapPicFilters = ["step", ["zoom"],
true,
TILES_PICTURES_ZOOM, mapPicFilters
];
}
return { mapFilters, mapSeqFilters, mapPicFilters, reloadMapStyle };
}
/**
* Change the map filters
* @param {object} filters Filtering values
* @param {string} [filters.minDate] Start date for pictures (format YYYY-MM-DD)
* @param {string} [filters.maxDate] End date for pictures (format YYYY-MM-DD)
* @param {string} [filters.pic_type] Type of picture to keep (flat, equirectangular)
* @param {string} [filters.camera] Camera make and model to keep
* @param {string} [filters.theme] Map theme to use
* @param {number[]} [filters.qualityscore] QualityScore values, as a list of 1 to 5 grades
* @param {boolean} [skipZoomIn=false] If true, doesn't force zoom in to map level >= 7
* @memberof Panoramax.components.core.MapMore#
*/
setFilters(filters, skipZoomIn = false) {
let { mapFilters, mapSeqFilters, mapPicFilters, reloadMapStyle } = this._mapFiltersToLayersFilters(filters);
this._mapFilters = mapFilters;
if(reloadMapStyle) {
this.reloadLayersStyles();
}
const allUsers = this.getVisibleUsers().includes("geovisio");
if(mapSeqFilters && allUsers) {
mapSeqFilters = ["step", ["zoom"],
true,
7, mapSeqFilters
];
}
this.filterUserLayersContent("sequences", mapSeqFilters);
this.filterUserLayersContent("pictures", mapPicFilters);
if(
!skipZoomIn
&& (
mapSeqFilters !== null
|| mapPicFilters !== null
|| (this._mapFilters.theme !== null && this._mapFilters.theme !== MAP_THEMES.DEFAULT)
)
&& allUsers
&& this.getZoom() < 7
&& !this._hasGridStats()
) {
this.easeTo({ zoom: 7 });
}
/**
* Event for filters changes
* @event Panoramax.components.ui.MapMore#filters-changed
* @type {maplibregl.util.evented.Event}
* @property {string} [minDate] The minimum date in time range (ISO format)
* @property {string} [maxDate] The maximum date in time range (ISO format)
* @property {string} [pic_type] Camera type (equirectangular, flat, null/empty string for both)
* @property {string} [camera] Camera make and model
* @property {string} [theme] Map theme
* @property {number[]} [qualityscore] QualityScore values, as a list of 1 to 5 grades
*/
this.fire("filters-changed", Object.assign({}, this._mapFilters));
}
/**
* Make given user layers visible on map, and hide all others (if any)
* @memberof Panoramax.components.ui.Map#
* @param {string|string[]} visibleIds The user layers IDs to display
*/
async setVisibleUsers(visibleIds = []) {
await super.setVisibleUsers(visibleIds);
// Force reload of styles & filters
let { mapSeqFilters, mapPicFilters, reloadMapStyle } = this._mapFiltersToLayersFilters(this._mapFilters);
if(reloadMapStyle) {
this.reloadLayersStyles();
}
const allUsers = visibleIds.includes("geovisio");
if(mapSeqFilters && allUsers) {
mapSeqFilters = ["step", ["zoom"],
true,
7, mapSeqFilters
];
}
this.filterUserLayersContent("sequences", mapSeqFilters);
this.filterUserLayersContent("pictures", mapPicFilters);
}
}