Source: src/js/views/maps/LayerDownloadView.js

"use strict";

define(["underscore", "backbone"], (_, Backbone) => {
  const BASE_CLASS = "layer-download";

  // HTML classes used inside this view
  const CLASS_NAMES = {
    header: `${BASE_CLASS}__header`,
    checkbox: `${BASE_CLASS}__checkbox`,
    title: `${BASE_CLASS}__title`,
    content: `${BASE_CLASS}__content`,
    contentVisible: `${BASE_CLASS}__content--visible`,
    dropdown: `${BASE_CLASS}__dropdown`,
    resolutionDropdown: `${BASE_CLASS}__dropdown--resolution`,
    fileTypeDropdown: `${BASE_CLASS}__dropdown--file-type`,
    dropdownLabel: `${BASE_CLASS}__dropdown-label`,
    dropdownWrapper: `${BASE_CLASS}__dropdown-wrapper`,
    dropdownContainer: `${BASE_CLASS}__dropdown-container`,
    information: `${BASE_CLASS}__information`,
    informationWarning: `${BASE_CLASS}__information--warning`,
    informationWmts: `${BASE_CLASS}__information--wmts`,
    error: "error",
  };

  const MESSAGES = {
    selectResolutionAndFormat:
      "Select resolution and file format to download...",
    selectFormat: "Select file format to download...",
  };

  /**
   * @class LayerDownloadView
   * @classdesc A component of {@link DownloadPanelView}. Renders an expandable
   * row for a single downloadable map layer: a header with a checkbox and layer
   * name, and a collapsible content area with resolution and file-format
   * dropdowns and an informational label with instructions and an estimated
   * file size.
   *
   * This view is tightly coupled to its parent by design — it reads
   * `dropdownOptions` and `zoomLevels` from the parent to populate its
   * dropdowns, and calls back into parent methods (`layerSelection`,
   * `fileTypeSelection`, `getRawFileSize`, `updateTextbox`) to update
   * panel-level state. It is not intended for use outside of
   * `DownloadPanelView`.
   *
   * When the resolution selection is invalidated, this view triggers a
   * `"download:invalidated"` event (with `layerID` as the argument) rather than
   * mutating the parent's `dataDownloadLinks` directly. The parent is
   * responsible for listening to that event and cleaning up its own state.
   * @classcategory Views/Maps
   * @name LayerDownloadView
   * @augments Backbone.View
   * @since 2.37.0
   * @screenshot views/maps/LayerDownloadView.png
   * @constructs LayerDownloadView
   */
  const LayerDownloadView = Backbone.View.extend(
    /** @lends LayerDownloadView.prototype */ {
      /**
       * The type of View this is
       * @type {string}
       */
      type: "LayerDownloadView",

      /**
       * The HTML classes to use for this view's element
       * @type {string}
       */
      className: BASE_CLASS,

      /**
       * The primary HTML template for this view.
       * @type {Underscore.template}
       */
      template: _.template(`
        <div class="${CLASS_NAMES.header}">
          <input type="checkbox" class="${CLASS_NAMES.checkbox}" />
          <span class="${CLASS_NAMES.title}"><%- layerName %></span>
        </div>
        <div class="${CLASS_NAMES.content}">
          <div class="${CLASS_NAMES.dropdownContainer}">
            <div class="${CLASS_NAMES.dropdownWrapper}">
              <label class="${CLASS_NAMES.dropdownLabel}" for="<%- resolutionDropdownId %>"><%- resolutionLabel %></label>
              <select id="<%- resolutionDropdownId %>" class="${CLASS_NAMES.dropdown} ${CLASS_NAMES.resolutionDropdown}">
                <option value="<%- resolutionDefaultValue %>" disabled selected><%- resolutionDefaultText %></option>
              </select>
            </div>
            <div class="${CLASS_NAMES.dropdownWrapper}">
              <label class="${CLASS_NAMES.dropdownLabel}" for="<%- fileTypeDropdownId %>"><%- fileTypeLabel %></label>
              <select id="<%- fileTypeDropdownId %>" class="${CLASS_NAMES.dropdown} ${CLASS_NAMES.fileTypeDropdown}" disabled>
                <option value="<%- fileTypeDefaultValue %>" disabled selected><%- fileTypeDefaultText %></option>
              </select>
            </div>
          </div>
          <span class="${CLASS_NAMES.information} ${CLASS_NAMES.informationWarning}"><%- initialMessage %></span>
        </div>
      `),

      /** @inheritdoc */
      events() {
        const CN = CLASS_NAMES;
        return {
          [`change .${CN.checkbox}`]: "handleCheckboxChange",
          [`change .${CN.resolutionDropdown}`]: "handleResolutionChange",
          [`change .${CN.fileTypeDropdown}`]: "handleFileTypeChange",
        };
      },

      /**
       * Initialise the view.
       * @param {object} options - Options for the view.
       * @param {object} options.item - The layer data object for this row.
       * @param {DownloadPanelView} options.downloadPanelView - Reference to
       *   the parent view for shared logic (save-button state, file-size).
       */
      initialize({ item, downloadPanelView }) {
        this.item = item;
        this.downloadPanelView = downloadPanelView;

        // State is stored as plain instance properties rather than a Backbone
        // model. A model would add testability and reactivity (change events),
        // but the cost outweighs the benefit here: these three properties are
        // only consumed by one parent view in a single synchronous loop, and
        // there is no async lifecycle or cross-view reactivity requirement.
        // If the parent ever needs to react to individual layer state changes
        // (e.g. recompute a running total the moment a file type is chosen)
        // rather than polling all views at once, switching to a model-backed
        // approach with listenTo(layerModel, "change:selectedFileType", ...)
        // would become worthwhile.

        /** Whether the layer is currently selected for download. */
        this.isSelected = false;
        /** The currently selected resolution zoom level (string). */
        this.selectedResolution = "";
        /** The currently selected file type (e.g. "png", "tif"). */
        this.selectedFileType = "";
      },

      /** @inheritdoc */
      remove() {
        this.downloadPanelView = null;
        Backbone.View.prototype.remove.call(this);
      },

      /** @returns {boolean} Whether the content section is currently visible. */
      isExpanded() {
        return (
          this.contentEl?.classList.contains(CLASS_NAMES.contentVisible) ??
          false
        );
      },

      /** Show the content section without changing the checkbox state. */
      expand() {
        this.contentEl?.classList.add(CLASS_NAMES.contentVisible);
      },

      /** Hide the content section without changing the checkbox state. */
      collapse() {
        this.contentEl?.classList.remove(CLASS_NAMES.contentVisible);
      },

      /**
       * Reset both dropdowns to their default/disabled state and clear the
       * file-size label.
       */
      resetDropdowns() {
        const { dropdownOptions } = this.downloadPanelView;
        if (this.resolutionDropdownEl) {
          this.resolutionDropdownEl.value =
            dropdownOptions.resolution.defaultValue;
        }
        if (this.fileTypeDropdownEl) {
          this.fileTypeDropdownEl.disabled = true;
          this.fileTypeDropdownEl.value = dropdownOptions.fileType.defaultValue;
        }
        this.selectedResolution = "";
        this.selectedFileType = "";
        // Notify the parent to remove any stale download link for this layer,
        this.trigger("download:invalidated", this.item.layerID);
        if (this.informationEl) {
          this.informationEl.textContent = MESSAGES.selectResolutionAndFormat;
          this.informationEl.classList.remove(
            CLASS_NAMES.error,
            CLASS_NAMES.informationWmts,
          );
          this.informationEl.classList.add(CLASS_NAMES.informationWarning);
        }
      },

      /**
       * Handles changes to the layer checkbox. If checked, selects the layer
       * and expands the content; if unchecked, deselects, collapses, and resets
       * the dropdowns.
       */
      handleCheckboxChange() {
        if (this.checkboxEl.checked) {
          this.isSelected = true;
          this.expand();
        } else {
          this.isSelected = false;
          this.collapse();
          this.resetDropdowns();
        }
        this.downloadPanelView.layerSelection();
      },

      /**
       * Handles changes to the resolution dropdown. Enables the file-type
       * dropdown, clears the file-type selection, removes stale download links,
       * and updates the info box and save-button state.
       */
      handleResolutionChange() {
        this.selectedResolution = this.resolutionDropdownEl.value;
        this.fileTypeDropdownEl.disabled = false;
        this.fileTypeDropdownEl.value =
          this.downloadPanelView.dropdownOptions.fileType.defaultValue;
        this.selectedFileType = "";
        // Notify the parent to remove the stale download link for this layer
        this.trigger("download:invalidated", this.item.layerID);
        this.informationEl.textContent = MESSAGES.selectFormat;
        this.informationEl.classList.add(CLASS_NAMES.informationWarning);
        this.informationEl.classList.remove(
          CLASS_NAMES.error,
          CLASS_NAMES.informationWmts,
        );

        this.downloadPanelView.layerSelection();
      },

      /**
       * Handles changes to the file-type dropdown. Calculates the estimated
       * file size and updates the info box and save-button state.
       */
      handleFileTypeChange() {
        const { item, downloadPanelView } = this;
        this.selectedFileType = this.fileTypeDropdownEl.value;
        this.informationEl.classList.remove(CLASS_NAMES.informationWarning);
        downloadPanelView.fileTypeSelection(item.layerID);
        const fileSize = downloadPanelView.getRawFileSize(
          this.resolutionDropdownEl.value,
          this.fileTypeDropdownEl.value,
          item.layerID,
          item.fullDownloadLink,
          item.pngDownloadLink,
          item.gpkgDownloadLink,
          item.ID,
          item.layerName,
          item.wmtsDownloadLink,
          item.metadataPid,
          item.tiffDownloadLink,
        );
        downloadPanelView.updateLayerInfoEl(
          this.informationEl,
          fileSize,
          this.fileTypeDropdownEl.value,
          item.layerID,
        );
      },

      /**
       * Render the complete panel: a header row with [checkbox] [title] and
       * a collapsible content section with the download controls.
       * @returns {LayerDownloadView} this
       */
      render() {
        const { item, downloadPanelView } = this;
        const { dropdownOptions, zoomLevels } = downloadPanelView;
        const resolutionDropdownId = `resolution-dropdown-${item.layerID}`;
        const fileTypeDropdownId = `fileType-dropdown-${item.layerID}`;

        this.el.innerHTML = this.template({
          layerName: item.layerName,
          resolutionDropdownId,
          resolutionLabel: dropdownOptions.resolution.label,
          resolutionDefaultValue: dropdownOptions.resolution.defaultValue,
          resolutionDefaultText: dropdownOptions.resolution.defaultText,
          fileTypeDropdownId,
          fileTypeLabel: dropdownOptions.fileType.label,
          fileTypeDefaultValue: dropdownOptions.fileType.defaultValue,
          fileTypeDefaultText: dropdownOptions.fileType.defaultText,
          initialMessage: MESSAGES.selectResolutionAndFormat,
        });

        // Cache element references for use in event handlers and helper methods
        this.checkboxEl = this.el.querySelector(`.${CLASS_NAMES.checkbox}`);
        this.resolutionDropdownEl = this.el.querySelector(
          `.${CLASS_NAMES.resolutionDropdown}`,
        );
        this.fileTypeDropdownEl = this.el.querySelector(
          `.${CLASS_NAMES.fileTypeDropdown}`,
        );
        this.informationEl = this.el.querySelector(
          `.${CLASS_NAMES.information}`,
        );
        this.contentEl = this.el.querySelector(`.${CLASS_NAMES.content}`);

        // Append zoom-level options to the resolution dropdown
        Object.entries(zoomLevels).forEach(([zoomLevel, pixelResolution]) => {
          const option = document.createElement("option");
          option.value = zoomLevel;
          option.textContent = `${zoomLevel} - ${pixelResolution}`;
          this.resolutionDropdownEl.appendChild(option);
        });

        // Append only file-type options that have a corresponding download link
        [
          [item.tiffDownloadLink, "tif", "Geotiff"],
          [item.pngDownloadLink, "png", "PNG"],
          [item.wmtsDownloadLink, "wmts", "WMTS file"],
          [item.gpkgDownloadLink, "gpkg", "Geopackage"],
        ].forEach(([link, value, text]) => {
          if (link == null) return;
          const option = document.createElement("option");
          option.value = value;
          option.textContent = text;
          this.fileTypeDropdownEl.appendChild(option);
        });

        return this;
      },
    },
  );

  return LayerDownloadView;
});