ui_elements_layerStyle.mjs

/**
### /ui/elements/layerStyle

The module exports an object of methods to for the creation of layer style elements.

@requires /ui/layers

@module /ui/elements/layerStyle
*/

mapp.utils.merge(mapp.dictionaries, {
  en: {
    icon_scaling_field: 'Toggle Field Icon Scaling',
    icon_scaling_label: 'Icon Scale',
    icon_scaling_cluster: 'Cluster Icon Scale',
    icon_scaling_cluster_log: 'Cluster Log Scale',
    icon_scaling_zoom_in: 'Zoom-in Scale',
    icon_scaling_zoom_out: 'Zoom-out Scale'
  }
});

const methods = {
  panel,
  hover,
  hovers,
  label,
  labels,
  opacitySlider,
  theme,
  themes,
  icon_scaling
}

export default methods

/**
@function panel

@description
The panel() style element method creates an array of style.elements from all available style property keys if not implicit.

The elements array will be mapped to return a flat array of HTMLElements as content for the layer.style.view node.

The layer.style.view is returned for the style drawer shown in the default mapp view layer.view.

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Array} [style.elements] Array of method keys to order elements in the layer.style.view.

@returns {HTMLElement} The layer.style.view element.
*/
function panel(layer) {

  if (!layer.style) return;

  layer.style.elements ??= Object.keys(layer.style)

  // The layer.style.elements define which elements should be added in which order to the layer.style.view element.
  const content = layer.style.elements
    .filter(key => Object.hasOwn(layer.style, key))
    .filter(key => Object.hasOwn(methods, key))
    .map(key => methods[key](layer))
    .flat()
    .filter(element => !!element)

  if (!content.length) return;

  layer.style.view = mapp.utils.html.node`<div>${content}`

  return layer.style.view
}

/**
@function hover

@description
The hover() style element method will return a checkbox to toggle the display of the current hover.

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Object} style.hover The current hover.
@property {Boolean} hover.hidden Whether a checkbox should be returned.
@property {Boolean} hover.display The state of the hover.

@returns {HTMLElement} A checkbox element to toggle the current hover.
*/
function hover(layer) {

  if (!layer.style.hover) return;

  if (layer.style.hover.hidden) return;

  return mapp.ui.elements.chkbox({
    data_id: 'hoverCheckbox',
    label: layer.style.hovers && mapp.dictionary.layer_style_display_hover
      || layer.style.hover.title || mapp.dictionary.layer_style_display_hover,
    checked: !!layer.style.hover.display,
    onchange: (checked) => {
      layer.style.hover.display = checked
    }
  })
}

/**
@function hovers

@description
The hovers() style element method will return a dropdown to change the current hover assigned to a layer.

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Object} style.hover The current hover.
@property {Object} style.hovers Object where each property represents a hover configuration.

@returns {HTMLElement} A dropdown element to switch the current hover.
*/
function hovers(layer) {

  if (!layer.style.hover) return;

  if (layer.style.hover.hidden) return;

  if (!layer.style.hovers) return;

  // It takes two 2 tango!
  if (Object.keys(layer.style.hovers).length < 2) return;

  const entries = Object.keys(layer.style.hovers)

    // Check if that label is set to be hidden. 
    .filter(key => !layer.style.hovers[key].hidden)
    .map(key => ({
      title: layer.style.hovers[key].title || key,
      option: key
    }))

  return mapp.ui.elements.dropdown({
    data_id: 'hoversDropdown',
    placeholder: layer.style.hover.title,
    entries,
    callback: (e, entry) => {

      const display = layer.style.hover.display

      // Set hover from dropdown option.
      layer.style.hover = layer.style.hovers[entry.option]

      // Assign default featureHover method if non is provided.
      layer.style.hover.method ??= mapp.layer.featureHover;

      layer.style.hover.display = display
    }
  })
}

/**
@function label

@description
The label() style element method will return a checkbox to toggle the display of the current label.

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Object} style.label The current label.
@property {Boolean} label.hidden Whether a checkbox should be returned.
@property {Boolean} label.display The state of the label.

@returns {HTMLElement} A checkbox element to toggle the current label.
*/
function label(layer) {

  if (!layer.style.label) return;

  if (layer.style.label.hidden) return;

  layer.style.labelCheckbox = mapp.ui.elements.chkbox({
    data_id: 'labelCheckbox',
    label: layer.style.labels && mapp.dictionary.layer_style_display_labels
      || layer.style.label.title || mapp.dictionary.layer_style_display_labels,
    checked: !!layer.style.label.display,
    onchange: (checked) => {
      layer.style.label.display = checked
      layer.reload()
    }
  })

  // This event must only be added once per layer.
  if (!layer.style.labelChangeEnd) {

    // The labelChangeEnd method disables the labelCheckbox when the style.label is out of range.
    layer.style.labelChangeEnd = () => {

      const z = layer.mapview.Map.getView().getZoom();

      if (z <= layer.style.label.minZoom) {
        layer.style.labelCheckbox?.classList.add('disabled');

      } else if (z >= layer.style.label.maxZoom) {
        layer.style.labelCheckbox?.classList.add('disabled');

      } else {
        layer.style.labelCheckbox?.classList.remove('disabled');
      }
    }

    // Add zoom level check for label display.
    layer.mapview.Map.getTargetElement().addEventListener('changeEnd', layer.style.labelChangeEnd)
  }

  return layer.style.labelCheckbox
}

/**
@function labels

@description
The labels() style element method will return a dropdown to change the current label assigned to a layer.

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Object} style.label The current label.
@property {Object} style.labels Object where each property represents a label configuration.

@returns {HTMLElement} A dropdown element to switch the current label.
*/
function labels(layer) {

  if (!layer.style.label) return;

  if (layer.style.label.hidden) return;

  if (!layer.style.labels) return;

  // It takes two 2 tango!
  if (Object.keys(layer.style.labels).length < 2) return;

  const entries = Object.keys(layer.style.labels)
    .filter(key => !layer.style.labels[key].hidden)
    .map(key => ({
      title: layer.style.labels[key].title || key,
      option: key
    }))

  function callback(e, entry) {

    const display = layer.style.label.display

    // Set label from dropdown option.
    layer.style.label = layer.style.labels[entry.option]

    layer.style.label.display = display

    layer.reload()
  }

  return mapp.ui.elements.dropdown({
    data_id: 'labelsDropdown',
    placeholder: layer.style.label.title,
    entries,
    callback
  })
}

function opacitySlider(layer) {

  return mapp.ui.elements.slider({
    data_id: 'opacitySlider',
    label: `${mapp.dictionary.layer_style_opacity}`,
    min: 0,
    max: 100,
    val: parseInt(layer.L.getOpacity() * 100),
    callback: e => {
      layer.L.setOpacity(parseFloat(e / 100))
    }
  })
}

/**
@function theme

@description
The theme() style element method will returns a content array with elements for the theme meta text and legend.

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Object} [style.labels] Available label configurations.
@property {Object} [style.hovers] Availabel hover configurations.
@property {Object} style.theme The current theme.
@property {string} [theme.title] Theme title for legend.
@property {string} [theme.meta] Meta text to display.
@property {string} [theme.setLabel] Key for label from style.labels{} to assign.
@property {string} [theme.setHover] Key for hover from style.hovers{} to assign.

@returns {HTMLElement} <div> with contents array for the theme meta and legend.
*/
function theme(layer) {

  if (!layer.style.theme) return;

  // Handle setLabel and labels in layer style.
  if (layer.style.theme?.setLabel && layer.style.labels) {
    layer.style.label = layer.style.labels[layer.style.theme.setLabel];
  }

  // Handle setHover and hovers in layer style.
  if (layer.style.theme?.setHover && layer.style.hovers) {
    layer.style.hover = layer.style.hovers[layer.style.theme.setHover];
  }

  const content = []

  if (layer.style.theme?.meta) {
    content.push(mapp.utils.html`<p>${layer.style.theme.meta}`)
  }

  if (Object.hasOwn(mapp.ui.layers.legends, layer.style.theme?.type)) {

    mapp.ui.layers.legends[layer.style.theme.type](layer)

    layer.style.legend && content.push(layer.style.legend)
  }

  return mapp.utils.html.node`<div data-id="layerTheme">${content}`;
}

/**
@function themes

@description
The themes() style element method will return a dropdown to change the current theme assigned to a layer.

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Object} style.theme The current theme.
@property {Object} style.themes Object where each property represents a theme.
@property {Object} [style.label] The current label.
@property {Object} [style.hover] The current hover.

@returns {HTMLElement} A dropdown element to switch the current theme.
*/
function themes(layer) {

  if (!layer.style.themes) return;

  // It takes two 2 tango!
  if (Object.keys(layer.style.themes).length < 2) return;

  const entries = Object.keys(layer.style.themes).map(key => ({
    title: layer.style.themes[key].title || key,
    option: key
  }))

  function callback(e, entry) {

    // Set theme from dropdown option.
    layer.style.theme = layer.style.themes[entry.option]

    if (layer.style.theme.setLabel && layer.style.labels) {

      layer.style.label = layer.style.labels[layer.style.theme.setLabel]
    }

    if (layer.style.theme.setHover && layer.style.hovers) {

      layer.style.hover = layer.style.hovers[layer.style.theme.setHover]

      // Assign default featureHover method if non is provided.
      layer.style.hover.method ??= mapp.layer.featureHover;
    }

    const stylePanel = mapp.ui.layers.panels.style(layer)

    if (layer.style.panel) {

      // Replace children in location layer entry style.panel
      layer.style.panel.replaceChildren(...stylePanel.children)
    }

    // Replace the children of the style panel.
    layer.view?.querySelector('[data-id=style-drawer]')
      .replaceChildren(...stylePanel.children)

    layer.reload()
  }

  const dropdown = mapp.utils.html`<div>
    ${mapp.dictionary.layer_style_select_theme}
    ${mapp.ui.elements.dropdown({
    data_id: 'themesDropdown',
    placeholder: layer.style.theme.title,
    entries,
    callback
  })}`

  return dropdown
}

/**
@function icon_scaling

@description
The icon_scaling() style element method will returns a contents array with controls for icon.scaling properties (field, icon, clusterScale, zoomInScale, and zoomOutScale).

@param {layer} layer A decorated mapp layer with a style object.

@property {layer-style} layer.style The layer style configuration.
@property {Object} style.icon_scaling The icon_scaling config.
@property {Boolean} icon_scaling.hidden Do not show icon_scaling controls in panel.
@property {String} [icon_scaling.field] Assign field value as feature property for scaling point feature icons.
@property {Boolean} [icon_scaling.icon] Add control to alter the base icon scale.
@property {Boolean} [icon_scaling.clusterScale] Add control to alter the cluster.clusterScale value.
@property {Boolean} [icon_scaling.zoomInScale] Add control to alter the zoomInScale value.
@property {Boolean} [icon_scaling.zoomOutScale] Add control to alter the zoomOutScale value.

@returns {HTMLElement} <div> with contents array of HTMLElements to control icon scaling.
*/
function icon_scaling(layer) {

  if (!layer.style.icon_scaling) return;

  if (layer.style.icon_scaling.hidden) return;

  const content = []

  if (layer.style.icon_scaling.field) {

    content.push(mapp.ui.elements.chkbox({
      data_id: 'iconScalingFieldCheckbox',
      label: mapp.dictionary.icon_scaling_field,
      checked: !!layer.style.icon_scaling.field,
      onchange: (checked) => {
        if (checked) {

          layer.style.icon_scaling.field = layer.style.icon_scaling._field
          delete layer.style.icon_scaling._field
        } else {

          layer.style.icon_scaling._field = layer.style.icon_scaling.field
          delete layer.style.icon_scaling.field
        }

        layer.L.changed()
      }
    }))
  }

  if (layer.style.icon_scaling.icon) {

    layer.style.default.scale ??= 1;

    layer.style.icon_scaling.maxScale ??= layer.style.default.scale * 3

    layer.style.icon_scaling.minScale ??= 0.1

    layer.style.icon_scaling.icon_scaling_label ??= mapp.dictionary.icon_scaling_label

    content.push(mapp.ui.elements.slider({
      data_id: 'iconScalingSlider',
      min: layer.style.icon_scaling.minScale,
      max: layer.style.icon_scaling.maxScale,
      step: layer.style.default.scale / 10,
      label: layer.style.icon_scaling.icon_scaling_label,
      val: layer.style.default.scale,
      callback: val => {
        layer.style.default.scale = parseFloat(val)
        clearTimeout(layer.style.timeout)
        layer.style.timeout = setTimeout(() => layer.L.changed(), 400)
      }
    }))
  }

  if (layer.style.icon_scaling.clusterScale && layer.style.cluster?.clusterScale) {

    layer.style.cluster.clusterScale ??= 1;

    content.push(mapp.ui.elements.slider({
      data_id: 'iconScalingClusterSlider',
      label: mapp.dictionary.icon_scaling_cluster,
      min: 0,
      max: layer.style.cluster.clusterScale * 3,
      step: layer.style.cluster.clusterScale / 10,
      val: layer.style.cluster.clusterScale,
      callback: val => {
        layer.style.cluster.clusterScale = parseFloat(val)
        clearTimeout(layer.style.timeout)
        layer.style.timeout = setTimeout(() => layer.L.changed(), 400)
      }
    }))
  }

  // Only add this elements if icon_scaling.zoomInScale is defined.
  if (layer.style.icon_scaling.zoomInScale) {

    // Assign zoomInScale as 1 if not implicit.
    layer.style.zoomInScale ??= 1;

    content.push(mapp.ui.elements.slider({
      data_id: 'iconScalingZoomInSlider',
      label: mapp.dictionary.icon_scaling_zoom_in,
      min: 0,
      max: layer.style.zoomInScale * 3,
      step: layer.style.zoomInScale / 10,
      val: layer.style.zoomInScale,
      callback: val => {       
        layer.style.zoomInScale = parseFloat(val)
        clearTimeout(layer.style.timeout)
        layer.style.timeout = setTimeout(() => layer.L.changed(), 400)
      }
    }))
  }

  // Only add this elements if icon_scaling.zoomOutScale is defined.
  if (layer.style.icon_scaling.zoomOutScale) {

    // Assign zoomOutScale as 1 if not implicit.
    layer.style.zoomOutScale ??= 1;

    content.push(mapp.ui.elements.slider({
      data_id: 'iconScalingZoomOutSlider',
      label: mapp.dictionary.icon_scaling_zoom_out,
      min: 0,
      max: layer.style.zoomOutScale * 3,
      step: layer.style.zoomOutScale / 10,
      val: layer.style.zoomOutScale,
      callback: val => {
        layer.style.zoomOutScale = parseFloat(val)
        clearTimeout(layer.style.timeout)
        layer.style.timeout = setTimeout(() => layer.L.changed(), 400)
      }
    }))
  }

  return mapp.utils.html.node`<div>${content}`;
}