Source: src/js/collections/maps/viewfinder/ViewfinderCards.js

"use strict";

define([
  "underscore",
  "backbone",
  "models/maps/viewfinder/ViewfinderCardModel",
], (_, Backbone, ViewfinderCardModel) => {
  // The LEO Network domain for viewfinder cards. This is used to determine
  // if the cards are from the LEO Network and to use as the base URL for
  // images.
  const LEO_NEWTORK_DOMAIN = "leonetwork.org";
  // Default height for viewfinder cards if not specified.
  const DEFAULT_HEIGHT = 800;
  const LEO_THUMBNAIL_PATH_SEGMENT = "/thumbnail/";
  const LEO_RESIZED_PATH_SEGMENT = "/resized/";
  /**
   * Determine if array is non-empty.
   * @param {Array} a The array in question.
   * @returns {boolean} Whether the array is non-empty.
   */
  function isNonEmptyArray(a) {
    return a && a.length && Array.isArray(a);
  }

  /**
   * Prefer the higher resolution LEO image path when available.
   * @param {string|null|undefined} thumbnailUrl LEO thumbnail path.
   * @returns {string|null} Upgraded image path, or null when unavailable.
   * @since 2.38.0
   */
  function getLeoImagePath(thumbnailUrl) {
    if (typeof thumbnailUrl !== "string" || !thumbnailUrl.length) return null;
    return thumbnailUrl.replace(
      LEO_THUMBNAIL_PATH_SEGMENT,
      LEO_RESIZED_PATH_SEGMENT,
    );
  }

  /**
   * Configuration options for preset map locations and enabled layers that
   * will be shown in the viewfinder UI. Renamed from ZoomPresets
   * for clarity when zoom presets were deprecated in favor of
   * more generalized viewfinder cards in 2.37.0.
   * @typedef {MapConfig#ViewfinderCard[]|MapConfig#LeoNetworkViewfinderCardConfig} MapConfig#ViewfinderCards
   * @since 2.35.0
   */

  /**
   * A configuration object for fetching viewfinder cards from the LEO Network.
   * Renamed from LeoNetworkZoomPresetConfig for clarity when zoom presets were
   * deprecated in favor of more generalized viewfinder cards in 2.37.0.
   * @typedef {object} MapConfig#LeoNetworkViewfinderCardConfig
   * @property {string} url The URL to fetch the GeoJSON of viewfinder cards
   * from the LEO Network, e.g.
   * `https://leonetwork.org/en/lists/geojson/A54B4AEA-21F9-4162-AEB7-AFE930C0D4E4`
   * @property {string[]} [layerIds] An optional list of layer IDs to enable
   * when a card is selected. If not provided, the visible layers will not
   * change when a card is selected.
   * @since 2.35.0
   */

  /**
   * Configuration options for a viewfinder card in the MapConfig.
   * Extended from ZoomPreset for clarity when zoom presets were
   * deprecated in favor of more generalized viewfinder cards in 2.37.0.
   * @typedef {object} MapConfig#ViewfinderCard
   * @property {string} title The displayed title for the card.
   * @property {string} description A brief description of the card.
   * @property {ViewfinderCardAction[]} [buttons] A list of action buttons.
   * buttons. Supported types:
   * - `iframe`: opens a URL in the full-screen visualization overlay; requires
   *   `url`, `label`, and optional `icon`.
   * - `tab`: opens a URL in a new browser tab; requires `url`, `label`, and
   *   optional `icon`.
   * - `map`: zooms the map to a location and/or toggles layers; requires
   *   `latitude` and `longitude`, and optional `height`, and `layerIds`.
   * @property {string} [image] An optional URL to a preview image shown in
   * the card.
   * @property {number} [latitude] Camera latitude for the "View Layers" button.
   * @property {number} [longitude] Camera longitude for the "View Layers" button.
   * @property {number} [height] Camera altitude (metres) for the "View Layers" button.
   * @property {string[]} [layerIds] Layer IDs toggled by the "View Layers" button.
   * Example configs:
   * Legacy preset:
   *   { title: "Lost Lakes", description: "Show a location", latitude: 64.1, longitude: -149.586, height: 3749999.999999828, layerIds: ["iwp"] }
   * Equivalent map action:
   *   { type: "map", label: "View Layers", latitude: 64.1, longitude: -149.586, height: 3749999.999999828, layerIds: ["iwp"] }
   * New tab action:
   *   { type: "tab", label: "Open in tab", url: "https://lostlakes.arcticdata.io/" }
   * New iframe action with url encoding and initial query params:
   *   { type: "iframe", label: "Open dashboard", url: "https://lostlakes.arcticdata.io/{?selected_lake,lat,lon,zoom}{#section_id}", initialQueryParams: { theme: "light", show_share: "false" } }
   * @since 2.37.0
   */

  /**
   * @class ViewfinderCards
   * @classdesc A ViewfinderCards collection is a group of ViewfinderCardModel
   * models that provide a location and list of layers to make visible
   * and/or allow the user to open content in an iframe or new tab. Renamed
   * from ZoomPresets for clarity when zoom presets were deprecated in favor
   * of more generalized viewfinder cards in 2.37.0.
   * @classcategory Collections/Maps
   * @augments Backbone.Collection
   * @since 2.29.0
   */
  const ViewfinderCards = Backbone.Collection.extend(
    /** @lends ViewfinderCards.prototype */ {
      /** @inheritdoc */
      model: ViewfinderCardModel,

      /**
       * Constructor for the ViewfinderCards collection.
       * @param {Array} _models The initial set of models to be added to the
       * collection.
       * @param {object} [options] Options for the collection.
       * @param {MapModel} options.mapModel The map model that this collection
       * is associated with.
       */
      initialize(_models, options = {}) {
        this.mapModel = options.mapModel;
      },

      /**
       * Checks if this collection of cards is fetched from the LEO Network.
       * @returns {boolean} True if there is a URL and it contains the LEO
       * Network domain, false otherwise.
       */
      isLEONetwork() {
        return this.url && this.url.includes(LEO_NEWTORK_DOMAIN);
      },

      /**
       * Parse the configured viewfinder cards or the GeoJSON response from
       * the LEO Network. Extended from the ZoomPresets parse method
       * when zoom presets were deprecated in favor of more generalized
       * viewfinder cards in 2.37.0. Applies default ordinality, labels, and icons and
       * synthesizes legacy top-level parameters into a map type button action.
       * @param {object[]|object} resp The configured cards passed to parse
       * may be one of:
       *    1. custom cards directly from a map config: a list of objects with
       *       properties like `description`, `latitude`, `longitude`,
       *       `height`, `title`, and `image`
       *   2. the configuration for the LEO Network collection: an object
       *      with a `url` and optionally `layerIds` property
       *   3. a GeoJSON response from the LEO Network.
       * @param {object} options Options for parsing the response.
       * @param {MapModel} [options.mapModel] The map model that this
       * collection is associated with, used to get all layers.
       * @returns {ViewfinderCardModel[]} A list of ViewfinderCardModel
       * instances representing the parsed viewfinder cards.
       */
      parse(resp, options = {}) {
        // So we can re-assign the response
        let response = resp;

        if (response?.url) {
          this.url = response.url;
          this.defaults = {
            layerIds: response.layerIds,
            featureLayerId: response.featureLayerId,
          };
        }

        if (this.isLEONetwork()) {
          response = this.parseLEONetwork(response);
        }

        if (!isNonEmptyArray(response)) return [];

        const map = options.mapModel || this.mapModel;
        const allLayers =
          typeof map.getAllLayers === "function"
            ? map.getAllLayers()
            : map.get("allLayers")?.models || [];

        const viewfinderCards = response.map((cardObj) => {
          const normalizedCard =
            ViewfinderCardModel.normalizeCardAttributes(cardObj);
          const { buttons } = normalizedCard;

          // Collect layerIds from top-level AND from any explicit map
          // buttons so all relevant layers appear in the badge display.
          const topLevelLayerIds = Array.isArray(cardObj.layerIds)
            ? cardObj.layerIds
            : [];
          const ctaMapLayerIds = buttons
            .filter((a) => a.type === "map")
            .flatMap((a) => a.layerIds || []);
          const uniqueMapLayerIds = [
            ...new Set([...topLevelLayerIds, ...ctaMapLayerIds]),
          ];

          const enabledLayerIds = [];
          const enabledLayerLabels = [];
          let featureLayer = null;

          allLayers.forEach((layer) => {
            const layerId = layer.get("layerId");
            if (uniqueMapLayerIds.includes(layerId)) {
              enabledLayerIds.push(layerId);
              enabledLayerLabels.push(layer.get("label"));
            }
            if (cardObj.featureLayerId && cardObj.featureLayerId === layerId) {
              featureLayer = layer;
            }
          });

          return new ViewfinderCardModel({
            ...normalizedCard,
            enabledLayerLabels,
            enabledLayerIds,
            featureId: cardObj.featureId,
            isLEONetwork: cardObj.isLEONetwork === true,
            featureLayerId: cardObj.featureLayerId || null,
            featureLayer,
          });
        });

        return viewfinderCards;
      },

      /**
       * Parse the GeoJSON response from the LEO Network to extract viewfinder
       * card data. This was updated to return ViewfinderCards instead of the
       * legacy ZoomPresets format when zoom presets were deprecated in 2.37.0,
       * and in 2.38.0 it was updated to use the new viewfinder card format
       * which attempts to use the resized higher res images when available and
       * synthesizes a buttons array specifying actions which get their ID from
       * the leonetwork id property.
       * @param {GeoJSON} response The GeoJSON response from the LEO Network.
       * @returns {object[]} An array of objects representing viewfinder cards.
       * @since 2.35.0
       */
      parseLEONetwork(response) {
        if (!response.features || !isNonEmptyArray(response.features)) {
          return [];
        }

        const imgBaseUrl = `https://${LEO_NEWTORK_DOMAIN}`;
        return response.features.map((feature) => {
          // Extract viewfinder card data from the GeoJSON
          const { properties, geometry } = feature;
          const { observation, id } = properties;
          const localizedDate = properties.localized_date;
          const thumbnailUrl = properties.thumbnail_url;
          const imagePath = getLeoImagePath(thumbnailUrl);
          const thumbnailPath =
            typeof thumbnailUrl === "string" && thumbnailUrl.length
              ? thumbnailUrl
              : null;
          const { title, summary } = observation;
          const { coordinates } = geometry;
          const [longitude, latitude] = coordinates;
          const layerIds = this.defaults?.layerIds || [];

          return {
            description: `<b>${localizedDate}:</b> ${summary}`,
            title,
            image: imagePath ? `${imgBaseUrl}${imagePath}` : null,
            imageFallback:
              thumbnailPath && thumbnailPath !== imagePath
                ? `${imgBaseUrl}${thumbnailPath}`
                : null,
            buttons: [
              {
                id,
                type: "map",
                latitude,
                longitude,
                height: DEFAULT_HEIGHT,
                layerIds,
              },
            ],
            featureId: id,
            isLEONetwork: true,
            featureLayerId: this.defaults?.featureLayerId,
          };
        });
      },
    },
  );

  return ViewfinderCards;
});