/* * 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 * */ 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(); /** * key: dimension * value: {position: [], rotation, } */ private _axesLayout: Dictionary = {}; /** * 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).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;