From 23220538a5b3584365116950f47e79af1aaa03dc Mon Sep 17 00:00:00 2001 From: liihuu Date: Mon, 18 Mar 2024 03:45:56 +0800 Subject: [PATCH] feat: `candle.area` support last data point mark --- src/common/Animation.ts | 93 ++++++++++++++++++++++++++++++++++++++ src/common/Styles.ts | 22 ++++++++- src/component/Figure.ts | 14 +++++- src/view/CandleAreaView.ts | 86 +++++++++++++++++++++++++++++++---- 4 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/common/Animation.ts diff --git a/src/common/Animation.ts b/src/common/Animation.ts new file mode 100644 index 000000000..f32a28cb6 --- /dev/null +++ b/src/common/Animation.ts @@ -0,0 +1,93 @@ +/** + * Licensed 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 type Nullable from './Nullable' +import { requestAnimationFrame } from './utils/compatible' +import { merge } from './utils/typeChecks' + +type AnimationDoFrameCallback = (frameTime: number) => void + +interface AnimationOptions { + duration: number + iterationCount: number +} + +export default class Animation { + private readonly _options = { duration: 500, iterationCount: 1 } + + private _doFrameCallback: Nullable + + private _currentIterationCount = 0 + private _running = false + + private _time = 0 + + constructor (options?: Partial) { + merge(this._options, options) + } + + _getTime (): number { + return new Date().getTime() + } + + _loop (): void { + this._running = true + const step: (() => void) = () => { + if (this._running) { + const time = this._getTime() + const diffTime = time - this._time + if (diffTime < this._options.duration) { + this._doFrameCallback?.(diffTime) + requestAnimationFrame(step) + } else { + this.stop() + this._currentIterationCount++ + if (this._currentIterationCount < this._options.iterationCount) { + this.start() + } + } + } + } + requestAnimationFrame(step) + } + + doFrame (callback: AnimationDoFrameCallback): this { + this._doFrameCallback = callback + return this + } + + setDuration (duration: number): this { + this._options.duration = duration + return this + } + + setIterationCount (iterationCount: number): this { + this._options.iterationCount = iterationCount + return this + } + + start (): void { + if (!this._running) { + this._time = new Date().getTime() + this._loop() + } + } + + stop (): void { + if (this._running) { + this._doFrameCallback?.(this._options.duration) + } + this._running = false + } +} diff --git a/src/common/Styles.ts b/src/common/Styles.ts index 077da825f..cb5ae680b 100644 --- a/src/common/Styles.ts +++ b/src/common/Styles.ts @@ -168,12 +168,23 @@ export interface TooltipStyle { icons: TooltipIconStyle[] } +export interface CandleAreaPointStyle { + show: boolean + color: string + radius: number + rippleColor: string + rippleRadius: number + animation: boolean + animationDuration: number +} + export interface CandleAreaStyle { lineSize: number lineColor: string value: string smooth: boolean backgroundColor: string | GradientColor[] + point: CandleAreaPointStyle } export interface CandleHighLowPriceMarkStyle { @@ -437,7 +448,16 @@ function getDefaultCandleStyle (): CandleStyle { }, { offset: 1, color: getAlphaBlue(0.2) - }] + }], + point: { + show: true, + color: blue, + radius: 4, + rippleColor: getAlphaBlue(0.3), + rippleRadius: 8, + animation: true, + animationDuration: 1000 + } }, priceMark: { show: true, diff --git a/src/component/Figure.ts b/src/component/Figure.ts index 645f27acf..7514f0010 100644 --- a/src/component/Figure.ts +++ b/src/component/Figure.ts @@ -36,8 +36,8 @@ export type FigureInnerConstructor = new (figure: FigureCreate export type FigureConstructor = new (figure: FigureCreate) => ({ draw: (ctx: CanvasRenderingContext2D) => void }) export default abstract class FigureImp extends Eventful implements Omit, 'name' | 'draw' | 'checkEventOn'> { - readonly attrs: A - readonly styles: S + attrs: A + styles: S constructor (figure: FigureCreate) { super() @@ -49,6 +49,16 @@ export default abstract class FigureImp extends Eventful imple return this.checkEventOnImp(event, this.attrs, this.styles) } + setAttrs (attrs: A): this { + this.attrs = attrs + return this + } + + setStyles (styles: S): this { + this.styles = styles + return this + } + draw (ctx: CanvasRenderingContext2D): void { this.drawImp(ctx, this.attrs, this.styles) } diff --git a/src/view/CandleAreaView.ts b/src/view/CandleAreaView.ts index 94d15b26e..4eed1ddb5 100644 --- a/src/view/CandleAreaView.ts +++ b/src/view/CandleAreaView.ts @@ -15,29 +15,45 @@ import type Coordinate from '../common/Coordinate' import type VisibleData from '../common/VisibleData' import type BarSpace from '../common/BarSpace' -import { type GradientColor } from '../common/Styles' +import { type GradientColor, type PolygonStyle } from '../common/Styles' +import Animation from '../common/Animation' +import { isNumber, isArray, isValid } from '../common/utils/typeChecks' +import { UpdateLevel } from '../common/Updater' import ChildrenView from './ChildrenView' -import { isNumber, isArray } from '../common/utils/typeChecks' - import { lineTo } from '../extension/figure/line' +import type Figure from '../component/Figure' +import type Nullable from '../common/Nullable' +import { type CircleAttrs } from '../extension/figure/circle' export default class CandleAreaView extends ChildrenView { + private _figure: Nullable>> = null + private _animationFrameTime = 0 + + private readonly _animation = new Animation({ iterationCount: Infinity }).doFrame((time) => { + this._animationFrameTime = time + const pane = this.getWidget().getPane() + pane.getChart().updatePane(UpdateLevel.Main, pane.getId()) + }) + override drawImp (ctx: CanvasRenderingContext2D): void { const widget = this.getWidget() const pane = widget.getPane() const chart = pane.getChart() + const dataList = chart.getDataList() + const lastDataIndex = dataList.length - 1 const bounding = widget.getBounding() const yAxis = pane.getAxisComponent() - const candleAreaStyles = chart.getStyles().candle.area + const styles = chart.getStyles().candle.area const coordinates: Coordinate[] = [] let minY = Number.MAX_SAFE_INTEGER let areaStartX: number = 0 + let indicatePointCoordinate: Nullable = null this.eachChildren((data: VisibleData, _: BarSpace, i: number) => { const { data: kLineData, x } = data // const { halfGapBar } = barSpace - const value = kLineData?.[candleAreaStyles.value] + const value = kLineData?.[styles.value] if (isNumber(value)) { const y = yAxis.convertToPixel(value) if (i === 0) { @@ -45,6 +61,9 @@ export default class CandleAreaView extends ChildrenView { } coordinates.push({ x, y }) minY = Math.min(minY, y) + if (data.dataIndex === lastDataIndex) { + indicatePointCoordinate = { x, y } + } } }) @@ -53,15 +72,15 @@ export default class CandleAreaView extends ChildrenView { name: 'line', attrs: { coordinates }, styles: { - color: candleAreaStyles.lineColor, - size: candleAreaStyles.lineSize, - smooth: candleAreaStyles.smooth + color: styles.lineColor, + size: styles.lineSize, + smooth: styles.smooth } } )?.draw(ctx) // render area - const backgroundColor = candleAreaStyles.backgroundColor + const backgroundColor = styles.backgroundColor let color: string | CanvasGradient if (isArray(backgroundColor)) { const gradient = ctx.createLinearGradient(0, bounding.height, 0, minY) @@ -79,10 +98,57 @@ export default class CandleAreaView extends ChildrenView { ctx.beginPath() ctx.moveTo(areaStartX, bounding.height) ctx.lineTo(coordinates[0].x, coordinates[0].y) - lineTo(ctx, coordinates, candleAreaStyles.smooth) + lineTo(ctx, coordinates, styles.smooth) ctx.lineTo(coordinates[coordinates.length - 1].x, bounding.height) ctx.closePath() ctx.fill() } + + const pointStyles = styles.point + if (pointStyles.show && isValid(indicatePointCoordinate)) { + this.createFigure({ + name: 'circle', + attrs: { + x: indicatePointCoordinate!.x, + y: indicatePointCoordinate!.y, + r: pointStyles.radius + }, + styles: { + style: 'fill', + color: pointStyles.color + } + })?.draw(ctx) + let rippleRadius = pointStyles.rippleRadius + if (pointStyles.animation) { + rippleRadius = pointStyles.radius + this._animationFrameTime / pointStyles.animationDuration * (pointStyles.rippleRadius - pointStyles.radius) + this._animation.setDuration(pointStyles.animationDuration).start() + } + if (this._figure === null) { + this._figure = this.createFigure({ + name: 'circle', + attrs: { + x: indicatePointCoordinate!.x, + y: indicatePointCoordinate!.y, + r: pointStyles.rippleRadius + }, + styles: { + style: 'fill', + color: pointStyles.rippleColor + } + }) + } else { + this._figure.setAttrs({ + x: indicatePointCoordinate!.x, + y: indicatePointCoordinate!.y, + r: rippleRadius + }) + } + this._figure?.draw(ctx) + if (pointStyles.animation) { + this._animation.setDuration(pointStyles.animationDuration).start() + } + } else { + this._animation.stop() + } } }