diff --git a/src/charts/bar.vue b/src/charts/bar.vue index 95fee69f..89c9038c 100644 --- a/src/charts/bar.vue +++ b/src/charts/bar.vue @@ -1,5 +1,14 @@ @@ -7,8 +16,9 @@ vue-plotly(:data="data" :layout="layout" :options="options" :config="{responsive import { Vue, Component, Watch, Prop } from 'vue-property-decorator' import { Worker, spawn, Thread } from 'threads' import VuePlotly from '@statnett/vue-plotly' - +import { rollup } from 'd3-array' import { FileSystemConfig, UI_FONT } from '@/Globals' +import DashboardDataManager from '@/js/DashboardDataManager' const mockData = { car: 34, @@ -23,10 +33,13 @@ export default class VueComponent extends Vue { @Prop({ required: true }) subfolder!: string @Prop({ required: true }) files!: string[] @Prop({ required: true }) config!: any + @Prop({ required: true }) datamanager!: DashboardDataManager + + private id = 'bar-' + Math.random() private globalState = this.$store.state - private thread!: any + // private thread!: any private dataRows: any = {} private plotID = this.getRandomInt(100000) @@ -36,6 +49,7 @@ export default class VueComponent extends Vue { await this.loadData() this.resizePlot() window.addEventListener('resize', this.myEventHandler) + this.$emit('isLoaded') } @@ -49,28 +63,34 @@ export default class VueComponent extends Vue { this.layout.font.color = this.globalState.isDarkMode ? '#cccccc' : '#444444' } + private handlePlotlyClick(click: any) { + try { + const { x, y, data } = click.points[0] + const fullData = Object.assign({}, data) + + fullData.x = [x] + fullData.y = [y] + this.data.push(fullData) + this.data[0].opacity = 0.25 + } catch (e) { + console.error(e) + } + } + private async loadData() { if (!this.files.length) return - if (this.thread) Thread.terminate(this.thread) - this.thread = await spawn(new Worker('../workers/DataFetcher.thread')) - try { - const data = await this.thread.fetchData({ - fileSystemConfig: this.fileSystemConfig, - subfolder: this.subfolder, - files: this.files, - config: this.config, - }) - - this.dataRows = data + const { fullData, filteredData } = await this.datamanager.getDataset(this.config) + + // console.log({ fullData }) + + this.dataRows = fullData this.updateChart() } catch (e) { const message = '' + e console.log(message) this.dataRows = {} - } finally { - Thread.terminate(this.thread) } } @@ -104,6 +124,67 @@ export default class VueComponent extends Vue { } private updateChart() { + if (this.config.groupBy) this.updateChartWithGroupBy() + else this.updateChartSimple() + } + + private updateChartWithGroupBy() { + this.className = this.plotID // stacked bug-fix hack + + const { x, y } = this.dataRows + + this.data = [ + { + x, + y, + name: this.config.groupBy, + type: 'bar', + textinfo: 'label+percent', + textposition: 'inside', + automargin: true, + opacity: 1.0, + }, + ] + + // var useOwnNames = false + + // for (var i = 0; i < this.dataRows.length; i++) { + // if (i == 0 && this.config.skipFirstRow) { + // } else { + // x.push(this.dataRows[i][this.config.x]) + // } + // } + + // for (let i = 0; i < this.config.columns.length; i++) { + // const name = this.config.columns[i] + // let legendName = '' + // if (this.config.columns[i] !== 'undefined') { + // if (useOwnNames) { + // legendName = this.config.legendTitles[i] + // } else { + // legendName = name + // } + // const value = [] + // for (var j = 0; j < this.dataRows.length; j++) { + // if (j == 0 && this.config.skipFirstRow) { + // } else { + // value.push(this.dataRows[j][name]) + // } + // } + // this.data.push({ + // x: x, + // y: value, + // name: legendName, + // type: 'bar', + // textinfo: 'label+percent', + // textposition: 'inside', + // automargin: true, + // }) + // } + // } + } + + private updateChartSimple() { const x = [] var useOwnNames = false @@ -148,6 +229,7 @@ export default class VueComponent extends Vue { textinfo: 'label+percent', textposition: 'inside', automargin: true, + opacity: 1.0, }) } } @@ -164,7 +246,7 @@ export default class VueComponent extends Vue { color: '#444444', family: UI_FONT, }, - barmode: '', + barmode: 'overlay', bargap: 0.08, xaxis: { autorange: true, @@ -190,6 +272,7 @@ export default class VueComponent extends Vue { textinfo: 'label+percent', textposition: 'inside', automargin: true, + opacity: 1.0, }, ] diff --git a/src/js/DashboardDataManager.ts b/src/js/DashboardDataManager.ts new file mode 100644 index 00000000..40eeb280 --- /dev/null +++ b/src/js/DashboardDataManager.ts @@ -0,0 +1,115 @@ +/** + * DashboardDataManager: this class loads, caches, and filters CSV and XML datasets + * for use by dashboard charts and maps. Loosely based on the VizWit system + * (see http://vizwit.io/) but we don't have a Carto database so all of the data + * is stored internally in this class. + * + * Each tabbed dashboard should instantiate this class once, and destroy it when the dashboard + * is closed. Datasets can be big, we don't want them to stick around forever! + * + * Data queries will return -both- the full dataset AND a filtered dataset. That way + * the filtered data can be visually layered on top of the full data. + */ + +import { rollup } from 'd3-array' +import { Worker, spawn, Thread } from 'threads' + +import { FileSystemConfig } from '@/Globals' +import globalStore from '@/store' +import HTTPFileSystem from './HTTPFileSystem' + +export default class DashboardDataManager { + constructor(...args: string[]) { + // hello + this.root = args.length ? args[0] : '' + this.subfolder = args.length ? args[1] : '' + this.fileApi = this.getFileSystem(this.root) + } + + public async getDataset(config: { dataset: string; groupBy?: string; value?: string }) { + console.log('getDataset', config) + + let dataframe: any[] = [] + + // first, get the dataset + if (!this.dataCache[config.dataset]) { + console.log('fetch:', config.dataset) + this.dataCache[config.dataset] = this.fetchDataset(config) + } + dataframe = await this.dataCache[config.dataset] + + // group the rows as needed + let bars: any = {} + + if (config.value && config.groupBy) { + const columnValues = config.value + const columnGroups = config.groupBy + bars = rollup( + dataframe, + v => v.reduce((a, b) => a + b[columnValues], 0), + (d: any) => d[columnGroups] // group-by + ) + } else { + // TODO need to handle non-value, non-group here + } + const x = Array.from(bars.keys()) + const y = Array.from(bars.values()) + + return { fullData: { x, y }, filteredData: {} } + } + + public setFilter(filter: string) {} + + public clearCache() { + this.dataCache = {} + } + + // ---- PRIVATE STUFFS ----------------------- + private thread!: any + private files: any[] = [] + + private async fetchDataset(config: { dataset: string; groupBy?: string; value?: string }) { + if (!this.files.length) { + const { files } = await new HTTPFileSystem(this.fileApi).getDirectory(this.subfolder) + this.files = files + } + + if (this.thread) Thread.terminate(this.thread) + this.thread = await spawn(new Worker('../workers/DataFetcher.thread')) + + try { + const data = await this.thread.fetchData({ + fileSystemConfig: this.fileApi, + subfolder: this.subfolder, + files: this.files, + config: config, + }) + + return data + } catch (e) { + const message = '' + e + console.log(message) + } finally { + Thread.terminate(this.thread) + } + return [] + } + + private getFileSystem(name: string) { + const svnProject: FileSystemConfig[] = globalStore.state.svnProjects.filter( + (a: FileSystemConfig) => a.slug === name + ) + if (svnProject.length === 0) { + console.error('DDM: no such project') + throw Error + } + return svnProject[0] + } + + private dataCache: { [dataset: string]: Promise | any[] } = {} + + private filteredDataCache: { [id: string]: any[] } = {} + private subfolder = '' + private root = '' + private fileApi: FileSystemConfig +} diff --git a/src/views/DashBoard.vue b/src/views/DashBoard.vue index 500dd1f8..fd33e555 100644 --- a/src/views/DashBoard.vue +++ b/src/views/DashBoard.vue @@ -28,6 +28,7 @@ :files="fileList" :yaml="card.props.configFile" :config="card.props" + :datamanager="datamanager" :style="{opacity: opacity[card.id]}" @isLoaded="handleCardIsLoaded(card)" ) @@ -42,6 +43,7 @@ import HTTPFileSystem from '@/js/HTTPFileSystem' import { FileSystemConfig } from '@/Globals' import TopSheet from '@/components/TopSheet/TopSheet.vue' import charts from '@/charts/allCharts' +import DashboardDataManager from '@/js/DashboardDataManager' // append a prefix so the html template is legal const namedCharts = {} as any @@ -54,6 +56,7 @@ Object.keys(charts).forEach((key: any) => { export default class VueComponent extends Vue { @Prop({ required: true }) private root!: string @Prop({ required: true }) private xsubfolder!: string + @Prop({ required: true }) private datamanager!: DashboardDataManager @Prop({ required: false }) private gist!: any @Prop({ required: false }) private config!: any diff --git a/src/views/TabbedDashboardView.vue b/src/views/TabbedDashboardView.vue index 6b4046b1..9792e5d6 100644 --- a/src/views/TabbedDashboardView.vue +++ b/src/views/TabbedDashboardView.vue @@ -20,6 +20,7 @@ :root="root" :xsubfolder="xsubfolder" :config="dashboards[activeTab]" + :datamanager="dashboardDataManager" @zoom="handleZoom" ) @@ -40,6 +41,7 @@ import { FileSystemConfig } from '@/Globals' import DashBoard from '@/views/DashBoard.vue' import FolderBrowser from '@/views/FolderBrowser.vue' import HTTPFileSystem from '@/js/HTTPFileSystem' +import DashboardDataManager from '@/js/DashboardDataManager' @Component({ components: { DashBoard, FolderBrowser }, props: {} }) export default class VueComponent extends Vue { @@ -51,6 +53,7 @@ export default class VueComponent extends Vue { private fileApi!: HTTPFileSystem private dashboards: any = [] + private dashboardDataManager?: DashboardDataManager private isZoomed = false @@ -58,12 +61,19 @@ export default class VueComponent extends Vue { this.updateRoute() } + private beforeDestroy() { + if (this.dashboardDataManager) this.dashboardDataManager.clearCache() + } + @Watch('root') @Watch('xsubfolder') private updateRoute() { this.fileSystemConfig = this.getFileSystem(this.root) if (!this.fileSystemConfig) return + if (this.dashboardDataManager) this.dashboardDataManager.clearCache() + this.dashboardDataManager = new DashboardDataManager(this.root, this.xsubfolder) + this.fileApi = new HTTPFileSystem(this.fileSystemConfig) // this.generateBreadcrumbs()