TargetProcess/tauCharts

View on GitHub
src/elements/coords.geomap.js

Summary

Maintainability
F
3 days
Test Coverage
import * as d3Array from 'd3-array';
import * as d3Geo from 'd3-geo';
import * as d3Selection from 'd3-selection';
import fetchJson from '../utils/fetchJson';
const d3 = {
    ...d3Array,
    ...d3Geo,
    ...d3Selection,
};
import * as utils from '../utils/utils';
import * as topojson from 'topojson-client';
import {d3Labeler} from '../utils/d3-labeler';
import {Element} from './element';

const avgCharSize = 5.5;
const iterationsCount = 10;
const pointOpacity = 0.5;

var hierarchy = [

    'land',

    'continents',

    'georegions',

    'countries',

    'regions',
    'subunits',
    'states',

    'counties'
];

export class GeoMap extends Element {

    constructor(config) {

        super(config);

        this.config = config;
        this.config.guide = utils.defaults(
            this.config.guide || {},
            {
                defaultFill: 'rgba(128,128,128,0.25)',
                padding: {l: 0, r: 0, t: 0, b: 0},
                showNames: true
            });
        this.contourToFill = null;

        this.on('highlight-area', (sender, e) => this._highlightArea(e));
        this.on('highlight-point', (sender, e) => this._highlightPoint(e));
        this.on('highlight', (sender, e) => this._highlightPoint(e));
    }

    defineGrammarModel(fnCreateScale) {

        var node = this.config;

        var options = node.options;
        var padding = node.guide.padding;

        var innerWidth = options.width - (padding.l + padding.r);
        var innerHeight = options.height - (padding.t + padding.b);

        // y - latitude
        this.latScale = fnCreateScale('pos', node.latitude, [0, innerHeight]);
        // x - longitude
        this.lonScale = fnCreateScale('pos', node.longitude, [innerWidth, 0]);
        // size
        this.sizeScale = fnCreateScale('size', node.size);
        // color
        this.colorScale = fnCreateScale('color', node.color);

        // code
        this.codeScale = fnCreateScale('value', node.code);
        // fill
        this.fillScale = fnCreateScale('fill', node.fill);

        this.W = innerWidth;
        this.H = innerHeight;

        this.regScale('latitude', this.latScale)
            .regScale('longitude', this.lonScale)
            .regScale('size', this.sizeScale)
            .regScale('color', this.colorScale)
            .regScale('code', this.codeScale)
            .regScale('fill', this.fillScale);

        return {};
    }

    drawFrames(frames) {

        var guide = this.config.guide;

        if (typeof (guide.sourcemap) === 'string') {

            fetchJson(guide.sourcemap, (e, topoJSONData) => {

                if (e) {
                    throw e;
                }

                this._drawMap(frames, topoJSONData);
            });

        } else {
            this._drawMap(frames, guide.sourcemap);
        }
    }

    _calcLabels(topoJSONData, reverseContours, path) {

        var innerW = this.W;
        var innerH = this.H;

        var labelsHashRef = {};

        reverseContours.forEach((c) => {

            var contourFeatures = topojson.feature(topoJSONData, topoJSONData.objects[c]).features || [];

            var labels = contourFeatures
                .map((d) => {

                    var info = (d.properties || {});

                    var center = path.centroid(d);
                    var bounds = path.bounds(d);

                    var sx = center[0];
                    var sy = center[1];

                    var br = bounds[1][0];
                    var bl = bounds[0][0];
                    var size = br - bl;
                    var name = info.name || '';
                    var abbr = info.abbr || name;
                    var isAbbr = (size < (name.length * avgCharSize));
                    var text = isAbbr ? abbr : name;
                    var isRef = (size < (2.5 * avgCharSize));
                    var r = (isRef ? (innerW - sx - 3 * avgCharSize) : 0);

                    return {
                        id: `${c}-${d.id}`,
                        sx: sx,
                        sy: sy,
                        x: sx + r,
                        y: sy,
                        width: text.length * avgCharSize,
                        height: 10,
                        name: text,
                        r: r,
                        isRef: isRef
                    };
                })
                .filter((d) => !Number.isNaN(d.x) && !Number.isNaN(d.y));

            var anchors = labels.map(d => ({x: d.sx, y: d.sy, r: d.r}));

            d3Labeler()
                .label(labels)
                .anchor(anchors)
                .width(innerW)
                .height(innerH)
                .start(iterationsCount);

            labels
                .filter((item) => !item.isRef)
                .map((item) => {
                    item.x = item.sx;
                    item.y = item.sy;
                    return item;
                })
                .reduce((memo, item) => {
                    memo[item.id] = item;
                    return memo;
                },
                labelsHashRef);

            var references = labels.filter((item) => item.isRef);
            if (references.length < 6) {
                references.reduce((memo, item) => {
                    memo[item.id] = item;
                    return memo;
                }, labelsHashRef);
            }
        });

        return labelsHashRef;
    }

    _drawMap(frames, topoJSONData) {

        var self = this;

        var guide = this.config.guide;
        var options = this.config.options;
        var node = this.config.options.container;

        var latScale = this.latScale;
        var lonScale = this.lonScale;
        var sizeScale = this.sizeScale;
        var colorScale = this.colorScale;

        var codeScale = this.codeScale;
        var fillScale = this.fillScale;

        var innerW = this.W;
        var innerH = this.H;

        var contours = hierarchy.filter((h) => (topoJSONData.objects || {}).hasOwnProperty(h));

        if (contours.length === 0) {
            throw new Error('Invalid map: should contain some contours');
        }

        var contourToFill;
        if (!fillScale.dim) {

            contourToFill = contours[contours.length - 1];

        } else if (codeScale.georole) {

            if (contours.indexOf(codeScale.georole) === -1) {
                console.log(`There is no contour for georole "${codeScale.georole}"`); // tslint:disable-line
                console.log(`Available contours are: ${contours.join(' | ')}`); // tslint:disable-line

                throw new Error(`Invalid [georole]`);
            }

            contourToFill = codeScale.georole;

        } else {
            console.log('Specify [georole] for code scale'); // tslint:disable-line
            throw new Error('[georole] is missing');
        }

        this.contourToFill = contourToFill;

        var center;

        if (latScale.dim && lonScale.dim) {
            var lats = d3.extent(latScale.domain());
            var lons = d3.extent(lonScale.domain());
            center = [
                ((lons[1] + lons[0]) / 2),
                ((lats[1] + lats[0]) / 2)
            ];
        }

        var d3Projection = this._createProjection(topoJSONData, contours[0], center);

        var path = d3.geoPath().projection(d3Projection);

        var xmap = node
            .selectAll('.map-container')
            .data([`${innerW}${innerH}${center}${contours.join('-')}`], (x) => x);
        xmap.exit()
            .remove();
        const merged = xmap.enter()
            .append('g')
            .call(function (selection) {

                var node = selection;

                node.attr('class', 'map-container');

                var labelsHash = {};
                var reverseContours = contours.reduceRight((m, t) => (m.concat(t)), []);

                if (guide.showNames) {
                    labelsHash = self._calcLabels(topoJSONData, reverseContours, path);
                }

                reverseContours.forEach((c, i) => {

                    var getInfo = (d) => labelsHash[`${c}-${d.id}`];

                    node.selectAll(`.map-contour-${c}`)
                        .data(topojson.feature(topoJSONData, topoJSONData.objects[c]).features || [])
                        .enter()
                        .append('g')
                        .call(function (selection) {

                            var cont = selection;

                            cont.attr('class', `map-contour-${c} map-contour-level map-contour-level-${i}`)
                                .attr('fill', 'none');

                            cont.append('title')
                                .text((d) => (d.properties || {}).name);

                            cont.append('path')
                                .attr('d', path);

                            cont.append('text')
                                .attr('class', `place-label-${c}`)
                                .attr('transform', (d) => {
                                    var i = getInfo(d);
                                    return i ? `translate(${[i.x, i.y]})` : '';
                                })
                                .text(d => {
                                    var i = getInfo(d);
                                    return i ? i.name : '';
                                });

                            cont.append('line')
                                .attr('class', `place-label-link-${c}`)
                                .attr('stroke', 'gray')
                                .attr('stroke-width', 0.25)
                                .attr('x1', (d) => {
                                    var i = getInfo(d);
                                    return (i && i.isRef) ? i.sx : 0;
                                })
                                .attr('y1', (d) => {
                                    var i = getInfo(d);
                                    return (i && i.isRef) ? i.sy : 0;
                                })
                                .attr('x2', (d) => {
                                    var i = getInfo(d);
                                    return (i && i.isRef) ? (i.x - i.name.length * 0.6 * avgCharSize) : 0;
                                })
                                .attr('y2', (d) => {
                                    var i = getInfo(d);
                                    return (i && i.isRef) ? (i.y - 3.5) : 0;
                                });
                        });
                });

                if (topoJSONData.objects.hasOwnProperty('places')) {

                    var placesFeature = topojson.feature(topoJSONData, topoJSONData.objects.places);

                    var labels = placesFeature
                        .features
                        .map((d) => {
                            var coords = d3Projection(d.geometry.coordinates);
                            return {
                                x: coords[0] + 3.5,
                                y: coords[1] + 3.5,
                                width: d.properties.name.length * avgCharSize,
                                height: 12,
                                name: d.properties.name
                            };
                        });

                    var anchors = placesFeature
                        .features
                        .map((d) => {
                            var coords = d3Projection(d.geometry.coordinates);
                            return {
                                x: coords[0],
                                y: coords[1],
                                r: 2.5
                            };
                        });

                    d3Labeler()
                        .label(labels)
                        .anchor(anchors)
                        .width(innerW)
                        .height(innerH)
                        .start(100);

                    node.selectAll('.place')
                        .data(anchors)
                        .enter()
                        .append('circle')
                        .attr('class', 'place')
                        .attr('transform', (d) => `translate(${d.x},${d.y})`)
                        .attr('r', (d) => `${d.r}px`);

                    node.selectAll('.place-label')
                        .data(labels)
                        .enter()
                        .append('text')
                        .attr('class', 'place-label')
                        .attr('transform', (d) => `translate(${d.x},${d.y})`)
                        .text((d) => d.name);
                }
            })
            .merge(xmap);

        this.groupByCode = frames.reduce(
            (groups, f) => {
                return f.part().reduce(
                    (memo, rec) => {
                        var key = (rec[codeScale.dim] || '').toLowerCase();
                        memo[key] = rec;
                        return memo;
                    },
                    groups);
            },
            {});

        var toData = this._resolveFeature.bind(this);

        merged.selectAll(`.map-contour-${contourToFill}`)
            .data(topojson.feature(topoJSONData, topoJSONData.objects[contourToFill]).features)
            .call(function (selection) {
                selection.classed('map-contour', true)
                    .attr('fill', (d) => {
                        var row = toData(d);
                        return (row === null) ?
                            guide.defaultFill :
                            fillScale(row[fillScale.dim]);
                    });
            })
            .on('mouseover', (d) => this.fire('area-mouseover', {data: toData(d), event: d3Selection.event}))
            .on('mouseout',  (d) => this.fire('area-mouseout', {data: toData(d), event: d3Selection.event}))
            .on('click',     (d) => this.fire('area-click', {data: toData(d), event: d3Selection.event}));

        if (!latScale.dim || !lonScale.dim) {
            return [];
        }

        var update = function (selection) {
            return selection
                .attr('r', (d) => sizeScale(d[sizeScale.dim]))
                .attr('transform', ({data: d}) => `translate(${d3Projection([d[lonScale.dim], d[latScale.dim]])})`)
                .attr('class', ({data: d}) => colorScale(d[colorScale.dim]))
                .attr('opacity', pointOpacity)
                .on('mouseover', ({data:d}) => self.fire('point-mouseover', {data: d, event: d3Selection.event}))
                .on('mouseout',  ({data:d}) => self.fire('point-mouseout', {data: d, event: d3Selection.event}))
                .on('click',     ({data:d}) => self.fire('point-click', {data: d, event: d3Selection.event}));
        };

        var updateGroups = function (selection) {

            selection.attr('class', (f) => `frame frame-${f.hash}`)
                .call(function (selection) {
                    var points = selection
                        .selectAll('circle')
                        .data(frame => frame.data.map(item => ({data: item, uid: options.uid})));
                    points
                        .exit()
                        .remove();
                    points
                        .call(update);
                    points
                        .enter()
                        .append('circle')
                        .call(update);
                });
        };

        var mapper = (f) => ({tags: f.key || {}, hash: f.hash(), data: f.part()});

        var frameGroups = merged
            .selectAll('.frame')
            .data(frames.map(mapper), (f) => f.hash);
        frameGroups
            .exit()
            .remove();
        frameGroups
            .enter()
            .append('g')
            .merge(frameGroups)
            .call(updateGroups);

        return [];
    }

    _resolveFeature(d) {
        var groupByCode = this.groupByCode;
        var prop = d.properties;
        var codes = ['c1', 'c2', 'c3', 'abbr', 'name'].filter((c) => {
            return prop.hasOwnProperty(c) &&
                prop[c] &&
                groupByCode.hasOwnProperty(prop[c].toLowerCase());
        });

        var value;
        if (codes.length === 0) {
            // doesn't match
            value = null;
        } else if (codes.length > 0) {
            let k = prop[codes[0]].toLowerCase();
            value = groupByCode[k];
        }

        return value;
    }

    _highlightArea(filter) {
        var node = this.config.options.container;
        var contourToFill = this.contourToFill;
        node.selectAll(`.map-contour-${contourToFill}`)
            .classed('map-contour-highlighted', (d) => filter(this._resolveFeature(d)));
    }

    _highlightPoint(filter) {
        this.config
            .options
            .container
            .selectAll('circle')
            .classed('map-point-highlighted', ({data:d}) => filter(d))
            .attr('opacity', ({data:d}) => (filter(d) ? pointOpacity : 0.1));
    }

    _createProjection(topoJSONData, topContour, center) {

        // The map's scale out is based on the solution:
        // https://stackoverflow.com/questions/14492284/center-a-map-in-d3-given-a-geojson-object

        var width = this.W;
        var height = this.H;
        var guide = this.config.guide;

        var scale = 100;
        var offset = [width / 2, height / 2];

        var mapCenter = center || topoJSONData.center;
        var mapProjection = guide.projection || topoJSONData.projection || 'mercator';

        var d3Projection = this._createD3Projection(mapProjection, mapCenter, scale, offset);

        var path = d3.geoPath().projection(d3Projection);

        // using the path determine the bounds of the current map and use
        // these to determine better values for the scale and translation
        var bounds = path.bounds(topojson.feature(topoJSONData, topoJSONData.objects[topContour]));

        var hscale = scale * width  / (bounds[1][0] - bounds[0][0]);
        var vscale = scale * height / (bounds[1][1] - bounds[0][1]);

        scale = (hscale < vscale) ? hscale : vscale;
        offset = [
            width - (bounds[0][0] + bounds[1][0]) / 2,
            height - (bounds[0][1] + bounds[1][1]) / 2
        ];

        // new projection
        return this._createD3Projection(mapProjection, mapCenter, scale, offset);
    }

    _createD3Projection(projection, center, scale, translate) {

        // TODO: Proper projection mapping.
        var proj = ('geo' + projection.substring(0, 1).toUpperCase() + projection.substring(1));
        var d3ProjectionMethod = d3[proj];

        if (!d3ProjectionMethod) {
            /*tslint:disable */
            console.log(`Unknown projection "${projection}"`);
            console.log(`See available projection types here: https://github.com/mbostock/d3/wiki/Geo-Projections`);
            /*tslint:enable */
            throw new Error(`Invalid map: unknown projection "${projection}"`);
        }

        var d3Projection = d3ProjectionMethod();

        var steps = [
            {method:'scale', args: scale},
            {method:'center', args: center},
            {method:'translate', args: translate}
        ].filter((step) => step.args);

        // because the Albers USA projection does not support rotation or centering
        return steps.reduce(
            (proj, step) => {
                if (proj[step.method]) {
                    proj = proj[step.method](step.args);
                }
                return proj;
            },
            d3Projection);
    }
}