diff --git a/src/js/views/maps/LegendView.js b/src/js/views/maps/LegendView.js index 24a8f117e..31036bf4b 100644 --- a/src/js/views/maps/LegendView.js +++ b/src/js/views/maps/LegendView.js @@ -71,7 +71,6 @@ define([ * dimensions are set with a viewBox property only, so the height and width * represent an aspect ratio rather than absolute size. * @type {object} - * @property {object} previewSvgDimensions - The dimension properties of the SVG. * @property {number} previewSvgDimensions.width - The width of the entire SVG * @property {number} previewSvgDimensions.height - The height of the entire SVG * @property {number} squareSpacing - Maximum spacing between each of the squares @@ -108,11 +107,15 @@ define([ * @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; - }); + try { + // Get all the options and apply them to this view + if (typeof options === "object") { + for (const [key, value] of Object.entries(options)) { + this[key] = value; + } + } + } catch (e) { + console.log(`A LegendView failed to initialize. Error message: ${e}`); } }, @@ -121,60 +124,70 @@ define([ * @returns {LegendView} Returns the rendered view element */ render() { - if (!this.model) { - return this; - } + try { + if (!this.model) { + return; + } - // The color palette maps colors to attributes of the map asset - let colorPalette = null; - // For color palettes, - let paletteType = null; - const { mode } = this; + // Save a reference to this view + const view = this; - // Insert the template into the view - this.$el.html(this.template({})); + // The color palette maps colors to attributes of the map asset + let colorPalette = null; + // For color palettes, + let paletteType = null; + const { mode } = this; - // Ensure the view's main element has the given class name - this.el.classList.add(this.className); + // Insert the template into the view + this.$el.html(this.template({})); - // Add a modifier class if this is a preview of a legend - if (mode === "preview") { - this.el.classList.add(this.classes.preview); - } + // Ensure the view's main element has the given class name + this.el.classList.add(this.className); - // Check for a color palette model in the Map Asset model. Even imagery layers - // may have a color palette configured, specifically to use to create a - // legend. - Object.values(this.model.attributes).forEach((attr) => { - if (attr instanceof AssetColorPalette) { - colorPalette = attr; - paletteType = attr.get("paletteType"); + // Add a modifier class if this is a preview of a legend + if (mode === "preview") { + this.el.classList.add(this.classes.preview); } - }); - - if (mode === "preview") { - // For categorical vector color palettes, in preview mode - if (colorPalette && paletteType === "categorical") { - this.renderCategoricalPreviewLegend(colorPalette); - } else if (colorPalette && paletteType === "continuous") { - this.renderContinuousPreviewLegend(colorPalette); + + // Check for a color palette model in the Map Asset model. Even imagery layers + // may have a color palette configured, specifically to use to create a + // legend. + for (const attr in this.model.attributes) { + if (this.model.attributes[attr] instanceof AssetColorPalette) { + colorPalette = this.model.get(attr); + paletteType = colorPalette.get("paletteType"); + } } - // For imagery layers that do not have a color palette, in preview mode - else if (typeof this.model.getThumbnail === "function") { - if (!this.model.get("thumbnail")) { - this.listenToOnce(this.model, "change:thumbnail", () => { + + if (mode === "preview") { + // For categorical vector color palettes, in preview mode + if (colorPalette && paletteType === "categorical") { + this.renderCategoricalPreviewLegend(colorPalette); + } else if (colorPalette && paletteType === "continuous") { + this.renderContinuousPreviewLegend(colorPalette); + } + // For imagery layers that do not have a color palette, in preview mode + else if (typeof this.model.getThumbnail === "function") { + if (!this.model.get("thumbnail")) { + this.listenToOnce(this.model, "change:thumbnail", function () { + this.renderImagePreviewLegend(this.model.get("thumbnail")); + }); + } else { this.renderImagePreviewLegend(this.model.get("thumbnail")); - }); - } else { - this.renderImagePreviewLegend(this.model.get("thumbnail")); + } } } - } - // TODO: - // - preview classified legend - // - full legends with labels, title, etc. + // TODO: + // - preview classified legend + // - full legends with labels, title, etc. - return this; + return this; + } catch (error) { + console.log( + `There was an error rendering a Legend View` + + `. Error details: ${error}`, + ); + } }, /** @@ -183,10 +196,17 @@ define([ * image */ renderImagePreviewLegend(thumbnailURL) { - const img = new Image(); - img.src = thumbnailURL; - img.classList.add(this.classes.previewImg); - this.el.append(img); + try { + const img = new Image(); + img.src = thumbnailURL; + img.classList.add(this.classes.previewImg); + this.el.append(img); + } catch (error) { + console.log( + `There was an error rendering an image preview legend in a LegendView` + + `. Error details: ${error}`, + ); + } }, /** @@ -196,132 +216,138 @@ define([ * feature attributes to colors, used to create the legend */ renderCategoricalPreviewLegend(colorPalette) { - if (!colorPalette) { - return; - } - const view = this; - // Data to use in d3 - let data = colorPalette.get("colors").toJSON().reverse(); + try { + if (!colorPalette) { + return; + } + const view = this; + // Data to use in d3 + let data = colorPalette.get("colors").toJSON().reverse(); - if (data.length === 0) { - return; - } - // The max width of the SVG, to be reduced if there are few colours - let { width } = this.previewSvgDimensions; - // The height of the SVG - const { height } = this.previewSvgDimensions; - // Height and width of the square is the height of the SVG, leaving some room - // for shadow to show - const squareSize = height * 0.92; - // Maximum spacing between squares. When not hovered, the squares will be - // spaced 80% of this value. - const { squareSpacing } = this.previewSvgDimensions; - // The maximum number of squares that can fit on the SVG without any spilling - // over - const maxNumSquares = Math.floor( - (width - squareSize) / squareSpacing + 1, - ); - - // If there are more colors than fit in the max width of the SVG space, only - // show the first n squares that will fit - if (data.length > maxNumSquares) { - data = data.slice(0, maxNumSquares); - } - // Add index to data for sorting later (also works as unique ID) - data.forEach((d, i) => { - // eslint-disable-next-line no-param-reassign - d.i = i; - }); - - // Don't create an SVG that is wider than it need to be. - width = squareSize + (data.length - 1) * squareSpacing; - - // SVG element - const svg = this.createSVG({ - dropshadowFilter: true, - width, - height, - }); - - // Add the preview class and dropshadow to the SVG - svg.classed(this.classes.previewSVG, true); - svg.style("filter", "url(#dropshadow)"); - - /** - * Calculates the placement of the square along x-axis, when SVG is hovered - * and when it's not - * @param {number} i Index of the data. - * @param {boolean} hovered Whether the SVG is on hover. - * @returns {number} The placement of the square along x-axis in pixel. - */ - function getSquareX(i, hovered) { - const multiplier = hovered ? 1 : 0.8; - return width - squareSize - i * (squareSpacing * multiplier); - } + if (data.length === 0) { + return; + } + // The max width of the SVG, to be reduced if there are few colours + let { width } = this.previewSvgDimensions; + // The height of the SVG + const { height } = this.previewSvgDimensions; + // Height and width of the square is the height of the SVG, leaving some room + // for shadow to show + const squareSize = height * 0.92; + // Maximum spacing between squares. When not hovered, the squares will be + // spaced 80% of this value. + const { squareSpacing } = this.previewSvgDimensions; + // The maximum number of squares that can fit on the SVG without any spilling + // over + const maxNumSquares = Math.floor( + (width - squareSize) / squareSpacing + 1, + ); + + // If there are more colors than fit in the max width of the SVG space, only + // show the first n squares that will fit + if (data.length > maxNumSquares) { + data = data.slice(0, maxNumSquares); + } + // Add index to data for sorting later (also works as unique ID) + data.forEach((d, i) => { + d.i = i; + }); - // Draw the legend (d3) - const legendSquares = svg - .selectAll("rect") - .data(data) - .enter() - .append("rect") - .attr("x", (d, i) => getSquareX(i, false)) - .attr("height", squareSize) - .attr("width", squareSize) - .attr("rx", squareSize * 0.1) - .style( - "fill", - (d) => - `rgb(${d.color.red * 255},${d.color.green * 255},${d.color.blue * 255})`, - ) - .style("filter", "url(#dropshadow)"); - - // For legend with multiple colours, show a tooltip with the value/label when - // the user hovers over a square. Also bring that square to the fore-front of - // the legend when hovered. Only when MapAsset is visible though. - if (data.length > 1) { - // Space the squares further apart when they are hovered over - svg - .on("mouseenter", () => { - if (view.model.get("visible")) { + // Don't create an SVG that is wider than it need to be. + width = squareSize + (data.length - 1) * squareSpacing; + + // SVG element + const svg = this.createSVG({ + dropshadowFilter: true, + width, + height, + }); + + // Add the preview class and dropshadow to the SVG + svg.classed(this.classes.previewSVG, true); + svg.style("filter", "url(#dropshadow)"); + + // Calculates the placement of the square along x-axis, when SVG is hovered + // and when it's not + /** + * + * @param i + * @param hovered + */ + function getSquareX(i, hovered) { + const multiplier = hovered ? 1 : 0.8; + return width - squareSize - i * (squareSpacing * multiplier); + } + + // Draw the legend (d3) + const legendSquares = svg + .selectAll("rect") + .data(data) + .enter() + .append("rect") + .attr("x", (d, i) => getSquareX(i, false)) + .attr("height", squareSize) + .attr("width", squareSize) + .attr("rx", squareSize * 0.1) + .style( + "fill", + (d) => + `rgb(${d.color.red * 255},${d.color.green * 255},${d.color.blue * 255})`, + ) + .style("filter", "url(#dropshadow)"); + + // For legend with multiple colours, show a tooltip with the value/label when + // the user hovers over a square. Also bring that square to the fore-front of + // the legend when hovered. Only when MapAsset is visible though. + if (data.length > 1) { + // Space the squares further apart when they are hovered over + svg + .on("mouseenter", () => { + if (view.model.get("visible")) { + legendSquares + .transition() + .duration(250) + .attr("x", (d, i) => getSquareX(i, true)); + } + }) + .on("mouseleave", () => { legendSquares .transition() - .duration(250) - .attr("x", (d, i) => getSquareX(i, true)); - } - }) - .on("mouseleave", () => { - legendSquares - .transition() - .duration(200) - .attr("x", (d, i) => getSquareX(i, false)); - }); + .duration(200) + .attr("x", (d, i) => getSquareX(i, false)); + }); - legendSquares - .on("mouseenter", (d) => { - // Bring the hovered element to the front, while keeping other - // legendSquares in order - legendSquares.sort((a, b) => d3.ascending(a.i, b.i)); - this.parentNode.appendChild(this); - // Show tooltip - if (d.label || d.value || d.value === 0) { - $(this) - .tooltip({ - placement: "bottom", - trigger: "manual", - title: d.label || d.value, - container: view.$el, - animation: false, - template: `
`, - }) - .tooltip("show"); - } - }) - // Hide tooltip and return squares to regular z-ordering - .on("mouseleave", () => { - $(this).tooltip("destroy"); - legendSquares.sort((a, b) => d3.ascending(a.i, b.i)); - }); + legendSquares + .on("mouseenter", function (d) { + // Bring the hovered element to the front, while keeping other + // legendSquares in order + legendSquares.sort((a, b) => d3.ascending(a.i, b.i)); + this.parentNode.appendChild(this); + // Show tooltip + if (d.label || d.value || d.value === 0) { + $(this) + .tooltip({ + placement: "bottom", + trigger: "manual", + title: d.label || d.value, + container: view.$el, + animation: false, + template: `
`, + }) + .tooltip("show"); + } + }) + // Hide tooltip and return squares to regular z-ordering + .on("mouseleave", function (d) { + $(this).tooltip("destroy"); + legendSquares.sort((a, b) => d3.ascending(a.i, b.i)); + }); + } + } catch (error) { + console.log( + `There was an error creating a categorical legend preview in a LegendView` + + `. Error details: ${error}`, + ); } }, @@ -332,179 +358,195 @@ define([ * feature attributes to colors, used to create the legend */ renderContinuousPreviewLegend(colorPalette) { - if (!colorPalette) { - return; - } - const view = this; - // Data to use in d3 - let data = colorPalette.get("colors").toJSON(); - // The max width of the SVG - const { width } = this.previewSvgDimensions; - // The height of the SVG - const { height } = this.previewSvgDimensions; - // Height of the gradient rectangle, leaving some room for the drop shadow - const gradientHeight = height * 0.92; - - // A unique ID for the gradient - const gradientId = `gradient-${view.cid}`; - - // Calculate the rounding precision we should use based on the - // range of the data. This determines how each value in the legend - // is displayed in the tooltip on mouseover. See the - // rect.on('mousemove'... function, below - data = data.sort((a, b) => a.value - b.value); - const min = data[0].value; - const max = data[data.length - 1].value; - const range = max - min; - const numDecimalPlaces = Utilities.getNumDecimalPlaces(range); - - // SVG element - const svg = this.createSVG({ - dropshadowFilter: false, - width, - height, - }); - - // Add the preview class and dropshadow to the SVG - svg.classed(this.classes.previewSVG, true); - svg.style("filter", "url(#dropshadow)"); - - // Create a gradient using the data - const gradient = svg - .append("defs") - .append("linearGradient") - .attr("id", gradientId) - .attr("x1", "0%") - .attr("y1", "0%"); - - const getOffset = (d) => `${((d.value - min) / range) * 100}%`; - const getStopColor = (d) => { - const r = d.color.red * 255; - const g = d.color.green * 255; - const b = d.color.blue * 255; - return `rgb(${r},${g},${b})`; - }; - - // Add the gradient stops - data.forEach((d) => { - gradient - .append("stop") - // offset should be relative to the value in the data - .attr("offset", getOffset(d, data)) - .attr("stop-color", getStopColor(d)); - }); - - // Create the rectangle - const rect = svg - .append("rect") - .attr("x", 0) - .attr("y", 0) - .attr("width", width) - .attr("height", gradientHeight) - .attr("rx", gradientHeight * 0.1) - .style("fill", `url(#${gradientId})`); - - // Create a proxy element to attach the tooltip to, so that we can move the - // tooltip to follow the mouse (by moving the proxy element to follow the mouse) - const proxyEl = svg.append("rect").attr("y", gradientHeight); - - rect - .on("mousemove", () => { - if (view.model.get("visible")) { - // Get the coordinates of the mouse relative to the rectangle - let xMouse = d3.mouse(this)[0]; - if (xMouse < 0) { - xMouse = 0; - } - if (xMouse > width) { - xMouse = width; - } - // Get the relative position of the mouse to the gradient - const relativePosition = xMouse / width; - // Get the value at the relative position by interpolating the data - let value = d3.interpolate( - data[0].value, - data[data.length - 1].value, - )(relativePosition); - // Show tooltip with the value - if (value || value === 0) { - // Round or show in scientific notation - if (numDecimalPlaces !== null) { - value = value.toFixed(numDecimalPlaces); - } else { - value = value.toExponential(2).toString(); + try { + if (!colorPalette) { + return; + } + const view = this; + // Data to use in d3 + let data = colorPalette.get("colors").toJSON(); + // The max width of the SVG + const { width } = this.previewSvgDimensions; + // The height of the SVG + const { height } = this.previewSvgDimensions; + // Height of the gradient rectangle, leaving some room for the drop shadow + const gradientHeight = height * 0.92; + + // A unique ID for the gradient + const gradientId = `gradient-${view.cid}`; + + // Calculate the rounding precision we should use based on the + // range of the data. This determines how each value in the legend + // is displayed in the tooltip on mouseover. See the + // rect.on('mousemove'... function, below + data = data.sort((a, b) => a.value - b.value); + const min = data[0].value; + const max = data[data.length - 1].value; + const range = max - min; + const numDecimalPlaces = Utilities.getNumDecimalPlaces(range); + + // SVG element + const svg = this.createSVG({ + dropshadowFilter: false, + width, + height, + }); + + // Add the preview class and dropshadow to the SVG + svg.classed(this.classes.previewSVG, true); + svg.style("filter", "url(#dropshadow)"); + + // Create a gradient using the data + const gradient = svg + .append("defs") + .append("linearGradient") + .attr("id", gradientId) + .attr("x1", "0%") + .attr("y1", "0%"); + + const getOffset = function (d, data) { + return `${((d.value - min) / range) * 100}%`; + }; + const getStopColor = function (d) { + const r = d.color.red * 255; + const g = d.color.green * 255; + const b = d.color.blue * 255; + return `rgb(${r},${g},${b})`; + }; + + // Add the gradient stops + data.forEach((d, i) => { + gradient + .append("stop") + // offset should be relative to the value in the data + .attr("offset", getOffset(d, data)) + .attr("stop-color", getStopColor(d)); + }); + + // Create the rectangle + const rect = svg + .append("rect") + .attr("x", 0) + .attr("y", 0) + .attr("width", width) + .attr("height", gradientHeight) + .attr("rx", gradientHeight * 0.1) + .style("fill", `url(#${gradientId})`); + + // Create a proxy element to attach the tooltip to, so that we can move the + // tooltip to follow the mouse (by moving the proxy element to follow the mouse) + const proxyEl = svg.append("rect").attr("y", gradientHeight); + + rect + .on("mousemove", function () { + if (view.model.get("visible")) { + // Get the coordinates of the mouse relative to the rectangle + let xMouse = d3.mouse(this)[0]; + if (xMouse < 0) { + xMouse = 0; + } + if (xMouse > width) { + xMouse = width; + } + // Get the relative position of the mouse to the gradient + const relativePosition = xMouse / width; + // Get the value at the relative position by interpolating the data + let value = d3.interpolate( + data[0].value, + data[data.length - 1].value, + )(relativePosition); + // Show tooltip with the value + if (value || value === 0) { + // Round or show in scientific notation + if (numDecimalPlaces !== null) { + value = value.toFixed(numDecimalPlaces); + } else { + value = value.toExponential(2).toString(); + } + // Move the proxy element to follow the mouse + proxyEl.attr("x", xMouse); + // Attach the tooltip to the proxy element. Tooltip needs to be + // refreshed every time the mouse moves + $(proxyEl).tooltip("destroy"); + $(proxyEl) + .tooltip({ + placement: "bottom", + trigger: "manual", + title: value, + container: view.$el, + animation: false, + template: `
`, + }) + .tooltip("show"); } - // Move the proxy element to follow the mouse - proxyEl.attr("x", xMouse); - // Attach the tooltip to the proxy element. Tooltip needs to be - // refreshed every time the mouse moves - $(proxyEl).tooltip("destroy"); - $(proxyEl) - .tooltip({ - placement: "bottom", - trigger: "manual", - title: value, - container: view.$el, - animation: false, - template: `
`, - }) - .tooltip("show"); } - } - }) - // Hide tooltip - .on("mouseleave", () => { - $(proxyEl).tooltip("destroy"); - }); + }) + // Hide tooltip + .on("mouseleave", () => { + $(proxyEl).tooltip("destroy"); + }); + } catch (error) { + console.log( + `There was an error rendering a continuous preview legend in a LegendView` + + `. Error details: ${error}`, + ); + } }, /** * Creates an SVG element and inserts it into the view * @param {object} options Used to configure parts of the SVG - * @property {boolean} dropshadowFilter Set to true to create a filter + * @property {boolean} options.dropshadowFilter Set to true to create a filter * element that creates a dropshadow behind any element it is applied to. It can * be added to child elements of the SVG by setting a `filter: url(#dropshadow);` * style rule on the child. - * @property {number} height The relative height of the SVG (for the + * @property {number} options.height The relative height of the SVG (for the * viewBox property) - * @property {number} width The relative width of the SVG (for the viewBox + * @property {number} options.width The relative width of the SVG (for the viewBox * property) * @returns {SVG} Returns the SVG element that is in the view */ createSVG(options = {}) { - // Create an SVG to hold legend elements - const container = this.el; - const { width } = options; - const { height } = options; - - const svg = d3 - .select(container) - .append("svg") - .attr("preserveAspectRatio", "xMidYMid") - .attr("viewBox", [0, 0, width, height]); - - if (options.dropshadowFilter) { - const filterText = ` - - - - - - - - - - `; - - const filterEl = new DOMParser().parseFromString( - `${filterText}`, - "application/xml", - ).documentElement.firstChild; - - svg.node().appendChild(document.importNode(filterEl, true)); - } + try { + // Create an SVG to hold legend elements + const container = this.el; + const { width } = options; + const { height } = options; + + const svg = d3 + .select(container) + .append("svg") + .attr("preserveAspectRatio", "xMidYMid") + .attr("viewBox", [0, 0, width, height]); + + if (options.dropshadowFilter) { + const filterText = ` + + + + + + + + + + `; + + const filterEl = new DOMParser().parseFromString( + `${filterText}`, + "application/xml", + ).documentElement.firstChild; + + svg.node().appendChild(document.importNode(filterEl, true)); + } - return svg; + return svg; + } catch (error) { + console.log( + `There was an error creating an SVG in a LegendView` + + `. Error details: ${error}`, + ); + } }, }, );