/* --------------------------------------------------------- * Odoo base_geoengine * Contributor Yannick Vaucher 2018 Camptocamp SA * License in __manifest__.py at root level of the module * --------------------------------------------------------- */ odoo.define('base_geoengine.GeoengineRenderer', function (require) { "use strict"; var BasicRenderer = require('web.BasicRenderer'); var utils = require('web.utils'); var QWeb = require('web.QWeb'); var session = require('web.session'); var Record = require('base_geoengine.Record'); var GeoengineRecord = Record.GeoengineRecord; var geoengine_common = require('base_geoengine.geoengine_common'); var BackgroundLayers = require('base_geoengine.BackgroundLayers'); /* CONSTANTS */ var DEFAULT_BEGIN_COLOR = "#FFFFFF"; var DEFAULT_END_COLOR = "#000000"; // For prop symbols only var DEFAULT_MIN_SIZE = 5; // For prop symbols only var DEFAULT_MAX_SIZE = 15; // For choroplets only var DEFAULT_NUM_CLASSES = 5; var LEGEND_MAX_ITEMS = 10; /** * Get display value of selection technical value * * @param {Object} field - odoo selection field * @param {String} value - technical value for selection field * * @returns {String} Selection label for value */ var _getSelectionLabel = function (field, value) { for (var option in field.selection) { if (field.selection[option][0] === value) { return field.selection[option][1]; } } }; var _createFieldSpan = function (field, value) { var label = field.string; var val = value; if (field.type === 'selection') { val = _getSelectionLabel(field, val); } if (val instanceof Array) { // TODO this needs a comment to know when a field in an Array val = val[1]; } if (val instanceof Object){ val = (val['data'] || {})['display_name']; } return '' + label + ': ' + val; }; /** * Formats attributes into a string for a single feature * * @param {Object} a - attributes * @param {Object} fields - list of fields * * @returns {String} HTML content */ var formatFeatureHTML = function (a, fields) { var str = []; var oid = ''; for (var key in a) { if (Object.prototype.hasOwnProperty.call(a, key)) { var val = a[key]; if (val === false) { continue; } if (Object.prototype.hasOwnProperty.call(fields, key)) { var field = fields[key]; var span = _createFieldSpan(field, val); // ID field to put on first position if (key === 'id') { oid = span; } else { str.push(span); } } } } str.unshift(oid); return str.join('
'); }; /** * Formats attributes into a string for multiple features * * @param {Array} features: features of the map * * @returns {String} HTML content */ var formatFeatureListHTML = function (features) { var str = []; // Count unique record selected through all features var selected_ids = []; features.forEach(function (x) { var rec_id = x.get('attributes').id; if (selected_ids.indexOf(rec_id) < 0) { selected_ids.push(rec_id); } }); str.push(selected_ids.length + ' selected records'); return str.join('
'); }; var GeoengineRenderer = BasicRenderer.extend(geoengine_common.GeoengineMixin, { // eslint-disable-line max-len events: { 'click #map_infobox': '_onInfoBoxClicked', }, /** * @class * @param {Widget} parent * @param {Any} state * @param {Object} params * @param {Boolean} params.hasSelectors */ init: function (parent, state, params) { this._super.apply(this, arguments); this.qweb = new QWeb(session.debug, {_s: session.origin}, false); this.viewInfo = params.viewInfo; this.mapOptions = { 'geoengine_layers': params.viewInfo.geoengine_layers, }; this.bgLayers = new BackgroundLayers(); this.selection = []; this.overlaysGroup = null; this.vectorSources = []; this.zoomToExtentCtrl = null; this.popupElement = null; this.overlayPopup = null; this.featurePopup = null; this.ids = {}; this.geometryFields = []; _.each(this.mapOptions.geoengine_layers.actives, function (item) { this.geometryFields.push(item.geo_field_id[1]); }.bind(this)); }, /** * @override * Called each time the renderer is attached into the DOM. * * Redraw the map and its vector layers */ on_attach_callback: function () { var map = this._renderMap(); $('#olmap').data('map', map); this._renderVectorLayers(); return this._super(); }, // -------------------------------------------------------------------- // Public // -------------------------------------------------------------------- /** * @override */ updateState: function () { var def = this._super.apply(this, arguments); this._renderVectorLayers(); return def; }, willStart: function () { var arch = this.viewInfo.arch; _.each(arch.children, function (child) { if (child.tag === 'templates') { this.qweb.add_template(utils.json_node_to_xml(child)); } }.bind(this)); return this._super(); }, // -------------------------------------------------------------------- // Private // -------------------------------------------------------------------- /** * Render the map * * @private * @returns {jQueryElement} a jquery element */ _renderMap: function () { if (_.isUndefined(this.map)) { this.zoomToExtentCtrl = new ol.control.ZoomToExtent(); var backgrounds = this.mapOptions.geoengine_layers.backgrounds; this.map = new ol.Map({ layers: [new ol.layer.Group({ title: 'Base maps', layers: this.bgLayers.create(backgrounds), })], target: 'olmap', view: new ol.View({ center: [0, 0], zoom: 2, }), controls: ol.control.defaults().extend([ new ol.control.FullScreen(), new ol.control.ScaleLine(), new ol.control.LayerSwitcher(), this.zoomToExtentCtrl, ]), }); this._registerInteraction(); } return this.map; }, /** * Main render function for the map. It is rendered as a div. * * @override * @private * returns {Deferred} this deferred is resolved immediately */ _renderView: function () { var $map_div = $('
', {id: 'olmap', class: 'olmap'}); var $map_infobox = $('
', { id: 'map_infobox', class: 'map_overlay', }); var $map_info_open = $('
', {id: 'map_info_open'}); $map_info_open.append($('', {class: 'fa fa-edit'})); $map_infobox.append($map_info_open); var $map_info_filter_selection = $('
', { id: 'map_info_filter_selection', }); var $filter_selection = $('', {class: 'fa fa-filter'}); $filter_selection.append('Filter selection'); $map_info_filter_selection.append($filter_selection); $map_infobox.append($map_info_filter_selection); $map_infobox.append($('
', {id: 'map_info'})); $map_div.append($map_infobox); this.$el.addClass('o_geo_view').append($map_div); return this._super(); }, _hidePopup: function () { this.overlayPopup.setPosition(undefined); }, /** * Method: _createVectorLayer * * Parameters: * @param {Object} cfg - config object specific to the vector layer * @param {Array} data - array of features * * @returns {ol.layer.Vector}: One Vector layer containing the features */ _createVectorLayer: function (cfg, data) { if (!data.length) { return new ol.layer.Vector({ source: new ol.source.Vector(), title: cfg.name, }); } var vectorSource = new ol.source.Vector(); _.each(data, function (item) { var attributes = _.clone(item.data); _.each(this.geometryFields, function (geo_field) { delete attributes[geo_field]; }); if (cfg.display_polygon_labels === true) { attributes.label = item[cfg.attribute_field_id[1]]; } else { attributes.label = ''; } var json_geometry = item.data[cfg.geo_field_id[1]]; if (json_geometry) { var feature = new ol.Feature({ geometry: new ol.format.GeoJSON() .readGeometry(json_geometry), attributes: attributes, }); var id = String(attributes.id); this.ids[id] = item.id; vectorSource.addFeature(feature); } }.bind(this)); var styleInfo = this._styleVectorLayer(cfg, data); // Init legend var parentContainer = this.$el.find('#map_legend'); var elLegend = $(styleInfo.legend || '
'); elLegend.hide(); parentContainer.append(elLegend); var lv = new ol.layer.Vector({ source: vectorSource, title: cfg.name, active_on_startup: cfg.active_on_startup, style: styleInfo.style, }); lv.on('change:visible', function () { if (lv.getVisible()) { elLegend.show(); } else { elLegend.hide(); } }); this.vectorSources.push(vectorSource); if (cfg.layer_opacity) { lv.setOpacity(cfg.layer_opacity); } return lv; }, _extractLayerValues: function (cfg, data) { var values = []; var indicator = cfg.attribute_field_id[1]; _.each(data, function (item) { values.push(item.data[indicator]); }); return values; }, _styleVectorLayerColored: function (cfg, data) { var indicator = cfg.attribute_field_id[1]; var values = this._extractLayerValues(cfg, data); var nb_class = cfg.nb_class || DEFAULT_NUM_CLASSES; var opacity = 0.8; var begin_color_hex = cfg.begin_color || DEFAULT_BEGIN_COLOR; var end_color_hex = cfg.end_color || DEFAULT_END_COLOR; var begin_color = chroma(begin_color_hex).alpha(opacity).css(); var end_color = chroma(end_color_hex).alpha(opacity).css(); var scale = chroma.scale([begin_color, end_color]); var serie = new geostats(values); var vals = null; switch (cfg.classification) { case 'unique': case 'custom': vals = serie.getClassUniqueValues(); scale = chroma.scale('RdYlBu').domain( [0, vals.length], vals.length); break; case 'quantile': serie.getClassQuantile(nb_class); vals = serie.getRanges(); scale = scale.domain([0, vals.length], vals.length); break; case 'interval': serie.getClassEqInterval(nb_class); vals = serie.getRanges(); scale = scale.domain([0, vals.length], vals.length); break; } var colors = []; if (cfg.classification === 'custom') { _.each(vals, function (val) { if (val) { colors.push(chroma(val).alpha(opacity).css()); } }); } else { _.each(scale.colors(), function (color) { colors.push(chroma(color).alpha(opacity).css()); }); } var styles_map = {}; _.each(colors, function (color) { if (color in styles_map) { return; } var fill = new ol.style.Fill({ color: color, }); var stroke = new ol.style.Stroke({ color: '#333333', width: 2, }); var styles = [ new ol.style.Style({ image: new ol.style.Circle({ fill: fill, stroke: stroke, radius: 7, }), fill: fill, stroke: stroke, }), ]; styles_map[color] = styles; }); var legend = null; if (vals.length <= LEGEND_MAX_ITEMS) { serie.setColors(colors); legend = serie.getHtmlLegend(null, cfg.name, 1); } return { style: function (feature) { var value = feature.get('attributes')[indicator]; var color_idx = this._getClass(value, vals); return styles_map[colors[color_idx]]; }.bind(this), legend: legend, }; }, _styleVectorLayerProportion: function (cfg, data) { var indicator = cfg.attribute_field_id[1]; var values = this._extractLayerValues(cfg, data); var serie = new geostats(values); var styles_map = {}; var minSize = cfg.min_size || DEFAULT_MIN_SIZE; var maxSize = cfg.max_size || DEFAULT_MAX_SIZE; var minVal = serie.min(); var maxVal = serie.max(); // TODO to be defined on cfg var opacity = 0.8; var color_hex = cfg.begin_color || DEFAULT_BEGIN_COLOR; var color = chroma(color_hex).alpha(opacity).css(); var fill = new ol.style.Fill({ color: color, }); var stroke = new ol.style.Stroke({ color: '#333333', width: 2, }); _.each(values, function (value) { if (value in styles_map) { return; } var proportion = (value - minVal) / (maxVal - minVal); var proportion_sized = proportion * (maxSize - minSize); var radius = proportion_sized + minSize; var styles = [ new ol.style.Style({ image: new ol.style.Circle({ fill: fill, stroke: stroke, radius: radius, }), fill: fill, stroke: stroke, }), ]; styles_map[value] = styles; }); return { style: function (feature) { var value = feature.get('attributes')[indicator]; return styles_map[value]; }, legend : '', }; }, _styleVectorLayerDefault: function (cfg, data) { // TODO to be defined on cfg var opacity = 0.8; var color_hex = cfg.begin_color || DEFAULT_BEGIN_COLOR; var color = chroma(color_hex).alpha(opacity).css(); // Basic var fill = new ol.style.Fill({ color: color, }); var stroke = new ol.style.Stroke({ color: '#333333', width: 2, }); var olStyleText = new ol.style.Text({ text: '', fill: new ol.style.Fill({ color: '#000000', }), stroke: new ol.style.Stroke({ color: '#FFFFFF', width: 5, }), }); var styles = [ new ol.style.Style({ image: new ol.style.Circle({ fill: fill, stroke: stroke, radius: this._getBasicCircleRadius(cfg, data), }), fill: fill, stroke: stroke, text: olStyleText, }), ]; return { style: function (feature) { var label_text = feature.values_.attributes.label; if (label_text === false) { label_text = ''; } styles[0].text_.text_ = label_text; return styles; }, legend: '', }; }, _styleVectorLayer: function (cfg, data) { switch (cfg.geo_repr) { case 'colored': return this._styleVectorLayerColored(cfg, data); case 'proportion': return this._styleVectorLayerProportion(cfg, data); default: return this._styleVectorLayerDefault(cfg, data); } }, _getBasicCircleRadius: function () { return 5; }, _getClass: function (val, a) { // Classification uniqueValues var idx = a.indexOf(val); if (idx > -1) { return idx; } // Range classification var separator = ' - '; for (var i = 0; i < a.length; i++) { // All classification except uniqueValues if (a[i].indexOf(separator) !== -1) { var item = a[i].split(separator); if (val <= parseFloat(item[1])) { return i; } } else if (val === a[i]) { // Classification uniqueValues return i; } } }, /** * Creates vector layers from config and populates them with features * * Parameters: * @param {Array} data - vector data to be added to the vector layer * * @returns {Array} - array of vector layers */ _createVectorLayers: function (data) { var out = []; var layers = this.mapOptions.geoengine_layers.actives; _.each(layers, function (item) { out.push(this._createVectorLayer(item, data)); }.bind(this)); return out; }, _createPopupOverlay: function () { if (this.overlayPopup !== null) { return this.overlayPopup; } var $popup = this.$('.layer-popup'); var popupElement = $popup.get(0); var overlayPopup = new ol.Overlay({ element: popupElement, positioning: 'bottom-center', stopEvent: false, }); this.popupElement = popupElement; this.overlayPopup = overlayPopup; return overlayPopup; }, _getGeoengineRecord: function (record) { var record_options = { fields: this.viewInfo.fields, model: this.model, qweb: this.qweb, }; return new GeoengineRecord(this, record, record_options); }, _showFeaturePopup: function (feature) { var template_name = 'layer-box'; if (this.qweb.templates[template_name]) { var coord = ol.extent.getCenter( feature.getGeometry().getExtent()); this.overlayPopup.setPosition(coord); this.featurePopup = feature; var record = feature.get('attributes'); var geoengine_record = this._getGeoengineRecord(record); this.$('.popup-content').empty(); geoengine_record.appendTo(this.$('.popup-content')); } }, _renderVectorLayers: function () { var data = this.state.data; this.map.removeLayer(this.overlaysGroup); this.ids = {}; var vectorLayers = this._createVectorLayers(data); this.overlaysGroup = new ol.layer.Group({ title: 'Overlays', layers: vectorLayers, }); this._createPopupOverlay(); _.each(vectorLayers, function (vlayer) { // First vector always visible on startup if (vlayer !== vectorLayers[0] && !vlayer.values_.active_on_startup) { vlayer.setVisible(false); } }); this.map.addLayer(this.overlaysGroup); this.map.addOverlay(this.overlayPopup); // Zoom to data extent if (data.length) { var extent = vectorLayers[0].getSource().getExtent(); this.zoomToExtentCtrl.extent_ = extent; this.zoomToExtentCtrl.changed(); // When user quits fullscreen map, the size is set to undefined // So we have to check this and recompute the size. if (typeof this.map.getSize() === 'undefined' ) { this.map.updateSize(); } if (!ol.extent.isEmpty(extent)) { this.map.getView().fit(extent); } } }, _updateInfoBoxSingle: function (features) { var $map_info = $("#map_info"); var $map_infobox = $("#map_infobox"); var $map_info_open = $('#map_info_open'); var $map_info_filter_selection = $("#map_info_filter_selection"); var feature = features.item(0); var attributes = feature.get('attributes'); $map_info.html(formatFeatureHTML(attributes, this.viewInfo.fields)); var id = attributes.id; if (id in this.ids) { $map_infobox.data('id', this.ids[id]); } $map_info_open.show(); $map_info_filter_selection.hide(); $map_infobox.show(); }, _updateInfoBoxMulti: function (features) { var $map_info = $("#map_info"); var $map_infobox = $("#map_infobox"); var $map_info_open = $('#map_info_open'); var $map_info_filter_selection = $("#map_info_filter_selection"); // Several selected features $map_info.html(formatFeatureListHTML(features)); var selected_ids = []; features.forEach(function (feature) { var attributes = feature.get('attributes'); selected_ids.push(attributes.id); }); $map_infobox.data('ids', selected_ids); $map_info_open.hide(); $map_info_filter_selection.show(); $map_infobox.show(); }, _updateInfoBox: function (features) { var nbFeatures = features.getLength(); var $map_infobox = $("#map_infobox"); $map_infobox.data('id', null); $map_infobox.data('ids', []); if (!nbFeatures) { $map_infobox.hide(); this._hidePopup(); return; } if (nbFeatures === 1) { this._updateInfoBoxSingle(features); } else { this._updateInfoBoxMulti(features); } }, _getCustomSelectedStyleFunction: function () { var self = this; return function (feature) { var geometryType = feature.getGeometry().getType(); var styles = ol.style.Style.createDefaultEditing(); var geometryStyle = styles[geometryType]; if (geometryType !== ol.geom.GeometryType.POINT) { return geometryStyle; } var geometryStylePoint = geometryStyle[0]; return [new ol.style.Style({ fill: geometryStylePoint.getFill(), stroke: geometryStylePoint.getStroke(), image: new ol.style.Circle({ fill: geometryStylePoint.image_.getFill(), stroke: geometryStylePoint.image_.getStroke(), radius: self._getBasicCircleRadius(), }), })]; }; }, _registerInteraction: function () { // Select interaction working on "click" var selectClick = new ol.interaction.Select({ condition: ol.events.condition.click, style: this._getCustomSelectedStyleFunction(), }); selectClick.on('select', function (e) { var features = e.target.getFeatures(); this._updateInfoBox(features); if (features.getLength() > 0) { var feature = features.item(0); this._showFeaturePopup(feature); } }.bind(this)); // Select interaction working on "pointermove" var selectPointerMove = new ol.interaction.Select({ condition: ol.events.condition.pointerMove, style: this._getCustomSelectedStyleFunction(), }); // A DragBox interaction used to select features by drawing boxes var dragBox = new ol.interaction.DragBox({ condition: ol.events.condition.shiftKeyOnly, style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: [0, 0, 255, 1], }), }), }); var selectedFeatures = selectClick.getFeatures(); dragBox.on('boxend', function () { // Features that intersect the box are added to the collection // of selected features, and their names are displayed in the // "info" div var extent = dragBox.getGeometry().getExtent(); var layerVectors = this.map.getLayers().item(1).getLayers(); var vectorSource = null; layerVectors.forEach(function (lv) { // Enable selection only on visible layers if (lv.getVisible()) { vectorSource = lv.getSource(); vectorSource.forEachFeatureIntersectingExtent( extent, function (feature) { if (selectedFeatures.getArray() .indexOf(feature) < 0) { selectedFeatures.push(feature); } } ); } }); this._updateInfoBox(selectedFeatures); }.bind(this)); this.map.addInteraction(dragBox); this.map.addInteraction(selectClick); this.map.addInteraction(selectPointerMove); }, // -------------------------------------------------------------------- // Handlers // -------------------------------------------------------------------- _onInfoBoxClicked: function (event) { if (!$(event.target).prop('special_click')) { var id = $(event.currentTarget).data('id'); var ids = $(event.currentTarget).data('ids'); if (id) { this.trigger_up( 'open_record', {id: id, target: event.target} ); } else if (ids.length) { // TODO restore "Geo selection" item in search view field // using icon fa-map-o currently the selection cannot be // cancelled as it was in Odoo 10.0 this.trigger_up('search', { domains: [[['id', 'in', ids]]], contexts: [], groupbys: [], }); } } }, }); return GeoengineRenderer; });