/**
* @module nyc/Choropleth
*/
import $ from 'jquery'
import Choice from 'nyc/Choice'
import Collapsible from 'nyc/Collapsible'
import Container from 'nyc/Container'
import Stats from 'nyc/Stats'
/**
* @desc Class for creating a set of controls for gerating rules for choropleth maps
* @public
* @class
* @extends {module:nyc/Container~Container}
* @fires module:nyc/Choropleth~Choropleth#change
*/
class Choropleth extends Container {
/**
* @desc Create an instance of Choropleth
* @public
* @constructor
* @param {module:nyc/Choropleth~Choropleth.Rules=} options The rules to set on the controls
*/
constructor(options) {
super(Choropleth.HTML)
options = options || {}
this.colorSchemes = JSON.parse(JSON.stringify(Choropleth.COLORS))
const counts = []
for (let i = 7; i > 1; i--) {
counts.push({name: 'count', label: `${i} classifications`, values: [i]})
}
counts[0].checked = true
this.count = new Choice({
target: $('<div></div>'),
choices: counts,
radio: true
})
this.appendCollapsible('Number of classifications', this.count.getContainer(), 'count')
this.method = this.choicesFromKeys(Stats.METHODS, 'method', 'mthd')
this.colorScheme = this.choicesFromKeys(this.colorSchemes, 'colorScheme', 'clr-sch')
this.colors = this.choicesFromKeys({}, 'color', 'clr')
this.appendCollapsible('Classification method', this.method.getContainer(), 'cls-mth')
this.appendCollapsible('Color scheme', this.colorScheme.getContainer(), 'clr-sch')
this.colorsClps = this.appendCollapsible('Color', this.colors.getContainer(), 'clr')
this.apply = $('<button class="btn rad-all apply">Apply</button>')
this.getContainer().append(this.apply)
this.colorScheme.on('change', this.colorChoices, this)
this.apply.click($.proxy(this.btnHdlr, this))
this.count.on('change', this.adjustColors, this)
this.method.on('change', this.adjustCounts, this)
this.val(options)
}
/**
* @private
* @method
*/
adjustCounts() {
const lastCount = this.count.val()[0].values[0]
const std = this.method.val()[0].values[0] === Stats.METHODS.stdDeviation.name
let checked = false
const counts = []
for (let i = 7; i > 1; i--) {
if (std) {
if (i % 2) {
counts.push({
name: 'count',
label: `${i} classifications`,
values: [i],
checked: i === lastCount
})
if (i === lastCount) {
checked = true
}
}
} else {
counts.push({
name: 'count',
label: `${i} classifications`,
values: [i],
checked: i === lastCount
})
if (i === lastCount) {
checked = true
}
}
}
if (!checked) {
counts[0].checked = true
}
this.count.setChoices(counts)
this.count.trigger('change', this.count)
}
/**
* @private
* @method
*/
adjustColors() {
const size = this.count.val()[0].values[0]
const colorScheme = this.colorScheme.val()[0].label.toLowerCase()
const colors = this.colorSchemes[colorScheme].values
const inputs = this.colors.inputs
const choices = []
colors.forEach((color, i) => {
const label = $('<div> </div>')
const values = this.resizeColors(color, size)
const input = $(inputs.get(i))
values.forEach(clr => {
label.append(`<div class="clr" style="background-color:${clr}"></div>`)
})
const choice = {
label,
name: input.attr('name'),
values,
checked: input.prop('checked')
}
choices.push(choice)
const reverse = $(Choropleth.REV_BTN_HTML)
.data('choice', choice)
.data('colorScheme', this.colorScheme.val()[0].label.toLowerCase())
.click($.proxy(this.reverseColors, this))
label.append(reverse)
})
this.colors.setChoices(choices)
}
/**
* @private
* @method
* @param {JQuery.Event} event The button click event
*/
reverseColors(event) {
const target = $(event.target)
this.val({
colorScheme: target.data('colorScheme'),
colors: target.data('choice').values.reverse()
})
}
/**
* @desc Get or set the choropleth rules
* @public
* @method
* @param {Chorpleth.Rules=} options The rules to set on the controls
* @returns {Chorpleth.Rules} The rules that are set on the controls
*/
val(options) {
if (options) {
this.count.choices.forEach(choice => {
if (choice.values[0] === options.count) {
this.count.val([choice])
}
})
if (this.count.val().length === 0) {
this.count.val([this.count.choices[0]])
}
this.method.choices.forEach(choice => {
if (choice.values[0] === options.method) {
this.method.val([choice])
}
})
if (this.method.val().length === 0) {
this.method.val([this.method.choices[0]])
}
this.colorScheme.choices.forEach(choice => {
if (choice.label.toLowerCase() === options.colorScheme) {
this.colorScheme.val([choice])
}
})
if (this.colorScheme.val().length === 0) {
this.colorScheme.val([this.colorScheme.choices[0]])
}
this.colorScheme.trigger('change', this.colorScheme)
this.colors.choices.some((choice, i) => {
if (this.arrEq(choice.values, options.colors)) {
this.colors.val([choice])
this.adjustColors()
return true
} else if (this.arrEq(choice.values.reverse(), options.colors)) {
this.colorSchemes[options.colorScheme].values[i].reverse()
this.colors.val([choice])
this.adjustColors()
return true
} else {
choice.values.reverse()
}
})
this.adjustCounts()
} else {
let colors = this.colors.val()[0].values
colors = this.resizeColors(colors, this.count.val())
return {
count: this.count.val()[0].values[0],
colorScheme: this.colorScheme.val()[0].label.toLowerCase(),
method: this.method.val()[0].values[0],
colors
}
}
}
/**
* @private
* @method
* @param {Array<number>|Array<string>} array1 One array
* @param {Array<number>|Array<string>} array2 Another array
* @returns {boolean} True if the arrays are the same
*/
arrEq(array1, array2) {
if (array1 && array2 && array1.length === array2.length) {
let equal = true
array1.some((a, i) => {
equal = a === array2[i]
return !equal
})
return equal
}
return false
}
/**
* @private
* @method
* @param {Array<string>} original The original colors
* @param {number} size The new size
* @returns {Array<string>} The resized colors
*/
resizeColors(original, size) {
const modified = []
original.forEach(element => {
modified.push(element)
})
while (modified.length > size) {
const mid = Math.floor((modified.length - 1) / 2)
modified.splice(mid, 1)
}
return modified
}
/**
* @private
* @method
*/
btnHdlr() {
this.trigger('change', this)
}
/**
* @private
* @method
* @param {string} title The title
* @param {JQuery} content The content
* @param {string} css A CSS class
* @returns {module:nyc/Collapsible~Collapsible} The collapsible that was appended
*/
appendCollapsible(title, content, css) {
const collapsible = new Collapsible({
target: $(`<div class="${css}"></div>`),
title,
content,
collapsed: title === 'Color'
})
this.getContainer().append(collapsible.getContainer())
return collapsible
}
/**
* @private
* @method
* @param {Object} obj The object
* @param {string} name The name
* @param {string} css A CSS class
* @returns {module:nyc/Choice~Choice} The radio buttons
*/
choicesFromKeys(obj, name, css) {
const choices = []
Object.keys(obj).forEach(key => {
const props = obj[key]
const label = props.label
let values = props.values
if (!$.isArray(values)) {
values = [props.name]
}
choices.push({name, label, values})
})
const radio = new Choice({
target: $(`<div class="${css}"></div>`),
choices,
radio: true
})
return radio
}
/**
* @private
* @method
*/
colorChoices() {
const choices = []
this.colorScheme.val().forEach(schemes => {
schemes.values.forEach((scheme, i) => {
const label = $('<div class="clr"> </div>')
scheme.forEach(color => {
label.append(`<div style="background-color:${color}"></div>`)
})
choices.push({
name: 'colors',
label,
values: scheme,
checked: $(this.colors.inputs[i]).prop('checked')
})
})
})
this.colors.setChoices(choices)
this.adjustColors()
if (this.colors.val().length === 0) {
this.colors.val([this.colors.choices[0]])
}
}
/**
* @private
* @method
* @param {string} color An HTML hex color
* @param {number} min The minimum value
* @param {number} max The maximum value
* @param {number} places The number of places for rounding of values
* @returns {JQuery} The legend item
*/
legItem(color, min, max, places) {
places = places || 0
return $(`<div class="it">
<div class="sym" style="background-color:${color}"> </div>
<div class="gt">${new Number(min.toFixed(places)).toLocaleString()}</div>
<div class="to">-</div>
<div class="lt">${new Number(max.toFixed(places)).toLocaleString()}</div>
</div>`)
}
/**
* @desc Generates an HTML legend for a choropleth map
* @public
* @method
* @param {string} title The title of the legend
* @param {Array<number>} classifications The numeric classification buckets for the legend
* @param {Array<string>} colors The colors for the classifications
* @param {number} places The nuber of places for rounding of the classifications
* @returns {JQuery} The legend
*/
legend(title, classifications, colors, places) {
const legend = $(Choropleth.LEGEND_HTML)
const items = legend.find('.its')
legend.find('h3').html(title)
classifications.forEach((cls, i) => {
if (i < classifications.length - 1) {
const item = this.legItem(colors[i], cls, classifications[i + 1], places)
if (i === 0) {
$(item.children().get(1)).remove()
$(item.children().get(1)).remove()
$('<div class="op"><</div>').insertBefore($(item.children().get(1)))
}
if (i === classifications.length - 2) {
item.children().last().remove()
item.children().last().remove()
$('<div class="op">></div>').insertBefore(item.children().last())
}
items.append(item)
}
})
return legend
}
}
/**
* @desc Color choices for choropleth maps
* @public
* @const
* @type {Object}
*/
Choropleth.COLORS = {
divergent: {
label: 'Divergent',
values: [
['#762a83', '#af8dc3', '#e7d4e8', '#f7f7f7', '#d9f0d3', '#7fbf7b', '#1b7837'],
['#8c510a', '#d8b365', '#f6e8c3', '#f5f5f5', '#c7eae5', '#5ab4ac', '#01665e'],
['#b2182b', '#ef8a62', '#fddbc7', '#ffffff', '#e0e0e0', '#999999', '#4d4d4d']
]
},
sequential: {
label: 'Sequential',
values: [
['#feedde', '#fdd0a2', '#fdae6b', '#fd8d3c', '#f16913', '#d94801', '#8c2d04'],
['#fee5d9', '#fcbba1', '#fc9272', '#fb6a4a', '#ef3b2c', '#cb181d', '#99000d'],
['#ffffb2', '#fed976', '#feb24c', '#fd8d3c', '#fc4e2a', '#e31a1c', '#b10026']
]
}
}
/**
* @desc Rules options for {@link module:nyc/Choropleth~Choropleth#val}
* @public
* @typedef {Object}
* @property {number} count The number of classifications
* @property {string} method The classification method
* @property {string} colorScheme The name of the color scheme ('divergent' or 'sequential')
* @property {Array<string>} colors An array of hex colors
*/
Choropleth.Rules
/**
* @desc The change event
* @event module:nyc/Choropleth~Choropleth#change
* @type {module:nyc/Choropleth~Choropleth}
*/
/**
* @private
* @const
* @type {string}
*/
Choropleth.HTML = '<div class="choro"></div>'
/**
* @private
* @const
* @type {string}
*/
Choropleth.LEGEND_HTML = `<div class="leg" title="Map legend">
<h3></h3>
<div class="its"></div>
</div>`
/**
* @private
* @const
* @type {string}
*/
Choropleth.REV_BTN_HTML = `<button class="rev" title="Reverse colors">
<span class="screen-reader-only">Reverse colors</span>⇄
</button>`
export default Choropleth