ui_layers_filters.mjs

/**
## mapp.ui.layers.filters[type](layer, filter)

The ui/layers/filters module exports a lookup object for the different layer filter type methods.

- like
- match
- numeric
- integer
- in
- ni
- date
- datetime
- boolean
- null

Dictionary entries:
- identical_values_filter
- no_data_filter
- layer_filter_greater_than
- layer_filter_less_than
- filter_searchbox_placeholder
- no_results

@requires /dictionary

@module /ui/layers/filters
*/

const filterMethods = {
  applyFilter,
  boolean: filter_boolean,
  date: filter_date,
  datetime: filter_date,
  in: filter_in,
  integer: filter_numeric,
  like: filter_text,
  match: filter_text,
  ni: filter_in,
  null: filter_null,
  numeric: filter_numeric,
  removeFilter,
  resetFilter,
  generateMinMax,
};

export default filterMethods;

/**
@function applyFilter

@description
The applyFilter method is used to debounce the layer.reload() from filter. This is required in order to not fire layer [data] reload in quick succession by moving a slider element.

The 'changeEnd' event is dispatched the layer.mapview in order to trigger the update of dataviews with the mapChange flag.

@param {Object} layer The layer to reload.
*/
let timeout;
function applyFilter(layer) {
  clearTimeout(timeout);

  // Debounce layer reload by 500
  timeout = setTimeout(() => {
    layer.reload();

    layer.filter.list?.forEach((filter) => {
      filter.histogram?.update?.();
    });

    // The 'changeEnd' event triggers dataview updates with the mapChange flag.
    layer.mapview?.Map.getTargetElement().dispatchEvent(new Event('changeEnd'));
  }, 500);
}

/**
@function deleteFilter

@description
The deleteFilter function deletes the filter in the layer.filter.current object. And applies the changes to the layer.

@param {layer} layer The layer to reload.
@property {Object} layer.filter The filter thats active on the layer.
@param {Object} filter The filter definition.
@property {Object} filter.field The field the filter works on.
@property {String} filter.type The filters' type e.g like, in, etc.
*/
function deleteFilter(layer, filter) {
  if (layer.filter.current[filter.field]) {
    if (Object.hasOwn(layer.filter.current[filter.field], filter.type)) {
      // Delete type property from filter
      delete layer.filter.current[filter.field][filter.type];
    }

    if (Object.hasOwn(layer.filter.current, filter.field)) {
      // Delete [filter] field property.
      delete layer.filter.current[filter.field];
    }

    // Delete [filter] min if not fixed as Min.
    if (isNaN(filter.Min)) {
      delete filter.min;
    }

    // Delete [filter] max if not fixed as Max.
    if (isNaN(filter.Max)) {
      delete filter.max;
    }
  }

  mapp.ui.layers.filters.applyFilter(layer);
}

/**
@function removeFilter

@description
The removeFilter function removes the selected filter from the panel.
Calls {@link module:/ui/layers/filters~applyFilter} once the card has been removed to update the layer.

Hides the clearAll and resetAll button if they are no more cards in the panel.

@param {layer} layer The layer to reload.
@param {Object} filter The filter definition.
@property {HTMLElement} [filter.card] The html element of the filter.
@property {HTMLElement} [filter.li] The list element if applicable.
*/
function removeFilter(layer, filter) {
  deleteFilter(layer, filter);

  filter.li?.classList.remove('selected');

  filter.card?.remove();

  filter.histogram?.removeChangeEnd?.();
  // remove selected flag to allow for selection in dropdown element.
  delete filter.selected;

  delete filter.card;

  if (!layer.filter.list?.some((filter) => filter.card)) {
    layer.filter.clearAll instanceof HTMLElement &&
      layer.filter.clearAll.style.setProperty('display', 'none');
    layer.filter.resetAll instanceof HTMLElement &&
      layer.filter.resetAll.style.setProperty('display', 'none');
    layer.filter.feature_count instanceof HTMLElement &&
      layer.filter.feature_count.style.setProperty('display', 'none');
  }
}

/**
@function resetFilter

@description
The resetFilter method deletes the filter and replaces the filter.card with a new card with initial filter config.

@param {layer} layer The layer to reload.
@param {Object} filter The filter definition.
@property {HTMLElement} filter.card The html element of the filter.
*/
async function resetFilter(layer, filter) {
  deleteFilter(layer, filter);

  // Get interface content for filter card.
  if (!filter.card) return;

  filter.content = [
    await mapp.ui.layers.filters[filter.type](layer, filter),
  ].flat();

  const newCard = mapp.ui.elements.card(filter);

  filter.card.replaceWith(newCard);

  filter.card = newCard;
}

/**
@function filter_text
@description Creates an input element for filtering text values.
@param {Object} layer - The layer object to apply the filter to.
@param {Object} filter - The filter configuration object.
@returns {HTMLElement} The input element for text filtering.
*/
function filter_text(layer, filter) {
  return mapp.utils.html.node`
  <input
    type="text"
    onkeyup=${(e) => {
      if (!e.target.value.length) {
        // Delete filter for empty input.
        delete layer.filter.current[filter.field];
      } else {
        layer.filter.current[filter.field] ??= {};
        layer.filter.current[filter.field][filter.type] = encodeURIComponent(
          `${(filter.leading_wildcard && '%') || ''}${e.target.value}`,
        );
      }
      filterMethods.applyFilter(layer);
    }}>`;
}

/**
@function filter_boolean
@description Creates a checkbox element for filtering boolean values.
@param {Object} layer - The layer object to apply the filter to.
@param {Object} filter - The filter configuration object.
@returns {HTMLElement} The checkbox element for boolean filtering.
*/
function filter_boolean(layer, filter) {
  function booleanFilter(checked) {
    layer.filter.current[filter.field] = {
      boolean: checked,
    };
    filterMethods.applyFilter(layer);
  }

  booleanFilter(false);

  return mapp.ui.elements.chkbox({
    label: filter.label || filter.title || 'chkbox',
    onchange: booleanFilter,
  });
}

/**
@function filter_null
@description Creates a checkbox element for filtering null values.
@param {Object} layer - The layer object to apply the filter to.
@param {Object} filter - The filter configuration object.
@returns {HTMLElement} The checkbox element for null filtering.
*/
function filter_null(layer, filter) {
  function nullFilter(checked) {
    layer.filter.current[filter.field] = {
      null: checked,
    };
    filterMethods.applyFilter(layer);
  }

  nullFilter(false);

  return mapp.ui.elements.chkbox({
    label: filter.label || filter.title || 'chkbox',
    onchange: nullFilter,
  });
}

/**
@function generateMinMax
@description
Query the min and max values for a numeric filter field.

@param {layer} layer MAPP layer typedef object.
@param {Object} filter Filter object.
@property {string} filter.field Field to filter.
@property {numeric} [filter.min] Min bounds.
@property {numeric} [filter.max] Max bounds.
@property {string} [filter.minmax_query] Query template for min max values.
*/
async function generateMinMax(layer, filter) {
  const queryParams = mapp.utils.queryParams({
    layer: layer,
    queryparams: {
      table: true,
      template: 'field_minmax',
      field: filter.field,
    },
    viewport: layer.filter.viewport,
  });

  queryParams.filter = structuredClone(layer.filter.current);

  // The filter field itself must be removed from the queryParams filter as otherwise the result would never increase.
  delete queryParams.filter[filter.field];

  const paramString = mapp.utils.paramString(queryParams);

  const response = await mapp.utils.xhr(
    `${layer.mapview.host}/api/query?${paramString}`,
  );

  // Assign min, max from response if not a number.
  let min = isNaN(filter.min) ? response.minmax[0] : filter.min;
  let max = isNaN(filter.max) ? response.minmax[1] : filter.max;

  // overwrite min max from response if viewport is applied
  if (layer.filter?.viewport) {
    min = response.minmax[0];
    max = response.minmax[1];
  }

  // Parse min, max as interger or float depending on type.
  // Round integer to be correct up or down.
  filter.min = filter.type === 'integer' ? Math.round(min) : parseFloat(min);
  filter.max = filter.type === 'integer' ? Math.round(max) : parseFloat(max);

  filter.min = filter.val_a < filter.min ? filter.val_a : filter.min;
  filter.max = filter.val_b > filter.max ? filter.val_b : filter.max;
}

/**
@function filter_numeric

@description
Returns numeric inputs and range slider element as UI for numeric layer filter.

Numeric layer filter are a combination of an LTE (less-than-[or]equal) and GTE (greater-than-[or]equal) filter for a field defined in a matching entry field.

@param {layer} layer MAPP layer typedef object.
@param {Object} filter Filter object.
@property {string} filter.field Field to filter.
@property {numeric} [filter.min] Min bounds.
@property {numeric} [filter.max] Max bounds.
@property {numeric} [filter.step] Step for renage slider.

@returns {Promise<HTMLElement>}
The filter UI elements.
*/

async function filter_numeric(layer, filter) {
  // Find infoj entry and merge into the filter object.
  const entry = layer.infoj.find((entry) => entry.field === filter.field);

  Object.assign(filter, entry);

  delete filter.val_a;
  delete filter.val_b;

  // Fix min and max as Min and Max if [finite] number.
  filter.Min ??= Number.isFinite(filter.min) ? filter.min : undefined;
  filter.Max ??= Number.isFinite(filter.max) ? filter.max : undefined;

  if (isNaN(filter.max) || isNaN(filter.min)) {
    // Query min and max if not implicit.
    await generateMinMax(layer, filter);
  }

  filter.step ??= filter.type === 'integer' ? 1 : 0.01;

  // Assign the range min / max as layer filter if not already defined.
  layer.filter.current[filter.field] = Object.assign(
    {
      gte: Number(filter.min),
      lte: Number(filter.max),
    },
    layer.filter.current[filter.field],
  );

  filterMethods.applyFilter(layer);

  // Only if filter.min and filter.max are not numeric values, return a message.
  if (isNaN(filter.min) || isNaN(filter.max)) {
    // Return text to indicate that the min and max values are not defined.
    return mapp.utils.html.node`<div>${mapp.dictionary.no_data_filter}</div>`;
  }

  // Create affix for rangeslider input label.
  const affix =
    filter.prefix || filter.suffix
      ? `(${(filter.prefix || filter.suffix).trim()})`
      : '';

  filter.label_a ??= `${mapp.dictionary.layer_filter_greater_than} ${affix}`;

  filter.label_b ??= `${mapp.dictionary.layer_filter_less_than} ${affix}`;

  filter.val_a = layer.filter.current?.[filter.field]?.gte;

  filter.val_b = layer.filter.current?.[filter.field]?.lte;

  filter.callback_a = (val) => {
    layer.filter.current[filter.field].gte = val;
    filter.val_a = Number(val);
    filterMethods.applyFilter(layer);
  };

  filter.callback_b = (val) => {
    layer.filter.current[filter.field].lte = val;
    filter.val_b = Number(val);
    filterMethods.applyFilter(layer);
  };

  filter.slider = mapp.ui.elements.slider_ab(filter);

  await histogram(filter, layer);

  const node = mapp.utils.html`
    ${filter.histogram?.container}
    ${filter.slider}`;

  return node;
}

/**
@function histogram
@description
Creates a histogram element based on configuration in the numeric filter object.
@param {Object} filter Filter object.
@param {layer} layer MAPP layer on which the filter is applied.
@property {Object} [filter.histogram] Configuration object for a histogram dataview.
*/
async function histogram(filter, layer) {
  if (!filter.histogram) return;

  if (filter.histogram === true) {
    // If histogram is set to true, make histogram an object.
    filter.histogram = {};
  }

  filter.histogram.container ??= mapp.utils.html.node`<div>`;
  filter.histogram.dataview ??= 'histogram';
  filter.histogram.options ??= {
    tooltip: true,
  };
  filter.histogram.target ??= filter.histogram.container;
  filter.histogram.layer ??= layer;

  filter.histogram.queryparams ??= {
    field: filter.field,
    table: true,
    filter: true,
  };

  filter.histogram.viewport ??= layer.filter.viewport;

  if (!filter.histogram.update) {
    await mapp.ui.Dataview(filter.histogram);
  }

  if (filter.histogram.viewport) {
    layer.mapview.Map.getTargetElement().addEventListener(
      'changeEnd',
      changeEnd,
    );

    filter.histogram.removeChangeEnd = () => {
      layer.mapview.Map.getTargetElement().removeEventListener(
        'changeEnd',
        changeEnd,
      );
    };

    function changeEnd() {
      filter.histogram.update();
    }
  }
}

/**
@function filter_in

@description
Filter interface elements for `in` and `ni` type layer filter are returned from the filter_in() method.

The method is async to wait for a distinct values query to populate the filter type [eg. in or ni] values array.

A list of checkbox elements will be returned as default interface without a `dropdown`, `dropdown_pills`, or `searchbox` flag.

@param {Object} layer Decorated MAPP Layer Object.
@param {Object} filter The filter object.
@param {string} filter.field The filter field.
@property {Array} [filter.in] Array of values for in type filter.
@property {Array} [filter.ni] Array of values for ni type filter.
@property {Boolean} [filter.dropdown] Create dropdown [pills] filter interface.
@property {Boolean} [filter.dropdown_pills] Create dropdown [pills] filter interface.
@property {Boolean} [filter.searchbox] Create searchbox [pills] filter interface.
@returns {Promise<HTMLElement>} Filter interface elements.
*/
async function filter_in(layer, filter) {
  // Check whether a filter type values array has been provided.
  if (!Array.isArray(filter[filter.type])) {
    const filter_current = structuredClone(layer.filter?.current);

    // The distinct_values query should not be filtered on the field.
    delete filter_current?.[filter.field];

    // Query distinct field values from the layer table.
    const response = await mapp.utils.xhr(
      `${layer.mapview.host}/api/query?` +
        mapp.utils.paramString({
          field: filter.field,
          filter: filter_current,
          layer: layer.key,
          locale: layer.mapview.locale.key,
          table: layer.tableCurrent(),
          template: 'distinct_values',
        }),
    );

    if (!response) {
      console.warn(
        `Distinct values query did not return any values for field ${filter.field}`,
      );

      // Set a div with a message that the field contains no data.
      return mapp.utils.html.node`<div>${mapp.dictionary.no_data_filter}</div>`;
    }

    filter[filter.type] = [response]

      // Flatten response in array to account for the response being a single record and not an array.
      .flat()

      // Map the entry field from response records.
      .map((record) => record[filter.field])

      // Filter out null values.
      .filter((val) => val !== null);
  }

  // Create set to check for current values in the filter array.
  const chkSet = new Set(
    layer.filter?.current[filter.field]?.[filter.type] || [],
  );

  if (filter.dropdown || filter.dropdown_pills || filter.dropdown_search) {
    const dropdown = mapp.ui.elements.dropdown({
      callback: async (e, options) => {
        if (!options.length) {
          // Remove empty array filter.
          delete layer.filter.current[filter.field];
        } else {
          // Set filter values array from options.
          Object.assign(layer.filter.current, {
            [filter.field]: {
              [filter.type]: options,
            },
          });
        }

        filterMethods.applyFilter(layer);
      },
      field: filter.field,
      search: filter.dropdown_search,
      entries: filter[filter.type].map((val) => ({
        option: val,
        selected: chkSet.has(val),
        title: val,
      })),
      inputfilter: true,
      keepPlaceholder: filter.dropdown_pills,
      maxHeight: 300,
      multi: true,
      pills: filter.dropdown_pills,
      placeholder:
        filter.placeholder || mapp.dictionary.layer_filter_dropdown_select,
    });

    return mapp.utils.html.node`<div class="filter">${dropdown}`;
  }

  if (filter.searchbox) {
    const searchbox_filter = mapp.utils.html.node`<div class="filter">`;

    const pillComponent = mapp.ui.elements.pills({
      addCallback: (val, pills) => {
        Object.assign(layer.filter.current, {
          [filter.field]: {
            [filter.type]: [...pills],
          },
        });
        filterMethods.applyFilter(layer);
      },
      removeCallback: (val, pills) => {
        if (pills.size === 0) {
          searchbox.input.value = null;

          //if nothing left selected delete the entire filter
          delete layer.filter.current[filter.field];
        } else {
          Object.assign(layer.filter.current, {
            [filter.field]: {
              [filter.type]: [...pills],
            },
          });
        }

        filterMethods.applyFilter(layer);
      },
      target: searchbox_filter,
    });

    const searchbox = mapp.ui.elements.searchbox({
      placeholder: mapp.dictionary.filter_searchbox_placeholder,
      searchFunction: (e) => {
        searchbox.list.innerHTML = '';

        if (!e.target.value) return; // nothing to match

        const pattern = e.target.value;

        const filtered = filter[filter.type].filter((val) =>
          // val may not be string.
          val
            .toString()
            .toLowerCase()
            .startsWith(pattern.toLowerCase()),
        );

        if (!filtered.length) {
          searchbox.list.append(mapp.utils.html.node`
            <li><span>${mapp.dictionary.no_results}`);

          return;
        }

        filtered
          .filter((val) => !pillComponent.pills.has(val)) // only those not selected already
          .filter((val, i) => i < 9)
          .forEach((val) => {
            // Append li element to searchbox.list
            searchbox.list.append(mapp.utils.html.node`
              <li onclick=${() => {
                // Add pill not yet in pillComponent
                !pillComponent.pills.has(val) && pillComponent.add(val);
              }}>${val}`);
          });
      },
      target: searchbox_filter,
    });

    return mapp.utils.html.node`${searchbox_filter}`;
  }

  const chkBoxes = filter[filter.type].map((val) =>
    mapp.ui.elements.chkbox({
      checked: chkSet.has(val),
      label: val,
      onchange: (checked, val) => {
        if (checked) {
          // Create filter object if it doesn't exist.
          if (!layer.filter.current[filter.field]) {
            layer.filter.current[filter.field] = {};
          }

          // Create empty in array if it doesn't exist.
          if (!layer.filter.current[filter.field][filter.type]) {
            layer.filter.current[filter.field][filter.type] = [];
          }

          // Add value to filter array.
          layer.filter.current[filter.field][filter.type].push(val);
        } else {
          // Get index of value in filter array.
          const idx =
            layer.filter.current[filter.field][filter.type].indexOf(val);

          // Splice filter array on idx.
          layer.filter.current[filter.field][filter.type].splice(idx, 1);

          // Remove filter object if it is empty.
          if (!layer.filter.current[filter.field][filter.type].length) {
            delete layer.filter.current[filter.field];
          }
        }

        filterMethods.applyFilter(layer);
      },
      val: val,
    }),
  );

  return mapp.utils.html.node`<div class="filter">${chkBoxes}`;
}

/**
@function filter_date
@description Creates input elements for filtering date values.
@param {Object} layer - The layer object to apply the filter to.
@param {Object} filter - The filter configuration object.
@returns {HTMLElement} The input elements for date filtering.
*/
function filter_date(layer, filter) {
  const inputAfter = mapp.utils.html.node`
    <input
      data-id="inputAfter"
      onchange=${onClose}
      type=${(filter.type === 'datetime' && 'datetime-local') || 'date'}>`;

  const inputBefore = mapp.utils.html.node`
    <input
      data-id="inputBefore"
      onchange=${onClose}
      type=${(filter.type === 'datetime' && 'datetime-local') || 'date'}>`;

  function onClose(e) {
    if (e.target.dataset.id === 'inputAfter') {
      layer.filter.current[filter.field] = Object.assign(
        layer.filter.current[filter.field] || {},
        {
          gt: new Date(e.target.value).getTime() / 1000,
        },
      );
    }

    if (e.target.dataset.id === 'inputBefore') {
      layer.filter.current[filter.field] = Object.assign(
        layer.filter.current[filter.field] || {},
        {
          lt: new Date(e.target.value).getTime() / 1000,
        },
      );
    }

    filterMethods.applyFilter(layer);
  }

  return mapp.utils.html`
    <div style="
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
      grid-gap: 5px;">
      <label>Date after
        ${inputAfter}</label>
      <label>Date before
        ${inputBefore}</label>`;
}