//////////////////////////////////////////////////////////////////////////////
// Legend
//////////////////////////////////////////////////////////////////////////////
import CGObject from './CGObject';
import CGArray from './CGArray';
import LegendItem from './LegendItem';
import Color from './Color';
import Font from './Font';
import Box from './Box';
import utils from './Utils';
import * as d3 from 'd3';
/**
* The Legend contains the [legendItems](LegendItem.html) for the maps and can be placed anywhere on the canvas or map.
*
* ### Action and Events
*
* Action | Viewer Method | Legend Method | Event
* ----------------------------------------|------------------------------|--------------------------------|-----
* [Update](../docs.html#updating-records) | - | [update()](Legend.html#update) | legends-update
* [Read](../docs.html#reading-records) | [legend](Viewer.html#legend) | - | -
*
* <a name="attributes"></a>
* ### Attributes
*
* Attribute | Type | Description
* -----------------------------------|-----------|------------
* [position](#position) | String\|Object | Where to draw the legend [Default: 'top-right']. See {@link Position} for details.
* [anchor](#anchor) | String\|Object | Where to anchor the legend box to the position [Default: 'auto']. See {@link Anchor} for details.
* [defaultFont](#defaultFont) | String | A string describing the default font [Default: 'SansSerif, plain, 8']. See {@link Font} for details.
* [defaultFontColor](#defaultFontColor) | String | A string describing the default font color [Default: 'black']. See {@link Color} for details.
* [defaultMinArcLength](#defaultMinArcLength) | Number | Default minimum length in pixels to use when drawing arcs. From 0 to 2 pixels [Default: 1]
* [textAlignment](#textAlignment) | String | Alignment of legend text: *left*, or *right* [Default: 'left']
* [backgroundColor](#font) | String | A string describing the background color of the legend [Default: 'white']. See {@link Color} for details.
* [on](#on)<sup>ic</sup> | String | Place the legend relative to the 'canvas' or 'map' [Default: 'canvas']
* [items](#items)<sup>iu</sup> | Array | Array of legend item data.
* [visible](CGObject.html#visible) | Boolean | Legend is visible [Default: true]
* [meta](CGObject.html#meta) | Object | [Meta data](../tutorials/details-meta-data.html)
*
* <sup>ic</sup> Ignored on Legend creation
* <sup>iu</sup> Ignored on Legend update
*
* ### Examples
*
* @extends CGObject
*/
class Legend extends CGObject {
/**
* Create a new Legend.
* @param {Viewer} viewer - The viewer
* @param {Object} options - [Attributes](#attributes) used to create the legend
* @param {Object} [meta] - User-defined [Meta data](../tutorials/details-meta-data.html) to add to the legend.
*/
constructor(viewer, options = {}, meta = {}) {
super(viewer, options, meta);
this._items = new CGArray();
this.backgroundColor = options.backgroundColor;
// FIXME: start using defaultFontColor, etc from JSON
this.defaultFontColor = utils.defaultFor(options.defaultFontColor, 'black');
this.textAlignment = utils.defaultFor(options.textAlignment, 'left');
this.box = new Box(viewer, {
position: utils.defaultFor(options.position, 'top-right'),
anchor: utils.defaultFor(options.anchor, 'middle-center')
});
// Setting font will refresh legend and draw
this.defaultFont = utils.defaultFor(options.defaultFont, 'sans-serif, plain, 14');
this.defaultMinArcLength = utils.defaultFor(options.defaultMinArcLength, 1);
this.viewer.trigger('legend-update', { attributes: this.toJSON({includeDefaults: true}) });
if (options.items) {
this.addItems(options.items);
}
// FIXME: should be done whenever an item is added
this.refresh();
}
/**
* Return the class name as a string.
* @return {String} - 'Legend'
*/
toString() {
return 'Legend';
}
get visible() {
return this._visible;
}
set visible(value) {
// super.visible = value;
this._visible = value;
this.viewer.refreshCanvasLayer();
// this.refresh();
}
/**
* @member {Context} - Get the *Context* for drawing.
* @private
*/
// FIXME:
// - if this is slow we could be set when setting "on" (e.g. this._ctx = ...)
get ctx() {
// return this._ctx || this.canvas.context('forground');
const layer = (this.on === 'map') ? 'foreground' : 'canvas';
return this.canvas.context(layer);
}
//
// /**
// * @member {String} - Alias for getting the position. Useful for querying CGArrays.
// */
// get id() {
// return this.position;
// }
/**
* @member {Position} - Get or set the position
*/
get position() {
return this.box.position;
}
set position(value) {
this.clear();
this.box.position = value;
this.viewer.refreshCanvasLayer();
// this.refresh();
}
/**
* @member {String} - Get or set where the legend should be position: 'canvas', 'map'
*/
get on() {
return this.box.on;
}
set on(value) {
this.clear();
this.box.on = value;
this.refresh();
}
/**
* @member {Anchor} - Get or set legend anchor
*/
get anchor() {
return this.box.anchor;
}
set anchor(value) {
this.clear();
this.box.anchor = value;
this.refresh();
}
/**
* @member {Color} - Get or set the backgroundColor. When setting the color, a string representing the color or a {@link Color} object can be used. For details see {@link Color}.
*/
get backgroundColor() {
// TODO set to cgview background color if not defined
return this._backgroundColor;
}
set backgroundColor(color) {
// this._backgroundColor.color = color;
if (color === undefined) {
this._backgroundColor = this.viewer.settings.backgroundColor;
} else if (color.toString() === 'Color') {
this._backgroundColor = color;
} else {
this._backgroundColor = new Color(color);
}
this.refresh();
}
/**
* @member {Font} - Get or set the default font. When setting the font, a string representing the font or a {@link Font} object can be used. For details see {@link Font}.
*/
get defaultFont() {
return this._defaultFont;
}
set defaultFont(value) {
if (value.toString() === 'Font') {
this._defaultFont = value;
} else {
this._defaultFont = new Font(value);
}
// Trigger update events for items with default font
for (let i = 0, len = this._items.length; i < len; i++) {
const item = this._items[i];
if (item.usingDefaultFont) {
item.update({font: undefined});
}
}
this.refresh();
}
/**
* @member {Color} - Get or set the defaultFontColor. When setting the color, a string representing the color or a {@link Color} object can be used. For details see {@link Color}.
*/
get defaultFontColor() {
// return this._fontColor.rgbaString;
return this._defaultFontColor;
}
set defaultFontColor(value) {
if (value.toString() === 'Color') {
this._defaultFontColor = value;
} else {
this._defaultFontColor = new Color(value);
}
// Trigger update events for items with default font color
for (let i = 0, len = this._items.length; i < len; i++) {
const item = this._items[i];
if (item.usingDefaultFontColor) {
item.update({fontColor: undefined});
}
}
this.refresh();
}
/**
* @member {String} - Get or set the text alignment. Possible values are *left*, or *right*.
*/
get textAlignment() {
return this._textAlignment;
}
set textAlignment(value) {
if ( utils.validate(value, ['left', 'right']) ) {
this._textAlignment = value;
}
this.refresh();
}
/**
* @member {Number} - Get or set the defaultMinArcLength for legend items. The value must be between 0 to 2 pixels [Default: 1].
* Minimum arc length refers to the minimum size (in pixels) an arc will be drawn.
* At some scales, small features will have an arc length of a fraction
* of a pixel. In these cases, the arcs are hard to see.
* A minArcLength of 0 means no adjustments will be made.
*/
get defaultMinArcLength() {
return this._defaultMinArcLength;
}
set defaultMinArcLength(value) {
this._defaultMinArcLength = utils.constrain(Number(value), 0, 2);
// Trigger update events for items with default font
for (let i = 0, len = this._items.length; i < len; i++) {
const item = this._items[i];
if (item.usingDefaultMinArcLength) {
item.update({minArcLength: undefined});
}
}
}
/**
* @member {LegendItem} - Get or set the selected swatch legendItem
* @private
*/
get selectedSwatchedItem() {
return this._selectedSwatchedItem;
}
set selectedSwatchedItem(value) {
this._selectedSwatchedItem = value;
}
/**
* @member {LegendItem} - Get or set the highlighted swatch legendItem
* @private
*/
get highlightedSwatchedItem() {
return this._highlightedSwatchedItem;
}
set highlightedSwatchedItem(value) {
this._highlightedSwatchedItem = value;
}
/**
* Update legend [attributes](#attributes).
* See [updating records](../docs.html#s.updating-records) for details.
* @param {Object} attributes - Object describing the properties to change
*/
update(attributes) {
this.viewer.updateRecords(this, attributes, {
recordClass: 'Legend',
validKeys: ['on', 'position', 'anchor', 'defaultFont', 'defaultFontColor', 'defaultMinArcLength', 'textAlignment', 'backgroundColor', 'visible']
});
this.viewer.trigger('legend-update', { attributes });
}
/**
* @member {CGArray} - Get the
*/
/**
* Returns a [CGArray](CGArray.html) of legendItems or a single legendItem.
* See [reading records](../docs.html#s.reading-records) for details.
* @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
* @return {LegendItem|CGArray}
*/
items(term) {
return this._items.get(term);
}
/**
* @member {CGArray} - Get the vidible legendItems
* @private
*/
visibleItems(term) {
return this._items.filter( i => i.visible ).get(term);
}
/**
* Add one or more [legendItems](LegendItem.html) (see [attributes](LegendItem.html#attributes)).
* See [adding records](../docs.html#s.adding-records) for details
* @param {Object|Array} data - Object or array of objects describing the legendItems
* @return {CGArray<LegendItem>} CGArray of added legendItems
*/
addItems(itemData = []) {
itemData = CGArray.arrayerize(itemData);
const items = itemData.map( (data) => new LegendItem(this, data));
this.viewer.trigger('legendItems-add', items);
return items;
}
/**
* Remove legendItems.
* See [removing records](../docs.html#s.removing-records) for details
* @param {LegendItem|Array} items - legendItem or a array of legendItems to remove
*/
removeItems(items) {
items = CGArray.arrayerize(items);
this._items = this._items.filter( i => !items.includes(i) );
this.viewer.clear('canvas');
this.viewer.refreshCanvasLayer();
// Remove from Objects
items.forEach( i => i.deleteFromObjects() );
this.viewer.trigger('legendItems-remove', items);
}
/**
* Update [attributes](LegendItem.html#attributes) for one or more legendItems.
* See [updating records](../docs.html#s.updating-records) for details.
* @param {LegendItem|Array|Object} itemsOrUpdates - legendItem, array of legendItems or object describing updates
* @param {Object} attributes - Object describing the properties to change
*/
updateItems(itemsOrUpdates, attributes) {
const { records: items, updates } = this.viewer.updateRecords(itemsOrUpdates, attributes, {
recordClass: 'LegendItem',
validKeys: ['name', 'font', 'fontColor', 'drawSwatch', 'swatchColor', 'decoration', 'minArcLength', 'visible']
});
this.viewer.trigger('legendItems-update', { items, attributes, updates });
}
/**
* Move a legendItem from one index to a new one
* @param {Number} oldIndex - Index of legendItem to move (0-based)
* @param {Number} newIndex - New index for the legendItem (0-based)
*/
moveItem(oldIndex, newIndex) {
this._items.move(oldIndex, newIndex);
this.viewer.trigger('legendItems-moved', {oldIndex: oldIndex, newIndex: newIndex});
this.refresh();
}
/**
* Move to the Legend position (if it's position on the map)
* @param {Number} duration - Duration of the animation
*/
moveTo(duration) {
this.position.moveTo(duration);
}
/**
* Recalculates the *Legend* size and position.
* @private
*/
refresh() {
const box = this.box;
if (!box) { return; }
this.clear();
let height = 0;
let maxHeight = 0;
const visibleItems = this.visibleItems();
for (let i = 0, len = visibleItems.length; i < len; i++) {
const item = visibleItems[i];
const itemHeight = item.height;
height += itemHeight;
if (i < len - 1) {
// Add spacing
height += (itemHeight / 2);
}
if (itemHeight > maxHeight) {
maxHeight = itemHeight;
}
}
box.padding = maxHeight / 2;
height += box.padding * 2;
// Calculate Legend Width
const itemFonts = visibleItems.map( i => i.font.css );
const itemNames = visibleItems.map( i => i.name );
const itemWidths = Font.calculateWidths(this.ctx, itemFonts, itemNames);
for (let i = 0, len = itemWidths.length; i < len; i++) {
const item = visibleItems[i];
if (item.drawSwatch) {
itemWidths[i] += item.height + (box.padding / 2);
}
item._width = itemWidths[i];
}
const width = d3.max(itemWidths) + (box.padding * 2);
box.resize(width, height);
this.draw();
}
/**
* Sets the position of the [ColorPicker](ColorPicker.html).
* @private
*/
setColorPickerPosition(cp) {
const margin = 5;
const originX = this.box.x;
const originY = this.box.y;
// Default: left of legend and aligned with top
let pos = {x: originX - cp.width - margin, y: originY + margin};
const legendWidth = this.box.width;
const legendHeight = this.box.height;
if (originX < cp.width) {
pos.x = originX + legendWidth + margin;
}
if ( (this.viewer.height - originY) < cp.height) {
pos.y = this.box.bottom - cp.height - margin;
}
cp.setPosition(pos);
}
/**
* @member {Number} - Get the swatch padding
* @private
*/
get swatchPadding() {
return this.box.padding / 2;
}
/**
* Fills the legend background color
* @private
*/
fillBackground() {
const box = this.box;
this.ctx.fillStyle = this.backgroundColor.rgbaString;
this.clear();
this.ctx.fillRect(box.x, box.y, box.width, box.height);
}
/**
* Invert colors of all legendItems
*/
invertColors() {
this.update({
backgroundColor: this.backgroundColor.invert().rgbaString,
defaultFontColor: this.defaultFontColor.invert().rgbaString
});
this.items().each( (i, item) => item.invertColors() );
}
/**
* Find the legendItem with the provided name or return undefined.
* @param {String} name - Name of legendItem
* @return {LegendItem} Returns undefined if not found
*/
findLegendItemByName(name) {
if (typeof name !== 'string') { return; }
// console.log(name)
return this._items.find( i => name.toLowerCase() === i.name.toLowerCase() );
}
/**
* Find the legendItem with the provided name or create a new legendItem.
* @param {String} name - Name of legendItem
* @param {Color} color - Use this color if creating a new legendItem
* @param {String} decoration - Use this decoration if creating a new legendItem
* @return {LegendItem}
*
*/
findLegendItemOrCreate(name = 'Unknown', color = null, decoration = 'arc') {
let item = this.findLegendItemByName(name);
if (!item) {
const obj = this.viewer.objects(name);
if (obj && obj.toString() === 'LegendItem') {
item = obj;
}
}
if (!item) {
if (!color) {
const currentColors = this._items.map( i => i.swatchColor );
// color = Color.getColor(currentColors);
color = Color.getColor(currentColors).rgbaString;
}
item = this.addItems({
name: name,
swatchColor: color,
decoration: decoration
})[0];
}
return item;
}
/**
* Returns a CGArray of LegendItems that only occur for the supplied features.
* (i.e. the returned LegendItems are not being used for any features (or plots) not provided.
* This is useful for determining if LegendItems should be deleted after deleting features.
* @private
*/
uniqueLegendsItemsFor(options = {}) {
const selectedFeatures = new Set(options.features || []);
const selectedPlots = new Set(options.plots || []);
const uniqueItems = new Set();
selectedFeatures.forEach( (f) => {
uniqueItems.add(f.legend);
});
selectedPlots.forEach( (p) => {
uniqueItems.add(p.legendItemPositive);
uniqueItems.add(p.legendItemNegative);
});
const nonSelectedFeatures = new Set();
this.viewer.features().each( (i, f) => {
if (!selectedFeatures.has(f)) {
nonSelectedFeatures.add(f);
}
});
const nonSelectedPlots = new Set();
this.viewer.plots().each( (i, p) => {
if (!selectedPlots.has(p)) {
nonSelectedPlots.add(p);
}
});
nonSelectedFeatures.forEach( (f) => {
if (uniqueItems.has(f.legend)) {
uniqueItems.delete(f.legend);
}
});
nonSelectedPlots.forEach( (p) => {
if (uniqueItems.has(p.legendItemPositive)) {
uniqueItems.delete(p.legendItemPositive);
}
if (uniqueItems.has(p.legendItemNegative)) {
uniqueItems.delete(p.legendItemNegative);
}
});
return Array.from(uniqueItems);
}
/**
* Clear the box containing the legend
*/
clear() {
this.box.clear(this.ctx);
}
/**
* Draw the legend
* @private
*/
draw() {
if (!this.visible) { return; }
const ctx = this.ctx;
// Update the box origin if relative to the map
this.box.refresh();
this.fillBackground();
let swatchX;
ctx.lineWidth = 1;
// ctx.textBaseline = 'top';
ctx.textBaseline = 'alphabetic'; // The default baseline works best across canvas and svg
for (let i = 0, len = this._items.length; i < len; i++) {
const legendItem = this._items[i];
if (!legendItem.visible) { continue; }
const y = legendItem.textY();
const drawSwatch = legendItem.drawSwatch;
const swatchWidth = legendItem.swatchWidth;
ctx.font = legendItem.font.css;
ctx.textAlign = legendItem.textAlignment;
if (drawSwatch) {
// Swatch border color
if (legendItem.swatchSelected) {
ctx.strokeStyle = 'black';
} else if (legendItem.swatchHighlighted) {
ctx.strokeStyle = 'grey';
}
// Draw box around Swatch depending on state
swatchX = legendItem.swatchX();
if (legendItem.swatchSelected || legendItem.swatchHighlighted) {
const border = 2;
ctx.strokeRect(swatchX - border, y - border, swatchWidth + (border * 2), swatchWidth + (border * 2));
}
// Draw Swatch
ctx.fillStyle = legendItem.swatchColor.rgbaString;
ctx.fillRect(swatchX, y, swatchWidth, swatchWidth);
}
// Draw Text Label
ctx.fillStyle = legendItem.fontColor.rgbaString;
// ctx.fillText(legendItem.name, legendItem.textX(), y);
ctx.fillText(legendItem.name, legendItem.textX(), y + legendItem.height - 1);
}
}
/**
* Returns JSON representing the object
*/
toJSON(options = {}) {
const json = {
name: this.name,
position: this.position.toJSON(options),
textAlignment: this.textAlignment,
defaultFont: this.defaultFont.string,
defaultFontColor: this.defaultFontColor.rgbaString,
defaultMinArcLength: this.defaultMinArcLength,
backgroundColor: this.backgroundColor.rgbaString,
items: []
};
if (this.position.onMap) {
json.anchor = this.anchor.toJSON(options);
}
// Optionally add default values
if (!this.visible || options.includeDefaults) {
json.visible = this.visible;
}
this.items().each( (i, item) => {
json.items.push(item.toJSON(options));
});
return json;
}
}
export default Legend;