/* * 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, selected: Dictionary ) { 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(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(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;