lightweight-charts-mtf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 lightweight-charts-mtf contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # lightweight-charts-mtf
2
+
3
+ Multi-timeframe (MTF) candle display plugin for [TradingView Lightweight Charts](https://github.com/nickolasburr/lightweight-charts). Renders higher-timeframe candles as native candlestick series alongside your main chart.
4
+
5
+ ![lightweight-charts >=4.0.0](https://img.shields.io/badge/lightweight--charts-%3E%3D4.0.0-blue)
6
+ ![npm](https://img.shields.io/npm/v/lightweight-charts-mtf)
7
+ ![license](https://img.shields.io/npm/l/lightweight-charts-mtf)
8
+
9
+ ## Features
10
+
11
+ - Display any number of higher-timeframe columns next to your main chart
12
+ - Native candlestick rendering — HTF candles share the same price scale
13
+ - Accepts any timeframe string as label (e.g. `'4H'`, `'1D'`, `'1W'`, `'1M'`)
14
+ - Built-in OHLC aggregation from lower timeframes (4H, 8H, 12H, 1D, 1W, 1M, 3M, 6M, 1Y)
15
+ - Per-column candle colors (body + wick, up + down)
16
+ - Separator lines and labels between columns
17
+ - Configurable gap between main chart and HTF zone, and between HTF columns
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install lightweight-charts-mtf
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ### With pre-aggregated data (from API)
28
+
29
+ If your data source already provides higher-timeframe candles (e.g., Bybit, Binance):
30
+
31
+ ```typescript
32
+ import { createChart, CandlestickSeries } from 'lightweight-charts';
33
+ import { MTFPrimitive } from 'lightweight-charts-mtf';
34
+
35
+ const chart = createChart(container);
36
+ const mainSeries = chart.addSeries(CandlestickSeries);
37
+
38
+ // Fetch data at different timeframes from your API
39
+ const hourlyData = await fetchKlines('BTCUSDT', '1h');
40
+ const dailyData = await fetchKlines('BTCUSDT', '1d');
41
+ const weeklyData = await fetchKlines('BTCUSDT', '1w');
42
+
43
+ // Create and attach
44
+ const mtf = new MTFPrimitive({
45
+ columns: [
46
+ { timeframe: '1D', data: dailyData, candleCount: 6 },
47
+ { timeframe: '1W', data: weeklyData, candleCount: 4 },
48
+ ],
49
+ gap: 5,
50
+ columnGap: 2,
51
+ });
52
+
53
+ mtf.attach(chart, mainSeries, hourlyData);
54
+ ```
55
+
56
+ ### With self-aggregated data
57
+
58
+ If you only have lower-timeframe data and need to derive higher timeframes:
59
+
60
+ ```typescript
61
+ import { createChart, CandlestickSeries } from 'lightweight-charts';
62
+ import { MTFPrimitive, aggregateOHLC } from 'lightweight-charts-mtf';
63
+
64
+ const chart = createChart(container);
65
+ const mainSeries = chart.addSeries(CandlestickSeries);
66
+
67
+ const hourlyData = [...];
68
+
69
+ // Aggregate into higher timeframes
70
+ const fourHour = aggregateOHLC(hourlyData, '4H');
71
+ const daily = aggregateOHLC(hourlyData, '1D');
72
+ const weekly = aggregateOHLC(hourlyData, '1W');
73
+
74
+ const mtf = new MTFPrimitive({
75
+ columns: [
76
+ { timeframe: '4H', data: fourHour, candleCount: 10 },
77
+ { timeframe: '1D', data: daily, candleCount: 6 },
78
+ { timeframe: '1W', data: weekly, candleCount: 4 },
79
+ ],
80
+ gap: 5,
81
+ columnGap: 2,
82
+ });
83
+
84
+ mtf.attach(chart, mainSeries, hourlyData);
85
+ ```
86
+
87
+ ## Data Format
88
+
89
+ The plugin accepts standard Lightweight Charts OHLC data:
90
+
91
+ ```typescript
92
+ // Unix timestamp (seconds)
93
+ { time: 1704067200, open: 42000, high: 42500, low: 41800, close: 42300, volume: 1500 }
94
+
95
+ // Date string
96
+ { time: '2024-01-01', open: 42000, high: 42500, low: 41800, close: 42300 }
97
+
98
+ // Object
99
+ { time: { year: 2024, month: 1, day: 1 }, open: 42000, high: 42500, low: 41800, close: 42300 }
100
+ ```
101
+
102
+ The `volume` field is optional.
103
+
104
+ ## Aggregation
105
+
106
+ If your data source doesn't provide higher-timeframe candles directly, use `aggregateOHLC`:
107
+
108
+ ```typescript
109
+ import { aggregateOHLC } from 'lightweight-charts-mtf';
110
+
111
+ const fourHour = aggregateOHLC(hourlyCandles, '4H');
112
+ const daily = aggregateOHLC(hourlyCandles, '1D');
113
+ const weekly = aggregateOHLC(hourlyCandles, '1W');
114
+ const monthly = aggregateOHLC(dailyCandles, '1M');
115
+ ```
116
+
117
+ Supported periods: `'4H'`, `'8H'`, `'12H'`, `'1D'`, `'1W'`, `'1M'`, `'3M'`, `'6M'`, `'1Y'`
118
+
119
+ Legacy helpers are also available:
120
+
121
+ ```typescript
122
+ import { aggregateToWeekly, aggregateToMonthly } from 'lightweight-charts-mtf';
123
+ ```
124
+
125
+ ## Options
126
+
127
+ ```typescript
128
+ interface MTFOptions {
129
+ columns: HTFColumnConfig[];
130
+ gap?: number; // whitespace bars between main chart and HTF (default: 5)
131
+ columnGap?: number; // whitespace bars between HTF columns (default: 2)
132
+ showSeparators?: boolean; // default: true
133
+ separatorColor?: string; // default: 'rgba(255,255,255,0.08)'
134
+ showLabels?: boolean; // default: true
135
+ labelFont?: string; // default: '11px sans-serif'
136
+ labelColor?: string; // default: 'rgba(255,255,255,0.5)'
137
+ }
138
+
139
+ interface HTFColumnConfig {
140
+ timeframe: string; // any label: '4H', '1D', '1W', etc.
141
+ data: OHLCData[];
142
+ candleCount?: number; // default: 6
143
+ upColor?: string; // default: '#26a69a'
144
+ downColor?: string; // default: '#ef5350'
145
+ wickUpColor?: string; // defaults to upColor
146
+ wickDownColor?: string; // defaults to downColor
147
+ borderVisible?: boolean; // default: false
148
+ borderUpColor?: string; // defaults to upColor
149
+ borderDownColor?: string; // defaults to downColor
150
+ }
151
+ ```
152
+
153
+ ## API
154
+
155
+ ```typescript
156
+ // Create the plugin
157
+ const mtf = new MTFPrimitive(options);
158
+
159
+ // Attach to chart (creates HTF series and renders)
160
+ mtf.attach(chart, mainSeries, mainData);
161
+
162
+ // Update with new data and/or options
163
+ mtf.update(newMainData);
164
+ mtf.update(newMainData, { columns: [...], gap: 8 });
165
+
166
+ // Update a single column's data
167
+ mtf.updateColumnData(0, newWeeklyData);
168
+
169
+ // Detach and clean up
170
+ mtf.detach();
171
+ ```
172
+
173
+ ## Example
174
+
175
+ See [`example/index.html`](./example/index.html) for a live demo using BTC data from Bybit with configurable base timeframe, HTF columns, gap, and column gap.
176
+
177
+ To run locally:
178
+
179
+ ```bash
180
+ npm run build
181
+ npx serve .
182
+ # Open http://localhost:3000/example/
183
+ ```
184
+
185
+ ## Development
186
+
187
+ ```bash
188
+ npm install
189
+ npm run build # Build CJS, ESM, and type declarations
190
+ npm run dev # Watch mode
191
+ npm run typecheck # Type check without emitting
192
+ npm test # Run unit tests
193
+ npm run test:watch # Watch mode for tests
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT
@@ -0,0 +1,82 @@
1
+ import { Time, IChartApi, ISeriesApi } from 'lightweight-charts';
2
+
3
+ /** OHLC candle data for a single timeframe period */
4
+ interface OHLCData {
5
+ time: Time;
6
+ open: number;
7
+ high: number;
8
+ low: number;
9
+ close: number;
10
+ volume?: number;
11
+ }
12
+ /** Supported higher timeframes — any string label is accepted */
13
+ type HTFTimeframe = string;
14
+ /** Configuration for a single HTF column displayed to the right */
15
+ interface HTFColumnConfig {
16
+ /** Timeframe label (e.g., '1W', '1M') */
17
+ timeframe: HTFTimeframe;
18
+ /** OHLC data for this timeframe — most recent N candles */
19
+ data: OHLCData[];
20
+ /** Number of candles to display (default: 6) */
21
+ candleCount?: number;
22
+ /** Up candle color */
23
+ upColor?: string;
24
+ /** Down candle color */
25
+ downColor?: string;
26
+ /** Wick color for up candles (defaults to upColor) */
27
+ wickUpColor?: string;
28
+ /** Wick color for down candles (defaults to downColor) */
29
+ wickDownColor?: string;
30
+ /** Border visible on candle bodies (default: false) */
31
+ borderVisible?: boolean;
32
+ /** Border up color */
33
+ borderUpColor?: string;
34
+ /** Border down color */
35
+ borderDownColor?: string;
36
+ }
37
+ /** Options for the MTF plugin */
38
+ interface MTFOptions {
39
+ /** Array of HTF columns to display, ordered left-to-right */
40
+ columns: HTFColumnConfig[];
41
+ /** Number of whitespace bars between main chart and first HTF column (default: 5) */
42
+ gap?: number;
43
+ /** Number of whitespace bars between HTF columns (default: 2) */
44
+ columnGap?: number;
45
+ /** Show separator lines between columns (default: true) */
46
+ showSeparators?: boolean;
47
+ /** Separator line color (default: 'rgba(255,255,255,0.08)') */
48
+ separatorColor?: string;
49
+ /** Show timeframe labels (default: true) */
50
+ showLabels?: boolean;
51
+ /** Label font (default: '11px sans-serif') */
52
+ labelFont?: string;
53
+ /** Label color (default: 'rgba(255,255,255,0.5)') */
54
+ labelColor?: string;
55
+ }
56
+
57
+ declare class MTFPrimitive {
58
+ private _chart;
59
+ private _mainSeries;
60
+ private _htfSeries;
61
+ private _separator;
62
+ private _options;
63
+ private _barDuration;
64
+ constructor(options: MTFOptions);
65
+ attach(chart: IChartApi, mainSeries: ISeriesApi<any>, mainData: OHLCData[]): void;
66
+ detach(): void;
67
+ update(mainData: OHLCData[], options?: Partial<MTFOptions>): void;
68
+ updateColumnData(columnIndex: number, data: OHLCData[]): void;
69
+ private _createHTFSeries;
70
+ private _removeHTFSeries;
71
+ private _render;
72
+ private _detectBarDuration;
73
+ private _getTimeAsNumber;
74
+ private _resolveOptions;
75
+ }
76
+
77
+ type AggregationPeriod = '4H' | '8H' | '12H' | '1D' | '1W' | '1M' | '3M' | '6M' | '1Y';
78
+ declare function aggregateOHLC(data: OHLCData[], period: AggregationPeriod): OHLCData[];
79
+ declare function aggregateToWeekly(data: OHLCData[]): OHLCData[];
80
+ declare function aggregateToMonthly(data: OHLCData[]): OHLCData[];
81
+
82
+ export { type AggregationPeriod, type HTFColumnConfig, type HTFTimeframe, type MTFOptions, MTFPrimitive, type OHLCData, aggregateOHLC, aggregateToMonthly, aggregateToWeekly };
@@ -0,0 +1,82 @@
1
+ import { Time, IChartApi, ISeriesApi } from 'lightweight-charts';
2
+
3
+ /** OHLC candle data for a single timeframe period */
4
+ interface OHLCData {
5
+ time: Time;
6
+ open: number;
7
+ high: number;
8
+ low: number;
9
+ close: number;
10
+ volume?: number;
11
+ }
12
+ /** Supported higher timeframes — any string label is accepted */
13
+ type HTFTimeframe = string;
14
+ /** Configuration for a single HTF column displayed to the right */
15
+ interface HTFColumnConfig {
16
+ /** Timeframe label (e.g., '1W', '1M') */
17
+ timeframe: HTFTimeframe;
18
+ /** OHLC data for this timeframe — most recent N candles */
19
+ data: OHLCData[];
20
+ /** Number of candles to display (default: 6) */
21
+ candleCount?: number;
22
+ /** Up candle color */
23
+ upColor?: string;
24
+ /** Down candle color */
25
+ downColor?: string;
26
+ /** Wick color for up candles (defaults to upColor) */
27
+ wickUpColor?: string;
28
+ /** Wick color for down candles (defaults to downColor) */
29
+ wickDownColor?: string;
30
+ /** Border visible on candle bodies (default: false) */
31
+ borderVisible?: boolean;
32
+ /** Border up color */
33
+ borderUpColor?: string;
34
+ /** Border down color */
35
+ borderDownColor?: string;
36
+ }
37
+ /** Options for the MTF plugin */
38
+ interface MTFOptions {
39
+ /** Array of HTF columns to display, ordered left-to-right */
40
+ columns: HTFColumnConfig[];
41
+ /** Number of whitespace bars between main chart and first HTF column (default: 5) */
42
+ gap?: number;
43
+ /** Number of whitespace bars between HTF columns (default: 2) */
44
+ columnGap?: number;
45
+ /** Show separator lines between columns (default: true) */
46
+ showSeparators?: boolean;
47
+ /** Separator line color (default: 'rgba(255,255,255,0.08)') */
48
+ separatorColor?: string;
49
+ /** Show timeframe labels (default: true) */
50
+ showLabels?: boolean;
51
+ /** Label font (default: '11px sans-serif') */
52
+ labelFont?: string;
53
+ /** Label color (default: 'rgba(255,255,255,0.5)') */
54
+ labelColor?: string;
55
+ }
56
+
57
+ declare class MTFPrimitive {
58
+ private _chart;
59
+ private _mainSeries;
60
+ private _htfSeries;
61
+ private _separator;
62
+ private _options;
63
+ private _barDuration;
64
+ constructor(options: MTFOptions);
65
+ attach(chart: IChartApi, mainSeries: ISeriesApi<any>, mainData: OHLCData[]): void;
66
+ detach(): void;
67
+ update(mainData: OHLCData[], options?: Partial<MTFOptions>): void;
68
+ updateColumnData(columnIndex: number, data: OHLCData[]): void;
69
+ private _createHTFSeries;
70
+ private _removeHTFSeries;
71
+ private _render;
72
+ private _detectBarDuration;
73
+ private _getTimeAsNumber;
74
+ private _resolveOptions;
75
+ }
76
+
77
+ type AggregationPeriod = '4H' | '8H' | '12H' | '1D' | '1W' | '1M' | '3M' | '6M' | '1Y';
78
+ declare function aggregateOHLC(data: OHLCData[], period: AggregationPeriod): OHLCData[];
79
+ declare function aggregateToWeekly(data: OHLCData[]): OHLCData[];
80
+ declare function aggregateToMonthly(data: OHLCData[]): OHLCData[];
81
+
82
+ export { type AggregationPeriod, type HTFColumnConfig, type HTFTimeframe, type MTFOptions, MTFPrimitive, type OHLCData, aggregateOHLC, aggregateToMonthly, aggregateToWeekly };
package/dist/index.js ADDED
@@ -0,0 +1,358 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ MTFPrimitive: () => MTFPrimitive,
24
+ aggregateOHLC: () => aggregateOHLC,
25
+ aggregateToMonthly: () => aggregateToMonthly,
26
+ aggregateToWeekly: () => aggregateToWeekly
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/mtf-primitive.ts
31
+ var import_lightweight_charts = require("lightweight-charts");
32
+ var DEFAULT_OPTIONS = {
33
+ gap: 5,
34
+ columnGap: 2,
35
+ showSeparators: true,
36
+ separatorColor: "rgba(255,255,255,0.08)",
37
+ showLabels: true,
38
+ labelFont: "11px sans-serif",
39
+ labelColor: "rgba(255,255,255,0.5)"
40
+ };
41
+ var DEFAULT_COLUMN = {
42
+ candleCount: 6,
43
+ upColor: "#26a69a",
44
+ downColor: "#ef5350",
45
+ wickUpColor: "#26a69a",
46
+ wickDownColor: "#ef5350",
47
+ borderVisible: false,
48
+ borderUpColor: "#26a69a",
49
+ borderDownColor: "#ef5350"
50
+ };
51
+ var MTFPrimitive = class {
52
+ constructor(options) {
53
+ this._chart = null;
54
+ this._mainSeries = null;
55
+ this._htfSeries = [];
56
+ this._separator = null;
57
+ this._barDuration = 86400;
58
+ this._options = this._resolveOptions(options);
59
+ }
60
+ attach(chart, mainSeries, mainData) {
61
+ this._chart = chart;
62
+ this._mainSeries = mainSeries;
63
+ this._barDuration = this._detectBarDuration(mainData);
64
+ this._createHTFSeries();
65
+ this._render(mainData);
66
+ }
67
+ detach() {
68
+ this._removeHTFSeries();
69
+ if (this._separator && this._mainSeries) {
70
+ this._mainSeries.detachPrimitive(this._separator);
71
+ this._separator = null;
72
+ }
73
+ this._chart = null;
74
+ this._mainSeries = null;
75
+ }
76
+ update(mainData, options) {
77
+ if (options) {
78
+ this._options = this._resolveOptions({ ...this._options, ...options });
79
+ this._removeHTFSeries();
80
+ this._createHTFSeries();
81
+ }
82
+ this._barDuration = this._detectBarDuration(mainData);
83
+ this._render(mainData);
84
+ }
85
+ updateColumnData(columnIndex, data) {
86
+ if (columnIndex < this._options.columns.length) {
87
+ this._options.columns[columnIndex].data = data;
88
+ }
89
+ }
90
+ _createHTFSeries() {
91
+ if (!this._chart) return;
92
+ for (const col of this._options.columns) {
93
+ const seriesOptions = {
94
+ upColor: col.upColor,
95
+ downColor: col.downColor,
96
+ wickUpColor: col.wickUpColor,
97
+ wickDownColor: col.wickDownColor,
98
+ borderVisible: col.borderVisible,
99
+ borderUpColor: col.borderUpColor,
100
+ borderDownColor: col.borderDownColor
101
+ };
102
+ const series = this._chart.addSeries(import_lightweight_charts.CandlestickSeries, seriesOptions);
103
+ this._htfSeries.push(series);
104
+ }
105
+ }
106
+ _removeHTFSeries() {
107
+ if (!this._chart) return;
108
+ for (const series of this._htfSeries) {
109
+ this._chart.removeSeries(series);
110
+ }
111
+ this._htfSeries = [];
112
+ }
113
+ _render(mainData) {
114
+ if (!this._chart || !this._mainSeries || mainData.length === 0) return;
115
+ const lastMainTime = this._getTimeAsNumber(mainData[mainData.length - 1].time);
116
+ const { columns, gap, columnGap } = this._options;
117
+ let currentOffset = gap;
118
+ const columnPositions = [];
119
+ for (let i = 0; i < columns.length; i++) {
120
+ const col = columns[i];
121
+ const visibleData = col.data.slice(-col.candleCount);
122
+ const count = visibleData.length;
123
+ columnPositions.push({ startOffset: currentOffset, count });
124
+ const htfData = visibleData.map((bar, idx) => ({
125
+ time: lastMainTime + (currentOffset + idx + 1) * this._barDuration,
126
+ open: bar.open,
127
+ high: bar.high,
128
+ low: bar.low,
129
+ close: bar.close
130
+ }));
131
+ this._htfSeries[i].setData(htfData);
132
+ currentOffset += count + columnGap;
133
+ }
134
+ const totalBars = currentOffset + 2;
135
+ const whitespace = [];
136
+ for (let i = 1; i <= totalBars; i++) {
137
+ whitespace.push({ time: lastMainTime + i * this._barDuration });
138
+ }
139
+ this._mainSeries.setData([...mainData, ...whitespace]);
140
+ if (this._separator && this._mainSeries) {
141
+ this._mainSeries.detachPrimitive(this._separator);
142
+ }
143
+ if (this._options.showSeparators || this._options.showLabels) {
144
+ this._separator = new MTFSeparatorPrimitive(
145
+ this._options,
146
+ lastMainTime,
147
+ this._barDuration,
148
+ columnPositions
149
+ );
150
+ this._mainSeries.attachPrimitive(this._separator);
151
+ }
152
+ this._chart.timeScale().fitContent();
153
+ }
154
+ _detectBarDuration(data) {
155
+ if (data.length < 2) return 86400;
156
+ const t1 = this._getTimeAsNumber(data[data.length - 1].time);
157
+ const t2 = this._getTimeAsNumber(data[data.length - 2].time);
158
+ return t1 - t2;
159
+ }
160
+ _getTimeAsNumber(time) {
161
+ if (typeof time === "number") return time;
162
+ if (typeof time === "string") return Math.floor(new Date(time).getTime() / 1e3);
163
+ return Math.floor(
164
+ (/* @__PURE__ */ new Date(`${time.year}-${String(time.month).padStart(2, "0")}-${String(time.day).padStart(2, "0")}`)).getTime() / 1e3
165
+ );
166
+ }
167
+ _resolveOptions(options) {
168
+ const columns = options.columns.map((col) => ({
169
+ timeframe: col.timeframe,
170
+ data: col.data,
171
+ candleCount: col.candleCount ?? DEFAULT_COLUMN.candleCount,
172
+ upColor: col.upColor ?? DEFAULT_COLUMN.upColor,
173
+ downColor: col.downColor ?? DEFAULT_COLUMN.downColor,
174
+ wickUpColor: col.wickUpColor ?? col.upColor ?? DEFAULT_COLUMN.wickUpColor,
175
+ wickDownColor: col.wickDownColor ?? col.downColor ?? DEFAULT_COLUMN.wickDownColor,
176
+ borderVisible: col.borderVisible ?? DEFAULT_COLUMN.borderVisible,
177
+ borderUpColor: col.borderUpColor ?? col.upColor ?? DEFAULT_COLUMN.borderUpColor,
178
+ borderDownColor: col.borderDownColor ?? col.downColor ?? DEFAULT_COLUMN.borderDownColor
179
+ }));
180
+ return {
181
+ gap: options.gap ?? DEFAULT_OPTIONS.gap,
182
+ columnGap: options.columnGap ?? DEFAULT_OPTIONS.columnGap,
183
+ showSeparators: options.showSeparators ?? DEFAULT_OPTIONS.showSeparators,
184
+ separatorColor: options.separatorColor ?? DEFAULT_OPTIONS.separatorColor,
185
+ showLabels: options.showLabels ?? DEFAULT_OPTIONS.showLabels,
186
+ labelFont: options.labelFont ?? DEFAULT_OPTIONS.labelFont,
187
+ labelColor: options.labelColor ?? DEFAULT_OPTIONS.labelColor,
188
+ columns
189
+ };
190
+ }
191
+ };
192
+ var MTFSeparatorPrimitive = class {
193
+ constructor(options, lastMainTime, barDuration, columnPositions) {
194
+ this._options = options;
195
+ this._lastMainTime = lastMainTime;
196
+ this._barDuration = barDuration;
197
+ this._columnPositions = columnPositions;
198
+ }
199
+ attached() {
200
+ }
201
+ detached() {
202
+ }
203
+ updateAllViews() {
204
+ }
205
+ paneViews() {
206
+ return [{ renderer: () => this, zOrder: () => "bottom" }];
207
+ }
208
+ draw(target) {
209
+ target.useBitmapCoordinateSpace((scope) => {
210
+ const ctx = scope.context;
211
+ const hRatio = scope.horizontalPixelRatio;
212
+ const vRatio = scope.verticalPixelRatio;
213
+ const chartHeight = scope.bitmapSize.height;
214
+ const timeScale = this._chart?.timeScale?.();
215
+ if (!timeScale) return;
216
+ if (this._options.showSeparators) {
217
+ ctx.strokeStyle = this._options.separatorColor;
218
+ ctx.lineWidth = 1 * hRatio;
219
+ ctx.setLineDash([4 * hRatio, 3 * hRatio]);
220
+ for (const pos of this._columnPositions) {
221
+ const sepTime = this._lastMainTime + pos.startOffset * this._barDuration;
222
+ const x = timeScale.timeToCoordinate(sepTime);
223
+ if (x === null) continue;
224
+ const px = x * hRatio;
225
+ ctx.beginPath();
226
+ ctx.moveTo(px, 0);
227
+ ctx.lineTo(px, chartHeight);
228
+ ctx.stroke();
229
+ }
230
+ ctx.setLineDash([]);
231
+ }
232
+ if (this._options.showLabels) {
233
+ ctx.font = this._options.labelFont;
234
+ ctx.fillStyle = this._options.labelColor;
235
+ ctx.textAlign = "center";
236
+ ctx.textBaseline = "top";
237
+ for (let i = 0; i < this._columnPositions.length; i++) {
238
+ const pos = this._columnPositions[i];
239
+ const midOffset = pos.startOffset + Math.floor(pos.count / 2) + 1;
240
+ const labelTime = this._lastMainTime + midOffset * this._barDuration;
241
+ const x = timeScale.timeToCoordinate(labelTime);
242
+ if (x === null) continue;
243
+ ctx.fillText(this._options.columns[i].timeframe, x * hRatio, 8 * vRatio);
244
+ }
245
+ }
246
+ });
247
+ }
248
+ };
249
+
250
+ // src/data-aggregator.ts
251
+ function aggregateOHLC(data, period) {
252
+ const getPeriodKey = getPeriodKeyFn(period);
253
+ return aggregateByPeriod(data, getPeriodKey);
254
+ }
255
+ function aggregateToWeekly(data) {
256
+ return aggregateByPeriod(data, getWeekStart);
257
+ }
258
+ function aggregateToMonthly(data) {
259
+ return aggregateByPeriod(data, getMonthStart);
260
+ }
261
+ function getPeriodKeyFn(period) {
262
+ switch (period) {
263
+ case "4H":
264
+ return (ts) => getHourBucket(ts, 4);
265
+ case "8H":
266
+ return (ts) => getHourBucket(ts, 8);
267
+ case "12H":
268
+ return (ts) => getHourBucket(ts, 12);
269
+ case "1D":
270
+ return getDayStart;
271
+ case "1W":
272
+ return getWeekStart;
273
+ case "1M":
274
+ return getMonthStart;
275
+ case "3M":
276
+ return (ts) => getQuarterStart(ts, 3);
277
+ case "6M":
278
+ return (ts) => getQuarterStart(ts, 6);
279
+ case "1Y":
280
+ return getYearStart;
281
+ }
282
+ }
283
+ function aggregateByPeriod(data, getPeriodKey) {
284
+ if (data.length === 0) return [];
285
+ const periods = /* @__PURE__ */ new Map();
286
+ for (const bar of data) {
287
+ const timestamp = timeToTimestamp(bar.time);
288
+ const periodKey = getPeriodKey(timestamp);
289
+ const existing = periods.get(periodKey);
290
+ if (!existing) {
291
+ periods.set(periodKey, {
292
+ time: bar.time,
293
+ open: bar.open,
294
+ high: bar.high,
295
+ low: bar.low,
296
+ close: bar.close,
297
+ volume: bar.volume ?? 0
298
+ });
299
+ } else {
300
+ existing.high = Math.max(existing.high, bar.high);
301
+ existing.low = Math.min(existing.low, bar.low);
302
+ existing.close = bar.close;
303
+ existing.volume = (existing.volume ?? 0) + (bar.volume ?? 0);
304
+ }
305
+ }
306
+ return Array.from(periods.values());
307
+ }
308
+ function timeToTimestamp(time) {
309
+ if (typeof time === "number") return time;
310
+ if (typeof time === "string") return Math.floor(new Date(time).getTime() / 1e3);
311
+ return Math.floor(
312
+ (/* @__PURE__ */ new Date(`${time.year}-${String(time.month).padStart(2, "0")}-${String(time.day).padStart(2, "0")}`)).getTime() / 1e3
313
+ );
314
+ }
315
+ function getHourBucket(timestamp, hours) {
316
+ const bucketSeconds = hours * 3600;
317
+ return Math.floor(timestamp / bucketSeconds) * bucketSeconds;
318
+ }
319
+ function getDayStart(timestamp) {
320
+ const date = new Date(timestamp * 1e3);
321
+ date.setUTCHours(0, 0, 0, 0);
322
+ return Math.floor(date.getTime() / 1e3);
323
+ }
324
+ function getWeekStart(timestamp) {
325
+ const date = new Date(timestamp * 1e3);
326
+ const day = date.getUTCDay();
327
+ const diff = day === 0 ? 6 : day - 1;
328
+ date.setUTCDate(date.getUTCDate() - diff);
329
+ date.setUTCHours(0, 0, 0, 0);
330
+ return Math.floor(date.getTime() / 1e3);
331
+ }
332
+ function getMonthStart(timestamp) {
333
+ const date = new Date(timestamp * 1e3);
334
+ date.setUTCDate(1);
335
+ date.setUTCHours(0, 0, 0, 0);
336
+ return Math.floor(date.getTime() / 1e3);
337
+ }
338
+ function getQuarterStart(timestamp, months) {
339
+ const date = new Date(timestamp * 1e3);
340
+ const month = date.getUTCMonth();
341
+ const quarterMonth = Math.floor(month / months) * months;
342
+ date.setUTCMonth(quarterMonth, 1);
343
+ date.setUTCHours(0, 0, 0, 0);
344
+ return Math.floor(date.getTime() / 1e3);
345
+ }
346
+ function getYearStart(timestamp) {
347
+ const date = new Date(timestamp * 1e3);
348
+ date.setUTCMonth(0, 1);
349
+ date.setUTCHours(0, 0, 0, 0);
350
+ return Math.floor(date.getTime() / 1e3);
351
+ }
352
+ // Annotate the CommonJS export names for ESM import in node:
353
+ 0 && (module.exports = {
354
+ MTFPrimitive,
355
+ aggregateOHLC,
356
+ aggregateToMonthly,
357
+ aggregateToWeekly
358
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,328 @@
1
+ // src/mtf-primitive.ts
2
+ import { CandlestickSeries } from "lightweight-charts";
3
+ var DEFAULT_OPTIONS = {
4
+ gap: 5,
5
+ columnGap: 2,
6
+ showSeparators: true,
7
+ separatorColor: "rgba(255,255,255,0.08)",
8
+ showLabels: true,
9
+ labelFont: "11px sans-serif",
10
+ labelColor: "rgba(255,255,255,0.5)"
11
+ };
12
+ var DEFAULT_COLUMN = {
13
+ candleCount: 6,
14
+ upColor: "#26a69a",
15
+ downColor: "#ef5350",
16
+ wickUpColor: "#26a69a",
17
+ wickDownColor: "#ef5350",
18
+ borderVisible: false,
19
+ borderUpColor: "#26a69a",
20
+ borderDownColor: "#ef5350"
21
+ };
22
+ var MTFPrimitive = class {
23
+ constructor(options) {
24
+ this._chart = null;
25
+ this._mainSeries = null;
26
+ this._htfSeries = [];
27
+ this._separator = null;
28
+ this._barDuration = 86400;
29
+ this._options = this._resolveOptions(options);
30
+ }
31
+ attach(chart, mainSeries, mainData) {
32
+ this._chart = chart;
33
+ this._mainSeries = mainSeries;
34
+ this._barDuration = this._detectBarDuration(mainData);
35
+ this._createHTFSeries();
36
+ this._render(mainData);
37
+ }
38
+ detach() {
39
+ this._removeHTFSeries();
40
+ if (this._separator && this._mainSeries) {
41
+ this._mainSeries.detachPrimitive(this._separator);
42
+ this._separator = null;
43
+ }
44
+ this._chart = null;
45
+ this._mainSeries = null;
46
+ }
47
+ update(mainData, options) {
48
+ if (options) {
49
+ this._options = this._resolveOptions({ ...this._options, ...options });
50
+ this._removeHTFSeries();
51
+ this._createHTFSeries();
52
+ }
53
+ this._barDuration = this._detectBarDuration(mainData);
54
+ this._render(mainData);
55
+ }
56
+ updateColumnData(columnIndex, data) {
57
+ if (columnIndex < this._options.columns.length) {
58
+ this._options.columns[columnIndex].data = data;
59
+ }
60
+ }
61
+ _createHTFSeries() {
62
+ if (!this._chart) return;
63
+ for (const col of this._options.columns) {
64
+ const seriesOptions = {
65
+ upColor: col.upColor,
66
+ downColor: col.downColor,
67
+ wickUpColor: col.wickUpColor,
68
+ wickDownColor: col.wickDownColor,
69
+ borderVisible: col.borderVisible,
70
+ borderUpColor: col.borderUpColor,
71
+ borderDownColor: col.borderDownColor
72
+ };
73
+ const series = this._chart.addSeries(CandlestickSeries, seriesOptions);
74
+ this._htfSeries.push(series);
75
+ }
76
+ }
77
+ _removeHTFSeries() {
78
+ if (!this._chart) return;
79
+ for (const series of this._htfSeries) {
80
+ this._chart.removeSeries(series);
81
+ }
82
+ this._htfSeries = [];
83
+ }
84
+ _render(mainData) {
85
+ if (!this._chart || !this._mainSeries || mainData.length === 0) return;
86
+ const lastMainTime = this._getTimeAsNumber(mainData[mainData.length - 1].time);
87
+ const { columns, gap, columnGap } = this._options;
88
+ let currentOffset = gap;
89
+ const columnPositions = [];
90
+ for (let i = 0; i < columns.length; i++) {
91
+ const col = columns[i];
92
+ const visibleData = col.data.slice(-col.candleCount);
93
+ const count = visibleData.length;
94
+ columnPositions.push({ startOffset: currentOffset, count });
95
+ const htfData = visibleData.map((bar, idx) => ({
96
+ time: lastMainTime + (currentOffset + idx + 1) * this._barDuration,
97
+ open: bar.open,
98
+ high: bar.high,
99
+ low: bar.low,
100
+ close: bar.close
101
+ }));
102
+ this._htfSeries[i].setData(htfData);
103
+ currentOffset += count + columnGap;
104
+ }
105
+ const totalBars = currentOffset + 2;
106
+ const whitespace = [];
107
+ for (let i = 1; i <= totalBars; i++) {
108
+ whitespace.push({ time: lastMainTime + i * this._barDuration });
109
+ }
110
+ this._mainSeries.setData([...mainData, ...whitespace]);
111
+ if (this._separator && this._mainSeries) {
112
+ this._mainSeries.detachPrimitive(this._separator);
113
+ }
114
+ if (this._options.showSeparators || this._options.showLabels) {
115
+ this._separator = new MTFSeparatorPrimitive(
116
+ this._options,
117
+ lastMainTime,
118
+ this._barDuration,
119
+ columnPositions
120
+ );
121
+ this._mainSeries.attachPrimitive(this._separator);
122
+ }
123
+ this._chart.timeScale().fitContent();
124
+ }
125
+ _detectBarDuration(data) {
126
+ if (data.length < 2) return 86400;
127
+ const t1 = this._getTimeAsNumber(data[data.length - 1].time);
128
+ const t2 = this._getTimeAsNumber(data[data.length - 2].time);
129
+ return t1 - t2;
130
+ }
131
+ _getTimeAsNumber(time) {
132
+ if (typeof time === "number") return time;
133
+ if (typeof time === "string") return Math.floor(new Date(time).getTime() / 1e3);
134
+ return Math.floor(
135
+ (/* @__PURE__ */ new Date(`${time.year}-${String(time.month).padStart(2, "0")}-${String(time.day).padStart(2, "0")}`)).getTime() / 1e3
136
+ );
137
+ }
138
+ _resolveOptions(options) {
139
+ const columns = options.columns.map((col) => ({
140
+ timeframe: col.timeframe,
141
+ data: col.data,
142
+ candleCount: col.candleCount ?? DEFAULT_COLUMN.candleCount,
143
+ upColor: col.upColor ?? DEFAULT_COLUMN.upColor,
144
+ downColor: col.downColor ?? DEFAULT_COLUMN.downColor,
145
+ wickUpColor: col.wickUpColor ?? col.upColor ?? DEFAULT_COLUMN.wickUpColor,
146
+ wickDownColor: col.wickDownColor ?? col.downColor ?? DEFAULT_COLUMN.wickDownColor,
147
+ borderVisible: col.borderVisible ?? DEFAULT_COLUMN.borderVisible,
148
+ borderUpColor: col.borderUpColor ?? col.upColor ?? DEFAULT_COLUMN.borderUpColor,
149
+ borderDownColor: col.borderDownColor ?? col.downColor ?? DEFAULT_COLUMN.borderDownColor
150
+ }));
151
+ return {
152
+ gap: options.gap ?? DEFAULT_OPTIONS.gap,
153
+ columnGap: options.columnGap ?? DEFAULT_OPTIONS.columnGap,
154
+ showSeparators: options.showSeparators ?? DEFAULT_OPTIONS.showSeparators,
155
+ separatorColor: options.separatorColor ?? DEFAULT_OPTIONS.separatorColor,
156
+ showLabels: options.showLabels ?? DEFAULT_OPTIONS.showLabels,
157
+ labelFont: options.labelFont ?? DEFAULT_OPTIONS.labelFont,
158
+ labelColor: options.labelColor ?? DEFAULT_OPTIONS.labelColor,
159
+ columns
160
+ };
161
+ }
162
+ };
163
+ var MTFSeparatorPrimitive = class {
164
+ constructor(options, lastMainTime, barDuration, columnPositions) {
165
+ this._options = options;
166
+ this._lastMainTime = lastMainTime;
167
+ this._barDuration = barDuration;
168
+ this._columnPositions = columnPositions;
169
+ }
170
+ attached() {
171
+ }
172
+ detached() {
173
+ }
174
+ updateAllViews() {
175
+ }
176
+ paneViews() {
177
+ return [{ renderer: () => this, zOrder: () => "bottom" }];
178
+ }
179
+ draw(target) {
180
+ target.useBitmapCoordinateSpace((scope) => {
181
+ const ctx = scope.context;
182
+ const hRatio = scope.horizontalPixelRatio;
183
+ const vRatio = scope.verticalPixelRatio;
184
+ const chartHeight = scope.bitmapSize.height;
185
+ const timeScale = this._chart?.timeScale?.();
186
+ if (!timeScale) return;
187
+ if (this._options.showSeparators) {
188
+ ctx.strokeStyle = this._options.separatorColor;
189
+ ctx.lineWidth = 1 * hRatio;
190
+ ctx.setLineDash([4 * hRatio, 3 * hRatio]);
191
+ for (const pos of this._columnPositions) {
192
+ const sepTime = this._lastMainTime + pos.startOffset * this._barDuration;
193
+ const x = timeScale.timeToCoordinate(sepTime);
194
+ if (x === null) continue;
195
+ const px = x * hRatio;
196
+ ctx.beginPath();
197
+ ctx.moveTo(px, 0);
198
+ ctx.lineTo(px, chartHeight);
199
+ ctx.stroke();
200
+ }
201
+ ctx.setLineDash([]);
202
+ }
203
+ if (this._options.showLabels) {
204
+ ctx.font = this._options.labelFont;
205
+ ctx.fillStyle = this._options.labelColor;
206
+ ctx.textAlign = "center";
207
+ ctx.textBaseline = "top";
208
+ for (let i = 0; i < this._columnPositions.length; i++) {
209
+ const pos = this._columnPositions[i];
210
+ const midOffset = pos.startOffset + Math.floor(pos.count / 2) + 1;
211
+ const labelTime = this._lastMainTime + midOffset * this._barDuration;
212
+ const x = timeScale.timeToCoordinate(labelTime);
213
+ if (x === null) continue;
214
+ ctx.fillText(this._options.columns[i].timeframe, x * hRatio, 8 * vRatio);
215
+ }
216
+ }
217
+ });
218
+ }
219
+ };
220
+
221
+ // src/data-aggregator.ts
222
+ function aggregateOHLC(data, period) {
223
+ const getPeriodKey = getPeriodKeyFn(period);
224
+ return aggregateByPeriod(data, getPeriodKey);
225
+ }
226
+ function aggregateToWeekly(data) {
227
+ return aggregateByPeriod(data, getWeekStart);
228
+ }
229
+ function aggregateToMonthly(data) {
230
+ return aggregateByPeriod(data, getMonthStart);
231
+ }
232
+ function getPeriodKeyFn(period) {
233
+ switch (period) {
234
+ case "4H":
235
+ return (ts) => getHourBucket(ts, 4);
236
+ case "8H":
237
+ return (ts) => getHourBucket(ts, 8);
238
+ case "12H":
239
+ return (ts) => getHourBucket(ts, 12);
240
+ case "1D":
241
+ return getDayStart;
242
+ case "1W":
243
+ return getWeekStart;
244
+ case "1M":
245
+ return getMonthStart;
246
+ case "3M":
247
+ return (ts) => getQuarterStart(ts, 3);
248
+ case "6M":
249
+ return (ts) => getQuarterStart(ts, 6);
250
+ case "1Y":
251
+ return getYearStart;
252
+ }
253
+ }
254
+ function aggregateByPeriod(data, getPeriodKey) {
255
+ if (data.length === 0) return [];
256
+ const periods = /* @__PURE__ */ new Map();
257
+ for (const bar of data) {
258
+ const timestamp = timeToTimestamp(bar.time);
259
+ const periodKey = getPeriodKey(timestamp);
260
+ const existing = periods.get(periodKey);
261
+ if (!existing) {
262
+ periods.set(periodKey, {
263
+ time: bar.time,
264
+ open: bar.open,
265
+ high: bar.high,
266
+ low: bar.low,
267
+ close: bar.close,
268
+ volume: bar.volume ?? 0
269
+ });
270
+ } else {
271
+ existing.high = Math.max(existing.high, bar.high);
272
+ existing.low = Math.min(existing.low, bar.low);
273
+ existing.close = bar.close;
274
+ existing.volume = (existing.volume ?? 0) + (bar.volume ?? 0);
275
+ }
276
+ }
277
+ return Array.from(periods.values());
278
+ }
279
+ function timeToTimestamp(time) {
280
+ if (typeof time === "number") return time;
281
+ if (typeof time === "string") return Math.floor(new Date(time).getTime() / 1e3);
282
+ return Math.floor(
283
+ (/* @__PURE__ */ new Date(`${time.year}-${String(time.month).padStart(2, "0")}-${String(time.day).padStart(2, "0")}`)).getTime() / 1e3
284
+ );
285
+ }
286
+ function getHourBucket(timestamp, hours) {
287
+ const bucketSeconds = hours * 3600;
288
+ return Math.floor(timestamp / bucketSeconds) * bucketSeconds;
289
+ }
290
+ function getDayStart(timestamp) {
291
+ const date = new Date(timestamp * 1e3);
292
+ date.setUTCHours(0, 0, 0, 0);
293
+ return Math.floor(date.getTime() / 1e3);
294
+ }
295
+ function getWeekStart(timestamp) {
296
+ const date = new Date(timestamp * 1e3);
297
+ const day = date.getUTCDay();
298
+ const diff = day === 0 ? 6 : day - 1;
299
+ date.setUTCDate(date.getUTCDate() - diff);
300
+ date.setUTCHours(0, 0, 0, 0);
301
+ return Math.floor(date.getTime() / 1e3);
302
+ }
303
+ function getMonthStart(timestamp) {
304
+ const date = new Date(timestamp * 1e3);
305
+ date.setUTCDate(1);
306
+ date.setUTCHours(0, 0, 0, 0);
307
+ return Math.floor(date.getTime() / 1e3);
308
+ }
309
+ function getQuarterStart(timestamp, months) {
310
+ const date = new Date(timestamp * 1e3);
311
+ const month = date.getUTCMonth();
312
+ const quarterMonth = Math.floor(month / months) * months;
313
+ date.setUTCMonth(quarterMonth, 1);
314
+ date.setUTCHours(0, 0, 0, 0);
315
+ return Math.floor(date.getTime() / 1e3);
316
+ }
317
+ function getYearStart(timestamp) {
318
+ const date = new Date(timestamp * 1e3);
319
+ date.setUTCMonth(0, 1);
320
+ date.setUTCHours(0, 0, 0, 0);
321
+ return Math.floor(date.getTime() / 1e3);
322
+ }
323
+ export {
324
+ MTFPrimitive,
325
+ aggregateOHLC,
326
+ aggregateToMonthly,
327
+ aggregateToWeekly
328
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "lightweight-charts-mtf",
3
+ "version": "0.1.0",
4
+ "description": "Multi-timeframe candle display plugin for TradingView Lightweight Charts",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/nsulistiyawan/lightweight-charts-mtf.git"
14
+ },
15
+ "homepage": "https://github.com/nsulistiyawan/lightweight-charts-mtf#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/nsulistiyawan/lightweight-charts-mtf/issues"
18
+ },
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
21
+ "dev": "tsup src/index.ts --format esm --dts --watch",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "keywords": [
27
+ "tradingview",
28
+ "lightweight-charts",
29
+ "multi-timeframe",
30
+ "mtf",
31
+ "plugin",
32
+ "candlestick",
33
+ "trading"
34
+ ],
35
+ "license": "MIT",
36
+ "peerDependencies": {
37
+ "lightweight-charts": ">=4.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "lightweight-charts": "^5.0.0",
41
+ "tsup": "^8.0.0",
42
+ "typescript": "^5.4.0",
43
+ "vitest": "^3.2.4"
44
+ }
45
+ }