Spaces:
Running
Running
| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| /** | |
| * Parallel Coordinates | |
| * <https://en.wikipedia.org/wiki/Parallel_coordinates> | |
| */ | |
| import * as zrUtil from 'zrender/src/core/util'; | |
| import * as matrix from 'zrender/src/core/matrix'; | |
| import * as layoutUtil from '../../util/layout'; | |
| import * as axisHelper from '../../coord/axisHelper'; | |
| import ParallelAxis from './ParallelAxis'; | |
| import * as graphic from '../../util/graphic'; | |
| import * as numberUtil from '../../util/number'; | |
| import sliderMove from '../../component/helper/sliderMove'; | |
| import ParallelModel, { ParallelLayoutDirection } from './ParallelModel'; | |
| import GlobalModel from '../../model/Global'; | |
| import ExtensionAPI from '../../core/ExtensionAPI'; | |
| import { Dictionary, DimensionName, ScaleDataValue } from '../../util/types'; | |
| import { CoordinateSystem, CoordinateSystemMaster } from '../CoordinateSystem'; | |
| import ParallelAxisModel, { ParallelActiveState } from './AxisModel'; | |
| import SeriesData from '../../data/SeriesData'; | |
| import { AxisBaseModel } from '../AxisBaseModel'; | |
| import { CategoryAxisBaseOption } from '../axisCommonTypes'; | |
| const each = zrUtil.each; | |
| const mathMin = Math.min; | |
| const mathMax = Math.max; | |
| const mathFloor = Math.floor; | |
| const mathCeil = Math.ceil; | |
| const round = numberUtil.round; | |
| const PI = Math.PI; | |
| interface ParallelCoordinateSystemLayoutInfo { | |
| layout: ParallelLayoutDirection; | |
| pixelDimIndex: number; | |
| layoutBase: number; | |
| layoutLength: number; | |
| axisBase: number; | |
| axisLength: number; | |
| axisExpandable: boolean; | |
| axisExpandWidth: number; | |
| axisCollapseWidth: number; | |
| axisExpandWindow: number[]; | |
| axisCount: number; | |
| winInnerIndices: number[]; | |
| axisExpandWindow0Pos: number; | |
| } | |
| export interface ParallelAxisLayoutInfo { | |
| position: number[]; | |
| rotation: number; | |
| transform: matrix.MatrixArray; | |
| axisNameAvailableWidth: number; | |
| axisLabelShow: boolean; | |
| nameTruncateMaxWidth: number; | |
| tickDirection: -1 | 1; | |
| labelDirection: -1 | 1; | |
| } | |
| type SlidedAxisExpandBehavior = 'none' | 'slide' | 'jump'; | |
| class Parallel implements CoordinateSystemMaster, CoordinateSystem { | |
| readonly type = 'parallel'; | |
| /** | |
| * key: dimension | |
| */ | |
| private _axesMap = zrUtil.createHashMap<ParallelAxis>(); | |
| /** | |
| * key: dimension | |
| * value: {position: [], rotation, } | |
| */ | |
| private _axesLayout: Dictionary<ParallelAxisLayoutInfo> = {}; | |
| /** | |
| * Always follow axis order. | |
| */ | |
| readonly dimensions: ParallelModel['dimensions']; | |
| private _rect: graphic.BoundingRect; | |
| private _model: ParallelModel; | |
| // Inject | |
| name: string; | |
| model: ParallelModel; | |
| constructor(parallelModel: ParallelModel, ecModel: GlobalModel, api: ExtensionAPI) { | |
| this.dimensions = parallelModel.dimensions; | |
| this._model = parallelModel; | |
| this._init(parallelModel, ecModel, api); | |
| } | |
| private _init(parallelModel: ParallelModel, ecModel: GlobalModel, api: ExtensionAPI): void { | |
| const dimensions = parallelModel.dimensions; | |
| const parallelAxisIndex = parallelModel.parallelAxisIndex; | |
| each(dimensions, function (dim, idx) { | |
| const axisIndex = parallelAxisIndex[idx]; | |
| const axisModel = ecModel.getComponent('parallelAxis', axisIndex) as ParallelAxisModel; | |
| const axis = this._axesMap.set(dim, new ParallelAxis( | |
| dim, | |
| axisHelper.createScaleByModel(axisModel), | |
| [0, 0], | |
| axisModel.get('type'), | |
| axisIndex | |
| )); | |
| const isCategory = axis.type === 'category'; | |
| axis.onBand = isCategory | |
| && (axisModel as AxisBaseModel<CategoryAxisBaseOption>).get('boundaryGap'); | |
| axis.inverse = axisModel.get('inverse'); | |
| // Injection | |
| axisModel.axis = axis; | |
| axis.model = axisModel; | |
| axis.coordinateSystem = axisModel.coordinateSystem = this; | |
| }, this); | |
| } | |
| /** | |
| * Update axis scale after data processed | |
| */ | |
| update(ecModel: GlobalModel, api: ExtensionAPI): void { | |
| this._updateAxesFromSeries(this._model, ecModel); | |
| } | |
| containPoint(point: number[]): boolean { | |
| const layoutInfo = this._makeLayoutInfo(); | |
| const axisBase = layoutInfo.axisBase; | |
| const layoutBase = layoutInfo.layoutBase; | |
| const pixelDimIndex = layoutInfo.pixelDimIndex; | |
| const pAxis = point[1 - pixelDimIndex]; | |
| const pLayout = point[pixelDimIndex]; | |
| return pAxis >= axisBase | |
| && pAxis <= axisBase + layoutInfo.axisLength | |
| && pLayout >= layoutBase | |
| && pLayout <= layoutBase + layoutInfo.layoutLength; | |
| } | |
| getModel(): ParallelModel { | |
| return this._model; | |
| } | |
| /** | |
| * Update properties from series | |
| */ | |
| private _updateAxesFromSeries(parallelModel: ParallelModel, ecModel: GlobalModel): void { | |
| ecModel.eachSeries(function (seriesModel) { | |
| if (!parallelModel.contains(seriesModel, ecModel)) { | |
| return; | |
| } | |
| const data = seriesModel.getData(); | |
| each(this.dimensions, function (dim) { | |
| const axis = this._axesMap.get(dim); | |
| axis.scale.unionExtentFromData(data, data.mapDimension(dim)); | |
| axisHelper.niceScaleExtent(axis.scale, axis.model); | |
| }, this); | |
| }, this); | |
| } | |
| /** | |
| * Resize the parallel coordinate system. | |
| */ | |
| resize(parallelModel: ParallelModel, api: ExtensionAPI): void { | |
| this._rect = layoutUtil.getLayoutRect( | |
| parallelModel.getBoxLayoutParams(), | |
| { | |
| width: api.getWidth(), | |
| height: api.getHeight() | |
| } | |
| ); | |
| this._layoutAxes(); | |
| } | |
| getRect(): graphic.BoundingRect { | |
| return this._rect; | |
| } | |
| private _makeLayoutInfo(): ParallelCoordinateSystemLayoutInfo { | |
| const parallelModel = this._model; | |
| const rect = this._rect; | |
| const xy = ['x', 'y'] as const; | |
| const wh = ['width', 'height'] as const; | |
| const layout = parallelModel.get('layout'); | |
| const pixelDimIndex = layout === 'horizontal' ? 0 : 1; | |
| const layoutLength = rect[wh[pixelDimIndex]]; | |
| const layoutExtent = [0, layoutLength]; | |
| const axisCount = this.dimensions.length; | |
| const axisExpandWidth = restrict(parallelModel.get('axisExpandWidth'), layoutExtent); | |
| const axisExpandCount = restrict(parallelModel.get('axisExpandCount') || 0, [0, axisCount]); | |
| const axisExpandable = parallelModel.get('axisExpandable') | |
| && axisCount > 3 | |
| && axisCount > axisExpandCount | |
| && axisExpandCount > 1 | |
| && axisExpandWidth > 0 | |
| && layoutLength > 0; | |
| // `axisExpandWindow` is According to the coordinates of [0, axisExpandLength], | |
| // for sake of consider the case that axisCollapseWidth is 0 (when screen is narrow), | |
| // where collapsed axes should be overlapped. | |
| let axisExpandWindow = parallelModel.get('axisExpandWindow'); | |
| let winSize; | |
| if (!axisExpandWindow) { | |
| winSize = restrict(axisExpandWidth * (axisExpandCount - 1), layoutExtent); | |
| const axisExpandCenter = parallelModel.get('axisExpandCenter') || mathFloor(axisCount / 2); | |
| axisExpandWindow = [axisExpandWidth * axisExpandCenter - winSize / 2]; | |
| axisExpandWindow[1] = axisExpandWindow[0] + winSize; | |
| } | |
| else { | |
| winSize = restrict(axisExpandWindow[1] - axisExpandWindow[0], layoutExtent); | |
| axisExpandWindow[1] = axisExpandWindow[0] + winSize; | |
| } | |
| let axisCollapseWidth = (layoutLength - winSize) / (axisCount - axisExpandCount); | |
| // Avoid axisCollapseWidth is too small. | |
| axisCollapseWidth < 3 && (axisCollapseWidth = 0); | |
| // Find the first and last indices > ewin[0] and < ewin[1]. | |
| const winInnerIndices = [ | |
| mathFloor(round(axisExpandWindow[0] / axisExpandWidth, 1)) + 1, | |
| mathCeil(round(axisExpandWindow[1] / axisExpandWidth, 1)) - 1 | |
| ]; | |
| // Pos in ec coordinates. | |
| const axisExpandWindow0Pos = axisCollapseWidth / axisExpandWidth * axisExpandWindow[0]; | |
| return { | |
| layout: layout, | |
| pixelDimIndex: pixelDimIndex, | |
| layoutBase: rect[xy[pixelDimIndex]], | |
| layoutLength: layoutLength, | |
| axisBase: rect[xy[1 - pixelDimIndex]], | |
| axisLength: rect[wh[1 - pixelDimIndex]], | |
| axisExpandable: axisExpandable, | |
| axisExpandWidth: axisExpandWidth, | |
| axisCollapseWidth: axisCollapseWidth, | |
| axisExpandWindow: axisExpandWindow, | |
| axisCount: axisCount, | |
| winInnerIndices: winInnerIndices, | |
| axisExpandWindow0Pos: axisExpandWindow0Pos | |
| }; | |
| } | |
| private _layoutAxes(): void { | |
| const rect = this._rect; | |
| const axes = this._axesMap; | |
| const dimensions = this.dimensions; | |
| const layoutInfo = this._makeLayoutInfo(); | |
| const layout = layoutInfo.layout; | |
| axes.each(function (axis) { | |
| const axisExtent = [0, layoutInfo.axisLength]; | |
| const idx = axis.inverse ? 1 : 0; | |
| axis.setExtent(axisExtent[idx], axisExtent[1 - idx]); | |
| }); | |
| each(dimensions, function (dim, idx) { | |
| const posInfo = (layoutInfo.axisExpandable | |
| ? layoutAxisWithExpand : layoutAxisWithoutExpand | |
| )(idx, layoutInfo); | |
| const positionTable = { | |
| horizontal: { | |
| x: posInfo.position, | |
| y: layoutInfo.axisLength | |
| }, | |
| vertical: { | |
| x: 0, | |
| y: posInfo.position | |
| } | |
| }; | |
| const rotationTable = { | |
| horizontal: PI / 2, | |
| vertical: 0 | |
| }; | |
| const position = [ | |
| positionTable[layout].x + rect.x, | |
| positionTable[layout].y + rect.y | |
| ]; | |
| const rotation = rotationTable[layout]; | |
| const transform = matrix.create(); | |
| matrix.rotate(transform, transform, rotation); | |
| matrix.translate(transform, transform, position); | |
| // TODO | |
| // tick layout info | |
| // TODO | |
| // update dimensions info based on axis order. | |
| this._axesLayout[dim] = { | |
| position: position, | |
| rotation: rotation, | |
| transform: transform, | |
| axisNameAvailableWidth: posInfo.axisNameAvailableWidth, | |
| axisLabelShow: posInfo.axisLabelShow, | |
| nameTruncateMaxWidth: posInfo.nameTruncateMaxWidth, | |
| tickDirection: 1, | |
| labelDirection: 1 | |
| }; | |
| }, this); | |
| } | |
| /** | |
| * Get axis by dim. | |
| */ | |
| getAxis(dim: DimensionName): ParallelAxis { | |
| return this._axesMap.get(dim); | |
| } | |
| /** | |
| * Convert a dim value of a single item of series data to Point. | |
| */ | |
| dataToPoint(value: ScaleDataValue, dim: DimensionName): number[] { | |
| return this.axisCoordToPoint( | |
| this._axesMap.get(dim).dataToCoord(value), | |
| dim | |
| ); | |
| } | |
| /** | |
| * Travel data for one time, get activeState of each data item. | |
| * @param start the start dataIndex that travel from. | |
| * @param end the next dataIndex of the last dataIndex will be travel. | |
| */ | |
| eachActiveState( | |
| data: SeriesData, | |
| callback: (activeState: ParallelActiveState, dataIndex: number) => void, | |
| start?: number, | |
| end?: number | |
| ): void { | |
| start == null && (start = 0); | |
| end == null && (end = data.count()); | |
| const axesMap = this._axesMap; | |
| const dimensions = this.dimensions; | |
| const dataDimensions = [] as DimensionName[]; | |
| const axisModels = [] as ParallelAxisModel[]; | |
| zrUtil.each(dimensions, function (axisDim) { | |
| dataDimensions.push(data.mapDimension(axisDim)); | |
| axisModels.push(axesMap.get(axisDim).model); | |
| }); | |
| const hasActiveSet = this.hasAxisBrushed(); | |
| for (let dataIndex = start; dataIndex < end; dataIndex++) { | |
| let activeState: ParallelActiveState; | |
| if (!hasActiveSet) { | |
| activeState = 'normal'; | |
| } | |
| else { | |
| activeState = 'active'; | |
| const values = data.getValues(dataDimensions, dataIndex); | |
| for (let j = 0, lenj = dimensions.length; j < lenj; j++) { | |
| const state = axisModels[j].getActiveState(values[j]); | |
| if (state === 'inactive') { | |
| activeState = 'inactive'; | |
| break; | |
| } | |
| } | |
| } | |
| callback(activeState, dataIndex); | |
| } | |
| } | |
| /** | |
| * Whether has any activeSet. | |
| */ | |
| hasAxisBrushed(): boolean { | |
| const dimensions = this.dimensions; | |
| const axesMap = this._axesMap; | |
| let hasActiveSet = false; | |
| for (let j = 0, lenj = dimensions.length; j < lenj; j++) { | |
| if (axesMap.get(dimensions[j]).model.getActiveState() !== 'normal') { | |
| hasActiveSet = true; | |
| } | |
| } | |
| return hasActiveSet; | |
| } | |
| /** | |
| * Convert coords of each axis to Point. | |
| * Return point. For example: [10, 20] | |
| */ | |
| axisCoordToPoint(coord: number, dim: DimensionName): number[] { | |
| const axisLayout = this._axesLayout[dim]; | |
| return graphic.applyTransform([coord, 0], axisLayout.transform); | |
| } | |
| /** | |
| * Get axis layout. | |
| */ | |
| getAxisLayout(dim: DimensionName): ParallelAxisLayoutInfo { | |
| return zrUtil.clone(this._axesLayout[dim]); | |
| } | |
| /** | |
| * @return {Object} {axisExpandWindow, delta, behavior: 'jump' | 'slide' | 'none'}. | |
| */ | |
| getSlidedAxisExpandWindow(point: number[]): { | |
| axisExpandWindow: number[], | |
| behavior: SlidedAxisExpandBehavior | |
| } { | |
| const layoutInfo = this._makeLayoutInfo(); | |
| const pixelDimIndex = layoutInfo.pixelDimIndex; | |
| let axisExpandWindow = layoutInfo.axisExpandWindow.slice(); | |
| const winSize = axisExpandWindow[1] - axisExpandWindow[0]; | |
| const extent = [0, layoutInfo.axisExpandWidth * (layoutInfo.axisCount - 1)]; | |
| // Out of the area of coordinate system. | |
| if (!this.containPoint(point)) { | |
| return {behavior: 'none', axisExpandWindow: axisExpandWindow}; | |
| } | |
| // Convert the point from global to expand coordinates. | |
| const pointCoord = point[pixelDimIndex] - layoutInfo.layoutBase - layoutInfo.axisExpandWindow0Pos; | |
| // For dragging operation convenience, the window should not be | |
| // slided when mouse is the center area of the window. | |
| let delta; | |
| let behavior: SlidedAxisExpandBehavior = 'slide'; | |
| const axisCollapseWidth = layoutInfo.axisCollapseWidth; | |
| const triggerArea = this._model.get('axisExpandSlideTriggerArea'); | |
| // But consider touch device, jump is necessary. | |
| const useJump = triggerArea[0] != null; | |
| if (axisCollapseWidth) { | |
| if (useJump && axisCollapseWidth && pointCoord < winSize * triggerArea[0]) { | |
| behavior = 'jump'; | |
| delta = pointCoord - winSize * triggerArea[2]; | |
| } | |
| else if (useJump && axisCollapseWidth && pointCoord > winSize * (1 - triggerArea[0])) { | |
| behavior = 'jump'; | |
| delta = pointCoord - winSize * (1 - triggerArea[2]); | |
| } | |
| else { | |
| (delta = pointCoord - winSize * triggerArea[1]) >= 0 | |
| && (delta = pointCoord - winSize * (1 - triggerArea[1])) <= 0 | |
| && (delta = 0); | |
| } | |
| delta *= layoutInfo.axisExpandWidth / axisCollapseWidth; | |
| delta | |
| ? sliderMove(delta, axisExpandWindow, extent, 'all') | |
| // Avoid nonsense triger on mousemove. | |
| : (behavior = 'none'); | |
| } | |
| // When screen is too narrow, make it visible and slidable, although it is hard to interact. | |
| else { | |
| const winSize2 = axisExpandWindow[1] - axisExpandWindow[0]; | |
| const pos = extent[1] * pointCoord / winSize2; | |
| axisExpandWindow = [mathMax(0, pos - winSize2 / 2)]; | |
| axisExpandWindow[1] = mathMin(extent[1], axisExpandWindow[0] + winSize2); | |
| axisExpandWindow[0] = axisExpandWindow[1] - winSize2; | |
| } | |
| return { | |
| axisExpandWindow: axisExpandWindow, | |
| behavior: behavior | |
| }; | |
| } | |
| // TODO | |
| // convertToPixel | |
| // convertFromPixel | |
| // Note: | |
| // (1) Consider Parallel, the return type of `convertToPixel` could be number[][] (Point[]). | |
| // (2) In parallel coord sys, how to make `convertFromPixel` make sense? | |
| // Perhaps convert a point based on "a rensent most axis" is more meaningful rather than based on all axes? | |
| } | |
| function restrict(len: number, extent: number[]): number { | |
| return mathMin(mathMax(len, extent[0]), extent[1]); | |
| } | |
| interface ParallelAxisLayoutPositionInfo { | |
| position: number; | |
| axisNameAvailableWidth: number; | |
| axisLabelShow: boolean; | |
| nameTruncateMaxWidth?: number; | |
| } | |
| function layoutAxisWithoutExpand( | |
| axisIndex: number, | |
| layoutInfo: ParallelCoordinateSystemLayoutInfo | |
| ): ParallelAxisLayoutPositionInfo { | |
| const step = layoutInfo.layoutLength / (layoutInfo.axisCount - 1); | |
| return { | |
| position: step * axisIndex, | |
| axisNameAvailableWidth: step, | |
| axisLabelShow: true | |
| }; | |
| } | |
| function layoutAxisWithExpand( | |
| axisIndex: number, | |
| layoutInfo: ParallelCoordinateSystemLayoutInfo | |
| ): ParallelAxisLayoutPositionInfo { | |
| const layoutLength = layoutInfo.layoutLength; | |
| const axisExpandWidth = layoutInfo.axisExpandWidth; | |
| const axisCount = layoutInfo.axisCount; | |
| const axisCollapseWidth = layoutInfo.axisCollapseWidth; | |
| const winInnerIndices = layoutInfo.winInnerIndices; | |
| let position; | |
| let axisNameAvailableWidth = axisCollapseWidth; | |
| let axisLabelShow = false; | |
| let nameTruncateMaxWidth; | |
| if (axisIndex < winInnerIndices[0]) { | |
| position = axisIndex * axisCollapseWidth; | |
| nameTruncateMaxWidth = axisCollapseWidth; | |
| } | |
| else if (axisIndex <= winInnerIndices[1]) { | |
| position = layoutInfo.axisExpandWindow0Pos | |
| + axisIndex * axisExpandWidth - layoutInfo.axisExpandWindow[0]; | |
| axisNameAvailableWidth = axisExpandWidth; | |
| axisLabelShow = true; | |
| } | |
| else { | |
| position = layoutLength - (axisCount - 1 - axisIndex) * axisCollapseWidth; | |
| nameTruncateMaxWidth = axisCollapseWidth; | |
| } | |
| return { | |
| position: position, | |
| axisNameAvailableWidth: axisNameAvailableWidth, | |
| axisLabelShow: axisLabelShow, | |
| nameTruncateMaxWidth: nameTruncateMaxWidth | |
| }; | |
| } | |
| export default Parallel; | |