import {geoPath, geoAlbersUsa} from 'd3-geo'; import {select} from 'd3-selection'; import {BaseMixin} from '../base/base-mixin'; import {ColorMixin} from '../base/color-mixin'; import {transition} from '../core/core'; import {logger} from '../core/logger'; import {events} from '../core/events'; import {utils} from '../core/utils'; import {d3compat} from '../core/config'; /** * The geo choropleth chart is designed as an easy way to create a crossfilter driven choropleth map * from GeoJson data. This chart implementation was inspired by * {@link http://bl.ocks.org/4060606 the great d3 choropleth example}. * * Examples: * - {@link http://dc-js.github.com/dc.js/vc/index.html US Venture Capital Landscape 2011} * @mixes ColorMixin * @mixes BaseMixin */ export class GeoChoroplethChart extends ColorMixin(BaseMixin) { /** * Create a Geo Choropleth Chart. * @example * // create a choropleth chart under '#us-chart' element using the default global chart group * var chart1 = new GeoChoroplethChart('#us-chart'); * // create a choropleth chart under '#us-chart2' element using chart group A * var chart2 = new CompositeChart('#us-chart2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. */ constructor (parent, chartGroup) { super(); this.colorAccessor(d => d || 0); this._geoPath = geoPath(); this._projectionFlag = undefined; this._projection = undefined; this._geoJsons = []; this.anchor(parent, chartGroup); } _doRender () { this.resetSvg(); for (let layerIndex = 0; layerIndex < this._geoJsons.length; ++layerIndex) { const states = this.svg().append('g') .attr('class', `layer${layerIndex}`); let regionG = states.selectAll(`g.${this._geoJson(layerIndex).name}`) .data(this._geoJson(layerIndex).data); regionG = regionG.enter() .append('g') .attr('class', this._geoJson(layerIndex).name) .merge(regionG); regionG .append('path') .classed('dc-tabbable', this._keyboardAccessible) .attr('fill', 'white') .attr('d', this._getGeoPath()); regionG.append('title'); this._plotData(layerIndex); } this._projectionFlag = false; } _plotData (layerIndex) { const data = this._generateLayeredData(); if (this._isDataLayer(layerIndex)) { const regionG = this._renderRegionG(layerIndex); this._renderPaths(regionG, layerIndex, data); this._renderTitles(regionG, layerIndex, data); } } _generateLayeredData () { const data = {}; const groupAll = this.data(); for (let i = 0; i < groupAll.length; ++i) { data[this.keyAccessor()(groupAll[i])] = this.valueAccessor()(groupAll[i]); } return data; } _isDataLayer (layerIndex) { return this._geoJson(layerIndex).keyAccessor; } _renderRegionG (layerIndex) { const regionG = this.svg() .selectAll(this._layerSelector(layerIndex)) .classed('selected', d => this._isSelected(layerIndex, d)) .classed('deselected', d => this._isDeselected(layerIndex, d)) .attr('class', d => { const layerNameClass = this._geoJson(layerIndex).name; const regionClass = utils.nameToId(this._geoJson(layerIndex).keyAccessor(d)); let baseClasses = `${layerNameClass} ${regionClass}`; if (this._isSelected(layerIndex, d)) { baseClasses += ' selected'; } if (this._isDeselected(layerIndex, d)) { baseClasses += ' deselected'; } return baseClasses; }); return regionG; } _layerSelector (layerIndex) { return `g.layer${layerIndex} g.${this._geoJson(layerIndex).name}`; } _isSelected (layerIndex, d) { return this.hasFilter() && this.hasFilter(this._getKey(layerIndex, d)); } _isDeselected (layerIndex, d) { return this.hasFilter() && !this.hasFilter(this._getKey(layerIndex, d)); } _getKey (layerIndex, d) { return this._geoJson(layerIndex).keyAccessor(d); } _geoJson (index) { return this._geoJsons[index]; } _renderPaths (regionG, layerIndex, data) { const paths = regionG .select('path') .attr('fill', function () { const currentFill = select(this).attr('fill'); if (currentFill) { return currentFill; } return 'none'; }) .on('click', d3compat.eventHandler(d => this.onClick(d, layerIndex))); if (this._keyboardAccessible) { this._makeKeyboardAccessible(this.onClick, layerIndex); } transition(paths, this.transitionDuration(), this.transitionDelay()).attr('fill', (d, i) => this.getColor(data[this._geoJson(layerIndex).keyAccessor(d)], i)); } onClick (d, layerIndex) { const selectedRegion = this._geoJson(layerIndex).keyAccessor(d); events.trigger(() => { this.filter(selectedRegion); this.redrawGroup(); }); } _renderTitles (regionG, layerIndex, data) { if (this.renderTitle()) { regionG.selectAll('title').text(d => { const key = this._getKey(layerIndex, d); const value = data[key]; return this.title()({key: key, value: value}); }); } } _doRedraw () { for (let layerIndex = 0; layerIndex < this._geoJsons.length; ++layerIndex) { this._plotData(layerIndex); if (this._projectionFlag) { this.svg().selectAll(`g.${this._geoJson(layerIndex).name} path`).attr('d', this._getGeoPath()); } } this._projectionFlag = false; } /** * **mandatory** * * Use this function to insert a new GeoJson map layer. This function can be invoked multiple times * if you have multiple GeoJson data layers to render on top of each other. If you overlay multiple * layers with the same name the new overlay will override the existing one. * @see {@link http://geojson.org/ GeoJSON} * @see {@link https://github.com/topojson/topojson/wiki TopoJSON} * @see {@link https://github.com/topojson/topojson-1.x-api-reference/blob/master/API-Reference.md#wiki-feature topojson.feature} * @example * // insert a layer for rendering US states * chart.overlayGeoJson(statesJson.features, 'state', function(d) { * return d.properties.name; * }); * @param {_geoJson} json - a geojson feed * @param {String} name - name of the layer * @param {Function} keyAccessor - accessor function used to extract 'key' from the GeoJson data. The key extracted by * this function should match the keys returned by the crossfilter groups. * @returns {GeoChoroplethChart} */ overlayGeoJson (json, name, keyAccessor) { for (let i = 0; i < this._geoJsons.length; ++i) { if (this._geoJsons[i].name === name) { this._geoJsons[i].data = json; this._geoJsons[i].keyAccessor = keyAccessor; return this; } } this._geoJsons.push({name: name, data: json, keyAccessor: keyAccessor}); return this; } /** * Gets or sets a custom geo projection function. See the available * {@link https://github.com/d3/d3-geo/blob/master/README.md#projections d3 geo projection functions}. * * Starting version 3.0 it has been deprecated to rely on the default projection being * {@link https://github.com/d3/d3-geo/blob/master/README.md#geoAlbersUsa d3.geoAlbersUsa()}. Please * set it explicitly. {@link https://bl.ocks.org/mbostock/5557726 * Considering that `null` is also a valid value for projection}, if you need * projection to be `null` please set it explicitly to `null`. * @see {@link https://github.com/d3/d3-geo/blob/master/README.md#projections d3.projection} * @see {@link https://github.com/d3/d3-geo-projection d3-geo-projection} * @param {d3.projection} [projection=d3.geoAlbersUsa()] * @returns {d3.projection|GeoChoroplethChart} */ projection (projection) { if (!arguments.length) { return this._projection; } this._projection = projection; this._projectionFlag = true; return this; } _getGeoPath () { if (this._projection === undefined) { logger.warn('choropleth projection default of geoAlbers is deprecated,' + ' in next version projection will need to be set explicitly'); return this._geoPath.projection(geoAlbersUsa()); } return this._geoPath.projection(this._projection); } /** * Returns all GeoJson layers currently registered with this chart. The returned array is a * reference to this chart's internal data structure, so any modification to this array will also * modify this chart's internal registration. * @returns {Array<{name:String, data: Object, accessor: Function}>} */ geoJsons () { return this._geoJsons; } /** * Returns the {@link https://github.com/d3/d3-geo/blob/master/README.md#paths d3.geoPath} object used to * render the projection and features. Can be useful for figuring out the bounding box of the * feature set and thus a way to calculate scale and translation for the projection. * @see {@link https://github.com/d3/d3-geo/blob/master/README.md#paths d3.geoPath} * @returns {d3.geoPath} */ geoPath () { return this._geoPath; } /** * Remove a GeoJson layer from this chart by name * @param {String} name * @returns {GeoChoroplethChart} */ removeGeoJson (name) { const geoJsons = []; for (let i = 0; i < this._geoJsons.length; ++i) { const layer = this._geoJsons[i]; if (layer.name !== name) { geoJsons.push(layer); } } this._geoJsons = geoJsons; return this; } } export const geoChoroplethChart = (parent, chartGroup) => new GeoChoroplethChart(parent, chartGroup);