"use strict";
define([
"jquery",
"underscore",
"backbone",
"d3",
"models/maps/AssetColorPalette",
"common/Utilities",
"text!templates/maps/legend.html",
], ($, _, Backbone, d3, AssetColorPalette, Utilities, Template) => {
/**
* @class LegendView
* @classdesc Creates a legend for a given Map Asset (Work In Progress). Currently
* supports making 'preview' legends for CesiumImagery assets and Cesium3DTileset
* assets (only for color palettes that are type 'categorical'). Eventually, will
* support full-sized legend for these, and other assets, and all types of color
* palettes (including 'continuous' and 'classified')
* @classcategory Views/Maps
* @name LegendView
* @augments Backbone.View
* @screenshot views/maps/LegendView.png
* @since 2.18.0
* @constructs
*/
const LegendView = Backbone.View.extend(
/** @lends LegendView.prototype */ {
/**
* The type of View this is
* @type {string}
*/
type: "LegendView",
/**
* The HTML classes to use for this view's element
* @type {string}
*/
className: "map-legend",
/**
* The MapAsset model that this view uses - currently supports CesiumImagery and
* Cesium3DTileset models.
* @type {MapAsset}
*/
model: null,
/**
* The primary HTML template for this view
* @type {Underscore.template}
*/
template: _.template(Template),
/**
* The events this view will listen to and the associated function to call.
* @type {object}
*/
events: {
// 'event selector': 'function',
},
/**
* Which type of legend to show? Can be set to either 'full' for a complete legend
* with labels, title, and all color coding, or 'preview' for just a small
* thumbnail of the colors used in the full legend.
* @type {string}
*/
mode: "preview",
/**
* For vector preview legends, the relative dimensions to use. The SVG's
* dimensions are set with a viewBox property only, so the height and width
* represent an aspect ratio rather than absolute size.
* @type {object}
* @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
* in the preview legend. Squares will be spaced 20% closed than this when the
* legend is not hovered over.
*/
previewSvgDimensions: {
width: 160,
height: 45,
squareSpacing: 20,
},
/**
* Classes that are used to identify, or that are added to, the HTML elements that
* comprise this view.
* @type {object}
* @property {string} preview Additional class to add to legend that are the
* preview/thumbnail version
* @property {string} previewSVG The SVG element that holds the shapes with all
* the legend colours in the preview legend.
* @property {string} previewImg The image element that represents a thumbnail of
* image layers, in preview legends
* @property {string} tooltip Class added to tooltips used in preview legends
*/
classes: {
preview: "map-legend--preview",
previewSVG: "map-legend__svg--preview",
previewImg: "map-legend__img--preview",
tooltip: "map-tooltip",
},
/**
* Executed when a new LegendView is created
* @param {object} [options] - A literal object with options to pass to the view
*/
initialize(options) {
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}`);
}
},
/**
* Renders this view
* @returns {LegendView} Returns the rendered view element
*/
render() {
try {
if (!this.model) {
return;
}
// Save a reference to this view
const view = this;
// The color palette maps colors to attributes of the map asset
let colorPalette = null;
// For color palettes,
let paletteType = null;
const { mode } = this;
// Insert the template into the view
this.$el.html(this.template({}));
// Ensure the view's main element has the given class name
this.el.classList.add(this.className);
// Add a modifier class if this is a preview of a legend
if (mode === "preview") {
this.el.classList.add(this.classes.preview);
}
// 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");
}
}
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"));
}
}
}
// TODO:
// - preview classified legend
// - full legends with labels, title, etc.
return this;
} catch (error) {
console.log(
`There was an error rendering a Legend View` +
`. Error details: ${error}`,
);
}
},
/**
* Inserts a thumbnail in image into this view
* @param {string} thumbnailURL A url to use for the src property of the thumbnail
* image
*/
renderImagePreviewLegend(thumbnailURL) {
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}`,
);
}
},
/**
* Creates a preview legend for categorical color palettes and inserts it into the
* view
* @param {AssetColorPalette} colorPalette - The AssetColorPalette that maps
* feature attributes to colors, used to create the legend
*/
renderCategoricalPreviewLegend(colorPalette) {
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) => {
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 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(200)
.attr("x", (d, i) => getSquareX(i, false));
});
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: `<div class="tooltip ${view.classes.tooltip}"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>`,
})
.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}`,
);
}
},
/**
* Creates a preview legend for continuous color palettes and inserts it into the
* view
* @param {AssetColorPalette} colorPalette - The AssetColorPalette that maps
* feature attributes to colors, used to create the legend
*/
renderContinuousPreviewLegend(colorPalette) {
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: `<div class="tooltip ${view.classes.tooltip}"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>`,
})
.tooltip("show");
}
}
})
// 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} 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} options.height The relative height of the SVG (for the
* viewBox property)
* @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 = {}) {
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 = `<filter id="dropshadow" height="110%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/> <!-- stdDeviation is how much to blur -->
<feOffset dx="1" dy="1" result="offsetblur"/> <!-- how much to offset -->
<feComponentTransfer>
<feFuncA type="linear" slope="0.7"/> <!-- slope is the opacity of the shadow -->
</feComponentTransfer>
<feMerge>
<feMergeNode/> <!-- this contains the offset blurred image -->
<feMergeNode in="SourceGraphic"/> <!-- this contains the element that the filter is applied to -->
</feMerge>
</filter>`;
const filterEl = new DOMParser().parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg">${filterText}</svg>`,
"application/xml",
).documentElement.firstChild;
svg.node().appendChild(document.importNode(filterEl, true));
}
return svg;
} catch (error) {
console.log(
`There was an error creating an SVG in a LegendView` +
`. Error details: ${error}`,
);
}
},
},
);
return LegendView;
});