Skip to content

Native vertical Seperator for session breaks #1931

@vishnuc

Description

@vishnuc

Hi , Is there a way to add some session breaks natively or a plugin example so we can add it to charts , I tried creating via help of AI , but its very laggy and not feeling native.. If it comes from the professional it will be better I guess. Below is my code

import type {
	ISeriesPrimitive,
	IPrimitivePaneView,
	IPrimitivePaneRenderer,
	PrimitivePaneViewZOrder
} from 'lightweight-charts';
import { PluginBase } from '../../tvplugin/plugin-base';
import {
	drawHolder,
	setActiveDrawingUid,
	setHoverDrawingUid,
	co2points,
	time2co
} from '@/addon/drawings/_drawIndex.svelte';
import type { VerticalSeparatorOptions } from '../../_options';
import { defaultVerticalSeparatorOptions } from '../../_options';

export class VerticalSeparatorPlugin extends PluginBase implements ISeriesPrimitive {
	_separatorLines: any[];
	myChart: any;
	mySeries: any;
	done: boolean;
	clickCount: number;
	_paneViews: VerticalSeparatorPaneView[];
	isActive: boolean = false;
	isHovered: boolean = false;
	isNear: 'line' | null = null;
	uid: string;
	initdrag: boolean = true;
	lastNear: 'line' | null = null;
	paneIndex: number;
	grid: string;
	options: VerticalSeparatorOptions = defaultVerticalSeparatorOptions;
	autoDetectEnabled: boolean = true;

	constructor(chart, series, uid, paneNum, grid, autoDetect = true) {
		super();
		this.uid = uid;
		this.myChart = chart;
		this.mySeries = series;
		this.done = true; // Auto-separators are always done
		this.clickCount = 0;
		this.autoDetectEnabled = autoDetect;
		this._separatorLines = [];

		this.paneIndex = paneNum;
		this.grid = grid;
		this._paneViews = [new VerticalSeparatorPaneView(this)];

		if (this.autoDetectEnabled) {
			this._detectDayChanges();
		}
	}

	_detectDayChanges() {
		if (!this.mySeries || !this.myChart) return;

		// Get the visible data range
		const timeScale = this.myChart.timeScale();
		const visibleRange = timeScale.getVisibleRange();
		
		if (!visibleRange) return;

		// Get series data
		const seriesData = this.mySeries.data();
		if (!seriesData || seriesData.length === 0) return;

		// Find day boundaries within visible range
		const dayBoundaries = [];
		let currentDay = null;

		seriesData.forEach((dataPoint, index) => {
			if (!dataPoint.time) return;

			// Convert time to date
			const date = new Date(dataPoint.time * 1000);
			const dayString = date.toDateString();

			if (currentDay && currentDay !== dayString) {
				// Day changed, add separator at this time
				dayBoundaries.push({
					time: dataPoint.time,
					price: dataPoint.close || dataPoint.value || 0
				});
			}
			currentDay = dayString;
		});

		// Convert to separator lines
		this._separatorLines = dayBoundaries.map(boundary => ({
			time: boundary.time,
			price: boundary.price,
			lastTime: boundary.time,
			barDiff: 0
		}));

		this.requestUpdate();
	}

	refreshSeparators() {
		if (this.autoDetectEnabled) {
			this._detectDayChanges();
		}
	}

	applyOptions(options: Partial<VerticalSeparatorOptions>) {
		this.options = { ...this.options, ...options };
		this.requestUpdate();
	}

	isDone() {
		return this.done;
	}

	addPoint(point, count) {
		// Auto-detect mode doesn't use manual point addition
		if (this.autoDetectEnabled) return true;
		
		this.clickCount = count;
		const newPoint = co2points(point, this.myChart, this.mySeries);
		this._separatorLines = [newPoint];
		this.requestUpdate();
		if (count == 1) {
			this.done = true;
			this.setActive(true);
			setActiveDrawingUid(this.uid);
			return true;
		}
		return false;
	}

	_dragStart() {
		this.initdrag = true;
		this.lastNear = null;
	}
	_dragDone() {
		this.initdrag = false;
		this.lastNear = null;
	}

	addPoint1(p) {
		if (this.autoDetectEnabled) return;
		const newPoint = co2points(p, this.myChart, this.mySeries);
		this._separatorLines = [newPoint];
		this.requestUpdate();
	}

	reposition(param, ogGrid) {
		// Auto-detect mode doesn't allow repositioning
		if (this.autoDetectEnabled) return;
		
		const paneNum = param.paneIndex;
		if (!param || !this.mySeries) return;
		if (!this._paneViews[0]?._data) return;
		if (param.point.x <= 0) return false;
		if (param.point.y <= 0) return false;
		if (paneNum !== this.paneIndex) return false;
		if (ogGrid !== this.grid) return false;

		const pointData = {
			x: param.point.x,
			y: param.point.y
		};

		// Only allow horizontal movement for vertical separator
		this.addPoint1({ x: pointData.x, y: this._paneViews[0]._data.y || pointData.y });
		this.requestUpdate();
	}

	paneViews() {
		return this._paneViews;
	}

	updateAllViews() {
		this._paneViews.forEach((view) => view.update());
	}

	setActive(active: boolean): void {
		drawHolder.inactiveAllDrawings();
		this.isActive = active;
		this.requestUpdate();
	}
	setInactive() {
		this.isActive = false;
		this.requestUpdate();
	}

	setHover(hover: boolean, nearPart: 'line' | null = null): void {
		if (!hover) {
			setHoverDrawingUid(this.uid, false);
			this.isHovered = false;
			return;
		}
		this.isHovered = hover;
		this.isNear = nearPart;
		this.requestUpdate();
		setHoverDrawingUid(this.uid, true);
	}

	hitTest(x: number, y: number) {
		if (!this._paneViews[0]?._data?.lines || this._paneViews[0]._data.lines.length === 0) {
			return null;
		}

		const lineWidth = 10;
		const lines = this._paneViews[0]._data.lines;

		// Check if hovering over any vertical line
		for (const line of lines) {
			if (line.x && Math.abs(x - line.x) <= lineWidth) {
				this.setHover(true, 'line');
				return {
					cursorStyle: this.autoDetectEnabled ? 'default' : 'pointer',
					externalId: 'line',
					zOrder: 'top' as PrimitivePaneViewZOrder
				};
			}
		}

		this.setHover(false, null);
		return null;
	}

	/**
	 * Get the current drawing configuration for server storage
	 */
	getDrawing() {
		return {
			uid: this.uid,
			type: 'verticalseparator',
			paneIndex: this.paneIndex,
			grid: this.grid,
			done: this.done,
			clickCount: this.clickCount,
			isActive: this.isActive,
			autoDetectEnabled: this.autoDetectEnabled,
			separatorLines: this._separatorLines.map(line => ({
				price: line.price,
				time: line.time,
				lastTime: line.lastTime,
				barDiff: line.barDiff
			})),
			options: { ...this.options },
			timestamp: Date.now()
		};
	}

	/**
	 * Restore drawing from server-stored configuration
	 */
	static setDrawing(drawingConfig: any, chart: any, series: any) {
		if (!drawingConfig || !drawingConfig.point) {
			throw new Error('Invalid drawing configuration: missing point data');
		}

		if (!chart || !series) {
			throw new Error('Invalid chart or series instance');
		}

		const plugin = new VerticalSeparatorPlugin(
			chart,
			series,
			drawingConfig.point,
			drawingConfig.uid,
			drawingConfig.paneIndex,
			drawingConfig.grid
		);

		plugin.done = drawingConfig.done || false;
		plugin.clickCount = drawingConfig.clickCount || 0;
		plugin.isActive = drawingConfig.isActive || false;

		if (drawingConfig.options) {
			plugin.applyOptions(drawingConfig.options);
		}

		plugin._point = {
			price: drawingConfig.point.price,
			time: drawingConfig.point.time,
			lastTime: drawingConfig.point.lastTime,
			barDiff: drawingConfig.point.barDiff
		};

		series.attachPrimitive(plugin);
		plugin.updateAllViews();
		plugin.requestUpdate();

		return plugin;
	}

	/**
	 * Update drawing from partial configuration
	 */
	updateFromConfig(partialConfig: any) {
		if (partialConfig.point) {
			this._point = {
				price: partialConfig.point.price,
				time: partialConfig.point.time,
				lastTime: partialConfig.point.lastTime,
				barDiff: partialConfig.point.barDiff
			};
		}

		if (partialConfig.options) {
			this.applyOptions(partialConfig.options);
		}

		if (partialConfig.done !== undefined) {
			this.done = partialConfig.done;
		}

		if (partialConfig.clickCount !== undefined) {
			this.clickCount = partialConfig.clickCount;
		}

		if (partialConfig.isActive !== undefined) {
			this.isActive = partialConfig.isActive;
		}

		this.updateAllViews();
		this.requestUpdate();
	}
}

class VerticalSeparatorPaneView implements IPrimitivePaneView {
	_source: VerticalSeparatorPlugin;
	_data: { lines?: any[]; paneHeight?: number };
	constructor(source) {
		this._source = source;
		this._data = {};
	}

	_calculatePoint(sourcePoint, price) {
		if (!sourcePoint) return { x: 0, y: 0 };

		const x = time2co(sourcePoint, this._source.myChart);
		const series = this._source.mySeries;

		// For vertical separator, we don't need a specific y coordinate
		// We'll draw from top to bottom of the pane
		let y = 0;
		if (series && typeof price === 'number') {
			y = series.priceToCoordinate(price);
			if (y === null) {
				y = 0;
			}
		}

		return { x, y };
	}

	update() {
		const { _separatorLines } = this._source;
		
		// Get pane height for drawing full vertical lines
		const paneHeight = this._source.myChart?.paneSize()?.height || 400;
		
		// Calculate positions for all separator lines
		const lines = _separatorLines.map(line => {
			const p = this._calculatePoint(line, line?.price);
			return {
				x: p.x,
				y: p.y,
				time: line.time
			};
		});
		
		this._data = { 
			lines,
			paneHeight 
		};
	}

	renderer() {
		return new VerticalSeparatorRenderer(this._data, this._source);
	}
}

class VerticalSeparatorRenderer implements IPrimitivePaneRenderer {
	_data: { lines?: any[]; paneHeight?: number };
	_source: VerticalSeparatorPlugin;
	constructor(data, source) {
		this._data = data;
		this._source = source;
	}

	draw(target) {
		target.useBitmapCoordinateSpace((scope) => {
			const ctx = scope.context;
			const { lines, paneHeight } = this._data;
			if (!lines || lines.length === 0 || paneHeight == null) return;
			
			const { horizontalPixelRatio, verticalPixelRatio } = scope;
			const { lineWidth, lineColor, lineStyle, showLabel, labelText, labelBackgroundColor, labelTextColor } = this._source.options;

			// Draw each vertical separator line
			lines.forEach((line, index) => {
				const { x } = line;
				if (x == null) return;

				// Draw vertical line from top to bottom of pane
				ctx.beginPath();
				ctx.moveTo(Math.round(x * horizontalPixelRatio), 0);
				ctx.lineTo(Math.round(x * horizontalPixelRatio), Math.round(paneHeight * verticalPixelRatio));

				// Apply line style
				if (lineStyle === 'dashed') {
					ctx.setLineDash([6 * horizontalPixelRatio, 3 * horizontalPixelRatio]);
				} else if (lineStyle === 'dotted') {
					ctx.setLineDash([2 * horizontalPixelRatio, 2 * horizontalPixelRatio]);
				} else {
					ctx.setLineDash([]);
				}

				ctx.strokeStyle = lineColor;
				ctx.lineWidth = lineWidth * horizontalPixelRatio;
				ctx.stroke();
				ctx.setLineDash([]);

				// Draw glowing effect if hovered
				if (this._source.isHovered && this._source.done) {
					ctx.save();
					ctx.beginPath();
					ctx.moveTo(Math.round(x * horizontalPixelRatio), 0);
					ctx.lineTo(Math.round(x * horizontalPixelRatio), Math.round(paneHeight * verticalPixelRatio));
					ctx.strokeStyle = 'rgba(30, 144, 255, 0.3)';
					ctx.lineWidth = lineWidth * 2 * horizontalPixelRatio;
					ctx.shadowColor = 'rgba(30, 144, 255, 0.5)';
					ctx.shadowBlur = lineWidth * 4 * horizontalPixelRatio;
					ctx.stroke();
					ctx.restore();
				}

				// Draw label if enabled (only on first line to avoid clutter)
				if (showLabel && labelText && this._source.done && index === 0) {
					ctx.save();
					ctx.font = `${12 * verticalPixelRatio}px Arial`;
					ctx.textAlign = 'center';
					ctx.textBaseline = 'middle';

					// Measure text for background
					const textMetrics = ctx.measureText(labelText);
					const textWidth = textMetrics.width;
					const textHeight = 12 * verticalPixelRatio;
					const padding = 4 * horizontalPixelRatio;

					// Position label at top of the line
					const labelY = 20 * verticalPixelRatio;
					const labelX = Math.round(x * horizontalPixelRatio);

					// Draw background
					ctx.fillStyle = labelBackgroundColor;
					ctx.fillRect(
						labelX - textWidth / 2 - padding,
						labelY - textHeight / 2 - padding,
						textWidth + padding * 2,
						textHeight + padding * 2
					);

					// Draw text
					ctx.fillStyle = labelTextColor;
					ctx.fillText(labelText, labelX, labelY);
					ctx.restore();
				}
			});
		});
	}
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions