d3-visualize
Version:
d3-view components for data visualization
295 lines (251 loc) • 9.25 kB
JavaScript
import {extent, max} from 'd3-array';
import {assign} from 'd3-let';
import createChart, {vizPrototype} from '../core/chart';
import accessor from '../utils/accessor';
import niceRange from '../utils/nicerange';
import warn from '../utils/warn';
vizPrototype.getGeoProjection = function (name) {
return this.getD3('geo', name)();
};
//
// GeoChart
// =============
//
// A chart displaying a geographical map
export default createChart('geochart', {
// load these libraries - add 'leaflet'?
requires: ['d3-scale', 'd3-geo', 'topojson', 'd3-geo-projection', 'd3-svg-legend'],
schema: {
// Geometry data to display in this chart - must be in the topojson source
geometry: {
type: 'string',
description: 'The geometry to visualize from the topojson geometry objects',
default: 'countries'
},
//
// for choropleth maps
// geoKey and dataKey are used to match geometry with data
geoKey: {
type: 'string',
default: 'id'
},
dataKey: {
type: 'string',
default: 'id'
},
dataLabelKey : {
type: 'string',
default: 'label'
},
dataValueKey: {
type: 'string',
default: 'value'
},
neighbors: {
type: 'boolean',
default: false
},
// how many color buckets to visualise
buckets: {
type: 'number',
default: 10
},
choroplethScale: {
type: 'string',
default: 'quantile'
},
//
// specify one of the topojson geometry object for calculating
// the projected bounding box
boundGeometry: {
type: 'string'
},
// how much to zoom out, 1 = no zoom out, 0.95 to 0.8 are sensible values
boundScaleFactor: {
type: 'number',
default: 0.9
},
//
projection: {
type: 'string',
default: 'kavrayskiy7'
},
graticule: {
type: 'boolean',
default: false
},
leaflet: {
type: 'boolean',
default: false
},
scale: {
type: 'number',
default: 200
}
},
doDraw () {
var info = dataInfo(this.frame);
if (!info.topology) return warn ('Topojson data not available - cannot draw topology');
if (!this._geoPath) this.createGeoPath(info);
this.update(info);
},
update (info) {
var model = this.getModel(),
color = this.getModel('color'),
box = this.boundingBox(),
group = this.group(),
geogroup = this.group('geo'),
path = this._geoPath,
data = geodata(this.$, info, model);
if (!data) {
var objects = Object.keys(info.topology.objects).map(key => `"${key}"`).join(', ');
return warn(`Could not find *geometry* "${model.geometry}" in [${objects}] - cannot draw geochart`);
}
group
.transition(this.transition('group0'))
.attr("transform", this.translate(box.padding.left, box.padding.top));
geogroup
.transition(this.transition('group1'))
.attr("transform", this.translate(box.margin.left, box.margin.top));
var paths = geogroup.selectAll('.geometry').data(data),
fill = this.choropleth(data, box);
this.center(info);
paths
.enter()
.append("path")
.attr("class", "geometry")
.attr("d", path)
.style('fill', 'none')
.style("stroke", this.modelProperty('stroke', color))
.on("mouseover", this.mouseOver())
.on("mouseout", this.mouseOut())
.merge(paths)
.transition(this.transition('geometry'))
.attr("d", path)
.style("stroke", this.modelProperty('stroke', color))
.style("fill", fill)
.style("fill-opacity", color.fillOpacity);
paths
.exit()
.remove();
},
createGeoPath (info) {
var model = this.getModel(),
projection = this.getGeoProjection(model.projection).scale(model.scale),
$ = this.$,
path = $.geoPath().projection(projection),
self = this,
lefletMap;
this._geoPath = path;
this.center(info);
if (model.leaflet) {
var leafletId = `leaflet-${model.uid}`,
paper = this.paper();
this.visualParent.paper
.append('div')
.attr('id', leafletId);
lefletMap = new $.Map(leafletId, {center: [37.8, -96.9], zoom: 4})
.addLayer(new $.TileLayer("http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png")),
lefletMap.getPanes().overlayPane.appendChild(paper.element);
projection = $.transform({point: projectPoint});
lefletMap.on("viewreset", () => self.update(info));
}
return path;
function projectPoint(x, y) {
var point = lefletMap.latLngToLayerPoint(new $.LatLng(y, x));
this.stream.point(point.x, point.y);
}
},
center (info) {
var model = this.getModel();
if (!model.boundGeometry) return;
var path = this._geoPath,
projection = path.projection(),
box = this.boundingBox(),
boundObject = info.topology.objects[model.boundGeometry],
boundGeometry = boundObject ? this.$.feature(info.topology, boundObject).features : null;
if (!boundGeometry) return warn(`Could not find *boundGeometry* "${model.boundGeometry}" for centering - skip centering geochart`);
projection.scale(1).translate([0, 0]);
var b = path.bounds(boundGeometry[0]),
topLeft = b[0],
bottomRight = b[1],
scaleX = (bottomRight[0] - topLeft[0]) / box.innerWidth,
scaleY = (bottomRight[1] - topLeft[1]) / box.innerHeight,
scale = Math.round(model.boundScaleFactor / Math.max(scaleX, scaleY)),
translate = [
(box.innerWidth - scale * (bottomRight[0] + topLeft[0])) / 2,
(box.innerHeight - scale * (bottomRight[1] + topLeft[1])) / 2
];
projection.scale(scale).translate(translate);
},
// choropleth map based on data
choropleth (data, box) {
var model = this.getModel(),
dataLabelKey = model.dataLabelKey,
dataValueKey = model.dataValueKey;
let dataValue, valueRange, colors, buckets;
if (model.neighbors) {
dataValue = accessor('rank');
valueRange = extent(data, dataValue);
buckets = valueRange[1] + 1;
} else {
dataValue = (d) => d.data[dataValueKey];
buckets = Math.min(model.buckets, data.length);
valueRange = niceRange(extent(data, dataValue), buckets);
}
colors = this.getScale(model.choroplethScale).range(this.colors(buckets).reverse()).domain(valueRange);
this.legend({
type: 'color',
scale: colors
}, box);
return d => {
d.label = d.data[dataLabelKey] || d.id;
d.value = dataValue(d);
d.color = colors(d.value);
return d.color;
};
}
});
export function dataInfo (frame) {
var info = {};
if (frame.type === 'frameCollection')
frame.frames.each(df => {
if (df.type === 'Topology') info.topology = df;
else if (df.type === 'dataframe') info.data = df.data;
});
else if (frame.type === 'Topology')
info.topology = frame;
return info;
}
//
// Create a geo data frame
// ===========================
//
// * geo - d3-geo & topojson object
// * info - object with topology and data frame (optional)
export function geodata (geo, info, config) {
var geoKey = config.geoKey,
dataKey = config.dataKey;
let data = {}, features, key, props;
if (!info.topology) return warn('No topology object available');
var geometry = info.topology.objects[config.geometry];
if (!geometry) return warn(`Topology object ${config.geometry} is not available`);
var neighbors = config.neighbors ? geo.neighbors(geometry.geometries) : null;
features = geo.feature(info.topology, geometry).features;
if (info.data) data = info.data.reduce((o, d) => {o[d[dataKey]] = d; return o;}, {});
features = features.map(d => {
props = d.properties;
key = d[geoKey] || props[geoKey];
return {
id: key,
type: d.type,
geometry: d.geometry,
data: assign({}, props, data[key])
};
});
if (neighbors) features.forEach((d, i) => {
d.neighbors = neighbors[i];
d.rank = max(d.neighbors, j => features[j].rank) + 1 | 0;
});
return features;
}