From 546101f5c9b7a71a2beb1d389406a8e6a6eaff11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Abarz=C3=BAa?= Date: Mon, 3 Dec 2018 04:49:14 -0300 Subject: [PATCH] implements precalculation for charts during dataset loading closes #309 --- .../src/components/ChartArea/ChartCard.jsx | 19 +- .../src/components/ChartArea/index.jsx | 81 ++- .../src/components/ChartArea/style.css | 12 +- .../src/components/Sidebar/Ranking.jsx | 37 +- .../vizbuilder/src/helpers/chartCriteria.js | 151 ++++++ .../vizbuilder/src/helpers/chartHelpers.js | 139 +++++ .../vizbuilder/src/helpers/chartconfig.js | 504 +++--------------- packages/vizbuilder/src/helpers/fetch.js | 2 +- packages/vizbuilder/src/helpers/loadstate.js | 68 ++- packages/vizbuilder/src/helpers/query.js | 10 +- packages/vizbuilder/src/helpers/sorting.js | 56 +- packages/vizbuilder/src/helpers/validation.js | 6 +- packages/vizbuilder/src/index.js | 38 +- packages/vizbuilder/src/state.js | 5 +- 14 files changed, 548 insertions(+), 580 deletions(-) create mode 100644 packages/vizbuilder/src/helpers/chartCriteria.js create mode 100644 packages/vizbuilder/src/helpers/chartHelpers.js diff --git a/packages/vizbuilder/src/components/ChartArea/ChartCard.jsx b/packages/vizbuilder/src/components/ChartArea/ChartCard.jsx index 6e041581a..da77f7710 100644 --- a/packages/vizbuilder/src/components/ChartArea/ChartCard.jsx +++ b/packages/vizbuilder/src/components/ChartArea/ChartCard.jsx @@ -18,15 +18,16 @@ class ChartCard extends React.PureComponent {
{this.props.children} - -
-
+ {!this.props.hideFooter && ( +
+
+ )}
); diff --git a/packages/vizbuilder/src/components/ChartArea/index.jsx b/packages/vizbuilder/src/components/ChartArea/index.jsx index a9db3d485..6b7399f59 100644 --- a/packages/vizbuilder/src/components/ChartArea/index.jsx +++ b/packages/vizbuilder/src/components/ChartArea/index.jsx @@ -8,7 +8,7 @@ import ChartCard from "./ChartCard"; import "./style.css"; -const EMPTY_DATASETS = ( +const NO_CHARTS = (
@@ -106,46 +106,29 @@ class ChartArea extends React.Component { } render() { - const {generalConfig} = this.context; - const { - activeChart, - datasets, - members, - queries, - selectedTime, - toolbar - } = this.props; - const {heightArea, heightToolbar} = this.state; + const {activeChart, charts, selectedTime, toolbar} = this.props; + const state = this.state; - if (!datasets.length) { - return EMPTY_DATASETS; + const n = charts.length; + if (n === 0) { + return NO_CHARTS; } - const chartElements = []; - - let n = queries.length; - while (n--) { - const configs = createChartConfig( - queries[n], - datasets[n], - members[n], - {activeChart, selectedTime, onTimeChange: this.handleTimeChange}, - generalConfig - ); - chartElements.unshift.apply(chartElements, configs); - } + const isUniqueChart = n === 1; + const isSingleChart = activeChart || n === 1; - if (!chartElements.length) { - return EMPTY_DATASETS; - } + const chartsToRender = activeChart + ? charts.filter(chart => chart.key === activeChart) + : charts; - const uniqueChart = !activeChart && chartElements.length === 1; - const singleChart = activeChart || chartElements.length === 1; - const chartHeight = uniqueChart - ? heightArea - heightToolbar - : singleChart - ? heightArea - heightToolbar - 50 - : 400; + const uiparams = { + activeChart, + isSingle: isSingleChart, + isUnique: isUniqueChart || chartsToRender.length === 1, + onTimeChange: this.handleTimeChange, + selectedTime, + uiheight: state.heightArea - state.heightToolbar, + }; return (
- {chartElements.map(chartConfig => { - const {config, key} = chartConfig; - config.height = Math.max(400, chartHeight); + {chartsToRender.map(chart => { + const {key} = chart; + const config = createChartConfig(chart, uiparams); return ( - + ); })} @@ -186,23 +171,21 @@ class ChartArea extends React.Component { } ChartArea.contextTypes = { - generalConfig: PropTypes.object, stateUpdate: PropTypes.func }; ChartArea.propTypes = { activeChart: PropTypes.string, - datasets: PropTypes.arrayOf(PropTypes.array), + datagroups: PropTypes.arrayOf(PropTypes.object), lastUpdate: PropTypes.number, - members: PropTypes.arrayOf(PropTypes.objectOf(PropTypes.array)), - queries: PropTypes.arrayOf(PropTypes.object) + selectedTime: PropTypes.any, + toolbar: PropTypes.any }; ChartArea.defaultProps = { activeChart: null, - datasets: [], - members: [], - queries: [] + charts: [], + datagroups: [] }; export default ChartArea; diff --git a/packages/vizbuilder/src/components/ChartArea/style.css b/packages/vizbuilder/src/components/ChartArea/style.css index ff53e857d..1b23a1003 100644 --- a/packages/vizbuilder/src/components/ChartArea/style.css +++ b/packages/vizbuilder/src/components/ChartArea/style.css @@ -24,14 +24,6 @@ justify-content: flex-start; align-items: flex-start; } - - &.single .chart-card { - height: 100%; - } - - &.unique .chart-card footer { - display: none; - } } & .chart-card { @@ -47,6 +39,10 @@ height: 100%; } + &:only-child > .wrapper { + margin: 0; + } + & .viz { flex: 1 0; diff --git a/packages/vizbuilder/src/components/Sidebar/Ranking.jsx b/packages/vizbuilder/src/components/Sidebar/Ranking.jsx index 621baa031..eba2c217d 100644 --- a/packages/vizbuilder/src/components/Sidebar/Ranking.jsx +++ b/packages/vizbuilder/src/components/Sidebar/Ranking.jsx @@ -3,23 +3,15 @@ import PropTypes from "prop-types"; class Ranking extends React.PureComponent { render() { - const {formatting} = this.context.generalConfig; - const props = this.props; - const dataset = props.datasets[0]; - const members = props.members[0]; - const query = props.queries[0]; + const {datagroup, selectedTime} = this.props; + const {dataset, formatter} = datagroup || {}; - if (!dataset || !members || !query) return null; + if (!dataset || !dataset.length) return null; - const measureName = query.measure.name; - const levelName = query.level.name; - const timeLevelName = query.timeLevel.name; + const {measureName, levelName, timeLevelName, xlevelName} = datagroup.names; - const measureFormatter = - formatting[query.measure.annotations.units_of_measurement] || - formatting["default"]; - const getLevelNames = query.xlevel - ? a => `${a[levelName]} - ${a[query.xlevel.name]}` + const getLevelNames = xlevelName + ? a => `${a[levelName]} - ${a[xlevelName]}` : a => a[levelName]; const renderListItem = datapoint => ( @@ -27,29 +19,30 @@ class Ranking extends React.PureComponent {
{getLevelNames(datapoint)} - {measureFormatter(datapoint[measureName])} + {formatter(datapoint[measureName])}
); - const selectedTime = props.selectedTime; - const maxTimeDataset = dataset.filter( + const selectedTimeDataset = dataset.filter( d => d[timeLevelName] == selectedTime ); - if (maxTimeDataset.length < 20) { + if (selectedTimeDataset.length < 20) { return (

{`Ranking (${selectedTime})`}

-
    {maxTimeDataset.map(renderListItem)}
+
    + {selectedTimeDataset.map(renderListItem)} +
); } - const upperDataset = maxTimeDataset.slice(0, 10); - const lowerIndex = maxTimeDataset.length - 10; - const lowerDataset = maxTimeDataset.slice(lowerIndex); + const upperDataset = selectedTimeDataset.slice(0, 10); + const lowerIndex = selectedTimeDataset.length - 10; + const lowerDataset = selectedTimeDataset.slice(lowerIndex); return (
diff --git a/packages/vizbuilder/src/helpers/chartCriteria.js b/packages/vizbuilder/src/helpers/chartCriteria.js new file mode 100644 index 000000000..6d30fea46 --- /dev/null +++ b/packages/vizbuilder/src/helpers/chartCriteria.js @@ -0,0 +1,151 @@ +//@ts-check +/** + * The main function here is chartCriteria + * This function is in charge of deciding which charts will be rendered for each + * dataset retrieved from the server. + */ + +import {getTopTenByYear} from "./sorting"; + +export default function chartCriteria(query, results, params) { + /** @type {Datagroup[]} */ + const datagroups = []; + + for (let i = 0; i < results.length; i++) { + /** @type {Datagroup} */ + const datagroup = results[i]; + datagroups.push(datagroup); + + let {members, query} = datagroup; + + const measureName = query.measure.name; + const levelName = query.level.name; + const xlevelName = query.xlevel && query.xlevel.name; + const timeLevelName = query.timeLevel && query.timeLevel.name; + + const levelMembers = members[levelName] || []; + const timeLevelMembers = members[timeLevelName] || []; + + const measureAnn = query.measure.annotations; + + const measureFormatter = + params.formatting[measureAnn.units_of_measurement] || + params.formatting["default"]; + const topojsonConfig = params.topojson[levelName]; + + const aggregatorType = + measureAnn.pre_aggregation_method || + measureAnn.aggregation_method || + query.measure.aggregatorType || + "UNKNOWN"; + + const availableKeys = Object.keys(members); + const availableCharts = new Set(params.visualizations); + + const hasTimeLvl = timeLevelName && timeLevelMembers.length > 1; + const hasGeoLvl = [query.level, query.xlevel].some( + lvl => lvl && lvl.hierarchy.dimension.annotations.dim_type === "GEOGRAPHY" + ); + + // Hide barcharts with more than 20 members + if (levelMembers.length > 20) { + availableCharts.delete("barchart"); + } + + // Hide time scale charts if dataset has not time or only one time + if (!hasTimeLvl) { + availableCharts.delete("barchartyear"); + availableCharts.delete("lineplot"); + availableCharts.delete("stacked"); + } + + // Hide geomaps if there no geo levels, or if there's less than 3 members + if ( + !hasGeoLvl || + !topojsonConfig || + levelMembers.length < 3 || + xlevelName + ) { + availableCharts.delete("geomap"); + } + + // Hide invalid charts according to the type of aggregation in the data + if (aggregatorType === "AVERAGE") { + availableCharts.delete("donut"); + availableCharts.delete("histogram"); + availableCharts.delete("stacked"); + availableCharts.delete("treemap"); + } else if (aggregatorType === "MEDIAN") { + availableCharts.delete("stacked"); + } + + // Hide barchartyear and treemap if aggregation is not SUM or UNKNOWN + if (aggregatorType !== "SUM" && aggregatorType !== "UNKNOWN") { + availableCharts.delete("barchartyear"); + availableCharts.delete("treemap"); + } + + // Hide charts that would show a single shape only + // (that is, if any drilldown, besides Year, only has 1 member) + if (availableKeys.some(d => d !== "Year" && members[d].length === 1)) { + availableCharts.delete("barchart"); + availableCharts.delete("stacked"); + availableCharts.delete("treemap"); + } + + datagroup.aggType = aggregatorType; + datagroup.formatter = measureFormatter; + datagroup.key = query.key; + datagroup.names = { + collectionName: query.collection && query.collection.name, + lciName: query.lci && query.lci.name, + levelName, + measureName, + moeName: query.moe && query.moe.name, + sourceName: query.source && query.source.name, + timeLevelName, + uciName: query.uci && query.uci.name, + xlevelName + }; + datagroup.topojson = topojsonConfig; + + /** + * If there's more than 60 lines in a lineplot, only show top ten each year + * due to the implementation, this remove lineplot from this datagroup + * and creates a new datagroup, lineplot-only, for the new trimmed dataset + * @see Issue#296 on {@link https://github.com/Datawheel/canon/issues/296 | GitHub} + */ + let totalMembers = members[levelName].length; + if (xlevelName) { + totalMembers *= members[xlevelName].length; + } + if (availableCharts.has("lineplot") && totalMembers > 60) { + availableCharts.delete("lineplot"); + + const {newDataset, newMembers} = getTopTenByYear(datagroup); + datagroups.push({ + ...datagroup, + charts: ["lineplot"], + dataset: newDataset, + members: newMembers + }); + } + + datagroup.charts = Array.from(availableCharts); + } + + return datagroups; +} + +/** + * @typedef Datagroup + * @prop {string} aggType + * @prop {string[]} charts + * @prop {any[]} dataset + * @prop {(d: number) => string} formatter + * @prop {string} key + * @prop {Object} members + * @prop {Object} names + * @prop {any} query + * @prop {any} topojson + */ diff --git a/packages/vizbuilder/src/helpers/chartHelpers.js b/packages/vizbuilder/src/helpers/chartHelpers.js new file mode 100644 index 000000000..081d6fa02 --- /dev/null +++ b/packages/vizbuilder/src/helpers/chartHelpers.js @@ -0,0 +1,139 @@ +import {assign} from "d3plus-common"; +import { + BarChart, + Donut, + Geomap, + LinePlot, + Pie, + StackedArea, + Treemap +} from "d3plus-react"; + +import {getPermutations} from "./sorting"; +import {areMetaMeasuresZero} from "./validation"; + +export const chartComponents = { + barchart: BarChart, + barchartyear: BarChart, + donut: Donut, + geomap: Geomap, + lineplot: LinePlot, + pie: Pie, + stacked: StackedArea, + treemap: Treemap +}; + +export const ALL_YEARS = "All years"; + +export function datagroupToCharts(datagroup, generalConfig) { + const {measureName, levelName} = datagroup.names; + + const baseConfig = buildBaseConfig(datagroup, generalConfig); + const topoConfig = generalConfig.topojson[levelName]; + const userConfig = assign( + {}, + generalConfig.defaultConfig, + generalConfig.measureConfig[measureName] || {} + ); + + const charts = datagroup.charts.reduce((sum, chartType) => { + const setups = calcChartSetups(chartType, datagroup.query).map(setup => { + const setupKeys = setup.map(lvl => lvl.annotations._key).join("_"); + return { + ...datagroup, + baseConfig, + chartType, + component: chartComponents[chartType], + key: `${chartType}-${setupKeys}`, + setup, + topoConfig, + userConfig + }; + }); + return sum.concat(setups); + }, []); + + return charts; +} + +export function buildBaseConfig(datagroup, params) { + const {aggType, formatter, names} = datagroup; + const {measureName, timeLevelName} = names; + const getMeasureValue = d => d[measureName]; + + const config = { + legend: false, + duration: 0, + + total: (aggType === "SUM" || aggType === "UNKNOWN") && getMeasureValue, + totalFormat: formatter, + + xConfig: {title: null}, + yConfig: { + title: measureName, + tickFormat: formatter + }, + + sum: getMeasureValue, + value: getMeasureValue + }; + + // config.title = composeChartTitle(flags); + config.tooltipConfig = tooltipGenerator(datagroup); + + if (timeLevelName && datagroup.members[timeLevelName].length > 1) { + config.time = timeLevelName; + } + + return config; +} + +export function calcChartSetups(type, query) { + switch (type) { + case "treemap": { + return getPermutations(query.levels); + } + + default: { + return [query.levels]; + } + } +} + +export function tooltipGenerator(datagroup) { + const {formatter, names} = datagroup; + const {levelName, measureName} = names; + const shouldShow = areMetaMeasuresZero(names, datagroup.dataset); + + const tbody = Object.keys(datagroup.members) + .filter(lvl => lvl !== levelName) + .map(lvl => [lvl, d => d[lvl]]); + tbody.push([measureName, d => formatter(d[measureName])]); + + if (shouldShow.lci && shouldShow.uci) { + const {lciName, uciName} = names; + tbody.push([ + "Confidence Interval", + d => + `${formatter(d[lciName] * 1 || 0)} - ${formatter(d[uciName] * 1 || 0)}` + ]); + } else if (shouldShow.moe) { + const {moeName} = names; + tbody.push(["Margin of Error", d => `± ${formatter(d[moeName] * 1 || 0)}`]); + } + + if (shouldShow.src) { + const {sourceName} = names; + tbody.push(["Source", d => `${d[sourceName]}`]); + } + + if (shouldShow.clt) { + const {collectionName} = names; + tbody.push(["Collection", d => `${d[collectionName]}`]); + } + + return { + title: d => [].concat(d[levelName]).join(", "), + tbody + }; +} diff --git a/packages/vizbuilder/src/helpers/chartconfig.js b/packages/vizbuilder/src/helpers/chartconfig.js index 13d5c55e7..50960eed8 100644 --- a/packages/vizbuilder/src/helpers/chartconfig.js +++ b/packages/vizbuilder/src/helpers/chartconfig.js @@ -1,89 +1,18 @@ import {assign} from "d3plus-common"; -import { - BarChart, - Donut, - Geomap, - LinePlot, - Pie, - StackedArea, - Treemap -} from "d3plus-react"; - -import {composeChartTitle} from "./formatting"; + +// import {composeChartTitle} from "./formatting"; import {relativeStdDev} from "./math"; import {sortByCustomKey} from "./sorting"; -import {areMetaMeasuresZero} from "./validation"; - -export const charts = { - barchart: BarChart, - barchartyear: BarChart, - donut: Donut, - geomap: Geomap, - lineplot: LinePlot, - pie: Pie, - stacked: StackedArea, - treemap: Treemap -}; - -export const ALL_YEARS = "All years"; - -export const tooltipGenerator = (query, flags) => { - const measureFormatter = flags.measureFormatter; - const {levelName, measureName} = query; - const shouldShow = areMetaMeasuresZero(query, flags.dataset); - - const tbody = flags.availableKeys - .filter(d => d !== levelName) - .map(dd => [dd, d => d[dd]]); - tbody.push([measureName, d => measureFormatter(d[measureName])]); - - if (shouldShow.lci && shouldShow.uci) { - const {lciName, uciName} = query; - tbody.push([ - "Confidence Interval", - d => - `${measureFormatter(d[lciName] * 1 || 0)} - ${measureFormatter( - d[uciName] * 1 || 0 - )}` - ]); - } - else if (shouldShow.moe) { - const {moeName} = query; - tbody.push([ - "Margin of Error", - d => `± ${measureFormatter(d[moeName] * 1 || 0)}` - ]); - } - - if (shouldShow.src) { - const {sourceName} = query; - tbody.push(["Source", d => `${d[sourceName]}`]); - } - - if (shouldShow.clt) { - const {collectionName} = query; - tbody.push(["Collection", d => `${d[collectionName]}`]); - } - return { - title: d => [].concat(d[levelName]).join(", "), - tbody - }; -}; -/** - * @type {Object} - */ const makeConfig = { - barchart(commonConfig, query, flags) { - const {timeLevel, level, measure} = query; - - const levelName = level.name; - const measureName = measure.name; + barchart(chart) { + const {timeLevel, level} = chart.query; + const {levelName, measureName} = chart.names; const config = assign( {}, - commonConfig, + chart.baseConfig, { discrete: "y", label: d => d[levelName], @@ -98,9 +27,9 @@ const makeConfig = { } } }, - ySort: sortByCustomKey(levelName, flags.members[levelName]) + ySort: sortByCustomKey(levelName, chart.members[levelName]) }, - flags.chartConfig + chart.userConfig ); if (timeLevel) { @@ -111,26 +40,20 @@ const makeConfig = { delete config.total; } + if (chart.setup.length > 1) { + config.groupBy = [chart.setup[1].name]; + } + return config; }, - barchart_ab(commonConfig, query, flags) { - const config = this.barchart(commonConfig, query, flags); - // config.y = query.level.name; - config.groupBy = [query.xlevel.name]; - return config; - }, - barchartyear(commonConfig, query, flags) { - const {level, timeLevel, measure} = query; - - const levelName = level.name; - const timeLevelName = timeLevel.name; - const measureName = measure.name; + barchartyear(chart) { + const {levelName, timeLevelName, measureName} = chart.names; const config = assign( {}, - commonConfig, + chart.baseConfig, { - title: `${measureName} by ${levelName}, by ${timeLevelName}\n${flags.subtitle}`, + // title: `${measureName} by ${levelName}, by ${timeLevelName}\n${flags.subtitle}`, discrete: "x", x: timeLevelName, xConfig: {title: timeLevelName}, @@ -138,7 +61,7 @@ const makeConfig = { stacked: true, groupBy: [levelName] }, - flags.chartConfig + chart.userConfig ); delete config.time; @@ -149,116 +72,93 @@ const makeConfig = { return config; }, - donut(commonConfig, query, flags) { - const {level, measure} = query; - - const levelName = level.name; - const measureName = measure.name; + donut(chart) { + const {levelName, measureName} = chart.names; const config = assign( {}, - commonConfig, + chart.baseConfig, { y: measureName, groupBy: [levelName] }, - flags.chartConfig + chart.userConfig ); + if (chart.setup.length > 1) { + config.groupBy = chart.setup.map(lvl => lvl.name); + } + return config; }, - donut_ab(commonConfig, query, flags) { - const config = this.donut(commonConfig, query, flags); - config.groupBy = [query.level.name, query.xlevel.name]; - return config; - }, - geomap(commonConfig, query, flags) { - const levelName = query.level.name; - const measureName = query.measure.name; + geomap(chart) { + const {names, query} = chart; + const {levelName, measureName} = names; const config = assign( {}, - commonConfig, + chart.baseConfig, { colorScale: measureName, colorScaleConfig: { - axisConfig: {tickFormat: flags.measureFormatter}, + axisConfig: {tickFormat: chart.formatter}, scale: "jenks" }, colorScalePosition: "right", groupBy: [`ID ${levelName}`], zoomScroll: false }, - flags.topojsonConfig, - flags.chartConfig + chart.topoConfig, + chart.userConfig ); - if (!flags.activeChart) { - config.zoom = false; - } - const levelCut = query.cuts && query.cuts.find(cut => cut.key.indexOf(`[${levelName}]`) > -1); - if (levelCut) { + if (levelCut && !config.fitFilter) { const levelCutMembers = levelCut.values.map(member => member.key); config.fitFilter = d => levelCutMembers.indexOf(d.id) > -1; } return config; }, - geomap_ab(commonConfig, query, flags) { - const level1Name = query.level.name; - const level2Name = query.xlevel.name; - const config = this.geomap(commonConfig, query, flags); - config.groupBy = [level1Name, level2Name]; - return config; - }, - histogram(commonConfig, query, flags) { - const config = this.barchart(commonConfig, query, flags); + histogram(chart) { + const config = this.barchart(chart); return assign( config, { groupPadding: 0 }, - flags.chartConfig + chart.userConfig ); }, - lineplot(commonConfig, query, flags) { - const {level, measure, moe, lci, uci, timeLevel, xlevel} = query; - - const timeLevelName = timeLevel.name; - const measureName = measure.name; + lineplot(chart) { + const {measureName, moeName, lciName, uciName, timeLevelName} = chart.names; const config = assign( {}, - commonConfig, + chart.baseConfig, { discrete: "x", - groupBy: [level.name, xlevel && xlevel.name].filter(Boolean), + groupBy: chart.setup.map(lvl => lvl.name), yConfig: {scale: "linear", title: measureName}, x: timeLevelName, xConfig: {title: timeLevelName}, y: measureName }, - flags.chartConfig + chart.userConfig ); - if (relativeStdDev(flags.dataset, measureName) > 1) { + if (relativeStdDev(chart.dataset, measureName) > 1) { config.yConfig.scale = "log"; config.yConfig.title += " (Log)"; } - if (lci && uci) { - const lciName = lci.name; - const uciName = uci.name; - + if (lciName && uciName) { config.confidence = [d => d[lciName], d => d[uciName]]; } - else if (moe) { - const moeName = moe.name; - + else if (moeName) { config.confidence = [ d => d[measureName] - d[moeName], d => d[measureName] + d[moeName] @@ -271,78 +171,46 @@ const makeConfig = { delete config.timelineConfig; delete config.total; - config.title = composeChartTitle(flags, {timeline: true}); + // config.title = composeChartTitle(flags, {timeline: true}); return config; }, - lineplot_ab(commonConfig, query, flags) { - return this.lineplot(commonConfig, query, flags); - }, - pie(commonConfig, query, flags) { - return this.donut(commonConfig, query, flags); + lineplot_ab(chart) { + return this.lineplot(chart); }, - pie_ab(commonConfig, query, flags) { - const config = this.pie(commonConfig, query, flags); - config.groupBy = [query.level.name, query.xlevel.name]; - return config; + pie(chart) { + return this.donut(chart); }, - stacked(commonConfig, query, flags) { - const config = this.lineplot(commonConfig, query, flags); - const measureName = query.measure.name; + stacked(chart) { + const {measureName} = chart.names; + const config = this.lineplot(chart); config.yConfig = {scale: "linear", title: measureName}; + if (chart.setup.length > 1) { + config.groupBy = chart.setup.map(lvl => lvl.name); + } + return config; }, - stacked_ab(commonConfig, query, flags) { - const config = this.stacked(commonConfig, query, flags); - config.groupBy = [query.level.name, query.xlevel.name]; - return config; - }, - treemap(commonConfig, query, flags) { - const {level} = query; + treemap(chart) { + const {level} = chart.query; const levels = level.hierarchy.levels; const ddIndex = levels.indexOf(level); const config = assign( {}, - commonConfig, + chart.baseConfig, { groupBy: levels.slice(1, ddIndex + 1).map(lvl => lvl.name) }, - flags.chartConfig + chart.userConfig ); - return config; - }, - treemap_ab(commonConfig, query, flags) { - const {level, xlevel} = query; - const config = assign({}, commonConfig, flags.chartConfig); - - const levels = level.hierarchy.levels; - const ddIndex = levels.indexOf(level); - - const groupBy = levels.slice(1, ddIndex + 1).map(lvl => lvl.name); - groupBy.push(xlevel.name); - - config.groupBy = groupBy; - config.title = composeChartTitle(flags, {levels: [level.name, xlevel.name]}); - - return config; - }, - treemap_ba(commonConfig, query, flags) { - const {level, xlevel} = query; - const config = assign({}, commonConfig, flags.chartConfig); - - const levels = xlevel.hierarchy.levels; - const ddIndex = levels.indexOf(xlevel); - - const groupBy = levels.slice(1, ddIndex + 1).map(lvl => lvl.name); - groupBy.push(level.name); - - config.groupBy = groupBy; - config.title = composeChartTitle(flags, {levels: [xlevel.name, level.name]}); + if (chart.setup.length > 1) { + config.groupBy.push(chart.setup.slice(1).map(lvl => lvl.name)); + } return config; } @@ -351,254 +219,42 @@ const makeConfig = { /** * Generates an array with valid config objects, depending on the type of data * retrieved and the current user defined parameters, to use in d3plus charts. - * @param {Object} query The current query object from the Vizbuilder's state - * @param {Object[]} dataset The dataset for the current query - * @param {Object} members An object with the members in the current dataset - * @param {any} activeChart The currently active chart type - * @param {UserDefinedChartConfig} param0 The object containing the parameters - * @returns {CreateChartConfigResult[]} */ export default function createChartConfig( - query, - dataset, - members, - {activeChart, selectedTime, onTimeChange}, - {defaultConfig, formatting, measureConfig, topojson, visualizations} + chart, + {activeChart, isSingle, isUnique, selectedTime, onTimeChange, uiheight} ) { - const queryKey = query.key; - - if (!dataset.length) { - return []; - } + const {chartType, members, names, query} = chart; + const {measureName, timeLevelName} = names; + const {measure} = query; - // this prevents execution when the activeChart isn't for this query - if (activeChart) { - const tokens = activeChart.split("-"); - if (tokens.indexOf(queryKey) !== 0) { - return []; - } - activeChart = tokens.pop(); - } + const config = makeConfig[chartType](chart); - const availableKeys = Object.keys(members); - const availableCharts = new Set(activeChart ? [activeChart] : visualizations); + config.data = chart.dataset; + config.height = isSingle ? uiheight - (isUnique ? 50 : 0) : 400; - const measure = query.measure; - const measureName = measure.name; const measureAnn = measure.annotations; - const measureFormatter = - formatting[measureAnn.units_of_measurement] || formatting.default; - const getMeasureValue = d => d[measureName]; - - const levelName = query.level.name; - const xlevelName = query.xlevel && query.xlevel.name; - const timeLevelName = query.timeLevel && query.timeLevel.name; - const dimension = query.level.hierarchy.dimension; const hasTimeDim = timeLevelName && members[timeLevelName].length; - const hasGeoDim = dimension.annotations.dim_type === "GEOGRAPHY"; - - const aggregatorType = - measureAnn.pre_aggregation_method || - measureAnn.aggregation_method || - measure.aggregatorType || - "UNKNOWN"; const subtitle = measureAnn._cb_tagline; - const commonConfig = { - data: dataset, - legend: false, - - totalFormat: measureFormatter, - - xConfig: {title: null}, - yConfig: { - title: measureName, - tickFormat: measureFormatter - }, - - duration: 0, - total: false, - sum: getMeasureValue, - value: getMeasureValue - }; - if (hasTimeDim) { - commonConfig.time = timeLevelName; - commonConfig.timeFilter = d => d[timeLevelName] == selectedTime; // eslint-disable-line - commonConfig.timeline = Boolean(activeChart); - commonConfig.timelineConfig = { + config.time = timeLevelName; + config.timeFilter = d => d[timeLevelName] == selectedTime; // eslint-disable-line + config.timeline = Boolean(activeChart); + config.timelineConfig = { on: {end: onTimeChange} }; } - if (aggregatorType === "SUM" || aggregatorType === "UNKNOWN") { - commonConfig.total = getMeasureValue; + if (chart.aggType === "SUM" || chart.aggType === "UNKNOWN") { + config.total = measureName; } - const topojsonConfig = topojson[levelName]; - - if (!activeChart) { - if (members[levelName].length > 20) { - availableCharts.delete("barchart"); - } - - let totalMembers = members[levelName].length; - if (xlevelName) { - totalMembers *= members[xlevelName].length; - } - if (totalMembers > 60) { - availableCharts.delete("lineplot"); - availableCharts.delete("stacked"); - } - - if (!hasTimeDim || members[timeLevelName].length === 1) { - availableCharts.delete("barchartyear"); - availableCharts.delete("lineplot"); - availableCharts.delete("stacked"); - } - - if (!hasGeoDim || !topojsonConfig || members[levelName].length < 3 || xlevelName) { - availableCharts.delete("geomap"); - } - - if (aggregatorType === "AVERAGE") { - availableCharts.delete("donut"); - availableCharts.delete("histogram"); - availableCharts.delete("stacked"); - availableCharts.delete("treemap"); - } - else if (aggregatorType === "MEDIAN") { - availableCharts.delete("stacked"); - } - - if (aggregatorType !== "UNKNOWN" && aggregatorType !== "SUM") { - availableCharts.delete("barchartyear"); - availableCharts.delete("treemap"); - } - - if (availableKeys.some(d => d !== "Year" && members[d].length === 1)) { - // if any drilldown (besides Year) only has 1 member, hide these - availableCharts.delete("barchart"); - availableCharts.delete("stacked"); - availableCharts.delete("treemap"); - } - - if (xlevelName) { - if (availableCharts.has("barchart")) { - availableCharts.delete("barchart"); - availableCharts.add("barchart_ab"); - } - - if (availableCharts.has("donut")) { - availableCharts.delete("donut"); - availableCharts.add("donut_ab"); - } - - if (availableCharts.has("geomap")) { - availableCharts.delete("geomap"); - availableCharts.add("geomap_ab"); - } - - if (availableCharts.has("lineplot")) { - availableCharts.delete("lineplot"); - availableCharts.add("lineplot_ab"); - } - - if (availableCharts.has("stacked")) { - availableCharts.delete("stacked"); - availableCharts.add("stacked_ab"); - } - - if (availableCharts.has("treemap")) { - availableCharts.delete("treemap"); - availableCharts.add("treemap_ab"); - availableCharts.add("treemap_ba"); - } - } + if (chartType === "geomap" && isSingle) { + config.zoom = true; } - const currentMeasureConfig = measureConfig[measureName] || {}; - - const flags = { - activeChart, - aggregatorType, - availableKeys, - dataset, - selectedTime, - measureFormatter, - members, - query, - subtitle, - topojsonConfig, - chartConfig: { - ...defaultConfig, - ...currentMeasureConfig - } - }; - - commonConfig.title = composeChartTitle(flags); - commonConfig.tooltipConfig = tooltipGenerator( - { - levelName, - measureName, - moeName: query.moe && query.moe.name, - lciName: query.lci && query.lci.name, - uciName: query.uci && query.uci.name, - sourceName: query.source && query.source.name, - collectionName: query.collection && query.collection.name - }, - flags - ); - - return Array.from(availableCharts, functionName => { - const chartType = functionName.split("_")[0]; - return ( - charts.hasOwnProperty(chartType) && - makeConfig.hasOwnProperty(functionName) && { - key: `${queryKey}-${functionName}`, - component: charts[chartType], - config: makeConfig[functionName](commonConfig, query, flags) - } - ); - }).filter(Boolean); + return config; } - -/** - * @typedef {(commonConfig, query, flags: ConfigFunctionFlags) => object} ChartConfigFunction - * @param {object} commonConfig The common config between all charts - * @param {object} query The current `query` object from the Vizbuilder's state - * @param {ConfigFunctionFlags} flags An object with flags and other state variables - * @returns {object} The config for the chart of the corresponding type - */ - -/** - * @typedef {object} ConfigFunctionFlags - * @prop {string} [activeChart] - * @prop {string} aggregatorType - * @prop {string[]} availableKeys - * @prop {object} chartConfig - * @prop {object[]} dataset - * @prop {(value: number) => string} measureFormatter - * @prop {string[]} members - * @prop {object} topojsonConfig - * @prop {object} subtitle - */ - -/** - * @typedef {object} UserDefinedChartConfig - * @prop {object} defaultConfig The general config params provided by the user - * @prop {object} formatting An object with formatting functions for measure values. Keys are the value of measure.annotations.units_of_measurement - * @prop {object} measureConfig The config params for specific measures provided by the user - * @prop {object} topojson An object where keys are Level names and values are config params for the topojson properties - * @prop {string[]} visualizations An array with valid visualization names to present - */ - -/** - * @typedef {object} CreateChartConfigResult - * @prop {string} type The type of chart for this config - * @prop {string} key A deterministic unique string to identify the chart - * @prop {object} component The chart component this config is intended to use - * @prop {object} config The config object for the chart - */ diff --git a/packages/vizbuilder/src/helpers/fetch.js b/packages/vizbuilder/src/helpers/fetch.js index 27d5f2828..abc1240cb 100644 --- a/packages/vizbuilder/src/helpers/fetch.js +++ b/packages/vizbuilder/src/helpers/fetch.js @@ -160,7 +160,7 @@ export function fetchQuery(datacap, query) { throw new TooMuchData(mondrianQuery, dataAmount); } - return {dataset, members}; + return {dataset, members, query}; }); } diff --git a/packages/vizbuilder/src/helpers/loadstate.js b/packages/vizbuilder/src/helpers/loadstate.js index a643294a0..ebeea1c75 100644 --- a/packages/vizbuilder/src/helpers/loadstate.js +++ b/packages/vizbuilder/src/helpers/loadstate.js @@ -1,4 +1,7 @@ import {Intent, Position, Toaster} from "@blueprintjs/core"; + +import chartCriteria from "./chartCriteria"; +import {datagroupToCharts} from "./chartHelpers"; import {fetchQuery} from "./fetch"; import {generateQueries} from "./query"; import {higherTimeLessThanNow} from "./sorting"; @@ -41,8 +44,8 @@ export function loadControl(preQuery, postQuery) { promise = promise.then(result => { const finalState = mergeStates(initialState, result || {}); - const query = finalState.query; - const queries = generateQueries(query); + const vbQuery = finalState.query; + const queries = generateQueries(vbQuery); /** * Update 1 @@ -57,7 +60,7 @@ export function loadControl(preQuery, postQuery) { inProgress: true, total: queries.length }; - finalState.queries = queries; + finalState.datagroups = []; return finalState; }) .then(() => { @@ -81,30 +84,42 @@ export function loadControl(preQuery, postQuery) { return Promise.all(fetchings); }) .then(results => { - const datasets = []; - const members = []; - - const timeLevel = query.timeLevel; - const activeQueryKey = `${query.activeChart}`.split("-")[0]; - const activeChart = queries.some(q => q.key === activeQueryKey) - ? query.activeChart - : null; - - let n = results.length; - while (n--) { - const fetchResult = results[n]; - datasets.unshift(fetchResult.dataset); - members.unshift(fetchResult.members); + const generalConfig = this.getGeneralConfig(); + const isGeomapOnly = + generalConfig.visualizations.length === 1 && + generalConfig.visualizations[0] === "geomap"; + + const datagroups = chartCriteria(vbQuery, results, generalConfig); + const charts = []; + + let selectedTime = Infinity; + let i = datagroups.length; + while (i--) { + const datagroup = datagroups[i]; + + const dgTimeList = datagroup.members[datagroup.names.timeLevelName]; + selectedTime = Math.min( + selectedTime, + higherTimeLessThanNow(dgTimeList) + ); + + const dgCharts = datagroupToCharts(datagroup, generalConfig); + charts.push.apply(charts, dgCharts); } - const selectedTime = - timeLevel && higherTimeLessThanNow(members[0], timeLevel.name); + // activeChart example: treemap-z9TnC_1cDpEA + let activeChart = null; + if (charts.length === 1) { + activeChart = charts[0].key; + } else if (charts.map(ch => ch.key).indexOf(vbQuery.activeChart) > -1) { + activeChart = vbQuery.activeChart; + } return setStatePromise.call(this, currentState => mergeStates(currentState, { - datasets, - members, - query: {activeChart, selectedTime} + charts, + datagroups, + query: {activeChart, isGeomapOnly, selectedTime} }) ); }); @@ -157,7 +172,9 @@ export function loadControl(preQuery, postQuery) { if (__DEV__) { promise = promise.then(() => { console.groupCollapsed("FINAL STATE"); - console.table(this.state.queries); + for (let key in this.state) { + console.debug(key, this.state[key]); + } console.groupEnd(); }); } @@ -188,10 +205,9 @@ export function mergeStates(state, newState) { for (let i = 0; i < keys.length; i++) { const key = keys[i]; - if (/^queries|^datasets|^members|^meta/.test(key)) { + if (Array.isArray(newState[key])) { finalState[key] = newState[key]; - } - else { + } else { finalState[key] = { ...state[key], ...newState[key] diff --git a/packages/vizbuilder/src/helpers/query.js b/packages/vizbuilder/src/helpers/query.js index e51626ecc..fadc8af72 100644 --- a/packages/vizbuilder/src/helpers/query.js +++ b/packages/vizbuilder/src/helpers/query.js @@ -68,8 +68,8 @@ export function generateQueries(params) { queries.push({ ...params, - key: grouping.key, level, + levels: [level], cuts: grouping.hasMembers && [ {key: level.fullName, values: grouping.members} ] @@ -88,9 +88,9 @@ export function generateQueries(params) { queries.push({ ...params, - key: `${grouping1.key}_${grouping2.key}`, level: grouping1.level, xlevel: grouping2.level, + levels: [grouping1.level, grouping2.level], cuts: [ grouping1.hasMembers && { key: grouping1.level.fullName, @@ -105,8 +105,6 @@ export function generateQueries(params) { } } - queries.reverse(); - return queries; } @@ -126,7 +124,9 @@ export function queryConverter(params) { const drilldownList = [] .concat(params.level, params.xlevel, params.timeLevel) .filter(Boolean); - const drilldowns = drilldownList.map(lvl => lvl.fullName.slice(1, -1).split("].[")); + const drilldowns = drilldownList.map(lvl => + lvl.fullName.slice(1, -1).split("].[") + ); const cuts = [].concat(params.cuts).filter(Boolean); diff --git a/packages/vizbuilder/src/helpers/sorting.js b/packages/vizbuilder/src/helpers/sorting.js index cd8184bc1..289c793b9 100644 --- a/packages/vizbuilder/src/helpers/sorting.js +++ b/packages/vizbuilder/src/helpers/sorting.js @@ -1,7 +1,9 @@ import sort from "fast-sort"; +import groupBy from "lodash/groupBy"; import union from "lodash/union"; import yn from "yn"; +import Grouping from "../components/Sidebar/GroupingManager/Grouping"; import {findFirstNumber} from "./formatting"; import { areKindaNumeric, @@ -10,7 +12,6 @@ import { isValidDimension, isValidMeasure } from "./validation"; -import Grouping from "../components/Sidebar/GroupingManager/Grouping"; /** * If `needle` is a valid value, returns the first element in the `haystack` @@ -83,7 +84,6 @@ export function matchDefault(matchingFunction, haystack, defaults, elseFirst) { /** * Reduces a list of cubes to the measures that will be used in the vizbuilder. * @param {Cube[]} cubes An array of the cubes to be reduced. - * @returns {Measure[]} */ export function classifyMeasures(cubes) { cubes = [].concat(cubes); @@ -137,7 +137,7 @@ export function getDefaultGroup(defaultGroup, levels) { * from the cube. If there's no MOE for the measure, returns undefined. * @param {Cube} cube The measure's parent cube * @param {*} measure The measure - * @returns {Measure|undefined} + * @returns {Object} */ export function getMeasureMeta(cube, measure) { let collection, lci, moe, source, uci; @@ -175,7 +175,7 @@ export function getMeasureMeta(cube, measure) { collection = currentMeasure; } - if (collection && (lci && uci || moe) && source) { + if (collection && ((lci && uci) || moe) && source) { break; } } @@ -260,9 +260,9 @@ export function reduceLevelsFromDimension(container, dimension) { return isTimeDimension(dimension) || yn(dimension.annotations.hide_in_ui) ? container : dimension.hierarchies.reduce( - (container, hierarchy) => container.concat(hierarchy.levels.slice(1)), - container - ); + (container, hierarchy) => container.concat(hierarchy.levels.slice(1)), + container + ); } /** @@ -333,14 +333,26 @@ export function getIncludedMembers(query, dataset) { /** * Returns the value of the highest timeLevel value in the dataset, but lower or equal than the current time. - * @param {Object} members An object with members and arrays of its available values - * @param {string} timeLevelName The name of the timeLevel for the current query + * @param {number[]} timelist An array with time-related members */ -export function higherTimeLessThanNow(members, timeLevelName) { +export function higherTimeLessThanNow(timelist) { + timelist = timelist || []; // TODO: prepare it to handle months, days, etc const now = new Date(); const currentTime = now.getFullYear(); - return members[timeLevelName].filter(time => time <= currentTime).pop(); + return timelist.filter(time => time <= currentTime).pop(); +} + +export function getTopTenByYear(datagroup) { + const timeLevelName = + datagroup.query.timeLevel && datagroup.query.timeLevel.name; + const groups = groupBy(datagroup.dataset, timeLevelName); + const newDataset = Object.keys(groups).reduce((all, time) => { + const top = groups[time].slice(0, 10); + all.push.apply(all, top); + return all; + }, []); + return {newDataset, newMembers}; } /** @@ -360,7 +372,7 @@ export function sortByCustomKey(key, members) { * Generates a 2-object combination from a list of objects. * @param {any[]} set An array of objects to get the combo. */ -export function *getCombinationsChoose2(set) { +export function* getCombinationsChoose2(set) { const n = set.length; if (n > 0) { const first = set[0]; @@ -370,3 +382,23 @@ export function *getCombinationsChoose2(set) { yield* getCombinationsChoose2(set.slice(1)); } } + +export function getPermutations(set) { + let result = []; + + const permute = (arr, m = []) => { + if (arr.length === 0) { + result.push(m); + } else { + for (let i = 0; i < arr.length; i++) { + let curr = arr.slice(); + let next = curr.splice(i, 1); + permute(curr.slice(), m.concat(next)); + } + } + }; + + permute(set); + + return result; +} diff --git a/packages/vizbuilder/src/helpers/validation.js b/packages/vizbuilder/src/helpers/validation.js index d05fba472..17e27a211 100644 --- a/packages/vizbuilder/src/helpers/validation.js +++ b/packages/vizbuilder/src/helpers/validation.js @@ -175,11 +175,11 @@ export function areKindaNumeric(list, tolerance = 0.8) { /** * Checks if all the additional measures (MoE, UCI, LCI) in a dataset are different from zero. * @see Issue#257 on {@link https://github.com/Datawheel/canon/issues/257 | Github} - * @param {object} query An object with the names of the properties in each item in dataset. + * @param {Object} names A dictionary of names of the properties in each item in dataset. * @param {object[]} dataset The dataset to analyze. */ -export function areMetaMeasuresZero(query, dataset) { - const {moeName, lciName, uciName, sourceName, collectionName} = query; +export function areMetaMeasuresZero(names, dataset) { + const {moeName, lciName, uciName, sourceName, collectionName} = names; const results = {}; let n = dataset.length; while (n--) { diff --git a/packages/vizbuilder/src/index.js b/packages/vizbuilder/src/index.js index 982e809c7..df9580796 100644 --- a/packages/vizbuilder/src/index.js +++ b/packages/vizbuilder/src/index.js @@ -11,21 +11,21 @@ import PermalinkManager from "./components/PermalinkManager"; import Sidebar from "./components/Sidebar"; import Ranking from "./components/Sidebar/Ranking"; -import * as api from "./helpers/api"; +import {resetClient} from "./helpers/api"; +import {chartComponents} from "./helpers/chartHelpers"; import {fetchCubes} from "./helpers/fetch"; import {DEFAULT_MEASURE_FORMATTERS} from "./helpers/formatting"; import {loadControl, mergeStates, setStatePromise} from "./helpers/loadstate"; import {parsePermalink, permalinkToState} from "./helpers/permalink"; import {getDefaultGroup} from "./helpers/sorting"; import {isSameQuery} from "./helpers/validation"; - import initialState from "./state"; class Vizbuilder extends React.PureComponent { constructor(props, ctx) { super(props); - api.resetClient(props.src); + resetClient(props.src); this.state = initialState(); const permalinkKeywords = { @@ -59,19 +59,24 @@ class Vizbuilder extends React.PureComponent { this.loadControl = loadControl.bind(this); this.stateUpdate = this.stateUpdate.bind(this); + this.getGeneralConfig = () => { + const props = this.props; + return { + defaultConfig: props.config, + formatting: {...DEFAULT_MEASURE_FORMATTERS, ...props.formatting}, + measureConfig: props.measureConfig, + topojson: props.topojson, + visualizations: props.visualizations.filter(viz => + chartComponents.hasOwnProperty(viz) + ) + }; + }; } getChildContext() { - const props = this.props; return { defaultQuery: this.defaultQuery, - generalConfig: { - defaultConfig: props.config, - formatting: {...DEFAULT_MEASURE_FORMATTERS, ...props.formatting}, - measureConfig: props.measureConfig, - topojson: props.topojson, - visualizations: props.visualizations - }, + generalConfig: this.getGeneralConfig(), getDefaultGroup: this.getDefaultGroup, loadControl: this.loadControl, permalinkKeywords: this.permalinkKeywords, @@ -103,7 +108,7 @@ class Vizbuilder extends React.PureComponent { render() { const {location} = this.context.router; const {permalink, toolbar} = this.props; - const {load, datasets, members, queries, options, query} = this.state; + const {charts, datagroups, load, options, query} = this.state; return (
{this.props.children} diff --git a/packages/vizbuilder/src/state.js b/packages/vizbuilder/src/state.js index a66313125..440ce7b2f 100644 --- a/packages/vizbuilder/src/state.js +++ b/packages/vizbuilder/src/state.js @@ -34,8 +34,7 @@ export default function initialStateFactory() { collection: null, selectedTime: null }, - queries: [], - datasets: [], - members: [] + charts: [], + datagroups: [] }; }