UNPKG

terriajs

Version:

Geospatial data visualization platform.

741 lines (682 loc) 33.3 kB
'use strict'; import Mustache from 'mustache'; import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import CesiumMath from 'terriajs-cesium/Source/Core/Math'; import classNames from 'classnames'; import dateFormat from 'dateformat'; import defined from 'terriajs-cesium/Source/Core/defined'; import Ellipsoid from 'terriajs-cesium/Source/Core/Ellipsoid'; import isArray from 'terriajs-cesium/Source/Core/isArray'; import JulianDate from 'terriajs-cesium/Source/Core/JulianDate'; import CustomComponents from '../Custom/CustomComponents'; import FeatureInfoDownload from './FeatureInfoDownload'; import formatNumberForLocale from '../../Core/formatNumberForLocale'; import Icon from '../Icon.jsx'; import ObserveModelMixin from '../ObserveModelMixin'; import propertyGetTimeValues from '../../Core/propertyGetTimeValues'; import parseCustomMarkdownToReact from '../Custom/parseCustomMarkdownToReact'; import Styles from './feature-info-section.scss'; // We use Mustache templates inside React views, where React does the escaping; don't escape twice, or eg. " => &quot; Mustache.escape = function(string) { return string; }; // Individual feature info section const FeatureInfoSection = createReactClass({ displayName: 'FeatureInfoSection', mixins: [ObserveModelMixin], propTypes: { viewState: PropTypes.object.isRequired, template: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), feature: PropTypes.object, position: PropTypes.object, catalogItem: PropTypes.object, // Note this may not be known (eg. WFS). isOpen: PropTypes.bool, onClickHeader: PropTypes.func, printView: PropTypes.bool }, getInitialState() { return { removeClockSubscription: undefined, timeoutIds: [], showRawData: false }; }, /* eslint-disable-next-line camelcase */ UNSAFE_componentWillMount() { setSubscriptionsAndTimeouts(this, this.props.feature); }, /* eslint-disable-next-line camelcase */ UNSAFE_componentWillReceiveProps(nextProps) { // If the feature changed (without an unmount/mount), // change the subscriptions that handle time-varying data. if (nextProps.feature !== this.props.feature) { removeSubscriptionsAndTimeouts(this); setSubscriptionsAndTimeouts(this, nextProps.feature); } }, componentWillUnmount() { removeSubscriptionsAndTimeouts(this); }, getPropertyValues() { return getPropertyValuesForFeature(this.props.feature, currentTimeIfAvailable(this), this.props.template && this.props.template.formats); }, getTemplateData() { const propertyData = this.getPropertyValues(); if (defined(propertyData)) { propertyData.terria = { formatNumber: mustacheFormatNumberFunction, formatDateTime: mustacheFormatDateTime, urlEncodeComponent: mustacheURLEncodeTextComponent, urlEncode: mustacheURLEncodeText }; if (this.props.position) { const latLngInRadians = Ellipsoid.WGS84.cartesianToCartographic(this.props.position); propertyData.terria.coords = { latitude: CesiumMath.toDegrees(latLngInRadians.latitude), longitude: CesiumMath.toDegrees(latLngInRadians.longitude) }; } if (this.props.catalogItem) { propertyData.terria.currentTime = this.props.catalogItem.discreteTime; } propertyData.terria.timeSeries = getTimeSeriesChartContext(this.props.catalogItem, this.props.feature, propertyData._terria_getChartDetails); } return propertyData; }, clickHeader() { if (defined(this.props.onClickHeader)) { this.props.onClickHeader(this.props.feature); } }, hasTemplate() { return this.props.template && (typeof this.props.template === 'string' || this.props.template.template); }, descriptionFromTemplate() { const template = this.props.template; const templateData = this.getTemplateData(); // If property names were changed, let the template access the original property names too. if (defined(templateData) && defined(templateData._terria_columnAliases)) { for (let i = 0; i < templateData._terria_columnAliases.length; i++) { const alias = templateData._terria_columnAliases[i]; templateData[alias.id] = templateData[alias.name]; } } // templateData may not be defined if a re-render gets triggered in the middle of a feature updating. // (Recall we re-render whenever feature.definitionChanged triggers.) if (defined(templateData)) { return typeof template === 'string' ? Mustache.render(template, templateData) : Mustache.render(template.template, templateData, template.partials); } else { return 'No information available'; } }, descriptionFromFeature() { const feature = this.props.feature; // This description could contain injected <script> tags etc. // Before rendering, we will pass it through parseCustomMarkdownToReact, which applies // markdownToHtml (which applies MarkdownIt.render and DOMPurify.sanitize), and then // parseCustomHtmlToReact (which calls htmlToReactParser). // Note that there is an unnecessary HTML encoding and decoding in this combination which would be good to remove. const currentTime = currentTimeIfAvailable(this) ? currentTimeIfAvailable(this) : JulianDate.now(); let description = feature.currentDescription || getCurrentDescription(feature, currentTime); if (!defined(description) && defined(feature.properties)) { description = describeFromProperties(feature.properties, currentTime); } return description; }, renderDataTitle() { const template = this.props.template; if (typeof template === 'object' && defined(template.name)) { return Mustache.render(template.name, this.getPropertyValues()); } const feature = this.props.feature; return (feature && feature.name) || 'Site Data'; }, isFeatureTimeVarying(feature) { // The feature is NOT time-varying if: // 1. There is no info (ie. no description and no properties). // 2. A template is provided and all feature.properties are constant. // OR // 3. No template is provided, and feature.description is either not defined, or defined and constant. // If info is time-varying, we need to keep updating the description. if (!defined(feature.description) && !defined(feature.properties)) { return false; } if (defined(this.props.template)) { return !areAllPropertiesConstant(feature.properties); } if (defined(feature.description)) { return !feature.description.isConstant; // This should always be a "Property" eg. a ConstantProperty. } return false; }, toggleRawData() { this.setState({ showRawData: !this.state.showRawData }); }, render() { const catalogItemName = (this.props.catalogItem && this.props.catalogItem.name) || ''; let baseFilename = catalogItemName; // Add the Lat, Lon to the baseFilename if it is possible and not already present. if (this.props.position) { const position = Ellipsoid.WGS84.cartesianToCartographic(this.props.position); const latitude = CesiumMath.toDegrees(position.latitude); const longitude = CesiumMath.toDegrees(position.longitude); const precision = 5; // Check that baseFilename doesn't already contain the lat, lon with the similar or better precision. if ((typeof baseFilename !== 'string') || !contains(baseFilename, latitude, precision) || !contains(baseFilename, longitude, precision)) { baseFilename += ' - Lat ' + latitude.toFixed(precision) + ' Lon ' + longitude.toFixed(precision); } } const fullName = (catalogItemName ? (catalogItemName + ' - ') : '') + this.renderDataTitle(); const reactInfo = getInfoAsReactComponent(this); return ( <li className={classNames(Styles.section)}> <If condition={this.props.printView}> <h2>{fullName}</h2> </If> <If condition={!this.props.printView}> <button type='button' onClick={this.clickHeader} className={Styles.title}> <span>{fullName}</span> {this.props.isOpen ? <Icon glyph={Icon.GLYPHS.opened}/> : <Icon glyph={Icon.GLYPHS.closed}/>} </button> </If> <If condition={this.props.isOpen}> <section className={Styles.content}> <If condition={!this.props.printView && this.hasTemplate()}> <button type="button" className={Styles.rawDataButton} onClick={this.toggleRawData}> {this.state.showRawData ? 'Show Curated Data' : 'Show Raw Data'} </button> </If> <div> <Choose> <When condition={reactInfo.showRawData || !this.hasTemplate()}> <If condition={reactInfo.hasRawData}> {reactInfo.rawData} </If> <If condition={!reactInfo.hasRawData}> <div ref="no-info" key="no-info">No information available.</div> </If> <If condition={!this.props.printView && reactInfo.timeSeriesChart}> <div className={Styles.timeSeriesChart}> <h4>{reactInfo.timeSeriesChartTitle}</h4> {reactInfo.timeSeriesChart} </div> </If> <If condition={!this.props.printView && defined(reactInfo.downloadableData)}> <FeatureInfoDownload key='download' viewState={this.props.viewState} data={reactInfo.downloadableData} name={baseFilename} /> </If> </When> <Otherwise> {reactInfo.info} </Otherwise> </Choose> <For each="ExtraComponent" index="i" of={FeatureInfoSection.extraComponents}> <ExtraComponent key={i} viewState={this.props.viewState} // eslint-disable-line react/jsx-no-undef template={this.props.template} feature={this.props.feature} position={this.props.position} // We should deprecate clock here and remove it alltogether, but currently leaving so don't break API. // Clients can and should use catalogItem.clock and catalogItem.currentTime. clock={clockIfAvailable(this)} catalogItem={this.props.catalogItem} isOpen={this.props.isOpen} onClickHeader={this.props.onClickHeader}/> </For> </div> </section> </If> </li> ); }, }); /** * Returns the clockForDisplay for the catalogItem if it is avaliable, otherwise returns undefined. * @private */ function clockIfAvailable(featureInfoSection) { if (defined(featureInfoSection.props.catalogItem)) { return featureInfoSection.props.catalogItem.clock; } return undefined; } /** * Returns the currentTime for the catalogItem if it is avaliable, otherwise returns undefined. * @private */ function currentTimeIfAvailable(featureInfoSection) { if (defined(featureInfoSection.props.catalogItem)) { return featureInfoSection.props.catalogItem.currentTime; } return undefined; } /** * Do we need to dynamically update this feature info over time? * There are three situations in which we would: * 1. When the feature description or properties are time-varying. * 2. When a custom component self-updates. * Eg. <chart poll-seconds="60" src="xyz.csv"> must reload data from xyz.csv every 60 seconds. * 3. When a catalog item changes a feature's properties, eg. changing from a daily view to a monthly view. * * For (1), use catalogItem.clock.currentTime knockout observable so don't need to do anything specific here. * For (2), use a regular javascript setTimeout to update a counter in feature's currentProperties. * For (3), use an event listener on the Feature's underlying Entity's "definitionChanged" event. * Conceivably it could also be handled by the catalog item itself changing, if its change is knockout tracked, and the * change leads to a change in what is rendered (unlikely). * Since the catalogItem is also a prop, this will trigger a rerender. * @private */ function setSubscriptionsAndTimeouts(featureInfoSection, feature) { featureInfoSection.setState({ removeFeatureChangedSubscription: feature.definitionChanged.addEventListener(function(changedFeature) { setCurrentFeatureValues(changedFeature, currentTimeIfAvailable(featureInfoSection)); }) }); setTimeoutsForUpdatingCustomComponents(featureInfoSection); } /** * Remove the clock subscription (event listener) and timeouts. * @private */ function removeSubscriptionsAndTimeouts(featureInfoSection) { if (defined(featureInfoSection.state.removeFeatureChangedSubscription)) { featureInfoSection.state.removeFeatureChangedSubscription(); featureInfoSection.setState({removeFeatureChangedSubscription: undefined}); } featureInfoSection.state.timeoutIds.forEach(id => { clearTimeout(id); }); } /** * Gets a map of property labels to property values for a feature at the provided current time. * @private * @param {Entity} feature A feature to get values for. * @param {JulianDate} currentTime A knockout observable containing the currentTime. * @param {Object} [formats] A map of property labels to the number formats that should be applied for them. */ function getPropertyValuesForFeature(feature, currentTime, formats) { // Manipulate the properties before templating them. // If they require .getValue, apply that. // If they have bad keys, fix them. // If they have formatting, apply it. const properties = feature.currentProperties || propertyGetTimeValues(feature.properties, currentTime); // Try JSON.parse on values that look like JSON arrays or objects let result = parseValues(properties); result = replaceBadKeyCharacters(result); if (defined(formats)) { applyFormatsInPlace(result, formats); } return result; } function parseValues(properties) { // JSON.parse property values that look like arrays or objects const result = {}; for (const key in properties) { if (properties.hasOwnProperty(key)) { let val = properties[key]; if (val && (typeof val === 'string' || val instanceof String) && (/^\s*[[{]/).test(val)) { try { val = JSON.parse(val); } catch (e) {} } result[key] = val; } } return result; } /** * Formats values in an object if their keys match the provided formats object. * @private * @param {Object} properties a map of property labels to property values. * @param {Object} formats A map of property labels to the number formats that should be applied for them. */ function applyFormatsInPlace(properties, formats) { // Optionally format each property. Updates properties in place, returning nothing. for (const key in formats) { if (properties.hasOwnProperty(key)) { // Default type if not provided is number. if (!defined(formats[key].type) || (defined(formats[key].type) && formats[key].type === "number")) { properties[key] = formatNumberForLocale(properties[key], formats[key]); } if (defined(formats[key].type)) { if (formats[key].type === "dateTime") { properties[key] = formatDateTime(properties[key], formats[key]); } } } } } /** * Recursively replace '.' and '#' in property keys with _, since Mustache cannot reference keys with these characters. * @private */ function replaceBadKeyCharacters(properties) { // if properties is anything other than an Object type, return it. Otherwise recurse through its properties. if (!properties || typeof properties !== 'object' || isArray(properties)) { return properties; } const result = {}; for (const key in properties) { if (properties.hasOwnProperty(key)) { const cleanKey = key.replace(/[.#]/g, '_'); result[cleanKey] = replaceBadKeyCharacters(properties[key]); } } return result; } /** * Determines whether all properties in the provided properties object have an isConstant flag set - otherwise they're * assumed to be time-varying. * @private * @returns {boolean} */ function areAllPropertiesConstant(properties) { // test this by assuming property is time-varying only if property.isConstant === false. // (so if it is undefined or true, it is constant.) let result = true; if (defined(properties.isConstant)) { return properties.isConstant; } for (const key in properties) { if (properties.hasOwnProperty(key)) { result = result && defined(properties[key]) && (properties[key].isConstant !== false); } } return result; } /** * Gets a text description for the provided feature at a certain time. * @private * @param {Entity} feature * @param {JulianDate} currentTime * @returns {String} */ function getCurrentDescription(feature, currentTime) { if (feature.description && typeof feature.description.getValue === 'function') { return feature.description.getValue(currentTime); } return feature.description; } /** * Updates {@link Entity#currentProperties} and {@link Entity#currentDescription} with the values at the provided time. * @private * @param {Entity} feature * @param {JulianDate} currentTime */ function setCurrentFeatureValues(feature, currentTime) { const newProperties = propertyGetTimeValues(feature.properties, currentTime); if (newProperties !== feature.currentProperties) { feature.currentProperties = newProperties; } const newDescription = getCurrentDescription(feature, currentTime); if (newDescription !== feature.currentDescription) { feature.currentDescription = newDescription; } } /** * Returns a function which extracts JSON elements from the content of a Mustache section template and calls the * supplied customProcessing function with the extracted JSON options, example syntax processed: * {optionKey: optionValue}{{value}} * @private */ function mustacheJsonSubOptions(customProcessing) { return function(text, render) { // Eg. "{foo:1}hi there".match(optionReg) = ["{foo:1}hi there", "{foo:1}", "hi there"]. // Note this won't work with nested objects in the options (but these aren't used yet). // Note I use [\s\S]* instead of .* at the end - .* does not match newlines, [\s\S]* does. const optionReg = /^(\{[^}]+\})([\s\S]*)/; const components = text.match(optionReg); // This regex unfortunately matches double-braced text like {{number}}, so detect that separately and do not treat it as option json. const startsWithdoubleBraces = (text.length > 4) && (text[0] === '{') && (text[1] === '{'); if (!components || startsWithdoubleBraces) { // If no options were provided, just use the defaults. return customProcessing(render(text)); } // Allow {foo: 1} by converting it to {"foo": 1} for JSON.parse. const quoteReg = /([{,])(\s*)([A-Za-z0-9_\-]+?)\s*:/g; const jsonOptions = components[1].replace(quoteReg, '$1"$3":'); const options = JSON.parse(jsonOptions); return customProcessing(render(components[2]), options); }; } /** * Returns a function which implements number formatting in Mustache templates, using this syntax: * {{#terria.formatNumber}}{useGrouping: true}{{value}}{{/terria.formatNumber}} * @private */ function mustacheFormatNumberFunction() { return mustacheJsonSubOptions(formatNumberForLocale); } /** * Formats the date according to the date format string. * If the date expression can't be parsed using Date.parse() it will be returned unmodified. * * @param {String} text The date to format. * @param {Object} options Object with the following properties: * @param {String} options.format If present, will override the default date format using the npm datefromat package * format (see https://www.npmjs.com/package/dateformat). E.g. "isoDateTime" * or "dd-mm-yyyy HH:MM:ss". If not supplied isoDateTime will be used. * @private */ function formatDateTime(text, options) { const date = Date.parse(text); if (!defined(date) || isNaN(date)) { return text; } if (defined(options) && defined(options.format)) { return dateFormat(date, options.format); } return dateFormat(date, "isoDateTime"); } /** * Returns a function which implements date/time formatting in Mustache templates, using this syntax: * {{#terria.formatDateTime}}{format: "npm dateFormat string"}DateExpression{{/terria.formatDateTime}} * format If present, will override the default date format (see https://www.npmjs.com/package/dateformat) * Eg. "isoDateTime" or "dd-mm-yyyy HH:MM:ss". * If the Date_Expression can't be parsed using Date.parse() it will be used(returned) unmodified by the terria.formatDateTime section expression. * If no valid date formatting options are present in the terria.formatDateTime section isoDateTime will be used. * @private */ function mustacheFormatDateTime() { return mustacheJsonSubOptions(formatDateTime); } /** * URL Encodes provided text: {{#terria.urlEncodeComponent}}{{value}}{{/terria.urlEncodeComponent}}. * See encodeURIComponent for details. * * {{#terria.urlEncodeComponent}}W/HO:E#1{{/terria.urlEncodeComponent}} -> W%2FHO%3AE%231 * @private */ function mustacheURLEncodeTextComponent() { return function(text, render) { return encodeURIComponent(render(text)); }; } /** * URL Encodes provided text: {{#terria.urlEncode}}{{value}}{{/terria.urlEncode}}. * See encodeURI for details. * * {{#terria.urlEncode}}http://example.com/a b{{/terria.urlEncode}} -> http://example.com/a%20b * @private */ function mustacheURLEncodeText() { return function(text, render) { return encodeURI(render(text)); }; } const simpleStyleIdentifiers = ['title', 'description', 'marker-size', 'marker-symbol', 'marker-color', 'stroke', 'stroke-opacity', 'stroke-width', 'fill', 'fill-opacity']; /** * A way to produce a description if properties are available but no template is given. * Derived from Cesium's geoJsonDataSource, but made to work with possibly time-varying properties. * @private */ function describeFromProperties(properties, time) { let html = ''; if (typeof properties.getValue === 'function') { properties = properties.getValue(time); } if (typeof properties === 'object') { for (const key in properties) { if (properties.hasOwnProperty(key)) { if (simpleStyleIdentifiers.indexOf(key) !== -1) { continue; } let value = properties[key]; if (defined(value)) { if (typeof value.getValue === 'function') { value = value.getValue(time); } if (Array.isArray(properties)) { html += '<tr><td>' + describeFromProperties(value, time) + '</td></tr>'; } else if (typeof value === 'object') { html += '<tr><th>' + key + '</th><td>' + describeFromProperties(value, time) + '</td></tr>'; } else { html += '<tr><th>' + key + '</th><td>' + value + '</td></tr>'; } } } } } else { // properties is only a single value. html += '<tr><th>' + '</th><td>' + properties + '</td></tr>'; } if (html.length > 0) { html = '<table class="cesium-infoBox-defaultTable"><tbody>' + html + '</tbody></table>'; } return html; } /** * Get parameters that should be exposed to the template, to help show a timeseries chart of the feature data. * @private */ function getTimeSeriesChartContext(catalogItem, feature, getChartDetails) { // Only show it as a line chart if the details are available, the data is sampled (so a line chart makes sense), and charts are available. if (defined(getChartDetails) && defined(catalogItem) && catalogItem.isSampled && CustomComponents.isRegistered('chart')) { const chartDetails = getChartDetails(); const distinguishingId = catalogItem.dataViewId; const featureId = defined(distinguishingId) ? (distinguishingId + '--' + feature.id) : feature.id; if (chartDetails) { const result = { xName: chartDetails.xName.replace(/\"/g, ''), yName: chartDetails.yName.replace(/\"/g, ''), title: chartDetails.yName, id: featureId.replace(/\"/g, ''), data: chartDetails.csvData.replace(/\\n/g, '\\n'), units: chartDetails.units.join(',').replace(/\"/g, '') }; const xAttribute = 'x-column="' + result.xName + '" '; const yAttribute = 'y-column="' + result.yName + '" '; const idAttribute = 'id="' + result.id + '" '; const unitsAttribute = 'column-units = "' + result.units + '" '; result.chart = '<chart ' + xAttribute + yAttribute + unitsAttribute + idAttribute + '>' + result.data + '</chart>'; return result; } } } /** * Wrangle the provided feature data into more convenient forms. * @private * @param {ReactClass} that The FeatureInfoSection. * @return {Object} Returns {info, rawData, showRawData, hasRawData, ...}. * info is the main body of the info section, as a react component. * rawData is the same for the raw data, if it needs to be shown. * showRawData is whether to show the rawData. * hasRawData is whether there is any rawData to show. * timeSeriesChart - if the feature has timeseries data that could be shown in chart, this is the chart. * downloadableData is the same as template data, but numerical. */ function getInfoAsReactComponent(that) { const templateData = that.getPropertyValues(); const downloadableData = defined(templateData) ? (templateData._terria_numericalProperties || templateData) : undefined; const updateCounters = that.props.feature.updateCounters; const context = { catalogItem: that.props.catalogItem, feature: that.props.feature, updateCounters: updateCounters }; let timeSeriesChart; let timeSeriesChartTitle; if (defined(templateData)) { const timeSeriesChartContext = getTimeSeriesChartContext(that.props.catalogItem, that.props.feature, templateData._terria_getChartDetails); if (defined(timeSeriesChartContext)) { timeSeriesChart = parseCustomMarkdownToReact(timeSeriesChartContext.chart, context); timeSeriesChartTitle = timeSeriesChartContext.title; } } const showRawData = !that.hasTemplate() || that.state.showRawData; let rawDataHtml; let rawData; if (showRawData) { rawDataHtml = that.descriptionFromFeature(); if (defined(rawDataHtml)) { rawData = parseCustomMarkdownToReact(rawDataHtml, context); } } return { info: that.hasTemplate() ? parseCustomMarkdownToReact(that.descriptionFromTemplate(), context) : rawData, rawData: rawData, showRawData: showRawData, hasRawData: !!rawDataHtml, timeSeriesChartTitle: timeSeriesChartTitle, timeSeriesChart: timeSeriesChart, downloadableData: downloadableData }; } function setTimeoutsForUpdatingCustomComponents(that) { // eslint-disable-line require-jsdoc const {info} = getInfoAsReactComponent(that); const foundCustomComponents = CustomComponents.find(info); foundCustomComponents.forEach((match, componentNumber) => { const updateSeconds = match.type.selfUpdateSeconds(match.reactComponent); if (updateSeconds > 0) { setTimeoutForUpdatingCustomComponent(that, match.reactComponent, updateSeconds, componentNumber); } }); } function setTimeoutForUpdatingCustomComponent(that, reactComponent, updateSeconds, componentNumber) { // eslint-disable-line require-jsdoc const timeoutId = setTimeout(() => { // Update the counter for this component. Handle various undefined cases. const updateCounters = that.props.feature.updateCounters; const counterObject = { reactComponent: reactComponent, counter: (defined(updateCounters) && defined(updateCounters[componentNumber])) ? updateCounters[componentNumber].counter + 1 : 1 }; if (!defined(that.props.feature.updateCounters)) { const counters = {}; counters[componentNumber] = counterObject; that.props.feature.updateCounters = counters; } else { that.props.feature.updateCounters[componentNumber] = counterObject; } // And finish by triggering the next timeout, but do this in another timeout so we aren't nesting setStates. setTimeout(() => { setTimeoutForUpdatingCustomComponent(that, reactComponent, updateSeconds, componentNumber); // console.log('Removing ' + timeoutId + ' from', that.state.timeoutIds); that.setState({timeoutIds: that.state.timeoutIds.filter(id => timeoutId !== id)}); }, 5); }, updateSeconds * 1000); const timeoutIds = that.state.timeoutIds; that.setState({timeoutIds: timeoutIds.concat(timeoutId)}); } // See if text contains the number (to a precision number of digits (after the dp) either fixed up or down on the last digit). function contains(text, number, precision) { // Take Math.ceil or Math.floor and use it to calculate the number with a precision number of digits (after the dp). function fixed(round, number) { const scale = Math.pow(10, precision); return (round(number*scale)/scale).toFixed(precision); } return (text.indexOf(fixed(Math.floor, number)) !== -1) || (text.indexOf(fixed(Math.ceil, number)) !== -1); } /** * Add your own react components to have them rendered in a FeatureInfoSection. ViewState will be passed as a prop. */ FeatureInfoSection.extraComponents = []; module.exports = FeatureInfoSection;