/**
* @module components/FeaturesTable.js
* @name FeaturesTable
* @copyright 2024 3Liz
* @author DOUCHIN Michaël
* @license MPL-2.0
*/
import { html, render } from 'lit-html';
import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js';
/**
* @class
* @name FeaturesTable
* @summary Allows to display a compact list of vector layer features labels
* @augments HTMLElement
* @element lizmap-features-table
* @fires features.table.item.dragged
* @fires features.table.rendered
*/
export default class FeaturesTable extends HTMLElement {
constructor() {
super();
// Random element id
this.id = window.crypto.randomUUID();
// Layer name
this.layerTitle = this.getAttribute('layerTitle') || 'Features table: error';
// Layer id
this.layerId = this.getAttribute('layerId');
// Error text
this.error = null;
// Get the layer name & configuration
this.layerConfig = null;
if (mainLizmap.initialConfig.layers.layerIds.includes(this.layerId)) {
this.layerConfig = mainLizmap.initialConfig.layers.getLayerConfigByLayerId(this.layerId);
}
// Primary key field
this.uniqueField = this.getAttribute('uniqueField');
// Expression filter
this.expressionFilter = this.getAttribute('expressionFilter');
// Get the geometry or NetworkError
this.withGeometry = this.hasAttribute('withGeometry');
// Sorting attribute and direction
this.sortingField = this.getAttribute('sortingField');
const sortingOrder = this.getAttribute('sortingOrder');
this.sortingOrder = (sortingOrder !== null && ['asc', 'desc'].includes(sortingOrder)) ? sortingOrder : 'asc';
// open popup ?
this.openPopup = (this.layerConfig && this.layerConfig.popup);
// Add drag&drop capability ?
const draggable = this.getAttribute('draggable');
this.itemsDraggable = (draggable !== null && ['yes', 'no'].includes(draggable)) ? draggable : 'no';
// Features
this.features = [];
// Clicked item feature ID
this.activeItemFeatureId = null;
// Clicked item line number
this.activeItemLineNumber = null;
}
/**
* Load features from the layer and configured filter
*/
async load() {
// Build needed fields
let fields = `${this.uniqueField}`;
if (this.sortingField) {
fields += ',' + this.sortingField;
}
// Get the features corresponding to the given parameters from attributes
mainLizmap.featuresTable.getFeatures(this.layerId, this.expressionFilter, this.withGeometry, fields)
.then(displayExpressions => {
// Check for errors
if (!('status' in displayExpressions)) return;
if (displayExpressions.status != 'success') {
console.error(displayExpressions.error);
} else {
// Set component data property
this.features = displayExpressions.data;
// Sort data if needed
if (this.features.length > 1) {
this.sortFeatures();
}
}
// Render
this.render();
// If an error occurred, replace empty content with error
if (displayExpressions.status != 'success') {
this.querySelector('div.lizmap-features-table-container').innerHTML = `<p style="padding: 3px;">
${displayExpressions.error}
</p>`;
}
})
.catch(err => {
// Display an error message
console.warn(err.message);
this.innerHTML = `<p style="padding: 3px;">${err.message}</p>`;
})
}
/**
* Sort the features array property
* depending on the options
*/
sortFeatures() {
// Order data by given fields or fallback to descending order by id
let sortingField = 'display_expression';
let sortingOrder = 'asc';
let sortingType = 'string';
// Get first line to check if needed fields are present
const first = this.features[0];
if (first && this.sortingField && this.sortingField in first.properties) {
sortingField = this.sortingField;
sortingOrder = this.sortingOrder;
if (!isNaN(first.properties[sortingField])) {
sortingType = 'number';
}
}
// Reorder the array of data
if (sortingType == 'number') {
if (sortingOrder == 'asc') {
this.features.sort((a, b) => (a.properties[sortingField] || 0) - (b.properties[sortingField] || 0));
} else {
this.features.sort((a, b) => (b.properties[sortingField] || 0) - (a.properties[sortingField] || 0));
}
} else {
if (sortingOrder == 'asc') {
this.features.sort(
(a, b) => (a.properties[sortingField] || '').localeCompare(
b.properties[sortingField] || '',
navigator.language,
{sensitivity: 'accent'}
)
);
} else {
this.features.sort(
(a, b) => (b.properties[sortingField] || '').localeCompare(
a.properties[sortingField] || '',
navigator.language,
{sensitivity: 'accent'}
)
);
}
}
}
/**
* Render component from the template using Lit
*/
render() {
// Render with lit-html
render(this._template(), this);
// If there is not features, add empty content in the container
if (this.features.length === 0) {
this.querySelector('div.lizmap-features-table-container').innerHTML = ' ';
}
// Add drag & drop capabilities if option is set
if (this.itemsDraggable == 'yes') {
this.addDragAndDropCapabilities();
}
/**
* When the table has been successfully displayed. The event carries the lizmap-features-table HTML element ID
* @event features.table.rendered
* @property {string} elementId HTML element ID
*/
mainEventDispatcher.dispatch({
type: 'features.table.rendered',
elementId: this.id
});
}
/**
* Display a popup when a feature item is clicked
*
* @param {Event} event Click event on a feature item
* @param {Object} feature WFS feature
*/
onItemClick(event, feature) {
if (!this.openPopup) {return true;}
// Check if the item was active
const itemWasActive = (this.activeItemFeatureId == feature.properties.feature_id);
// Titles based on active status
const activeItemTitle = `${this.openPopup ? lizDict['featuresTable.item.active.hover']: ''}`;
const defaultItemTitle = `${this.openPopup ? lizDict['featuresTable.item.hover'] + '.': ''} ${this.itemsDraggable == 'yes' ? lizDict['featuresTable.item.draggable.hover'] + '.' : ''}`;
if (!itemWasActive) {
// Set the features table properties
const lineId = parseInt(event.target.dataset.lineId);
this.activeItemFeatureId = feature.properties.feature_id;
this.activeItemLineNumber = lineId;
// Get popup data and display it
mainLizmap.featuresTable.openPopup(
event.target.dataset.layerId,
feature,
this.uniqueField,
event.target.parentElement.parentElement.querySelector('div.lizmap-features-table-item-popup'),
function(aLayerId, aFeature, aTarget) {
// Add bootstrap classes to the popup tables
const popupTable = aTarget.querySelector('table.lizmapPopupTable');
if (popupTable) {
popupTable.classList.add('table', 'table-condensed', 'table-sm', 'table-bordered', 'table-striped');
}
// Show popup and hide other children
const featuresTableDiv = aTarget.parentElement;
if (featuresTableDiv) {
// Add class to the parent
featuresTableDiv.classList.add('popup-displayed');
// Remove popup-displayed for all other items
// And restore previous title
var items = featuresTableDiv.querySelectorAll('div.lizmap-features-table-container div.lizmap-features-table-item.popup-displayed');
Array.from(items).forEach(item => {
item.classList.remove('popup-displayed');
item.setAttribute('title', defaultItemTitle);
});
// Add class to the active item
const childSelector = `div.lizmap-features-table-item[data-feature-id="${feature.properties.feature_id}"]`;
const activeItem = featuresTableDiv.querySelector(childSelector);
if (activeItem) activeItem.classList.add('popup-displayed');
// Change title
activeItem.setAttribute('title', activeItemTitle);
// Toggle previous/next buttons depending on active line id
const previousButton = featuresTableDiv.querySelector('div.lizmap-features-table-toolbar button.previous-popup');
const nextButton = featuresTableDiv.querySelector('div.lizmap-features-table-toolbar button.next-popup');
previousButton.style.display = (activeItem.dataset.lineId == 1) ? 'none' : 'initial';
nextButton.style.display = (activeItem.dataset.lineId == featuresTableDiv.dataset.featuresCount) ? 'none' : 'initial';
}
}
);
} else {
// Set the features table properties
this.activeItemFeatureId = null;
this.activeItemLineNumber = null;
event.target.classList.remove('popup-displayed');
event.target.setAttribute('title', defaultItemTitle);
event.target.closest('div.lizmap-features-table').classList.remove('popup-displayed');
}
}
/**
* Add drag&drop capabilities to the lizmap-features-table element
*
* A request is sent when the order changes
*/
addDragAndDropCapabilities() {
// Add drag and drop events to table items
const items = this.querySelectorAll('div.lizmap-features-table-container div.lizmap-features-table-item');
if (!items) return;
Array.from(items).forEach(item => {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', onDragStart)
item.addEventListener('drop', OnDropped)
item.addEventListener('dragenter', onDragEnter)
item.addEventListener('dragover', onDragOver)
item.addEventListener('dragleave', onDragLeave)
item.addEventListener('dragend', onDragEnd)
});
// Utility functions for drag & drop capability
function onDragStart (e) {
const index = [].indexOf.call(e.target.parentElement.children, e.target);
e.dataTransfer.setData('text/plain', index)
}
function onDragEnter (e) {
cancelDefault(e);
}
function onDragOver (e) {
// Change the target element's style to signify a drag over event
// has occurred
e.currentTarget.style.background = "lightblue";
cancelDefault(e);
}
function onDragLeave (e) {
// Change the target element's style back to default
e.currentTarget.style.background = "";
cancelDefault(e);
}
function waitForIt(delay) {
return new Promise((resolve) => setTimeout(resolve, delay))
}
function OnDropped (e) {
cancelDefault(e)
// Change the target element's style back to default
// Celui sur lequel on a lâché l'item déplacé
e.currentTarget.style.background = "";
// Get item
const item = e.currentTarget;
// Get dragged item old and new index
const oldIndex = e.dataTransfer.getData('text/plain');
// Get the dropped item
const dropped = item.parentElement.children[oldIndex];
// Emphasize the element
// So that the user sees it well after drop
dropped.style.border = "2px solid var(--color-contrasted-elements)";
// Move the dropped items at new place
item.before(dropped);
// Set the new line number to the items
let i = 1;
for (const child of item.parentElement.children) {
if (!child.classList.contains('lizmap-features-table-item')) {
continue;
}
const lineId = i;
child.dataset.lineId = lineId;
i++;
}
// Send event
const movedFeatureId = dropped.dataset.featureId;
const newItem = item.parentElement.querySelector(`div.lizmap-features-table-item[data-feature-id="${movedFeatureId}"]`);
/**
* When the user has dropped an item in a new position
* @event features.table.item.dragged
* @property {string} itemFeatureId The vector feature ID
* @property {string} itemOldLineId The original line ID before dropping the item
* @property {string} itemNewLineId The new line ID after dropping the item in a new position
*/
mainEventDispatcher.dispatch({
type: 'features.table.item.dragged',
itemFeatureId: movedFeatureId,
itemOldLineId: dropped.dataset.lineId,
itemNewLineId: newItem.dataset.lineId
});
}
async function onDragEnd (e) {
// Restore style after some time
await waitForIt(3000);
e.target.style.border = "";
// e.target.style.backgroundColor = "";
cancelDefault(e);
}
function cancelDefault (e) {
e.preventDefault();
e.stopPropagation();
return false;
}
}
connectedCallback() {
// Template
this._template = () => html`
<div class="lizmap-features-table" data-features-count="${this.features.length}"
title="${lizDict['bob']}">
<h4>${this.layerTitle}</h4>
<div class="lizmap-features-table-toolbar">
<button class="btn btn-mini previous-popup"
title="${lizDict['featuresTable.toolbar.previous']}"
@click=${event => {
// Click on the previous item
const lineNumber = this.activeItemLineNumber - 1;
const featureDiv = this.querySelector(`div.lizmap-features-table-item[data-line-id="${lineNumber}"]`);
if (featureDiv) featureDiv.click();
}}></button>
<button class="btn btn-mini next-popup"
title="${lizDict['featuresTable.toolbar.next']}"
@click=${event => {
// Click on the next item
const lineNumber = this.activeItemLineNumber + 1;
const featureDiv = this.querySelector(`div.lizmap-features-table-item[data-line-id="${lineNumber}"]`);
if (featureDiv) featureDiv.click();
}}></button>
<button class="btn btn-mini close-popup"
title="${lizDict['featuresTable.toolbar.close']}"
@click=${event => {
// Click on the active line to deactivate it
if (this.activeItemFeatureId === null) return;
const featureDiv = this.querySelector(`div.lizmap-features-table-item[data-feature-id="${this.activeItemFeatureId}"]`);
featureDiv.click();
}}></button>
</div>
<div class="lizmap-features-table-container">
${this.features.map((feature, idx) =>
html`
<div
class="lizmap-features-table-item ${this.openPopup ? 'has-action' : ''}"
data-layer-id="${this.layerId}"
data-feature-id="${feature.properties.feature_id}"
data-line-id="${idx+1}"
title="${this.openPopup ? lizDict['featuresTable.item.hover'] + '.': ''} ${this.itemsDraggable == 'yes' ? lizDict['featuresTable.item.draggable.hover'] + '.' : ''}"
@click=${event => {
this.onItemClick(event, feature);
}}
>${feature.properties.display_expression}
</div>
`
)}
</div>
<div class="lizmap-features-table-item-popup"></div>
</div>
`;
// Load
this.load();
}
static get observedAttributes() { return ['updated']; }
attributeChangedCallback(name, oldValue, newValue) {
// Listen to the change of the updated attribute
// This will trigger the load (refresh the content)
if (name === 'updated') {
console.log('Reload features table');
this.load();
}
}
disconnectedCallback() {
}
}