/**
 * 3D View
 */
beestat.component.card.three_d = function() {
  const self = this;

  if (
    beestat.component.card.three_d.active_instance_ !== undefined &&
    beestat.component.card.three_d.active_instance_ !== null &&
    beestat.component.card.three_d.active_instance_ !== this
  ) {
    beestat.component.card.three_d.active_instance_.force_dispose_stale_instance_();
  }
  beestat.component.card.three_d.active_instance_ = this;

  this.disposed_ = false;
  this.rerender_timeout_id_ = undefined;
  this.rerender_pending_delay_ms_ = undefined;
  this.rerender_waiting_for_visibility_ = false;
  this.visibility_observer_ = undefined;
  this.is_in_viewport_ = true;

  this.handle_scene_settings_change_ = function() {
    if (self.disposed_ === true || self.scene_ === undefined) {
      return;
    }
    self.update_scene_();
    self.update_hud_();
  };

  // Things that update the scene that don't require a rerender.
  beestat.dispatcher.addEventListener(
    [
      'setting.visualize.data_type',
      'setting.visualize.heat_map_values',
      'setting.visualize.heat_map_static.temperature.min',
      'setting.visualize.heat_map_static.temperature.max',
      'setting.visualize.heat_map_static.occupancy.min',
      'setting.visualize.heat_map_static.occupancy.max'
    ],
    this.handle_scene_settings_change_
  );

  this.handle_floor_plan_cache_change_ = function() {
    if (self.disposed_ === true) {
      return;
    }
    // Force settings to be rehydrated from persisted floor plan data.
    self.scene_settings_values_ = undefined;
    self.request_rerender(beestat.component.card.three_d.rerender_delay_floor_plan_ms);
  };

  // Rerender the scene when the floor plan changes.
  beestat.dispatcher.addEventListener('cache.floor_plan', this.handle_floor_plan_cache_change_);

  this.handle_runtime_data_change_ = beestat.debounce(function() {
    if (self.disposed_ === true || self.scene_ === undefined) {
      return;
    }
    self.state_.scene_camera_state = self.scene_.get_camera_state();
    self.rerender();
  }, 10);

  beestat.dispatcher.addEventListener(
    [
      'cache.data.three_d__runtime_sensor',
      'cache.data.three_d__runtime_thermostat'
    ],
    this.handle_runtime_data_change_
  );

  this.handle_thermostat_cache_change_ = function() {
    if (self.disposed_ === true || self.scene_ === undefined) {
      return;
    }
    if (self.get_weather_() !== 'auto') {
      return;
    }
    self.apply_weather_setting_to_scene_(false);
    self.decorate_toolbar_();
  };
  beestat.dispatcher.addEventListener('cache.thermostat', this.handle_thermostat_cache_change_);

  this.scene_settings_menu_open_ = false;
  this.scene_settings_values_ = undefined;
  this.scene_settings_scroll_top_ = 0;
  this.scene_settings_panel_content_ = undefined;
  this.weather_values_ = ['auto', 'sunny', 'overcast', 'rain', 'thunderstorm', 'snow'];

  beestat.component.card.apply(this, arguments);
};
beestat.extend(beestat.component.card.three_d, beestat.component.card);

/**
 * Debounce delay in milliseconds for floor plan-triggered rerenders.
 *
 * @type {number}
 */
beestat.component.card.three_d.rerender_delay_floor_plan_ms = 2147483647;

/**
 * Debounce delay in milliseconds for scene setting-triggered rerenders.
 *
 * @type {number}
 */
beestat.component.card.three_d.rerender_delay_scene_setting_ms = 1000;

/**
 * Minimum time to keep the render loading mask visible (ms).
 *
 * @type {number}
 */
beestat.component.card.three_d.rerender_loading_min_visible_ms = 350;

/**
 * Fallback latitude used when the floor plan has no address.
 *
 * @type {number}
 */
beestat.component.card.three_d.fallback_latitude = 39;

/**
 * Fallback longitude used when the floor plan has no address.
 *
 * @type {number}
 */
beestat.component.card.three_d.fallback_longitude = -97;

/**
 * Scene setting keys that require a full rerender.
 *
 * @type {!Object<string, boolean>}
 */
beestat.component.card.three_d.rerender_required_scene_settings = {
  'tree_enabled': true,
  'star_density': true,
  'light_user_enabled': true
};

/**
 * Decorate
 *
 * @param {rocket.Elements} parent
 */
beestat.component.card.three_d.prototype.decorate_ = function(parent) {
  this.hide_loading_();

  this.parent_ = parent;

  /*
   * Unfortunate but necessary to get the card to fill the height of the flex
   * container. Everything leading up to the card has to be 100% height.
   */
  parent.style('height', '100%');

  this.contents_ = $.createElement('div')
    .style({
      'height': '100%',
      'background': beestat.style.color.bluegray.base,
      'border-radius': beestat.style.size.border_radius
    });

  if (this.box_shadow_ === true) {
    this.contents_.style('box-shadow', '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)');
  }

  parent.appendChild(this.contents_);

  this.decorate_contents_(this.contents_);
  this.init_visibility_observer_();
};

/**
 * Decorate
 *
 * @param {rocket.Elements} parent
 */
beestat.component.card.three_d.prototype.decorate_contents_ = function(parent) {
  // Reset cached runtime data/state tied to prior scene instances.
  delete this.data_;
  this.scene_settings_values_ = undefined;
  window.clearInterval(this.fps_interval_);
  delete this.fps_interval_;

  // Build the 3D drawing surface.
  const drawing_pane_container = document.createElement('div');
  drawing_pane_container.style.overflowX = 'hidden';

  parent.appendChild(drawing_pane_container);
  this.decorate_drawing_pane_(drawing_pane_container);

  // Build always-visible overlay elements (watermark, FPS, toolbar, settings).
  // Watermark
  const watermark_container = document.createElement('div');
  Object.assign(watermark_container.style, {
    'position': 'absolute',
    'height': '20px',
    'bottom': `${beestat.style.size.gutter}px`,
    'right': `${beestat.style.size.gutter}px`
  });
  parent.appendChild(watermark_container);
  this.decorate_watermark_(watermark_container);

  // FPS ticker
  const fps_container = document.createElement('div');
  Object.assign(fps_container.style, {
    'position': 'absolute',
    'bottom': `${beestat.style.size.gutter + 24}px`,
    'right': `${beestat.style.size.gutter}px`,
    'font-size': beestat.style.font_size.small,
    'color': 'rgba(255,255,255,0.85)',
    'font-family': 'Consolas, Courier, Monospace',
    'pointer-events': 'none',
    'text-align': 'right'
  });
  parent.appendChild(fps_container);
  this.decorate_fps_ticker_(fps_container);
  this.update_fps_visibility_();

  // Toolbar
  const toolbar_container = document.createElement('div');
  Object.assign(toolbar_container.style, {
    'position': 'absolute',
    'width': '1px',
    'top': `${beestat.style.size.gutter}px`,
    'left': `${beestat.style.size.gutter}px`
  });
  parent.appendChild(toolbar_container);
  this.decorate_toolbar_(toolbar_container);

  // Scene settings panel
  const scene_settings_container = document.createElement('div');
  Object.assign(scene_settings_container.style, {
    'position': 'absolute',
    'top': `${beestat.style.size.gutter + 52}px`,
    'right': `${beestat.style.size.gutter}px`,
    'min-width': '220px',
    'max-width': '250px',
    'height': '375px',
    'overflow': 'hidden',
    'z-index': 2
  });
  parent.appendChild(scene_settings_container);
  this.decorate_scene_settings_panel_(scene_settings_container);

  // Environment date slider (shown only in environment view)
  const environment_date_container = document.createElement('div');
  Object.assign(environment_date_container.style, {
    'position': 'absolute',
    'left': `${beestat.style.size.gutter}px`,
    'bottom': `${beestat.style.size.gutter}px`,
    'width': '230px'
  });
  parent.appendChild(environment_date_container);
  this.decorate_environment_date_(environment_date_container);

  // Build top-row HUD containers (floors + controls) and legend.
  const top_container = document.createElement('div');
  Object.assign(top_container.style, {
    'display': 'flex',
    'position': 'absolute',
    'width': '100%',
    'top': `${beestat.style.size.gutter}px`,
    'padding-left': '55px',
    'padding-right': `${beestat.style.size.gutter}px`
  });
  parent.appendChild(top_container);

  // Floors
  const floors_container = document.createElement('div');
  Object.assign(floors_container.style, {
    'flex-shrink': '0'
  });
  top_container.appendChild(floors_container);
  this.decorate_floors_(floors_container);

  // Controls
  const controls_container = document.createElement('div');
  Object.assign(controls_container.style, {
    'flex-grow': '1'
  });
  top_container.appendChild(controls_container);
  this.decorate_controls_(controls_container);

  // Legend
  const legend_container = document.createElement('div');
  Object.assign(legend_container.style, {
    'position': 'absolute',
    'top': '50%',
    'margin-top': '-90px',
    'right': `${beestat.style.size.gutter}px`,
    'height': '180px'
  });
  parent.appendChild(legend_container);
  this.decorate_legend_(legend_container);

  // Resolve the required data range from current visualize date settings.
  let required_begin;
  let required_end;
  if (beestat.setting('visualize.range_type') === 'dynamic') {
    if (
      beestat.setting('visualize.range_dynamic') === 0 ||
      beestat.setting('visualize.range_dynamic') === 1
    ) {
      // Rig "today" and "yesterday" to behave differently.
      required_begin = moment()
        .subtract(
          beestat.setting('visualize.range_dynamic'),
          'day'
        )
        .hour(0)
        .minute(0)
        .second(0);

      required_end = required_begin
        .clone()
        .hour(23)
        .minute(59)
        .second(59);
    } else {
      required_begin = moment()
        .subtract(
          beestat.setting('visualize.range_dynamic'),
          'day'
        )
        .hour(0)
        .minute(0)
        .second(0);

      required_end = moment()
        .subtract(1, 'day')
        .clone()
        .hour(23)
        .minute(59)
        .second(59);
    }
  } else {
    required_begin = moment(
      beestat.setting('visualize.range_static.begin') + ' 00:00:00'
    );
    required_end = moment(
      beestat.setting('visualize.range_static.end') + ' 23:59:59'
    );
  }

  const sensor_ids = Object.keys(beestat.floor_plan.get_sensor_ids_map(this.floor_plan_id_));
  if (sensor_ids.length > 0) {
    // Fetch and cache runtime streams needed by get_data_ when cache is missing.
    if (
      beestat.cache.data.three_d__runtime_sensor === undefined ||
      beestat.cache.data.three_d__runtime_thermostat === undefined
    ) {
      this.show_loading_('Fetching');

      const value = [
        required_begin.format(),
        required_end.format()
      ];
      const operator = 'between';

      const thermostat_ids = Object.keys(beestat.floor_plan.get_thermostat_ids_map(this.floor_plan_id_));
      const api_call = new beestat.api();

      // Sensor data
      sensor_ids.forEach(function(sensor_id) {
        api_call.add_call(
          'runtime_sensor',
          'read',
          {
            'attributes': {
              'sensor_id': sensor_id,
              'timestamp': {
                'value': value,
                'operator': operator
              }
            }
          },
          'runtime_sensor_' + sensor_id
        );
      });

      // Thermostat data
      thermostat_ids.forEach(function(thermostat_id) {
        api_call.add_call(
          'runtime_thermostat',
          'read',
          {
            'attributes': {
              'thermostat_id': thermostat_id,
              'timestamp': {
                'value': value,
                'operator': operator
              }
            }
          },
          'runtime_thermostat_' + thermostat_id
        );
      });

      api_call.set_callback(function(response) {
        let runtime_sensors = [];
        let runtime_thermostats = [];
        for (let alias in response) {
          if (alias.includes('runtime_sensor_') === true) {
            runtime_sensors = runtime_sensors.concat(response[alias]);
          } else {
            runtime_thermostats = runtime_thermostats.concat(response[alias]);
          }
        }
        beestat.cache.set('data.three_d__runtime_sensor', runtime_sensors);
        beestat.cache.set('data.three_d__runtime_thermostat', runtime_thermostats);
      });

      api_call.send();
    }
  }
};

/**
 * Decorate the drawing pane.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_drawing_pane_ = function(parent) {
  const self = this;
  const drawing_height = 770;

  // Create the scene
  if (this.scene_ !== undefined) {
    this.scene_.dispose();
  }
  this.ensure_scene_settings_values_();
  this.scene_ = new beestat.component.scene(
    beestat.setting('visualize.floor_plan_id'),
    this.get_data_()
  );

  const initial_width = parent.getBoundingClientRect().width;
  if (this.state_.width === undefined && initial_width > 0) {
    this.state_.width = initial_width;
  }
  if (this.state_.width !== undefined && this.state_.width > 0) {
    this.scene_.set_initial_width(this.state_.width);
  }
  if (this.state_.scene_camera_state !== undefined) {
    this.scene_.set_initial_camera_state(this.state_.scene_camera_state);
  }

  this.scene_.set_scene_settings(this.scene_settings_values_, {
    'rerender': false
  });

  this.scene_.addEventListener('change_active_room', function() {
    self.update_hud_();
  });

  // Set the initial date.
  this.update_scene_();
  this.scene_.render($(parent));
  this.scene_.height_ = drawing_height;
  this.scene_.set_width(this.scene_.width_);

  // Use current local time of day for initial scene lighting.
  const now = moment();
  now
    .minute(Math.floor(now.minute() / 5) * 5)
    .second(0);

  if (beestat.setting('visualize.range_type') === 'dynamic') {
    // Set the date, then apply current time of day
    this.date_m_ = moment()
      .subtract(
        beestat.setting('visualize.range_dynamic'),
        'day'
      )
      .hour(now.hour())
      .minute(now.minute())
      .second(0);
  } else {
    // Set the static date, then apply current time of day
    this.date_m_ = moment(
      beestat.setting('visualize.range_static.begin') + ' 00:00:00'
    )
      .hour(now.hour())
      .minute(now.minute())
      .second(0);
  }
  this.try_align_time_to_recent_data_();

  // Default environment date to today.
  this.environment_date_m_ = moment().startOf('day');
  if (this.get_show_environment_() === true) {
    this.date_m_
      .year(this.environment_date_m_.year())
      .month(this.environment_date_m_.month())
      .date(this.environment_date_m_.date());
  }

  // Set some defaults on the scene.
  this.scene_.set_date(this.date_m_);
  this.scene_.set_labels(
    this.get_show_environment_() === true
      ? false
      : this.get_show_labels_()
  );
  this.scene_.set_room_interaction_enabled(this.get_show_environment_() === false);
  this.scene_.set_auto_rotate(this.get_auto_rotate_());

  const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];

  // Set location for celestial light calculations.
  const scene_location = this.get_scene_location_();
  this.scene_.set_location(scene_location.latitude, scene_location.longitude);

  this.apply_layer_visibility_();

  // Manage width of the scene.
  if (this.state_.width === undefined) {
    setTimeout(function() {
      if (parent.getBoundingClientRect().width > 0) {
        self.state_.width = parent.getBoundingClientRect().width;
        self.scene_.set_width(self.state_.width);
      }
    }, 0);
  } else {
    this.scene_.set_width(this.state_.width);
  }

  beestat.dispatcher.removeEventListener('resize.three_d');
  beestat.dispatcher.addEventListener('resize.three_d', function() {
    self.state_.width = parent.getBoundingClientRect().width;
    self.scene_.set_width(self.state_.width);
  });
};

/**
 * Get (and initialize if needed) persisted scene settings.
 *
 * @return {object|null}
 */
beestat.component.card.three_d.prototype.get_scene_visualize_state_ = function() {
  const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
  if (floor_plan === undefined || floor_plan.data === undefined) {
    return null;
  }

  if (floor_plan.data.scene === undefined || floor_plan.data.scene === null) {
    floor_plan.data.scene = {};
  }

  // Migrate legacy storage from floor_plan.data.visualize.scene to floor_plan.data.scene.
  if (
    floor_plan.data.visualize !== undefined &&
    floor_plan.data.visualize !== null &&
    floor_plan.data.visualize.scene !== undefined &&
    floor_plan.data.visualize.scene !== null &&
    typeof floor_plan.data.visualize.scene === 'object'
  ) {
    floor_plan.data.scene = Object.assign(
      {},
      floor_plan.data.visualize.scene,
      floor_plan.data.scene
    );
    delete floor_plan.data.visualize.scene;
    this.save_scene_visualize_state_();
  }

  if (typeof floor_plan.data.scene !== 'object') {
    floor_plan.data.scene = {};
  }

  const scene_visualize = floor_plan.data.scene;
  if (scene_visualize.settings === undefined || scene_visualize.settings === null) {
    scene_visualize.settings = {};
  }
  if (scene_visualize.show_group === undefined || scene_visualize.show_group === null) {
    scene_visualize.show_group = {};
  }
  if (scene_visualize.mode === undefined) {
    scene_visualize.mode = 'floor_plan';
  }
  if (scene_visualize.auto_rotate === undefined) {
    scene_visualize.auto_rotate = false;
  }
  if (scene_visualize.show_labels === undefined) {
    scene_visualize.show_labels = true;
  }
  if (scene_visualize.weather === undefined) {
    scene_visualize.weather = 'auto';
  }

  return scene_visualize;
};

/**
 * Persist current floor plan data after scene-visualize changes.
 */
beestat.component.card.three_d.prototype.save_scene_visualize_state_ = function() {
  beestat.floor_plan.queue_data_save_(this.floor_plan_id_, 300);
};

/**
 * Get whether environment mode is enabled.
 *
 * @return {boolean}
 */
beestat.component.card.three_d.prototype.get_show_environment_ = function() {
  if (beestat.user.has_early_access() !== true) {
    return false;
  }
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return false;
  }
  return scene_visualize.mode === 'environment';
};

/**
 * Set environment mode.
 *
 * @param {boolean} show_environment
 */
beestat.component.card.three_d.prototype.set_show_environment_ = function(show_environment) {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return;
  }
  scene_visualize.mode = show_environment === true ? 'environment' : 'floor_plan';
  this.save_scene_visualize_state_();
};

/**
 * Get selected weather value.
 *
 * @return {string}
 */
beestat.component.card.three_d.prototype.get_weather_ = function() {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return 'auto';
  }
  const weather = scene_visualize.weather;
  if (this.weather_values_.includes(weather) !== true) {
    scene_visualize.weather = 'auto';
    this.save_scene_visualize_state_();
    return 'auto';
  }
  return weather;
};

/**
 * Set weather value.
 *
 * @param {string} weather
 */
beestat.component.card.three_d.prototype.set_weather_ = function(weather) {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return;
  }
  const normalized_weather = this.weather_values_.includes(weather)
    ? weather
    : 'auto';
  scene_visualize.weather = normalized_weather;
  this.save_scene_visualize_state_();
};

/**
 * Get normalized thermostat weather condition for auto scene weather.
 *
 * @return {string}
 */
beestat.component.card.three_d.prototype.get_auto_weather_from_thermostat_ = function() {
  const thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
  return beestat.weather.get_settings_(thermostat?.weather?.condition).condition;
};

/**
 * Resolve selected weather mode into a weather condition.
 *
 * @param {string} weather
 *
 * @return {string}
 */
beestat.component.card.three_d.prototype.get_weather_condition_from_mode_ = function(weather) {
  if (weather === 'auto') {
    return this.get_auto_weather_from_thermostat_();
  }
  return beestat.weather.get_settings_(weather).condition;
};

/**
 * Get sidebar weather icon for a selected mode.
 *
 * @param {string} weather
 *
 * @return {string}
 */
beestat.component.card.three_d.prototype.get_weather_icon_from_mode_ = function(weather) {
  return beestat.weather.get_icon(this.get_weather_condition_from_mode_(weather));
};

/**
 * Get explicit weather mode list for expanded weather picker.
 *
 * @return {!Array<!{value: string, icon: string, title: string}>}
 */
beestat.component.card.three_d.prototype.get_weather_mode_tiles_ = function() {
  return [
    {'value': 'sunny', 'icon': beestat.weather.get_icon('sunny'), 'title': 'Weather: Sunny'},
    {'value': 'overcast', 'icon': beestat.weather.get_icon('overcast'), 'title': 'Weather: Overcast'},
    {'value': 'rain', 'icon': beestat.weather.get_icon('rain'), 'title': 'Weather: Rain'},
    {'value': 'thunderstorm', 'icon': beestat.weather.get_icon('thunderstorm'), 'title': 'Weather: Thunderstorm'},
    {'value': 'snow', 'icon': beestat.weather.get_icon('snow'), 'title': 'Weather: Snow'}
  ];
};

/**
 * Get whether labels are enabled in floor plan mode.
 *
 * @return {boolean}
 */
beestat.component.card.three_d.prototype.get_show_labels_ = function() {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return true;
  }
  return scene_visualize.show_labels !== false;
};

/**
 * Set labels visibility in floor plan mode.
 *
 * @param {boolean} show_labels
 */
beestat.component.card.three_d.prototype.set_show_labels_ = function(show_labels) {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return;
  }
  scene_visualize.show_labels = show_labels === true;
  this.save_scene_visualize_state_();
};

/**
 * Get whether auto-rotate is enabled.
 *
 * @return {boolean}
 */
beestat.component.card.three_d.prototype.get_auto_rotate_ = function() {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return false;
  }
  return scene_visualize.auto_rotate === true;
};

/**
 * Set auto-rotate.
 *
 * @param {boolean} auto_rotate
 */
beestat.component.card.three_d.prototype.set_auto_rotate_ = function(auto_rotate) {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return;
  }
  scene_visualize.auto_rotate = auto_rotate === true;
  this.save_scene_visualize_state_();
};

/**
 * Get visibility state for one floor/group layer.
 *
 * @param {number} group_id
 *
 * @return {boolean}
 */
beestat.component.card.three_d.prototype.get_show_group_ = function(group_id) {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return true;
  }
  return scene_visualize.show_group[group_id] !== false;
};

/**
 * Set visibility state for one floor/group layer.
 *
 * @param {number} group_id
 * @param {boolean} visible
 */
beestat.component.card.three_d.prototype.set_show_group_ = function(group_id, visible) {
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize === null) {
    return;
  }
  scene_visualize.show_group[group_id] = visible === true;
  this.save_scene_visualize_state_();
};

/**
 * Map weather to scene weather property values.
 *
 * @param {string} weather
 *
 * @return {{
 *   cloud_density: number,
 *   cloud_darkness: number,
 *   fog_density: number,
 *   fog_color: string,
 *   rain_density: number,
 *   snow_density: number,
 *   lightning_frequency: number,
 *   wind_speed: number
 * }}
 */
beestat.component.card.three_d.prototype.get_weather_settings_from_weather_ = function(weather) {
  const condition = this.get_weather_condition_from_mode_(weather);
  return {
    'cloud_density': beestat.weather.get_cloud_density(condition),
    'cloud_darkness': beestat.weather.get_cloud_darkness(condition),
    'fog_density': beestat.weather.get_fog_density(condition),
    'fog_color': beestat.weather.get_fog_color(condition),
    'rain_density': beestat.weather.get_rain_density(condition),
    'snow_density': beestat.weather.get_snow_density(condition),
    'lightning_frequency': beestat.weather.get_lightning_frequency(condition),
    'wind_speed': beestat.weather.get_wind_speed(condition)
  };
};

/**
 * Apply current weather settings to the scene.
 *
 * @param {boolean=} opt_persist Persist weather settings to floor-plan scene data.
 */
beestat.component.card.three_d.prototype.apply_weather_setting_to_scene_ = function(opt_persist) {
  if (this.scene_ === undefined) {
    return;
  }

  this.ensure_scene_settings_values_();
  const weather_settings = this.get_weather_settings_from_weather_(this.get_weather_());
  Object.assign(this.scene_settings_values_, weather_settings);
  const persist = opt_persist === true;
  if (persist === true) {
    const scene_visualize = this.get_scene_visualize_state_();
    if (scene_visualize !== null) {
      const previous_settings_json = JSON.stringify(scene_visualize.settings || {});
      Object.assign(scene_visualize.settings, weather_settings);
      const next_settings_json = JSON.stringify(scene_visualize.settings);
      if (previous_settings_json !== next_settings_json) {
        this.save_scene_visualize_state_();
      }
    }
  }
  this.scene_.set_scene_settings(weather_settings, {
    'rerender': false
  });

  if (this.scene_settings_container_ !== undefined) {
    this.decorate_scene_settings_panel_();
  }
};

/**
 * Ensure local scene settings state exists.
 */
beestat.component.card.three_d.prototype.ensure_scene_settings_values_ = function() {
  if (this.scene_settings_values_ !== undefined) {
    return;
  }

  const scene_visualize = this.get_scene_visualize_state_();
  this.scene_settings_values_ = Object.assign({}, beestat.component.scene.default_settings);
  if (scene_visualize !== null && scene_visualize.settings !== undefined) {
    Object.assign(this.scene_settings_values_, scene_visualize.settings);
  }
  if (this.scene_settings_values_.seed !== undefined) {
    this.scene_settings_values_.random_seed = this.scene_settings_values_.seed;
    delete this.scene_settings_values_.seed;
  }
  if (
    Number.isFinite(Number(this.scene_settings_values_.random_seed)) !== true ||
    Number(this.scene_settings_values_.random_seed) <= 0
  ) {
    this.scene_settings_values_.random_seed = Math.floor(Math.random() * 2147483646) + 1;
  }
  if (scene_visualize !== null) {
    const normalized_settings = Object.assign({}, this.scene_settings_values_, {
      'seed': this.scene_settings_values_.random_seed
    });
    delete normalized_settings.random_seed;
    const previous_settings_json = JSON.stringify(scene_visualize.settings || {});
    const next_settings_json = JSON.stringify(normalized_settings);
    scene_visualize.settings = normalized_settings;
    if (previous_settings_json !== next_settings_json) {
      this.save_scene_visualize_state_();
    }
  }
};

/**
 * Set one scene setting from the settings panel and force rerender.
 *
 * @param {string} key
 * @param {*} value
 */
beestat.component.card.three_d.prototype.set_scene_setting_from_panel_ = function(key, value) {
  this.ensure_scene_settings_values_();
  this.scene_settings_values_[key] = value;
  const scene_visualize = this.get_scene_visualize_state_();
  if (scene_visualize !== null) {
    const persisted_key = key === 'random_seed' ? 'seed' : key;
    scene_visualize.settings[persisted_key] = value;
    this.save_scene_visualize_state_();
  }

  if (this.scene_ !== undefined) {
    this.scene_.set_scene_settings({
      [key]: value
    }, {
      'rerender': false,
      'source': 'panel'
    });
    if (beestat.component.card.three_d.rerender_required_scene_settings[key] === true) {
      this.request_rerender(beestat.component.card.three_d.rerender_delay_scene_setting_ms);
    }
  }
};

/**
 * Request a full scene rerender after a delay. New requests reset the timer.
 *
 * @param {number} milliseconds
 */
beestat.component.card.three_d.prototype.request_rerender = function(milliseconds) {
  let delay = Number(milliseconds);
  if (Number.isFinite(delay) !== true || delay < 0) {
    delay = 0;
  }
  delay = Math.floor(delay);

  this.rerender_pending_delay_ms_ = delay;
  this.rerender_waiting_for_visibility_ = false;

  if (this.rerender_timeout_id_ !== undefined) {
    window.clearTimeout(this.rerender_timeout_id_);
    this.rerender_timeout_id_ = undefined;
  }

  this.rerender_timeout_id_ = window.setTimeout(function() {
    this.rerender_timeout_id_ = undefined;
    this.rerender_pending_delay_ms_ = undefined;

    if (this.disposed_ === true || this.scene_ === undefined) {
      return;
    }

    if (this.is_scene_in_viewport_() !== true) {
      this.rerender_waiting_for_visibility_ = true;
      return;
    }

    this.show_loading_('Rendering');
    const loading_started_ms = window.performance.now();
    const run_rerender = function() {
      if (this.disposed_ === true || this.scene_ === undefined) {
        this.hide_loading_();
        return;
      }

      try {
        this.scene_.rerender();
        this.apply_layer_visibility_();
        this.update_scene_();
        this.update_hud_();
      } finally {
        const elapsed_ms = window.performance.now() - loading_started_ms;
        const min_visible_ms = Number(
          beestat.component.card.three_d.rerender_loading_min_visible_ms || 0
        );
        const remaining_ms = Math.max(0, min_visible_ms - elapsed_ms);
        window.setTimeout(function() {
          this.hide_loading_();
        }.bind(this), remaining_ms);
      }
    }.bind(this);

    // Yield at least one paint so the loading mask appears before heavy work.
    if (typeof window.requestAnimationFrame === 'function') {
      window.requestAnimationFrame(function() {
        window.requestAnimationFrame(run_rerender);
      });
    } else {
      window.setTimeout(run_rerender, 16);
    }
  }.bind(this), delay);
};

/**
 * Whether this card is currently visible in the viewport.
 *
 * @return {boolean}
 */
beestat.component.card.three_d.prototype.is_scene_in_viewport_ = function() {
  if (this.contents_ === undefined || this.contents_[0] === undefined) {
    return true;
  }

  const rect = this.contents_[0].getBoundingClientRect();
  const viewport_height = window.innerHeight || document.documentElement.clientHeight;
  const viewport_width = window.innerWidth || document.documentElement.clientWidth;

  return (
    rect.bottom > 0 &&
    rect.right > 0 &&
    rect.top < viewport_height &&
    rect.left < viewport_width
  );
};

/**
 * Track whether the card is visible and flush deferred rerenders when visible.
 */
beestat.component.card.three_d.prototype.init_visibility_observer_ = function() {
  if (
    typeof window.IntersectionObserver !== 'function' ||
    this.contents_ === undefined ||
    this.contents_[0] === undefined
  ) {
    return;
  }

  if (this.visibility_observer_ !== undefined) {
    this.visibility_observer_.disconnect();
  }

  this.visibility_observer_ = new window.IntersectionObserver(function(entries) {
    if (entries === undefined || entries.length === 0) {
      return;
    }

    const entry = entries[entries.length - 1];
    this.is_in_viewport_ = entry.isIntersecting === true && entry.intersectionRatio > 0;

    if (this.is_in_viewport_ === true && this.rerender_waiting_for_visibility_ === true) {
      this.rerender_waiting_for_visibility_ = false;
      this.request_rerender(0);
    }
  }.bind(this), {
    'threshold': 0
  });

  this.visibility_observer_.observe(this.contents_[0]);
};

/**
 * Decorate scene settings panel.
 *
 * @param {HTMLDivElement=} parent
 */
beestat.component.card.three_d.prototype.decorate_scene_settings_panel_ = function(parent) {
  if (parent !== undefined) {
    this.scene_settings_container_ = parent;
  }
  if (this.scene_settings_container_ === undefined) {
    return;
  }

  if (this.scene_settings_panel_content_ !== undefined) {
    this.scene_settings_scroll_top_ = this.scene_settings_panel_content_.scrollTop;
  } else {
    this.scene_settings_scroll_top_ = this.scene_settings_container_.scrollTop;
  }

  this.scene_settings_container_.innerHTML = '';
  this.scene_settings_panel_content_ = undefined;
  if (
    this.get_show_environment_() !== true ||
    this.scene_settings_menu_open_ !== true
  ) {
    this.scene_settings_container_.style.display = 'none';
    this.update_fps_visibility_();
    return;
  }
  this.scene_settings_container_.style.display = 'block';

  this.ensure_scene_settings_values_();

  const panel = document.createElement('div');
  Object.assign(panel.style, {
    'background': 'rgba(32, 42, 48, 0.94)',
    'border': '1px solid rgba(255,255,255,0.16)',
    'border-radius': `${beestat.style.size.border_radius}px`,
    'padding': '10px',
    'color': '#fff',
    'font-size': beestat.style.font_size.small,
    'display': 'flex',
    'flex-direction': 'column',
    'grid-gap': '8px',
    'height': '100%',
    'overflow-y': 'auto',
    'box-sizing': 'border-box'
  });
  panel.addEventListener('scroll', () => {
    this.scene_settings_scroll_top_ = panel.scrollTop;
  });
  this.scene_settings_container_.appendChild(panel);
  this.scene_settings_panel_content_ = panel;
  this.scene_settings_container_.scrollTop = this.scene_settings_scroll_top_;
  panel.scrollTop = this.scene_settings_scroll_top_;
  const restore_scroll = () => {
    this.scene_settings_container_.scrollTop = this.scene_settings_scroll_top_;
    panel.scrollTop = this.scene_settings_scroll_top_;
  };
  if (typeof window.requestAnimationFrame === 'function') {
    window.requestAnimationFrame(() => {
      restore_scroll();
      window.requestAnimationFrame(restore_scroll);
    });
  } else {
    window.setTimeout(restore_scroll, 0);
  }

  const get_title_case_label = (key) => {
    return key
      .split('_')
      .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
      .join(' ');
  };

  const add_boolean_setting = (label, key) => {
    const row = document.createElement('label');
    Object.assign(row.style, {
      'display': 'flex',
      'justify-content': 'space-between',
      'align-items': 'center',
      'grid-gap': '10px'
    });
    const text = document.createElement('span');
    text.innerText = label;
    row.appendChild(text);

    const input = document.createElement('input');
    input.type = 'checkbox';
    input.checked = this.scene_settings_values_[key] === true;
    Object.assign(input.style, {
      'visibility': 'visible',
      'appearance': 'auto',
      '-webkit-appearance': 'checkbox',
      'accent-color': beestat.style.color.lightblue.base,
      'width': '16px',
      'height': '16px',
      'margin': '0'
    });
    input.addEventListener('change', () => {
      this.set_scene_setting_from_panel_(key, input.checked === true);
    });
    row.appendChild(input);
    panel.appendChild(row);
  };

  const add_number_setting = (label, key, min, max, step) => {
    const row = document.createElement('label');
    Object.assign(row.style, {
      'display': 'flex',
      'justify-content': 'space-between',
      'align-items': 'center',
      'grid-gap': '10px'
    });
    const text = document.createElement('span');
    text.innerText = label;
    row.appendChild(text);

    const input = document.createElement('input');
    input.type = 'number';
    input.value = String(this.scene_settings_values_[key]);
    input.min = String(min);
    input.max = String(max);
    input.step = String(step);
    Object.assign(input.style, {
      'width': '62px',
      'background': '#1a242a',
      'color': '#fff',
      'border': '1px solid rgba(255,255,255,0.2)',
      'border-radius': '4px',
      'padding': '2px 4px'
    });
    input.addEventListener('change', () => {
      const parsed = Number(input.value);
      if (Number.isFinite(parsed) !== true) {
        input.value = String(this.scene_settings_values_[key]);
        return;
      }
      const clamped = Math.max(min, Math.min(max, parsed));
      const normalized = step >= 1 ? Math.round(clamped) : clamped;
      input.value = String(normalized);
      this.set_scene_setting_from_panel_(key, normalized);
    });
    row.appendChild(input);
    panel.appendChild(row);
  };

  const add_separator = () => {
    const separator = document.createElement('div');
    Object.assign(separator.style, {
      'height': '1px',
      'background': 'rgba(255,255,255,0.16)',
      'margin': '2px 0'
    });
    panel.appendChild(separator);
  };
  const add_section_title = (title) => {
    const heading = document.createElement('div');
    Object.assign(heading.style, {
      'font-size': '11px',
      'letter-spacing': '0.06em',
      'text-transform': 'uppercase',
      'color': 'rgba(255,255,255,0.75)',
      'margin-top': '2px'
    });
    heading.innerText = title;
    panel.appendChild(heading);
  };

  // Weather
  add_section_title('Weather');
  add_number_setting(get_title_case_label('cloud_density'), 'cloud_density', 0, 2, 0.1);
  add_number_setting(get_title_case_label('cloud_darkness'), 'cloud_darkness', 0, 2, 0.1);
  add_number_setting(get_title_case_label('fog_density'), 'fog_density', 0, 2, 0.1);
  add_number_setting(get_title_case_label('rain_density'), 'rain_density', 0, 2, 0.1);
  add_number_setting(get_title_case_label('snow_density'), 'snow_density', 0, 2, 0.1);
  add_number_setting(get_title_case_label('lightning_frequency'), 'lightning_frequency', 0, 2, 0.1);

  add_separator();
  add_section_title('Wind');
  add_number_setting(get_title_case_label('wind_speed'), 'wind_speed', 0, 2, 0.1);
  add_number_setting(get_title_case_label('wind_direction'), 'wind_direction', 0, 360, 1);

  add_separator();

  // Tree
  add_section_title('Tree');
  add_boolean_setting(get_title_case_label('tree_enabled'), 'tree_enabled');
  add_boolean_setting(get_title_case_label('tree_wobble'), 'tree_wobble');

  add_separator();

  // Light / Sky
  add_section_title('Light / Sky');
  add_number_setting(get_title_case_label('star_density'), 'star_density', 0, 2, 0.1);
  add_boolean_setting(get_title_case_label('light_user_enabled'), 'light_user_enabled');
  add_boolean_setting(get_title_case_label('light_user_cast_shadows'), 'light_user_cast_shadows');
  this.update_fps_visibility_();
};

/**
 * Apply saved layer visibility (environment/floor plan/floors) to the current
 * scene. Used after initial draw and after scene rerenders.
 */
beestat.component.card.three_d.prototype.apply_layer_visibility_ = function() {
  if (this.scene_ === undefined) {
    return;
  }

  const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
  if (floor_plan === undefined) {
    return;
  }

  const show_environment = this.get_show_environment_();
  if (show_environment === false) {
    this.weather_menu_open_ = false;
    this.scene_settings_menu_open_ = false;
  }

  this.scene_.set_layer_visible('walls', show_environment);
  this.scene_.set_layer_visible('roof', show_environment);
  this.scene_.set_layer_visible('environment', show_environment);
  this.scene_.set_layer_visible('openings', show_environment);
  this.scene_.set_layer_visible('light_sources', show_environment);

  Object.values(floor_plan.data.groups).forEach((group) => {
    const group_visible = this.get_show_group_(group.group_id);
    this.scene_.set_layer_visible(
      group.group_id,
      group_visible
    );
  });

  this.scene_.set_labels(
    show_environment === true
      ? false
      : this.get_show_labels_()
  );
  this.scene_.set_room_interaction_enabled(show_environment === false);

  this.update_environment_date_visibility_();

  if (this.controls_container_ !== undefined) {
    this.decorate_controls_();
  }
  if (this.legend_container_ !== undefined) {
    this.decorate_legend_();
  }
  if (this.toolbar_container_ !== undefined) {
    this.decorate_toolbar_();
  }
  if (this.scene_settings_container_ !== undefined) {
    this.decorate_scene_settings_panel_();
  }
  this.update_fps_visibility_();
};

/**
 * Decorate environment date controls.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_environment_date_ = function(parent) {
  const self = this;
  window.clearInterval(this.environment_date_interval_);
  delete this.environment_date_interval_;

  this.environment_date_container_ = parent;
  this.environment_date_container_.innerHTML = '';

  const header = document.createElement('div');
  Object.assign(header.style, {
    'display': 'flex',
    'justify-content': 'space-between',
    'align-items': 'center',
    'margin-bottom': '4px',
    'color': '#fff',
    'font-size': beestat.style.font_size.small
  });
  this.environment_date_container_.appendChild(header);

  this.environment_month_label_ = document.createElement('div');
  Object.assign(this.environment_month_label_.style, {
    'font-weight': beestat.style.font_weight.bold
  });
  header.appendChild(this.environment_month_label_);

  const controls_right = document.createElement('div');
  Object.assign(controls_right.style, {
    'display': 'flex',
    'align-items': 'center',
    'grid-gap': '8px'
  });
  header.appendChild(controls_right);

  this.environment_date_play_tile_ = new beestat.component.tile()
    .set_icon('play')
    .set_size('small')
    .set_shadow(false)
    .set_background_color('transparent')
    .set_text_color('#fff')
    .set_text_hover_color(beestat.style.color.lightblue.light)
    .render($(controls_right));
  this.environment_date_play_tile_.addEventListener('click', function() {
    if (self.environment_date_interval_ === undefined) {
      self.environment_date_play_tile_.set_icon('pause');
      self.environment_date_interval_ = window.setInterval(function() {
        const current = self.environment_date_m_.clone();
        const next = current.add(1, 'day').startOf('day');
        const wrapped = next.year() !== year_start.year() ? year_start.clone() : next;
        const day_index = wrapped.diff(year_start, 'days');
        self.environment_date_slider_.set_value(day_index);
        self.set_environment_date_(wrapped);
      }, 140);
    } else {
      window.clearInterval(self.environment_date_interval_);
      delete self.environment_date_interval_;
      self.environment_date_play_tile_.set_icon('play');
    }
  });

  const today_icon_container = document.createElement('div');
  controls_right.appendChild(today_icon_container);
  new beestat.component.tile()
    .set_icon('calendar-today')
    .set_size('small')
    .set_shadow(false)
    .set_background_color('transparent')
    .set_text_color('#fff')
    .set_text_hover_color(beestat.style.color.lightblue.light)
    .render($(today_icon_container))
    .addEventListener('click', function() {
      const today_date = moment().startOf('day');
      const day_index = today_date.diff(year_start, 'days');
      self.environment_date_slider_.set_value(day_index);
      self.set_environment_date_(today_date);
    });

  const slider_container = document.createElement('div');
  this.environment_date_container_.appendChild(slider_container);

  const year_start = moment().startOf('year');
  const today = moment().startOf('day');
  const day_index_today = today.diff(year_start, 'days');
  const max_day_index = year_start.clone().endOf('year').diff(year_start, 'days');

  this.environment_date_slider_ = new beestat.component.input.range()
    .set_min(0)
    .set_max(max_day_index)
    .set_value(day_index_today)
    .set_background(
      'linear-gradient(90deg, ' +
      beestat.style.color.bluegray.base +
      ' 0% 100%)'
    )
    .render($(slider_container));

  this.environment_date_slider_.addEventListener('input', function() {
    if (self.environment_date_interval_ !== undefined) {
      window.clearInterval(self.environment_date_interval_);
      delete self.environment_date_interval_;
      self.environment_date_play_tile_.set_icon('play');
    }
    const day_index = parseInt(self.environment_date_slider_.get_value(), 10);
    const selected_date = year_start.clone().add(day_index, 'days').startOf('day');
    self.set_environment_date_(selected_date);
  });

  this.set_environment_date_(today);
  this.update_environment_date_visibility_();
};

/**
 * Set the current environment date used for celestial calculations while
 * preserving the current time-of-day.
 *
 * @param {moment} date_m
 */
beestat.component.card.three_d.prototype.set_environment_date_ = function(date_m) {
  if (this.date_m_ === undefined) {
    return;
  }

  this.environment_date_m_ = date_m.clone().startOf('day');
  this.date_m_
    .year(this.environment_date_m_.year())
    .month(this.environment_date_m_.month())
    .date(this.environment_date_m_.date());

  if (this.environment_month_label_ !== undefined) {
    this.environment_month_label_.innerText = this.environment_date_m_.format('MMMM D');
  }

  if (this.scene_ !== undefined) {
    this.scene_.set_date(this.date_m_);
  }

  this.update_sunrise_sunset_markers_();
};

/**
 * Toggle visibility of environment date controls based on environment view.
 */
beestat.component.card.three_d.prototype.update_environment_date_visibility_ = function() {
  if (this.environment_date_container_ === undefined) {
    return;
  }

  const show_environment_controls = this.get_show_environment_();
  this.environment_date_container_.style.display = show_environment_controls ? 'block' : 'none';

  if (show_environment_controls === false && this.environment_date_interval_ !== undefined) {
    window.clearInterval(this.environment_date_interval_);
    delete this.environment_date_interval_;
    if (this.environment_date_play_tile_ !== undefined) {
      this.environment_date_play_tile_.set_icon('play');
    }
  }
};

/**
 * Decorate the playback controls.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_controls_ = function(parent) {
  const self = this;

  const active_room = this.scene_.get_active_room_();
  let thermostat_ids;
  if (
    active_room !== null &&
    active_room.sensor_id !== undefined &&
    beestat.cache.sensor[active_room.sensor_id] !== undefined
  ) {
    thermostat_ids = [beestat.cache.sensor[active_room.sensor_id].thermostat_id];
  } else {
    thermostat_ids = Object.keys(
      beestat.floor_plan.get_thermostat_ids_map(this.floor_plan_id_)
    );
  }

  if (parent !== undefined) {
    this.controls_container_ = parent;
  }

  this.controls_container_.innerHTML = '';

  window.clearInterval(self.interval_);
  delete self.interval_;

  // Hoisting
  const range = new beestat.component.input.range();
  const time_container = document.createElement('div');

  const show_environment = this.get_show_environment_();
  if (show_environment === true) {
    range.set_background(
      'linear-gradient(90deg, ' +
      beestat.style.color.bluegray.base +
      ' 0% 100%)'
    );
  } else {
    range.set_background(this.get_chart_gradient_(thermostat_ids));
  }

  const container = document.createElement('div');
  Object.assign(container.style, {
    'display': 'flex',
    'align-items': 'center'
  });
  this.controls_container_.appendChild(container);

  const left_container = document.createElement('div');
  container.appendChild(left_container);

  const play_tile = new beestat.component.tile()
    .set_icon('play')
    .set_shadow(false)
    .set_text_hover_color(beestat.style.color.gray.base)
    .render($(left_container));
  play_tile.addEventListener('click', function() {
    if (self.interval_ === undefined) {
      play_tile.set_icon('pause');

      self.interval_ = window.setInterval(function() {
        self.date_m_.add(5, 'minutes');
        self.scene_.set_date(self.date_m_);
        range.set_value(
          ((self.date_m_.hours() * 60) + self.date_m_.minutes()) / 1440 * 288
        );
        time_container.innerText = self.date_m_.format('h:mm a');
      }, 70);
    } else {
      play_tile
        .set_icon('play');
      window.clearInterval(self.interval_);
      delete self.interval_;
    }
  });

  const range_container = document.createElement('div');
  Object.assign(range_container.style, {
    'position': 'relative',
    'flex-grow': '1'
  });
  container.appendChild(range_container);
  this.range_container_ = range_container;

  // Sunrise/Sunset markers
  this.sunrise_marker_container_ = document.createElement('div');
  Object.assign(this.sunrise_marker_container_.style, {
    'position': 'absolute',
    'top': '-10px',
    'display': 'none'
  });
  range_container.appendChild(this.sunrise_marker_container_);
  this.sunrise_marker_icon_ = new beestat.component.icon('white_balance_sunny')
    .set_size(16)
    .set_color(beestat.style.color.yellow.base)
    .render($(this.sunrise_marker_container_));

  this.sunset_marker_container_ = document.createElement('div');
  Object.assign(this.sunset_marker_container_.style, {
    'position': 'absolute',
    'top': '-10px',
    'display': 'none'
  });
  range_container.appendChild(this.sunset_marker_container_);
  this.sunset_marker_icon_ = new beestat.component.icon('moon_waning_crescent')
    .set_size(16)
    .set_color(beestat.style.color.purple.base)
    .render($(this.sunset_marker_container_));

  // Range input
  range
    .set_min(0)
    .set_max(287)
    .set_value(((self.date_m_.hours() * 60) + self.date_m_.minutes()) / 1440 * 288)
    .render($(range_container));

  time_container.innerText = self.date_m_.format('h:mm a');
  Object.assign(time_container.style, {
    'margin-top': '-8px',
    'text-align': 'right'
  });
  this.controls_container_.appendChild(time_container);

  this.approximate_sun_notice_container_ = document.createElement('div');
  Object.assign(this.approximate_sun_notice_container_.style, {
    'font-size': beestat.style.font_size.small,
    'margin-top': '-16px',
    'margin-left': `${(beestat.style.size.gutter * 2) + 4}px`,
    // 'color': 'rgba(255,255,255,0.9)'
  });
  this.controls_container_.appendChild(this.approximate_sun_notice_container_);

  range.addEventListener('input', function() {
    play_tile.set_icon('play');
    window.clearInterval(self.interval_);
    delete self.interval_;

    const minute_of_day = range.get_value() * 5;
    self.date_m_.hours(Math.floor(minute_of_day / 60));
    self.date_m_.minutes(Math.floor(minute_of_day % 60));
    time_container.innerText = self.date_m_.format('h:mm a');
    self.scene_.set_date(self.date_m_);
  });

  this.update_sunrise_sunset_markers_();
  this.update_approximate_sun_notice_();
};

/**
 * Get the location used for solar calculations.
 *
 * @return {{latitude: number, longitude: number, approximate: boolean}}
 */
beestat.component.card.three_d.prototype.get_scene_location_ = function() {
  const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
  if (
    floor_plan !== undefined &&
    floor_plan.address_id !== undefined &&
    floor_plan.address_id !== null
  ) {
    const address = beestat.cache.address[floor_plan.address_id];
    if (
      address !== undefined &&
      address.normalized !== undefined &&
      address.normalized !== null &&
      address.normalized.metadata !== undefined &&
      address.normalized.metadata !== null &&
      Number.isFinite(Number(address.normalized.metadata.latitude)) === true &&
      Number.isFinite(Number(address.normalized.metadata.longitude)) === true
    ) {
      return {
        'latitude': Number(address.normalized.metadata.latitude),
        'longitude': Number(address.normalized.metadata.longitude),
        'approximate': false
      };
    }
  }

  return {
    'latitude': beestat.component.card.three_d.fallback_latitude,
    'longitude': beestat.component.card.three_d.fallback_longitude,
    'approximate': true
  };
};

/**
 * Update fallback sun-position notice visibility/content.
 */
beestat.component.card.three_d.prototype.update_approximate_sun_notice_ = function() {
  if (this.approximate_sun_notice_container_ === undefined) {
    return;
  }

  if (this.get_scene_location_().approximate === true) {
    this.approximate_sun_notice_container_.style.display = 'block';
    this.approximate_sun_notice_container_.innerText = 'No address on floor plan; sun position approximated.';
  } else {
    this.approximate_sun_notice_container_.style.display = 'none';
    this.approximate_sun_notice_container_.innerText = '';
  }
};

/**
 * Reposition and retitle sunrise/sunset markers for the current date.
 */
beestat.component.card.three_d.prototype.update_sunrise_sunset_markers_ = function() {
  if (
    this.range_container_ === undefined ||
    this.sunrise_marker_container_ === undefined ||
    this.sunset_marker_container_ === undefined
  ) {
    return;
  }

  const scene_location = this.get_scene_location_();
  const times = SunCalc.getTimes(
    this.date_m_.toDate(),
    scene_location.latitude,
    scene_location.longitude
  );

  const sunrise_m = moment(times.sunrise);
  const sunrise_percentage = ((sunrise_m.hours() * 60) + sunrise_m.minutes()) / 1440 * 100;
  this.sunrise_marker_container_.style.left = `${sunrise_percentage}%`;
  this.sunrise_marker_container_.style.display = 'block';
  this.sunrise_marker_container_.setAttribute(
    'title',
    (scene_location.approximate === true ? 'Approximate sunrise @ ' : 'Sunrise @ ') + sunrise_m.format('h:mm')
  );

  const sunset_m = moment(times.sunset);
  const sunset_percentage = ((sunset_m.hours() * 60) + sunset_m.minutes()) / 1440 * 100;
  this.sunset_marker_container_.style.left = `${sunset_percentage}%`;
  this.sunset_marker_container_.style.display = 'block';
  this.sunset_marker_container_.setAttribute(
    'title',
    (scene_location.approximate === true ? 'Approximate sunset @ ' : 'Sunset @ ') + sunset_m.format('h:mm')
  );
};

/**
 * Get a CSS linear gradient style that represents the runtime chart.
 *
 * @param {array} thermostat_ids Which thermostat_ids to include data from.
 *
 * @return {string}
 */
beestat.component.card.three_d.prototype.get_chart_gradient_ = function(thermostat_ids) {
  const data = this.get_data_();

  const background_color_rgb = beestat.style.hex_to_rgb(beestat.style.color.bluegray.base);
  const no_data_color_rgb = beestat.style.hex_to_rgb(beestat.style.color.bluegray.dark);
  const background_color = 'rgba(' + background_color_rgb.r + ',' + background_color_rgb.g + ',' + background_color_rgb.b + ',1)';
  const no_data_color = 'rgba(' + no_data_color_rgb.r + ',' + no_data_color_rgb.g + ',' + no_data_color_rgb.b + ',1)';

  let current_color = background_color;
  const gradient = [
    {
      'color': current_color,
      'position': 0
    }
  ];

  const last_data_m = this.get_most_recent_time_with_data_();

  const date_m = moment();
  for (let i = 0; i < 287; i++) {
    const minute_of_day = i * 5;
    date_m.hours(Math.floor(minute_of_day / 60));
    date_m.minutes(Math.floor(minute_of_day % 60));

    let this_color = background_color;

    if (last_data_m === null || date_m.isAfter(last_data_m) === false) {
      const time = date_m.format('HH:mm');

      let red = 0;
      let green = 0;
      let blue = 0;
      let alpha = 0;
      let count = 0;

      [
        'fan',
        'compressor_heat_1',
        'compressor_heat_2',
        'auxiliary_heat_1',
        'auxiliary_heat_2',
        'compressor_cool_1',
        'compressor_cool_2'
      ].forEach(function(series_code) {
        thermostat_ids.forEach(function(thermostat_id) {
          if (
            data.series[series_code][thermostat_id] !== undefined &&
            data.series[series_code][thermostat_id][time] !== undefined &&
            data.series[series_code][thermostat_id][time] > 0
          ) {
            // Only resets these if there is data to override it.
            if (count > 0) {
              red = 0;
              green = 0;
              blue = 0;
              alpha = 0;
              count = 0;
            }

            const color = beestat.style.hex_to_rgb(beestat.series[series_code].color);
            red += color.r;
            green += color.g;
            blue += color.b;
            alpha += data.series[series_code][thermostat_id][time] / thermostat_ids.length;
            count++;
          }
        });

        let rgb;
        if (count > 0) {
          rgb = {
            'r': red / count,
            'g': green / count,
            'b': blue / count
          };
          alpha /= count;
          this_color = 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' + alpha + ')';
        } else {
          this_color = background_color;
        }
      });
    } else {
      this_color = no_data_color;
    }

    if (this_color !== current_color) {
      gradient.push({
        'color': this_color,
        'position': Math.round(i / 288 * 100 * 10) / 10
      });
      current_color = this_color;
    }
  }

  let gradient_string = ['90deg'];

  for (let i = 0; i < gradient.length; i++) {
    const start = gradient[i].position + '%';
    const end = gradient[i + 1] !== undefined ? gradient[i + 1].position + '%' : '100%';
    gradient_string.push(gradient[i].color + ' ' + start + ' ' + end);
  }

  return 'linear-gradient(' + gradient_string.join(', ') + ')';
};

/**
 * Watermark.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_watermark_ = function(parent) {
  const img = document.createElement('img');
  img.setAttribute('src', 'img/logo.png');
  Object.assign(img.style, {
    'height': '100%',
    'opacity': '0.7'
  });
  parent.appendChild(img);
};

/**
 * FPS ticker.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_fps_ticker_ = function(parent) {
  const self = this;
  this.fps_container_ = parent;

  const set_text = function() {
    const fps = (
      self.scene_ !== undefined &&
      typeof self.scene_.get_fps === 'function'
    )
      ? self.scene_.get_fps()
      : 0;
    self.fps_container_.innerText = fps + ' FPS';
  };

  set_text();
  window.clearInterval(this.fps_interval_);
  this.fps_interval_ = window.setInterval(set_text, 250);
};

/**
 * Show FPS only while scene settings are open.
 */
beestat.component.card.three_d.prototype.update_fps_visibility_ = function() {
  if (this.fps_container_ === undefined) {
    return;
  }

  const show = (
    this.get_show_environment_() === true &&
    this.scene_settings_menu_open_ === true
  );
  this.fps_container_.style.display = show ? 'block' : 'none';
};

/**
 * Toolbar.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_toolbar_ = function(parent) {
  const self = this;
  if (parent !== undefined) {
    this.toolbar_container_ = parent;
  }
  this.toolbar_container_.innerHTML = '';

  // Build the primary toolbar tile group.
  const tile_group = new beestat.component.tile_group();

  // Add floor
  tile_group.add_tile(new beestat.component.tile()
    .set_icon('layers')
    .set_shadow(false)
    .set_text_color(beestat.style.color.lightblue.base)
  );

  const show_environment = this.get_show_environment_();

  // View mode toggle (floor plan vs environment).
  // Toggle between environment view and floor plan view.
  if (beestat.user.has_early_access() === true) {
    const view_toggle_tile = new beestat.component.tile()
      .set_icon(show_environment === false ? 'floor_plan' : 'home')
      .set_title('Toggle View')
      .set_text_color(beestat.style.color.gray.light)
      .set_background_color(beestat.style.color.bluegray.base)
      .set_background_hover_color(beestat.style.color.bluegray.light)
      .addEventListener('click', function(e) {
        e.stopPropagation();
        const new_value = !self.get_show_environment_();
        self.set_show_environment_(new_value);

        this.set_icon(new_value ? 'home' : 'floor_plan');
        self.apply_layer_visibility_();
      });

    tile_group.add_tile(view_toggle_tile);
  }

  // Auto-rotate
  tile_group.add_tile(new beestat.component.tile()
    .set_icon(this.get_auto_rotate_() === false ? 'restart_off' : 'restart')
    .set_title('Toggle Auto-Rotate')
    .set_text_color(beestat.style.color.gray.light)
    .set_background_color(beestat.style.color.bluegray.base)
    .set_background_hover_color(beestat.style.color.bluegray.light)
    .addEventListener('click', function(e) {
      e.stopPropagation();
      const next_auto_rotate = self.get_auto_rotate_() !== true;
      self.set_auto_rotate_(next_auto_rotate);
      this.set_icon(
        'restart' + (next_auto_rotate === true ? '' : '_off')
      );
      self.scene_.set_auto_rotate(next_auto_rotate);
    })
  );

  // Weather controls (environment view only)
  if (show_environment === true) {
    const selected_mode = this.get_weather_();

    tile_group.add_tile(new beestat.component.tile()
      .set_icon(this.get_weather_icon_from_mode_(selected_mode))
      .set_title('Weather')
      .set_text_color(beestat.style.color.gray.light)
      .set_background_color(this.weather_menu_open_ === true ? beestat.style.color.lightblue.base : beestat.style.color.bluegray.base)
      .set_background_hover_color(this.weather_menu_open_ === true ? beestat.style.color.lightblue.light : beestat.style.color.bluegray.light)
      .addEventListener('click', function(e) {
        e.stopPropagation();
        self.weather_menu_open_ = self.weather_menu_open_ !== true;
        self.decorate_toolbar_();
      })
    );
  }

  if (show_environment === true) {
    tile_group.add_tile(new beestat.component.tile()
      .set_icon('tune')
      .set_title('Scene Settings')
      .set_text_color(beestat.style.color.gray.light)
      .set_background_color(this.scene_settings_menu_open_ === true ? beestat.style.color.lightblue.base : beestat.style.color.bluegray.base)
      .set_background_hover_color(this.scene_settings_menu_open_ === true ? beestat.style.color.lightblue.light : beestat.style.color.bluegray.light)
      .addEventListener('click', function(e) {
        e.stopPropagation();
        self.scene_settings_menu_open_ = self.scene_settings_menu_open_ !== true;
        self.decorate_toolbar_();
        self.decorate_scene_settings_panel_();
        self.update_fps_visibility_();
      })
    );
  }

  // Labels (hidden while environment view is on)
  if (show_environment === false) {
    tile_group.add_tile(new beestat.component.tile()
      .set_icon(this.get_show_labels_() === false ? 'label_off' : 'label')
      .set_title('Toggle Labels')
      .set_text_color(beestat.style.color.gray.light)
      .set_background_color(beestat.style.color.bluegray.base)
      .set_background_hover_color(beestat.style.color.bluegray.light)
      .addEventListener('click', function(e) {
        e.stopPropagation();
        const next_show_labels = self.get_show_labels_() !== true;
        self.set_show_labels_(next_show_labels);
        this.set_icon(
          'label' + (next_show_labels === true ? '' : '_off')
        );
        self.scene_.set_labels(next_show_labels);
      })
    );
  }

  tile_group.render($(this.toolbar_container_));

  // Render weather quick-select popup anchored to the Weather tile.
  if (show_environment === true && this.weather_menu_open_ === true) {
    const weather_tile_element = this.toolbar_container_.querySelector('[title=\"Weather\"]');
    if (weather_tile_element !== null) {
      const toolbar_rect = this.toolbar_container_.getBoundingClientRect();
      const weather_tile_rect = weather_tile_element.getBoundingClientRect();
      const selected_mode = this.get_weather_();
      const weather_modes = this.get_weather_mode_tiles_();

      const popup = document.createElement('div');
      Object.assign(popup.style, {
        'position': 'absolute',
        'left': `${Math.round(weather_tile_rect.right - toolbar_rect.left + 6)}px`,
        'top': `${Math.round(weather_tile_rect.top - toolbar_rect.top - 2)}px`,
        'display': 'flex',
        'flex-direction': 'row',
        'align-items': 'center',
        'grid-gap': '4px',
        'padding': '2px'
      });
      this.toolbar_container_.appendChild(popup);

      weather_modes.forEach((mode) => {
        const is_selected = mode.value === selected_mode;
        const tile = new beestat.component.tile()
          .set_icon(mode.icon)
          .set_title(mode.title)
          .set_text_color(is_selected ? beestat.style.color.gray.dark : beestat.style.color.gray.light)
          .set_background_color(is_selected ? beestat.style.color.bluegray.light : beestat.style.color.bluegray.base)
          .set_background_hover_color(is_selected ? beestat.style.color.bluegray.light : beestat.style.color.bluegray.light);

        if (is_selected === false) {
          tile.addEventListener('click', (e) => {
            e.stopPropagation();
            this.set_weather_(mode.value);
            this.apply_weather_setting_to_scene_(true);
            this.weather_menu_open_ = false;
            this.decorate_toolbar_();
          });
        }

        tile.render($(popup));
      });

      const auto_selected = selected_mode === 'auto';
      const auto_tile = new beestat.component.tile()
        .set_text('Auto')
        .set_title('Weather: Auto')
        .set_text_color(auto_selected ? beestat.style.color.gray.dark : beestat.style.color.gray.light)
        .set_background_color(auto_selected ? beestat.style.color.bluegray.light : beestat.style.color.bluegray.base)
        .set_background_hover_color(beestat.style.color.bluegray.light);

      if (auto_selected === false) {
        auto_tile.addEventListener('click', (e) => {
          e.stopPropagation();
          this.set_weather_('auto');
          this.apply_weather_setting_to_scene_(true);
          this.weather_menu_open_ = false;
          this.decorate_toolbar_();
        });
      }

      auto_tile.render($(popup));
    }
  }
};

/**
 * Floors.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_floors_ = function(parent) {
  const self = this;

  const tile_group = new beestat.component.tile_group();

  const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];

  const sorted_groups = Object.values(floor_plan.data.groups)
    .sort(function(a, b) {
      return (a.elevation || 0) - (b.elevation || 0);
    });

  let icon_number = 1;
  sorted_groups.forEach(function(group) {
    const button = new beestat.component.tile()
      .set_title(group.name)
      .set_shadow(false)
      .set_text_hover_color(beestat.style.color.lightblue.light)
      .set_text_color(beestat.style.color.lightblue.base);

    let icon;
    if (group.elevation < 0) {
      icon = 'alpha_b';
    } else {
      icon = 'numeric_' + icon_number++;
    }

    button
      .set_icon(icon + (self.get_show_group_(group.group_id) === false ? '' : '_box'))
      .addEventListener('click', function() {
        const next_visible = self.get_show_group_(group.group_id) !== true;
        self.set_show_group_(group.group_id, next_visible);
        self.scene_.set_layer_visible(group.group_id, next_visible);
        this.set_icon(
          icon + (next_visible === true ? '_box' : '')
        );
      });

    tile_group.add_tile(button);
  });

  tile_group.render($(parent));
};

/**
 * Legend.
 *
 * @param {HTMLDivElement} parent
 */
beestat.component.card.three_d.prototype.decorate_legend_ = function(parent) {
  if (parent !== undefined) {
    this.legend_container_ = parent;
  }

  // Hide runtime gradient legend in environment view.
  if (this.get_show_environment_() === true) {
    this.legend_container_.innerHTML = '';
    this.legend_container_.style.display = 'none';
    return;
  }

  this.legend_container_.style.display = 'block';
  this.legend_container_.innerHTML = '';

  const gradient = this.get_gradient_();
  const gradient_parts = [
    beestat.style.rgb_to_hex(gradient[0]) + ' 0%',
    beestat.style.rgb_to_hex(gradient[Math.round(gradient.length / 2)]) + ' 50%',
    beestat.style.rgb_to_hex(gradient[gradient.length - 1]) + ' 100%'
  ];

  const gradient_container = document.createElement('div');
  Object.assign(gradient_container.style, {
    'background': 'linear-gradient(0deg, ' + gradient_parts.join(', ') + ')',
    'height': '100%',
    'width': '6px',
    'border-radius': '6px',
    'position': 'relative'
  });
  this.legend_container_.appendChild(gradient_container);

  let units;
  let min = this.get_heat_map_min_();
  let max = this.get_heat_map_max_();

  if (
    min !== Infinity &&
    max !== -Infinity
  ) {
    if (beestat.setting('visualize.data_type') === 'temperature') {
      min = beestat.temperature(min);
      max = beestat.temperature(max);
      units = beestat.setting('units.temperature');
    } else {
      units = '%';
    }

    const min_container = document.createElement('div');
    Object.assign(min_container.style, {
      'position': 'absolute',
      'bottom': '0',
      'right': '10px',
      'font-size': beestat.style.font_size.small
    });
    min_container.innerText = min + units;
    gradient_container.appendChild(min_container);

    const max_container = document.createElement('div');
    Object.assign(max_container.style, {
      'position': 'absolute',
      'top': '0',
      'right': '10px',
      'font-size': beestat.style.font_size.small
    });
    max_container.innerText = max + units;
    gradient_container.appendChild(max_container);
  }
};

/**
 * Get data. This doesn't directly or indirectly make any API calls, but it
 * caches the data so it doesn't have to loop over everything more than once.
 *
 * @return {object} The data.
 */
beestat.component.card.three_d.prototype.get_data_ = function() {
  const self = this;
  if (this.data_ === undefined) {
    // Initialize sensor/thermostat inclusion maps and output containers.
    const sensor_ids_map = beestat.floor_plan.get_sensor_ids_map(this.floor_plan_id_);
    const thermostat_ids_map = beestat.floor_plan.get_thermostat_ids_map(this.floor_plan_id_);

    this.data_ = {
      'metadata': {
        'series': {
          'temperature': {
            'min': Infinity,
            'max': -Infinity
          },
          'occupancy': {
            'min': Infinity,
            'max': -Infinity
          },
          'fan': {
            'min': Infinity,
            'max': -Infinity
          },
          'compressor_heat_1': {
            'min': Infinity,
            'max': -Infinity
          },
          'compressor_heat_2': {
            'min': Infinity,
            'max': -Infinity
          },
          'auxiliary_heat_1': {
            'min': Infinity,
            'max': -Infinity
          },
          'auxiliary_heat_2': {
            'min': Infinity,
            'max': -Infinity
          },
          'compressor_cool_1': {
            'min': Infinity,
            'max': -Infinity
          },
          'compressor_cool_2': {
            'min': Infinity,
            'max': -Infinity
          }
        }
      },
      'series': {
        'temperature': {},
        'occupancy': {},
        'fan': {},
        'compressor_heat_1': {},
        'compressor_heat_2': {},
        'auxiliary_heat_1': {},
        'auxiliary_heat_2': {},
        'compressor_cool_1': {},
        'compressor_cool_2': {}
      }
    };

    // Fold raw sensor runtime rows into per-sensor/per-minute series buckets.
    if (beestat.cache.data.three_d__runtime_sensor !== undefined) {
      // Add to data
      beestat.cache.data.three_d__runtime_sensor.forEach(function(runtime_sensor) {
        if (sensor_ids_map[runtime_sensor.sensor_id] !== undefined) {
          const timestamp_m = moment(runtime_sensor.timestamp);
          const time = timestamp_m.format('HH:mm');

          // Temperature
          if (runtime_sensor.temperature !== null) {
            if (self.data_.series.temperature[runtime_sensor.sensor_id] === undefined) {
              self.data_.series.temperature[runtime_sensor.sensor_id] = {};
            }
            if (self.data_.series.temperature[runtime_sensor.sensor_id][time] === undefined) {
              self.data_.series.temperature[runtime_sensor.sensor_id][time] = [];
            }
            self.data_.series.temperature[runtime_sensor.sensor_id][time].push(runtime_sensor.temperature);
          }

          // Occupancy
          if (runtime_sensor.occupancy !== null) {
            if (self.data_.series.occupancy[runtime_sensor.sensor_id] === undefined) {
              self.data_.series.occupancy[runtime_sensor.sensor_id] = {};
            }
            if (self.data_.series.occupancy[runtime_sensor.sensor_id][time] === undefined) {
              self.data_.series.occupancy[runtime_sensor.sensor_id][time] = [];
            }
            self.data_.series.occupancy[runtime_sensor.sensor_id][time].push(runtime_sensor.occupancy === true ? 100 : 0);
          }
        }
      });
    }

    // Fold thermostat runtime rows into per-thermostat/per-minute equipment series.
    if (beestat.cache.data.three_d__runtime_thermostat !== undefined) {
      // Add to data
      beestat.cache.data.three_d__runtime_thermostat.forEach(function(runtime_thermostat) {
        if (thermostat_ids_map[runtime_thermostat.thermostat_id] !== undefined) {
          const timestamp_m = moment(runtime_thermostat.timestamp);
          const time = timestamp_m.format('HH:mm');
          [
            'compressor_heat_1',
            'compressor_heat_2',
            'compressor_cool_1',
            'compressor_cool_2'
          ].forEach(function(series_key) {
            const runtime_key = series_key.replace(
              /compressor_(?:heat|cool)/,
              'compressor'
            );
            if (
              runtime_thermostat[runtime_key] !== null
            ) {
              if (self.data_.series[series_key][runtime_thermostat.thermostat_id] === undefined) {
                self.data_.series[series_key][runtime_thermostat.thermostat_id] = {};
              }
              if (self.data_.series[series_key][runtime_thermostat.thermostat_id][time] === undefined) {
                self.data_.series[series_key][runtime_thermostat.thermostat_id][time] = [];
              }
              self.data_.series[series_key][runtime_thermostat.thermostat_id][time].push(
                (
                  runtime_thermostat[runtime_key] > 0 &&
                  runtime_thermostat.compressor_mode === series_key.substring(11, 15)
                ) ? 1 : 0
              );
            }
          });

          [
            'auxiliary_heat_1',
            'auxiliary_heat_2',
            'fan'
          ].forEach(function(key) {
            if (runtime_thermostat[key] !== null) {
              if (self.data_.series[key][runtime_thermostat.thermostat_id] === undefined) {
                self.data_.series[key][runtime_thermostat.thermostat_id] = {};
              }
              if (self.data_.series[key][runtime_thermostat.thermostat_id][time] === undefined) {
                self.data_.series[key][runtime_thermostat.thermostat_id][time] = [];
              }
              self.data_.series[key][runtime_thermostat.thermostat_id][time].push(runtime_thermostat[key] > 0 ? 1 : 0);
            }
          });
        }
      });
    }

    // Average each minute bucket and update global min/max metadata.
    for (let key in this.data_.series) {
      for (let sensor_id in this.data_.series[key]) {
        for (let time in this.data_.series[key][sensor_id]) {
          this.data_.series[key][sensor_id][time] = this.data_.series[key][sensor_id][time].reduce(function(a, b) {
            return a + b;
          }) / this.data_.series[key][sensor_id][time].length;

          // Set min/max
          this.data_.metadata.series[key].min = Math.min(
            this.data_.series[key][sensor_id][time],
            this.data_.metadata.series[key].min
          );
          this.data_.metadata.series[key].max = Math.max(
            this.data_.series[key][sensor_id][time],
            this.data_.metadata.series[key].max
          );
        }
      }
    }
  }

  return this.data_;
};

/**
 * Whether or not there is data to display on the chart.
 *
 * @return {boolean} Whether or not there is data to display on the chart.
 */
beestat.component.card.three_d.prototype.has_data_ = function() {
  const data = this.get_data_();
  for (let key in data.series) {
    for (let sensor_id in data.series[key]) {
      if (Object.keys(data.series[key][sensor_id]).length > 0) {
        return true;
      }
    }
  }

  return false;
};

/**
 * Update the scene with current settings. Anything that doesn't require
 * re-rendering can go here.
 */
beestat.component.card.three_d.prototype.update_scene_ = function() {
  this.scene_.set_data_type(beestat.setting('visualize.data_type'));
  this.scene_.set_gradient(this.get_gradient_());
  this.scene_.set_heat_map_min(this.get_heat_map_min_());
  this.scene_.set_heat_map_max(this.get_heat_map_max_());
};

/**
 * Update the HUD.
 */
beestat.component.card.three_d.prototype.update_hud_ = function() {
  this.decorate_legend_();
  this.decorate_controls_();
};

/**
 * Set the floor_plan_id.
 *
 * @param {number} floor_plan_id
 *
 * @return {beestat.component.card.three_d}
 */
beestat.component.card.three_d.prototype.set_floor_plan_id = function(floor_plan_id) {
  this.floor_plan_id_ = floor_plan_id;
  this.scene_settings_values_ = undefined;

  if (this.rendered_ === true) {
    this.rerender();
  }

  return this;
};

/**
 * Get the effective minimum heat map value based on the settings.
 *
 * @return {number}
 */
beestat.component.card.three_d.prototype.get_heat_map_min_ = function() {
  if (beestat.setting('visualize.heat_map_values') === 'dynamic') {
    return this.data_.metadata.series[beestat.setting('visualize.data_type')].min;
  }
  return beestat.setting(
    'visualize.heat_map_static.' +
    beestat.setting('visualize.data_type') +
    '.min'
  ) / (beestat.setting('visualize.data_type') === 'occupancy' ? 100 : 1);
};

/**
 * Get the effective maximum heat map value based on the settings.
 *
 * @return {number}
 */
beestat.component.card.three_d.prototype.get_heat_map_max_ = function() {
  if (beestat.setting('visualize.heat_map_values') === 'dynamic') {
    return this.data_.metadata.series[beestat.setting('visualize.data_type')].max;
  }
  return beestat.setting(
    'visualize.heat_map_static.' +
    beestat.setting('visualize.data_type') +
    '.max'
  ) / (beestat.setting('visualize.data_type') === 'occupancy' ? 100 : 1);
};

/**
 * Get the gradient based on the settings.
 *
 * @return {object}
 */
beestat.component.card.three_d.prototype.get_gradient_ = function() {
  if (beestat.setting('visualize.data_type') === 'temperature') {
    return beestat.style.generate_gradient(
      [
        beestat.style.hex_to_rgb(beestat.style.color.blue.dark),
        beestat.style.hex_to_rgb(beestat.style.color.gray.base),
        beestat.style.hex_to_rgb(beestat.style.color.red.dark)
      ],
      100
    );
  } else {
    return beestat.style.generate_gradient(
      [
        beestat.style.hex_to_rgb(beestat.style.color.gray.base),
        beestat.style.hex_to_rgb(beestat.style.color.orange.dark)
      ],
      200
    );
  }
};

/**
 * On initial draw, snap time-of-day to the latest available runtime minute
 * when it is close enough to now.
 *
 * @return {boolean} Whether date_m_ was updated.
 */
beestat.component.card.three_d.prototype.try_align_time_to_recent_data_ = function() {
  if (this.date_m_ === undefined) {
    return false;
  }

  const most_recent_data_m = this.get_most_recent_time_with_data_();
  if (most_recent_data_m === null) {
    return false;
  }

  const now = moment();
  const minutes_behind = now.diff(most_recent_data_m, 'minutes');
  if (minutes_behind < 0 || minutes_behind > 30) {
    return false;
  }

  this.date_m_
    .hour(most_recent_data_m.hour())
    .minute(most_recent_data_m.minute())
    .minute(Math.floor(this.date_m_.minute() / 5) * 5)
    .second(0);

  return true;
};

/**
 * Get the most recent data timestamp available for the active sensor set.
 *
 * @return {moment}
 */
beestat.component.card.three_d.prototype.get_most_recent_time_with_data_ = function() {
  const self = this;

  const sensor_ids = Object.keys(
    beestat.floor_plan.get_sensor_ids_map(this.floor_plan_id_)
  );
  if (sensor_ids.length > 0) {
    let keys = [];
    sensor_ids.forEach(function(sensor_id) {
      if (self.get_data_().series[beestat.setting('visualize.data_type')][sensor_id] !== undefined) {
        keys = keys.concat(Object.keys(
          self.get_data_().series[beestat.setting('visualize.data_type')][sensor_id]
        ));
      }
    });

    let hour;
    let minute;
    if (keys.length > 0) {
      keys.sort();
      const key_parts = keys[keys.length - 1].split(':');
      hour = key_parts[0];
      minute = key_parts[1];
    } else {
      hour = 0;
      minute = 0;
    }

    return moment()
      .hour(hour)
      .minute(minute)
      .second(0);
  }

  return null;
};

/**
 * Remove global listeners registered by this card instance.
 */
beestat.component.card.three_d.prototype.remove_global_listeners_ = function() {
  beestat.dispatcher.removeEventListener(
    'setting.visualize.data_type',
    this.handle_scene_settings_change_
  );
  beestat.dispatcher.removeEventListener(
    'setting.visualize.heat_map_values',
    this.handle_scene_settings_change_
  );
  beestat.dispatcher.removeEventListener(
    'setting.visualize.heat_map_static.temperature.min',
    this.handle_scene_settings_change_
  );
  beestat.dispatcher.removeEventListener(
    'setting.visualize.heat_map_static.temperature.max',
    this.handle_scene_settings_change_
  );
  beestat.dispatcher.removeEventListener(
    'setting.visualize.heat_map_static.occupancy.min',
    this.handle_scene_settings_change_
  );
  beestat.dispatcher.removeEventListener(
    'setting.visualize.heat_map_static.occupancy.max',
    this.handle_scene_settings_change_
  );
  beestat.dispatcher.removeEventListener(
    'cache.floor_plan',
    this.handle_floor_plan_cache_change_
  );
  beestat.dispatcher.removeEventListener(
    'cache.data.three_d__runtime_sensor',
    this.handle_runtime_data_change_
  );
  beestat.dispatcher.removeEventListener(
    'cache.data.three_d__runtime_thermostat',
    this.handle_runtime_data_change_
  );
  beestat.dispatcher.removeEventListener(
    'cache.thermostat',
    this.handle_thermostat_cache_change_
  );
  beestat.dispatcher.removeEventListener('resize.three_d');
};

/**
 * Shared teardown path for stale-instance disposal and normal disposal.
 */
beestat.component.card.three_d.prototype.teardown_ = function() {
  if (this.rerender_timeout_id_ !== undefined) {
    window.clearTimeout(this.rerender_timeout_id_);
    this.rerender_timeout_id_ = undefined;
    this.rerender_pending_delay_ms_ = undefined;
  }
  this.rerender_waiting_for_visibility_ = false;
  if (this.visibility_observer_ !== undefined) {
    this.visibility_observer_.disconnect();
    this.visibility_observer_ = undefined;
  }
  this.hide_loading_();
  window.clearInterval(this.fps_interval_);
  delete this.fps_interval_;
  this.remove_global_listeners_();

  if (this.scene_ !== undefined) {
    this.scene_.dispose();
    delete this.scene_;
  }
};

/**
 * Force teardown for stale card instances that were not formally disposed.
 */
beestat.component.card.three_d.prototype.force_dispose_stale_instance_ = function() {
  if (this.disposed_ === true) {
    return;
  }

  this.disposed_ = true;
  this.teardown_();
};

/**
 * Dispose.
 */
beestat.component.card.three_d.prototype.dispose = function() {
  this.disposed_ = true;
  this.teardown_();
  if (beestat.component.card.three_d.active_instance_ === this) {
    delete beestat.component.card.three_d.active_instance_;
  }

  beestat.component.card.prototype.dispose.apply(this, arguments);
};
