/**
* @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
* @example <caption>Example of use</caption>
* <lizmap-features-table draggable="yes"sortingorder="asc" sortingfield="libsquart"
* withgeometry="1" expressionfilter="quartmno = 'HO'"
* uniquefield="id" layerid="subdistrict_24ceec66_e7fe_46a2_b57a_af5c50389649"
* layertitle="child sub-districts" id="0782b34c-840c-4b0f-821c-1b66c964e371">
* <lizmap-field data-alias="District's name" data-description="Label of district's name">
* "libsquart"
* </lizmap-field>
* </lizmap-features-table>
*/
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.toLowerCase())) ? 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.toLowerCase())) ? draggable : 'no';
// Features
this.features = [];
// Additional Fields JSON
this.additionalFields = {fields:[]};
// 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;
}
let uniqueAdditionalFields = [];
// Create a unique JSON object for PHP request
if (!this.isAdditionalFieldsEmpty()) {
uniqueAdditionalFields = {};
this.additionalFields.fields.forEach(field => {
uniqueAdditionalFields[field.alias] = field.expression;
});
}
// Get the features corresponding to the given parameters from attributes
mainLizmap.featuresTable.getFeatures(this.layerId, this.expressionFilter, this.withGeometry, fields, uniqueAdditionalFields)
.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('table.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('table.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'] + '.' : ''}`;
// Fix event.target depending on which HTML tag we click on
const eventTarget = event.currentTarget;
if (!itemWasActive) {
// Set the features table properties
const lineId = parseInt(eventTarget.dataset.lineId);
this.activeItemFeatureId = feature.properties.feature_id;
this.activeItemLineNumber = lineId;
// Get popup data and display it
mainLizmap.featuresTable.openPopup(
eventTarget.dataset.layerId,
feature,
this.uniqueField,
eventTarget.parentElement.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('table.lizmap-features-table-container tr.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 = `tr.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;
eventTarget.classList.remove('popup-displayed');
eventTarget.setAttribute('title', defaultItemTitle);
eventTarget.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('table.lizmap-features-table-container tr.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(`tr.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() {
if (this.querySelector("lizmap-field")) {
const listField = this.querySelectorAll("lizmap-field");
const verifiedFields = this.verifyFields(listField);
verifiedFields.forEach((field) => {
const fieldExpression = field.innerText;
const fieldDescription = field.dataset.description;
let fieldAlias = field.dataset.alias;
if (!fieldAlias) fieldAlias = fieldExpression.replaceAll('"', '');
// Prevent all fields goes on one tab instead of the other when multiple layers are clicked on
if (btoa(fieldAlias) in this.additionalFields.fields) {
field.remove();
return;
}
this.additionalFields.fields.push({
'alias': btoa(fieldAlias),
'expression': fieldExpression,
'description': fieldDescription
});
field.remove();
});
}
// 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(`tr.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(`tr.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(`tr.lizmap-features-table-item[data-feature-id="${this.activeItemFeatureId}"]`);
featureDiv.click();
}}></button>
</div>
<table class="table table-sm table-bordered table-condensed lizmap-features-table-container">
${this.buildLabels()}
<tbody>
${this.features.map((feature, idx) =>
html`
<tr
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);
}}
>
${this.buildColumns(feature.properties)}
</tr>
`
)}
</tbody>
</table>
<div class="lizmap-features-table-item-popup"></div>
</div>
`;
// Load
this.load();
}
/**
* Build the columns of the table
* @param properties - Object containing the properties of the feature
* @returns {TemplateResult<1>} The columns of the table
*/
buildColumns(properties) {
let result = html`
${this.buildDisplayExpressionColumn(properties)}
`;
if (!this.isAdditionalFieldsEmpty()) {
this.additionalFields.fields.forEach(field => {
let td = html`
<td
class="lizmap-features-table-item"
>
${properties[field.alias]}
</td>
`;
result = html`
${result}
${td}
`;
});
}
return result;
}
/**
* Initialize tab with the first column "display_expression"
* @param {object} properties - Object containing the properties of the feature
* @returns {TemplateResult<1>} The first column of the table
*/
buildDisplayExpressionColumn(properties) {
if (this.isGeneralLabelExisting()) {
return html`
<td class="lizmap-features-table-item">
${properties.display_expression}
</td>
`;
} else {
return html``;
}
}
/**
* Initialize the labels of the table
* @returns {TemplateResult<1>} The labels of the table
*/
buildLabels() {
if (this.isAdditionalFieldsEmpty()) {
return html``;
}
let result;
this.additionalFields.fields.forEach(field => {
let th = html`
<th
class="border lizmap-features-table-item"
title="${(field.description) ? field.description : ''}"
>
${atob(field.alias)}
</th>
`;
result = html`
${result}
${th}
`;
});
if (this.isGeneralLabelExisting()) {
// First th to create an empty column for "display_expression"
return html`
<thead>
<tr class="border-0">
<th class="border-0 lizmap-features-table-item-empty"></th>
${result}
</tr>
</thead>
`;
} else {
return html`
<thead>
<tr>
${result}
</tr>
</thead>
`;
}
}
/**
* Check if the additionalFields property is empty
* @returns {boolean} True if the additionalFields property is empty
*/
isAdditionalFieldsEmpty() {
return this.additionalFields.fields.length === 0;
}
/**
* Check if the general label "display_expression" is existing
* @returns {boolean} True if the general label "display_expression" is existing
*/
isGeneralLabelExisting() {
return this.features[0].properties.hasOwnProperty('display_expression');
}
/**
* Verify if there's no fields with the same alias or expression
* @param {Array.<object>} listField - List of fields
* @returns {Array.<object>} - List of verified fields
*/
verifyFields(listField) {
let verifiedFields = [listField[0]];
for (let i = 1; i < listField.length; i++) {
const fieldAlias = listField[i].dataset.alias;
const fieldExpression = listField[i].innerText;
let isValid = true;
verifiedFields.forEach(field => {
if (field.innerText === fieldExpression) {
listField[i].remove();
isValid = false;
} else if (field.dataset.alias === fieldAlias && field.dataset.alias !== "") {
// Remove the field if the alias is already used but not when they are both empty because fields will be automatically different
listField[i].remove();
isValid = false;
}
})
if (isValid) {
verifiedFields.push(listField[i]);
}
}
return verifiedFields;
}
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() {
}
}