getredash/redash#2317 Choropleth visualization

This commit is contained in:
Levko Kravets
2018-03-06 13:33:15 +02:00
parent bf86d17d56
commit 2f30dbf645
16 changed files with 712 additions and 47 deletions

View File

@@ -1,3 +1,37 @@
.map-visualization-container {
height: 500px;
> div:first-child {
width: 100%;
height: 100%;
z-index: 0;
}
.map-custom-control.leaflet-bar {
background: #fff;
padding: 10px;
margin: 10px;
position: absolute;
z-index: 1;
&.top-left {
left: 0;
top: 0;
}
&.top-right {
right: 0;
top: 0;
}
&.bottom-left {
left: 0;
bottom: 0;
}
&.bottom-right {
right: 0;
bottom: 0;
}
}
}

View File

@@ -1,21 +1,14 @@
import { isUndefined, isFunction } from 'underscore';
const hasOwnProperty = Object.prototype.hasOwnProperty;
import { isFunction, extend } from 'underscore';
import { formatSimpleTemplate } from '@/lib/value-format';
function trim(str) {
return str.replace(/^\s+|\s+$/g, '');
}
function processTags(str, data, defaultColumn) {
return str.replace(/{{\s*([^\s]+)\s*}}/g, (match, column) => {
if (column === '@') {
column = defaultColumn;
}
if (hasOwnProperty.call(data, column) && !isUndefined(data[column])) {
return data[column];
}
return match;
});
return formatSimpleTemplate(str, extend({
'@': data[defaultColumn],
}, data));
}
export function renderDefault(column, row) {

View File

@@ -5,6 +5,8 @@ import _ from 'underscore';
// eslint-disable-next-line
const urlPattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
const hasOwnProperty = Object.prototype.hasOwnProperty;
function createDefaultFormatter(highlightLinks) {
if (highlightLinks) {
return (value) => {
@@ -50,7 +52,7 @@ function createNumberFormatter(format) {
return value => value;
}
export default function createFormatter(column) {
export function createFormatter(column) {
switch (column.displayAs) {
case 'number': return createNumberFormatter(column.numberFormat);
case 'boolean': return createBooleanFormatter(column.booleanValues);
@@ -58,3 +60,15 @@ export default function createFormatter(column) {
default: return createDefaultFormatter(column.allowHTML && column.highlightLinks);
}
}
export function formatSimpleTemplate(str, data) {
if (!_.isString(str)) {
return '';
}
return str.replace(/{{\s*([^\s]+)\s*}}/g, (match, prop) => {
if (hasOwnProperty.call(data, prop) && !_.isUndefined(data[prop])) {
return data[prop];
}
return match;
});
}

View File

@@ -3,7 +3,7 @@ import {
each, values, sortBy, pluck, identity, filter, map,
} from 'underscore';
import moment from 'moment';
import createFormatter from '@/lib/value-format';
import { createFormatter } from '@/lib/value-format';
// The following colors will be used if you pick "Automatic" color.
const BaseColors = {

View File

@@ -0,0 +1,202 @@
<div>
<ul class="tab-nav">
<li ng-class="{active: currentTab == 'general'}">
<a ng-click="changeTab('general')">General</a>
</li>
<li ng-class="{active: currentTab == 'colors'}">
<a ng-click="changeTab('colors')">Colors</a>
</li>
</ul>
<div ng-if="currentTab == 'general'" class="m-t-10 m-b-10">
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Country code column</label>
<select ng-options="name for name in queryResult.getColumnNames()"
ng-model="options.countryCodeColumn" class="form-control"></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Country code type</label>
<select ng-options="key as value for (key, value) in countryCodeTypes"
ng-model="options.countryCodeType" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Value column</label>
<select ng-options="name for name in queryResult.getColumnNames()"
ng-model="options.valueColumn" class="form-control"></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-value-format">
Value format
<span class="m-l-5"
uib-popover-html="'Format <a href=&quot;http://numeraljs.com/&quot; target=&quot;_blank&quot;>specs.</a>'"
popover-trigger="'click outsideClick'"><i class="fa fa-question-circle"></i></span>
</label>
<input class="form-control" id="legend-value-format"
ng-model="options.valueFormat" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-value-placeholder">Value placeholder</label>
<input class="form-control" id="legend-value-placeholder"
ng-model="options.noValuePlaceholder" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label><input type="checkbox" ng-model="options.legend.visible"> Show legend</label>
<div class="form-group">
<label for="legend-position">Legend position</label>
<select class="form-control" id="legend-position"
ng-options="key as value for (key, value) in legendPositions"
ng-model="options.legend.position"
ng-disabled="!options.legend.visible"
></select>
</div>
</div>
</div>
</div>
<label><input type="checkbox" ng-model="options.tooltip.enabled"> Show tooltip</label>
<div class="form-group">
<label for="tooltip-template">Tooltip template</label>
<input class="form-control" id="tooltip-template"
ng-model="options.tooltip.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
ng-disabled="!options.tooltip.enabled">
</div>
<label><input type="checkbox" ng-model="options.popup.enabled"> Show popup</label>
<div class="form-group">
<label for="popup-template">Popup template</label>
<textarea class="form-control resize-vertical" id="popup-template" rows="3"
ng-model="options.popup.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
ng-disabled="!options.popup.enabled"></textarea>
</div>
<div class="form-group">
<label class="ui-sortable-bypass text-muted" style="font-weight: normal; cursor: pointer;"
uib-popover-html="templateHint"
popover-trigger="'click outsideClick'" popover-placement="top-left">
Format specs <i class="fa fa-question-circle m-l-5"></i>
</label>
</div>
</div>
<div ng-if="currentTab == 'colors'" class="m-t-10 m-b-10">
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Steps</label>
<input type="number" min="3" max="11" class="form-control"
ng-model="options.steps">
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Clustering mode</label>
<select ng-options="key as value for (key, value) in clusteringModes"
ng-model="options.clusteringMode" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Min color</label>
<ui-select ng-model="options.colors.min">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Max color</label>
<ui-select ng-model="options.colors.max">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>No value color</label>
<ui-select ng-model="options.colors.noValue">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Background color</label>
<ui-select ng-model="options.colors.background">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Borders color</label>
<ui-select ng-model="options.colors.borders">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,10 @@
<div class="map-visualization-container">
<div resize-event="handleResize()" ng-style="{ background: options.colors.background }"></div>
<div ng-if="options.legend.visible && (legendItems.length > 0)"
class="leaflet-bar map-custom-control" ng-class="options.legend.position"
>
<div ng-repeat="item in legendItems">
<color-box color="item.color"></color-box><span>{{ formatValue(item.limit) }}</span>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,257 @@
import _ from 'underscore';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { formatSimpleTemplate } from '@/lib/value-format';
import {
AdditionalColors,
darkenColor,
createNumberFormatter,
prepareData,
getValueForFeature,
createScale,
prepareFeatureProperties,
getColorByValue,
inferCountryCodeType,
} from './utils';
import template from './choropleth.html';
import editorTemplate from './choropleth-editor.html';
import countriesDataUrl from './countries.geo.json';
function choroplethRenderer($sanitize, $http) {
return {
restrict: 'E',
template,
scope: {
queryResult: '=',
options: '=?',
},
link($scope, $element) {
let countriesData;
let map;
function render() {
if (map) {
map.remove();
}
if (!_.isObject(countriesData)) {
return;
}
$scope.formatValue = createNumberFormatter(
$scope.options.valueFormat,
$scope.options.noValuePlaceholder,
);
const data = prepareData(
$scope.queryResult.getData(),
$scope.options.countryCodeColumn,
$scope.options.valueColumn,
);
const { limits, colors, legend } = createScale(
countriesData.features,
data,
$scope.options,
);
// Update data for legend block
$scope.legendItems = legend;
const choropleth = L.geoJson(countriesData, {
onEachFeature: (feature, layer) => {
const value = getValueForFeature(feature, data, $scope.options.countryCodeType);
const valueFormatted = $scope.formatValue(value);
const featureData = prepareFeatureProperties(feature, valueFormatted);
const color = getColorByValue(value, limits, colors, $scope.options.colors.noValue);
layer.setStyle({
color: $scope.options.colors.borders,
weight: 1,
fillColor: color,
fillOpacity: 1,
});
if ($scope.options.tooltip.enabled) {
layer.bindTooltip($sanitize(formatSimpleTemplate(
$scope.options.tooltip.template,
featureData,
)));
}
if ($scope.options.popup.enabled) {
layer.bindPopup($sanitize(formatSimpleTemplate(
$scope.options.popup.template,
featureData,
)));
}
layer.on('mouseover', () => {
layer.setStyle({
weight: 2,
fillColor: darkenColor(color),
});
});
layer.on('mouseout', () => {
layer.setStyle({
weight: 1,
fillColor: color,
});
});
},
});
const choroplethBounds = choropleth.getBounds();
map = L.map($element[0].children[0].children[0], {
center: choroplethBounds.getCenter(),
zoom: 1,
minZoom: 1,
layers: [choropleth],
scrollWheelZoom: false,
maxBounds: choroplethBounds,
maxBoundsViscosity: 1,
});
}
function resize() {
if (map) {
map.invalidateSize(false);
}
}
$scope.handleResize = () => {
resize();
};
$http.get(countriesDataUrl).then((response) => {
countriesData = response.data;
render();
});
$scope.$watch('queryResult && queryResult.getData()', render);
$scope.$watch('options', render, true);
},
};
}
function choroplethEditor(ChoroplethPalette) {
return {
restrict: 'E',
template: editorTemplate,
scope: {
queryResult: '=',
options: '=?',
},
link($scope) {
$scope.currentTab = 'general';
$scope.changeTab = (tab) => {
$scope.currentTab = tab;
};
$scope.colors = ChoroplethPalette;
$scope.clusteringModes = {
q: 'quantile',
e: 'equidistant',
k: 'k-means',
};
$scope.legendPositions = {
'top-left': 'top / left',
'top-right': 'top / right',
'bottom-left': 'bottom / left',
'bottom-right': 'bottom / right',
};
$scope.countryCodeTypes = {
name: 'Short name',
name_long: 'Full name',
abbrev: 'Abbreviated name',
iso_a2: 'ISO code (2 letters)',
iso_a3: 'ISO code (3 letters)',
iso_n3: 'ISO code (3 digits)',
};
$scope.templateHint = `
<div class="p-b-5">All query result columns can be referenced using <code>{{ column_name }}</code> syntax.</div>
<div class="p-b-5">Use special names to access additional properties:</div>
<div><code>{{ @@value }}</code> formatted value;</div>
<div><code>{{ @@name }}</code> short country name;</div>
<div><code>{{ @@name_long }}</code> full country name;</div>
<div><code>{{ @@abbrev }}</code> abbreviated country name;</div>
<div><code>{{ @@iso_a2 }}</code> two-letter ISO country code;</div>
<div><code>{{ @@iso_a3 }}</code> three-letter ISO country code;</div>
<div><code>{{ @@iso_n3 }}</code> three-digit ISO country code.</div>
<div class="p-t-5">This syntax is applicable to tooltip and popup templates.</div>
`;
function updateCountryCodeType() {
$scope.options.countryCodeType = inferCountryCodeType(
$scope.queryResult.getData(),
$scope.options.countryCodeColumn,
) || $scope.options.countryCodeType;
}
$scope.$watch('options.countryCodeColumn', updateCountryCodeType);
$scope.$watch('queryResult.getData()', updateCountryCodeType);
},
};
}
export default function init(ngModule) {
ngModule.constant('ChoroplethPalette', {});
ngModule.directive('choroplethRenderer', choroplethRenderer);
ngModule.directive('choroplethEditor', choroplethEditor);
ngModule.config((VisualizationProvider, ColorPalette, ChoroplethPalette) => {
_.extend(ChoroplethPalette, AdditionalColors, ColorPalette);
const renderTemplate =
'<choropleth-renderer options="visualization.options" query-result="queryResult"></choropleth-renderer>';
const editTemplate = '<choropleth-editor options="visualization.options" query-result="queryResult"></choropleth-editor>';
const defaultOptions = {
defaultColumns: 3,
defaultRows: 8,
minColumns: 2,
countryCodeColumn: '',
countryCodeType: 'iso_a3',
valueColumn: '',
clusteringMode: 'e',
steps: 5,
valueFormat: '0,0.00',
noValuePlaceholder: 'N/A',
colors: {
min: ChoroplethPalette['Light Blue'],
max: ChoroplethPalette['Dark Blue'],
background: ChoroplethPalette.White,
borders: ChoroplethPalette.White,
noValue: ChoroplethPalette['Light Gray'],
},
legend: {
visible: true,
position: 'bottom-left',
},
tooltip: {
enabled: true,
template: '<b>{{ @@name }}</b>: {{ @@value }}',
},
popup: {
enabled: true,
template: 'Country: <b>{{ @@name_long }} ({{ @@iso_a2 }})\n</b>\n<br>Value: <b>{{ @@value }}</b>',
},
};
VisualizationProvider.registerVisualization({
type: 'CHOROPLETH',
name: 'Choropleth',
renderTemplate,
editorTemplate: editTemplate,
defaultOptions,
});
});
}

View File

@@ -0,0 +1,140 @@
import chroma from 'chroma-js';
import _ from 'underscore';
import { createFormatter } from '@/lib/value-format';
export const AdditionalColors = {
White: '#ffffff',
Black: '#000000',
'Light Gray': '#dddddd',
};
export function darkenColor(color) {
return chroma(color).darken().hex();
}
export function createNumberFormatter(format, placeholder) {
const formatter = createFormatter({
displayAs: 'number',
numberFormat: format,
});
return (value) => {
if (_.isNumber(value) && isFinite(value)) {
return formatter(value);
}
return placeholder;
};
}
export function prepareData(data, countryCodeField, valueField) {
if (!countryCodeField || !valueField) {
return {};
}
const result = {};
_.each(data, (item) => {
if (item[countryCodeField]) {
const value = parseFloat(item[valueField]);
result[item[countryCodeField]] = {
code: item[countryCodeField],
value: isFinite(value) ? value : undefined,
item,
};
}
});
return result;
}
export function prepareFeatureProperties(feature, valueFormatted) {
const result = {};
_.each(feature.properties, (value, key) => {
result['@@' + key] = value;
});
result['@@value'] = valueFormatted;
return result;
}
export function getValueForFeature(feature, data, countryCodeType) {
const code = feature.properties[countryCodeType];
if (_.isString(code) && _.isObject(data[code])) {
return data[code].value;
}
return undefined;
}
export function getColorByValue(value, limits, colors, defaultColor) {
if (_.isNumber(value) && isFinite(value)) {
for (let i = 0; i < limits.length; i += 1) {
if (value <= limits[i]) {
return colors[i];
}
}
}
return defaultColor;
}
export function createScale(features, data, options) {
// Calculate limits
const values = _.uniq(_.filter(
_.map(features, feature => getValueForFeature(feature, data, options.countryCodeType)),
_.isNumber,
));
if (values.length === 0) {
return {
limits: [],
colors: [],
legend: [],
};
}
const steps = Math.min(values.length, options.steps);
if (steps === 1) {
return {
limits: values,
colors: [options.colors.max],
legend: [{
color: options.colors.max,
limit: _.first(values),
}],
};
}
const limits = chroma.limits(values, options.clusteringMode, steps - 1);
// Create color buckets
const colors = chroma.scale([options.colors.min, options.colors.max])
.colors(limits.length);
// Group values for legend
const legend = _.map(colors, (color, index) => ({
color,
limit: limits[index],
})).reverse();
return { limits, colors, legend };
}
export function inferCountryCodeType(data, countryCodeField) {
const regex = {
iso_a2: /^[a-z]{2}$/i,
iso_a3: /^[a-z]{3}$/i,
iso_n3: /^[0-9]{3}$/i,
};
const result = _.chain(data)
.reduce((memo, item) => {
const value = item[countryCodeField];
if (_.isString(value)) {
_.each(regex, (r, k) => {
memo[k] += r.test(value) ? 1 : 0;
});
}
return memo;
}, {
iso_a2: 0,
iso_a3: 0,
iso_n3: 0,
})
.pairs()
.max(item => item[1])
.value();
return (result[1] / data.length) >= 0.9 ? result[0] : null;
}

View File

@@ -23,7 +23,6 @@ L.Icon.Default.mergeOptions({
delete L.Icon.Default.prototype._getIconUrl;
function mapRenderer() {
return {
restrict: 'E',
@@ -209,7 +208,6 @@ function mapRenderer() {
$scope.$watch('queryResult && queryResult.getData()', render);
$scope.$watch('visualization.options', render, true);
$scope.$watch('visualization.options.height', resize);
},
};
}

View File

@@ -5,7 +5,7 @@
<li ng-class="{active: currentTab == 'map'}"><a ng-click="currentTab='map'">Map Settings</a></li>
</ul>
<div ng-show="currentTab == 'general'">
<div ng-show="currentTab == 'general'" class="m-t-10 m-b-10">
<div class="form-group">
<label class="control-label">Latitude Column Name</label>
<ui-select name="form-control" required ng-model="visualization.options.latColName">
@@ -40,7 +40,7 @@
</div>
</div>
<div ng-show="currentTab == 'groups'">
<div ng-show="currentTab == 'groups'" class="m-b-10">
<table class="table table-condensed col-table">
<thead>
<th>Name</th>
@@ -50,14 +50,14 @@
<tr ng-repeat="(name, options) in visualization.options.groups">
<td>{{name}}</td>
<td>
<input class="form-control" type="color" ng-model="options.color"/>
<input class="form-control" type="color" ng-model="options.color"/>
</td>
</tr>
</tbody>
</table>
</div>
<div ng-show="currentTab == 'map'">
<div ng-show="currentTab == 'map'" class="m-t-10 m-b-10">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="visualization.options.clusterMarkers">
@@ -67,8 +67,8 @@
<div class="form-group">
<label class="control-label">Map Tiles</label>
<select ng-options="tile.url as tile.name for tile in mapTiles" ng-model="visualization.options.mapTileUrl"
class="form-control"></select>
<select ng-options="tile.url as tile.name for tile in mapTiles"
ng-model="visualization.options.mapTileUrl" class="form-control"></select>
</div>
</div>

View File

@@ -1,3 +1,3 @@
<div class="map-visualization-container">
<div resize-event="handleResize()" style="width:100%; height:100%;"></div>
<div resize-event="handleResize()"></div>
</div>

View File

@@ -1,6 +1,6 @@
import _ from 'underscore';
import { getColumnCleanName } from '@/services/query-result';
import createFormatter from '@/lib/value-format';
import { createFormatter } from '@/lib/value-format';
import template from './table.html';
import editorTemplate from './table-editor.html';
import './table-editor.less';

51
package-lock.json generated
View File

@@ -1,19 +1,9 @@
{
"name": "redash-client",
"version": "4.0.0",
"version": "4.0.0-rc.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@plotly/d3-sankey": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.5.0.tgz",
"integrity": "sha1-si+up0LlglEzXuXZ+6JIdyYHgA8=",
"requires": {
"d3-array": "1.2.1",
"d3-collection": "1.0.4",
"d3-interpolate": "1.1.5"
}
},
"3d-view": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/3d-view/-/3d-view-2.0.0.tgz",
@@ -36,6 +26,21 @@
"right-now": "1.0.0"
}
},
"@plotly/d3-sankey": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.5.0.tgz",
"integrity": "sha1-si+up0LlglEzXuXZ+6JIdyYHgA8=",
"requires": {
"d3-array": "1.2.1",
"d3-collection": "1.0.4",
"d3-interpolate": "1.1.5"
}
},
"JSV": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz",
"integrity": "sha1-0Hf2glVx+CEy+d/67Vh7QCn+/1c="
},
"a-big-triangle": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/a-big-triangle/-/a-big-triangle-1.0.3.tgz",
@@ -1946,6 +1951,11 @@
"integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=",
"dev": true
},
"chroma-js": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-1.3.6.tgz",
"integrity": "sha512-UGTgpHKEvDspZHVLEaYr6DXa3/eA+9u2FYL69OO62WSuIeKj+6z3bwN0Uyfn2YflSD+7Z3SJOehNbrNCFkGGnQ=="
},
"cipher-base": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
@@ -2506,7 +2516,7 @@
},
"core-js": {
"version": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz",
"integrity": "sha1-TekR5mew6ukSTjQlS1OupvxhjT4="
"integrity": "sha512-W4Zkayb9VI4zr+s7ReDSgTTaV9KWB4L997i8/mkOV2kY1c7QGNj91k8X0zcr8Tl24oYF6kiBomCDSYO4BvQQdQ=="
},
"core-util-is": {
"version": "1.0.2",
@@ -6963,11 +6973,6 @@
"verror": "1.10.0"
}
},
"JSV": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz",
"integrity": "sha1-0Hf2glVx+CEy+d/67Vh7QCn+/1c="
},
"kdbush": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-1.0.1.tgz",
@@ -9044,8 +9049,8 @@
"resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-1.30.1.tgz",
"integrity": "sha1-kcHsdm6FTnnK6FTH6VOjfYcxGT8=",
"requires": {
"@plotly/d3-sankey": "0.5.0",
"3d-view": "2.0.0",
"@plotly/d3-sankey": "0.5.0",
"alpha-shape": "1.0.0",
"color-rgba": "1.1.1",
"convex-hull": "1.0.3",
@@ -11423,11 +11428,6 @@
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=",
"dev": true
},
"string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
},
"string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
@@ -11465,6 +11465,11 @@
"function-bind": "1.1.1"
}
},
"string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
},
"stringstream": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",

View File

@@ -39,6 +39,7 @@
"angular-vs-repeat": "^1.1.7",
"bootstrap": "^3.3.7",
"brace": "^0.10.0",
"chroma-js": "^1.3.6",
"core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz",
"cornelius": "git+https://github.com/restorando/cornelius.git",
"d3": "^3.5.17",

View File

@@ -136,6 +136,16 @@ const config = {
}
}]
},
{
test: /\.geo\.json$/,
use: [{
loader: 'file-loader',
options: {
outputPath: 'data/',
name: '[hash:7].[name].[ext]',
}
}]
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: [{