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

"use strict";

define([
  "backbone",
  "views/maps/ScaleBarView",
  "views/maps/LayerLoadingIndicatorView",
], (Backbone, ScaleBarView, LayerLoadingIndicatorView) => {
  /**
   * The loading row only expands into view once a layer has been loading longer
   * than this, so brief loads never cause a visible flash.
   * @type {number}
   */
  const REVEAL_DELAY_MS = 1000;

  /**
   * Once expanded, the loading row stays open at least this long, so it never
   * collapses right after opening (which would look jerky).
   * @type {number}
   */
  const MIN_OPEN_DURATION_MS = 1000;

  /**
   * Message shown briefly when loading finishes while the row is still open.
   * @type {string}
   */
  const COMPLETED_MESSAGE = "Loading completed!";

  /**
   * Minimum time between visible message text updates. Layer status can churn
   * (e.g. several layers finishing/starting within milliseconds of each other),
   * so updates are throttled to this rate rather than applied as soon as they
   * happen, which would make the message flicker between different layer
   * names/counts.
   * @type {number}
   */
  const MESSAGE_UPDATE_INTERVAL_MS = 400;

  const BASE_CLASS = "map-status-bar";
  const CLASS_NAMES = {
    expanded: `${BASE_CLASS}--loading-expanded`,
  };

  /**
   * @class MapStatusBarView
   * @classdesc Combines the map's {@link ScaleBarView} with a
   * {@link LayerLoadingIndicatorView} that expands beneath it while layers are
   * loading. This view owns all of the timing/animation logic that decides *when*
   * the loading row is shown, so that neither of its children need to know about
   * delays, minimum open durations, or the "completed" message.
   * @classcategory Views/Maps
   * @name MapStatusBarView
   * @augments Backbone.View
   * @screenshot views/maps/MapStatusBarView.png
   * @since 2.41.0
   * @constructs
   */
  const MapStatusBarView = Backbone.View.extend(
    /** @lends MapStatusBarView.prototype */ {
      /** @inheritdoc */
      className: BASE_CLASS,

      /**
       * The map model that provides the aggregate `isLoadingLayers`/
       * `loadingLayersMessage` loading state.
       * @type {Map}
       */
      model: null,

      /**
       * Passed through to the ScaleBarView.
       * @type {GeoScale}
       */
      scaleModel: null,

      /**
       * Passed through to the ScaleBarView.
       * @type {GeoPoint}
       */
      pointModel: null,

      /**
       * Timer id for the pending {@link MapStatusBarView#scheduleReveal} expand,
       * or null if none is pending.
       * @type {number|null}
       */
      revealTimer: null,

      /**
       * Timer id for the pending {@link MapStatusBarView#handleLoadingFinished}
       * collapse, or null if none is pending.
       * @type {number|null}
       */
      collapseTimer: null,

      /**
       * Timer id for the pending throttled {@link MapStatusBarView#flushMessageUpdate}
       * call, or null if none is pending.
       * @type {number|null}
       */
      messageUpdateTimer: null,

      /**
       * Timestamp (ms) of the last time the visible message was updated.
       * @type {number}
       */
      lastMessageUpdateAt: 0,

      /**
       * The most recent message awaiting display, throttled by
       * {@link MESSAGE_UPDATE_INTERVAL_MS}.
       * @type {?string}
       */
      pendingMessage: null,

      /**
       * Timestamp (ms) when the loading row was last expanded, or null if it is
       * currently collapsed.
       * @type {?number}
       */
      expandedAt: null,

      /**
       * Whether the map is currently reporting that layers are loading.
       * @type {boolean}
       */
      isCurrentlyLoading: false,

      /**
       * @param {object} options The options for this view.
       * @param {Map} options.model The map model that provides the aggregate
       * `isLoadingLayers`/`loadingLayersMessage` loading state.
       * @param {GeoScale} options.scaleModel Passed through to the ScaleBarView.
       * @param {GeoPoint} options.pointModel Passed through to the ScaleBarView.
       */
      initialize(options = {}) {
        this.model = options.model;
        this.scaleModel = options.scaleModel;
        this.pointModel = options.pointModel;
      },

      /** @inheritdoc */
      render() {
        this.scaleBar = new ScaleBarView({
          scaleModel: this.scaleModel,
          pointModel: this.pointModel,
        });
        // Attach before rendering so the initial update:barWidth (fired during
        // render) is caught and the CSS variable starts in sync.
        this.listenTo(
          this.scaleBar,
          "update:barWidth",
          this.handleBarWidthChange,
        );
        this.scaleBar.render();
        this.$el.append(this.scaleBar.el);

        this.loadingIndicator = new LayerLoadingIndicatorView().render();
        this.$el.append(this.loadingIndicator.el);

        this.stopListening(this.model);
        this.listenTo(
          this.model,
          "change:isLoadingLayers change:loadingLayersMessage",
          this.handleLoadingStateChange,
        );
        this.handleLoadingStateChange();

        return this;
      },

      /**
       * Mirror the scale bar's live pixel width onto this element's own style so
       * this box's width (see `.map-status-bar` in map-view.css) can track it via a
       * plain CSS calc(), growing/shrinking by the same amount.
       * @param {number} barWidth The scale bar graphic's current width, in pixels.
       */
      handleBarWidthChange(barWidth) {
        this.el.style.setProperty(
          "--map-status-bar-bar-width",
          `${barWidth}px`,
        );
      },

      /**
       * React to changes in the map's aggregate layer-loading state.
       */
      handleLoadingStateChange() {
        const isLoading = this.model.get("isLoadingLayers") === true;

        if (!isLoading) {
          this.isCurrentlyLoading = false;
          this.handleLoadingFinished();
          return;
        }

        const message =
          this.model.get("loadingLayersMessage") || "Loading layers";
        const justStarted = !this.isCurrentlyLoading;
        this.isCurrentlyLoading = true;

        if (justStarted) {
          // A fresh loading start (or resume right after finishing) is a
          // meaningful transition, not churn, so show it immediately instead
          // of waiting out the throttle window below.
          this.clearMessageUpdateTimer();
          this.pendingMessage = message;
          this.flushMessageUpdate();
        } else {
          this.scheduleMessageUpdate(message);
        }

        if (this.isExpanded()) {
          // Loading resumed/changed while already open; keep it open.
          this.clearCollapseTimer();
          return;
        }

        this.scheduleReveal();
      },

      /**
       * Show the latest message, but no more than once every
       * {@link MESSAGE_UPDATE_INTERVAL_MS}, so a burst of near-simultaneous
       * layer status changes settles into a single visible update instead of
       * flickering through every intermediate value. Always resolves to the
       * most recent message once the interval has elapsed.
       * @param {string} message The latest message to (eventually) display.
       */
      scheduleMessageUpdate(message) {
        this.pendingMessage = message;

        const elapsed = Date.now() - this.lastMessageUpdateAt;
        if (elapsed >= MESSAGE_UPDATE_INTERVAL_MS) {
          this.flushMessageUpdate();
          return;
        }

        if (this.messageUpdateTimer) return;
        this.messageUpdateTimer = setTimeout(() => {
          this.messageUpdateTimer = null;
          this.flushMessageUpdate();
        }, MESSAGE_UPDATE_INTERVAL_MS - elapsed);
      },

      /** Render the currently pending message and record the update time. */
      flushMessageUpdate() {
        this.lastMessageUpdateAt = Date.now();
        this.loadingIndicator.setMessage(this.pendingMessage);
      },

      /** @returns {boolean} Whether the loading row is currently expanded. */
      isExpanded() {
        return this.el.classList.contains(CLASS_NAMES.expanded);
      },

      /**
       * Wait for {@link REVEAL_DELAY_MS} before expanding, so brief loads never
       * cause the loading row to flash open then closed.
       */
      scheduleReveal() {
        if (this.revealTimer || this.isExpanded()) return;
        this.revealTimer = setTimeout(() => {
          this.revealTimer = null;
          if (this.model.get("isLoadingLayers") === true) this.expand();
        }, REVEAL_DELAY_MS);
      },

      /** Expand the status bar to reveal the loading row. */
      expand() {
        this.expandedAt = Date.now();
        this.el.classList.add(CLASS_NAMES.expanded);
      },

      /** Collapse the status bar back down to just the scale bar. */
      collapse() {
        this.clearRevealTimer();
        this.clearCollapseTimer();
        this.expandedAt = null;
        this.el.classList.remove(CLASS_NAMES.expanded);
      },

      /**
       * Handle loading having finished, whether or not the row ever opened. If it
       * finished before it was ever revealed, this is a no-op (no flash). If it
       * finished before the minimum open duration elapsed, show a brief "completed"
       * message before collapsing; otherwise collapse right away.
       */
      handleLoadingFinished() {
        this.clearRevealTimer();
        this.clearMessageUpdateTimer();
        if (!this.isExpanded()) return;

        const remaining = MIN_OPEN_DURATION_MS - (Date.now() - this.expandedAt);
        if (remaining <= 0) {
          this.collapse();
          return;
        }

        // Completion is a definitive, one-time change, so it bypasses the
        // message throttle rather than waiting for it.
        this.pendingMessage = COMPLETED_MESSAGE;
        this.flushMessageUpdate();
        this.clearCollapseTimer();
        this.collapseTimer = setTimeout(() => {
          this.collapseTimer = null;
          this.collapse();
        }, remaining);
      },

      /** Clear the pending reveal timer, if any. */
      clearRevealTimer() {
        if (!this.revealTimer) return;
        clearTimeout(this.revealTimer);
        this.revealTimer = null;
      },

      /** Clear the pending collapse timer, if any. */
      clearCollapseTimer() {
        if (!this.collapseTimer) return;
        clearTimeout(this.collapseTimer);
        this.collapseTimer = null;
      },

      /** Clear the pending message-update timer, if any. */
      clearMessageUpdateTimer() {
        if (!this.messageUpdateTimer) return;
        clearTimeout(this.messageUpdateTimer);
        this.messageUpdateTimer = null;
      },

      /** @inheritdoc */
      onClose() {
        this.clearRevealTimer();
        this.clearCollapseTimer();
        this.clearMessageUpdateTimer();
        this.stopListening(this.model);
        if (this.scaleBar?.onClose) this.scaleBar.onClose();
      },
    },
  );

  return MapStatusBarView;
});