Source: modules/Action.js

  1. /**
  2. * @module modules/Action.js
  3. * @name Action
  4. * @copyright 2023 3Liz
  5. * @author DHONT René-Luc
  6. * @license MPL-2.0
  7. */
  8. import { Vector as VectorSource } from 'ol/source.js';
  9. import { Vector as VectorLayer } from 'ol/layer.js';
  10. import GeoJSON from 'ol/format/GeoJSON.js';
  11. import Point from 'ol/geom/Point.js';
  12. import { fromExtent } from 'ol/geom/Polygon.js';
  13. import WKT from 'ol/format/WKT.js';
  14. /**
  15. * @class
  16. * @name Action
  17. */
  18. export default class Action {
  19. /**
  20. * @enum {string} Scopes - List of available scopes for the actions
  21. */
  22. Scopes = {
  23. Project: "project",
  24. Layer: "layer",
  25. Feature: "feature"
  26. }
  27. /**
  28. * @enum {string} Callbacks - List of available callbacks for the actions
  29. */
  30. CallbackMethods = {
  31. Redraw: "redraw",
  32. Select: "select",
  33. Zoom: "zoom"
  34. }
  35. /**
  36. * If the project has actions
  37. * @type {boolean}
  38. */
  39. hasActions = false;
  40. /**
  41. * Unique ID of an action object
  42. * We allow only one active action at a time
  43. * @type {string}
  44. */
  45. ACTIVE_LIZMAP_ACTION = null;
  46. /**
  47. * OpenLayers vector layer to draw the action results
  48. */
  49. actionLayer = null;
  50. /**
  51. * Build the lizmap Action instance
  52. * @param {Map} map - OpenLayers map
  53. * @param {SelectionTool} selectionTool - The lizmap selection tool
  54. * @param {Digitizing} digitizing - The Lizmap digitizing instance
  55. * @param {object} lizmap3 - The old lizmap object
  56. */
  57. constructor(map, selectionTool, digitizing, lizmap3) {
  58. this._map = map;
  59. this._selectionTool = selectionTool;
  60. this._digitizing = digitizing;
  61. this._lizmap3 = lizmap3;
  62. this.hasActions = true;
  63. if (typeof actionConfig === 'undefined') {
  64. this.hasActions = false;
  65. }
  66. if (this.hasActions) {
  67. // Add an OpenLayers layer to show & use the geometries returned by an action
  68. this.createActionMapLayer();
  69. // Get the list of used scopes
  70. let usedScopes = [];
  71. for (let i in actionConfig) {
  72. let item = actionConfig[i];
  73. if (!usedScopes.includes(item['scope'])) {
  74. usedScopes.push(item['scope']);
  75. }
  76. }
  77. // Hide the action dock if no action has the projet scope
  78. if (!usedScopes.includes(this.Scopes.Project)) {
  79. let actionMenu = document.querySelector('#mapmenu li.action');
  80. if (actionMenu) {
  81. actionMenu.style.display = "none";
  82. }
  83. }
  84. // Close the windows via the action-close button
  85. let closeDockButton = document.getElementById('action-close');
  86. if (closeDockButton) {
  87. closeDockButton.addEventListener('click', () => {
  88. let actionMenu = document.querySelector('#mapmenu li.action.active a');
  89. if (actionMenu) {
  90. actionMenu.click();
  91. }
  92. });
  93. }
  94. const self = this;
  95. // React on the main Lizmap events
  96. this._lizmap3.events.on({
  97. // The popup has been displayed
  98. // We need to add the buttons for the action with a 'feature' scope
  99. // corresponding to the popup feature layer
  100. lizmappopupdisplayed: function (popup) {
  101. // Add action buttons if needed
  102. let popupContainerId = popup.containerId;
  103. let popupContainer = document.getElementById(popupContainerId);
  104. if (!popupContainer) return false;
  105. Array.from(popupContainer.querySelectorAll('div.lizmapPopupContent .lizmapPopupSingleFeature')).map(element => {
  106. // Get layer ID and feature ID
  107. const featureId = element.dataset.featureId;
  108. const layerId = element.dataset.layerId;
  109. // Get layer lizmap config
  110. let getLayerConfig = lizmap3.getLayerConfigById(layerId);
  111. if (!getLayerConfig) {
  112. return true;
  113. }
  114. // Do nothing if popup feature layer is not found in action config
  115. // and a list of layers related to the action
  116. for (let i in actionConfig) {
  117. let action = actionConfig[i];
  118. // Only add action in Popup for the scope "feature"
  119. if (!('scope' in action) || action['scope'] != self.Scopes.Feature) {
  120. continue;
  121. }
  122. // Only add action if the layer is in the list
  123. if (action['layers'].includes(layerId)) {
  124. self.addPopupActionButton(action, layerId, featureId, popupContainerId);
  125. }
  126. }
  127. });
  128. }
  129. });
  130. }
  131. this._lizmap3.events.on({
  132. minidockclosed: (event) => {
  133. if (event.id === 'action'){
  134. this._digitizing.toolSelected = 'deactivate';
  135. }
  136. }
  137. });
  138. }
  139. /**
  140. * Create the OpenLayers layer to display the action geometries.
  141. *
  142. */
  143. createActionMapLayer() {
  144. // Create the OL layer
  145. const strokeColor = 'blue';
  146. const strokeWidth = 3;
  147. const fillColor = 'rgba(173,216,230,0.8)'; // lightblue
  148. this.actionLayer = new VectorLayer({
  149. source: new VectorSource({
  150. wrapX: false
  151. }),
  152. style: {
  153. 'circle-radius': 6,
  154. 'circle-stroke-color': strokeColor,
  155. 'circle-stroke-width': strokeWidth,
  156. 'circle-fill-color': fillColor,
  157. 'stroke-color': strokeColor,
  158. 'stroke-width': strokeWidth,
  159. 'fill-color': fillColor,
  160. }
  161. });
  162. this.actionLayer.setProperties({
  163. name: 'LizmapActionActionLayer'
  164. });
  165. // Add the layer inside Lizmap objects
  166. this._map.addToolLayer(this.actionLayer);
  167. }
  168. /**
  169. * Get an action item by its name and scope.
  170. *
  171. * If no layer id is given, return the first item
  172. * corresponding to the given name.
  173. * If the layer ID is given, only return the action
  174. * if it concerns the given layer ID.
  175. * @param {string} name - Name of the action
  176. * @param {Action.Scopes} scope - Scope of the action
  177. * @param {string} layerId - Layer ID (optional)
  178. * @returns {object} The corresponding action
  179. */
  180. getActionItemByName(name, scope = this.Scopes.Feature, layerId = null) {
  181. if (!this.hasActions) {
  182. return null;
  183. }
  184. // Loop through the actions
  185. for (let i in actionConfig) {
  186. // Current action
  187. let action = actionConfig[i];
  188. // Avoid the actions with a different scope
  189. if (action.scope != scope) {
  190. continue;
  191. }
  192. // Return the action if its name matches
  193. // and optionally also if the layerId matches
  194. if (action.name == name) {
  195. // Return if not layer ID is given
  196. if (layerId === null) {
  197. return action;
  198. }
  199. // Compare the layer ID
  200. if ('layers' in action && action.layers.includes(layerId)) {
  201. return action;
  202. }
  203. }
  204. }
  205. return null;
  206. }
  207. /**
  208. * Get the list of actions
  209. *
  210. * A scope and/or a layer ID can be given to filter the actions
  211. * @param {string} scope - Scope of the actions to filter
  212. * @param {string} layerId - Layer ID of the actions to filter
  213. * @returns {Array} actions - Array of the actions
  214. */
  215. getActions(scope = null, layerId = null) {
  216. let actions = [];
  217. if (!this.hasActions) {
  218. return actions;
  219. }
  220. // Loop through the actions
  221. for (let i in actionConfig) {
  222. let action = actionConfig[i];
  223. if (scope && action.scope != scope) continue;
  224. if (layerId && !('layers' in action)) continue;
  225. if (layerId && !action.layers.includes(layerId)) continue;
  226. actions.push(action);
  227. }
  228. return actions;
  229. }
  230. /**
  231. * Run the callbacks as defined in the action configuration
  232. * @param {object} action - The action
  233. * @param {Array} features - The OpenLayers features created by the action from the response
  234. */
  235. runCallbacks(action, features = null) {
  236. for (let c in action.callbacks) {
  237. // Get the callback item
  238. let callback = action.callbacks[c];
  239. if (callback['method'] == this.CallbackMethods.Zoom && features.length) {
  240. // Zoom to the returned features
  241. const bounds = this.actionLayer.getSource().getExtent();
  242. this._map.getView().fit(bounds, {nearest: true});
  243. }
  244. // Check the given layerId is a valid Lizmap layer
  245. // Only for the methods which gives a layerId in their configuration
  246. if (callback['method'] == this.CallbackMethods.Redraw || callback['method'] == this.CallbackMethods.Select) {
  247. let getLayerConfig = this._lizmap3.getLayerConfigById(callback['layerId']);
  248. if (!getLayerConfig) {
  249. continue;
  250. }
  251. let featureType = getLayerConfig[0];
  252. let layerConfig = getLayerConfig[1];
  253. // Get the corresponding OpenLayers layer instance
  254. const layer = this._map.getLayerByName(layerConfig.name);
  255. if(!layer){
  256. continue;
  257. }
  258. // Redraw the layer
  259. if (callback['method'] == this.CallbackMethods.Redraw) {
  260. // Redraw the given layer
  261. layer.getSource().changed();
  262. }
  263. // Select items in the layer which intersect the returned geometry
  264. if (callback['method'] == this.CallbackMethods.Select && features.length) {
  265. // Select features in the given layer
  266. let feat = features[0];
  267. let f = feat.clone();
  268. this._selectionTool.selectLayerFeaturesFromSelectionFeature(featureType, f);
  269. }
  270. }
  271. }
  272. }
  273. /**
  274. * Build the unique ID of an action
  275. * based on its scope
  276. * @param {string} actionName - The action name
  277. * @param {string} scope - The action scope
  278. * @param {string} layerId - The layer ID
  279. * @param {string} featureId - The feature ID
  280. * @returns {string} uniqueId - The action unique ID.
  281. */
  282. buildActionInstanceUniqueId(actionName, scope, layerId, featureId) {
  283. // The default name is the action name
  284. let actionUniqueId = actionName;
  285. // For the project scope, return
  286. if (scope == this.Scopes.Project) {
  287. return actionUniqueId;
  288. }
  289. // For the layer and feature scopes, we add the layer ID
  290. actionUniqueId += '.' + layerId;
  291. // For the feature scope, we add the feature ID
  292. if (scope == this.Scopes.Feature) {
  293. actionUniqueId += '.' + featureId;
  294. }
  295. return actionUniqueId;
  296. }
  297. /**
  298. * Explode the action unique ID into its components
  299. * action name, layer ID, feature ID
  300. * @param {string} uniqueId - The instance object unique ID
  301. * @returns {Array} components - The components [actionName, layerId, featureId]
  302. */
  303. explodeActionInstanceUniqueId(uniqueId) {
  304. let vals = uniqueId.split('.');
  305. let actionName = vals[0];
  306. let layerId = (vals.length > 1) ? vals[1] : null;
  307. let featureId = (vals.length > 2) ? vals[2] : null;
  308. return [actionName, layerId, featureId];
  309. }
  310. /**
  311. * Run a Lizmap action.
  312. * @param {string} actionName - The action name
  313. * @param {Action.Scopes} scope - The action scope
  314. * @param {string} layerId - The optional layer ID
  315. * @param {string} featureId - The optional feature ID
  316. * @param {string} wkt - An optional geometry in WKT format and project EPSG:4326
  317. * @returns {boolean} - If the action was successful
  318. */
  319. async runLizmapAction(actionName, scope = this.Scopes.Feature, layerId = null, featureId = null, wkt = null) {
  320. if (!this.hasActions) {
  321. return false;
  322. }
  323. // Get the action
  324. let action = this.getActionItemByName(actionName, scope, layerId);
  325. if (!action) {
  326. console.warn('No corresponding action found in the configuration !');
  327. return false;
  328. }
  329. const WKTformat = new WKT();
  330. const projOptions = {
  331. featureProjection: this._lizmap3.map.getProjection(),
  332. dataProjection: 'EPSG:4326'
  333. };
  334. // Reset the other actions
  335. // We allow only one active action at a time
  336. // We do not remove the active status of the button (btn-primary)
  337. this.resetLizmapAction(true, true, true, false);
  338. // Take drawn geometry if any and if none exists as a parameter
  339. if (!wkt && this._digitizing.context === "action" && this._digitizing.featureDrawn) {
  340. wkt = WKTformat.writeFeatures(this._digitizing.featureDrawn, projOptions);
  341. }
  342. // Set the request parameters
  343. let options = {
  344. "layerId": layerId,
  345. "featureId": featureId,
  346. "name": actionName,
  347. "wkt": wkt
  348. };
  349. const viewExtent = this._map.getView().calculateExtent();
  350. const viewCenter = this._map.getView().getCenter();
  351. // We add the map extent and center
  352. // as WKT geometries
  353. options['mapExtent'] = WKTformat.writeGeometry(fromExtent(viewExtent), projOptions);
  354. options['mapCenter'] = WKTformat.writeGeometry(new Point(viewCenter), projOptions);
  355. // Request action and get data
  356. let url = actionConfigData.url;
  357. try {
  358. let response = await fetch(url, {
  359. method: 'POST',
  360. headers: {
  361. 'Content-Type': 'application/json;charset=utf-8'
  362. },
  363. body: JSON.stringify(options)
  364. });
  365. // Parse the data
  366. let data = await response.json();
  367. // Report errors
  368. if ('errors' in data) {
  369. // Reset the action
  370. this.resetLizmapAction(true, true, true, true);
  371. // Display the errors
  372. this._lizmap3.addMessage(data.errors.title + '\n' + data.errors.detail, 'danger', true).attr('id', 'lizmap-action-message');
  373. console.warn(data.errors);
  374. return false;
  375. }
  376. // Add the features in the OpenLayers map layer
  377. const features = this.addFeaturesFromActionResponse(data, action.style);
  378. // Display a message if given in the first feature
  379. if (features.length > 0) {
  380. const feat = features[0];
  381. const featureProperties = feat.getProperties();
  382. const message_field = 'message';
  383. if (featureProperties && featureProperties?.[message_field]) {
  384. // Clear the previous message
  385. const previousMessage = document.getElementById('lizmap-action-message');
  386. if (previousMessage) previousMessage.remove();
  387. // Display the message if given
  388. const message = featureProperties[message_field].trim();
  389. if (message) {
  390. this._lizmap3.addMessage(message, 'info', true).attr('id', 'lizmap-action-message');
  391. }
  392. // Display the HTML message if given
  393. const message_html = featureProperties?.message_html?.trim();
  394. if (message_html) {
  395. document.getElementById('action-message-html').innerHTML = message_html;
  396. }
  397. }
  398. }
  399. // Run the configured action callbacks
  400. // Callbacks
  401. if (features.length > 0 && 'callbacks' in action && action.callbacks.length > 0) {
  402. this.runCallbacks(action, features);
  403. }
  404. /**
  405. * Lizmap event to allow other scripts to process the data if needed
  406. * @event actionResultReceived
  407. * @property {string} action Name of the action
  408. * @property {string} layerId Layer ID of the current layer
  409. * @property {string} featureId Feature ID of the current feature
  410. * @property {Array<*>} features List of features returned in the map projection
  411. */
  412. lizMap.events.triggerEvent("actionResultReceived",
  413. {
  414. 'action': action,
  415. 'layerId': layerId,
  416. 'featureId': featureId,
  417. 'features': features // in map projection
  418. }
  419. );
  420. // Set the action as active
  421. this.ACTIVE_LIZMAP_ACTION = this.buildActionInstanceUniqueId(action.name, scope, layerId, featureId);
  422. } catch (error) {
  423. // Display the error
  424. console.warn(error);
  425. // Reset the action
  426. this.resetLizmapAction(true, true, true, true);
  427. }
  428. }
  429. /**
  430. * Reset action
  431. * @param {boolean} destroyFeatures - If we must remove the geometries in the map.
  432. * @param {boolean} removeMessage - If we must remove the message displayed at the top.
  433. * @param {boolean} resetGlobalVariable - If we must empty the global variable ACTIVE_LIZMAP_ACTION
  434. * @param {boolean} resetActiveInterfaceElements - If we must remove the "active" interface for the buttons
  435. */
  436. resetLizmapAction(destroyFeatures = true, removeMessage = true, resetGlobalVariable = true, resetActiveInterfaceElements = true) {
  437. // Remove the objects in the map
  438. if (destroyFeatures) {
  439. this.actionLayer.getSource().clear();
  440. }
  441. // Clear the previous Lizmap message
  442. if (removeMessage) {
  443. let previousMessage = document.getElementById('lizmap-action-message');
  444. if (previousMessage) previousMessage.remove();
  445. }
  446. // Remove all btn-primary classes in the target objects
  447. if (resetActiveInterfaceElements) {
  448. let selector = '.popup-action.btn-primary';
  449. Array.from(document.querySelectorAll(selector)).map(element => {
  450. element.classList.remove('btn-primary');
  451. });
  452. }
  453. // Reset the global variable
  454. if (resetGlobalVariable) {
  455. this.ACTIVE_LIZMAP_ACTION = null;
  456. }
  457. }
  458. /**
  459. * Add the features returned by a action
  460. * to the OpenLayers layer in the map
  461. * @param {object} data - The data returned by the action
  462. * @param {object|undefined} style - Optional OpenLayers style object
  463. * @returns {object} features The OpenLayers features converted from the data
  464. */
  465. addFeaturesFromActionResponse(data, style) {
  466. // Change the layer style
  467. if (style) {
  468. this.actionLayer.setStyle(style);
  469. }
  470. // Convert the action GeoJSON data into OpenLayers features
  471. const features = (new GeoJSON()).readFeatures(data, {
  472. featureProjection: this._lizmap3.map.getProjection()
  473. });
  474. // Add them to the action layer
  475. this.actionLayer.getSource().addFeatures(features);
  476. return features;
  477. }
  478. /**
  479. * Reacts to the click on a popup action button.
  480. * @param {Event} event - The click event
  481. * @returns {boolean} - If the action was successful
  482. */
  483. popupActionButtonClickHandler(event) {
  484. // Only go on when the button has been clicked
  485. // not the child <i> icon
  486. let target = event.target;
  487. if (!event.target.matches('.popup-action')) {
  488. target = target.parentNode;
  489. }
  490. // Get the button which triggered the click event
  491. let button = target;
  492. // Get the layerId, featureId and action for this button
  493. let val = button.value;
  494. let [actionName, layerId, featureId] = this.explodeActionInstanceUniqueId(val);
  495. // Get the action item data
  496. let popupAction = this.getActionItemByName(actionName, this.Scopes.Feature, layerId);
  497. if (!popupAction) {
  498. console.warn('No corresponding action found in the configuration !');
  499. return false;
  500. }
  501. // We allow only one active action at a time.
  502. // If the action is already active for the clicked button
  503. // we need to deactivate it completely
  504. if (this.ACTIVE_LIZMAP_ACTION) {
  505. let actionUniqueId = this.buildActionInstanceUniqueId(actionName, this.Scopes.Feature, layerId, featureId);
  506. if (this.ACTIVE_LIZMAP_ACTION == actionUniqueId) {
  507. // Reset the action
  508. this.resetLizmapAction(true, true, true, true);
  509. // Return
  510. return true;
  511. }
  512. }
  513. // The action was not active, we can run it
  514. // This will override the previous actions and replace them
  515. // with this one
  516. this.ACTIVE_LIZMAP_ACTION = null;
  517. // Display a confirm question if needed
  518. if ('confirm' in popupAction && popupAction.confirm.trim() != '') {
  519. let msg = popupAction.confirm.trim();
  520. let go_on = confirm(msg);
  521. if (!go_on) {
  522. return false;
  523. }
  524. }
  525. // Reset
  526. this.resetLizmapAction(true, true, true, true);
  527. // Add the button btn-primary class
  528. button.classList.add('btn-primary');
  529. // Run the Lizmap action for this feature
  530. // It will set the global variable ACTIVE_LIZMAP_ACTION
  531. this.runLizmapAction(actionName, this.Scopes.Feature, layerId, featureId);
  532. return false;
  533. }
  534. /**
  535. * Add an action button for the given popup feature
  536. * and the given action item.
  537. * @param {object} action - The action configuration object
  538. * @param {string} layerId - The layer ID
  539. * @param {string} featureId - The feature ID
  540. * @param {string} popupContainerId - The popup container ID
  541. * @returns {boolean|void} - If the action failed
  542. */
  543. addPopupActionButton(action, layerId, featureId, popupContainerId) {
  544. // Value of the action button for this layer and this feature
  545. let actionUniqueId = this.buildActionInstanceUniqueId(action.name, this.Scopes.Feature, layerId, featureId);
  546. // Build the HTML button
  547. let actionButtonHtml = `
  548. <button class="btn btn-sm popup-action" value="${actionUniqueId}" type="button" data-bs-toggle="tooltip" data-bs-title="${action.title}">
  549. `;
  550. // The icon can be
  551. // * an old bootstrap 2 icon, e.g. 'icon-star'
  552. // * a SVG in the media file, e.g. 'media/icon/my-icon.svg'
  553. if (action.icon.startsWith('icon-')) {
  554. actionButtonHtml += `<i class="${action.icon}"></i>`;
  555. }
  556. let regex = new RegExp('^(.{1,2})?(/)?media/');
  557. if (action.icon.match(regex)) {
  558. let mediaLink = globalThis['lizUrls'].media + '?' + new URLSearchParams(globalThis['lizUrls'].params);
  559. let imageUrl = `${mediaLink}&path=${action.icon}`;
  560. actionButtonHtml += `<img style="width: 20px; height: 20px;" src="${imageUrl}">`;
  561. }
  562. actionButtonHtml += '&nbsp;</button>';
  563. // Find Lizmap popup toolbar
  564. let popupContainer = document.getElementById(popupContainerId);
  565. let featureToolbar = popupContainer.querySelector(`lizmap-feature-toolbar[value="${layerId}.${featureId}"]`);
  566. if (!featureToolbar) {
  567. return false;
  568. }
  569. let featureToolbarDiv = featureToolbar.querySelector('div.feature-toolbar');
  570. // Get the button if it already exists
  571. let existingButton = featureToolbarDiv.querySelector(`button.popup-action[value="${actionUniqueId}"]`);
  572. if (existingButton) {
  573. return false;
  574. }
  575. // Append the button to the toolbar
  576. featureToolbarDiv.insertAdjacentHTML('beforeend', actionButtonHtml);
  577. let actionButton = featureToolbarDiv.querySelector(`button.popup-action[value="${actionUniqueId}"]`);
  578. // If the action is already active for this feature,
  579. // add the btn-primary class
  580. if (actionButton.value == this.ACTIVE_LIZMAP_ACTION) {
  581. actionButton.classList.add('btn-primary');
  582. }
  583. // Trigger the action when clicking on button
  584. actionButton.addEventListener('click', this.popupActionButtonClickHandler.bind(this));
  585. }
  586. };