UNPKG

virool-pivot

Version:

A web-based exploratory visualization UI for Druid.io

841 lines (699 loc) 27.8 kB
import { List, OrderedSet, Iterable } from 'immutable'; import { compressToBase64, decompressFromBase64 } from 'lz-string'; import { Class, Instance, isInstanceOf, immutableEqual } from 'immutable-class'; import { Timezone, Duration, minute } from 'chronoshift'; import { $, Expression, RefExpression, TimeRange, ApplyAction, SortAction, Set } from 'plywood'; import { hasOwnProperty } from '../../../common/utils/general/general'; import { DataSource } from '../data-source/data-source'; import { Filter, FilterJS } from '../filter/filter'; import { FilterClause } from '../filter-clause/filter-clause'; import { Highlight, HighlightJS } from '../highlight/highlight'; import { Splits, SplitsJS } from '../splits/splits'; import { SplitCombine } from '../split-combine/split-combine'; import { Dimension } from '../dimension/dimension'; import { Measure } from '../measure/measure'; import { Colors, ColorsJS } from '../colors/colors'; import { Manifest, Resolve } from '../manifest/manifest'; const HASH_VERSION = 2; function constrainDimensions(dimensions: OrderedSet<string>, dataSource: DataSource): OrderedSet<string> { return <OrderedSet<string>>dimensions.filter((dimensionName) => Boolean(dataSource.getDimension(dimensionName))); } function constrainMeasures(measures: OrderedSet<string>, dataSource: DataSource): OrderedSet<string> { return <OrderedSet<string>>measures.filter((measureName) => Boolean(dataSource.getMeasure(measureName))); } function addToSetInOrder<T>(order: Iterable<any, T>, setToAdd: OrderedSet<T>, thing: T): OrderedSet<T> { return OrderedSet(order.toArray().filter((name) => setToAdd.has(name) || name === thing)); } export interface VisualizationAndResolve { visualization: Manifest; resolve: Resolve; } /** * FairGame - Run all visualizations pretending that there is no current * UnfairGame - Run all visualizations but mark current vis as current * KeepAlways - Just keep the current one */ export enum VisStrategy { FairGame, UnfairGame, KeepAlways } export interface EssenceValue { visualizations?: List<Manifest>; dataSource?: DataSource; visualization: Manifest; timezone: Timezone; filter: Filter; splits: Splits; multiMeasureMode: boolean; singleMeasure: string; selectedMeasures: OrderedSet<string>; pinnedDimensions: OrderedSet<string>; colors: Colors; pinnedSort: string; compare: Filter; highlight: Highlight; } export interface EssenceJS { visualization?: string; timezone?: string; filter?: FilterJS; splits?: SplitsJS; multiMeasureMode?: boolean; singleMeasure?: string; selectedMeasures?: string[]; pinnedDimensions?: string[]; colors?: ColorsJS; pinnedSort?: string; compare?: FilterJS; highlight?: HighlightJS; } export interface EssenceContext { dataSource: DataSource; visualizations: List<Manifest>; } var check: Class<EssenceValue, EssenceJS>; export class Essence implements Instance<EssenceValue, EssenceJS> { static isEssence(candidate: any): candidate is Essence { return isInstanceOf(candidate, Essence); } static getBestVisualization(visualizations: List<Manifest>, dataSource: DataSource, splits: Splits, colors: Colors, currentVisualization: Manifest): VisualizationAndResolve { var visAndResolves = visualizations.toArray().map((visualization) => { return { visualization, resolve: visualization.handleCircumstance(dataSource, splits, colors, visualization === currentVisualization) }; }); return visAndResolves.sort((vr1, vr2) => Resolve.compare(vr1.resolve, vr2.resolve))[0]; } static fromHash(hash: string, context: EssenceContext): Essence { var parts = hash.split('/'); if (parts.length < 3) return null; var visualization = parts.shift(); var version = parseInt(parts.shift(), 10); if (version > HASH_VERSION) return null; var jsArray: any[] = null; try { jsArray = JSON.parse('[' + decompressFromBase64(parts.join('/')) + ']'); } catch (e) { return null; } if (!Array.isArray(jsArray)) return null; if (version === 1) { // Upgrade to version 2 jsArray.splice(3, 0, false, null); // Insert null at position 3 (between splits and selectedMeasures) } var jsArrayLength = jsArray.length; if (!(8 <= jsArrayLength && jsArrayLength <= 11)) return null; var essence: Essence; try { essence = Essence.fromJS({ visualization: visualization, timezone: jsArray[0], filter: jsArray[1], splits: jsArray[2], multiMeasureMode: jsArray[3], singleMeasure: jsArray[4], selectedMeasures: jsArray[5], pinnedDimensions: jsArray[6], pinnedSort: jsArray[7], colors: jsArray[8] || null, compare: jsArray[9] || null, highlight: jsArray[10] || null }, context); } catch (e) { return null; } return essence; } static fromDataSource(dataSource: DataSource, context: EssenceContext): Essence { var timezone = dataSource.defaultTimezone; var splits = Splits.EMPTY; var { defaultSplits } = dataSource.options; if (defaultSplits) { splits = Splits.fromJS(defaultSplits, dataSource); } var essence = new Essence({ dataSource: context.dataSource, visualizations: context.visualizations, visualization: null, timezone, filter: null, splits, multiMeasureMode: false, singleMeasure: dataSource.defaultSortMeasure, selectedMeasures: OrderedSet(dataSource.measures.toArray().slice(0, 4).map(m => m.name)), pinnedDimensions: dataSource.defaultPinnedDimensions, colors: null, pinnedSort: dataSource.defaultSortMeasure, compare: null, highlight: null }); if (defaultSplits) { essence = essence.updateSplitsWithFilter(); } return essence; } static fromJS(parameters: EssenceJS, context?: EssenceContext): Essence { if (!context) throw new Error('must have context'); const { dataSource, visualizations } = context; var visualizationID = parameters.visualization; var visualization = visualizations.find(v => v.id === visualizationID); var timezone = parameters.timezone ? Timezone.fromJS(parameters.timezone) : null; var filter = parameters.filter ? Filter.fromJS(parameters.filter).constrainToDimensions(dataSource.dimensions, dataSource.timeAttribute) : null; var splits = Splits.fromJS(parameters.splits || [], dataSource).constrainToDimensions(dataSource.dimensions); var defaultSortMeasureName = dataSource.defaultSortMeasure; var multiMeasureMode = hasOwnProperty(parameters, 'multiMeasureMode') ? parameters.multiMeasureMode : !hasOwnProperty(parameters, 'singleMeasure'); var singleMeasure = dataSource.getMeasure(parameters.singleMeasure) ? parameters.singleMeasure : defaultSortMeasureName; var selectedMeasures = constrainMeasures(OrderedSet(parameters.selectedMeasures || []), dataSource); var pinnedDimensions = constrainDimensions(OrderedSet(parameters.pinnedDimensions || []), dataSource); var colors = parameters.colors ? Colors.fromJS(parameters.colors) : null; var pinnedSort = dataSource.getMeasure(parameters.pinnedSort) ? parameters.pinnedSort : defaultSortMeasureName; var compare: Filter = null; var compareJS = parameters.compare; if (compareJS) { compare = Filter.fromJS(compareJS).constrainToDimensions(dataSource.dimensions, dataSource.timeAttribute); } var highlight: Highlight = null; var highlightJS = parameters.highlight; if (highlightJS) { highlight = Highlight.fromJS(highlightJS).constrainToDimensions(dataSource.dimensions, dataSource.timeAttribute); } return new Essence({ dataSource, visualizations, visualization, timezone, filter, splits, multiMeasureMode, singleMeasure, selectedMeasures, pinnedDimensions, colors, pinnedSort, compare, highlight }); } public dataSource: DataSource; public visualizations: List<Manifest>; public visualization: Manifest; public timezone: Timezone; public filter: Filter; public splits: Splits; public multiMeasureMode: boolean; public singleMeasure: string; public selectedMeasures: OrderedSet<string>; public pinnedDimensions: OrderedSet<string>; public colors: Colors; public pinnedSort: string; public compare: Filter; public highlight: Highlight; public visResolve: Resolve; constructor(parameters: EssenceValue) { var { visualizations, dataSource, visualization, timezone, filter, splits, multiMeasureMode, singleMeasure, selectedMeasures, pinnedDimensions, colors, pinnedSort, compare, highlight } = parameters; if (!dataSource) throw new Error('Essence must have a dataSource'); timezone = timezone || Timezone.UTC; if (!filter) { if (dataSource.timeAttribute) { filter = dataSource.defaultFilter.setSelection( dataSource.timeAttribute, $(FilterClause.MAX_TIME_REF_NAME).timeRange(dataSource.defaultDuration, -1) ); } else { filter = Filter.EMPTY; } } multiMeasureMode = Boolean(multiMeasureMode); function visibleMeasure(measureName: string): boolean { return multiMeasureMode ? selectedMeasures.has(measureName) : measureName === singleMeasure; } // Wipe out the highlight if measure is not selected if (highlight && highlight.measure && !visibleMeasure(highlight.measure)) { highlight = null; } // Place vis here because it needs to know about splits and colors (and maybe later other things) if (!visualization) { var visAndResolve = Essence.getBestVisualization(visualizations, dataSource, splits, colors, null); visualization = visAndResolve.visualization; } var visResolve = visualization.handleCircumstance(dataSource, splits, colors, true); if (visResolve.isAutomatic()) { var adjustment = visResolve.adjustment; splits = adjustment.splits; colors = adjustment.colors || null; visResolve = visualization.handleCircumstance(dataSource, splits, colors, true); if (!visResolve.isReady()) { console.log(visResolve); throw new Error(visualization.title + ' must be ready after automatic adjustment'); } } this.visualizations = visualizations; this.dataSource = dataSource; this.visualization = visualization; this.dataSource = dataSource; this.timezone = timezone; this.filter = filter; this.splits = splits; this.multiMeasureMode = multiMeasureMode; this.singleMeasure = singleMeasure; this.selectedMeasures = selectedMeasures; this.pinnedDimensions = pinnedDimensions; this.colors = colors; this.pinnedSort = pinnedSort; this.highlight = highlight; this.compare = compare; this.visResolve = visResolve; } public valueOf(): EssenceValue { return { dataSource: this.dataSource, visualizations: this.visualizations, visualization: this.visualization, timezone: this.timezone, filter: this.filter, splits: this.splits, multiMeasureMode: this.multiMeasureMode, singleMeasure: this.singleMeasure, selectedMeasures: this.selectedMeasures, pinnedDimensions: this.pinnedDimensions, colors: this.colors, pinnedSort: this.pinnedSort, compare: this.compare, highlight: this.highlight }; } public toJS(): EssenceJS { var js: EssenceJS = { visualization: this.visualization.id, timezone: this.timezone.toJS(), filter: this.filter.toJS(), splits: this.splits.toJS(), singleMeasure: this.singleMeasure, selectedMeasures: this.selectedMeasures.toArray(), pinnedDimensions: this.pinnedDimensions.toArray() }; if (this.multiMeasureMode) js.multiMeasureMode = true; if (this.colors) js.colors = this.colors.toJS(); var defaultSortMeasure = this.dataSource.defaultSortMeasure; if (this.pinnedSort !== defaultSortMeasure) js.pinnedSort = this.pinnedSort; if (this.compare) js.compare = this.compare.toJS(); if (this.highlight) js.highlight = this.highlight.toJS(); return js; } public toJSON(): EssenceJS { return this.toJS(); } public toString(): string { return `[Essence]`; } public equals(other: Essence): boolean { return Essence.isEssence(other) && this.dataSource.equals(other.dataSource) && this.visualization.id === other.visualization.id && this.timezone.equals(other.timezone) && this.filter.equals(other.filter) && this.splits.equals(other.splits) && this.multiMeasureMode === other.multiMeasureMode && this.singleMeasure === other.singleMeasure && this.selectedMeasures.equals(other.selectedMeasures) && this.pinnedDimensions.equals(other.pinnedDimensions) && immutableEqual(this.colors, other.colors) && this.pinnedSort === other.pinnedSort && immutableEqual(this.compare, other.compare) && immutableEqual(this.highlight, other.highlight); } public toHash(): string { var js = this.toJS(); var compressed: any[] = [ js.timezone, // 0 js.filter, // 1 js.splits, // 2 js.multiMeasureMode, // 3 js.singleMeasure, // 4 js.selectedMeasures, // 5 js.pinnedDimensions, // 6 js.pinnedSort // 7 ]; if (js.colors) compressed[8] = js.colors; if (js.compare) compressed[9] = js.compare; if (js.highlight) compressed[10] = js.highlight; var restJSON: string[] = []; for (var i = 0; i < compressed.length; i++) { restJSON.push(JSON.stringify(compressed[i] || null)); } return [ js.visualization, HASH_VERSION, compressToBase64(restJSON.join(',')) ].join('/'); } public getURL(urlPrefix: string): string { return urlPrefix + this.toHash(); } public getTimeAttribute(): RefExpression { return this.dataSource.timeAttribute; } public getTimeDimension(): Dimension { return this.dataSource.getTimeDimension(); } public evaluateSelection(selection: Expression, now: Date = new Date()): TimeRange { var { dataSource, timezone } = this; var maxTime = dataSource.getMaxTimeDate(); return FilterClause.evaluate(selection, now, maxTime, timezone); } public getEffectiveFilter(highlightId: string = null, unfilterDimension: Dimension = null): Filter { var { dataSource, filter, highlight, timezone } = this; if (highlight && (highlightId !== highlight.owner)) filter = highlight.applyToFilter(filter); if (unfilterDimension) filter = filter.remove(unfilterDimension.expression); var maxTime = dataSource.getMaxTimeDate(); return filter.getSpecificFilter(new Date(), maxTime, timezone); } public getTimeSelection(): Expression { const timeAttribute = this.getTimeAttribute(); return this.filter.getSelection(timeAttribute); } public isFixedMeasureMode(): boolean { return this.visualization.measureModeNeed !== 'any'; } public getEffectiveMultiMeasureMode(): boolean { const { measureModeNeed } = this.visualization; if (measureModeNeed !== 'any') { return measureModeNeed === 'multi'; } return this.multiMeasureMode; } public getEffectiveMeasures(): List<Measure> { if (this.getEffectiveMultiMeasureMode()) { return this.getMeasures(); } else { return List([this.dataSource.getMeasure(this.singleMeasure)]); } } public getMeasures(): List<Measure> { var dataSource = this.dataSource; return <List<Measure>>this.selectedMeasures.toList().map(measureName => dataSource.getMeasure(measureName)); } public getEffectiveSelectedMeasure(): OrderedSet<string> { if (this.getEffectiveMultiMeasureMode()) { return this.selectedMeasures; } else { return OrderedSet([this.singleMeasure]); } } public differentDataSource(other: Essence): boolean { return this.dataSource !== other.dataSource; } public differentTimezone(other: Essence): boolean { return !this.timezone.equals(other.timezone); } public differentTimezoneMatters(other: Essence): boolean { return this.splits.timezoneDependant() && this.differentTimezone(other); } public differentFilter(other: Essence): boolean { return !this.filter.equals(other.filter); } public differentSplits(other: Essence): boolean { return !this.splits.equals(other.splits); } public differentEffectiveSplits(other: Essence): boolean { return this.differentSplits(other) || this.differentTimezoneMatters(other); } public differentColors(other: Essence): boolean { if (Boolean(this.colors) !== Boolean(other.colors)) return true; if (!this.colors) return false; return !this.colors.equals(other.colors); } public differentSelectedMeasures(other: Essence): boolean { return !this.selectedMeasures.equals(other.selectedMeasures); } public differentEffectiveMeasures(other: Essence): boolean { return !this.getEffectiveSelectedMeasure().equals(other.getEffectiveSelectedMeasure()); } public newSelectedMeasures(other: Essence): boolean { return !this.selectedMeasures.isSubset(other.selectedMeasures); } public newEffectiveMeasures(other: Essence): boolean { return !this.getEffectiveSelectedMeasure().isSubset(other.getEffectiveSelectedMeasure()); } public differentPinnedDimensions(other: Essence): boolean { return !this.pinnedDimensions.equals(other.pinnedDimensions); } public differentPinnedSort(other: Essence): boolean { return this.pinnedSort !== other.pinnedSort; } public differentCompare(other: Essence): boolean { if (Boolean(this.compare) !== Boolean(other.compare)) return true; return Boolean(this.compare && !this.compare.equals(other.compare)); } public differentHighligh(other: Essence): boolean { if (Boolean(this.highlight) !== Boolean(other.highlight)) return true; return Boolean(this.highlight && !this.highlight.equals(other.highlight)); } public differentEffectiveFilter(other: Essence, highlightId: string = null, unfilterDimension: Dimension = null): boolean { var myEffectiveFilter = this.getEffectiveFilter(highlightId, unfilterDimension); var otherEffectiveFilter = other.getEffectiveFilter(highlightId, unfilterDimension); return !myEffectiveFilter.equals(otherEffectiveFilter); } public highlightOn(owner: string, measure?: string): boolean { var { highlight } = this; if (!highlight) return false; return highlight.owner === owner && (!measure || highlight.measure === measure); } public highlightOnDifferentMeasure(owner: string, measure: string): boolean { var { highlight } = this; if (!highlight) return false; return highlight.owner === owner && measure && highlight.measure !== measure; } public getSingleHighlightSet(): Set { var { highlight } = this; if (!highlight) return null; return highlight.delta.getSingleClauseSet(); } public getApplyForSort(sort: SortAction): ApplyAction { var sortOn = (<RefExpression>sort.expression).name; var sortMeasure = this.dataSource.getMeasure(sortOn); if (!sortMeasure) return null; return sortMeasure.toApplyAction(); } public getCommonSort(): SortAction { var splits = this.splits.toArray(); var commonSort: SortAction = null; for (var split of splits) { var sort = split.sortAction; if (commonSort) { if (!commonSort.equals(sort)) return null; } else { commonSort = sort; } } return commonSort; } public updateDataSource(newDataSource: DataSource): Essence { var { dataSource, visualizations } = this; if (dataSource.equals(newDataSource)) return this; // nothing to do if (dataSource.equalsWithoutMaxTime(newDataSource)) { // Updated maxTime var value = this.valueOf(); value.dataSource = newDataSource; return new Essence(value); } if (dataSource.name !== newDataSource.name) return Essence.fromDataSource(newDataSource, { dataSource: newDataSource, visualizations }); var value = this.valueOf(); value.dataSource = newDataSource; // Make sure that all the elements of state are still valid value.filter = value.filter.constrainToDimensions(newDataSource.dimensions, newDataSource.timeAttribute, dataSource.timeAttribute); value.splits = value.splits.constrainToDimensions(newDataSource.dimensions); value.selectedMeasures = constrainMeasures(value.selectedMeasures, newDataSource); value.pinnedDimensions = constrainDimensions(value.pinnedDimensions, newDataSource); if (value.colors && !newDataSource.getDimension(value.colors.dimension)) { value.colors = null; } var defaultSortMeasureName = newDataSource.defaultSortMeasure; if (!newDataSource.getMeasure(value.pinnedSort)) value.pinnedSort = defaultSortMeasureName; if (value.compare) { value.compare = value.compare.constrainToDimensions(newDataSource.dimensions, newDataSource.timeAttribute); } if (value.highlight) { value.highlight = value.highlight.constrainToDimensions(newDataSource.dimensions, newDataSource.timeAttribute); } return new Essence(value); } // Modification public changeFilter(filter: Filter, removeHighlight = false): Essence { var value = this.valueOf(); value.filter = filter; if (removeHighlight) { value.highlight = null; } var differentAttributes = filter.getDifferentAttributes(this.filter); value.splits = value.splits.removeBucketingFrom(differentAttributes); return (new Essence(value)).updateSplitsWithFilter(); } public changeTimezone(newTimezone: Timezone): Essence { var { timezone } = this; if (timezone === newTimezone) return this; var value = this.valueOf(); value.timezone = newTimezone; return new Essence(value); } public changeTimeSelection(check: Expression): Essence { var { filter } = this; var timeAttribute = this.getTimeAttribute(); return this.changeFilter(filter.setSelection(timeAttribute, check)); } public convertToSpecificFilter(): Essence { var { dataSource, filter, timezone } = this; if (!filter.isRelative()) return this; var maxTime = dataSource.getMaxTimeDate(); return this.changeFilter(filter.getSpecificFilter(new Date(), maxTime, timezone)); } public changeSplits(splits: Splits, strategy: VisStrategy): Essence { var { visualizations, dataSource, visualization, visResolve, colors } = this; splits = splits.updateWithFilter(this.getEffectiveFilter(), dataSource.dimensions); // If in manual mode stay there, keep the vis regardless of suggested strategy if (visResolve.isManual()) { strategy = VisStrategy.KeepAlways; } if (strategy !== VisStrategy.KeepAlways) { var visAndResolve = Essence.getBestVisualization(visualizations, dataSource, splits, colors, (strategy === VisStrategy.FairGame ? null : visualization)); visualization = visAndResolve.visualization; } var value = this.valueOf(); value.splits = splits; value.visualization = visualization; if (value.highlight) { value.filter = value.highlight.applyToFilter(value.filter); value.highlight = null; } return new Essence(value); } public changeSplit(splitCombine: SplitCombine, strategy: VisStrategy): Essence { return this.changeSplits(Splits.fromSplitCombine(splitCombine), strategy); } public addSplit(split: SplitCombine, strategy: VisStrategy): Essence { var { splits } = this; return this.changeSplits(splits.addSplit(split), strategy); } public removeSplit(split: SplitCombine, strategy: VisStrategy): Essence { var { splits } = this; return this.changeSplits(splits.removeSplit(split), strategy); } public updateSplitsWithFilter(): Essence { var value = this.valueOf(); value.splits = value.splits.updateWithFilter(this.getEffectiveFilter(), this.dataSource.dimensions); return new Essence(value); } public changeColors(colors: Colors): Essence { var value = this.valueOf(); value.colors = colors; return new Essence(value); } public changeVisualization(visualization: Manifest): Essence { var value = this.valueOf(); value.visualization = visualization; return new Essence(value); } public pin(dimension: Dimension): Essence { var value = this.valueOf(); value.pinnedDimensions = value.pinnedDimensions.add(dimension.name); return new Essence(value); } public unpin(dimension: Dimension): Essence { var value = this.valueOf(); value.pinnedDimensions = value.pinnedDimensions.remove(dimension.name); return new Essence(value); } public getPinnedSortMeasure(): Measure { return this.dataSource.getMeasure(this.pinnedSort); } public changePinnedSortMeasure(measure: Measure): Essence { var value = this.valueOf(); value.pinnedSort = measure.name; return new Essence(value); } public toggleMultiMeasureMode(): Essence { const { dataSource, multiMeasureMode, selectedMeasures, singleMeasure } = this; var value = this.valueOf(); value.multiMeasureMode = !multiMeasureMode; if (multiMeasureMode) { // Ensure that the singleMeasure is in the selectedMeasures if (selectedMeasures.size && !selectedMeasures.has(singleMeasure)) { value.singleMeasure = selectedMeasures.first(); } } else { value.selectedMeasures = addToSetInOrder(dataSource.measures.map(m => m.name), value.selectedMeasures, singleMeasure); } return new Essence(value); } public changeSingleMeasure(measure: Measure): Essence { if (measure.name === this.singleMeasure) return this; var value = this.valueOf(); value.singleMeasure = measure.name; value.pinnedSort = measure.name; return new Essence(value); } public toggleSelectedMeasure(measure: Measure): Essence { var dataSource = this.dataSource; var value = this.valueOf(); var selectedMeasures = value.selectedMeasures; var measureName = measure.name; if (selectedMeasures.has(measureName)) { value.selectedMeasures = selectedMeasures.delete(measureName); } else { value.selectedMeasures = addToSetInOrder(dataSource.measures.map(m => m.name), selectedMeasures, measureName); } return new Essence(value); } public toggleEffectiveMeasure(measure: Measure): Essence { if (this.getEffectiveMultiMeasureMode()) { return this.toggleSelectedMeasure(measure); } else { return this.changeSingleMeasure(measure); } } public acceptHighlight(): Essence { var { highlight } = this; if (!highlight) return this; return this.changeFilter(highlight.applyToFilter(this.filter), true); } public changeHighlight(owner: string, measure: string, delta: Filter): Essence { var { highlight } = this; // If there is already a highlight from someone else accept it var value: EssenceValue; if (highlight && highlight.owner !== owner) { value = this.changeFilter(highlight.applyToFilter(this.filter)).valueOf(); } else { value = this.valueOf(); } value.highlight = new Highlight({ owner, delta, measure }); return new Essence(value); } public dropHighlight(): Essence { var value = this.valueOf(); value.highlight = null; return new Essence(value); } } check = Essence;