diff --git a/client/app/visualizations/chart/Editor/AxisSettings.jsx b/client/app/visualizations/chart/Editor/AxisSettings.jsx
new file mode 100644
index 0000000000..918b41a27a
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/AxisSettings.jsx
@@ -0,0 +1,106 @@
+import { isString, isObject, isFinite, isNumber, merge } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useDebouncedCallback } from 'use-debounce';
+import Select from 'antd/lib/select';
+import Input from 'antd/lib/input';
+import InputNumber from 'antd/lib/input-number';
+import * as Grid from 'antd/lib/grid';
+
+function toNumber(value) {
+ value = isNumber(value) ? value : parseFloat(value);
+ return isFinite(value) ? value : null;
+}
+
+export default function AxisSettings({ id, options, features, onChange }) {
+ function optionsChanged(newOptions) {
+ onChange(merge({}, options, newOptions));
+ }
+
+ const [handleNameChange] = useDebouncedCallback((text) => {
+ const title = isString(text) && (text !== '') ? { text } : null;
+ optionsChanged({ title });
+ }, 200);
+
+ const [handleMinMaxChange] = useDebouncedCallback(opts => optionsChanged(opts), 200);
+
+ return (
+
+
+ Scale
+ optionsChanged({ type })}
+ >
+ {features.autoDetectType && Auto Detect }
+ Datetime
+ Linear
+ Logarithmic
+ Category
+
+
+
+
+ Name
+ handleNameChange(event.target.value)}
+ />
+
+
+ {features.range && (
+
+
+ Min Value
+ handleMinMaxChange({ rangeMin: toNumber(value) })}
+ />
+
+
+ Max Value
+ handleMinMaxChange({ rangeMax: toNumber(value) })}
+ />
+
+
+ )}
+
+ );
+}
+
+AxisSettings.propTypes = {
+ id: PropTypes.string.isRequired,
+ options: PropTypes.shape({
+ type: PropTypes.string.isRequired,
+ title: PropTypes.shape({
+ text: PropTypes.string,
+ }),
+ rangeMin: PropTypes.number,
+ rangeMax: PropTypes.number,
+ }).isRequired,
+ features: PropTypes.shape({
+ autoDetectType: PropTypes.bool,
+ range: PropTypes.bool,
+ }),
+ onChange: PropTypes.func,
+};
+
+AxisSettings.defaultProps = {
+ features: {},
+ onChange: () => {},
+};
diff --git a/client/app/visualizations/chart/Editor/ChartTypeSelect.jsx b/client/app/visualizations/chart/Editor/ChartTypeSelect.jsx
new file mode 100644
index 0000000000..9931130d12
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/ChartTypeSelect.jsx
@@ -0,0 +1,36 @@
+import { map } from 'lodash';
+import React, { useMemo } from 'react';
+import Select from 'antd/lib/select';
+import { clientConfig } from '@/services/auth';
+
+export default function ChartTypeSelect(props) {
+ const chartTypes = useMemo(() => {
+ const result = [
+ { type: 'line', name: 'Line', icon: 'line-chart' },
+ { type: 'column', name: 'Bar', icon: 'bar-chart' },
+ { type: 'area', name: 'Area', icon: 'area-chart' },
+ { type: 'pie', name: 'Pie', icon: 'pie-chart' },
+ { type: 'scatter', name: 'Scatter', icon: 'circle-o' },
+ { type: 'bubble', name: 'Bubble', icon: 'circle-o' },
+ { type: 'heatmap', name: 'Heatmap', icon: 'th' },
+ { type: 'box', name: 'Box', icon: 'square-o' },
+ ];
+
+ if (clientConfig.allowCustomJSVisualizations) {
+ result.push({ type: 'custom', name: 'Custom', icon: 'code' });
+ }
+
+ return result;
+ }, []);
+
+ return (
+
+ {map(chartTypes, ({ type, name, icon }) => (
+
+
+ {name}
+
+ ))}
+
+ );
+}
diff --git a/client/app/visualizations/chart/Editor/ColorsSettings.jsx b/client/app/visualizations/chart/Editor/ColorsSettings.jsx
new file mode 100644
index 0000000000..395ac8179c
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/ColorsSettings.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { EditorPropTypes } from '@/visualizations';
+
+import PieColorsSettings from './PieColorsSettings';
+import HeatmapColorsSettings from './HeatmapColorsSettings';
+import DefaultColorsSettings from './DefaultColorsSettings';
+
+const components = {
+ pie: PieColorsSettings,
+ heatmap: HeatmapColorsSettings,
+};
+
+export default function ColorsSettings({ options, ...props }) {
+ const Component = components[options.globalSeriesType] || DefaultColorsSettings;
+ return
;
+}
+
+ColorsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/ColorsSettings.test.js b/client/app/visualizations/chart/Editor/ColorsSettings.test.js
new file mode 100644
index 0000000000..505f20d482
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/ColorsSettings.test.js
@@ -0,0 +1,84 @@
+import { after } from 'lodash';
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import ColorsSettings from './ColorsSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(options, done) {
+ options = getOptions(options);
+ return enzyme.mount((
+
{
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Chart -> Editor -> Colors Settings', () => {
+ describe('for pie', () => {
+ test('Changes series color', (done) => {
+ const el = mount({
+ globalSeriesType: 'pie',
+ columnMapping: { a: 'x', b: 'y' },
+ }, done);
+
+ findByTestID(el, 'Chart.Series.v.Color').first().simulate('click');
+ findByTestID(el, 'ColorPicker').first().find('input')
+ .simulate('change', { target: { value: 'red' } });
+ });
+ });
+
+ describe('for heatmap', () => {
+ test('Changes color scheme', (done) => {
+ const el = mount({
+ globalSeriesType: 'heatmap',
+ columnMapping: { a: 'x', b: 'y' },
+ }, done);
+
+ findByTestID(el, 'Chart.Colors.Heatmap.ColorScheme').first().simulate('click');
+ findByTestID(el, 'Chart.Colors.Heatmap.ColorScheme.RdBu').first().simulate('click');
+ });
+
+ test('Sets custom color scheme', async (done) => {
+ const el = mount({
+ globalSeriesType: 'heatmap',
+ columnMapping: { a: 'x', b: 'y' },
+ colorScheme: 'Custom...',
+ }, after(2, done)); // we will perform 2 actions, so call `done` after all of them completed
+
+ findByTestID(el, 'Chart.Colors.Heatmap.MinColor').first().simulate('click');
+ findByTestID(el, 'ColorPicker').first().find('input')
+ .simulate('change', { target: { value: 'yellow' } });
+
+ findByTestID(el, 'Chart.Colors.Heatmap.MaxColor').first().simulate('click');
+ findByTestID(el, 'ColorPicker').first().find('input')
+ .simulate('change', { target: { value: 'red' } });
+ });
+ });
+
+ describe('for all except of pie and heatmap', () => {
+ test('Changes series color', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ columnMapping: { a: 'x', b: 'y' },
+ }, done);
+
+ findByTestID(el, 'Chart.Series.b.Color').first().simulate('click');
+ findByTestID(el, 'ColorPicker').first().find('input')
+ .simulate('change', { target: { value: 'red' } });
+ });
+ });
+});
diff --git a/client/app/visualizations/chart/Editor/ColumnMappingSelect.jsx b/client/app/visualizations/chart/Editor/ColumnMappingSelect.jsx
new file mode 100644
index 0000000000..336c611b43
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/ColumnMappingSelect.jsx
@@ -0,0 +1,60 @@
+import { isString, map, uniq, flatten, filter, sortBy, keys } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import Select from 'antd/lib/select';
+
+const MappingTypes = {
+ x: { label: 'X Column' },
+ y: { label: 'Y Columns', multiple: true },
+ series: { label: 'Group by' },
+ yError: { label: 'Errors column' },
+ size: { label: 'Bubble size column' },
+ zVal: { label: 'Color Column' },
+};
+
+export default function ColumnMappingSelect({ value, availableColumns, type, onChange }) {
+ const options = sortBy(filter(
+ uniq(flatten([availableColumns, value])),
+ v => isString(v) && (v !== ''),
+ ));
+ const { label, multiple } = MappingTypes[type];
+
+ return (
+
+ {label}
+ onChange(column || null, type)}
+ >
+ {map(options, c => (
+ {c}
+ ))}
+
+
+ );
+}
+
+ColumnMappingSelect.propTypes = {
+ value: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.arrayOf(PropTypes.string),
+ ]),
+ availableColumns: PropTypes.arrayOf(PropTypes.string),
+ type: PropTypes.oneOf(keys(MappingTypes)),
+ onChange: PropTypes.func,
+};
+
+ColumnMappingSelect.defaultProps = {
+ value: null,
+ availableColumns: [],
+ type: null,
+ onChange: () => {},
+};
+
+ColumnMappingSelect.MappingTypes = MappingTypes;
diff --git a/client/app/visualizations/chart/Editor/CustomChartSettings.jsx b/client/app/visualizations/chart/Editor/CustomChartSettings.jsx
new file mode 100644
index 0000000000..9560f75589
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/CustomChartSettings.jsx
@@ -0,0 +1,58 @@
+import { isNil, trimStart } from 'lodash';
+import React from 'react';
+import Switch from 'antd/lib/switch';
+import Input from 'antd/lib/input';
+import { EditorPropTypes } from '@/visualizations';
+
+const { TextArea } = Input;
+
+const defaultCustomCode = trimStart(`
+// Available variables are x, ys, element, and Plotly
+// Type console.log(x, ys); for more info about x and ys
+// To plot your graph call Plotly.plot(element, ...)
+// Plotly examples and docs: https://plot.ly/javascript/
+`);
+
+export default function CustomChartSettings({ options, onOptionsChange }) {
+ return (
+
+
+ Custom code
+
+
+
+
+ onOptionsChange({ enableConsoleLogs })}
+ />
+ Show errors in the console
+
+
+
+
+
+ onOptionsChange({ autoRedraw })}
+ />
+ Auto update graph
+
+
+
+ );
+}
+
+CustomChartSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/DataLabelsSettings.jsx b/client/app/visualizations/chart/Editor/DataLabelsSettings.jsx
new file mode 100644
index 0000000000..bb32ccaf4e
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/DataLabelsSettings.jsx
@@ -0,0 +1,137 @@
+import { includes } from 'lodash';
+import React from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import Checkbox from 'antd/lib/checkbox';
+import Input from 'antd/lib/input';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import { EditorPropTypes } from '@/visualizations';
+
+export default function DataLabelsSettings({ options, onOptionsChange }) {
+ const isShowDataLabelsAvailable = includes(
+ ['line', 'area', 'column', 'scatter', 'pie', 'heatmap'],
+ options.globalSeriesType,
+ );
+
+ const [debouncedOnOptionsChange] = useDebouncedCallback(onOptionsChange, 200);
+
+ return (
+
+ { isShowDataLabelsAvailable && (
+
+
+ onOptionsChange({ showDataLabels: event.target.checked })}
+ />
+ Show Data Labels
+
+
+ )}
+
+
+
+ Number Values Format
+
+ Format
+ specs.
+
+ )}
+ >
+
+
+
+
debouncedOnOptionsChange({ numberFormat: e.target.value })}
+ />
+
+
+
+
+ Percent Values Format
+
+ Format
+ specs.
+
+ )}
+ >
+
+
+
+
debouncedOnOptionsChange({ percentFormat: e.target.value })}
+ />
+
+
+
+
+ Date/Time Values Format
+
+ Format
+ specs.
+
+ )}
+ >
+
+
+
+
debouncedOnOptionsChange({ dateTimeFormat: e.target.value })}
+ />
+
+
+
+
+ Data Labels
+
+ Use special names to access additional properties:
+ {'{{ @@name }}'}
series name;
+ {'{{ @@x }}'}
x-value;
+ {'{{ @@y }}'}
y-value;
+ {'{{ @@yPercent }}'}
relative y-value;
+ {'{{ @@yError }}'}
y deviation;
+ {'{{ @@size }}'}
bubble size;
+
+ Also, all query result columns can be referenced using
+ {'{{ column_name }}'}
syntax.
+
+
+ )}
+ >
+
+
+
+
debouncedOnOptionsChange({ textFormat: e.target.value })}
+ />
+
+
+ );
+}
+
+DataLabelsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/DataLabelsSettings.test.js b/client/app/visualizations/chart/Editor/DataLabelsSettings.test.js
new file mode 100644
index 0000000000..fba5b365c6
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/DataLabelsSettings.test.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import DataLabelsSettings from './DataLabelsSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(options, done) {
+ options = getOptions(options);
+ return enzyme.mount((
+ {
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Chart -> Editor -> Data Labels Settings', () => {
+ test('Sets Show Data Labels option', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ showDataLabels: false,
+ }, done);
+
+ findByTestID(el, 'Chart.DataLabels.ShowDataLabels').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ test('Changes number format', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ numberFormat: '0[.]0000',
+ }, done);
+
+ findByTestID(el, 'Chart.DataLabels.NumberFormat').first()
+ .simulate('change', { target: { value: '0.00' } });
+ });
+
+ test('Changes percent values format', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ percentFormat: '0[.]00%',
+ }, done);
+
+ findByTestID(el, 'Chart.DataLabels.PercentFormat').first()
+ .simulate('change', { target: { value: '0.0%' } });
+ });
+
+ test('Changes date/time format', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ dateTimeFormat: 'YYYY-MM-DD HH:mm:ss',
+ }, done);
+
+ findByTestID(el, 'Chart.DataLabels.DateTimeFormat').first()
+ .simulate('change', { target: { value: 'YYYY MMM DD' } });
+ });
+
+ test('Changes data labels format', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ textFormat: null,
+ }, done);
+
+ findByTestID(el, 'Chart.DataLabels.TextFormat').first()
+ .simulate('change', { target: { value: '{{ @@x }} :: {{ @@y }} / {{ @@yPercent }}' } });
+ });
+});
diff --git a/client/app/visualizations/chart/Editor/DefaultColorsSettings.jsx b/client/app/visualizations/chart/Editor/DefaultColorsSettings.jsx
new file mode 100644
index 0000000000..866ca85136
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/DefaultColorsSettings.jsx
@@ -0,0 +1,65 @@
+import { map } from 'lodash';
+import React, { useMemo, useCallback } from 'react';
+import Table from 'antd/lib/table';
+import ColorPicker from '@/components/ColorPicker';
+import { EditorPropTypes } from '@/visualizations';
+import ColorPalette from '@/visualizations/ColorPalette';
+import getChartData from '../getChartData';
+
+export default function DefaultColorsSettings({ options, data, onOptionsChange }) {
+ const colors = useMemo(() => ({
+ Automatic: null,
+ ...ColorPalette,
+ }), []);
+
+ const series = useMemo(() => map(
+ getChartData(data.rows, options),
+ ({ name }) => ({ key: name, color: (options.seriesOptions[name] || {}).color || null }),
+ ), [options, data]);
+
+ const updateSeriesOption = useCallback((key, prop, value) => {
+ onOptionsChange({
+ seriesOptions: {
+ [key]: {
+ [prop]: value,
+ },
+ },
+ });
+ }, [onOptionsChange]);
+
+ const columns = [
+ {
+ title: 'Series',
+ dataIndex: 'key',
+ },
+ {
+ title: 'Color',
+ dataIndex: 'color',
+ width: '1%',
+ render: (unused, item) => (
+
+ updateSeriesOption(item.key, 'color', value)}
+ />
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
+
+DefaultColorsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/GeneralSettings.jsx b/client/app/visualizations/chart/Editor/GeneralSettings.jsx
new file mode 100644
index 0000000000..185e1d2155
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/GeneralSettings.jsx
@@ -0,0 +1,219 @@
+import { isArray, map, mapValues, includes, some, each, difference } from 'lodash';
+import React, { useMemo } from 'react';
+import Select from 'antd/lib/select';
+import Checkbox from 'antd/lib/checkbox';
+import { EditorPropTypes } from '@/visualizations';
+
+import ChartTypeSelect from './ChartTypeSelect';
+import ColumnMappingSelect from './ColumnMappingSelect';
+
+function getAvailableColumnMappingTypes(options) {
+ const result = ['x', 'y'];
+
+ if (!includes(['custom', 'heatmap'], options.globalSeriesType)) {
+ result.push('series');
+ }
+
+ if (some(options.seriesOptions, { type: 'bubble' })) {
+ result.push('size');
+ }
+
+ if (some(options.seriesOptions, { type: 'heatmap' })) {
+ result.push('zVal');
+ }
+
+ if (!includes(['custom', 'heatmap'], options.globalSeriesType)) {
+ result.push('yError');
+ }
+
+ return result;
+}
+
+function getMappedColumns(options, availableColumns) {
+ const mappedColumns = {};
+ const availableTypes = getAvailableColumnMappingTypes(options);
+ each(availableTypes, (type) => {
+ mappedColumns[type] = ColumnMappingSelect.MappingTypes[type].multiple ? [] : null;
+ });
+
+ availableColumns = map(availableColumns, c => c.name);
+ const usedColumns = [];
+
+ each(options.columnMapping, (type, column) => {
+ if (includes(availableColumns, column) && includes(availableTypes, type)) {
+ const { multiple } = ColumnMappingSelect.MappingTypes[type];
+ if (multiple) {
+ mappedColumns[type].push(column);
+ } else {
+ mappedColumns[type] = column;
+ }
+ usedColumns.push(column);
+ }
+ });
+
+ return {
+ mappedColumns,
+ unusedColumns: difference(availableColumns, usedColumns),
+ };
+}
+
+function mappedColumnsToColumnMappings(mappedColumns) {
+ const result = {};
+ each(mappedColumns, (value, type) => {
+ if (isArray(value)) {
+ each(value, (v) => {
+ result[v] = type;
+ });
+ } else {
+ if (value) {
+ result[value] = type;
+ }
+ }
+ });
+ return result;
+}
+
+export default function GeneralSettings({ options, data, onOptionsChange }) {
+ const { mappedColumns, unusedColumns } = useMemo(
+ () => getMappedColumns(options, data.columns),
+ [options, data.columns],
+ );
+
+ function handleGlobalSeriesTypeChange(globalSeriesType) {
+ onOptionsChange({
+ globalSeriesType,
+ showDataLabels: globalSeriesType === 'pie',
+ seriesOptions: mapValues(options.seriesOptions, series => ({
+ ...series,
+ type: globalSeriesType,
+ })),
+ });
+ }
+
+ function handleColumnMappingChange(column, type) {
+ const columnMapping = mappedColumnsToColumnMappings({
+ ...mappedColumns,
+ [type]: column,
+ });
+ onOptionsChange({ columnMapping }, false);
+ }
+
+ return (
+
+
+ Chart Type
+
+
+
+ {map(mappedColumns, (value, type) => (
+
+ ))}
+
+ {includes(['pie'], options.globalSeriesType) && (
+
+ Direction
+ onOptionsChange({ direction: { type } })}
+ >
+ Counterclockwise
+ Clockwise
+
+
+ )}
+
+ {!includes(['custom', 'heatmap'], options.globalSeriesType) && (
+
+
+ onOptionsChange({ legend: { enabled: event.target.checked } })}
+ />
+ Show Legend
+
+
+ )}
+
+ {includes(['box'], options.globalSeriesType) && (
+
+
+ onOptionsChange({ showpoints: event.target.checked })}
+ />
+ Show All Points
+
+
+ )}
+
+ {!includes(['custom', 'heatmap'], options.globalSeriesType) && (
+
+ Stacking
+
+ onOptionsChange({ series: { stacking } })}
+ >
+ Disabled
+ Stack
+
+
+ )}
+
+ {includes(['line', 'area', 'column'], options.globalSeriesType) && (
+
+
+ onOptionsChange({ series: { percentValues: event.target.checked } })}
+ />
+ Normalize values to percentage
+
+
+ )}
+
+ {!includes(['custom', 'heatmap', 'bubble', 'scatter'], options.globalSeriesType) && (
+
+ Missing and NULL values
+ onOptionsChange({ missingValuesAsZero: !!value })}
+ >
+ Do not display in chart
+ Convert to 0 and display in chart
+
+
+ )}
+
+ );
+}
+
+GeneralSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/GeneralSettings.test.js b/client/app/visualizations/chart/Editor/GeneralSettings.test.js
new file mode 100644
index 0000000000..5866176492
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/GeneralSettings.test.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import GeneralSettings from './GeneralSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function elementExists(wrapper, testId) {
+ return findByTestID(wrapper, testId).length > 0;
+}
+
+function mount(options, done) {
+ options = getOptions(options);
+ return enzyme.mount((
+ {
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Chart -> Editor -> General Settings', () => {
+ test('Changes global series type', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ showDataLabels: false,
+ seriesOptions: {
+ a: { type: 'column' },
+ b: { type: 'line' },
+ },
+ }, done);
+
+ findByTestID(el, 'Chart.GlobalSeriesType').first().simulate('click');
+ findByTestID(el, 'Chart.ChartType.pie').first().simulate('click');
+ });
+
+ test('Pie: changes direction', (done) => {
+ const el = mount({
+ globalSeriesType: 'pie',
+ direction: { type: 'counterclockwise' },
+ }, done);
+
+ findByTestID(el, 'Chart.PieDirection').first().simulate('click');
+ findByTestID(el, 'Chart.PieDirection.Clockwise').first().simulate('click');
+ });
+
+ test('Toggles legend', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ legend: { enabled: true },
+ }, done);
+
+ findByTestID(el, 'Chart.ShowLegend').first().find('input')
+ .simulate('change', { target: { checked: false } });
+ });
+
+ test('Box: toggles show points', (done) => {
+ const el = mount({
+ globalSeriesType: 'box',
+ showpoints: false,
+ }, done);
+
+ findByTestID(el, 'Chart.ShowPoints').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ test('Enables stacking', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ series: {},
+ }, done);
+
+ findByTestID(el, 'Chart.Stacking').first().simulate('click');
+ findByTestID(el, 'Chart.Stacking.Stack').first().simulate('click');
+ });
+
+ test('Toggles normalize values to percentage', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ series: {},
+ }, done);
+
+ findByTestID(el, 'Chart.NormalizeValues').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ test('Keep missing/null values', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ missingValuesAsZero: true,
+ }, done);
+
+ findByTestID(el, 'Chart.MissingValues').first().simulate('click');
+ findByTestID(el, 'Chart.MissingValues.Keep').first().simulate('click');
+ });
+
+ describe('Column mappings should be available', () => {
+ test('for bubble', () => {
+ const el = mount({
+ globalSeriesType: 'column',
+ seriesOptions: {
+ a: { type: 'column' },
+ b: { type: 'bubble' },
+ c: { type: 'heatmap' },
+ },
+ });
+
+ expect(elementExists(el, 'Chart.ColumnMapping.x')).toBeTruthy();
+ expect(elementExists(el, 'Chart.ColumnMapping.y')).toBeTruthy();
+ expect(elementExists(el, 'Chart.ColumnMapping.size')).toBeTruthy();
+ });
+
+ test('for heatmap', () => {
+ const el = mount({
+ globalSeriesType: 'column',
+ seriesOptions: {
+ a: { type: 'column' },
+ b: { type: 'bubble' },
+ c: { type: 'heatmap' },
+ },
+ });
+
+ expect(elementExists(el, 'Chart.ColumnMapping.x')).toBeTruthy();
+ expect(elementExists(el, 'Chart.ColumnMapping.y')).toBeTruthy();
+ expect(elementExists(el, 'Chart.ColumnMapping.zVal')).toBeTruthy();
+ });
+
+ test('for all types except of bubble, heatmap and custom', () => {
+ const el = mount({
+ globalSeriesType: 'column',
+ seriesOptions: {
+ a: { type: 'column' },
+ b: { type: 'bubble' },
+ c: { type: 'heatmap' },
+ },
+ });
+
+ expect(elementExists(el, 'Chart.ColumnMapping.x')).toBeTruthy();
+ expect(elementExists(el, 'Chart.ColumnMapping.y')).toBeTruthy();
+ expect(elementExists(el, 'Chart.ColumnMapping.series')).toBeTruthy();
+ expect(elementExists(el, 'Chart.ColumnMapping.yError')).toBeTruthy();
+ });
+ });
+});
diff --git a/client/app/visualizations/chart/Editor/HeatmapColorsSettings.jsx b/client/app/visualizations/chart/Editor/HeatmapColorsSettings.jsx
new file mode 100644
index 0000000000..923a557314
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/HeatmapColorsSettings.jsx
@@ -0,0 +1,70 @@
+import { map } from 'lodash';
+import React from 'react';
+import Select from 'antd/lib/select';
+import * as Grid from 'antd/lib/grid';
+import ColorPicker from '@/components/ColorPicker';
+import { EditorPropTypes } from '@/visualizations';
+import ColorPalette from '@/visualizations/ColorPalette';
+
+const ColorSchemes = [
+ 'Blackbody', 'Bluered', 'Blues', 'Earth', 'Electric',
+ 'Greens', 'Greys', 'Hot', 'Jet', 'Picnic', 'Portland',
+ 'Rainbow', 'RdBu', 'Reds', 'Viridis', 'YlGnBu', 'YlOrRd',
+ 'Custom...',
+];
+
+export default function HeatmapColorsSettings({ options, onOptionsChange }) {
+ return (
+
+
+ Color Scheme
+ onOptionsChange({ colorScheme: value || null })}
+ >
+ {map(ColorSchemes, scheme => (
+
+ {scheme}
+
+ ))}
+
+
+
+ {(options.colorScheme === 'Custom...') && (
+
+
+ Min Color:
+ onOptionsChange({ heatMinColor })}
+ />
+
+
+ Max Color:
+ onOptionsChange({ heatMaxColor })}
+ />
+
+
+ )}
+
+ );
+}
+
+HeatmapColorsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/PieColorsSettings.jsx b/client/app/visualizations/chart/Editor/PieColorsSettings.jsx
new file mode 100644
index 0000000000..0db3145f55
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/PieColorsSettings.jsx
@@ -0,0 +1,75 @@
+import { each, map } from 'lodash';
+import React, { useMemo, useCallback } from 'react';
+import Table from 'antd/lib/table';
+import ColorPicker from '@/components/ColorPicker';
+import { EditorPropTypes } from '@/visualizations';
+import ColorPalette from '@/visualizations/ColorPalette';
+import getChartData from '../getChartData';
+
+function getUniqueValues(chartData) {
+ const uniqueValuesNames = new Set();
+ each(chartData, (series) => {
+ each(series.data, (row) => {
+ uniqueValuesNames.add(row.x);
+ });
+ });
+ return [...uniqueValuesNames];
+}
+
+export default function PieColorsSettings({ options, data, onOptionsChange }) {
+ const colors = useMemo(() => ({
+ Automatic: null,
+ ...ColorPalette,
+ }), []);
+
+ const series = useMemo(() => map(
+ getUniqueValues(getChartData(data.rows, options)),
+ value => ({ key: value, color: (options.valuesOptions[value] || {}).color || null }),
+ ), [options, data]);
+
+ const updateValuesOption = useCallback((key, prop, value) => {
+ onOptionsChange({
+ valuesOptions: {
+ [key]: {
+ [prop]: value,
+ },
+ },
+ });
+ }, [onOptionsChange]);
+
+ const columns = [
+ {
+ title: 'Values',
+ dataIndex: 'key',
+ },
+ {
+ title: 'Color',
+ dataIndex: 'color',
+ width: '1%',
+ render: (unused, item) => (
+
+ updateValuesOption(item.key, 'color', value)}
+ />
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
+
+PieColorsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/SeriesSettings.jsx b/client/app/visualizations/chart/Editor/SeriesSettings.jsx
new file mode 100644
index 0000000000..a6665006f2
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/SeriesSettings.jsx
@@ -0,0 +1,138 @@
+import { includes, map, extend, fromPairs } from 'lodash';
+import React, { useMemo, useCallback } from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import Table from 'antd/lib/table';
+import Input from 'antd/lib/input';
+import Radio from 'antd/lib/radio';
+import { sortableElement } from 'react-sortable-hoc';
+import { SortableContainer, DragHandle } from '@/components/sortable';
+import { EditorPropTypes } from '@/visualizations';
+import ChartTypeSelect from './ChartTypeSelect';
+import getChartData from '../getChartData';
+
+const SortableBodyRow = sortableElement(props => );
+
+function getTableColumns(options, updateSeriesOption, debouncedUpdateSeriesOption) {
+ const result = [
+ {
+ title: 'Order',
+ dataIndex: 'zIndex',
+ className: 'text-nowrap',
+ render: (unused, item) => (
+
+
+ {item.zIndex + 1}
+
+ ),
+ },
+ {
+ title: 'Label',
+ dataIndex: 'name',
+ className: 'text-nowrap',
+ render: (unused, item) => (
+ debouncedUpdateSeriesOption(item.key, 'name', event.target.value)}
+ />
+ ),
+ },
+ ];
+
+ if (!includes(['pie', 'heatmap'], options.globalSeriesType)) {
+ result.push({
+ title: 'Y Axis',
+ dataIndex: 'yAxis',
+ className: 'text-nowrap',
+ render: (unused, item) => (
+ updateSeriesOption(item.key, 'yAxis', event.target.value)}
+ >
+ left
+ right
+
+ ),
+ });
+ result.push({
+ title: 'Type',
+ dataIndex: 'type',
+ className: 'text-nowrap',
+ render: (unused, item) => (
+ updateSeriesOption(item.key, 'type', value)}
+ />
+ ),
+ });
+ }
+
+ return result;
+}
+
+export default function SeriesSettings({ options, data, onOptionsChange }) {
+ const series = useMemo(() => map(
+ getChartData(data.rows, options), // returns sorted series
+ ({ name }, zIndex) => extend(
+ { key: name, type: options.globalSeriesType },
+ options.seriesOptions[name],
+ { zIndex },
+ ),
+ ), [options, data]);
+
+ const handleSortEnd = useCallback(({ oldIndex, newIndex }) => {
+ const seriesOptions = [...series];
+ seriesOptions.splice(newIndex, 0, ...seriesOptions.splice(oldIndex, 1));
+ onOptionsChange({ seriesOptions: fromPairs(map(seriesOptions, ({ key }, zIndex) => ([key, { zIndex }]))) });
+ }, [series]);
+
+ const updateSeriesOption = useCallback((key, prop, value) => {
+ onOptionsChange({
+ seriesOptions: {
+ [key]: {
+ [prop]: value,
+ },
+ },
+ });
+ }, [onOptionsChange]);
+ const [debouncedUpdateSeriesOption] = useDebouncedCallback(updateSeriesOption, 200);
+
+ const columns = useMemo(
+ () => getTableColumns(options, updateSeriesOption, debouncedUpdateSeriesOption),
+ [options, updateSeriesOption, debouncedUpdateSeriesOption],
+ );
+
+ return (
+ container.querySelector('tbody')}
+ onSortEnd={handleSortEnd}
+ containerProps={{
+ className: 'chart-editor-series',
+ }}
+ >
+ ({ index: item.zIndex })}
+ pagination={false}
+ />
+
+ );
+}
+
+SeriesSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/SeriesSettings.test.js b/client/app/visualizations/chart/Editor/SeriesSettings.test.js
new file mode 100644
index 0000000000..0075795cd4
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/SeriesSettings.test.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import SeriesSettings from './SeriesSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(options, done) {
+ options = getOptions(options);
+ return enzyme.mount((
+ {
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Chart -> Editor -> Series Settings', () => {
+ test('Changes series type', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ columnMapping: { a: 'y' },
+ seriesOptions: {
+ a: { type: 'column', label: 'a', yAxis: 0 },
+ },
+ }, done);
+
+ findByTestID(el, 'Chart.Series.a.Type').first().simulate('click');
+ findByTestID(el, 'Chart.ChartType.area').first().simulate('click');
+ });
+
+ test('Changes series label', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ columnMapping: { a: 'y' },
+ seriesOptions: {
+ a: { type: 'column', label: 'a', yAxis: 0 },
+ },
+ }, done);
+
+ findByTestID(el, 'Chart.Series.a.Label').first().simulate('change', { target: { value: 'test' } });
+ });
+
+ test('Changes series axis', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ columnMapping: { a: 'y' },
+ seriesOptions: {
+ a: { type: 'column', name: 'a', yAxis: 0 },
+ },
+ }, done);
+
+ findByTestID(el, 'Chart.Series.a.UseRightAxis').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+});
diff --git a/client/app/visualizations/chart/Editor/XAxisSettings.jsx b/client/app/visualizations/chart/Editor/XAxisSettings.jsx
new file mode 100644
index 0000000000..6ad2070705
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/XAxisSettings.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import Switch from 'antd/lib/switch';
+import { EditorPropTypes } from '@/visualizations';
+
+import AxisSettings from './AxisSettings';
+
+export default function XAxisSettings({ options, onOptionsChange }) {
+ return (
+
+ onOptionsChange({ xAxis })}
+ />
+
+
+
+ onOptionsChange({ sortX })}
+ />
+ Sort Values
+
+
+
+
+
+ onOptionsChange({ reverseX })}
+ />
+ Reverse Order
+
+
+
+
+
+ onOptionsChange({ xAxis: { labels: { enabled } } })}
+ />
+ Show Labels
+
+
+
+ );
+}
+
+XAxisSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/XAxisSettings.test.js b/client/app/visualizations/chart/Editor/XAxisSettings.test.js
new file mode 100644
index 0000000000..57fc4e4361
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/XAxisSettings.test.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import XAxisSettings from './XAxisSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(options, done) {
+ options = getOptions(options);
+ return enzyme.mount((
+ {
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Chart -> Editor -> X-Axis Settings', () => {
+ test('Changes axis type', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ xAxis: { type: '-', labels: { enabled: true } },
+ }, done);
+
+ findByTestID(el, 'Chart.XAxis.Type').first().simulate('click');
+ findByTestID(el, 'Chart.XAxis.Type.Linear').first().simulate('click');
+ });
+
+ test('Changes axis name', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ xAxis: { type: '-', labels: { enabled: true } },
+ }, done);
+
+ findByTestID(el, 'Chart.XAxis.Name').first().simulate('change', { target: { value: 'test' } });
+ });
+
+ test('Sets Show Labels option', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ xAxis: { type: '-', labels: { enabled: false } },
+ }, done);
+
+ findByTestID(el, 'Chart.XAxis.ShowLabels').first().simulate('click');
+ });
+
+ test('Sets Sort X Values option', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ sortX: false,
+ }, done);
+
+ findByTestID(el, 'Chart.XAxis.Sort').first().simulate('click');
+ });
+
+ test('Sets Reverse X Values option', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ reverseX: false,
+ }, done);
+
+ findByTestID(el, 'Chart.XAxis.Reverse').first().simulate('click');
+ });
+});
diff --git a/client/app/visualizations/chart/Editor/YAxisSettings.jsx b/client/app/visualizations/chart/Editor/YAxisSettings.jsx
new file mode 100644
index 0000000000..a6889a5bba
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/YAxisSettings.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import Switch from 'antd/lib/switch';
+import { EditorPropTypes } from '@/visualizations';
+
+import AxisSettings from './AxisSettings';
+
+export default function YAxisSettings({ options, onOptionsChange }) {
+ const [leftYAxis, rightYAxis] = options.yAxis;
+
+ return (
+
+
+
Left Y Axis
+
onOptionsChange({ yAxis: [axis, rightYAxis] })}
+ />
+
+
+ {(options.globalSeriesType !== 'heatmap') && (
+
+
Right Y Axis
+
onOptionsChange({ yAxis: [leftYAxis, axis] })}
+ />
+
+ )}
+
+ {(options.globalSeriesType === 'heatmap') && (
+
+
+
+ onOptionsChange({ sortY })}
+ />
+ Sort Values
+
+
+
+
+
+ onOptionsChange({ reverseY })}
+ />
+ Reverse Order
+
+
+
+ )}
+
+ );
+}
+
+YAxisSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/YAxisSettings.test.js b/client/app/visualizations/chart/Editor/YAxisSettings.test.js
new file mode 100644
index 0000000000..c609c1c4e0
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/YAxisSettings.test.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import YAxisSettings from './YAxisSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function elementExists(wrapper, testId) {
+ return findByTestID(wrapper, testId).length > 0;
+}
+
+function mount(options, done) {
+ options = getOptions(options);
+ return enzyme.mount((
+ {
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Chart -> Editor -> Y-Axis Settings', () => {
+ test('Changes axis type', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
+ }, done);
+
+ findByTestID(el, 'Chart.LeftYAxis.Type').first().simulate('click');
+ findByTestID(el, 'Chart.LeftYAxis.Type.Category').first().simulate('click');
+ });
+
+ test('Changes axis name', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
+ }, done);
+
+ findByTestID(el, 'Chart.LeftYAxis.Name').first().simulate('change', { target: { value: 'test' } });
+ });
+
+ test('Changes axis min value', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
+ }, done);
+
+ findByTestID(el, 'Chart.LeftYAxis.RangeMin').find('input').first().simulate('change', { target: { value: '50' } });
+ });
+
+ test('Changes axis max value', (done) => {
+ const el = mount({
+ globalSeriesType: 'column',
+ yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
+ }, done);
+
+ findByTestID(el, 'Chart.LeftYAxis.RangeMax').find('input').first().simulate('change', { target: { value: '200' } });
+ });
+
+ describe('for non-heatmap', () => {
+ test('Right Y Axis should be available', () => {
+ const el = mount({
+ globalSeriesType: 'column',
+ yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
+ });
+
+ expect(elementExists(el, 'Chart.RightYAxis.Type')).toBeTruthy();
+ });
+ });
+
+ describe('for heatmap', () => {
+ test('Right Y Axis should not be available', () => {
+ const el = mount({
+ globalSeriesType: 'heatmap',
+ yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
+ });
+
+ expect(elementExists(el, 'Chart.RightYAxis.Type')).toBeFalsy();
+ });
+
+ test('Sets Sort X Values option', (done) => {
+ const el = mount({
+ globalSeriesType: 'heatmap',
+ sortY: false,
+ }, done);
+
+ findByTestID(el, 'Chart.LeftYAxis.Sort').first().simulate('click');
+ });
+
+ test('Sets Reverse Y Values option', (done) => {
+ const el = mount({
+ globalSeriesType: 'heatmap',
+ reverseY: false,
+ }, done);
+
+ findByTestID(el, 'Chart.LeftYAxis.Reverse').first().simulate('click');
+ });
+ });
+});
diff --git a/client/app/visualizations/chart/Editor/__snapshots__/ColorsSettings.test.js.snap b/client/app/visualizations/chart/Editor/__snapshots__/ColorsSettings.test.js.snap
new file mode 100644
index 0000000000..dd8111f6d2
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/__snapshots__/ColorsSettings.test.js.snap
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Chart -> Editor -> Colors Settings for all except of pie and heatmap Changes series color 1`] = `
+Object {
+ "seriesOptions": Object {
+ "b": Object {
+ "color": "#FF0000",
+ },
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Colors Settings for heatmap Changes color scheme 1`] = `
+Object {
+ "colorScheme": "RdBu",
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Colors Settings for heatmap Sets custom color scheme 1`] = `
+Object {
+ "heatMinColor": "#FFFF00",
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Colors Settings for heatmap Sets custom color scheme 2`] = `
+Object {
+ "heatMinColor": "#FF0000",
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Colors Settings for pie Changes series color 1`] = `
+Object {
+ "valuesOptions": Object {
+ "v": Object {
+ "color": "#FF0000",
+ },
+ },
+}
+`;
diff --git a/client/app/visualizations/chart/Editor/__snapshots__/DataLabelsSettings.test.js.snap b/client/app/visualizations/chart/Editor/__snapshots__/DataLabelsSettings.test.js.snap
new file mode 100644
index 0000000000..bb5f723c50
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/__snapshots__/DataLabelsSettings.test.js.snap
@@ -0,0 +1,31 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes data labels format 1`] = `
+Object {
+ "textFormat": "{{ @@x }} :: {{ @@y }} / {{ @@yPercent }}",
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes date/time format 1`] = `
+Object {
+ "dateTimeFormat": "YYYY MMM DD",
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes number format 1`] = `
+Object {
+ "numberFormat": "0.00",
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes percent values format 1`] = `
+Object {
+ "percentFormat": "0.0%",
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Sets Show Data Labels option 1`] = `
+Object {
+ "showDataLabels": true,
+}
+`;
diff --git a/client/app/visualizations/chart/Editor/__snapshots__/GeneralSettings.test.js.snap b/client/app/visualizations/chart/Editor/__snapshots__/GeneralSettings.test.js.snap
new file mode 100644
index 0000000000..8da2f9fe05
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/__snapshots__/GeneralSettings.test.js.snap
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Chart -> Editor -> General Settings Box: toggles show points 1`] = `
+Object {
+ "showpoints": true,
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> General Settings Changes global series type 1`] = `
+Object {
+ "globalSeriesType": "pie",
+ "seriesOptions": Object {
+ "a": Object {
+ "type": "pie",
+ },
+ "b": Object {
+ "type": "pie",
+ },
+ },
+ "showDataLabels": true,
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> General Settings Enables stacking 1`] = `
+Object {
+ "series": Object {
+ "stacking": "stack",
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> General Settings Keep missing/null values 1`] = `
+Object {
+ "missingValuesAsZero": false,
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> General Settings Pie: changes direction 1`] = `
+Object {
+ "direction": Object {
+ "type": "clockwise",
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> General Settings Toggles legend 1`] = `
+Object {
+ "legend": Object {
+ "enabled": false,
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> General Settings Toggles normalize values to percentage 1`] = `
+Object {
+ "series": Object {
+ "percentValues": true,
+ },
+}
+`;
diff --git a/client/app/visualizations/chart/Editor/__snapshots__/SeriesSettings.test.js.snap b/client/app/visualizations/chart/Editor/__snapshots__/SeriesSettings.test.js.snap
new file mode 100644
index 0000000000..a0f5db1dee
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/__snapshots__/SeriesSettings.test.js.snap
@@ -0,0 +1,31 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Chart -> Editor -> Series Settings Changes series axis 1`] = `
+Object {
+ "seriesOptions": Object {
+ "a": Object {
+ "yAxis": 1,
+ },
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Series Settings Changes series label 1`] = `
+Object {
+ "seriesOptions": Object {
+ "a": Object {
+ "name": "test",
+ },
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Series Settings Changes series type 1`] = `
+Object {
+ "seriesOptions": Object {
+ "a": Object {
+ "type": "area",
+ },
+ },
+}
+`;
diff --git a/client/app/visualizations/chart/Editor/__snapshots__/XAxisSettings.test.js.snap b/client/app/visualizations/chart/Editor/__snapshots__/XAxisSettings.test.js.snap
new file mode 100644
index 0000000000..4e5fbe197b
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/__snapshots__/XAxisSettings.test.js.snap
@@ -0,0 +1,48 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Changes axis name 1`] = `
+Object {
+ "xAxis": Object {
+ "labels": Object {
+ "enabled": true,
+ },
+ "title": Object {
+ "text": "test",
+ },
+ "type": "-",
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Changes axis type 1`] = `
+Object {
+ "xAxis": Object {
+ "labels": Object {
+ "enabled": true,
+ },
+ "type": "linear",
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Sets Reverse X Values option 1`] = `
+Object {
+ "reverseX": true,
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Sets Show Labels option 1`] = `
+Object {
+ "xAxis": Object {
+ "labels": Object {
+ "enabled": true,
+ },
+ },
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Sets Sort X Values option 1`] = `
+Object {
+ "sortX": true,
+}
+`;
diff --git a/client/app/visualizations/chart/Editor/__snapshots__/YAxisSettings.test.js.snap b/client/app/visualizations/chart/Editor/__snapshots__/YAxisSettings.test.js.snap
new file mode 100644
index 0000000000..b9364b20fa
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/__snapshots__/YAxisSettings.test.js.snap
@@ -0,0 +1,74 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis max value 1`] = `
+Object {
+ "yAxis": Array [
+ Object {
+ "rangeMax": 200,
+ "type": "linear",
+ },
+ Object {
+ "opposite": true,
+ "type": "linear",
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis min value 1`] = `
+Object {
+ "yAxis": Array [
+ Object {
+ "rangeMin": 50,
+ "type": "linear",
+ },
+ Object {
+ "opposite": true,
+ "type": "linear",
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis name 1`] = `
+Object {
+ "yAxis": Array [
+ Object {
+ "title": Object {
+ "text": "test",
+ },
+ "type": "linear",
+ },
+ Object {
+ "opposite": true,
+ "type": "linear",
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis type 1`] = `
+Object {
+ "yAxis": Array [
+ Object {
+ "type": "category",
+ },
+ Object {
+ "opposite": true,
+ "type": "linear",
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings for heatmap Sets Reverse Y Values option 1`] = `
+Object {
+ "reverseY": true,
+}
+`;
+
+exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings for heatmap Sets Sort X Values option 1`] = `
+Object {
+ "sortY": true,
+}
+`;
diff --git a/client/app/visualizations/chart/Editor/editor.less b/client/app/visualizations/chart/Editor/editor.less
new file mode 100644
index 0000000000..13e5bc53e7
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/editor.less
@@ -0,0 +1,33 @@
+.chart-editor-series {
+ .drag-handle {
+ height: 28px;
+ padding: 0 5px;
+ margin-left: -5px;
+ }
+
+ &.sortable-container {
+ table {
+ background: transparent;
+ }
+
+ thead th {
+ // TODO: replace with @table-header-bg
+ // Cannot do it not because of conflict between Antd and Bootstrap variables
+ background: mix(#ffffff, rgb(102, 136, 153), 97%) !important;
+ }
+
+ &.sortable-container-dragging tbody {
+ td {
+ background: transparent !important;
+ }
+
+ .chart-editor-series-dragged-item {
+ td {
+ // TODO: replace with @table-row-hover-bg
+ // Cannot do it not because of conflict between Antd and Bootstrap variables
+ background: mix(#ffffff, rgb(102, 136, 153), 95%) !important;
+ }
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/Editor/index.jsx b/client/app/visualizations/chart/Editor/index.jsx
new file mode 100644
index 0000000000..1ae2e8a67f
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/index.jsx
@@ -0,0 +1,65 @@
+import { merge, extend } from 'lodash';
+import React from 'react';
+import Tabs from 'antd/lib/tabs';
+import { EditorPropTypes } from '@/visualizations';
+
+import GeneralSettings from './GeneralSettings';
+import XAxisSettings from './XAxisSettings';
+import YAxisSettings from './YAxisSettings';
+import SeriesSettings from './SeriesSettings';
+import ColorsSettings from './ColorsSettings';
+import DataLabelsSettings from './DataLabelsSettings';
+import CustomChartSettings from './CustomChartSettings';
+
+import './editor.less';
+
+export default function Editor(props) {
+ const { options, onOptionsChange } = props;
+
+ const optionsChanged = (newOptions, deepUpdate = true) => {
+ if (deepUpdate) {
+ onOptionsChange(merge({}, options, newOptions));
+ } else {
+ onOptionsChange(extend({}, options, newOptions));
+ }
+ };
+
+ const isCustomChart = options.globalSeriesType === 'custom';
+ const isPieChart = options.globalSeriesType === 'pie';
+
+ return (
+
+ General}>
+
+ {isCustomChart && }
+
+ {!isCustomChart && !isPieChart && (
+ X Axis}>
+
+
+ )}
+ {!isCustomChart && !isPieChart && (
+ Y Axis}>
+
+
+ )}
+ {!isCustomChart && (
+ Series}>
+
+
+ )}
+ {!isCustomChart && (
+ Colors}>
+
+
+ )}
+ {!isCustomChart && (
+ Data Labels}>
+
+
+ )}
+
+ );
+}
+
+Editor.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/chart/Editor/index.test.js b/client/app/visualizations/chart/Editor/index.test.js
new file mode 100644
index 0000000000..16165ac0fb
--- /dev/null
+++ b/client/app/visualizations/chart/Editor/index.test.js
@@ -0,0 +1,55 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import Editor from './index';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function elementExists(wrapper, testId) {
+ return findByTestID(wrapper, testId).length > 0;
+}
+
+function mount(options, data) {
+ options = getOptions(options);
+ return enzyme.mount((
+ {}}
+ />
+ ));
+}
+
+describe('Visualizations -> Chart -> Editor (wrapper)', () => {
+ test('Renders generic wrapper', () => {
+ const el = mount({ globalSeriesType: 'column' }, { columns: [], rows: [] });
+
+ expect(elementExists(el, 'Chart.EditorTabs.General')).toBeTruthy();
+ expect(elementExists(el, 'Chart.EditorTabs.XAxis')).toBeTruthy();
+ expect(elementExists(el, 'Chart.EditorTabs.YAxis')).toBeTruthy();
+ expect(elementExists(el, 'Chart.EditorTabs.Series')).toBeTruthy();
+ expect(elementExists(el, 'Chart.EditorTabs.Colors')).toBeTruthy();
+ expect(elementExists(el, 'Chart.EditorTabs.DataLabels')).toBeTruthy();
+
+ expect(elementExists(el, 'Chart.GlobalSeriesType')).toBeTruthy(); // general settings block exists
+ expect(elementExists(el, 'Chart.Custom.Code')).toBeFalsy(); // custom settings block does not exist
+ });
+
+ test('Renders wrapper for custom charts', () => {
+ const el = mount({ globalSeriesType: 'custom' }, { columns: [], rows: [] });
+
+ expect(elementExists(el, 'Chart.EditorTabs.General')).toBeTruthy();
+ expect(elementExists(el, 'Chart.EditorTabs.XAxis')).toBeFalsy();
+ expect(elementExists(el, 'Chart.EditorTabs.YAxis')).toBeFalsy();
+ expect(elementExists(el, 'Chart.EditorTabs.Series')).toBeFalsy();
+ expect(elementExists(el, 'Chart.EditorTabs.Colors')).toBeFalsy();
+ expect(elementExists(el, 'Chart.EditorTabs.DataLabels')).toBeFalsy();
+
+ expect(elementExists(el, 'Chart.GlobalSeriesType')).toBeTruthy(); // general settings block exists
+ expect(elementExists(el, 'Chart.Custom.Code')).toBeTruthy(); // custom settings block exists
+ });
+});
diff --git a/client/app/visualizations/chart/chart-editor.html b/client/app/visualizations/chart/chart-editor.html
deleted file mode 100644
index ebe23910fa..0000000000
--- a/client/app/visualizations/chart/chart-editor.html
+++ /dev/null
@@ -1,483 +0,0 @@
-
-
-
-
-
-
- X Column
-
-
- {{$select.selected}}
-
-
-
-
-
-
-
-
-
-
- Y Columns
-
-
- {{$item}}
-
-
-
-
-
-
-
-
-
-
- Group by
-
-
- {{$select.selected}}
-
-
-
-
-
-
-
-
-
-
- Bubble size column
-
-
- {{$select.selected}}
-
-
-
-
-
-
-
-
-
- Color Column
-
-
- {{$select.selected}}
-
-
-
-
-
-
-
-
- Errors column
-
-
- {{$select.selected}}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Show Legend
-
-
-
-
-
-
- Show All Points
-
-
-
-
-
-
-
-
- Custom code
-
-
-
-
-
-
- Show errors in the console
-
-
-
-
-
-
- Auto update graph
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Min Color
-
-
-
-
-
-
-
-
-
-
-
-
-
- Max Color
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/app/visualizations/chart/getOptions.js b/client/app/visualizations/chart/getOptions.js
new file mode 100644
index 0000000000..82a74c7ad7
--- /dev/null
+++ b/client/app/visualizations/chart/getOptions.js
@@ -0,0 +1,39 @@
+import { merge } from 'lodash';
+import { clientConfig } from '@/services/auth';
+
+const DEFAULT_OPTIONS = {
+ globalSeriesType: 'column',
+ sortX: true,
+ legend: { enabled: true },
+ yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
+ xAxis: { type: '-', labels: { enabled: true } },
+ error_y: { type: 'data', visible: true },
+ series: { stacking: null, error_y: { type: 'data', visible: true } },
+ seriesOptions: {},
+ valuesOptions: {},
+ columnMapping: {},
+ direction: { type: 'counterclockwise' },
+
+ // showDataLabels: false, // depends on chart type
+ numberFormat: '0,0[.]00000',
+ percentFormat: '0[.]00%',
+ // dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig
+ textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }})
+
+ missingValuesAsZero: true,
+};
+
+export default function getOptions(options) {
+ const result = merge({}, DEFAULT_OPTIONS, {
+ showDataLabels: options.globalSeriesType === 'pie',
+ dateTimeFormat: clientConfig.dateTimeFormat,
+ }, options);
+
+ // Backward compatibility
+ if (['normal', 'percent'].indexOf(result.series.stacking) >= 0) {
+ result.series.percentValues = result.series.stacking === 'percent';
+ result.series.stacking = 'stack';
+ }
+
+ return result;
+}
diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js
index d8a3e2fed5..b9ae2bfbe8 100644
--- a/client/app/visualizations/chart/index.js
+++ b/client/app/visualizations/chart/index.js
@@ -1,321 +1,22 @@
-import {
- some, partial, intersection, without, includes, sortBy, each, map, keys, difference, merge, isNil, trim, pick,
-} from 'lodash';
-import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
-import { clientConfig } from '@/services/auth';
-import ColorPalette from '@/visualizations/ColorPalette';
-import getChartData from './getChartData';
-import editorTemplate from './chart-editor.html';
+import getOptions from './getOptions';
import Renderer from './Renderer';
-
-const DEFAULT_OPTIONS = {
- globalSeriesType: 'column',
- sortX: true,
- legend: { enabled: true },
- yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
- xAxis: { type: '-', labels: { enabled: true } },
- error_y: { type: 'data', visible: true },
- series: { stacking: null, error_y: { type: 'data', visible: true } },
- seriesOptions: {},
- valuesOptions: {},
- columnMapping: {},
- direction: { type: 'counterclockwise' },
-
- // showDataLabels: false, // depends on chart type
- numberFormat: '0,0[.]00000',
- percentFormat: '0[.]00%',
- // dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig
- textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }})
-
- missingValuesAsZero: true,
-};
-
-function initEditorForm(options, columns) {
- const result = {
- yAxisColumns: [],
- seriesList: sortBy(keys(options.seriesOptions), name => options.seriesOptions[name].zIndex),
- valuesList: keys(options.valuesOptions),
- };
-
- // Use only mappings for columns that exists in query results
- const mappings = pick(
- options.columnMapping,
- map(columns, c => c.name),
- );
-
- each(mappings, (type, column) => {
- switch (type) {
- case 'x':
- result.xAxisColumn = column;
- break;
- case 'y':
- result.yAxisColumns.push(column);
- break;
- case 'series':
- result.groupby = column;
- break;
- case 'yError':
- result.errorColumn = column;
- break;
- case 'size':
- result.sizeColumn = column;
- break;
- case 'zVal':
- result.zValColumn = column;
- break;
- // no default
- }
- });
-
- return result;
-}
-
-const ChartEditor = {
- template: editorTemplate,
- bindings: {
- data: '<',
- options: '<',
- onOptionsChange: '<',
- },
- controller($scope) {
- this.currentTab = 'general';
- this.setCurrentTab = (tab) => {
- this.currentTab = tab;
- };
-
- this.colors = {
- Automatic: null,
- ...ColorPalette,
- };
-
- this.stackingOptions = {
- Disabled: null,
- Stack: 'stack',
- };
-
- this.chartTypes = {
- line: { name: 'Line', icon: 'line-chart' },
- column: { name: 'Bar', icon: 'bar-chart' },
- area: { name: 'Area', icon: 'area-chart' },
- pie: { name: 'Pie', icon: 'pie-chart' },
- scatter: { name: 'Scatter', icon: 'circle-o' },
- bubble: { name: 'Bubble', icon: 'circle-o' },
- heatmap: { name: 'Heatmap', icon: 'th' },
- box: { name: 'Box', icon: 'square-o' },
- };
-
- if (clientConfig.allowCustomJSVisualizations) {
- this.chartTypes.custom = { name: 'Custom', icon: 'code' };
- }
-
- this.directions = [
- { label: 'Counterclockwise', value: 'counterclockwise' },
- { label: 'Clockwise', value: 'clockwise' },
- ];
-
- this.xAxisScales = [
- { label: 'Auto Detect', value: '-' },
- { label: 'Datetime', value: 'datetime' },
- { label: 'Linear', value: 'linear' },
- { label: 'Logarithmic', value: 'logarithmic' },
- { label: 'Category', value: 'category' },
- ];
- this.yAxisScales = ['linear', 'logarithmic', 'datetime', 'category'];
-
- this.colorScheme = ['Blackbody', 'Bluered', 'Blues', 'Earth', 'Electric',
- 'Greens', 'Greys', 'Hot', 'Jet', 'Picnic', 'Portland',
- 'Rainbow', 'RdBu', 'Reds', 'Viridis', 'YlGnBu', 'YlOrRd', 'Custom...'];
-
- this.chartTypeChanged = () => {
- keys(this.options.seriesOptions).forEach((key) => {
- this.options.seriesOptions[key].type = this.options.globalSeriesType;
- });
- this.options.showDataLabels = this.options.globalSeriesType === 'pie';
- $scope.$applyAsync();
- };
-
- this.showSizeColumnPicker = () => some(this.options.seriesOptions, options => options.type === 'bubble');
- this.showZColumnPicker = () => some(this.options.seriesOptions, options => options.type === 'heatmap');
-
- if (isNil(this.options.customCode)) {
- this.options.customCode = trim(`
-// Available variables are x, ys, element, and Plotly
-// Type console.log(x, ys); for more info about x and ys
-// To plot your graph call Plotly.plot(element, ...)
-// Plotly examples and docs: https://plot.ly/javascript/
- `);
- }
-
- this.form = initEditorForm(this.options, this.data.columns);
-
- const refreshColumns = () => {
- this.columns = this.data.columns;
- this.columnNames = map(this.columns, c => c.name);
- if (this.columnNames.length > 0) {
- each(difference(keys(this.options.columnMapping), this.columnNames), (column) => {
- delete this.options.columnMapping[column];
- });
- }
- };
-
- const refreshColumnsAndForm = () => {
- refreshColumns();
- const data = this.data;
- if (data && (data.columns.length > 0) && (data.rows.length > 0)) {
- this.form.yAxisColumns = intersection(this.form.yAxisColumns, this.columnNames);
- if (!includes(this.columnNames, this.form.xAxisColumn)) {
- this.form.xAxisColumn = undefined;
- }
- if (!includes(this.columnNames, this.form.groupby)) {
- this.form.groupby = undefined;
- }
- }
- };
-
- const refreshSeries = () => {
- const chartData = getChartData(this.data.rows, this.options);
- const seriesNames = map(chartData, s => s.name);
- const existing = keys(this.options.seriesOptions);
- each(difference(seriesNames, existing), (name) => {
- this.options.seriesOptions[name] = {
- type: this.options.globalSeriesType,
- yAxis: 0,
- };
- this.form.seriesList.push(name);
- });
- each(difference(existing, seriesNames), (name) => {
- this.form.seriesList = without(this.form.seriesList, name);
- delete this.options.seriesOptions[name];
- });
-
- if (this.options.globalSeriesType === 'pie') {
- const uniqueValuesNames = new Set();
- each(chartData, (series) => {
- each(series.data, (row) => {
- uniqueValuesNames.add(row.x);
- });
- });
- const valuesNames = [];
- uniqueValuesNames.forEach(v => valuesNames.push(v));
-
- // initialize newly added values
- const newValues = difference(valuesNames, keys(this.options.valuesOptions));
- each(newValues, (name) => {
- this.options.valuesOptions[name] = {};
- this.form.valuesList.push(name);
- });
- // remove settings for values that are no longer available
- each(keys(this.options.valuesOptions), (name) => {
- if (valuesNames.indexOf(name) === -1) {
- delete this.options.valuesOptions[name];
- }
- });
- this.form.valuesList = intersection(this.form.valuesList, valuesNames);
- }
- };
-
- const setColumnRole = (role, column) => {
- this.options.columnMapping[column] = role;
- };
-
- const unsetColumn = column => setColumnRole('unused', column);
-
- refreshColumns();
-
- $scope.$watch('$ctrl.options.columnMapping', refreshSeries, true);
-
- $scope.$watch('$ctrl.data', () => {
- refreshColumnsAndForm();
- refreshSeries();
- });
-
- $scope.$watchCollection('$ctrl.form.seriesList', (value) => {
- each(value, (name, index) => {
- this.options.seriesOptions[name].zIndex = index;
- this.options.seriesOptions[name].index = 0; // is this needed?
- });
- });
-
- $scope.$watchCollection('$ctrl.form.yAxisColumns', (value, old) => {
- each(old, unsetColumn);
- each(value, partial(setColumnRole, 'y'));
- });
-
- $scope.$watch('$ctrl.form.xAxisColumn', (value, old) => {
- if (old !== undefined) { unsetColumn(old); }
- if (value !== undefined) { setColumnRole('x', value); }
- });
-
- $scope.$watch('$ctrl.form.errorColumn', (value, old) => {
- if (old !== undefined) { unsetColumn(old); }
- if (value !== undefined) { setColumnRole('yError', value); }
- });
-
- $scope.$watch('$ctrl.form.sizeColumn', (value, old) => {
- if (old !== undefined) { unsetColumn(old); }
- if (value !== undefined) { setColumnRole('size', value); }
- });
-
- $scope.$watch('$ctrl.form.zValColumn', (value, old) => {
- if (old !== undefined) { unsetColumn(old); }
- if (value !== undefined) { setColumnRole('zVal', value); }
- });
-
- $scope.$watch('$ctrl.form.groupby', (value, old) => {
- if (old !== undefined) { unsetColumn(old); }
- if (value !== undefined) { setColumnRole('series', value); }
- });
-
- $scope.$watch('$ctrl.options', (options) => {
- this.onOptionsChange(options);
- }, true);
-
- this.templateHint = `
- Use special names to access additional properties:
- {{ @@name }}
series name;
- {{ @@x }}
x-value;
- {{ @@y }}
y-value;
- {{ @@yPercent }}
relative y-value;
- {{ @@yError }}
y deviation;
- {{ @@size }}
bubble size;
- Also, all query result columns can be referenced using
- {{ column_name }}
syntax.
- `;
- },
-};
-
-export default function init(ngModule) {
- ngModule.component('chartEditor', ChartEditor);
-
- ngModule.run(($injector) => {
- registerVisualization({
- type: 'CHART',
- name: 'Chart',
- isDefault: true,
- getOptions: (options) => {
- const result = merge({}, DEFAULT_OPTIONS, {
- showDataLabels: options.globalSeriesType === 'pie',
- dateTimeFormat: clientConfig.dateTimeFormat,
- }, options);
-
- // Backward compatibility
- if (['normal', 'percent'].indexOf(result.series.stacking) >= 0) {
- result.series.percentValues = result.series.stacking === 'percent';
- result.series.stacking = 'stack';
- }
-
- return result;
- },
- Renderer,
- Editor: angular2react('chartEditor', ChartEditor, $injector),
-
- defaultColumns: 3,
- defaultRows: 8,
- minColumns: 1,
- minRows: 5,
- });
+import Editor from './Editor';
+
+export default function init() {
+ registerVisualization({
+ type: 'CHART',
+ name: 'Chart',
+ isDefault: true,
+ getOptions,
+ Renderer,
+ Editor,
+
+ defaultColumns: 3,
+ defaultRows: 8,
+ minColumns: 1,
+ minRows: 5,
});
}
diff --git a/client/app/visualizations/chart/plotly/prepareDefaultData.js b/client/app/visualizations/chart/plotly/prepareDefaultData.js
index aeedae3ba3..d42a935b66 100644
--- a/client/app/visualizations/chart/plotly/prepareDefaultData.js
+++ b/client/app/visualizations/chart/plotly/prepareDefaultData.js
@@ -1,4 +1,4 @@
-import { isNil, each, includes, isString, map, sortBy } from 'lodash';
+import { isNil, isString, extend, each, includes, map, sortBy } from 'lodash';
import { cleanNumber, normalizeValue, getSeriesAxis } from './utils';
import { ColorPaletteArray } from '@/visualizations/ColorPalette';
@@ -101,7 +101,10 @@ function prepareBoxSeries(series, options, { seriesColor }) {
function prepareSeries(series, options, additionalOptions) {
const { hoverInfoPattern, index } = additionalOptions;
- const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType };
+ const seriesOptions = extend(
+ { type: options.globalSeriesType, yAxis: 0 },
+ options.seriesOptions[series.name],
+ );
const seriesColor = getSeriesColor(seriesOptions, index);
const seriesYAxis = getSeriesAxis(series, options);
diff --git a/client/app/visualizations/choropleth/ColorPalette.js b/client/app/visualizations/choropleth/ColorPalette.js
new file mode 100644
index 0000000000..fd06852a7e
--- /dev/null
+++ b/client/app/visualizations/choropleth/ColorPalette.js
@@ -0,0 +1,8 @@
+import { extend } from 'lodash';
+import ColorPalette from '@/visualizations/ColorPalette';
+
+export default extend({
+ White: '#ffffff',
+ Black: '#000000',
+ 'Light Gray': '#dddddd',
+}, ColorPalette);
diff --git a/client/app/visualizations/choropleth/Editor/BoundsSettings.jsx b/client/app/visualizations/choropleth/Editor/BoundsSettings.jsx
new file mode 100644
index 0000000000..29b5f0c0af
--- /dev/null
+++ b/client/app/visualizations/choropleth/Editor/BoundsSettings.jsx
@@ -0,0 +1,80 @@
+import { isFinite, cloneDeep } from 'lodash';
+import React, { useState, useEffect, useCallback } from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import InputNumber from 'antd/lib/input-number';
+import * as Grid from 'antd/lib/grid';
+import { EditorPropTypes } from '@/visualizations';
+
+export default function BoundsSettings({ options, onOptionsChange }) {
+ // Bounds may be changed in editor or on preview (by drag/zoom map).
+ // Changes from preview does not come frequently (only when user release mouse button),
+ // but changes from editor should be debounced.
+ // Therefore this component has intermediate state to hold immediate user input,
+ // which is updated from `options.bounds` and by inputs immediately on user input,
+ // but `onOptionsChange` event is debounced and uses last value from internal state.
+
+ const [bounds, setBounds] = useState(options.bounds);
+ const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
+
+ useEffect(() => {
+ setBounds(options.bounds);
+ }, [options.bounds]);
+
+ const updateBounds = useCallback((i, j, v) => {
+ v = parseFloat(v); // InputNumber may emit `null` and empty strings instead of numbers
+ if (isFinite(v)) {
+ const newBounds = cloneDeep(bounds);
+ newBounds[i][j] = v;
+ setBounds(newBounds);
+ onOptionsChangeDebounced({ bounds: newBounds });
+ }
+ }, [bounds]);
+
+ return (
+
+
+ North-East latitude and longitude
+
+
+ updateBounds(1, 0, value)}
+ />
+
+
+ updateBounds(1, 1, value)}
+ />
+
+
+
+
+
+ South-West latitude and longitude
+
+
+ updateBounds(0, 0, value)}
+ />
+
+
+ updateBounds(0, 1, value)}
+ />
+
+
+
+
+ );
+}
+
+BoundsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx b/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx
new file mode 100644
index 0000000000..fa637cf350
--- /dev/null
+++ b/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import Select from 'antd/lib/select';
+import InputNumber from 'antd/lib/input-number';
+import * as Grid from 'antd/lib/grid';
+import ColorPicker from '@/components/ColorPicker';
+import { EditorPropTypes } from '@/visualizations';
+import ColorPalette from '../ColorPalette';
+
+export default function ColorsSettings({ options, onOptionsChange }) {
+ const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
+
+ return (
+
+
+
+ Clustering mode
+
+
+ onOptionsChange({ clusteringMode })}
+ >
+ quantile
+ equidistant
+ k-means
+
+
+
+
+
+
+ Steps
+
+
+ onOptionsChangeDebounced({ steps })}
+ />
+
+
+
+
+
+ Min Color
+
+
+ onOptionsChange({ colors: { min } })}
+ />
+
+
+
+
+
+
+ Max Color
+
+
+ onOptionsChange({ colors: { max } })}
+ />
+
+
+
+
+
+
+ No value color
+
+
+ onOptionsChange({ colors: { noValue } })}
+ />
+
+
+
+
+
+
+ Background color
+
+
+ onOptionsChange({ colors: { background } })}
+ />
+
+
+
+
+
+
+ Borders color
+
+
+ onOptionsChange({ colors: { borders } })}
+ />
+
+
+
+
+ );
+}
+
+ColorsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/choropleth/Editor/FormatSettings.jsx b/client/app/visualizations/choropleth/Editor/FormatSettings.jsx
new file mode 100644
index 0000000000..49c5934b29
--- /dev/null
+++ b/client/app/visualizations/choropleth/Editor/FormatSettings.jsx
@@ -0,0 +1,200 @@
+import React from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import Input from 'antd/lib/input';
+import Checkbox from 'antd/lib/checkbox';
+import Select from 'antd/lib/select';
+import Radio from 'antd/lib/radio';
+import Tooltip from 'antd/lib/tooltip';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import * as Grid from 'antd/lib/grid';
+import { EditorPropTypes } from '@/visualizations';
+
+function TemplateFormatHint({ mapType }) { // eslint-disable-line react/prop-types
+ return (
+
+ All query result columns can be referenced using {'{{ column_name }}'}
syntax.
+ Use special names to access additional properties:
+ {'{{ @@value }}'}
formatted value;
+ {mapType === 'countries' && (
+
+ {'{{ @@name }}'}
short country name;
+ {'{{ @@name_long }}'}
full country name;
+ {'{{ @@abbrev }}'}
abbreviated country name;
+ {'{{ @@iso_a2 }}'}
two-letter ISO country code;
+ {'{{ @@iso_a3 }}'}
three-letter ISO country code;
+ {'{{ @@iso_n3 }}'}
three-digit ISO country code.
+
+ )}
+ {mapType === 'subdiv_japan' && (
+
+ {'{{ @@name }}'}
Prefecture name in English;
+ {'{{ @@name_local }}'}
Prefecture name in Kanji;
+ {'{{ @@iso_3166_2 }}'}
five-letter ISO subdivision code (JP-xx);
+
+ )}
+
+ )}
+ >
+
+
+ );
+}
+
+export default function GeneralSettings({ options, onOptionsChange }) {
+ const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
+
+ const templateFormatHint = ;
+
+ return (
+
+
+
+
+ Value format
+
+ Format
+ specs.
+
+ )}
+ >
+
+
+
+ onOptionsChangeDebounced({ valueFormat: event.target.value })}
+ />
+
+
+ Value placeholder
+ onOptionsChangeDebounced({ noValuePlaceholder: event.target.value })}
+ />
+
+
+
+
+
+ onOptionsChange({ legend: { visible: event.target.checked } })}
+ />
+ Show legend
+
+
+
+
+
+ Legend position
+ onOptionsChange({ legend: { position } })}
+ >
+ top / left
+ top / right
+ bottom / left
+ bottom / right
+
+
+
+ Legend text alignment
+ onOptionsChange({ legend: { alignText: event.target.value } })}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onOptionsChange({ tooltip: { enabled: event.target.checked } })}
+ />
+ Show tooltip
+
+
+
+
+ Tooltip template {templateFormatHint}
+ onOptionsChangeDebounced({ tooltip: { template: event.target.value } })}
+ />
+
+
+
+
+
+
+
+
+ Popup template {templateFormatHint}
+
+
+ );
+}
+
+GeneralSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/choropleth/Editor/GeneralSettings.jsx b/client/app/visualizations/choropleth/Editor/GeneralSettings.jsx
new file mode 100644
index 0000000000..2ab7e30191
--- /dev/null
+++ b/client/app/visualizations/choropleth/Editor/GeneralSettings.jsx
@@ -0,0 +1,103 @@
+import { map } from 'lodash';
+import React, { useMemo } from 'react';
+import Select from 'antd/lib/select';
+import { EditorPropTypes } from '@/visualizations';
+import { inferCountryCodeType } from './utils';
+
+export default function GeneralSettings({ options, data, onOptionsChange }) {
+ const countryCodeTypes = useMemo(() => {
+ switch (options.mapType) {
+ case 'countries':
+ return {
+ 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)',
+ };
+ case 'subdiv_japan':
+ return {
+ name: 'Name',
+ name_local: 'Name (local)',
+ iso_3166_2: 'ISO-3166-2',
+ };
+ default:
+ return {};
+ }
+ }, [options.mapType]);
+
+ const handleChangeAndInferType = (newOptions) => {
+ newOptions.countryCodeType = inferCountryCodeType(
+ newOptions.mapType || options.mapType,
+ data ? data.rows : [],
+ newOptions.countryCodeColumn || options.countryCodeColumn,
+ ) || options.countryCodeType;
+ onOptionsChange(newOptions);
+ };
+
+ return (
+
+
+ Map type
+ handleChangeAndInferType({ mapType })}
+ >
+ Countries
+ Japan/Prefectures
+
+
+
+
+ Key column
+ handleChangeAndInferType({ countryCodeColumn })}
+ >
+ {map(data.columns, ({ name }) => (
+ {name}
+ ))}
+
+
+
+
+ Key type
+ onOptionsChange({ countryCodeType })}
+ >
+ {map(countryCodeTypes, (name, type) => (
+ {name}
+ ))}
+
+
+
+
+ Value column
+ onOptionsChange({ valueColumn })}
+ >
+ {map(data.columns, ({ name }) => (
+ {name}
+ ))}
+
+
+
+ );
+}
+
+GeneralSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/choropleth/Editor/editor.less b/client/app/visualizations/choropleth/Editor/editor.less
new file mode 100644
index 0000000000..27c315e84c
--- /dev/null
+++ b/client/app/visualizations/choropleth/Editor/editor.less
@@ -0,0 +1,15 @@
+.choropleth-visualization-editor-format-settings {
+ .choropleth-visualization-editor-legend-align-text {
+ display: flex;
+ align-items: stretch;
+ justify-content: stretch;
+
+ .ant-radio-button-wrapper {
+ flex-grow: 1;
+ text-align: center;
+ // fit height
+ height: 35px;
+ line-height: 33px;
+ }
+ }
+}
diff --git a/client/app/visualizations/choropleth/Editor/index.jsx b/client/app/visualizations/choropleth/Editor/index.jsx
new file mode 100644
index 0000000000..ec18d09173
--- /dev/null
+++ b/client/app/visualizations/choropleth/Editor/index.jsx
@@ -0,0 +1,38 @@
+import { merge } from 'lodash';
+import React from 'react';
+import Tabs from 'antd/lib/tabs';
+import { EditorPropTypes } from '@/visualizations';
+
+import GeneralSettings from './GeneralSettings';
+import ColorsSettings from './ColorsSettings';
+import FormatSettings from './FormatSettings';
+import BoundsSettings from './BoundsSettings';
+
+import './editor.less';
+
+export default function Editor(props) {
+ const { options, onOptionsChange } = props;
+
+ const optionsChanged = (newOptions) => {
+ onOptionsChange(merge({}, options, newOptions));
+ };
+
+ return (
+
+ General}>
+
+
+ Colors}>
+
+
+ Format}>
+
+
+ Bounds}>
+
+
+
+ );
+}
+
+Editor.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/choropleth/Editor/utils.js b/client/app/visualizations/choropleth/Editor/utils.js
new file mode 100644
index 0000000000..9acb211ce7
--- /dev/null
+++ b/client/app/visualizations/choropleth/Editor/utils.js
@@ -0,0 +1,38 @@
+/* eslint-disable import/prefer-default-export */
+
+import _ from 'lodash';
+
+export function inferCountryCodeType(mapType, data, countryCodeField) {
+ const regexMap = {
+ countries: {
+ iso_a2: /^[a-z]{2}$/i,
+ iso_a3: /^[a-z]{3}$/i,
+ iso_n3: /^[0-9]{3}$/i,
+ },
+ subdiv_japan: {
+ name: /^[a-z]+$/i,
+ name_local: /^[\u3400-\u9FFF\uF900-\uFAFF]|[\uD840-\uD87F][\uDC00-\uDFFF]+$/i,
+ iso_3166_2: /^JP-[0-9]{2}$/i,
+ },
+ };
+
+ const regex = regexMap[mapType];
+
+ const initState = _.mapValues(regex, () => 0);
+
+ 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;
+ }, initState)
+ .toPairs()
+ .reduce((memo, item) => (item[1] > memo[1] ? item : memo))
+ .value();
+
+ return (result[1] / data.length) >= 0.9 ? result[0] : null;
+}
diff --git a/client/app/visualizations/choropleth/Renderer/Legend.jsx b/client/app/visualizations/choropleth/Renderer/Legend.jsx
new file mode 100644
index 0000000000..5bf76436e9
--- /dev/null
+++ b/client/app/visualizations/choropleth/Renderer/Legend.jsx
@@ -0,0 +1,30 @@
+import { map } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import ColorPicker from '@/components/ColorPicker';
+
+export default function Legend({ items, alignText }) {
+ return (
+
+ {map(items, (item, index) => (
+
+ ))}
+
+ );
+}
+
+Legend.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.shape({
+ color: PropTypes.string.isRequired,
+ text: PropTypes.string.isRequired,
+ })),
+ alignText: PropTypes.oneOf(['left', 'center', 'right']),
+};
+
+Legend.defaultProps = {
+ items: [],
+ alignText: 'left',
+};
diff --git a/client/app/visualizations/choropleth/Renderer/index.jsx b/client/app/visualizations/choropleth/Renderer/index.jsx
new file mode 100644
index 0000000000..d2bdf61f9e
--- /dev/null
+++ b/client/app/visualizations/choropleth/Renderer/index.jsx
@@ -0,0 +1,86 @@
+import { omit, merge } from 'lodash';
+import React, { useState, useEffect } from 'react';
+import { RendererPropTypes } from '@/visualizations';
+import { $http } from '@/services/ng';
+import useMemoWithDeepCompare from '@/lib/hooks/useMemoWithDeepCompare';
+
+import initChoropleth from './initChoropleth';
+import { prepareData } from './utils';
+import './renderer.less';
+
+import countriesDataUrl from '../maps/countries.geo.json';
+import subdivJapanDataUrl from '../maps/japan.prefectures.geo.json';
+
+function getDataUrl(type) {
+ switch (type) {
+ case 'countries': return countriesDataUrl;
+ case 'subdiv_japan': return subdivJapanDataUrl;
+ default: return null;
+ }
+}
+
+export default function Renderer({ data, options, onOptionsChange }) {
+ const [container, setContainer] = useState(null);
+ const [geoJson, setGeoJson] = useState(null);
+
+ const optionsWithoutBounds = useMemoWithDeepCompare(
+ () => omit(options, ['bounds']),
+ [options],
+ );
+
+ const [map, setMap] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ $http.get(getDataUrl(options.mapType)).then((response) => {
+ if (!cancelled) {
+ setGeoJson(response.data);
+ }
+ });
+
+ return () => { cancelled = true; };
+ }, [options.mapType]);
+
+ useEffect(() => {
+ if (container) {
+ const _map = initChoropleth(container);
+ setMap(_map);
+ return () => { _map.destroy(); };
+ }
+ }, [container]);
+
+ useEffect(() => {
+ if (map) {
+ map.updateLayers(
+ geoJson,
+ prepareData(data.rows, optionsWithoutBounds.countryCodeColumn, optionsWithoutBounds.valueColumn),
+ options, // detect changes for all options except bounds, but pass them all!
+ );
+ }
+ }, [map, geoJson, data, optionsWithoutBounds]);
+
+ useEffect(() => {
+ if (map) {
+ map.updateBounds(options.bounds);
+ }
+ }, [map, options.bounds]);
+
+ useEffect(() => {
+ if (map && onOptionsChange) {
+ map.onBoundsChange = (bounds) => {
+ onOptionsChange(merge({}, options, { bounds }));
+ };
+ }
+ }, [map, options, onOptionsChange]);
+
+ return (
+
+ );
+}
+
+Renderer.propTypes = RendererPropTypes;
diff --git a/client/app/visualizations/choropleth/Renderer/initChoropleth.js b/client/app/visualizations/choropleth/Renderer/initChoropleth.js
new file mode 100644
index 0000000000..e029b1eb1d
--- /dev/null
+++ b/client/app/visualizations/choropleth/Renderer/initChoropleth.js
@@ -0,0 +1,185 @@
+import { isFunction, isObject, isArray, map } from 'lodash';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+import 'leaflet-fullscreen';
+import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
+import { formatSimpleTemplate } from '@/lib/value-format';
+import { $sanitize } from '@/services/ng';
+import resizeObserver from '@/services/resizeObserver';
+import {
+ createNumberFormatter,
+ createScale,
+ darkenColor,
+ getColorByValue,
+ getValueForFeature,
+ prepareFeatureProperties,
+} from './utils';
+import Legend from './Legend';
+
+const CustomControl = L.Control.extend({
+ options: {
+ position: 'topright',
+ },
+ onAdd() {
+ const div = document.createElement('div');
+ div.className = 'leaflet-bar leaflet-custom-toolbar';
+ div.style.background = '#fff';
+ div.style.backgroundClip = 'padding-box';
+ return div;
+ },
+ onRemove() {
+ ReactDOM.unmountComponentAtNode(this.getContainer());
+ },
+});
+
+function prepareLayer({ feature, layer, data, options, limits, colors, formatValue }) {
+ const value = getValueForFeature(feature, data, options.countryCodeType);
+ const valueFormatted = formatValue(value);
+ const featureData = prepareFeatureProperties(
+ feature,
+ valueFormatted,
+ data,
+ options.countryCodeType,
+ );
+ const color = getColorByValue(value, limits, colors, options.colors.noValue);
+
+ layer.setStyle({
+ color: options.colors.borders,
+ weight: 1,
+ fillColor: color,
+ fillOpacity: 1,
+ });
+
+ if (options.tooltip.enabled) {
+ layer.bindTooltip($sanitize(formatSimpleTemplate(
+ options.tooltip.template,
+ featureData,
+ )), { sticky: true });
+ }
+
+ if (options.popup.enabled) {
+ layer.bindPopup($sanitize(formatSimpleTemplate(
+ options.popup.template,
+ featureData,
+ )));
+ }
+
+ layer.on('mouseover', () => {
+ layer.setStyle({
+ weight: 2,
+ fillColor: darkenColor(color),
+ });
+ });
+ layer.on('mouseout', () => {
+ layer.setStyle({
+ weight: 1,
+ fillColor: color,
+ });
+ });
+}
+
+export default function initChoropleth(container) {
+ const _map = L.map(container, {
+ center: [0.0, 0.0],
+ zoom: 1,
+ zoomSnap: 0,
+ scrollWheelZoom: false,
+ maxBoundsViscosity: 1,
+ attributionControl: false,
+ fullscreenControl: true,
+ });
+ let _choropleth = null;
+ const _legend = new CustomControl();
+
+ let onBoundsChange = () => {};
+ function handleMapBoundsChange() {
+ const bounds = _map.getBounds();
+ onBoundsChange([
+ [bounds._southWest.lat, bounds._southWest.lng],
+ [bounds._northEast.lat, bounds._northEast.lng],
+ ]);
+ }
+
+ let boundsChangedFromMap = false;
+ const onMapMoveEnd = () => { handleMapBoundsChange(); };
+ _map.on('focus', () => {
+ boundsChangedFromMap = true;
+ _map.on('moveend', onMapMoveEnd);
+ });
+ _map.on('blur', () => {
+ _map.off('moveend', onMapMoveEnd);
+ boundsChangedFromMap = false;
+ });
+
+ function updateLayers(geoJson, data, options) {
+ _map.eachLayer(layer => _map.removeLayer(layer));
+ _map.removeControl(_legend);
+
+ if (!isObject(geoJson) || !isArray(geoJson.features)) {
+ _choropleth = null;
+ _map.setMaxBounds(null);
+ return;
+ }
+
+ const { limits, colors, legend } = createScale(geoJson.features, data, options);
+ const formatValue = createNumberFormatter(options.valueFormat, options.noValuePlaceholder);
+
+ _choropleth = L.geoJSON(geoJson, {
+ onEachFeature(feature, layer) {
+ prepareLayer({ feature, layer, data, options, limits, colors, formatValue });
+ },
+ }).addTo(_map);
+
+ const bounds = _choropleth.getBounds();
+ _map.fitBounds(options.bounds || bounds, { animate: false, duration: 0 });
+ _map.setMaxBounds(bounds);
+
+ // send updated bounds to editor; delay this to avoid infinite update loop
+ setTimeout(() => {
+ handleMapBoundsChange();
+ }, 10);
+
+ // update legend
+ if (options.legend.visible && (legend.length > 0)) {
+ _legend.setPosition(options.legend.position.replace('-', ''));
+ _map.addControl(_legend);
+ ReactDOM.render(
+ ({ ...item, text: formatValue(item.limit) }))}
+ alignText={options.legend.alignText}
+ />,
+ _legend.getContainer(),
+ );
+ }
+ }
+
+ function updateBounds(bounds) {
+ if (!boundsChangedFromMap) {
+ const layerBounds = _choropleth ? _choropleth.getBounds() : _map.getBounds();
+ bounds = bounds ? L.latLngBounds(bounds[0], bounds[1]) : layerBounds;
+ if (bounds.isValid()) {
+ _map.fitBounds(bounds, { animate: false, duration: 0 });
+ }
+ }
+ }
+
+ const unwatchResize = resizeObserver(container, () => { _map.invalidateSize(false); });
+
+ return {
+ get onBoundsChange() {
+ return onBoundsChange;
+ },
+ set onBoundsChange(value) {
+ onBoundsChange = isFunction(value) ? value : () => {};
+ },
+ updateLayers,
+ updateBounds,
+ destroy() {
+ unwatchResize();
+ _map.removeControl(_legend); // _map.remove() does not cleanup controls - bug in Leaflet?
+ _map.remove();
+ },
+ };
+}
diff --git a/client/app/visualizations/choropleth/Renderer/renderer.less b/client/app/visualizations/choropleth/Renderer/renderer.less
new file mode 100644
index 0000000000..46f680aeb5
--- /dev/null
+++ b/client/app/visualizations/choropleth/Renderer/renderer.less
@@ -0,0 +1,9 @@
+.choropleth-visualization-legend {
+ padding: 3px;
+ cursor: default;
+
+ > div {
+ line-height: 1;
+ margin: 5px;
+ }
+}
diff --git a/client/app/visualizations/choropleth/utils.js b/client/app/visualizations/choropleth/Renderer/utils.js
similarity index 59%
rename from client/app/visualizations/choropleth/utils.js
rename to client/app/visualizations/choropleth/Renderer/utils.js
index 4b65171e59..1fc9b6ed55 100644
--- a/client/app/visualizations/choropleth/utils.js
+++ b/client/app/visualizations/choropleth/Renderer/utils.js
@@ -1,13 +1,7 @@
+import { isString, isObject, isFinite, each, map, extend, uniq, filter, first } from 'lodash';
import chroma from 'chroma-js';
-import _ from 'lodash';
import { createNumberFormatter as createFormatter } from '@/lib/value-format';
-export const AdditionalColors = {
- White: '#ffffff',
- Black: '#000000',
- 'Light Gray': '#dddddd',
-};
-
export function darkenColor(color) {
return chroma(color).darken().hex();
}
@@ -15,7 +9,7 @@ export function darkenColor(color) {
export function createNumberFormatter(format, placeholder) {
const formatter = createFormatter(format);
return (value) => {
- if (_.isNumber(value) && isFinite(value)) {
+ if (isFinite(value)) {
return formatter(value);
}
return placeholder;
@@ -28,7 +22,7 @@ export function prepareData(data, countryCodeField, valueField) {
}
const result = {};
- _.each(data, (item) => {
+ each(data, (item) => {
if (item[countryCodeField]) {
const value = parseFloat(item[valueField]);
result[item[countryCodeField]] = {
@@ -43,24 +37,24 @@ export function prepareData(data, countryCodeField, valueField) {
export function prepareFeatureProperties(feature, valueFormatted, data, countryCodeType) {
const result = {};
- _.each(feature.properties, (value, key) => {
+ each(feature.properties, (value, key) => {
result['@@' + key] = value;
});
result['@@value'] = valueFormatted;
const datum = data[feature.properties[countryCodeType]] || {};
- return _.extend(result, datum.item);
+ return extend(result, datum.item);
}
export function getValueForFeature(feature, data, countryCodeType) {
const code = feature.properties[countryCodeType];
- if (_.isString(code) && _.isObject(data[code])) {
+ if (isString(code) && isObject(data[code])) {
return data[code].value;
}
return undefined;
}
export function getColorByValue(value, limits, colors, defaultColor) {
- if (_.isNumber(value) && isFinite(value)) {
+ if (isFinite(value)) {
for (let i = 0; i < limits.length; i += 1) {
if (value <= limits[i]) {
return colors[i];
@@ -72,9 +66,9 @@ export function getColorByValue(value, limits, colors, defaultColor) {
export function createScale(features, data, options) {
// Calculate limits
- const values = _.uniq(_.filter(
- _.map(features, feature => getValueForFeature(feature, data, options.countryCodeType)),
- _.isNumber,
+ const values = uniq(filter(
+ map(features, feature => getValueForFeature(feature, data, options.countryCodeType)),
+ isFinite,
));
if (values.length === 0) {
return {
@@ -90,7 +84,7 @@ export function createScale(features, data, options) {
colors: [options.colors.max],
legend: [{
color: options.colors.max,
- limit: _.first(values),
+ limit: first(values),
}],
};
}
@@ -101,45 +95,10 @@ export function createScale(features, data, options) {
.colors(limits.length);
// Group values for legend
- const legend = _.map(colors, (color, index) => ({
+ const legend = map(colors, (color, index) => ({
color,
limit: limits[index],
})).reverse();
return { limits, colors, legend };
}
-
-export function inferCountryCodeType(mapType, data, countryCodeField) {
- const regexMap = {
- countries: {
- iso_a2: /^[a-z]{2}$/i,
- iso_a3: /^[a-z]{3}$/i,
- iso_n3: /^[0-9]{3}$/i,
- },
- subdiv_japan: {
- name: /^[a-z]+$/i,
- name_local: /^[\u3400-\u9FFF\uF900-\uFAFF]|[\uD840-\uD87F][\uDC00-\uDFFF]+$/i,
- iso_3166_2: /^JP-[0-9]{2}$/i,
- },
- };
-
- const regex = regexMap[mapType];
-
- const initState = _.mapValues(regex, () => 0);
-
- 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;
- }, initState)
- .toPairs()
- .reduce((memo, item) => (item[1] > memo[1] ? item : memo))
- .value();
-
- return (result[1] / data.length) >= 0.9 ? result[0] : null;
-}
diff --git a/client/app/visualizations/choropleth/choropleth-editor.html b/client/app/visualizations/choropleth/choropleth-editor.html
deleted file mode 100644
index 589bf7caf5..0000000000
--- a/client/app/visualizations/choropleth/choropleth-editor.html
+++ /dev/null
@@ -1,260 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Value format
-
-
-
-
-
-
-
-
- Value placeholder
-
-
-
-
-
-
- Show legend
-
-
-
-
- Legend position
-
-
-
-
-
-
-
Show tooltip
-
- Tooltip template
-
-
-
-
Show popup
-
- Popup template
-
-
-
-
-
- Format specs
-
-
-
-
-
-
-
-
-
- Clustering mode
-
-
-
-
-
-
-
-
- Min color
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Max color
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- No value color
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Background color
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Borders color
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/app/visualizations/choropleth/choropleth.html b/client/app/visualizations/choropleth/choropleth.html
deleted file mode 100644
index bd1c8093cd..0000000000
--- a/client/app/visualizations/choropleth/choropleth.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
{{ $ctrl.formatValue(item.limit) }}
-
-
-
diff --git a/client/app/visualizations/choropleth/getOptions.js b/client/app/visualizations/choropleth/getOptions.js
new file mode 100644
index 0000000000..9615647100
--- /dev/null
+++ b/client/app/visualizations/choropleth/getOptions.js
@@ -0,0 +1,37 @@
+import { merge } from 'lodash';
+import ColorPalette from './ColorPalette';
+
+const DEFAULT_OPTIONS = {
+ mapType: 'countries',
+ countryCodeColumn: '',
+ countryCodeType: 'iso_a3',
+ valueColumn: '',
+ clusteringMode: 'e',
+ steps: 5,
+ valueFormat: '0,0.00',
+ noValuePlaceholder: 'N/A',
+ colors: {
+ min: ColorPalette['Light Blue'],
+ max: ColorPalette['Dark Blue'],
+ background: ColorPalette.White,
+ borders: ColorPalette.White,
+ noValue: ColorPalette['Light Gray'],
+ },
+ legend: {
+ visible: true,
+ position: 'bottom-left',
+ alignText: 'right',
+ },
+ tooltip: {
+ enabled: true,
+ template: '{{ @@name }} : {{ @@value }}',
+ },
+ popup: {
+ enabled: true,
+ template: 'Country: {{ @@name_long }} ({{ @@iso_a2 }}) \n \nValue: {{ @@value }} ',
+ },
+};
+
+export default function getOptions(options) {
+ return merge({}, DEFAULT_OPTIONS, options);
+}
diff --git a/client/app/visualizations/choropleth/index.js b/client/app/visualizations/choropleth/index.js
index 16751d4a9e..fbb5651c01 100644
--- a/client/app/visualizations/choropleth/index.js
+++ b/client/app/visualizations/choropleth/index.js
@@ -1,367 +1,20 @@
-import _ from 'lodash';
-import L from 'leaflet';
-import 'leaflet/dist/leaflet.css';
-import { formatSimpleTemplate } from '@/lib/value-format';
-import 'leaflet-fullscreen';
-import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
-import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
-import ColorPalette from '@/visualizations/ColorPalette';
-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';
-import subdivJapanDataUrl from './japan.prefectures.geo.json';
-
-export const ChoroplethPalette = _.extend({}, AdditionalColors, ColorPalette);
-
-const DEFAULT_OPTIONS = {
- mapType: 'countries',
- 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',
- alignText: 'right',
- },
- tooltip: {
- enabled: true,
- template: '{{ @@name }} : {{ @@value }}',
- },
- popup: {
- enabled: true,
- template: 'Country: {{ @@name_long }} ({{ @@iso_a2 }}) \n \nValue: {{ @@value }} ',
- },
-};
-
-const loadCountriesData = _.bind(function loadCountriesData($http, url) {
- if (!this[url]) {
- this[url] = $http.get(url).then(response => response.data);
- }
- return this[url];
-}, {});
-
-const ChoroplethRenderer = {
- template,
- bindings: {
- data: '<',
- options: '<',
- onOptionsChange: '<',
- },
- controller($scope, $element, $sanitize, $http) {
- let countriesData = null;
- let map = null;
- let choropleth = null;
- let mapMoveLock = false;
-
- const onMapMoveStart = () => {
- mapMoveLock = true;
- };
-
- const onMapMoveEnd = () => {
- const bounds = map.getBounds();
- this.options.bounds = [
- [bounds._southWest.lat, bounds._southWest.lng],
- [bounds._northEast.lat, bounds._northEast.lng],
- ];
- if (this.onOptionsChange) {
- this.onOptionsChange(this.options);
- }
- $scope.$applyAsync(() => {
- mapMoveLock = false;
- });
- };
-
- const updateBounds = ({ disableAnimation = false } = {}) => {
- if (mapMoveLock) {
- return;
- }
- if (map && choropleth) {
- const bounds = this.options.bounds || choropleth.getBounds();
- const options = disableAnimation ? {
- animate: false,
- duration: 0,
- } : null;
- map.fitBounds(bounds, options);
- }
- };
-
- const getDataUrl = (type) => {
- switch (type) {
- case 'countries': return countriesDataUrl;
- case 'subdiv_japan': return subdivJapanDataUrl;
- default: return '';
- }
- };
-
- let dataUrl = getDataUrl(this.options.mapType);
-
- const render = () => {
- if (map) {
- map.remove();
- map = null;
- choropleth = null;
- }
- if (!countriesData) {
- return;
- }
-
- this.formatValue = createNumberFormatter(
- this.options.valueFormat,
- this.options.noValuePlaceholder,
- );
-
- const data = prepareData(this.data.rows, this.options.countryCodeColumn, this.options.valueColumn);
-
- const { limits, colors, legend } = createScale(countriesData.features, data, this.options);
-
- // Update data for legend block
- this.legendItems = legend;
-
- choropleth = L.geoJson(countriesData, {
- onEachFeature: (feature, layer) => {
- const value = getValueForFeature(feature, data, this.options.countryCodeType);
- const valueFormatted = this.formatValue(value);
- const featureData = prepareFeatureProperties(
- feature,
- valueFormatted,
- data,
- this.options.countryCodeType,
- );
- const color = getColorByValue(value, limits, colors, this.options.colors.noValue);
-
- layer.setStyle({
- color: this.options.colors.borders,
- weight: 1,
- fillColor: color,
- fillOpacity: 1,
- });
-
- if (this.options.tooltip.enabled) {
- layer.bindTooltip($sanitize(formatSimpleTemplate(
- this.options.tooltip.template,
- featureData,
- )), { sticky: true });
- }
-
- if (this.options.popup.enabled) {
- layer.bindPopup($sanitize(formatSimpleTemplate(
- this.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,
- zoomSnap: 0,
- layers: [choropleth],
- scrollWheelZoom: false,
- maxBounds: choroplethBounds,
- maxBoundsViscosity: 1,
- attributionControl: false,
- fullscreenControl: true,
- });
-
- map.on('focus', () => {
- map.on('movestart', onMapMoveStart);
- map.on('moveend', onMapMoveEnd);
- });
- map.on('blur', () => {
- map.off('movestart', onMapMoveStart);
- map.off('moveend', onMapMoveEnd);
- });
-
- updateBounds({ disableAnimation: true });
- };
-
- const load = () => {
- loadCountriesData($http, dataUrl).then((data) => {
- if (_.isObject(data)) {
- countriesData = data;
- render();
- }
- });
- };
-
- load();
-
-
- $scope.handleResize = _.debounce(() => {
- if (map) {
- map.invalidateSize(false);
- updateBounds({ disableAnimation: true });
- }
- }, 50);
-
- $scope.$watch('$ctrl.data', render);
- $scope.$watch(() => _.omit(this.options, 'bounds', 'mapType'), render, true);
- $scope.$watch('$ctrl.options.bounds', updateBounds, true);
- $scope.$watch('$ctrl.options.mapType', () => {
- dataUrl = getDataUrl(this.options.mapType);
- load();
- }, true);
- },
-};
-
-const ChoroplethEditor = {
- template: editorTemplate,
- bindings: {
- data: '<',
- options: '<',
- onOptionsChange: '<',
- },
- controller($scope) {
- this.currentTab = 'general';
- this.setCurrentTab = (tab) => {
- this.currentTab = tab;
- };
-
- this.colors = ChoroplethPalette;
-
- this.mapTypes = {
- countries: 'Countries',
- subdiv_japan: 'Japan/Prefectures',
- };
-
- this.clusteringModes = {
- q: 'quantile',
- e: 'equidistant',
- k: 'k-means',
- };
-
- this.legendPositions = {
- 'top-left': 'top / left',
- 'top-right': 'top / right',
- 'bottom-left': 'bottom / left',
- 'bottom-right': 'bottom / right',
- };
-
- this.countryCodeTypes = {};
-
- this.templateHintFormatter = propDescription => `
- All query result columns can be referenced using {{ column_name }}
syntax.
- Use special names to access additional properties:
- {{ @@value }}
formatted value;
- ${propDescription}
- This syntax is applicable to tooltip and popup templates.
- `;
-
- const updateCountryCodeType = () => {
- this.options.countryCodeType = inferCountryCodeType(
- this.options.mapType,
- this.data ? this.data.rows : [],
- this.options.countryCodeColumn,
- ) || this.options.countryCodeType;
- };
-
- const populateCountryCodeTypes = () => {
- let propDescription = '';
- switch (this.options.mapType) {
- case 'subdiv_japan':
- propDescription = `
- {{ @@name }}
Prefecture name in English;
- {{ @@name_local }}
Prefecture name in Kanji;
- {{ @@iso_3166_2 }}
five-letter ISO subdivision code (JP-xx);
- `;
- this.countryCodeTypes = {
- name: 'Name',
- name_local: 'Name (local)',
- iso_3166_2: 'ISO-3166-2',
- };
- break;
- case 'countries':
- propDescription = `
- {{ @@name }}
short country name;
- {{ @@name_long }}
full country name;
- {{ @@abbrev }}
abbreviated country name;
- {{ @@iso_a2 }}
two-letter ISO country code;
- {{ @@iso_a3 }}
three-letter ISO country code;
- {{ @@iso_n3 }}
three-digit ISO country code.
- `;
- this.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)',
- };
- break;
- default:
- this.countryCodeTypes = {};
- }
- this.templateHint = this.templateHintFormatter(propDescription);
- };
-
- $scope.$watch('$ctrl.options.mapType', populateCountryCodeTypes);
- $scope.$watch('$ctrl.options.countryCodeColumn', updateCountryCodeType);
- $scope.$watch('$ctrl.data', updateCountryCodeType);
-
- $scope.$watch('$ctrl.options', (options) => {
- this.onOptionsChange(options);
- }, true);
- },
-};
-
-export default function init(ngModule) {
- ngModule.component('choroplethRenderer', ChoroplethRenderer);
- ngModule.component('choroplethEditor', ChoroplethEditor);
-
- ngModule.run(($injector) => {
- registerVisualization({
- type: 'CHOROPLETH',
- name: 'Map (Choropleth)',
- getOptions: options => _.merge({}, DEFAULT_OPTIONS, options),
- Renderer: angular2react('choroplethRenderer', ChoroplethRenderer, $injector),
- Editor: angular2react('choroplethEditor', ChoroplethEditor, $injector),
-
- defaultColumns: 3,
- defaultRows: 8,
- minColumns: 2,
- });
+import getOptions from './getOptions';
+import Renderer from './Renderer';
+import Editor from './Editor';
+
+export default function init() {
+ registerVisualization({
+ type: 'CHOROPLETH',
+ name: 'Map (Choropleth)',
+ getOptions,
+ Renderer,
+ Editor,
+
+ defaultColumns: 3,
+ defaultRows: 8,
+ minColumns: 2,
});
}
diff --git a/client/app/visualizations/choropleth/countries.geo.json b/client/app/visualizations/choropleth/maps/countries.geo.json
similarity index 100%
rename from client/app/visualizations/choropleth/countries.geo.json
rename to client/app/visualizations/choropleth/maps/countries.geo.json
diff --git a/client/app/visualizations/choropleth/japan.prefectures.geo.json b/client/app/visualizations/choropleth/maps/japan.prefectures.geo.json
similarity index 100%
rename from client/app/visualizations/choropleth/japan.prefectures.geo.json
rename to client/app/visualizations/choropleth/maps/japan.prefectures.geo.json
diff --git a/client/app/visualizations/cohort/Cornelius.jsx b/client/app/visualizations/cohort/Cornelius.jsx
new file mode 100644
index 0000000000..bac74fb449
--- /dev/null
+++ b/client/app/visualizations/cohort/Cornelius.jsx
@@ -0,0 +1,209 @@
+/*!
+ * React port of Cornelius library (based on v0.1 released under the MIT license)
+ * Original library: http://restorando.github.io/cornelius
+ */
+
+import { isNil, isFinite, map, extend, min, max } from 'lodash';
+import moment from 'moment';
+import chroma from 'chroma-js';
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import Tooltip from 'antd/lib/tooltip';
+import { createNumberFormatter, formatSimpleTemplate } from '@/lib/value-format';
+
+import './cornelius.less';
+
+const momentInterval = {
+ daily: 'days',
+ weekly: 'weeks',
+ monthly: 'months',
+ yearly: 'years',
+};
+
+const timeLabelFormats = {
+ daily: 'MMMM D, YYYY',
+ weekly: '[Week of] MMM D, YYYY',
+ monthly: 'MMMM YYYY',
+ yearly: 'YYYY',
+};
+
+const defaultOptions = {
+ initialDate: null,
+ timeInterval: 'monthly',
+ drawEmptyCells: true,
+ rawNumberOnHover: true,
+ displayAbsoluteValues: false,
+ initialIntervalNumber: 1,
+ maxColumns: Infinity,
+
+ title: null,
+ timeColumnTitle: 'Time',
+ peopleColumnTitle: 'People',
+ stageColumnTitle: '{{ @ }}',
+ numberFormat: '0,0[.]00',
+ percentFormat: '0.00%',
+ timeLabelFormat: timeLabelFormats.monthly,
+
+ colors: {
+ min: '#ffffff',
+ max: '#041d66',
+ steps: 7,
+ },
+};
+
+function prepareOptions(options) {
+ options = extend({}, defaultOptions, options, {
+ initialDate: moment(options.initialDate),
+ colors: extend({}, defaultOptions.colors, options.colors),
+ });
+
+ return extend(options, {
+ timeLabelFormat: timeLabelFormats[options.timeInterval],
+ formatNumber: createNumberFormatter(options.numberFormat),
+ formatPercent: createNumberFormatter(options.percentFormat),
+ getColorForValue: chroma.scale([options.colors.min, options.colors.max])
+ .mode('hsl')
+ .domain([0, 100])
+ .classes(options.colors.steps),
+ });
+}
+
+function isDarkColor(backgroundColor) {
+ backgroundColor = chroma(backgroundColor);
+ const white = '#ffffff';
+ const black = '#000000';
+ return chroma.contrast(backgroundColor, white) > chroma.contrast(backgroundColor, black);
+}
+
+function formatStageTitle(options, index) {
+ return formatSimpleTemplate(options.stageColumnTitle, { '@': options.initialIntervalNumber - 1 + index });
+}
+
+function formatTimeLabel(options, offset) {
+ const interval = momentInterval[options.timeInterval];
+ return options.initialDate.clone().add(offset, interval).format(options.timeLabelFormat);
+}
+
+function CorneliusHeader({ options, maxRowLength }) { // eslint-disable-line react/prop-types
+ const cells = [];
+ for (let i = 1; i < maxRowLength; i += 1) {
+ cells.push({formatStageTitle(options, i)} );
+ }
+
+ return (
+
+ {options.timeColumnTitle}
+ {options.peopleColumnTitle}
+ {cells}
+
+ );
+}
+
+function CorneliusRow({ options, data, index, maxRowLength }) { // eslint-disable-line react/prop-types
+ const baseValue = data[0] || 0;
+
+ const cells = [];
+ for (let i = 1; i < maxRowLength; i += 1) {
+ const value = data[i];
+ const percentageValue = isFinite(value / baseValue) ? value / baseValue * 100 : null;
+ const cellProps = { key: `col${i}` };
+
+ if (isNil(percentageValue)) {
+ if (options.drawEmptyCells) {
+ cellProps.className = 'cornelius-empty';
+ cellProps.children = '-';
+ }
+ } else {
+ cellProps.className = options.displayAbsoluteValues ? 'cornelius-absolute' : 'cornelius-percentage';
+ cellProps.children = options.displayAbsoluteValues ?
+ options.formatNumber(value) :
+ options.formatPercent(percentageValue);
+
+ const backgroundColor = options.getColorForValue(percentageValue);
+ cellProps.style = { backgroundColor };
+ if (isDarkColor(backgroundColor)) {
+ cellProps.className += ' cornelius-white-text';
+ }
+
+ if (options.rawNumberOnHover && !options.displayAbsoluteValues) {
+ cellProps.children = (
+
+ {cellProps.children}
+
+ );
+ }
+ }
+
+ cells.push( );
+ }
+
+ return (
+
+ {formatTimeLabel(options, index)}
+ {options.formatNumber(baseValue)}
+ {cells}
+
+ );
+}
+
+export default function Cornelius({ data, options }) {
+ options = useMemo(() => prepareOptions(options), [options]);
+
+ const maxRowLength = useMemo(() => min([
+ max(map(data, d => d.length)) || 0,
+ options.maxColumns + 1, // each row includes totals, but `maxColumns` is only for stage columns
+ ]), [data]);
+
+ if (data.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {options.title &&
{options.title}
}
+
+
+
+
+
+
+ {map(data, (row, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+Cornelius.propTypes = {
+ data: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)),
+ options: PropTypes.shape({
+ initialDate: PropTypes.instanceOf(Date).isRequired,
+ timeInterval: PropTypes.oneOf(['daily', 'weekly', 'monthly', 'yearly']),
+ drawEmptyCells: PropTypes.bool,
+ rawNumberOnHover: PropTypes.bool,
+ displayAbsoluteValues: PropTypes.bool,
+ initialIntervalNumber: PropTypes.number,
+ maxColumns: PropTypes.number,
+
+ title: PropTypes.string,
+ timeColumnTitle: PropTypes.string,
+ peopleColumnTitle: PropTypes.string,
+ stageColumnTitle: PropTypes.string,
+ numberFormat: PropTypes.string,
+ percentFormat: PropTypes.string,
+ timeLabelFormat: PropTypes.string,
+
+ colors: PropTypes.shape({
+ min: PropTypes.string,
+ max: PropTypes.string,
+ steps: PropTypes.number,
+ }),
+ }),
+};
+
+Cornelius.defaultProps = {
+ data: [],
+ options: {},
+};
diff --git a/client/app/visualizations/cohort/Editor.jsx b/client/app/visualizations/cohort/Editor.jsx
new file mode 100644
index 0000000000..54adffe044
--- /dev/null
+++ b/client/app/visualizations/cohort/Editor.jsx
@@ -0,0 +1,121 @@
+import { map, merge } from 'lodash';
+import React from 'react';
+import Tabs from 'antd/lib/tabs';
+import Select from 'antd/lib/select';
+import { EditorPropTypes } from '@/visualizations';
+
+const CohortTimeIntervals = {
+ daily: 'Daily',
+ weekly: 'Weekly',
+ monthly: 'Monthly',
+};
+
+const CohortModes = {
+ diagonal: 'Fill gaps with zeros',
+ simple: 'Show data as is',
+};
+
+export default function Editor({ options, data, onOptionsChange }) {
+ const optionsChanged = (newOptions) => {
+ onOptionsChange(merge({}, options, newOptions));
+ };
+
+ return (
+
+ Columns}>
+
+ Date (Bucket)
+ optionsChanged({ dateColumn })}
+ >
+ {map(data.columns, ({ name }) => (
+ {name}
+ ))}
+
+
+
+
+ Stage
+ optionsChanged({ stageColumn })}
+ >
+ {map(data.columns, ({ name }) => (
+ {name}
+ ))}
+
+
+
+
+ Bucket Population Size
+ optionsChanged({ totalColumn })}
+ >
+ {map(data.columns, ({ name }) => (
+ {name}
+ ))}
+
+
+
+
+ Stage Value
+ optionsChanged({ valueColumn })}
+ >
+ {map(data.columns, ({ name }) => (
+ {name}
+ ))}
+
+
+
+ Options}>
+
+ Time Interval
+ optionsChanged({ timeInterval })}
+ >
+ {map(CohortTimeIntervals, (name, value) => (
+ {name}
+ ))}
+
+
+
+
+ Mode
+ optionsChanged({ mode })}
+ >
+ {map(CohortModes, (name, value) => (
+ {name}
+ ))}
+
+
+
+
+ );
+}
+
+Editor.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/cohort/Renderer.jsx b/client/app/visualizations/cohort/Renderer.jsx
new file mode 100644
index 0000000000..752af2e1ac
--- /dev/null
+++ b/client/app/visualizations/cohort/Renderer.jsx
@@ -0,0 +1,33 @@
+import React, { useMemo } from 'react';
+import { RendererPropTypes } from '@/visualizations';
+import ColorPalette from '@/visualizations/ColorPalette';
+
+import prepareData from './prepareData';
+import './renderer.less';
+
+import Cornelius from './Cornelius';
+
+export default function Renderer({ data, options }) {
+ const { data: cohortData, initialDate } = useMemo(() => prepareData(data, options), [data, options]);
+
+ const corneliusOptions = useMemo(() => ({
+ initialDate,
+ timeInterval: options.timeInterval,
+ peopleColumnTitle: 'Users',
+ colors: {
+ max: ColorPalette['Dark Blue'],
+ },
+ }), [options, initialDate]);
+
+ if (cohortData.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
+
+Renderer.propTypes = RendererPropTypes;
diff --git a/client/app/visualizations/cohort/cohort-editor.html b/client/app/visualizations/cohort/cohort-editor.html
deleted file mode 100644
index 9e49b75ec2..0000000000
--- a/client/app/visualizations/cohort/cohort-editor.html
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
- Time Interval
-
- Daily
- Weekly
- Monthly
-
-
-
-
- Mode
-
- Fill gaps with zeros
- Show data as is
-
-
-
-
-
-
- Date (Bucket)
-
-
-
-
- Stage
-
-
-
-
- Bucket Population Size
-
-
-
-
- Stage Value
-
-
-
diff --git a/client/app/visualizations/cohort/cornelius.less b/client/app/visualizations/cohort/cornelius.less
new file mode 100644
index 0000000000..0d4a4bdead
--- /dev/null
+++ b/client/app/visualizations/cohort/cornelius.less
@@ -0,0 +1,53 @@
+.cornelius-container {
+ .cornelius-title {
+ text-align: center;
+ padding-bottom: 10px;
+ font-weight: bold;
+ font-size: 14pt;
+ color: #3A3838;
+ border-collapse: collapse;
+ }
+
+ .cornelius-table {
+ font-size: 9pt;
+ border-spacing: 0;
+ border: 1px solid #E4E4E4;
+ border-collapse: collapse;
+
+ th, td {
+ text-align: center;
+ padding: 10px;
+ border: 1px solid #E4E4E4;
+ color: #3A3838;
+ font-weight: bold;
+ }
+
+ .cornelius-label {
+ text-align: left;
+ }
+
+ .cornelius-empty,
+ .cornelius-percentage,
+ .cornelius-absolute {
+ font-weight: normal;
+ border: none;
+ }
+
+ .cornelius-label,
+ .cornelius-people,
+ .cornelius-stage {
+ font-weight: bold;
+ color: #3A3838;
+ }
+
+ .cornelius-percentage,
+ .cornelius-absolute {
+ color: #000000;
+
+ &.cornelius-white-text {
+ color: #ffffff;
+ text-shadow: 1px 1px 1px #000000;
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/cohort/getOptions.js b/client/app/visualizations/cohort/getOptions.js
new file mode 100644
index 0000000000..319339cb9b
--- /dev/null
+++ b/client/app/visualizations/cohort/getOptions.js
@@ -0,0 +1,12 @@
+const DEFAULT_OPTIONS = {
+ timeInterval: 'daily',
+ mode: 'diagonal',
+ dateColumn: 'date',
+ stageColumn: 'day_number',
+ totalColumn: 'total',
+ valueColumn: 'value',
+};
+
+export default function getOptions(options) {
+ return { ...DEFAULT_OPTIONS, ...options };
+}
diff --git a/client/app/visualizations/cohort/index.js b/client/app/visualizations/cohort/index.js
index 764517c0d1..52a3069376 100644
--- a/client/app/visualizations/cohort/index.js
+++ b/client/app/visualizations/cohort/index.js
@@ -1,223 +1,19 @@
-/* global Cornelius */
-import _ from 'lodash';
-import moment from 'moment';
-import 'cornelius/src/cornelius';
-import 'cornelius/src/cornelius.css';
-import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
-import editorTemplate from './cohort-editor.html';
+import getOptions from './getOptions';
+import Renderer from './Renderer';
+import Editor from './Editor';
-const momentInterval = {
- weekly: 'weeks',
- daily: 'days',
- monthly: 'months',
-};
+export default function init() {
+ registerVisualization({
+ type: 'COHORT',
+ name: 'Cohort',
+ getOptions,
+ Renderer,
+ Editor,
-const DEFAULT_OPTIONS = {
- timeInterval: 'daily',
- mode: 'diagonal',
- dateColumn: 'date',
- stageColumn: 'day_number',
- totalColumn: 'total',
- valueColumn: 'value',
-};
-
-function groupData(sortedData) {
- const result = {};
-
- _.each(sortedData, (item) => {
- const groupKey = item.date + 0;
- result[groupKey] = result[groupKey] || {
- date: moment(item.date),
- total: parseInt(item.total, 10),
- values: {},
- };
- result[groupKey].values[item.stage] = parseInt(item.value, 10);
- });
-
- return _.values(result);
-}
-
-function prepareDiagonalData(sortedData, options) {
- const timeInterval = options.timeInterval;
- const grouped = groupData(sortedData);
- const firstStage = _.min(_.map(sortedData, i => i.stage));
- const stageCount = moment(_.last(grouped).date).diff(_.first(grouped).date, momentInterval[timeInterval]);
- let lastStage = firstStage + stageCount;
-
- let previousDate = null;
-
- const data = [];
- _.each(grouped, (group) => {
- if (previousDate !== null) {
- let diff = Math.abs(previousDate.diff(group.date, momentInterval[timeInterval]));
- while (diff > 1) {
- const row = [0];
- for (let stage = firstStage; stage <= lastStage; stage += 1) {
- row.push(group.values[stage] || 0);
- }
- data.push(row);
- // It should be diagonal, so decrease count of stages for each next row
- lastStage -= 1;
- diff -= 1;
- }
- }
-
- previousDate = group.date;
-
- const row = [group.total];
- for (let stage = firstStage; stage <= lastStage; stage += 1) {
- row.push(group.values[stage] || 0);
- }
- // It should be diagonal, so decrease count of stages for each next row
- lastStage -= 1;
-
- data.push(row);
- });
-
- return data;
-}
-
-function prepareSimpleData(sortedData, options) {
- const timeInterval = options.timeInterval;
- const grouped = groupData(sortedData);
- const stages = _.map(sortedData, i => i.stage);
- const firstStage = _.min(stages);
- const lastStage = _.max(stages);
-
- let previousDate = null;
-
- const data = [];
- _.each(grouped, (group) => {
- if (previousDate !== null) {
- let diff = Math.abs(previousDate.diff(group.date, momentInterval[timeInterval]));
- while (diff > 1) {
- data.push([0]);
- diff -= 1;
- }
- }
-
- previousDate = group.date;
-
- const row = [group.total];
- for (let stage = firstStage; stage <= lastStage; stage += 1) {
- row.push(group.values[stage]);
- }
-
- data.push(row);
- });
-
- return data;
-}
-
-function prepareData(rawData, options) {
- rawData = _.map(rawData, item => ({
- date: item[options.dateColumn],
- stage: parseInt(item[options.stageColumn], 10),
- total: parseFloat(item[options.totalColumn]),
- value: parseFloat(item[options.valueColumn]),
- }));
- const sortedData = _.sortBy(rawData, r => r.date + r.stage);
- const initialDate = moment(sortedData[0].date).toDate();
-
- let data;
- switch (options.mode) {
- case 'simple':
- data = prepareSimpleData(sortedData, options);
- break;
- default:
- data = prepareDiagonalData(sortedData, options);
- break;
- }
-
- return { data, initialDate };
-}
-
-const CohortRenderer = {
- bindings: {
- data: '<',
- options: '<',
- },
- template: '',
- replace: false,
- controller($scope, $element) {
- $scope.options = _.extend({}, DEFAULT_OPTIONS, $scope.options);
-
- const update = () => {
- $element.empty();
-
- if (this.data.rows.length === 0) {
- return;
- }
-
- const options = this.options;
-
- const columnNames = _.map(this.data.columns, i => i.name);
- if (
- !_.includes(columnNames, options.dateColumn) ||
- !_.includes(columnNames, options.stageColumn) ||
- !_.includes(columnNames, options.totalColumn) ||
- !_.includes(columnNames, options.valueColumn)
- ) {
- return;
- }
-
- const { data, initialDate } = prepareData(this.data.rows, options);
-
- Cornelius.draw({
- initialDate,
- container: $element[0],
- cohort: data,
- title: null,
- timeInterval: options.timeInterval,
- labels: {
- time: 'Time',
- people: 'Users',
- weekOf: 'Week of',
- },
- });
- };
-
- $scope.$watch('$ctrl.data', update);
- $scope.$watch('$ctrl.options', update, true);
- },
-};
-
-const CohortEditor = {
- template: editorTemplate,
- bindings: {
- data: '<',
- options: '<',
- onOptionsChange: '<',
- },
- controller($scope) {
- this.currentTab = 'columns';
- this.setCurrentTab = (tab) => {
- this.currentTab = tab;
- };
-
- $scope.$watch('$ctrl.options', (options) => {
- this.onOptionsChange(options);
- }, true);
- },
-};
-
-export default function init(ngModule) {
- ngModule.component('cohortRenderer', CohortRenderer);
- ngModule.component('cohortEditor', CohortEditor);
-
- ngModule.run(($injector) => {
- registerVisualization({
- type: 'COHORT',
- name: 'Cohort',
- getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }),
- Renderer: angular2react('cohortRenderer', CohortRenderer, $injector),
- Editor: angular2react('cohortEditor', CohortEditor, $injector),
-
- autoHeight: true,
- defaultRows: 8,
- });
+ autoHeight: true,
+ defaultRows: 8,
});
}
diff --git a/client/app/visualizations/cohort/prepareData.js b/client/app/visualizations/cohort/prepareData.js
new file mode 100644
index 0000000000..c98c160199
--- /dev/null
+++ b/client/app/visualizations/cohort/prepareData.js
@@ -0,0 +1,135 @@
+import _ from 'lodash';
+import moment from 'moment';
+
+const momentInterval = {
+ weekly: 'weeks',
+ daily: 'days',
+ monthly: 'months',
+};
+
+function groupData(sortedData) {
+ const result = {};
+
+ _.each(sortedData, (item) => {
+ const date = moment(item.date);
+ const groupKey = date.valueOf();
+ result[groupKey] = result[groupKey] || {
+ date,
+ total: parseInt(item.total, 10) || 0,
+ values: {},
+ };
+ result[groupKey].values[item.stage] = parseInt(item.value, 10) || null;
+ });
+
+ return _.values(result);
+}
+
+function prepareDiagonalData(sortedData, options) {
+ const timeInterval = options.timeInterval;
+ const grouped = groupData(sortedData);
+ const firstStage = _.min(_.map(sortedData, i => i.stage));
+ const stageCount = moment(_.last(grouped).date).diff(_.first(grouped).date, momentInterval[timeInterval]);
+ let lastStage = firstStage + stageCount;
+
+ let previousDate = null;
+
+ const data = [];
+ _.each(grouped, (group) => {
+ if (previousDate !== null) {
+ let diff = Math.abs(previousDate.diff(group.date, momentInterval[timeInterval]));
+ while (diff > 1) {
+ const row = [0];
+ for (let stage = firstStage; stage <= lastStage; stage += 1) {
+ row.push(group.values[stage] || 0);
+ }
+ data.push(row);
+ // It should be diagonal, so decrease count of stages for each next row
+ lastStage -= 1;
+ diff -= 1;
+ }
+ }
+
+ previousDate = group.date;
+
+ const row = [group.total];
+ for (let stage = firstStage; stage <= lastStage; stage += 1) {
+ row.push(group.values[stage] || 0);
+ }
+ // It should be diagonal, so decrease count of stages for each next row
+ lastStage -= 1;
+
+ data.push(row);
+ });
+
+ return data;
+}
+
+function prepareSimpleData(sortedData, options) {
+ const timeInterval = options.timeInterval;
+ const grouped = groupData(sortedData);
+ const stages = _.map(sortedData, i => i.stage);
+ const firstStage = _.min(stages);
+ const lastStage = _.max(stages);
+
+ let previousDate = null;
+
+ const data = [];
+ _.each(grouped, (group) => {
+ if (previousDate !== null) {
+ let diff = Math.abs(previousDate.diff(group.date, momentInterval[timeInterval]));
+ while (diff > 1) {
+ data.push([0]);
+ diff -= 1;
+ }
+ }
+
+ previousDate = group.date;
+
+ const row = [group.total];
+ for (let stage = firstStage; stage <= lastStage; stage += 1) {
+ row.push(group.values[stage]);
+ }
+
+ data.push(row);
+ });
+
+ return data;
+}
+
+function isDataValid(rawData, options) {
+ const columnNames = _.map(rawData.columns, c => c.name);
+ return (
+ (rawData.rows.length > 0) &&
+ _.includes(columnNames, options.dateColumn) &&
+ _.includes(columnNames, options.stageColumn) &&
+ _.includes(columnNames, options.totalColumn) &&
+ _.includes(columnNames, options.valueColumn)
+ );
+}
+
+export default function prepareData(rawData, options) {
+ if (!isDataValid(rawData, options)) {
+ return { data: [], initialDate: null };
+ }
+
+ rawData = _.map(rawData.rows, item => ({
+ date: item[options.dateColumn],
+ stage: parseInt(item[options.stageColumn], 10),
+ total: parseFloat(item[options.totalColumn]),
+ value: parseFloat(item[options.valueColumn]),
+ }));
+ const sortedData = _.sortBy(rawData, r => r.date + r.stage);
+ const initialDate = moment(sortedData[0].date).toDate();
+
+ let data;
+ switch (options.mode) {
+ case 'simple':
+ data = prepareSimpleData(sortedData, options);
+ break;
+ default:
+ data = prepareDiagonalData(sortedData, options);
+ break;
+ }
+
+ return { data, initialDate };
+}
diff --git a/client/app/visualizations/cohort/renderer.less b/client/app/visualizations/cohort/renderer.less
new file mode 100644
index 0000000000..7728f6bebb
--- /dev/null
+++ b/client/app/visualizations/cohort/renderer.less
@@ -0,0 +1,18 @@
+@import (reference, less) '~@/assets/less/inc/variables';
+
+.cohort-visualization-container {
+ .cornelius-table {
+ width: 100%;
+
+ &, tr, th, td {
+ border-color: @table-border-color;
+ }
+
+ .cornelius-time,
+ .cornelius-label,
+ .cornelius-stage,
+ .cornelius-people {
+ background-color: fade(@redash-gray, 3%);
+ }
+ }
+}
diff --git a/client/app/visualizations/funnel/Editor/AppearanceSettings.jsx b/client/app/visualizations/funnel/Editor/AppearanceSettings.jsx
new file mode 100644
index 0000000000..c664ffb2d6
--- /dev/null
+++ b/client/app/visualizations/funnel/Editor/AppearanceSettings.jsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import Input from 'antd/lib/input';
+import InputNumber from 'antd/lib/input-number';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import * as Grid from 'antd/lib/grid';
+import { EditorPropTypes } from '@/visualizations';
+
+export default function AppearanceSettings({ options, onOptionsChange }) {
+ const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
+
+ return (
+
+
+
+
+ Number Values Format
+
+ Format
+ specs.
+
+ )}
+ >
+
+
+
+
+
+ onOptionsChangeDebounced({ numberFormat: event.target.value })}
+ />
+
+
+
+
+
+
+ Percent Values Format
+
+ Format
+ specs.
+
+ )}
+ >
+
+
+
+
+
+ onOptionsChangeDebounced({ percentFormat: event.target.value })}
+ />
+
+
+
+
+
+ Items Count Limit
+
+
+ onOptionsChangeDebounced({ itemsLimit })}
+ />
+
+
+
+
+
+ Min Percent Value
+
+
+ onOptionsChangeDebounced({ percentValuesRange: { min } })}
+ />
+
+
+
+
+
+ Max Percent Value
+
+
+ onOptionsChangeDebounced({ percentValuesRange: { max } })}
+ />
+
+
+
+ );
+}
+
+AppearanceSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/funnel/Editor/GeneralSettings.jsx b/client/app/visualizations/funnel/Editor/GeneralSettings.jsx
new file mode 100644
index 0000000000..c889f339ab
--- /dev/null
+++ b/client/app/visualizations/funnel/Editor/GeneralSettings.jsx
@@ -0,0 +1,147 @@
+import { map } from 'lodash';
+import React, { useMemo } from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import Select from 'antd/lib/select';
+import Input from 'antd/lib/input';
+import Checkbox from 'antd/lib/checkbox';
+import * as Grid from 'antd/lib/grid';
+import { EditorPropTypes } from '@/visualizations';
+
+export default function GeneralSettings({ options, data, onOptionsChange }) {
+ const columnNames = useMemo(() => map(data.columns, c => c.name), [data]);
+
+ const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
+
+ return (
+
+
+
+ Step Column
+
+
+ onOptionsChange({ stepCol: { colName: colName || null } })}
+ >
+ {map(columnNames, col => (
+ {col}
+ ))}
+
+
+
+
+
+
+ Step Column Title
+
+
+ onOptionsChangeDebounced({ stepCol: { displayAs: event.target.value } })}
+ />
+
+
+
+
+
+ Value Column
+
+
+ onOptionsChange({ valueCol: { colName: colName || null } })}
+ >
+ {map(columnNames, col => (
+ {col}
+ ))}
+
+
+
+
+
+
+ Value Column Title
+
+
+ onOptionsChangeDebounced({ valueCol: { displayAs: event.target.value } })}
+ />
+
+
+
+
+
+
+ onOptionsChange({ autoSort: !event.target.checked })}
+ />
+ Custom Sorting
+
+
+
+ {!options.autoSort && (
+
+
+
+ Sort Column
+
+
+ onOptionsChange({ sortKeyCol: { colName: colName || null } })}
+ >
+ {map(columnNames, col => (
+ {col}
+ ))}
+
+
+
+
+
+
+ Sort Order
+
+
+ onOptionsChange({ sortKeyCol: { reverse: order === 'desc' } })}
+ >
+ ascending
+ descending
+
+
+
+
+ )}
+
+ );
+}
+
+GeneralSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/funnel/Editor/index.jsx b/client/app/visualizations/funnel/Editor/index.jsx
new file mode 100644
index 0000000000..ed67394341
--- /dev/null
+++ b/client/app/visualizations/funnel/Editor/index.jsx
@@ -0,0 +1,28 @@
+import { merge } from 'lodash';
+import React from 'react';
+import Tabs from 'antd/lib/tabs';
+import { EditorPropTypes } from '@/visualizations';
+
+import GeneralSettings from './GeneralSettings';
+import AppearanceSettings from './AppearanceSettings';
+
+export default function Editor(props) {
+ const { options, onOptionsChange } = props;
+
+ const optionsChanged = (newOptions) => {
+ onOptionsChange(merge({}, options, newOptions));
+ };
+
+ return (
+
+ General}>
+
+
+ Appearance}>
+
+
+
+ );
+}
+
+Editor.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/funnel/Renderer/FunnelBar.jsx b/client/app/visualizations/funnel/Renderer/FunnelBar.jsx
new file mode 100644
index 0000000000..7facac2658
--- /dev/null
+++ b/client/app/visualizations/funnel/Renderer/FunnelBar.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import './funnel-bar.less';
+
+export default function FunnelBar({ color, value, align, className, children }) {
+ return (
+
+ );
+}
+
+FunnelBar.propTypes = {
+ color: PropTypes.string,
+ value: PropTypes.number,
+ align: PropTypes.oneOf(['left', 'center', 'right']),
+ className: PropTypes.string,
+ children: PropTypes.node,
+};
+
+FunnelBar.defaultProps = {
+ color: '#dadada',
+ value: 0.0,
+ align: 'left',
+ className: null,
+ children: null,
+};
diff --git a/client/app/visualizations/funnel/Renderer/funnel-bar.less b/client/app/visualizations/funnel/Renderer/funnel-bar.less
new file mode 100644
index 0000000000..0de7e3d6d1
--- /dev/null
+++ b/client/app/visualizations/funnel/Renderer/funnel-bar.less
@@ -0,0 +1,32 @@
+.funnel-bar {
+ @height: 30px;
+
+ position: relative;
+ height: @height;
+ line-height: @height;
+
+ &-left {
+ text-align: left;
+ }
+ &-center {
+ text-align: center;
+ }
+ &-right {
+ text-align: right;
+ }
+
+ .funnel-bar-value {
+ display: inline-block;
+ vertical-align: top;
+ height: @height;
+ }
+
+ .funnel-bar-label {
+ display: inline-block;
+ text-align: center;
+ vertical-align: middle;
+ position: absolute;
+ left: 0;
+ right: 0;
+ }
+}
diff --git a/client/app/visualizations/funnel/Renderer/index.jsx b/client/app/visualizations/funnel/Renderer/index.jsx
new file mode 100644
index 0000000000..1f46b8f9bc
--- /dev/null
+++ b/client/app/visualizations/funnel/Renderer/index.jsx
@@ -0,0 +1,99 @@
+import { maxBy } from 'lodash';
+import React, { useMemo } from 'react';
+import Table from 'antd/lib/table';
+import Tooltip from 'antd/lib/tooltip';
+import { RendererPropTypes } from '@/visualizations';
+import ColorPalette from '@/visualizations/ColorPalette';
+import { createNumberFormatter } from '@/lib/value-format';
+
+import prepareData from './prepareData';
+import FunnelBar from './FunnelBar';
+import './index.less';
+
+function generateRowKeyPrefix() {
+ return Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString(36) + ':';
+}
+
+export default function Renderer({ data, options }) {
+ const funnelData = useMemo(() => prepareData(data.rows, options), [data, options]);
+ const rowKeyPrefix = useMemo(() => generateRowKeyPrefix(), [funnelData]);
+
+ const formatValue = useMemo(() => createNumberFormatter(options.numberFormat), [options.numberFormat]);
+
+ const formatPercentValue = useMemo(() => {
+ const format = createNumberFormatter(options.percentFormat);
+ return (value) => {
+ if (value < options.percentValuesRange.min) {
+ return `<${format(options.percentValuesRange.min)}`;
+ }
+ if (value > options.percentValuesRange.max) {
+ return `>${format(options.percentValuesRange.max)}`;
+ }
+ return format(value);
+ };
+ }, [options.percentFormat, options.percentValuesRange]);
+
+ const columns = useMemo(() => {
+ if (funnelData.length === 0) {
+ return [];
+ }
+
+ const maxToPrevious = maxBy(funnelData, d => (isFinite(d.pctPrevious) ? d.pctPrevious : 0)).pctPrevious;
+ return [
+ {
+ title: options.stepCol.displayAs,
+ dataIndex: 'step',
+ width: '25%',
+ className: 'text-ellipsis',
+ render: text => {text} ,
+ },
+ {
+ title: options.valueCol.displayAs,
+ dataIndex: 'value',
+ width: '45%',
+ align: 'center',
+ render: (value, item) => (
+ {formatValue(value)}
+ ),
+ },
+ {
+ title: '% Max',
+ dataIndex: 'pctMax',
+ width: '15%',
+ align: 'center',
+ render: value => formatPercentValue(value),
+ },
+ {
+ title: '% Previous',
+ dataIndex: 'pctPrevious',
+ width: '15%',
+ align: 'center',
+ render: value => (
+
+ {formatPercentValue(value)}
+
+ ),
+ },
+ ];
+ }, [
+ options.stepCol.displayAs, options.valueCol.displayAs,
+ funnelData, formatValue, formatPercentValue,
+ ]);
+
+ if (funnelData.length === 0) {
+ return null;
+ }
+
+ return (
+
+
rowKeyPrefix + index}
+ pagination={false}
+ />
+
+ );
+}
+
+Renderer.propTypes = RendererPropTypes;
diff --git a/client/app/visualizations/funnel/funnel.less b/client/app/visualizations/funnel/Renderer/index.less
similarity index 58%
rename from client/app/visualizations/funnel/funnel.less
rename to client/app/visualizations/funnel/Renderer/index.less
index e9ae64a66f..5c86a74b9f 100644
--- a/client/app/visualizations/funnel/funnel.less
+++ b/client/app/visualizations/funnel/Renderer/index.less
@@ -1,10 +1,17 @@
.funnel-visualization-container {
table {
min-width: 450px;
- }
- .table-borderless td, .table-borderless th {
- border: 0;
- vertical-align: middle;
+ table-layout: fixed;
+
+ tbody tr td {
+ border: none;
+
+ &.text-ellipsis {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
}
.step {
max-width: 0;
@@ -28,22 +35,4 @@
.step:hover .step-name {
visibility: visible;
}
-
- div.bar {
- height: 30px;
- }
- div.bar.centered {
- margin: auto;
- }
- .value {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- }
- .container {
- position: relative;
- padding: 0;
- text-align: center;
- }
}
diff --git a/client/app/visualizations/funnel/Renderer/prepareData.js b/client/app/visualizations/funnel/Renderer/prepareData.js
new file mode 100644
index 0000000000..835d11ba2b
--- /dev/null
+++ b/client/app/visualizations/funnel/Renderer/prepareData.js
@@ -0,0 +1,28 @@
+import { map, maxBy, sortBy } from 'lodash';
+
+export default function prepareData(rows, options) {
+ if ((rows.length === 0) || !options.stepCol.colName || !options.valueCol.colName) {
+ return [];
+ }
+
+ rows = [...rows];
+ if (options.sortKeyCol.colName) {
+ rows = sortBy(rows, options.sortKeyCol.colName);
+ }
+ if (options.sortKeyCol.reverse) {
+ rows = rows.reverse();
+ }
+
+ const data = map(rows, row => ({
+ step: row[options.stepCol.colName],
+ value: parseFloat(row[options.valueCol.colName]) || 0.0,
+ }));
+
+ const maxVal = maxBy(data, d => d.value).value;
+ data.forEach((d, i) => {
+ d.pctMax = (d.value / maxVal) * 100.0;
+ d.pctPrevious = (i === 0) || (d.value === data[i - 1].value) ? 100.0 : (d.value / data[i - 1].value) * 100.0;
+ });
+
+ return data.slice(0, options.itemsLimit);
+}
diff --git a/client/app/visualizations/funnel/funnel-editor.html b/client/app/visualizations/funnel/funnel-editor.html
deleted file mode 100644
index 7240fd559e..0000000000
--- a/client/app/visualizations/funnel/funnel-editor.html
+++ /dev/null
@@ -1,46 +0,0 @@
-
diff --git a/client/app/visualizations/funnel/getOptions.js b/client/app/visualizations/funnel/getOptions.js
new file mode 100644
index 0000000000..ac9ba12849
--- /dev/null
+++ b/client/app/visualizations/funnel/getOptions.js
@@ -0,0 +1,42 @@
+import { isFinite, map, merge, includes } from 'lodash';
+
+const DEFAULT_OPTIONS = {
+ stepCol: { colName: null, displayAs: 'Steps' },
+ valueCol: { colName: null, displayAs: 'Value' },
+ autoSort: true,
+ sortKeyCol: { colName: null, reverse: false },
+ itemsLimit: 100,
+ percentValuesRange: { min: 0.01, max: 1000.0 },
+ numberFormat: '0,0[.]00',
+ percentFormat: '0[.]00%',
+};
+
+export default function getOptions(options, { columns }) {
+ options = merge({}, DEFAULT_OPTIONS, options);
+
+ // Validate
+ const availableColumns = map(columns, c => c.name);
+ if (!includes(availableColumns, options.stepCol.colName)) {
+ options.stepCol.colName = null;
+ }
+ if (!includes(availableColumns, options.valueCol.colName)) {
+ options.valueCol.colName = null;
+ }
+ if (!includes(availableColumns, options.sortKeyCol.colName)) {
+ options.sortKeyCol.colName = null;
+ }
+
+ if (!isFinite(options.itemsLimit)) {
+ options.itemsLimit = DEFAULT_OPTIONS.itemsLimit;
+ }
+ if (options.itemsLimit < 2) {
+ options.itemsLimit = 2;
+ }
+
+ if (options.autoSort) {
+ options.sortKeyCol.colName = options.valueCol.colName;
+ options.sortKeyCol.reverse = true;
+ }
+
+ return options;
+}
diff --git a/client/app/visualizations/funnel/index.js b/client/app/visualizations/funnel/index.js
index 7fe167cc41..a229006f05 100644
--- a/client/app/visualizations/funnel/index.js
+++ b/client/app/visualizations/funnel/index.js
@@ -1,236 +1,18 @@
-import { debounce, sortBy, isFinite, every, difference, merge, map } from 'lodash';
-import d3 from 'd3';
-import angular from 'angular';
-import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
-import { normalizeValue } from '@/visualizations/chart/plotly/utils';
-import ColorPalette from '@/visualizations/ColorPalette';
-import editorTemplate from './funnel-editor.html';
-import './funnel.less';
+import getOptions from './getOptions';
+import Renderer from './Renderer';
+import Editor from './Editor';
-const DEFAULT_OPTIONS = {
- stepCol: { colName: '', displayAs: 'Steps' },
- valueCol: { colName: '', displayAs: 'Value' },
- sortKeyCol: { colName: '' },
- autoSort: true,
-};
+export default function init() {
+ registerVisualization({
+ type: 'FUNNEL',
+ name: 'Funnel',
+ getOptions,
+ Renderer,
+ Editor,
-function normalizePercentage(num) {
- if (num < 0.01) {
- return '<0.01%';
- }
- if (num > 1000) {
- return '>1000%';
- }
- return num.toFixed(2) + '%';
-}
-
-function Funnel(scope, element) {
- this.element = element;
- this.watches = [];
- const vis = d3.select(element);
- const options = scope.$ctrl.options;
-
- function drawFunnel(data) {
- const maxToPrevious = d3.max(data, d => d.pctPrevious);
- // Table
- const table = vis.append('table').attr('class', 'table table-condensed table-hover table-borderless');
-
- // Header
- const header = table.append('thead').append('tr');
- header.append('th').text(options.stepCol.displayAs);
- header
- .append('th')
- .attr('class', 'text-center')
- .text(options.valueCol.displayAs);
- header
- .append('th')
- .attr('class', 'text-center')
- .text('% Max');
- header
- .append('th')
- .attr('class', 'text-center')
- .text('% Previous');
-
- // Body
- const trs = table
- .append('tbody')
- .selectAll('tr')
- .data(data)
- .enter()
- .append('tr');
-
- // Steps row
- trs
- .append('td')
- .attr('class', 'col-xs-3 step')
- .attr('title', d => d.step)
- .text(d => d.step);
-
- // Funnel bars
- const valContainers = trs
- .append('td')
- .attr('class', 'col-xs-5')
- .append('div')
- .attr('class', 'container');
- valContainers
- .append('div')
- .attr('class', 'bar centered')
- .style('background-color', ColorPalette.Cyan)
- .style('width', d => d.pctMax + '%');
- valContainers
- .append('div')
- .attr('class', 'value')
- .text(d => d.value.toLocaleString());
-
- // pctMax
- trs
- .append('td')
- .attr('class', 'col-xs-2 text-center')
- .text(d => normalizePercentage(d.pctMax));
-
- // pctPrevious
- const pctContainers = trs
- .append('td')
- .attr('class', 'col-xs-2')
- .append('div')
- .attr('class', 'container');
- pctContainers
- .append('div')
- .attr('class', 'bar')
- .style('background-color', ColorPalette.Gray)
- .style('opacity', '0.2')
- .style('width', d => (d.pctPrevious / maxToPrevious) * 100.0 + '%');
- pctContainers
- .append('div')
- .attr('class', 'value')
- .text(d => normalizePercentage(d.pctPrevious));
- }
-
- function createVisualization(data) {
- drawFunnel(data); // draw funnel
- }
-
- function removeVisualization() {
- vis.selectAll('table').remove();
- }
-
- function prepareData(queryData) {
- if (queryData.length === 0) {
- return [];
- }
- const data = queryData.map(
- row => ({
- step: normalizeValue(row[options.stepCol.colName]),
- value: Number(row[options.valueCol.colName]),
- sortVal: options.autoSort ? '' : row[options.sortKeyCol.colName],
- }),
- [],
- );
- let sortedData;
- if (options.autoSort) {
- sortedData = sortBy(data, 'value').reverse();
- } else {
- sortedData = sortBy(data, 'sortVal');
- }
-
- // Column validity
- if (sortedData[0].value === 0 || !every(sortedData, d => isFinite(d.value))) {
- return;
- }
- const maxVal = d3.max(data, d => d.value);
- sortedData.forEach((d, i) => {
- d.pctMax = (d.value / maxVal) * 100.0;
- d.pctPrevious = i === 0 ? 100.0 : (d.value / sortedData[i - 1].value) * 100.0;
- });
- return sortedData.slice(0, 100);
- }
-
- function invalidColNames() {
- const colNames = map(scope.$ctrl.data.columns, col => col.name);
- const colToCheck = [options.stepCol.colName, options.valueCol.colName];
- if (!options.autoSort) {
- colToCheck.push(options.sortKeyCol.colName);
- }
- return difference(colToCheck, colNames).length > 0;
- }
-
- function refresh() {
- removeVisualization();
- if (invalidColNames()) {
- return;
- }
-
- const queryData = scope.$ctrl.data.rows;
- const data = prepareData(queryData, options);
- if (data.length > 0) {
- createVisualization(data); // draw funnel
- }
- }
-
- refresh();
- this.watches.push(scope.$watch('$ctrl.data', refresh));
- this.watches.push(scope.$watch('$ctrl.options', refresh, true));
-}
-
-Funnel.prototype.remove = function remove() {
- this.watches.forEach((unregister) => {
- unregister();
- });
- angular.element(this.element).empty();
-};
-
-const FunnelRenderer = {
- template: '
',
- bindings: {
- data: '<',
- options: '<',
- },
- controller($scope, $element) {
- const container = $element[0].querySelector('.funnel-visualization-container');
- let funnel = new Funnel($scope, container);
-
- const update = () => {
- funnel.remove();
- funnel = new Funnel($scope, container);
- };
-
- $scope.handleResize = debounce(update, 50);
-
- $scope.$watch('$ctrl.data', update);
- $scope.$watch('$ctrl.options', update, true);
- },
-};
-
-const FunnelEditor = {
- template: editorTemplate,
- bindings: {
- data: '<',
- options: '<',
- onOptionsChange: '<',
- },
- controller($scope) {
- $scope.$watch('$ctrl.options', (options) => {
- this.onOptionsChange(options);
- }, true);
- },
-};
-
-export default function init(ngModule) {
- ngModule.component('funnelRenderer', FunnelRenderer);
- ngModule.component('funnelEditor', FunnelEditor);
-
- ngModule.run(($injector) => {
- registerVisualization({
- type: 'FUNNEL',
- name: 'Funnel',
- getOptions: options => merge({}, DEFAULT_OPTIONS, options),
- Renderer: angular2react('funnelRenderer', FunnelRenderer, $injector),
- Editor: angular2react('funnelEditor', FunnelEditor, $injector),
-
- defaultRows: 10,
- });
+ defaultRows: 10,
});
}
diff --git a/client/app/visualizations/map/Editor/GeneralSettings.jsx b/client/app/visualizations/map/Editor/GeneralSettings.jsx
new file mode 100644
index 0000000000..d24a1641df
--- /dev/null
+++ b/client/app/visualizations/map/Editor/GeneralSettings.jsx
@@ -0,0 +1,71 @@
+import { isNil, map, filter, difference } from 'lodash';
+import React, { useMemo } from 'react';
+import Select from 'antd/lib/select';
+import { EditorPropTypes } from '@/visualizations';
+
+function getColumns(column, unusedColumns) {
+ return filter(
+ [column, ...unusedColumns],
+ v => !isNil(v),
+ );
+}
+
+export default function GeneralSettings({ options, data, onOptionsChange }) {
+ const unusedColumns = useMemo(
+ () => difference(map(data.columns, c => c.name), [options.latColName, options.lonColName, options.classify]),
+ [data, options.latColName, options.lonColName, options.classify],
+ );
+
+ return (
+
+
+ Latitude Column Name
+ onOptionsChange({ latColName })}
+ >
+ {map(getColumns(options.latColName, unusedColumns), col => (
+ {col}
+ ))}
+
+
+
+
+ Longitude Column Name
+ onOptionsChange({ lonColName })}
+ >
+ {map(getColumns(options.lonColName, unusedColumns), col => (
+ {col}
+ ))}
+
+
+
+
+ Group By
+ onOptionsChange({ classify: column || null })}
+ >
+ {map(getColumns(options.classify, unusedColumns), col => (
+ {col}
+ ))}
+
+
+
+ );
+}
+
+GeneralSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/map/Editor/GroupsSettings.jsx b/client/app/visualizations/map/Editor/GroupsSettings.jsx
new file mode 100644
index 0000000000..7e4c03f0fd
--- /dev/null
+++ b/client/app/visualizations/map/Editor/GroupsSettings.jsx
@@ -0,0 +1,67 @@
+import { map } from 'lodash';
+import React, { useMemo, useCallback } from 'react';
+import Table from 'antd/lib/table';
+import ColorPicker from '@/components/ColorPicker';
+import { EditorPropTypes } from '@/visualizations';
+import ColorPalette from '@/visualizations/ColorPalette';
+
+import prepareData from '../prepareData';
+
+export default function GroupsSettings({ options, data, onOptionsChange }) {
+ const groups = useMemo(() => map(
+ prepareData(data, options),
+ ({ name }) => ({ name, color: (options.groups[name] || {}).color || null }),
+ ), [data, options]);
+
+ const colors = useMemo(() => ({
+ Automatic: null,
+ ...ColorPalette,
+ }), []);
+
+ const updateGroupOption = useCallback((name, prop, value) => {
+ onOptionsChange({
+ groups: {
+ [name]: {
+ [prop]: value,
+ },
+ },
+ });
+ }, [onOptionsChange]);
+
+ const columns = [
+ {
+ title: 'Group',
+ dataIndex: 'name',
+ },
+ {
+ title: 'Color',
+ dataIndex: 'color',
+ width: '1%',
+ render: (unused, item) => (
+
+ updateGroupOption(item.name, 'color', value)}
+ />
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
+
+GroupsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/map/Editor/StyleSettings.jsx b/client/app/visualizations/map/Editor/StyleSettings.jsx
new file mode 100644
index 0000000000..97aa6c797a
--- /dev/null
+++ b/client/app/visualizations/map/Editor/StyleSettings.jsx
@@ -0,0 +1,277 @@
+import { isNil, map } from 'lodash';
+import React, { useMemo } from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import Select from 'antd/lib/select';
+import Input from 'antd/lib/input';
+import Checkbox from 'antd/lib/checkbox';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import Typography from 'antd/lib/typography';
+import * as Grid from 'antd/lib/grid';
+import ColorPicker from '@/components/ColorPicker';
+import { EditorPropTypes } from '@/visualizations';
+import ColorPalette from '@/visualizations/ColorPalette';
+
+const mapTiles = [
+ {
+ name: 'OpenStreetMap',
+ url: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'OpenStreetMap BW',
+ url: '//{s}.tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'OpenStreetMap DE',
+ url: '//{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'OpenStreetMap FR',
+ url: '//{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'OpenStreetMap Hot',
+ url: '//{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'Thunderforest',
+ url: '//{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'Thunderforest Spinal',
+ url: '//{s}.tile.thunderforest.com/spinal-map/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'OpenMapSurfer',
+ url: '//korona.geog.uni-heidelberg.de/tiles/roads/x={x}&y={y}&z={z}',
+ },
+ {
+ name: 'Stamen Toner',
+ url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'Stamen Toner Background',
+ url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-background/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'Stamen Toner Lite',
+ url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png',
+ },
+ {
+ name: 'OpenTopoMap',
+ url: '//{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
+ },
+];
+
+const CustomColorPalette = {
+ White: '#ffffff',
+ ...ColorPalette,
+};
+
+function getCustomIconOptionFields(iconShape) {
+ switch (iconShape) {
+ case 'doughnut':
+ return { showIcon: false, showBackgroundColor: true, showBorderColor: true };
+ case 'circle-dot':
+ case 'rectangle-dot':
+ return { showIcon: false, showBackgroundColor: false, showBorderColor: true };
+ default:
+ return { showIcon: true, showBackgroundColor: true, showBorderColor: true };
+ }
+}
+
+export default function StyleSettings({ options, onOptionsChange }) {
+ const [debouncedOnOptionsChange] = useDebouncedCallback(onOptionsChange, 200);
+
+ const { showIcon, showBackgroundColor, showBorderColor } = useMemo(
+ () => getCustomIconOptionFields(options.iconShape),
+ [options.iconShape],
+ );
+
+ const isCustomMarkersStyleAllowed = isNil(options.classify);
+
+ return (
+
+
+ Map Tiles
+ onOptionsChange({ mapTileUrl })}
+ >
+ {map(mapTiles, ({ name, url }) => (
+ {name}
+ ))}
+
+
+
+ Markers
+
+
+
+ onOptionsChange({ clusterMarkers: event.target.checked })}
+ />
+ Cluster Markers
+
+
+
+
+
+ onOptionsChange({ customizeMarkers: event.target.checked })}
+ />
+ Override default style
+ {!isCustomMarkersStyleAllowed && (
+
+ Custom marker styles are not available
+ when Group By column selected.
+
+ )}
+ >
+
+
+ )}
+
+
+
+ {isCustomMarkersStyleAllowed && options.customizeMarkers && (
+
+
+
+ Shape
+
+
+ onOptionsChange({ iconShape })}
+ >
+ Marker + Icon
+ Circle
+ Circle Dot
+ Circle + Icon
+ Square Dot
+ Square + Icon
+
+
+
+
+ {showIcon && (
+
+
+
+ Icon
+
+
+
+ Examples: check
, times-circle
, flag
+
+ Leave blank to remove.
+
+ )}
+ >
+
+
+
+
+
+ debouncedOnOptionsChange({ iconFont: event.target.value })}
+ />
+
+
+ )}
+
+ {showIcon && (
+
+
+ Icon Color
+
+
+ onOptionsChange({ foregroundColor })}
+ />
+
+
+
+ )}
+
+ {showBackgroundColor && (
+
+
+ Background Color
+
+
+ onOptionsChange({ backgroundColor })}
+ />
+
+
+
+ )}
+
+ {showBorderColor && (
+
+
+ Border Color
+
+
+ onOptionsChange({ borderColor })}
+ />
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+StyleSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/map/Editor/index.jsx b/client/app/visualizations/map/Editor/index.jsx
new file mode 100644
index 0000000000..d1f97f8696
--- /dev/null
+++ b/client/app/visualizations/map/Editor/index.jsx
@@ -0,0 +1,32 @@
+import { merge } from 'lodash';
+import React from 'react';
+import Tabs from 'antd/lib/tabs';
+import { EditorPropTypes } from '@/visualizations';
+
+import GeneralSettings from './GeneralSettings';
+import GroupsSettings from './GroupsSettings';
+import StyleSettings from './StyleSettings';
+
+export default function Editor(props) {
+ const { options, onOptionsChange } = props;
+
+ const optionsChanged = (newOptions) => {
+ onOptionsChange(merge({}, options, newOptions));
+ };
+
+ return (
+
+ General}>
+
+
+ Groups}>
+
+
+ Style}>
+
+
+
+ );
+}
+
+Editor.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/map/Renderer.jsx b/client/app/visualizations/map/Renderer.jsx
new file mode 100644
index 0000000000..1d60b7b514
--- /dev/null
+++ b/client/app/visualizations/map/Renderer.jsx
@@ -0,0 +1,63 @@
+import { isEqual, omit, merge } from 'lodash';
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { RendererPropTypes } from '@/visualizations';
+
+import prepareData from './prepareData';
+import initMap from './initMap';
+
+function useMemoWithDeepCompare(create, inputs) {
+ const valueRef = useRef();
+ const value = useMemo(create, inputs);
+ if (!isEqual(value, valueRef.current)) {
+ valueRef.current = value;
+ }
+ return valueRef.current;
+}
+
+export default function Renderer({ data, options, onOptionsChange }) {
+ const [container, setContainer] = useState(null);
+
+ const optionsWithoutBounds = useMemoWithDeepCompare(
+ () => omit(options, ['bounds']),
+ [options],
+ );
+
+ const groups = useMemo(
+ () => prepareData(data, optionsWithoutBounds),
+ [data, optionsWithoutBounds],
+ );
+
+ const [map, setMap] = useState(null);
+
+ useEffect(() => {
+ if (container) {
+ const _map = initMap(container);
+ setMap(_map);
+ return () => { _map.destroy(); };
+ }
+ }, [container]);
+
+ useEffect(() => {
+ if (map) {
+ map.updateLayers(groups, optionsWithoutBounds);
+ }
+ }, [map, groups, optionsWithoutBounds]);
+
+ useEffect(() => {
+ if (map) {
+ map.updateBounds(options.bounds);
+ }
+ }, [map, options.bounds]);
+
+ useEffect(() => {
+ if (map && onOptionsChange) {
+ map.onBoundsChange = (bounds) => {
+ onOptionsChange(merge({}, options, { bounds }));
+ };
+ }
+ }, [map, options, onOptionsChange]);
+
+ return (
);
+}
+
+Renderer.propTypes = RendererPropTypes;
diff --git a/client/app/visualizations/map/getOptions.js b/client/app/visualizations/map/getOptions.js
new file mode 100644
index 0000000000..a80e761a0e
--- /dev/null
+++ b/client/app/visualizations/map/getOptions.js
@@ -0,0 +1,29 @@
+import { merge } from 'lodash';
+
+const DEFAULT_OPTIONS = {
+ latColName: 'lat',
+ lonColName: 'lon',
+ classify: null,
+ groups: {},
+ mapTileUrl: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ clusterMarkers: true,
+ customizeMarkers: false,
+ iconShape: 'marker',
+ iconFont: 'circle',
+ foregroundColor: '#ffffff',
+ backgroundColor: '#356AFF',
+ borderColor: '#356AFF',
+ bounds: null,
+};
+
+export default function getOptions(options) {
+ options = merge({}, DEFAULT_OPTIONS, options);
+ options.mapTileUrl = options.mapTileUrl || DEFAULT_OPTIONS.mapTileUrl;
+
+ // Backward compatibility
+ if (options.classify === 'none') {
+ options.classify = null;
+ }
+
+ return options;
+}
diff --git a/client/app/visualizations/map/index.js b/client/app/visualizations/map/index.js
index aa0ab4684d..79a2193ffc 100644
--- a/client/app/visualizations/map/index.js
+++ b/client/app/visualizations/map/index.js
@@ -1,393 +1,20 @@
-import _ from 'lodash';
-import d3 from 'd3';
-import L from 'leaflet';
-import 'leaflet.markercluster';
-import 'leaflet/dist/leaflet.css';
-import 'leaflet.markercluster/dist/MarkerCluster.css';
-import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
-import 'beautifymarker';
-import 'beautifymarker/leaflet-beautify-marker-icon.css';
-import markerIcon from 'leaflet/dist/images/marker-icon.png';
-import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.png';
-import markerShadow from 'leaflet/dist/images/marker-shadow.png';
-import 'leaflet-fullscreen';
-import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
-import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
-import ColorPalette from '@/visualizations/ColorPalette';
-import template from './map.html';
-import editorTemplate from './map-editor.html';
-
-// This is a workaround for an issue with giving Leaflet load the icon on its own.
-L.Icon.Default.mergeOptions({
- iconUrl: markerIcon,
- iconRetinaUrl: markerIconRetina,
- shadowUrl: markerShadow,
-});
-
-delete L.Icon.Default.prototype._getIconUrl;
-
-const MAP_TILES = [
- {
- name: 'OpenStreetMap',
- url: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
- },
- {
- name: 'OpenStreetMap BW',
- url: '//{s}.tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
- },
- {
- name: 'OpenStreetMap DE',
- url: '//{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png',
- },
- {
- name: 'OpenStreetMap FR',
- url: '//{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png',
- },
- {
- name: 'OpenStreetMap Hot',
- url: '//{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
- },
- {
- name: 'Thunderforest',
- url: '//{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png',
- },
- {
- name: 'Thunderforest Spinal',
- url: '//{s}.tile.thunderforest.com/spinal-map/{z}/{x}/{y}.png',
- },
- {
- name: 'OpenMapSurfer',
- url: '//korona.geog.uni-heidelberg.de/tiles/roads/x={x}&y={y}&z={z}',
- },
- {
- name: 'Stamen Toner',
- url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.png',
- },
- {
- name: 'Stamen Toner Background',
- url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-background/{z}/{x}/{y}.png',
- },
- {
- name: 'Stamen Toner Lite',
- url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png',
- },
- {
- name: 'OpenTopoMap',
- url: '//{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
- },
-];
-
-const iconAnchors = {
- marker: [14, 32],
- circle: [10, 10],
- rectangle: [11, 11],
- 'circle-dot': [1, 2],
- 'rectangle-dot': [1, 2],
- doughnut: [8, 8],
-};
-
-const popupAnchors = {
- rectangle: [0, -3],
- circle: [1, -3],
-};
-
-const DEFAULT_OPTIONS = {
- classify: 'none',
- clusterMarkers: true,
- iconShape: 'marker',
- iconFont: 'circle',
- foregroundColor: '#ffffff',
- backgroundColor: '#356AFF',
- borderColor: '#356AFF',
-};
-
-function heatpoint(lat, lon, color) {
- const style = {
- fillColor: color,
- fillOpacity: 0.9,
- stroke: false,
- };
-
- return L.circleMarker([lat, lon], style);
-}
-
-const createMarker = (lat, lon) => L.marker([lat, lon]);
-const createIconMarker = (lat, lon, icn) => L.marker([lat, lon], { icon: icn });
-
-function createDescription(latCol, lonCol, row) {
- const lat = row[latCol];
- const lon = row[lonCol];
-
- let description = '