Загрузка данных
import {
AutoscaleInfo,
Coordinate,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clamp,
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPointerPoint as getPointerPointFromEvent,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { VolumeProfilePaneView } from './paneView';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';
type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'start' | 'end' | null;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface VolumeProfileParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
}
interface SeriesCandleData {
time: Time;
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
customValues?: {
volume?: number;
};
}
interface VolumeProfileState {
hidden: boolean;
isActive: boolean;
mode: VolumeProfileMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
interface VolumeProfileDataRow {
priceLow: number;
priceHigh: number;
buyVolume: number;
sellVolume: number;
totalVolume: number;
}
interface VolumeProfileGeometry extends Bounds {
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
interface VolumeProfileRenderRow {
top: number;
height: number;
buyWidth: number;
sellWidth: number;
}
export interface VolumeProfileRenderData extends VolumeProfileGeometry {
rows: VolumeProfileRenderRow[];
pocY: number | null;
showHandles: boolean;
}
const PROFILE_ROW_COUNT = 24;
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const { colors } = getThemeStore();
export class VolumeProfile implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private container: HTMLElement;
private removeSelf?: () => void;
private requestUpdate: (() => void) | null = null;
private isBound = false;
private subscriptions = new Subscription();
private hidden = false;
private isActive = false;
private mode: VolumeProfileMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private profileRows: VolumeProfileDataRow[] = [];
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: VolumeProfileGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: VolumeProfilePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor(chart: IChartApi, series: SeriesApi, { container, formatObservable, removeSelf }: VolumeProfileParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.removeSelf = removeSelf;
this.paneView = new VolumeProfilePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.series.detachPrimitive(this);
this.requestUpdate = null;
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this);
this.calculateProfile();
this.render();
}
public getState(): VolumeProfileState {
return {
hidden: this.hidden,
isActive: this.isActive,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(state: unknown): void {
const nextState = state as Partial<VolumeProfileState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.isActive = typeof nextState.isActive === 'boolean' ? nextState.isActive : this.isActive;
this.mode = nextState.mode ?? this.mode;
this.startAnchor = nextState.startAnchor ?? this.startAnchor;
this.endAnchor = nextState.endAnchor ?? this.endAnchor;
this.calculateProfile();
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public autoscaleInfo(start: Logical, end: Logical): AutoscaleInfo | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startLogical = this.getLogicalIndex(this.startAnchor.time);
const endLogical = this.getLogicalIndex(this.endAnchor.time);
if (startLogical === null || endLogical === null) {
return null;
}
const left = Math.min(Number(startLogical), Number(endLogical));
const right = Math.max(Number(startLogical), Number(endLogical));
if (end < left || start > right) {
return null;
}
return {
priceRange: {
minValue: Math.min(this.startAnchor.price, this.endAnchor.price),
maxValue: Math.max(this.startAnchor.price, this.endAnchor.price),
},
};
}
public getRenderData(): VolumeProfileRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const { rows, pocY } = this.getProfileRenderRows(geometry);
return {
...geometry,
rows,
pocY,
showHandles: this.isActive || this.mode === 'drawing',
};
}
public getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
public getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time);
if (coordinate === null) {
return null;
}
return {
coordinate,
text: formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
public getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
if (coordinate === null) {
return null;
}
return {
coordinate,
text: formatPrice(anchor.price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isActive) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'volume-profile',
zOrder: 'top',
};
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
return null;
}
return {
cursorStyle: dragTarget === 'body' ? 'grab' : 'move',
externalId: 'volume-profile',
zOrder: 'top',
};
}
private bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('pointerdown', this.handlePointerDown);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
private unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('pointerdown', this.handlePointerDown);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isActive) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive = true;
this.render();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.isActive = false;
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
private handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveBody(point);
this.render();
return;
}
this.moveHandle(point);
this.render();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.isActive = true;
this.mode = 'drawing';
this.calculateProfile();
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.calculateProfile();
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || geometry.width < MIN_PROFILE_SIZE || geometry.height < MIN_PROFILE_SIZE) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DragTarget, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.hidden = false;
this.isActive = false;
this.mode = 'idle';
this.startAnchor = null;
this.endAnchor = null;
this.profileRows = [];
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private moveHandle(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
if (this.activeDragTarget === 'start') {
this.startAnchor = anchor;
}
if (this.activeDragTarget === 'end') {
this.endAnchor = anchor;
}
this.calculateProfile();
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const dragStartPoint = this.dragStartPoint;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
this.setAnchorsFromPoints(
{
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
},
{
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
},
);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createAnchor(this.clampPointToContainer(startPoint));
const endAnchor = this.createAnchor(this.clampPointToContainer(endPoint));
if (!startAnchor || !endAnchor) {
return;
}
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
this.calculateProfile();
}
private getDragTarget(point: Point): Exclude<DragTarget, null> | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'end';
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private calculateProfile(): void {
if (!this.startAnchor || !this.endAnchor) {
this.profileRows = [];
return;
}
const leftTime = Math.min(Number(this.startAnchor.time), Number(this.endAnchor.time));
const rightTime = Math.max(Number(this.startAnchor.time), Number(this.endAnchor.time));
if (!Number.isFinite(leftTime) || !Number.isFinite(rightTime)) {
this.profileRows = [];
return;
}
let minPrice = Math.min(this.startAnchor.price, this.endAnchor.price);
let maxPrice = Math.max(this.startAnchor.price, this.endAnchor.price);
if (minPrice === maxPrice) {
maxPrice = minPrice + Math.max(Math.abs(minPrice) * 0.001, 1);
}
const priceRange = maxPrice - minPrice;
const priceStep = priceRange / PROFILE_ROW_COUNT;
const profileRows: VolumeProfileDataRow[] = [];
for (let index = 0; index < PROFILE_ROW_COUNT; index += 1) {
profileRows.push({
priceLow: minPrice + priceStep * index,
priceHigh: minPrice + priceStep * (index + 1),
buyVolume: 0,
sellVolume: 0,
totalVolume: 0,
});
}
const candles = this.series.data() as SeriesCandleData[];
candles.forEach((candle) => {
const candleTime = Number(candle.time);
if (!Number.isFinite(candleTime) || candleTime < leftTime || candleTime > rightTime) {
return;
}
const volume = candle.customValues?.volume ?? 0;
if (volume <= 0) {
return;
}
const price = candle.close ?? candle.value;
if (price === undefined) {
return;
}
const candleHigh = candle.high ?? price;
const candleLow = candle.low ?? price;
if (candleHigh < minPrice || candleLow > maxPrice) {
return;
}
const highInRange = Math.min(maxPrice, candleHigh);
const lowInRange = Math.max(minPrice, candleLow);
const candleRange = Math.max(candleHigh - candleLow, priceStep);
const isBuyVolume = candle.open === undefined || candle.close === undefined || candle.close >= candle.open;
profileRows.forEach((row) => {
const overlap = Math.max(0, Math.min(row.priceHigh, highInRange) - Math.max(row.priceLow, lowInRange));
if (overlap <= 0) {
return;
}
const volumePart = volume * (overlap / candleRange);
if (isBuyVolume) {
row.buyVolume += volumePart;
} else {
row.sellVolume += volumePart;
}
row.totalVolume += volumePart;
});
});
this.profileRows = profileRows;
}
private getGeometry(): VolumeProfileGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time);
const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.min(startPoint.x, endPoint.x);
const right = Math.max(startPoint.x, endPoint.x);
const top = Math.min(startPoint.y, endPoint.y);
const bottom = Math.max(startPoint.y, endPoint.y);
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getProfileRenderRows(geometry: VolumeProfileGeometry): { rows: VolumeProfileRenderRow[]; pocY: number | null } {
if (!this.profileRows.length) {
return { rows: [], pocY: null };
}
const maxVolume = this.profileRows.reduce((max, row) => Math.max(max, row.totalVolume), 0);
if (maxVolume <= 0) {
return { rows: [], pocY: null };
}
let pocY: number | null = null;
let pocVolume = 0;
const rows = this.profileRows
.map((row) => {
const highY = getYCoordinateFromPrice(this.series, row.priceHigh);
const lowY = getYCoordinateFromPrice(this.series, row.priceLow);
if (highY === null || lowY === null) {
return null;
}
const top = clamp(Math.min(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
const bottom = clamp(Math.max(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
if (row.totalVolume > pocVolume) {
pocVolume = row.totalVolume;
pocY = (top + bottom) / 2;
}
return {
top,
height: Math.max(1, bottom - top),
buyWidth: (geometry.width * row.buyVolume) / maxVolume,
sellWidth: (geometry.width * row.sellVolume) / maxVolume,
};
})
.filter((row): row is VolumeProfileRenderRow => row !== null);
return { rows, pocY };
}
private getLogicalIndex(time: Time): Logical | null {
const coordinate = getXCoordinateFromTime(this.chart, time);
if (coordinate === null) {
return null;
}
return this.chart.timeScale().coordinateToLogical(coordinate as Coordinate);
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
private showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
private getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
private render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import type { VolumeProfile } from './volumeProfile';
const UI = {
borderWidth: 1,
handleRadius: 5,
handleBorderWidth: 2,
rowGap: 1,
pocLineWidth: 1,
};
const { colors } = getThemeStore();
export class VolumeProfilePaneRenderer implements IPrimitivePaneRenderer {
private readonly volumeProfile: VolumeProfile;
constructor(volumeProfile: VolumeProfile) {
this.volumeProfile = volumeProfile;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.volumeProfile.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
const bottom = data.bottom * verticalPixelRatio;
context.save();
context.fillStyle = colors.volumeProfileAreaFill;
context.fillRect(left, top, right - left, bottom - top);
data.rows.forEach((row) => {
const rowTop = row.top * verticalPixelRatio;
const rowHeight = row.height * verticalPixelRatio;
const buyWidth = row.buyWidth * horizontalPixelRatio;
const sellWidth = row.sellWidth * horizontalPixelRatio;
drawVolumeRow(context, left, rowTop, rowHeight, buyWidth, sellWidth, pixelRatio);
});
if (data.pocY !== null) {
context.strokeStyle = colors.volumeProfilePocLine;
context.lineWidth = UI.pocLineWidth * pixelRatio;
context.beginPath();
context.moveTo(left, data.pocY * verticalPixelRatio);
context.lineTo(right, data.pocY * verticalPixelRatio);
context.stroke();
}
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.borderWidth * pixelRatio;
context.strokeRect(left, top, right - left, bottom - top);
if (data.showHandles) {
drawHandle(
context,
data.startPoint.x * horizontalPixelRatio,
data.startPoint.y * verticalPixelRatio,
pixelRatio,
);
drawHandle(
context,
data.endPoint.x * horizontalPixelRatio,
data.endPoint.y * verticalPixelRatio,
pixelRatio,
);
}
context.restore();
});
}
}
function drawVolumeRow(
context: CanvasRenderingContext2D,
left: number,
top: number,
height: number,
buyWidth: number,
sellWidth: number,
pixelRatio: number,
): void {
const safeHeight = Math.max(1, height - UI.rowGap * pixelRatio);
if (buyWidth <= 0 && sellWidth <= 0) {
return;
}
if (buyWidth > 0 && sellWidth > 0) {
const halfHeight = Math.max(1, safeHeight / 2);
context.fillStyle = colors.volumeProfileBuyFill;
context.fillRect(left, top, buyWidth, halfHeight);
context.fillStyle = colors.volumeProfileSellFill;
context.fillRect(left, top + halfHeight, sellWidth, halfHeight);
return;
}
context.fillStyle = buyWidth > 0 ? colors.volumeProfileBuyFill : colors.volumeProfileSellFill;
context.fillRect(left, top, Math.max(buyWidth, sellWidth), safeHeight);
}
function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, pixelRatio: number): void {
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * pixelRatio;
context.beginPath();
context.arc(x, y, UI.handleRadius * pixelRatio, 0, Math.PI * 2);
context.fill();
context.stroke();
context.restore();
}
import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { VolumeProfilePaneRenderer } from './paneRenderer';
import type { VolumeProfile } from './volumeProfile';
export class VolumeProfilePaneView implements IPrimitivePaneView {
private readonly volumeProfile: VolumeProfile;
constructor(volumeProfile: VolumeProfile) {
this.volumeProfile = volumeProfile;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return new VolumeProfilePaneRenderer(this.volumeProfile);
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}