diff --git a/lightweight_charts/abstract.py b/lightweight_charts/abstract.py index 31a0e71a..a6b3f70b 100644 --- a/lightweight_charts/abstract.py +++ b/lightweight_charts/abstract.py @@ -423,16 +423,19 @@ def vertical_span( return VerticalSpan(self, start_time, end_time, color) -class Line(SeriesCommon): - def __init__(self, chart, name, color, style, width, price_line, price_label, price_scale_id=None, crosshair_marker=True): +class Line(SeriesCommon): + def __init__(self, chart, name, color, style, width, price_line, price_label, group, price_scale_id=None, crosshair_marker=True): super().__init__(chart, name) self.color = color + self.group = group # Store group for potential internal use + # Pass group as part of the options if createLineSeries handles removing it self.run_script(f''' {self.id} = {self._chart.id}.createLineSeries( "{name}", {{ + group: '{group}', color: '{color}', lineStyle: {as_enum(style, LINE_STYLE)}, lineWidth: {width}, @@ -832,17 +835,19 @@ def fit(self): """ self.run_script(f'{self.id}.chart.timeScale().fitContent()') - def create_line( + def create_line( self, name: str = '', color: str = 'rgba(214, 237, 255, 0.6)', style: LINE_STYLE = 'solid', width: int = 2, - price_line: bool = True, price_label: bool = True, price_scale_id: Optional[str] = None + price_line: bool = True, price_label: bool = True, group: str = '', + price_scale_id: Optional[str] =None ) -> Line: """ Creates and returns a Line object. """ - self._lines.append(Line(self, name, color, style, width, price_line, price_label, price_scale_id)) + self._lines.append(Line(self, name, color, style, width, price_line, price_label, group, price_scale_id )) return self._lines[-1] + def create_histogram( self, name: str = '', color: str = 'rgba(214, 237, 255, 0.6)', price_line: bool = True, price_label: bool = True, @@ -854,6 +859,7 @@ def create_histogram( return Histogram( self, name, color, price_line, price_label, scale_margin_top, scale_margin_bottom) + def create_area( self, name: str = '', top_color: str ='rgba(0, 100, 0, 0.5)', @@ -868,6 +874,8 @@ def create_area( width, price_line, price_label, price_scale_id)) return self._lines[-1] + + def create_bar( self, name: str = '', up_color: str = '#26a69a', down_color: str = '#ef5350', open_visible: bool = True, thin_bars: bool = True, diff --git a/src/general/handler.ts b/src/general/handler.ts index adca7ab7..8ed085ec 100644 --- a/src/general/handler.ts +++ b/src/general/handler.ts @@ -29,7 +29,9 @@ export interface Scale{ height: number, } - +interface MultiLineOptions extends DeepPartial { + group?: string; // Define group as an optional string identifier +} globalParamInit(); declare const window: GlobalParams; @@ -179,16 +181,40 @@ export class Handler { return volumeSeries; } - createLineSeries(name: string, options: DeepPartial) { - const line = this.chart.addLineSeries({...options}); + createLineSeries( + name: string, + options: MultiLineOptions + ): { name: string; series: ISeriesApi } { + const { group, ...lineOptions } = options; + const line = this.chart.addLineSeries(lineOptions); this._seriesList.push(line); - this.legend.makeSeriesRow(name, line) - return { - name: name, - series: line, + + // Get color of the series for legend display + const color = line.options().color || 'rgba(255,0,0,1)'; // Default to red if no color is defined + const solidColor = color.startsWith('rgba') ? color.replace(/[^,]+(?=\))/, '1') : color; + + if (!group || group === '') { + // No group: create a standalone series row + this.legend.makeSeriesRow(name, line); + } else { + // Check if the group already exists + const existingGroup = this.legend._groups.find(g => g.name === group); + + if (existingGroup) { + // Group exists: add the new line's name and color to the `names` and `solidColors` arrays + existingGroup.names.push(name); + existingGroup.seriesList.push(line); + existingGroup.solidColors.push(solidColor); + } else { + // Group does not exist: create a new one + this.legend.makeSeriesGroup(group, [name], [line], [solidColor]); + } } + + return { name, series: line }; } - + + createHistogramSeries(name: string, options: DeepPartial) { const line = this.chart.addHistogramSeries({...options}); this._seriesList.push(line); diff --git a/src/general/legend.ts b/src/general/legend.ts index 10997280..2be24864 100644 --- a/src/general/legend.ts +++ b/src/general/legend.ts @@ -1,20 +1,63 @@ import { ISeriesApi, LineData, Logical, MouseEventParams, PriceFormatBuiltIn, SeriesType } from "lightweight-charts"; import { Handler } from "./handler"; - +// Interfaces for the legend elements interface LineElement { name: string; div: HTMLDivElement; row: HTMLDivElement; - toggle: HTMLDivElement, - series: ISeriesApi, + toggle: HTMLDivElement; + series: ISeriesApi; solid: string; } +interface LegendGroup { + name: string; + seriesList: ISeriesApi[]; + div: HTMLDivElement; + row: HTMLDivElement; + toggle: HTMLDivElement; + solidColors: string[]; + names: string[]; +} +// Define the SVG path data +const openEye = ` + + + + +`; + +const closedEye = ` + + + + + +`; export class Legend { private handler: Handler; public div: HTMLDivElement; - public seriesContainer: HTMLDivElement + public seriesContainer: HTMLDivElement; private ohlcEnabled: boolean = false; private percentEnabled: boolean = false; @@ -24,132 +67,157 @@ export class Legend { private text: HTMLSpanElement; private candle: HTMLDivElement; public _lines: LineElement[] = []; - + public _groups: LegendGroup[] = []; constructor(handler: Handler) { - this.legendHandler = this.legendHandler.bind(this) - this.handler = handler; - this.ohlcEnabled = false; - this.percentEnabled = false - this.linesEnabled = false - this.colorBasedOnCandle = false - this.div = document.createElement('div'); - this.div.classList.add("legend") - this.div.style.maxWidth = `${(handler.scale.width * 100) - 8}vw` - this.div.style.display = 'none'; + this.div.classList.add("legend"); + this.seriesContainer = document.createElement("div"); + this.text = document.createElement('span'); + this.candle = document.createElement('div'); + this.setupLegend(); + this.legendHandler = this.legendHandler.bind(this); + handler.chart.subscribeCrosshairMove(this.legendHandler); + } + + private setupLegend() { + this.div.style.maxWidth = `${(this.handler.scale.width * 100) - 8}vw`; + this.div.style.display = 'none'; + const seriesWrapper = document.createElement('div'); seriesWrapper.style.display = 'flex'; seriesWrapper.style.flexDirection = 'row'; - this.seriesContainer = document.createElement("div"); + this.seriesContainer.classList.add("series-container"); + this.text.style.lineHeight = '1.8'; - this.text = document.createElement('span') - this.text.style.lineHeight = '1.8' - this.candle = document.createElement('div') - seriesWrapper.appendChild(this.seriesContainer); - this.div.appendChild(this.text) - this.div.appendChild(this.candle) - this.div.appendChild(seriesWrapper) - handler.div.appendChild(this.div) - - // this.makeSeriesRows(handler); + this.div.appendChild(this.text); + this.div.appendChild(this.candle); + this.div.appendChild(seriesWrapper); + this.handler.div.appendChild(this.div); + } - handler.chart.subscribeCrosshairMove(this.legendHandler) + legendItemFormat(num: number, decimal: number) { + return num.toFixed(decimal).toString().padStart(8, ' '); } - toJSON() { - // Exclude the chart attribute from serialization - const {_lines, handler, ...serialized} = this; - return serialized; + shorthandFormat(num: number) { + const absNum = Math.abs(num); + return absNum >= 1000000 ? (num / 1000000).toFixed(1) + 'M' : + absNum >= 1000 ? (num / 1000).toFixed(1) + 'K' : + num.toString().padStart(8, ' '); } - // makeSeriesRows(handler: Handler) { - // if (this.linesEnabled) handler._seriesList.forEach(s => this.makeSeriesRow(s)) - // } - - makeSeriesRow(name: string, series: ISeriesApi) { - const strokeColor = '#FFF'; - let openEye = ` - - \` - ` - let closedEye = ` - - ` - - let row = document.createElement('div') - row.style.display = 'flex' - row.style.alignItems = 'center' - let div = document.createElement('div') - let toggle = document.createElement('div') + makeSeriesRow(name: string, series: ISeriesApi): HTMLDivElement { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + + const div = document.createElement('div'); + div.innerText = name; + + const toggle = document.createElement('div'); toggle.classList.add('legend-toggle-switch'); - - - let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("width", "22"); - svg.setAttribute("height", "16"); - - let group = document.createElementNS("http://www.w3.org/2000/svg", "g"); - group.innerHTML = openEye - - let on = true + + const color = (series.options() as any).color || 'rgba(255,0,0,1)'; // Use a default color + const solidColor = color.startsWith('rgba') ? color.replace(/[^,]+(?=\))/, '1') : color; + + const onIcon = this.createSvgIcon(openEye); + const offIcon = this.createSvgIcon(closedEye); + toggle.appendChild(onIcon.cloneNode(true)); // Clone nodes to avoid duplication + + let visible = true; toggle.addEventListener('click', () => { - if (on) { - on = false - group.innerHTML = closedEye - series.applyOptions({ - visible: false - }) - } else { - on = true - series.applyOptions({ - visible: true - }) - group.innerHTML = openEye - } - }) - - svg.appendChild(group) - toggle.appendChild(svg); - row.appendChild(div) - row.appendChild(toggle) - this.seriesContainer.appendChild(row) - - const color = series.options().color; + visible = !visible; + series.applyOptions({ visible }); + toggle.innerHTML = ''; // Clear current icon + toggle.appendChild(visible ? onIcon.cloneNode(true) : offIcon.cloneNode(true)); + }); + + row.appendChild(div); + row.appendChild(toggle); + this._lines.push({ - name: name, - div: div, - row: row, - toggle: toggle, - series: series, - solid: color.startsWith('rgba') ? color.replace(/[^,]+(?=\))/, '1') : color + name, + div, + row, + toggle, + series, + solid: solidColor, }); - } + this.seriesContainer.appendChild(row); - legendItemFormat(num: number, decimal: number) { return num.toFixed(decimal).toString().padStart(8, ' ') } + return row; + } + makeSeriesGroup(groupName: string, names: string[], seriesList: ISeriesApi[], solidColors: string[]) { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + + const div = document.createElement('div'); + div.style.color = '#FFF'; // Keep group name text in white + div.innerText = `${groupName}:`; + + const toggle = document.createElement('div'); + toggle.classList.add('legend-toggle-switch'); + + const onIcon = this.createSvgIcon(openEye); + const offIcon = this.createSvgIcon(closedEye); + toggle.appendChild(onIcon.cloneNode(true)); // Default to visible + + let visible = true; + toggle.addEventListener('click', () => { + visible = !visible; + seriesList.forEach(series => series.applyOptions({ visible })); + toggle.innerHTML = ''; // Clear toggle before appending new icon + toggle.appendChild(visible ? onIcon.cloneNode(true) : offIcon.cloneNode(true)); + }); + + // Build the legend text with only colored squares and regular-weight line names + let legendText = `${groupName}:`; + names.forEach((name, index) => { + const color = solidColors[index]; + legendText += ` ${name}: -`; + }); + + div.innerHTML = legendText; // Set HTML content to maintain colored squares and regular font for line names + + this._groups.push({ + name: groupName, + seriesList, + div, + row, + toggle, + solidColors, + names, + }); + + row.appendChild(div); + row.appendChild(toggle); + this.seriesContainer.appendChild(row); + return row; + } + - shorthandFormat(num: number) { - const absNum = Math.abs(num) - if (absNum >= 1000000) { - return (num / 1000000).toFixed(1) + 'M'; - } else if (absNum >= 1000) { - return (num / 1000).toFixed(1) + 'K'; - } - return num.toString().padStart(8, ' '); + private createSvgIcon(svgContent: string): SVGElement { + const tempContainer = document.createElement('div'); + tempContainer.innerHTML = svgContent.trim(); + const svgElement = tempContainer.querySelector('svg'); + return svgElement as SVGElement; } + - legendHandler(param: MouseEventParams, usingPoint= false) { + legendHandler(param: MouseEventParams, usingPoint = false) { if (!this.ohlcEnabled && !this.linesEnabled && !this.percentEnabled) return; - const options: any = this.handler.series.options() + const options: any = this.handler.series.options(); if (!param.time) { - this.candle.style.color = 'transparent' - this.candle.innerHTML = this.candle.innerHTML.replace(options['upColor'], '').replace(options['downColor'], '') - return + this.candle.style.color = 'transparent'; + this.candle.innerHTML = this.candle.innerHTML.replace(options['upColor'], '').replace(options['downColor'], ''); + return; } let data: any; @@ -157,76 +225,90 @@ export class Legend { if (usingPoint) { const timeScale = this.handler.chart.timeScale(); - let coordinate = timeScale.timeToCoordinate(param.time) - if (coordinate) - logical = timeScale.coordinateToLogical(coordinate.valueOf()) - if (logical) - data = this.handler.series.dataByIndex(logical.valueOf()) - } - else { + const coordinate = timeScale.timeToCoordinate(param.time); + if (coordinate) logical = timeScale.coordinateToLogical(coordinate.valueOf()); + if (logical) data = this.handler.series.dataByIndex(logical.valueOf()); + } else { data = param.seriesData.get(this.handler.series); } - this.candle.style.color = '' - let str = '' + let str = ''; if (data) { + // OHLC Data if (this.ohlcEnabled) { - str += `O ${this.legendItemFormat(data.open, this.handler.precision)} ` - str += `| H ${this.legendItemFormat(data.high, this.handler.precision)} ` - str += `| L ${this.legendItemFormat(data.low, this.handler.precision)} ` - str += `| C ${this.legendItemFormat(data.close, this.handler.precision)} ` + str += `O ${this.legendItemFormat(data.open, this.handler.precision)} `; + str += `| H ${this.legendItemFormat(data.high, this.handler.precision)} `; + str += `| L ${this.legendItemFormat(data.low, this.handler.precision)} `; + str += `| C ${this.legendItemFormat(data.close, this.handler.precision)} `; } + // Percentage Movement if (this.percentEnabled) { - let percentMove = ((data.close - data.open) / data.open) * 100 - let color = percentMove > 0 ? options['upColor'] : options['downColor'] - let percentStr = `${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %` - - if (this.colorBasedOnCandle) { - str += `| ${percentStr}` - } else { - str += '| ' + percentStr - } - } - - if (this.handler.volumeSeries) { - let volumeData: any; - if (logical) { - volumeData = this.handler.volumeSeries.dataByIndex(logical) - } - else { - volumeData = param.seriesData.get(this.handler.volumeSeries) - } - if (volumeData) { - str += this.ohlcEnabled ? `
V ${this.shorthandFormat(volumeData.value)}` : '' - } + const percentMove = ((data.close - data.open) / data.open) * 100; + const color = percentMove > 0 ? options['upColor'] : options['downColor']; + const percentStr = `${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %`; + str += this.colorBasedOnCandle ? `| ${percentStr}` : `| ${percentStr}`; } } - this.candle.innerHTML = str + '
' + this.candle.innerHTML = str + '
'; - this._lines.forEach((e) => { - if (!this.linesEnabled) { - e.row.style.display = 'none' - return - } - e.row.style.display = 'flex' + this.updateGroupLegend(param, logical, usingPoint); + this.updateSeriesLegend(param, logical, usingPoint); + } - let data - if (usingPoint && logical) { - data = e.series.dataByIndex(logical) as LineData + private updateGroupLegend(param: MouseEventParams, logical: Logical | null, usingPoint: boolean) { + this._groups.forEach((group) => { + if (!this.linesEnabled) { + group.row.style.display = 'none'; + return; } - else { - data = param.seriesData.get(e.series) as LineData + group.row.style.display = 'flex'; + + let legendText = `${group.name}:`; + group.seriesList.forEach((series, index) => { + const data = usingPoint && logical + ? series.dataByIndex(logical) as LineData + : param.seriesData.get(series) as LineData; + + if (!data?.value) return; + + const priceFormat = series.options().priceFormat; + const price = 'precision' in priceFormat + ? this.legendItemFormat(data.value, (priceFormat as PriceFormatBuiltIn).precision) + : this.legendItemFormat(data.value, 2); // Default precision + + const color = group.solidColors ? group.solidColors[index] : 'inherit'; + const name = group.names[index]; + + // Include `price` in legendText + legendText += ` ${name}: ${price}`; + }); + + group.div.innerHTML = legendText; + }); + } + private updateSeriesLegend(param: MouseEventParams, logical: Logical | null, usingPoint: boolean) { + this._lines.forEach((line) => { + if (!this.linesEnabled) { + line.row.style.display = 'none'; + return; } - if (!data?.value) return; - let price; - if (e.series.seriesType() == 'Histogram') { - price = this.shorthandFormat(data.value) + line.row.style.display = 'flex'; + + const data = usingPoint && logical + ? line.series.dataByIndex(logical) as LineData + : param.seriesData.get(line.series) as LineData; + + if (data?.value !== undefined) { + const priceFormat = line.series.options().priceFormat as PriceFormatBuiltIn; + const price = 'precision' in priceFormat + ? this.legendItemFormat(data.value, priceFormat.precision) + : this.legendItemFormat(data.value, 2); + + line.div.innerHTML = ` ${line.name}: ${price}`; } else { - const format = e.series.options().priceFormat as PriceFormatBuiltIn - price = this.legendItemFormat(data.value, format.precision) // couldn't this just be line.options().precision? + line.div.innerHTML = `${line.name}: -`; } - e.div.innerHTML = ` ${e.name} : ${price}` - }) + }); } }