hyperprop-charting-library 0.1.20 → 0.1.22

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/dist/index.js CHANGED
@@ -145,8 +145,481 @@ var DEFAULT_OPTIONS = {
145
145
  labelTextColor: "#0b1220",
146
146
  labelBorderRadius: 3
147
147
  },
148
- dashPatterns: DEFAULT_DASH_PATTERNS
148
+ dashPatterns: DEFAULT_DASH_PATTERNS,
149
+ indicators: []
149
150
  };
151
+ var getIndicatorSourceValue = (point, source) => {
152
+ if (source === "open") return point.o;
153
+ if (source === "high") return point.h;
154
+ if (source === "low") return point.l;
155
+ if (source === "hl2") return (point.h + point.l) / 2;
156
+ return point.c;
157
+ };
158
+ var clampIndicatorLength = (value, fallback) => {
159
+ return Math.max(1, Math.round(Number(value) || fallback));
160
+ };
161
+ var computeSmaSeries = (data, length, source) => {
162
+ const result = new Array(data.length).fill(null);
163
+ let sum = 0;
164
+ for (let i = 0; i < data.length; i += 1) {
165
+ const point = data[i];
166
+ if (!point) continue;
167
+ const value = getIndicatorSourceValue(point, source);
168
+ sum += value;
169
+ if (i >= length) {
170
+ const oldPoint = data[i - length];
171
+ if (oldPoint) {
172
+ sum -= getIndicatorSourceValue(oldPoint, source);
173
+ }
174
+ }
175
+ if (i >= length - 1) {
176
+ result[i] = sum / length;
177
+ }
178
+ }
179
+ return result;
180
+ };
181
+ var computeEmaSeries = (data, length, source) => {
182
+ const result = new Array(data.length).fill(null);
183
+ const alpha = 2 / (length + 1);
184
+ let prev = null;
185
+ for (let i = 0; i < data.length; i += 1) {
186
+ const point = data[i];
187
+ if (!point) continue;
188
+ const value = getIndicatorSourceValue(point, source);
189
+ prev = prev === null ? value : alpha * value + (1 - alpha) * prev;
190
+ if (i >= length - 1) {
191
+ result[i] = prev;
192
+ }
193
+ }
194
+ return result;
195
+ };
196
+ var computeRmaSeries = (data, length, source) => {
197
+ const result = new Array(data.length).fill(null);
198
+ let prev = null;
199
+ let seedSum = 0;
200
+ for (let i = 0; i < data.length; i += 1) {
201
+ const point = data[i];
202
+ if (!point) continue;
203
+ const value = getIndicatorSourceValue(point, source);
204
+ if (i < length) {
205
+ seedSum += value;
206
+ if (i === length - 1) {
207
+ prev = seedSum / length;
208
+ result[i] = prev;
209
+ }
210
+ continue;
211
+ }
212
+ prev = prev === null ? value : (prev * (length - 1) + value) / length;
213
+ result[i] = prev;
214
+ }
215
+ return result;
216
+ };
217
+ var computeWmaSeriesFromValues = (values, length) => {
218
+ const result = new Array(values.length).fill(null);
219
+ const denominator = length * (length + 1) / 2;
220
+ for (let i = 0; i < values.length; i += 1) {
221
+ if (i < length - 1) continue;
222
+ let weighted = 0;
223
+ let valid = true;
224
+ for (let j = 0; j < length; j += 1) {
225
+ const value = values[i - length + 1 + j];
226
+ if (value == null) {
227
+ valid = false;
228
+ break;
229
+ }
230
+ weighted += value * (j + 1);
231
+ }
232
+ if (valid) {
233
+ result[i] = weighted / denominator;
234
+ }
235
+ }
236
+ return result;
237
+ };
238
+ var computeWmaSeries = (data, length, source) => {
239
+ const sourceValues = data.map((point) => getIndicatorSourceValue(point, source));
240
+ return computeWmaSeriesFromValues(sourceValues, length);
241
+ };
242
+ var computeVwmaSeries = (data, length, source) => {
243
+ const result = new Array(data.length).fill(null);
244
+ let sumPv = 0;
245
+ let sumV = 0;
246
+ for (let i = 0; i < data.length; i += 1) {
247
+ const point = data[i];
248
+ if (!point) continue;
249
+ const sourceValue = getIndicatorSourceValue(point, source);
250
+ const volume = Math.max(0, point.v ?? 0);
251
+ sumPv += sourceValue * volume;
252
+ sumV += volume;
253
+ if (i >= length) {
254
+ const oldPoint = data[i - length];
255
+ if (oldPoint) {
256
+ const oldSource = getIndicatorSourceValue(oldPoint, source);
257
+ const oldVolume = Math.max(0, oldPoint.v ?? 0);
258
+ sumPv -= oldSource * oldVolume;
259
+ sumV -= oldVolume;
260
+ }
261
+ }
262
+ if (i >= length - 1 && sumV > 0) {
263
+ result[i] = sumPv / sumV;
264
+ }
265
+ }
266
+ return result;
267
+ };
268
+ var computeHmaSeries = (data, length, source) => {
269
+ const halfLength = Math.max(1, Math.floor(length / 2));
270
+ const sqrtLength = Math.max(1, Math.round(Math.sqrt(length)));
271
+ const wmaHalf = computeWmaSeries(data, halfLength, source);
272
+ const wmaFull = computeWmaSeries(data, length, source);
273
+ const diff = wmaHalf.map((value, idx) => {
274
+ const full = wmaFull[idx];
275
+ if (value == null || full == null) return null;
276
+ return 2 * value - full;
277
+ });
278
+ return computeWmaSeriesFromValues(diff, sqrtLength);
279
+ };
280
+ var computeStdDevSeries = (data, length, source) => {
281
+ const result = new Array(data.length).fill(null);
282
+ const values = data.map((point) => point ? getIndicatorSourceValue(point, source) : 0);
283
+ for (let i = 0; i < values.length; i += 1) {
284
+ if (i < length - 1) continue;
285
+ let sum = 0;
286
+ for (let j = 0; j < length; j += 1) {
287
+ sum += values[i - j] ?? 0;
288
+ }
289
+ const mean = sum / length;
290
+ let variance = 0;
291
+ for (let j = 0; j < length; j += 1) {
292
+ const delta = (values[i - j] ?? 0) - mean;
293
+ variance += delta * delta;
294
+ }
295
+ result[i] = Math.sqrt(variance / length);
296
+ }
297
+ return result;
298
+ };
299
+ var computeRsiSeries = (data, length) => {
300
+ const result = new Array(data.length).fill(null);
301
+ if (data.length < 2) return result;
302
+ let avgGain = 0;
303
+ let avgLoss = 0;
304
+ let seeded = false;
305
+ for (let i = 1; i < data.length; i += 1) {
306
+ const point = data[i];
307
+ const prevPoint = data[i - 1];
308
+ if (!point || !prevPoint) continue;
309
+ const delta = point.c - prevPoint.c;
310
+ const gain = Math.max(0, delta);
311
+ const loss = Math.max(0, -delta);
312
+ if (i <= length) {
313
+ avgGain += gain;
314
+ avgLoss += loss;
315
+ if (i === length) {
316
+ avgGain /= length;
317
+ avgLoss /= length;
318
+ seeded = true;
319
+ }
320
+ } else if (seeded) {
321
+ avgGain = (avgGain * (length - 1) + gain) / length;
322
+ avgLoss = (avgLoss * (length - 1) + loss) / length;
323
+ }
324
+ if (seeded && i >= length) {
325
+ if (avgLoss === 0) {
326
+ result[i] = 100;
327
+ } else {
328
+ const rs = avgGain / avgLoss;
329
+ result[i] = 100 - 100 / (1 + rs);
330
+ }
331
+ }
332
+ }
333
+ return result;
334
+ };
335
+ var computeAtrSeries = (data, length) => {
336
+ const result = new Array(data.length).fill(null);
337
+ if (data.length === 0) return result;
338
+ const trueRanges = [];
339
+ for (let i = 0; i < data.length; i += 1) {
340
+ const point = data[i];
341
+ if (!point) continue;
342
+ const prevPoint = i > 0 ? data[i - 1] : null;
343
+ const prevClose = prevPoint ? prevPoint.c : point.c;
344
+ const tr = Math.max(point.h - point.l, Math.abs(point.h - prevClose), Math.abs(point.l - prevClose));
345
+ trueRanges.push(tr);
346
+ }
347
+ let seed = 0;
348
+ let prevAtr = null;
349
+ for (let i = 0; i < trueRanges.length; i += 1) {
350
+ const tr = trueRanges[i];
351
+ if (tr === void 0) continue;
352
+ if (i < length) {
353
+ seed += tr;
354
+ if (i === length - 1) {
355
+ prevAtr = seed / length;
356
+ result[i] = prevAtr;
357
+ }
358
+ continue;
359
+ }
360
+ prevAtr = prevAtr === null ? tr : (prevAtr * (length - 1) + tr) / length;
361
+ result[i] = prevAtr;
362
+ }
363
+ return result;
364
+ };
365
+ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
366
+ if (!renderContext.yFromPrice) return;
367
+ const yFromPrice = renderContext.yFromPrice;
368
+ ctx.save();
369
+ ctx.strokeStyle = color;
370
+ ctx.lineWidth = Math.max(1, width);
371
+ ctx.setLineDash([]);
372
+ let drawing = false;
373
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
374
+ const value = values[index];
375
+ if (!Number.isFinite(value ?? Number.NaN)) {
376
+ drawing = false;
377
+ continue;
378
+ }
379
+ const x = renderContext.xFromIndex(index);
380
+ const y = yFromPrice(value);
381
+ if (!drawing) {
382
+ ctx.beginPath();
383
+ ctx.moveTo(x, y);
384
+ drawing = true;
385
+ } else {
386
+ ctx.lineTo(x, y);
387
+ }
388
+ const nextValue = values[index + 1];
389
+ if (!Number.isFinite(nextValue ?? Number.NaN) || index === renderContext.endIndex) {
390
+ ctx.stroke();
391
+ drawing = false;
392
+ }
393
+ }
394
+ ctx.restore();
395
+ };
396
+ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines) => {
397
+ const visible = [];
398
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
399
+ const value = values[index];
400
+ if (Number.isFinite(value ?? Number.NaN)) {
401
+ visible.push(value);
402
+ }
403
+ }
404
+ if (visible.length === 0) return;
405
+ const minValue = minOverride ?? Math.min(...visible);
406
+ const maxValue = maxOverride ?? Math.max(...visible);
407
+ const range = maxValue - minValue || 1;
408
+ const yFromValue = (value) => {
409
+ const ratio = (value - minValue) / range;
410
+ return renderContext.chartBottom - ratio * renderContext.chartHeight;
411
+ };
412
+ if (guideLines && guideLines.length > 0) {
413
+ ctx.save();
414
+ ctx.strokeStyle = "rgba(148,163,184,0.35)";
415
+ ctx.lineWidth = 1;
416
+ ctx.setLineDash([4, 4]);
417
+ for (const guide of guideLines) {
418
+ const y = yFromValue(guide);
419
+ ctx.beginPath();
420
+ ctx.moveTo(renderContext.chartLeft, y);
421
+ ctx.lineTo(renderContext.chartRight, y);
422
+ ctx.stroke();
423
+ }
424
+ ctx.restore();
425
+ }
426
+ ctx.save();
427
+ ctx.strokeStyle = color;
428
+ ctx.lineWidth = Math.max(1, width);
429
+ ctx.setLineDash([]);
430
+ let drawing = false;
431
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
432
+ const value = values[index];
433
+ if (!Number.isFinite(value ?? Number.NaN)) {
434
+ drawing = false;
435
+ continue;
436
+ }
437
+ const x = renderContext.xFromIndex(index);
438
+ const y = yFromValue(value);
439
+ if (!drawing) {
440
+ ctx.beginPath();
441
+ ctx.moveTo(x, y);
442
+ drawing = true;
443
+ } else {
444
+ ctx.lineTo(x, y);
445
+ }
446
+ const nextValue = values[index + 1];
447
+ if (!Number.isFinite(nextValue ?? Number.NaN) || index === renderContext.endIndex) {
448
+ ctx.stroke();
449
+ drawing = false;
450
+ }
451
+ }
452
+ ctx.restore();
453
+ };
454
+ var BUILTIN_VOLUME_INDICATOR = {
455
+ id: "volume",
456
+ name: "Volume",
457
+ pane: "overlay",
458
+ paneHeightRatio: 0.22,
459
+ defaultInputs: {
460
+ upOpacity: 0.7,
461
+ downOpacity: 0.7,
462
+ minBarWidth: 1,
463
+ overlayHeightRatio: 0.22
464
+ },
465
+ draw: (ctx, renderContext, inputs) => {
466
+ const { data, startIndex, endIndex, chartTop, chartBottom, candleSpacing, xFromIndex, upColor, downColor } = renderContext;
467
+ const fullPaneHeight = Math.max(1, chartBottom - chartTop);
468
+ const overlayMode = renderContext.yFromPrice !== null;
469
+ const overlayHeightRatio = Math.min(0.45, Math.max(0.08, Number(inputs.overlayHeightRatio) || 0.22));
470
+ const paneHeight = overlayMode ? Math.max(20, Math.round(fullPaneHeight * overlayHeightRatio)) : fullPaneHeight;
471
+ const paneBottom = chartBottom;
472
+ const visiblePoints = data.slice(startIndex, endIndex + 1);
473
+ const maxVolume = Math.max(1, ...visiblePoints.map((point) => point.v ?? 0));
474
+ const barWidth = Math.max(
475
+ Math.max(1, Number(inputs.minBarWidth) || 1),
476
+ Math.min(Math.max(1, candleSpacing - 1), Math.floor(candleSpacing * 0.7))
477
+ );
478
+ const upOpacity = Math.min(1, Math.max(0.05, Number(inputs.upOpacity) || 0.7));
479
+ const downOpacity = Math.min(1, Math.max(0.05, Number(inputs.downOpacity) || 0.7));
480
+ for (let index = startIndex; index <= endIndex; index += 1) {
481
+ const point = data[index];
482
+ if (!point || point.v === void 0 || point.v <= 0) {
483
+ continue;
484
+ }
485
+ const ratio = Math.min(1, Math.max(0, point.v / maxVolume));
486
+ const volumeHeight = Math.max(1, Math.round(paneHeight * ratio));
487
+ const xCenter = xFromIndex(index);
488
+ const barX = Math.round(xCenter - barWidth / 2);
489
+ const barY = Math.round(paneBottom - volumeHeight);
490
+ const isUp = point.c >= point.o;
491
+ const opacity = isUp ? upOpacity : downOpacity;
492
+ ctx.save();
493
+ ctx.globalAlpha = opacity;
494
+ ctx.fillStyle = isUp ? upColor : downColor;
495
+ ctx.fillRect(barX, barY, Math.max(1, Math.round(barWidth)), volumeHeight);
496
+ ctx.restore();
497
+ }
498
+ }
499
+ };
500
+ var BUILTIN_SMA_INDICATOR = {
501
+ id: "sma",
502
+ name: "SMA",
503
+ pane: "overlay",
504
+ defaultInputs: { length: 20, source: "close", color: "#60a5fa", width: 2 },
505
+ draw: (ctx, renderContext, inputs) => {
506
+ const length = clampIndicatorLength(inputs.length, 20);
507
+ const values = computeSmaSeries(renderContext.data, length, inputs.source ?? "close");
508
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#60a5fa", Number(inputs.width) || 2);
509
+ }
510
+ };
511
+ var BUILTIN_EMA_INDICATOR = {
512
+ id: "ema",
513
+ name: "EMA",
514
+ pane: "overlay",
515
+ defaultInputs: { length: 20, source: "close", color: "#f59e0b", width: 2 },
516
+ draw: (ctx, renderContext, inputs) => {
517
+ const length = clampIndicatorLength(inputs.length, 20);
518
+ const values = computeEmaSeries(renderContext.data, length, inputs.source ?? "close");
519
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
520
+ }
521
+ };
522
+ var BUILTIN_WMA_INDICATOR = {
523
+ id: "wma",
524
+ name: "WMA",
525
+ pane: "overlay",
526
+ defaultInputs: { length: 20, source: "close", color: "#a78bfa", width: 2 },
527
+ draw: (ctx, renderContext, inputs) => {
528
+ const length = clampIndicatorLength(inputs.length, 20);
529
+ const values = computeWmaSeries(renderContext.data, length, inputs.source ?? "close");
530
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#a78bfa", Number(inputs.width) || 2);
531
+ }
532
+ };
533
+ var BUILTIN_VWMA_INDICATOR = {
534
+ id: "vwma",
535
+ name: "VWMA",
536
+ pane: "overlay",
537
+ defaultInputs: { length: 20, source: "close", color: "#ef4444", width: 2 },
538
+ draw: (ctx, renderContext, inputs) => {
539
+ const length = clampIndicatorLength(inputs.length, 20);
540
+ const values = computeVwmaSeries(renderContext.data, length, inputs.source ?? "close");
541
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#ef4444", Number(inputs.width) || 2);
542
+ }
543
+ };
544
+ var BUILTIN_RMA_INDICATOR = {
545
+ id: "rma",
546
+ name: "RMA",
547
+ pane: "overlay",
548
+ defaultInputs: { length: 14, source: "close", color: "#22c55e", width: 2 },
549
+ draw: (ctx, renderContext, inputs) => {
550
+ const length = clampIndicatorLength(inputs.length, 14);
551
+ const values = computeRmaSeries(renderContext.data, length, inputs.source ?? "close");
552
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#22c55e", Number(inputs.width) || 2);
553
+ }
554
+ };
555
+ var BUILTIN_HMA_INDICATOR = {
556
+ id: "hma",
557
+ name: "HMA",
558
+ pane: "overlay",
559
+ defaultInputs: { length: 21, source: "close", color: "#14b8a6", width: 2 },
560
+ draw: (ctx, renderContext, inputs) => {
561
+ const length = clampIndicatorLength(inputs.length, 21);
562
+ const values = computeHmaSeries(renderContext.data, length, inputs.source ?? "close");
563
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
564
+ }
565
+ };
566
+ var BUILTIN_STDDEV_INDICATOR = {
567
+ id: "stddev",
568
+ name: "StdDev",
569
+ pane: "separate",
570
+ paneHeightRatio: 0.16,
571
+ defaultInputs: { length: 20, source: "close", color: "#f97316", width: 2 },
572
+ draw: (ctx, renderContext, inputs) => {
573
+ const length = clampIndicatorLength(inputs.length, 20);
574
+ const values = computeStdDevSeries(renderContext.data, length, inputs.source ?? "close");
575
+ drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#f97316", Number(inputs.width) || 2);
576
+ }
577
+ };
578
+ var BUILTIN_ATR_INDICATOR = {
579
+ id: "atr",
580
+ name: "ATR",
581
+ pane: "separate",
582
+ paneHeightRatio: 0.16,
583
+ defaultInputs: { length: 14, color: "#eab308", width: 2 },
584
+ draw: (ctx, renderContext, inputs) => {
585
+ const length = clampIndicatorLength(inputs.length, 14);
586
+ const values = computeAtrSeries(renderContext.data, length);
587
+ drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#eab308", Number(inputs.width) || 2);
588
+ }
589
+ };
590
+ var BUILTIN_RSI_INDICATOR = {
591
+ id: "rsi",
592
+ name: "RSI",
593
+ pane: "separate",
594
+ paneHeightRatio: 0.18,
595
+ defaultInputs: { length: 14, color: "#3b82f6", width: 2 },
596
+ draw: (ctx, renderContext, inputs) => {
597
+ const length = clampIndicatorLength(inputs.length, 14);
598
+ const values = computeRsiSeries(renderContext.data, length);
599
+ drawSeparateSeries(
600
+ ctx,
601
+ renderContext,
602
+ values,
603
+ inputs.color ?? "#3b82f6",
604
+ Number(inputs.width) || 2,
605
+ 0,
606
+ 100,
607
+ [30, 50, 70]
608
+ );
609
+ }
610
+ };
611
+ var BUILTIN_INDICATORS = [
612
+ BUILTIN_VOLUME_INDICATOR,
613
+ BUILTIN_SMA_INDICATOR,
614
+ BUILTIN_EMA_INDICATOR,
615
+ BUILTIN_RSI_INDICATOR,
616
+ BUILTIN_WMA_INDICATOR,
617
+ BUILTIN_VWMA_INDICATOR,
618
+ BUILTIN_RMA_INDICATOR,
619
+ BUILTIN_HMA_INDICATOR,
620
+ BUILTIN_STDDEV_INDICATOR,
621
+ BUILTIN_ATR_INDICATOR
622
+ ];
150
623
  function createChart(element, options = {}) {
151
624
  const mergedOptions = {
152
625
  ...DEFAULT_OPTIONS,
@@ -197,6 +670,27 @@ function createChart(element, options = {}) {
197
670
  let orderDragRegions = [];
198
671
  let generatedPriceLineId = 1;
199
672
  let generatedOrderLineId = 1;
673
+ let generatedIndicatorId = 1;
674
+ const indicatorRegistry = /* @__PURE__ */ new Map();
675
+ for (const indicator of BUILTIN_INDICATORS) {
676
+ indicatorRegistry.set(indicator.id, indicator);
677
+ }
678
+ const normalizeIndicatorState = (indicator) => {
679
+ const plugin = indicatorRegistry.get(indicator.type);
680
+ const defaults = plugin?.defaultInputs ?? {};
681
+ return {
682
+ id: indicator.id ?? `indicator-${generatedIndicatorId++}`,
683
+ type: indicator.type,
684
+ visible: indicator.visible ?? true,
685
+ pane: indicator.pane ?? plugin?.pane ?? "overlay",
686
+ ...indicator.paneHeightRatio === void 0 ? {} : { paneHeightRatio: indicator.paneHeightRatio },
687
+ inputs: {
688
+ ...defaults,
689
+ ...indicator.inputs ?? {}
690
+ }
691
+ };
692
+ };
693
+ let indicators = (options.indicators ?? []).map((indicator) => normalizeIndicatorState(indicator));
200
694
  const orderWidgetWidthById = /* @__PURE__ */ new Map();
201
695
  const orderPriceTagWidthById = /* @__PURE__ */ new Map();
202
696
  let xCenter = 0;
@@ -209,6 +703,7 @@ function createChart(element, options = {}) {
209
703
  let watermarkImage = null;
210
704
  let watermarkImageReady = false;
211
705
  let drawState = null;
706
+ let plotBottomForHit = 0;
212
707
  let orderDragState = null;
213
708
  let actionDragState = null;
214
709
  let pointerDownInfo = null;
@@ -425,6 +920,39 @@ function createChart(element, options = {}) {
425
920
  const extra = index - (data.length - 1);
426
921
  return new Date(last.time.getTime() + extra * stepMs);
427
922
  };
923
+ const findNearestIndexForTimeMs = (timeMs) => {
924
+ if (!Number.isFinite(timeMs) || data.length === 0) {
925
+ return null;
926
+ }
927
+ let low = 0;
928
+ let high = data.length - 1;
929
+ while (low <= high) {
930
+ const mid = Math.floor((low + high) / 2);
931
+ const midPoint = data[mid];
932
+ if (!midPoint) {
933
+ break;
934
+ }
935
+ const midTime = midPoint.time.getTime();
936
+ if (midTime === timeMs) {
937
+ return mid;
938
+ }
939
+ if (midTime < timeMs) {
940
+ low = mid + 1;
941
+ } else {
942
+ high = mid - 1;
943
+ }
944
+ }
945
+ const lower = clamp(high, 0, data.length - 1);
946
+ const upper = clamp(low, 0, data.length - 1);
947
+ const lowerPoint = data[lower];
948
+ const upperPoint = data[upper];
949
+ if (!lowerPoint && !upperPoint) return null;
950
+ if (!lowerPoint) return upper;
951
+ if (!upperPoint) return lower;
952
+ const lowerDelta = Math.abs(lowerPoint.time.getTime() - timeMs);
953
+ const upperDelta = Math.abs(upperPoint.time.getTime() - timeMs);
954
+ return lowerDelta <= upperDelta ? lower : upper;
955
+ };
428
956
  const formatHoverTimeLabel = (time, mode) => {
429
957
  if (mode === "time") {
430
958
  return time.toLocaleTimeString(void 0, {
@@ -805,18 +1333,36 @@ function createChart(element, options = {}) {
805
1333
  const chartLeft = margin.left;
806
1334
  const chartTop = margin.top;
807
1335
  const chartWidth = width - margin.left - margin.right;
808
- const chartHeight = height - margin.top - margin.bottom;
809
- const chartBottom = chartTop + chartHeight;
1336
+ const fullChartHeight = height - margin.top - margin.bottom;
1337
+ const fullChartBottom = chartTop + fullChartHeight;
810
1338
  const chartRight = chartLeft + chartWidth;
811
1339
  const watermark = { ...DEFAULT_WATERMARK_OPTIONS, ...mergedOptions.watermark ?? {} };
1340
+ const paneGap = 8;
1341
+ const separatePaneSpacing = 6;
1342
+ const activeSeparateIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1343
+ (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "separate"
1344
+ );
1345
+ const separatePaneHeightDefaults = activeSeparateIndicators.map(({ indicator, plugin }) => {
1346
+ const ratio = Math.min(0.45, Math.max(0.08, indicator.paneHeightRatio ?? plugin.paneHeightRatio ?? 0.22));
1347
+ return Math.round(fullChartHeight * ratio);
1348
+ });
1349
+ const separatePaneDesiredTotal = separatePaneHeightDefaults.reduce((sum, value) => sum + value, 0) + Math.max(0, activeSeparateIndicators.length - 1) * separatePaneSpacing;
1350
+ const maxSeparatePaneTotal = Math.max(0, fullChartHeight - 140);
1351
+ const separatePaneScale = separatePaneDesiredTotal > 0 && separatePaneDesiredTotal > maxSeparatePaneTotal ? maxSeparatePaneTotal / separatePaneDesiredTotal : 1;
1352
+ const separatePaneHeights = separatePaneHeightDefaults.map((value) => Math.max(48, Math.round(value * separatePaneScale)));
1353
+ const separatePaneTotal = separatePaneHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, activeSeparateIndicators.length - 1) * separatePaneSpacing;
1354
+ const pricePaneGap = activeSeparateIndicators.length > 0 ? paneGap : 0;
1355
+ const chartHeight = Math.max(120, fullChartHeight - separatePaneTotal - pricePaneGap);
1356
+ const chartBottom = chartTop + chartHeight;
812
1357
  if (data.length === 0) {
1358
+ plotBottomForHit = fullChartBottom;
813
1359
  drawState = {
814
1360
  chartLeft,
815
1361
  chartTop,
816
1362
  chartRight,
817
- chartBottom,
1363
+ chartBottom: fullChartBottom,
818
1364
  chartWidth,
819
- chartHeight,
1365
+ chartHeight: fullChartHeight,
820
1366
  xStart: 0,
821
1367
  xSpan: 0,
822
1368
  yMin: 0,
@@ -884,6 +1430,7 @@ function createChart(element, options = {}) {
884
1430
  yMin,
885
1431
  yMax
886
1432
  };
1433
+ plotBottomForHit = fullChartBottom;
887
1434
  const yFromPrice = (price) => {
888
1435
  return chartBottom - (price - yMin) / yRange * chartHeight;
889
1436
  };
@@ -976,7 +1523,7 @@ function createChart(element, options = {}) {
976
1523
  ctx.strokeStyle = gridColor;
977
1524
  ctx.beginPath();
978
1525
  ctx.moveTo(crisp(x), crisp(chartTop));
979
- ctx.lineTo(crisp(x), crisp(chartBottom));
1526
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
980
1527
  ctx.stroke();
981
1528
  }
982
1529
  ctx.restore();
@@ -1004,6 +1551,36 @@ function createChart(element, options = {}) {
1004
1551
  ctx.fillStyle = candleColor;
1005
1552
  ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1006
1553
  }
1554
+ const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1555
+ (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
1556
+ );
1557
+ if (activeOverlayIndicators.length > 0) {
1558
+ const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1559
+ activeOverlayIndicators.forEach(({ indicator, plugin }) => {
1560
+ plugin.draw(
1561
+ ctx,
1562
+ {
1563
+ data,
1564
+ startIndex,
1565
+ endIndex,
1566
+ xStart,
1567
+ xSpan,
1568
+ chartLeft,
1569
+ chartRight,
1570
+ chartTop,
1571
+ chartBottom,
1572
+ chartWidth,
1573
+ chartHeight,
1574
+ xFromIndex,
1575
+ yFromPrice,
1576
+ candleSpacing,
1577
+ upColor: mergedOptions.upColor,
1578
+ downColor: mergedOptions.downColor
1579
+ },
1580
+ indicator.inputs
1581
+ );
1582
+ });
1583
+ }
1007
1584
  const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1008
1585
  if (crosshair.visible && crosshairPoint) {
1009
1586
  const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
@@ -1027,11 +1604,56 @@ function createChart(element, options = {}) {
1027
1604
  ctx.restore();
1028
1605
  }
1029
1606
  ctx.restore();
1607
+ if (activeSeparateIndicators.length > 0) {
1608
+ const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1609
+ let paneTopCursor = chartBottom + paneGap;
1610
+ activeSeparateIndicators.forEach(({ indicator, plugin }, paneIndex) => {
1611
+ const paneHeight = separatePaneHeights[paneIndex] ?? 80;
1612
+ const paneTop = paneTopCursor;
1613
+ const paneBottom = paneTop + paneHeight;
1614
+ paneTopCursor = paneBottom + separatePaneSpacing;
1615
+ ctx.save();
1616
+ ctx.beginPath();
1617
+ ctx.rect(chartLeft + 1, paneTop + 1, Math.max(0, chartWidth - 2), Math.max(0, paneHeight - 2));
1618
+ ctx.clip();
1619
+ plugin.draw(
1620
+ ctx,
1621
+ {
1622
+ data,
1623
+ startIndex,
1624
+ endIndex,
1625
+ xStart,
1626
+ xSpan,
1627
+ chartLeft,
1628
+ chartRight,
1629
+ chartTop: paneTop,
1630
+ chartBottom: paneBottom,
1631
+ chartWidth,
1632
+ chartHeight: paneHeight,
1633
+ xFromIndex,
1634
+ yFromPrice: null,
1635
+ candleSpacing,
1636
+ upColor: mergedOptions.upColor,
1637
+ downColor: mergedOptions.downColor
1638
+ },
1639
+ indicator.inputs
1640
+ );
1641
+ ctx.restore();
1642
+ ctx.save();
1643
+ ctx.strokeStyle = axis.lineColor;
1644
+ ctx.lineWidth = Math.max(1, axis.lineWidth);
1645
+ ctx.beginPath();
1646
+ ctx.moveTo(crisp(chartLeft), crisp(paneTop));
1647
+ ctx.lineTo(crisp(chartRight), crisp(paneTop));
1648
+ ctx.stroke();
1649
+ ctx.restore();
1650
+ });
1651
+ }
1030
1652
  ctx.strokeStyle = axis.lineColor;
1031
1653
  ctx.lineWidth = Math.max(1, axis.lineWidth);
1032
1654
  ctx.beginPath();
1033
- ctx.moveTo(crisp(chartLeft), crisp(chartBottom));
1034
- ctx.lineTo(crisp(chartRight), crisp(chartBottom));
1655
+ ctx.moveTo(crisp(chartLeft), crisp(fullChartBottom));
1656
+ ctx.lineTo(crisp(chartRight), crisp(fullChartBottom));
1035
1657
  ctx.moveTo(crisp(chartRight), crisp(chartTop));
1036
1658
  ctx.lineTo(crisp(chartRight), crisp(chartBottom));
1037
1659
  ctx.stroke();
@@ -1095,7 +1717,7 @@ function createChart(element, options = {}) {
1095
1717
  month: "short",
1096
1718
  day: "numeric"
1097
1719
  });
1098
- drawText(timeLabel, x, chartBottom + 8, "center", "top", axis.textColor);
1720
+ drawText(timeLabel, x, fullChartBottom + 8, "center", "top", axis.textColor);
1099
1721
  }
1100
1722
  if (crosshair.visible && crosshairPoint) {
1101
1723
  const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
@@ -1219,7 +1841,7 @@ function createChart(element, options = {}) {
1219
1841
  const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
1220
1842
  const timeWidth = Math.ceil(ctx.measureText(timeText).width) + labelPaddingX * 2;
1221
1843
  const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
1222
- const timeY = chartBottom + 8;
1844
+ const timeY = fullChartBottom + 8;
1223
1845
  ctx.fillStyle = labelBackground;
1224
1846
  fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, labelHeight, labelRadius);
1225
1847
  strokeCrosshairLabel(timeX, timeY, timeWidth);
@@ -1426,11 +2048,12 @@ function createChart(element, options = {}) {
1426
2048
  if (!drawState) {
1427
2049
  return "outside";
1428
2050
  }
1429
- const inPlot = x >= drawState.chartLeft && x <= drawState.chartRight && y >= drawState.chartTop && y <= drawState.chartBottom;
2051
+ const plotBottom = Math.max(drawState.chartBottom, plotBottomForHit);
2052
+ const inPlot = x >= drawState.chartLeft && x <= drawState.chartRight && y >= drawState.chartTop && y <= plotBottom;
1430
2053
  if (inPlot) {
1431
2054
  return "plot";
1432
2055
  }
1433
- const inXAxis = x >= drawState.chartLeft && x <= drawState.chartRight && y > drawState.chartBottom && y <= height;
2056
+ const inXAxis = x >= drawState.chartLeft && x <= drawState.chartRight && y > plotBottom && y <= height;
1434
2057
  if (inXAxis) {
1435
2058
  return "x-axis";
1436
2059
  }
@@ -1755,6 +2378,9 @@ function createChart(element, options = {}) {
1755
2378
  };
1756
2379
  const setData = (nextData) => {
1757
2380
  const hadData = data.length > 0;
2381
+ const previousCenterRounded = hadData ? Math.round(xCenter) : 0;
2382
+ const previousCenterFraction = hadData ? xCenter - previousCenterRounded : 0;
2383
+ const previousCenterTimeMs = hadData && previousCenterRounded >= 0 && previousCenterRounded < data.length ? data[previousCenterRounded]?.time.getTime() ?? null : null;
1758
2384
  data = parseData(nextData);
1759
2385
  if (data.length === 0) {
1760
2386
  xCenter = 0;
@@ -1768,6 +2394,12 @@ function createChart(element, options = {}) {
1768
2394
  resetYViewport();
1769
2395
  } else {
1770
2396
  if (mergedOptions.preserveViewportOnDataUpdate) {
2397
+ if (previousCenterTimeMs !== null) {
2398
+ const nextCenter = findNearestIndexForTimeMs(previousCenterTimeMs);
2399
+ if (nextCenter !== null) {
2400
+ xCenter = nextCenter + previousCenterFraction;
2401
+ }
2402
+ }
1771
2403
  clampXViewport();
1772
2404
  } else {
1773
2405
  fitXViewport();
@@ -1844,6 +2476,60 @@ function createChart(element, options = {}) {
1844
2476
  const setDoubleClickAction = (action) => {
1845
2477
  doubleClickAction = action;
1846
2478
  };
2479
+ const registerIndicator = (plugin) => {
2480
+ if (!plugin.id || typeof plugin.draw !== "function") {
2481
+ throw new Error("Invalid indicator plugin. Expected { id, draw }.");
2482
+ }
2483
+ indicatorRegistry.set(plugin.id, plugin);
2484
+ draw();
2485
+ };
2486
+ const unregisterIndicator = (type) => {
2487
+ indicatorRegistry.delete(type);
2488
+ indicators = indicators.filter((indicator) => indicator.type !== type);
2489
+ draw();
2490
+ };
2491
+ const setIndicators = (nextIndicators) => {
2492
+ indicators = nextIndicators.map((indicator) => normalizeIndicatorState(indicator));
2493
+ draw();
2494
+ };
2495
+ const addIndicator = (type, inputs = {}, options2 = {}) => {
2496
+ const plugin = indicatorRegistry.get(type);
2497
+ if (!plugin) {
2498
+ throw new Error(`Unknown indicator type "${type}". Register it first.`);
2499
+ }
2500
+ const next = normalizeIndicatorState({
2501
+ ...options2,
2502
+ type,
2503
+ inputs
2504
+ });
2505
+ indicators.push(next);
2506
+ draw();
2507
+ return next.id;
2508
+ };
2509
+ const updateIndicator = (id, patch) => {
2510
+ indicators = indicators.map((indicator) => {
2511
+ if (indicator.id !== id) {
2512
+ return indicator;
2513
+ }
2514
+ const plugin = indicatorRegistry.get(indicator.type);
2515
+ return {
2516
+ ...indicator,
2517
+ visible: patch.visible ?? indicator.visible,
2518
+ pane: patch.pane ?? indicator.pane ?? plugin?.pane ?? "overlay",
2519
+ ...patch.paneHeightRatio !== void 0 || indicator.paneHeightRatio !== void 0 ? { paneHeightRatio: patch.paneHeightRatio ?? indicator.paneHeightRatio } : {},
2520
+ type: patch.type ?? indicator.type,
2521
+ inputs: {
2522
+ ...indicator.inputs,
2523
+ ...patch.inputs ?? {}
2524
+ }
2525
+ };
2526
+ });
2527
+ draw();
2528
+ };
2529
+ const removeIndicator = (id) => {
2530
+ indicators = indicators.filter((indicator) => indicator.id !== id);
2531
+ draw();
2532
+ };
1847
2533
  const destroy = () => {
1848
2534
  canvas.removeEventListener("pointerdown", onPointerDown);
1849
2535
  canvas.removeEventListener("pointermove", onPointerMove);
@@ -1878,6 +2564,12 @@ function createChart(element, options = {}) {
1878
2564
  fitContent,
1879
2565
  setDoubleClickEnabled,
1880
2566
  setDoubleClickAction,
2567
+ registerIndicator,
2568
+ unregisterIndicator,
2569
+ addIndicator,
2570
+ updateIndicator,
2571
+ removeIndicator,
2572
+ setIndicators,
1881
2573
  resize,
1882
2574
  destroy
1883
2575
  };