"use strict";
define([
"jquery",
"underscore",
"backbone",
"models/maps/Feature",
"text!templates/maps/feature-info/feature-info.html",
], ($, _, Backbone, Feature, Template) => {
/**
* @class FeatureInfoView
* @classdesc An info-box / panel that shows more details about a specific geo-spatial
* feature that is highlighted or in focus in a Map View, as specified by a given
* {@link Feature} model. The format and content of the info-box varies based on which
* template is configured in the parent {@link MapAsset} model, but at a minimum a link
* is included that opens the associated {@link LayerInfoView}. Unless otherwise
* configured, the title of the panel will use the value of the feature's 'name',
* 'title', 'id', 'identifier', or 'assetId' property, if it has one (case
* insensitive).
* @classcategory Views/Maps
* @name FeatureInfoView
* @augments Backbone.View
* @screenshot views/maps/FeatureInfoView.png
* @since 2.18.0
* @constructs
*/
const FeatureInfoView = Backbone.View.extend(
/** @lends FeatureInfoView.prototype */ {
/**
* The type of View this is
* @type {string}
*/
type: "FeatureInfoView",
/**
* The HTML classes to use for this view's element
* @type {string}
*/
className: "feature-info",
/**
* The model that this view uses
* @type {Feature}
*/
model: undefined,
/**
* The primary HTML template for this view
* @type {Underscore.template}
*/
template: _.template(Template),
/**
* A ContentTemplate object specifies a single template designed to render
* information about the Feature.
* @typedef {object} ContentTemplate
* @since 2.19.0
* @property {string} [name] - An identifier for this template.
* @property {string[]} [options] - The list of keys (option names) that are
* allowed for the given template. Only options with these keys will be passed to
* the underscore.js template, regardless of what is configured in the
* {@link MapConfig#FeatureTemplate}. When no options are specified, then the
* entire Feature model will be passed to the template as JSON.
* @property {string} template - The path to the HTML template. This will be used
* with require() to load the template as needed.
*/
/**
* The list of available templates that format information about the Feature. The
* last template in the list is the default template. It will be used when a
* matching template is not found or one is not specified.
* @type {ContentTemplate[]}
* @since 2.19.0
*/
contentTemplates: [
{
name: "story",
template: "text!templates/maps/feature-info/story.html",
options: [
"title",
"subtitle",
"description",
"thumbnail",
"url",
"urlText",
],
},
{
name: "table",
template: "text!templates/maps/feature-info/table.html",
},
],
/** @inheritdoc */
events() {
const events = {};
// Close the layer details panel when the toggle button is clicked. Get the
// class of the toggle button from the classes property set in this view.
events[`click .${this.classes.toggle}`] = "close";
// Open the Layer Details panel
events[`click .${this.classes.layerDetailsButton}`] =
"showLayerDetails";
events[`click .${this.classes.zoomButton}`] = "zoomToFeature";
return events;
},
/**
* Classes that are used to identify the HTML elements that comprise this view.
* @type {object}
* @property {string} open The class to add to the outermost HTML element for this
* view when the layer details view is open/expanded (not hidden)
* @property {string} toggle The element in the template that acts as a toggle to
* close/hide the info view
* @property {string} layerDetailsButton The layer details button is added to the
* view when the selected feature is associated with a layer (a MapAsset like a 3D
* tileset). When clicked, it opens the LayerDetailsView for that layer.
* @property {string} contentContainer The iframe that holds the content rendered
* by the {@link FeatureInfoView#ContentTemplate}
* @property {string} title The label/title at the very top of the Feature panel,
* next to the close button.
*/
classes: {
open: "feature-info--open",
toggle: "feature-info__toggle",
layerDetailsButton: "feature-info__layer-details-button",
zoomButton: "feature-info__zoom-button",
contentContainer: "feature-info__content",
title: "feature-info__label",
},
/**
* Whether or not the layer details view is open
* @type {boolean}
*/
isOpen: false,
/**
* Executed when a new FeatureInfoView is created
* @param {object} [options] - A literal object with options to pass to the view
*/
initialize(options) {
// Get all the options and apply them to this view
if (typeof options === "object") {
Object.entries(options).forEach(([key, value]) => {
this[key] = value;
});
}
},
/**
* Renders this view
* @returns {FeatureInfoView} Returns the rendered view element
*/
render() {
const view = this;
const { classes } = view;
// Show the feature info box as open if the view is set to have it open
// already
if (view.isOpen) {
view.el.classList.add(view.classes.open);
}
// Insert the principal template into the view
view.$el.html(
view.template({
classes,
}),
);
const iFrame = view.el.querySelector(`.${classes.contentContainer}`);
// Select the iFrame
const iFrameDoc = iFrame.contentWindow.document;
// Add a script that gets all of the CSS stylesheets from the parent and
// applies them within the iFrame. Create a div within the iFrame to hold the
// feature info template content.
iFrameDoc.open();
iFrameDoc.write(`
<div id="content"></div>
<script type="text/javascript">
window.onload = function() {
if (parent) {
var h = document.getElementsByTagName("head")[0];
var ss = parent.document.getElementsByTagName("style");
for (var i = 0; i < ss.length; i++)
h.appendChild(ss[i].cloneNode(true));
}
}
</script>
<style>
body {
background-color: transparent;
color: var(--portal-col-neutral-8, var(--map-col-text__deprecate));
font-family: var(--portal-body-font, "Helvetica Nueue", "Helvetica", "Arial", "Lato", "sans serif");
margin: 0;
box-sizing: border-box;
}
</style>
`);
iFrameDoc.close();
// Identify the elements from the template that will be updated when the
// Feature model changes
view.elements = {
title: view.el.querySelector(`.${classes.title}`),
iFrame,
iFrameContentContainer: iFrameDoc.getElementById("content"),
layerDetailsButton: view.el.querySelector(
`.${classes.layerDetailsButton}`,
),
zoomButton: view.el.querySelector(`.${classes.zoomButton}`),
};
view.update();
// Ensure the view's main element has the given class name
view.el.classList.add(view.className);
// When the model changes, update the view
view.stopListening(view.model, "change");
view.listenTo(view.model, "change", view.update);
return view;
},
/**
* Updates the view with information from the current Feature model
*/
updateContent() {
const view = this;
// Elements to update
const title = this.getFeatureTitle();
const { iFrame } = this.elements;
const iFrameDiv = this.elements.iFrameContentContainer;
const { layerDetailsButton } = this.elements;
const { zoomButton } = this.elements;
const mapAsset = this.model.get("mapAsset");
const mapAssetLabel = mapAsset ? mapAsset.get("label") : null;
const layerButtonDisplay = mapAsset ? null : "none";
const layerButtonText = `See ${mapAssetLabel} layer details`;
// The Cesium Map Widget can't zoom to Cesium3DTileFeatures, so for now, hide
// the 'zoom to feature' button
const zoomButtonDisplay =
!mapAsset || mapAsset.get("type") === "Cesium3DTileset"
? "none"
: null;
// Insert the title into the title element
this.elements.title.innerHTML = title;
// Update the iFrame content
this.getContent().then((html) => {
iFrameDiv.innerHTML = html;
iFrame.style.height = 0;
iFrame.style.opacity = 0;
// Not the ideal solution, but check the height of the iFrame
// again after some time to allow external content to load. This
// is necessary for content that loads asynchronously, like
// images. Difficult to set listeners for this, since the content
// may be from a different domain.
setTimeout(() => {
view.updateIFrameHeight();
}, 500);
});
// Show or hide the layer details button, update the text
layerDetailsButton.style.display = layerButtonDisplay;
layerDetailsButton.innerText = layerButtonText;
// Show or hide the zoom to feature button
zoomButton.style.display = zoomButtonDisplay;
},
/**
* Update the height of the iFrame to match the height of the content
* within it.
* @since 2.27.0
*/
updateIFrameHeight() {
const iFrame = this.elements?.iFrame;
// 336 includes the maximum height of the top bars, bottom padding, and other
// content of feature info like label on top and buttons at the bottom. This is
// only an estimate and a temporary approach. Eventually we should move away
// from iFrame so that css layout can figure this out without us doing math
// here.
const maxHeight = window.innerHeight - 336;
const height = Math.min(
maxHeight,
iFrame.contentWindow.document.getElementById("content").scrollHeight,
);
iFrame.style.height = `${height / 16}rem`;
iFrame.style.opacity = 1;
},
/**
* Get the inner HTML content to insert into the iFrame. The content will vary
* based on the feature and if there is a template set on the parent Map Asset
* model.
* @since 2.19.0
* @returns {Promise} Returns a promise that resolves to the content HTML
* when ready, otherwise null
*/
getContent() {
let content = null;
let templateOptions = this.model.toJSON();
const mapAsset = this.model.get("mapAsset");
const featureProperties = this.model.get("properties");
const templateConfig = mapAsset
? mapAsset.get("featureTemplate")
: null;
const propertyMap = templateConfig ? templateConfig.options : {};
const templateName = templateConfig ? templateConfig.template : null;
const { contentTemplates } = this;
// Given the name of a template configured in the MapAsset model, find the
// matching template from the contentTemplates set on this view
let contentTemplate = contentTemplates.find(
(template) => template.name === templateName,
);
if (!contentTemplate) {
contentTemplate = contentTemplates[contentTemplates.length - 1];
}
// To get variables to pass to the template, there must be properties set on
// the feature and the selected content template must accept options
if (
contentTemplate &&
contentTemplate.options &&
templateConfig &&
templateConfig.options
) {
templateOptions = {};
contentTemplate.options.forEach((prop) => {
const key = propertyMap[prop];
templateOptions[prop] = featureProperties[key] || "";
});
}
// Return a promise that resolves to the content HTML
return new Promise((resolve) => {
if (contentTemplate) {
// eslint-disable-next-line import/no-dynamic-require
require([contentTemplate.template], (template) => {
content = _.template(template)(templateOptions);
resolve(content);
});
} else {
resolve(null);
}
});
},
/**
* Create a title for the feature info box
* @since 2.19.0
* @returns {string} The title for the feature info box
*/
getFeatureTitle() {
let title = "";
let suffix = "";
if (this.model) {
// Get the layer/mapAsset model
const mapAsset = this.model.get("mapAsset");
const featureTemplate = mapAsset
? mapAsset.get("featureTemplate")
: null;
const properties = this.model.get("properties") ?? {};
const assetName = mapAsset ? mapAsset.get("label") : null;
let name = featureTemplate
? properties[featureTemplate.label]
: this.model.get("label");
// Build a title if the feature has no label. Check if the feature has a name,
// title, ID, or identifier property. Search for these properties independent
// of case. If none of these properties exist, use the feature ID provided by
// the model.
if (!name) {
title = "Feature";
let searchKeys = ["name", "title", "id", "identifier"];
searchKeys = searchKeys.map((key) => key.toLowerCase());
const propKeys = Object.keys(properties);
const propKeysLower = propKeys.map((key) => key.toLowerCase());
// Search by search key, since search keys are in order of preference. Find
// the first matching key.
const nameKeyLower = searchKeys.find((searchKey) =>
propKeysLower.includes(searchKey),
);
// Then figure out which of the original property keys matches (we need it
// in the original case).
const nameKey = propKeys[propKeysLower.indexOf(nameKeyLower)];
name = properties[nameKey] ?? this.model.get("featureID");
if (assetName) {
suffix = ` from ${assetName} Layer`;
}
}
if (name) {
title = `${title} ${name}`;
}
if (suffix) {
title += suffix;
}
}
// Do some basic sanitization of the title
title = title.replace(/&/g, "&");
title = title.replace(/</g, "<");
title = title.replace(/>/g, ">");
title = title.replace(/"/g, """);
title = title.replace(/'/g, "'");
return title;
},
/**
* Show details about the layer that contains this feature. The function does this
* by setting the associated layer model's 'selected' attribute to true. The
* parent Map view has a listener set to show the Layer Details view when this
* attribute is changed.
*/
showLayerDetails() {
if (this.model && this.model.get("mapAsset")) {
this.model.get("mapAsset").set("selected", true);
}
},
/**
* Trigger an event from the parent Map model that tells the Map Widget to
* zoom to the full extent of this feature in the map. Also make sure that the Map
* Asset layer is visible in the map.
*/
zoomToFeature() {
const { model } = this;
const mapAsset = model ? model.get("mapAsset") : false;
if (mapAsset) {
mapAsset.zoomTo(model);
}
},
/**
* Shows the feature info box
*/
open() {
this.el.classList.add(this.classes.open);
this.isOpen = true;
},
/**
* Hide the feature info box from view
*/
close() {
this.el.classList.remove(this.classes.open);
this.isOpen = false;
// When the feature info panel is closed, remove the Feature model from the
// Features collection. This will trigger the map widget to remove
// highlighting from the feature.
if (this.model && this.model.collection) {
this.model.collection.remove(this.model);
}
},
/**
* Update the content that's displayed in a feature info box, based on the
* information in the Feature model. Open the panel if there is a Feature model,
* or close it if there is no model or the model has only default values.
*/
update() {
if (!this.model || this.model.isDefault()) {
if (this.isOpen) {
this.close();
}
} else {
this.open();
this.updateContent();
}
},
/**
* Stops listening to the previously set model, replaces it with a new Feature
* model, re-sets the listeners and re-renders the content in this view based on
* the new model.
* @param {Feature} newModel The new Feature model to display content for
*/
changeModel(newModel) {
const view = this;
const currentModel = view.model;
const currentMapAsset = currentModel
? currentModel.get("mapAsset")
: null;
// Stop listening to the current Feature & Map Asset models before they're removed
view.stopListening(currentModel, "change");
if (currentMapAsset) {
view.stopListening(currentMapAsset, "change:visible");
}
// Update the model
view.model = newModel;
// Listen to the new model
view.stopListening(newModel, "change");
view.listenTo(newModel, "change", view.update);
// If the Map Asset layer is ever hidden, then de-select the Feature and close
// the view view
const newMapAsset = newModel ? newModel.get("mapAsset") : null;
if (newMapAsset) {
view.listenTo(newMapAsset, "change:visible", () => {
if (!newMapAsset.get("visible")) {
view.close();
}
});
}
// Update
view.update();
},
},
);
return FeatureInfoView;
});