| /* | |
| * 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. | |
| */ | |
| import * as graphic from '../../util/graphic'; | |
| import { toggleHoverEmphasis } from '../../util/states'; | |
| import HeatmapLayer from './HeatmapLayer'; | |
| import * as zrUtil from 'zrender/src/core/util'; | |
| import ChartView from '../../view/Chart'; | |
| import HeatmapSeriesModel, { HeatmapDataItemOption } from './HeatmapSeries'; | |
| import type GlobalModel from '../../model/Global'; | |
| import type ExtensionAPI from '../../core/ExtensionAPI'; | |
| import type VisualMapModel from '../../component/visualMap/VisualMapModel'; | |
| import type PiecewiseModel from '../../component/visualMap/PiecewiseModel'; | |
| import type ContinuousModel from '../../component/visualMap/ContinuousModel'; | |
| import { CoordinateSystem, isCoordinateSystemType } from '../../coord/CoordinateSystem'; | |
| import { StageHandlerProgressParams, Dictionary, OptionDataValue } from '../../util/types'; | |
| import type Cartesian2D from '../../coord/cartesian/Cartesian2D'; | |
| import type Calendar from '../../coord/calendar/Calendar'; | |
| import { setLabelStyle, getLabelStatesModels } from '../../label/labelStyle'; | |
| import type Element from 'zrender/src/Element'; | |
| // Coord can be 'geo' 'bmap' 'amap' 'leaflet'... | |
| interface GeoLikeCoordSys extends CoordinateSystem { | |
| dimensions: ['lng', 'lat'] | |
| getViewRect(): graphic.BoundingRect | |
| } | |
| function getIsInPiecewiseRange( | |
| dataExtent: number[], | |
| pieceList: ReturnType<PiecewiseModel['getPieceList']>, | |
| selected: Dictionary<boolean> | |
| ) { | |
| const dataSpan = dataExtent[1] - dataExtent[0]; | |
| pieceList = zrUtil.map(pieceList, function (piece) { | |
| return { | |
| interval: [ | |
| (piece.interval[0] - dataExtent[0]) / dataSpan, | |
| (piece.interval[1] - dataExtent[0]) / dataSpan | |
| ] | |
| }; | |
| }); | |
| const len = pieceList.length; | |
| let lastIndex = 0; | |
| return function (val: number) { | |
| let i; | |
| // Try to find in the location of the last found | |
| for (i = lastIndex; i < len; i++) { | |
| const interval = pieceList[i].interval; | |
| if (interval[0] <= val && val <= interval[1]) { | |
| lastIndex = i; | |
| break; | |
| } | |
| } | |
| if (i === len) { // Not found, back interation | |
| for (i = lastIndex - 1; i >= 0; i--) { | |
| const interval = pieceList[i].interval; | |
| if (interval[0] <= val && val <= interval[1]) { | |
| lastIndex = i; | |
| break; | |
| } | |
| } | |
| } | |
| return i >= 0 && i < len && selected[i]; | |
| }; | |
| } | |
| function getIsInContinuousRange(dataExtent: number[], range: number[]) { | |
| const dataSpan = dataExtent[1] - dataExtent[0]; | |
| range = [ | |
| (range[0] - dataExtent[0]) / dataSpan, | |
| (range[1] - dataExtent[0]) / dataSpan | |
| ]; | |
| return function (val: number) { | |
| return val >= range[0] && val <= range[1]; | |
| }; | |
| } | |
| function isGeoCoordSys(coordSys: CoordinateSystem): coordSys is GeoLikeCoordSys { | |
| const dimensions = coordSys.dimensions; | |
| // Not use coordSys.type === 'geo' because coordSys maybe extended | |
| return dimensions[0] === 'lng' && dimensions[1] === 'lat'; | |
| } | |
| class HeatmapView extends ChartView { | |
| static readonly type = 'heatmap'; | |
| readonly type = HeatmapView.type; | |
| private _hmLayer: HeatmapLayer; | |
| private _progressiveEls: Element[]; | |
| render(seriesModel: HeatmapSeriesModel, ecModel: GlobalModel, api: ExtensionAPI) { | |
| let visualMapOfThisSeries; | |
| ecModel.eachComponent('visualMap', function (visualMap: VisualMapModel) { | |
| visualMap.eachTargetSeries(function (targetSeries) { | |
| if (targetSeries === seriesModel) { | |
| visualMapOfThisSeries = visualMap; | |
| } | |
| }); | |
| }); | |
| if (__DEV__) { | |
| if (!visualMapOfThisSeries) { | |
| throw new Error('Heatmap must use with visualMap'); | |
| } | |
| } | |
| // Clear previously rendered progressive elements. | |
| this._progressiveEls = null; | |
| this.group.removeAll(); | |
| const coordSys = seriesModel.coordinateSystem; | |
| if (coordSys.type === 'cartesian2d' || coordSys.type === 'calendar') { | |
| this._renderOnCartesianAndCalendar(seriesModel, api, 0, seriesModel.getData().count()); | |
| } | |
| else if (isGeoCoordSys(coordSys)) { | |
| this._renderOnGeo( | |
| coordSys, seriesModel, visualMapOfThisSeries, api | |
| ); | |
| } | |
| } | |
| incrementalPrepareRender(seriesModel: HeatmapSeriesModel, ecModel: GlobalModel, api: ExtensionAPI) { | |
| this.group.removeAll(); | |
| } | |
| incrementalRender( | |
| params: StageHandlerProgressParams, | |
| seriesModel: HeatmapSeriesModel, | |
| ecModel: GlobalModel, | |
| api: ExtensionAPI | |
| ) { | |
| const coordSys = seriesModel.coordinateSystem; | |
| if (coordSys) { | |
| // geo does not support incremental rendering? | |
| if (isGeoCoordSys(coordSys)) { | |
| this.render(seriesModel, ecModel, api); | |
| } | |
| else { | |
| this._progressiveEls = []; | |
| this._renderOnCartesianAndCalendar(seriesModel, api, params.start, params.end, true); | |
| } | |
| } | |
| } | |
| eachRendered(cb: (el: Element) => boolean | void) { | |
| graphic.traverseElements(this._progressiveEls || this.group, cb); | |
| } | |
| _renderOnCartesianAndCalendar( | |
| seriesModel: HeatmapSeriesModel, | |
| api: ExtensionAPI, | |
| start: number, | |
| end: number, | |
| incremental?: boolean | |
| ) { | |
| const coordSys = seriesModel.coordinateSystem as Cartesian2D | Calendar; | |
| const isCartesian2d = isCoordinateSystemType<Cartesian2D>(coordSys, 'cartesian2d'); | |
| let width; | |
| let height; | |
| let xAxisExtent; | |
| let yAxisExtent; | |
| if (isCartesian2d) { | |
| const xAxis = coordSys.getAxis('x'); | |
| const yAxis = coordSys.getAxis('y'); | |
| if (__DEV__) { | |
| if (!(xAxis.type === 'category' && yAxis.type === 'category')) { | |
| throw new Error('Heatmap on cartesian must have two category axes'); | |
| } | |
| if (!(xAxis.onBand && yAxis.onBand)) { | |
| throw new Error('Heatmap on cartesian must have two axes with boundaryGap true'); | |
| } | |
| } | |
| // add 0.5px to avoid the gaps | |
| width = xAxis.getBandWidth() + .5; | |
| height = yAxis.getBandWidth() + .5; | |
| xAxisExtent = xAxis.scale.getExtent(); | |
| yAxisExtent = yAxis.scale.getExtent(); | |
| } | |
| const group = this.group; | |
| const data = seriesModel.getData(); | |
| let emphasisStyle = seriesModel.getModel(['emphasis', 'itemStyle']).getItemStyle(); | |
| let blurStyle = seriesModel.getModel(['blur', 'itemStyle']).getItemStyle(); | |
| let selectStyle = seriesModel.getModel(['select', 'itemStyle']).getItemStyle(); | |
| let borderRadius = seriesModel.get(['itemStyle', 'borderRadius']); | |
| let labelStatesModels = getLabelStatesModels(seriesModel); | |
| const emphasisModel = seriesModel.getModel('emphasis'); | |
| let focus = emphasisModel.get('focus'); | |
| let blurScope = emphasisModel.get('blurScope'); | |
| let emphasisDisabled = emphasisModel.get('disabled'); | |
| const dataDims = isCartesian2d | |
| ? [ | |
| data.mapDimension('x'), | |
| data.mapDimension('y'), | |
| data.mapDimension('value') | |
| ] | |
| : [ | |
| data.mapDimension('time'), | |
| data.mapDimension('value') | |
| ]; | |
| for (let idx = start; idx < end; idx++) { | |
| let rect; | |
| const style = data.getItemVisual(idx, 'style'); | |
| if (isCartesian2d) { | |
| const dataDimX = data.get(dataDims[0], idx); | |
| const dataDimY = data.get(dataDims[1], idx); | |
| // Ignore empty data and out of extent data | |
| if (isNaN(data.get(dataDims[2], idx) as number) | |
| || isNaN(dataDimX as number) | |
| || isNaN(dataDimY as number) | |
| || dataDimX < xAxisExtent[0] | |
| || dataDimX > xAxisExtent[1] | |
| || dataDimY < yAxisExtent[0] | |
| || dataDimY > yAxisExtent[1] | |
| ) { | |
| continue; | |
| } | |
| const point = coordSys.dataToPoint([ | |
| dataDimX, | |
| dataDimY | |
| ]); | |
| rect = new graphic.Rect({ | |
| shape: { | |
| x: point[0] - width / 2, | |
| y: point[1] - height / 2, | |
| width, | |
| height | |
| }, | |
| style | |
| }); | |
| } | |
| else { | |
| // Ignore empty data | |
| if (isNaN(data.get(dataDims[1], idx) as number)) { | |
| continue; | |
| } | |
| rect = new graphic.Rect({ | |
| z2: 1, | |
| shape: coordSys.dataToRect([data.get(dataDims[0], idx)]).contentShape, | |
| style | |
| }); | |
| } | |
| // Optimization for large dataset | |
| if (data.hasItemOption) { | |
| const itemModel = data.getItemModel<HeatmapDataItemOption>(idx); | |
| const emphasisModel = itemModel.getModel('emphasis'); | |
| emphasisStyle = emphasisModel.getModel('itemStyle').getItemStyle(); | |
| blurStyle = itemModel.getModel(['blur', 'itemStyle']).getItemStyle(); | |
| selectStyle = itemModel.getModel(['select', 'itemStyle']).getItemStyle(); | |
| // Each item value struct in the data would be firstly | |
| // { | |
| // itemStyle: { borderRadius: [30, 30] }, | |
| // value: [2022, 02, 22] | |
| // } | |
| borderRadius = itemModel.get(['itemStyle', 'borderRadius']); | |
| focus = emphasisModel.get('focus'); | |
| blurScope = emphasisModel.get('blurScope'); | |
| emphasisDisabled = emphasisModel.get('disabled'); | |
| labelStatesModels = getLabelStatesModels(itemModel); | |
| } | |
| rect.shape.r = borderRadius; | |
| const rawValue = seriesModel.getRawValue(idx) as OptionDataValue[]; | |
| let defaultText = '-'; | |
| if (rawValue && rawValue[2] != null) { | |
| defaultText = rawValue[2] + ''; | |
| } | |
| setLabelStyle( | |
| rect, labelStatesModels, | |
| { | |
| labelFetcher: seriesModel, | |
| labelDataIndex: idx, | |
| defaultOpacity: style.opacity, | |
| defaultText: defaultText | |
| } | |
| ); | |
| rect.ensureState('emphasis').style = emphasisStyle; | |
| rect.ensureState('blur').style = blurStyle; | |
| rect.ensureState('select').style = selectStyle; | |
| toggleHoverEmphasis(rect, focus, blurScope, emphasisDisabled); | |
| rect.incremental = incremental; | |
| // PENDING | |
| if (incremental) { | |
| // Rect must use hover layer if it's incremental. | |
| rect.states.emphasis.hoverLayer = true; | |
| } | |
| group.add(rect); | |
| data.setItemGraphicEl(idx, rect); | |
| if (this._progressiveEls) { | |
| this._progressiveEls.push(rect); | |
| } | |
| } | |
| } | |
| _renderOnGeo( | |
| geo: GeoLikeCoordSys, | |
| seriesModel: HeatmapSeriesModel, | |
| visualMapModel: VisualMapModel, | |
| api: ExtensionAPI | |
| ) { | |
| const inRangeVisuals = visualMapModel.targetVisuals.inRange; | |
| const outOfRangeVisuals = visualMapModel.targetVisuals.outOfRange; | |
| // if (!visualMapping) { | |
| // throw new Error('Data range must have color visuals'); | |
| // } | |
| const data = seriesModel.getData(); | |
| const hmLayer = this._hmLayer || (this._hmLayer || new HeatmapLayer()); | |
| hmLayer.blurSize = seriesModel.get('blurSize'); | |
| hmLayer.pointSize = seriesModel.get('pointSize'); | |
| hmLayer.minOpacity = seriesModel.get('minOpacity'); | |
| hmLayer.maxOpacity = seriesModel.get('maxOpacity'); | |
| const rect = geo.getViewRect().clone(); | |
| const roamTransform = geo.getRoamTransform(); | |
| rect.applyTransform(roamTransform); | |
| // Clamp on viewport | |
| const x = Math.max(rect.x, 0); | |
| const y = Math.max(rect.y, 0); | |
| const x2 = Math.min(rect.width + rect.x, api.getWidth()); | |
| const y2 = Math.min(rect.height + rect.y, api.getHeight()); | |
| const width = x2 - x; | |
| const height = y2 - y; | |
| const dims = [ | |
| data.mapDimension('lng'), | |
| data.mapDimension('lat'), | |
| data.mapDimension('value') | |
| ]; | |
| const points = data.mapArray(dims, function (lng: number, lat: number, value: number) { | |
| const pt = geo.dataToPoint([lng, lat]); | |
| pt[0] -= x; | |
| pt[1] -= y; | |
| pt.push(value); | |
| return pt; | |
| }); | |
| const dataExtent = visualMapModel.getExtent(); | |
| const isInRange = visualMapModel.type === 'visualMap.continuous' | |
| ? getIsInContinuousRange(dataExtent, (visualMapModel as ContinuousModel).option.range) | |
| : getIsInPiecewiseRange( | |
| dataExtent, | |
| (visualMapModel as PiecewiseModel).getPieceList(), | |
| (visualMapModel as PiecewiseModel).option.selected | |
| ); | |
| hmLayer.update( | |
| points, width, height, | |
| inRangeVisuals.color.getNormalizer(), | |
| { | |
| inRange: inRangeVisuals.color.getColorMapper(), | |
| outOfRange: outOfRangeVisuals.color.getColorMapper() | |
| }, | |
| isInRange | |
| ); | |
| const img = new graphic.Image({ | |
| style: { | |
| width: width, | |
| height: height, | |
| x: x, | |
| y: y, | |
| image: hmLayer.canvas | |
| }, | |
| silent: true | |
| }); | |
| this.group.add(img); | |
| } | |
| } | |
| export default HeatmapView; |