diff --git a/docs/guides/maps/cesium.md b/docs/guides/maps/cesium.md index e078aae05..22914db52 100644 --- a/docs/guides/maps/cesium.md +++ b/docs/guides/maps/cesium.md @@ -216,6 +216,20 @@ Below is an example of a detailed map configuration (descriptions have been shor "label": "Some Test GeoJSON", "type": "GeoJsonDataSource", "description": "A single point specified using GeoJSON", + "customProperties": { + "year": { + "type": "date", + "property": "dateAndTime", + "format": "YYYY" + } + }, + "featureTemplate": { + "template": "story", + "label": "year", + "options": { + "description": "summary", + } + }, "cesiumOptions": { "data": { "type": "FeatureCollection", @@ -227,7 +241,9 @@ Below is an example of a detailed map configuration (descriptions have been shor "coordinates": [102.0, 0.5] }, "properties": { - "prop0": "value0" + "prop0": "value0", + "dateAndTime": "2007-03-01T13:00:00Z", + "summary": "This is an example point!" } } ] diff --git a/docs/screenshots/views/maps/FeatureInfoView.png b/docs/screenshots/views/maps/FeatureInfoView.png index c06892ba7..1e9e20ecf 100644 Binary files a/docs/screenshots/views/maps/FeatureInfoView.png and b/docs/screenshots/views/maps/FeatureInfoView.png differ diff --git a/src/components/dayjs.min.js b/src/components/dayjs.min.js new file mode 100644 index 000000000..d284eec4d --- /dev/null +++ b/src/components/dayjs.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs=e()}(this,(function(){"use strict";var t=1e3,e=6e4,n=36e5,r="millisecond",i="second",s="minute",u="hour",a="day",o="week",f="month",h="quarter",c="year",d="date",$="Invalid Date",l=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,y=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,M={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_")},m=function(t,e,n){var r=String(t);return!r||r.length>=e?t:""+Array(e+1-r.length).join(n)+t},g={s:m,z:function(t){var e=-t.utcOffset(),n=Math.abs(e),r=Math.floor(n/60),i=n%60;return(e<=0?"+":"-")+m(r,2,"0")+":"+m(i,2,"0")},m:function t(e,n){if(e.date() svg { +.layer-item__icon>svg { height: 100%; width: auto; } @@ -692,7 +695,11 @@ other class: .ui-slider-range */ * * Feature Info * - * The box that shows more information about a feature that a user clicks + * The box that shows more information about a feature that a user clicks. The content is + * formatted according to a configurable template. Available templates include: + * + * - table: A 2 column table that lists the key and value for each of the properties in a feature + * - story: Intended to display a user friendly summary of the data in the feature. Includes a title, sub-title, description, image and link. * */ @@ -701,12 +708,12 @@ other class: .ui-slider-range */ border-top-right-radius: var(--map-border-radius); border-top-left-radius: var(--map-border-radius); padding: 1rem 0.8rem 1rem 0.8rem; - row-gap: 1rem; + row-gap: 0.8rem; background-color: var(--map-col-bkg-lighter); color: var(--map-col-text); box-shadow: var(--map-shadow-md); grid-template-columns: auto 2.5rem; - grid-template-rows: min-content 1fr min-content; + grid-template-rows: min-content auto min-content; justify-items: center; /* Don't show the details panel unless it also has the feature-info--open class */ display: none; @@ -726,7 +733,6 @@ other class: .ui-slider-range */ font-weight: 500; font-size: 0.9rem; opacity: 60%; - padding-left: 2.5rem; } .feature-info__content { @@ -735,7 +741,7 @@ other class: .ui-slider-range */ grid-template-columns: 100%; gap: 1rem; grid-column: 1 / 3; - padding-bottom: 1rem; + padding-bottom: 0.5rem; overflow: scroll; } @@ -747,12 +753,19 @@ other class: .ui-slider-range */ justify-self: end; } +.feature-info__layer-details-button { + grid-column: 1 / 3; +} + + +/* styles for the (default) table template */ + .feature-info__table { /* TODO: use colour vars */ background-color: var(--map-col-bkg-lightest); border-radius: var(--map-border-radius); box-shadow: var(--map-shadow-md); - width: calc(var(--map-width-toolbar) - 4rem); + width: calc(var(--map-width-toolbar) - 2.2rem); } .feature-info__table-body {} @@ -771,13 +784,47 @@ other class: .ui-slider-range */ padding: 0.6rem; overflow-wrap: break-word; word-wrap: break-word; - max-width: calc( (var(--map-width-toolbar)/2) - 1rem ); + max-width: calc((var(--map-width-toolbar)/2) - 1rem); } -.feature-info__layer-details-button { - grid-column: 1 / 3; +/* styles for the story template */ + +.feature-info__thumbnail { + min-width: 6rem; + min-height: 6rem; + max-width: 50%; + float: right; + margin: 0.3rem; + border-radius: var(--map-border-radius); + box-shadow: var(--map-shadow-md); + +} + +.feature-info__subtitle { + font-size: 0.9em; + text-transform: uppercase; + display: block; + opacity: 0.85; + margin: 0; + padding: 0; +} + +.feature-info__description { + margin: 0.55rem 0; + font-size: 0.9rem; + line-height: 1.4rem; +} + +.feature-info__link { + text-transform: uppercase; + font-size: 0.9rem; + display: flex; + align-items: center; + font-weight: bold; + text-decoration: none; } + /***************************************************************************************** * * Map Legend @@ -786,17 +833,17 @@ other class: .ui-slider-range */ * */ -.map-legend--preview{ +.map-legend--preview { height: 1.2rem; width: 100% } -.map-legend__svg--preview{ +.map-legend__svg--preview { height: 100%; width: auto; } -.map-legend__img--preview{ +.map-legend__img--preview { --img-overflow: 0.8rem; /* allow images previews to bleed into the padding a little, so that more detail is visible */ height: calc(100% + var(--img-overflow)); @@ -806,5 +853,4 @@ other class: .ui-slider-range */ box-shadow: var(--map-shadow-md); /* imagery appears lighter on the map */ filter: brightness(1.75); -} - +} \ No newline at end of file diff --git a/src/js/models/maps/Feature.js b/src/js/models/maps/Feature.js index 2c81d2bb9..b70718121 100644 --- a/src/js/models/maps/Feature.js +++ b/src/js/models/maps/Feature.js @@ -46,7 +46,7 @@ define( * @property {*} featureObject The object that a Map widget uses to represent this * feature in the map. For example, in Cesium this could be a * Cesium.Cesium3DTileFeature or a Cesium.Entity. - * @property {string} label An optional friendly label for this feature. + * @property {string} label An optional friendly label or name for this feature. */ defaults: function () { return { diff --git a/src/js/models/maps/assets/Cesium3DTileset.js b/src/js/models/maps/assets/Cesium3DTileset.js index 6af184c75..36103484f 100644 --- a/src/js/models/maps/assets/Cesium3DTileset.js +++ b/src/js/models/maps/assets/Cesium3DTileset.js @@ -314,17 +314,17 @@ define( */ getPropertiesFromFeature(feature) { try { - const properties = {}; + let properties = {}; feature.getPropertyNames().forEach(function (propertyName) { properties[propertyName] = feature.getProperty(propertyName) }) + properties = this.addCustomProperties(properties) return properties } catch (error) { console.log( 'There was an error getting properties from a A Cesium 3D Tile feature' + - '. Error details: ' + error + - '. Returning an empty object.' + '. Error details: ' + error + '. Returning an empty object.' ); return {} } diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index bb3c24f4a..9380562d1 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -252,6 +252,7 @@ define( if (featureProps) { properties = feature.properties.getValue(new Date()) } + properties = this.addCustomProperties(properties) return properties } catch (error) { diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index 03f73cb1b..44005b368 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -6,14 +6,16 @@ define( 'underscore', 'backbone', 'models/portals/PortalImage', - 'models/maps/AssetColorPalette' + 'models/maps/AssetColorPalette', + MetacatUI.root + '/components/dayjs.min.js' ], function ( $, _, Backbone, PortalImage, - AssetColorPalette + AssetColorPalette, + dayjs ) { /** * @classdesc A MapAsset Model comprises information required to fetch source data for @@ -75,6 +77,13 @@ define( * attributes of this asset. This applies to raster/imagery and vector assets. For * imagery, the colorPalette will be used to create a legend. For vector assets * (e.g. 3Dtilesets), it will also be used to style the features. + * @property {MapConfig#FeatureTemplate} [featureTemplate] Configuration for + * content and layout of the Feature Info panel - the panel that shows information + * about a selected feature from a vector asset ({@link FeatureInfoView}). + * @property {MapConfig#CustomProperties} [customProperties] Configuration that + * allows for the definition of custom feature properties, potentially based on + * other properties. For example, a custom property could be a formatted version + * of an existing date property. * @property {'ready'|'error'|null} [status = null] Set to 'ready' when the * resource is loaded and ready to be rendered in a map view. Set to 'error' when * the asset is not supported, or there was a problem requesting the resource. @@ -95,8 +104,10 @@ define( opacity: 1, visible: true, colorPalette: null, + customProperties: {}, + featureTemplate: {}, status: null, - statusDetails: null, + statusDetails: null } }, @@ -144,10 +155,126 @@ define( * mapped to attributes of this asset. This applies to raster/imagery and vector * assets. For imagery, the colorPalette will be used to create a legend. For * vector assets (e.g. 3Dtilesets), it will also be used to style the features. + * @property {MapConfig#FeatureTemplate} [featureTemplate] Configuration for the + * content and layout of the Feature Info panel ({@link FeatureInfoView}) - the + * panel that shows information about a selected feature from a vector asset. If + * no feature template is set, then the default table layout is used. + * @property {MapConfig#CustomProperties} [customProperties] Definitions of custom + * properties of features, potentially based on existing properties. For example, + * a custom property could be a formatted version of another date property. These + * custom properties can be used in the filters, colorPalette, or featureTemplate. + * So far, custom strings and formatted dates are supported. Eventually, the + * custom properties may be expanded to support formatted numbers and booleans. * @property {MapConfig#VectorFilterConfig} [filters] - A set of conditions used * to show or hide specific features of this tileset. */ + /** + * A feature template configures the format and content of information displayed + * in the Feature Info panel ({@link FeatureInfoView}). The Feature Info panel is + * displayed in a map when a user clicks on a vector feature in a map. + * @typedef {Object} FeatureTemplate + * @name MapConfig#FeatureTemplate + * @since 2.x.x + * @property {'story'|'table'} [template='table'] The name/ID of the template to + * use. This must match the name of one of the templates available in + * {@link FeatureInfoView#contentTemplates}. + * @property {string} [label] Sets which of the feature properties to use as the + * title for the FeatureInfoView. The string must exactly match the key for a + * property that exists in the feature. + * @property {MapConfig#StoryTemplateOptions} [options] A list of key-value pairs + * that map the template variable to a property/attribute of the the feature. Keys + * are the template variable names and values are the names of properties in the + * feature. Template variable names are specific to each template. Currently only + * the 'story' template allows variables. These are specified in the + * {@link FeatureInfoView#contentTemplates}. + * @example + * // Use the "story" template, which shows a secondary title, image, description, + * // and link. + * { + * "template": "story", + * "label": "title", + * "options": { + * "subtitle": "formattedDate", + * "description": "summary", + * "thumbnail": "imageSrc", + * "url": "newsLink", + * "urlText": "newsTitle", + * } + * } + * @example + * // Use the default template (a table), but use the "forestName" attribute for + * // the FeatureInfo panel label + * { + * "label": "forestName" + * } + */ + + /** + * An object that maps template variable to feature properties for the "story" + * template. + * @typedef {Object} + * @name MapConfig#StoryTemplateOptions + * @since 2.x.x + * @property {string} subtitle The name of a feature property to use for a + * secondary title in the template + * @property {string} description The name of a feature property that contains a + * brief summary or description of the feature; displayed as a paragraph. + * @property {string} thumbnail The name of a feature property that contains a URL + * for an image. Displayed as a thumbnail next to the description. + * @property {string} url The name of a feature property with a URL to use to + * create a link (e.g. to learn more information about the given feature) + * @property {string} urlText The name of a feature property that has text to + * display for the url. Defaults to 'Read More' if none is set. + */ + + /** + * An object where the keys indicate the name/ID of the new custom property to + * create, and the values are an object that defines the new property. + * @typedef {Object.} CustomProperties + * @name MapConfig#CustomProperties + * @since 2.x.x + * @example + * { + * "year": { + * "type": "date", + * "property": "dateTime", + * "format": "YYYY", + * }, + * "urlText": { + * "type": "string", + * "value": "Click here to learn more about this feature" + * } + * } + */ + + /** + * An object that defines a formatted date to use as a property in a feature. Used + * in the {@link MapConfig#CustomProperties} object. + * @typedef {Object} CustomDateProperty + * @name MapConfig#CustomDateProperty + * @since 2.x.x + * @property {'date'} type Must be set to 'date' to indicate that this is a custom + * date property + * @property {string} property The name/ID of the existing date property to format + * @property {string} format A string that indicates the new format to use. + * Follows the syntax used by Day.JS, see + * {@link https://day.js.org/docs/en/display/format} + */ + + /** + * An object that defines a custom string to use as a property in a feature. Used + * in the {@link MapConfig#CustomProperties} object. + * @typedef {Object} CustomStringProperty + * @name MapConfig#CustomStringProperty + * @since 2.x.x + * @property {'string'} type Must be set to 'string' to indicate that this is a + * custom string property + * @property {string} value The new string to use. So far only static strings are + * available. In the future, templates that include other properties may be + * supported. + */ + /** * Executed when a new MapAsset model is created. * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the @@ -235,6 +362,160 @@ define( return map.get('selectedFeatures').containsFeature(feature) }, + /** + * Given a set of properties from a Feature from this Map Asset model, add any + * custom properties to the properties object and return it. + * @since 2.x.x + * @param {Object} properties A set of key-value pairs representing the existing + * properties of a feature from this asset. + * @returns {Object} The properties object with any custom properties added. + */ + addCustomProperties: function (properties) { + try { + + const model = this; + const customProperties = model.get('customProperties'); + const formattedProperties = {}; + + if (!customProperties || !Object.keys(customProperties).length) { + return properties + } + + if (!properties || typeof properties !== 'object') { + properties = {} + } + + if (customProperties) { + _.each(customProperties, function (config, key) { + let formattedValue = ''; + if (config.type === 'date') { + formattedValue = model.formatDateProperty(config, properties) + // TODO: support formatted numbers and booleans... + // } else if (config.type === 'number') { + // formattedValue = model.formatNumberProperty(config, properties) + // } else if (config.type === 'boolean') { + // formattedValue = model.formatBooleanProperty(config, properties) + } else { + formattedValue = model.formatStringProperty(config, properties) + } + formattedProperties[key] = formattedValue; + }); + } + // merge the properties with the formatted properties + return Object.assign(properties, formattedProperties); + } catch (error) { + console.log( + 'There was an error adding custom properties. Returning properties ' + + 'unchanged. Error details: ' + + error + ); + return properties + } + }, + + /** + * Given a definition for a new date property, and the properties that already + * exist on a specific feature, returns a new string with the formatted date. + * @since 2.x.x + * @param {MapConfig#CustomDateProperty} config - An object that defines the new + * date property to create + * @param {Object} properties key-value pairs representing existing properties in + * a Feature + * @returns {string} The value for the new date property, formatted as defined by + * config, for the given feature + */ + formatDateProperty: function (config, properties) { + try { + if (!properties) { + properties = {} + } + let formattedDate = '' + if (!config || !config.format) { + return formattedDate; + } + const value = properties[config.property]; + if (value) { + formattedDate = dayjs(value).format(config.format); + } + return formattedDate; + } + catch (error) { + console.log( + 'There was an error formatting a date for a Feature model' + + '. Error details: ' + error + ); + return ''; + } + }, + + /** + * For a given set of Feature properties and a definition for a new sting + * property, returns the value of the custom property. Note that since only static + * strings are supported so far, this function essentially just returns the value + * of config.value. This function exists to allow support of dynamic strings in + * the future (e.g. combining strings from existing properties) + * @since 2.x.x + * @param {MapConfig#CustomStringProperty} config The object the defines the new + * custom property + * @param {Object} properties key-value pairs representing existing properties in + * a Feature + * @returns {string} The new string for the given Feature property + */ + formatStringProperty: function (config, properties) { + try { + if (!properties) { + properties = {} + } + let formattedString = '' + if (!config || !config.value) { + return formattedString; + } + formattedString = config.value; + return formattedString; + } + catch (error) { + console.log( + 'There was an error formatting a string for a Feature model' + + '. Error details: ' + error + ); + return ''; + } + }, + + // formatNumberProperty: function (config, properties) { + // try { + // if (!properties) { + // properties = {} + // } + // let formattedNumber = '' + // // TODO... + // } + // catch (error) { + // console.log( + // 'There was an error formatting a number for a Feature model' + + // '. Error details: ' + error + // ); + // return ''; + // } + // }, + + // formatBooleanProperty: function (config, properties) { + // try { + // if (!properties) { + // properties = {} + // } + // let formattedBoolean = '' + // // TODO... + // } + // catch (error) { + // console.log( + // 'There was an error formatting a boolean for a Feature model' + + // '. Error details: ' + error + // ); + // return ''; + // } + // }, + /** * Sanitizes an SVG string and updates the model's 'icon' attribute the sanitized * string. Also sets the 'iconStatus' attribute to 'success'. diff --git a/src/js/templates/maps/feature-info.html b/src/js/templates/maps/feature-info.html deleted file mode 100644 index bb3181b2e..000000000 --- a/src/js/templates/maps/feature-info.html +++ /dev/null @@ -1,22 +0,0 @@ - -
<%= title %>
- - -
- <% if(properties) { %> - - - <% for (const [key, value] of Object.entries(properties)) { %> - - - - - <% } %> - -
<%= key %><%= value %>
- <% } %> -
- -<% if(showLayerDetailsButton) { %> - -<% } %> \ No newline at end of file diff --git a/src/js/templates/maps/feature-info/feature-info.html b/src/js/templates/maps/feature-info/feature-info.html new file mode 100644 index 000000000..ba2c5afba --- /dev/null +++ b/src/js/templates/maps/feature-info/feature-info.html @@ -0,0 +1,7 @@ + +
+ + + + + \ No newline at end of file diff --git a/src/js/templates/maps/feature-info/story.html b/src/js/templates/maps/feature-info/story.html new file mode 100644 index 000000000..1bc842ec9 --- /dev/null +++ b/src/js/templates/maps/feature-info/story.html @@ -0,0 +1,17 @@ +
+<% if(thumbnail) { %> + +<% } %> + +<% if(subtitle) { %> +
<%= subtitle %>
+<% } %> + +<% if(description) { %> +

<%= description %>

+<% } %> + +<% if(url) { %> + <%= urlText || "Read More" %> +<% } %> +
\ No newline at end of file diff --git a/src/js/templates/maps/feature-info/table.html b/src/js/templates/maps/feature-info/table.html new file mode 100644 index 000000000..22ae74f73 --- /dev/null +++ b/src/js/templates/maps/feature-info/table.html @@ -0,0 +1,12 @@ +<% if(properties) { %> + + + <% for (const [key, value] of Object.entries(properties)) { %> + + + + + <% } %> + +
<%= key %><%= value %>
+<% } %> \ No newline at end of file diff --git a/src/js/views/maps/FeatureInfoView.js b/src/js/views/maps/FeatureInfoView.js index daf4756d9..7a7dd16d2 100644 --- a/src/js/views/maps/FeatureInfoView.js +++ b/src/js/views/maps/FeatureInfoView.js @@ -7,7 +7,7 @@ define( 'underscore', 'backbone', 'models/maps/Feature', - 'text!templates/maps/feature-info.html' + 'text!templates/maps/feature-info/feature-info.html' ], function ( $, @@ -20,12 +20,13 @@ define( /** * @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. Details displayed include a - * table of attributes that are set on that feature, and a link to view more - * information about the Map Asset (e.g. 3D tileset) that contains the feature. The - * title of the panel will use the value of the feature's 'name', 'title', 'id', or - * 'identifier' property, if it has one (case insensitive). Otherwise, it will use the - * 'assetId' in the Feature model (an ID used by the map widget.) + * 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 * @extends Backbone.View @@ -59,6 +60,40 @@ define( */ template: _.template(Template), + /** + * A ContentTemplate object specifies a single template designed to render + * information about the Feature. + * @typedef {Object} ContentTemplate + * @since 2.x.x + * @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.x.x + */ + 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' + } + ], + /** * Creates an object that gives the events this view will listen to and the * associated function to call. Each entry in the object has the format 'event @@ -85,11 +120,17 @@ define( * @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' + layerDetailsButton: 'feature-info__layer-details-button', + contentContainer: 'feature-info__content', + title: 'feature-info__label' }, /** @@ -125,22 +166,75 @@ define( try { + const view = this + const classes = view.classes + // Show the feature info box as open if the view is set to have it open // already - if (this.isOpen) { - this.el.classList.add(this.classes.open); + if (view.isOpen) { + view.el.classList.add(view.classes.open); } - this.renderContent() + // Insert the principal template into the view + view.$el.html(view.template({ + classes: 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(` +
+ + + `); + 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: iFrame, + iFrameContentContainer: iFrameDoc.getElementById('content'), + layerDetailsButton: view.el.querySelector('.' + classes.layerDetailsButton), + } + + view.update(); // Ensure the view's main element has the given class name - this.el.classList.add(this.className); + view.el.classList.add(view.className); // When the model changes, update the view - this.stopListening(this.model, 'change') - this.listenTo(this.model, 'change', this.update) + view.stopListening(view.model, 'change') + view.listenTo(view.model, 'change', view.update) - return this + return view } catch (error) { @@ -152,38 +246,138 @@ define( }, /** - * Render or re-render the attributes table and layer details button. + * Updates the view with information from the current Feature model + */ + updateContent: function () { + + try { + + // Elements to update + const title = this.getFeatureTitle() + const iFrame = this.elements.iFrame + const iFrameDiv = this.elements.iFrameContentContainer + const layerDetailsButton = this.elements.layerDetailsButton + const mapAsset = this.model.get('mapAsset') + let mapAssetLabel = mapAsset ? mapAsset.get('label') : null + const buttonDisplay = mapAsset ? null : 'none' + const buttonText = 'See ' + mapAssetLabel + ' Layer Details' + + // Insert the title into the title element + this.elements.title.innerHTML = title + + // Update the iFrame content + iFrame.height = 0; + this.getContent().then(function (html) { + iFrameDiv.innerHTML = html; + const maxHeight = window.innerHeight - 275; + const scrollHeight = iFrame.contentWindow.document.body.scrollHeight + 5; + iFrame.height = scrollHeight > maxHeight ? maxHeight : scrollHeight; + }) + + // Show or hide the layer details button, update the text + layerDetailsButton.style.display = buttonDisplay + layerDetailsButton.innerText = buttonText + + } + catch (error) { + console.log( + 'There was an error rendering the content of a FeatureInfoView' + + '. Error details: ' + error + ); + } + }, + + /** + * 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.x.x + * @returns {Promise|null} Returns a promise that resolves to the content HTML + * when ready, otherwise null */ - renderContent: function () { + getContent: function () { try { - const classes = this.classes; - let title = 'Feature'; - let properties = null; - let showLayerDetailsButton = false; + + 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.contentTemplates; + + // 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(function (prop) { + const key = propertyMap[prop] + templateOptions[prop] = featureProperties[key] || '' + }) + } + + // Return a promise that resolves to the content HTML + return new Promise(function (resolve, reject) { + if (contentTemplate) { + require([contentTemplate.template], function (template) { + content = _.template(template)(templateOptions); + resolve(content); + }) + } else { + resolve(null); + } + }) + + } + catch (error) { + console.log( + 'There was an error getting the content of a FeatureInfoView' + + '. Error details: ' + error + ); + } + }, + + /** + * Create a title for the feature info box + * @since 2.x.x + * @returns {string} The title for the feature info box + */ + getFeatureTitle: function () { + try { + let title = ''; + let suffix = ''; if (this.model) { // Get the layer/mapAsset model const mapAsset = this.model.get('mapAsset') - // Get the properties to show in the table - properties = this.model.get('properties') ?? {} - // Show a link to open the details for the feature's parent layer - if (mapAsset) { - showLayerDetailsButton = true; - } + 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'); - // Create a title for the feature info box - let label = mapAsset ? mapAsset.get('label') : null; + // 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) { - // If the feature has a label, use that in the title - let name = this.model.get('label') + title = 'Feature'; - // If no feature 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) { - let searchKeys = ['name', 'title', 'id', 'identifier'] searchKeys = searchKeys.map(key => key.toLowerCase()); const propKeys = Object.keys(properties) @@ -200,32 +394,36 @@ define( 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 (label) { - title = title + ' from ' + label + ' Layer' + if (suffix) { + title = title + suffix } } - // Insert the template into the view - this.$el.html(this.template({ - classes: classes, - title: title, - properties: properties, - showLayerDetailsButton: showLayerDetailsButton - })); + // Do some basic sanitization of the title + title = title.replace(/&/g, '&') + title = title.replace(//g, '>') + title = title.replace(/"/g, '"') + title = title.replace(/'/g, ''') + + return title } catch (error) { console.log( - 'There was an error rendering the content of a FeatureInfoView' + + 'There was an error making a title for the FeatureInfoView' + '. Error details: ' + error ); + return 'Feature' } }, @@ -300,7 +498,7 @@ define( } } else { this.open() - this.renderContent() + this.updateContent() } } catch (error) {