mirror of
https://github.com/getredash/redash.git
synced 2026-05-09 12:01:08 -04:00
getredash/redash#2317 Choropleth visualization
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
202
client/app/visualizations/choropleth/choropleth-editor.html
Normal file
202
client/app/visualizations/choropleth/choropleth-editor.html
Normal 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="http://numeraljs.com/" target="_blank">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>
|
||||
10
client/app/visualizations/choropleth/choropleth.html
Normal file
10
client/app/visualizations/choropleth/choropleth.html
Normal 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>
|
||||
1
client/app/visualizations/choropleth/countries.geo.json
Normal file
1
client/app/visualizations/choropleth/countries.geo.json
Normal file
File diff suppressed because one or more lines are too long
257
client/app/visualizations/choropleth/index.js
Normal file
257
client/app/visualizations/choropleth/index.js
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
140
client/app/visualizations/choropleth/utils.js
Normal file
140
client/app/visualizations/choropleth/utils.js
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
51
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
Reference in New Issue
Block a user