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.cjs CHANGED
@@ -169,8 +169,481 @@ var DEFAULT_OPTIONS = {
169
169
  labelTextColor: "#0b1220",
170
170
  labelBorderRadius: 3
171
171
  },
172
- dashPatterns: DEFAULT_DASH_PATTERNS
172
+ dashPatterns: DEFAULT_DASH_PATTERNS,
173
+ indicators: []
173
174
  };
175
+ var getIndicatorSourceValue = (point, source) => {
176
+ if (source === "open") return point.o;
177
+ if (source === "high") return point.h;
178
+ if (source === "low") return point.l;
179
+ if (source === "hl2") return (point.h + point.l) / 2;
180
+ return point.c;
181
+ };
182
+ var clampIndicatorLength = (value, fallback) => {
183
+ return Math.max(1, Math.round(Number(value) || fallback));
184
+ };
185
+ var computeSmaSeries = (data, length, source) => {
186
+ const result = new Array(data.length).fill(null);
187
+ let sum = 0;
188
+ for (let i = 0; i < data.length; i += 1) {
189
+ const point = data[i];
190
+ if (!point) continue;
191
+ const value = getIndicatorSourceValue(point, source);
192
+ sum += value;
193
+ if (i >= length) {
194
+ const oldPoint = data[i - length];
195
+ if (oldPoint) {
196
+ sum -= getIndicatorSourceValue(oldPoint, source);
197
+ }
198
+ }
199
+ if (i >= length - 1) {
200
+ result[i] = sum / length;
201
+ }
202
+ }
203
+ return result;
204
+ };
205
+ var computeEmaSeries = (data, length, source) => {
206
+ const result = new Array(data.length).fill(null);
207
+ const alpha = 2 / (length + 1);
208
+ let prev = null;
209
+ for (let i = 0; i < data.length; i += 1) {
210
+ const point = data[i];
211
+ if (!point) continue;
212
+ const value = getIndicatorSourceValue(point, source);
213
+ prev = prev === null ? value : alpha * value + (1 - alpha) * prev;
214
+ if (i >= length - 1) {
215
+ result[i] = prev;
216
+ }
217
+ }
218
+ return result;
219
+ };
220
+ var computeRmaSeries = (data, length, source) => {
221
+ const result = new Array(data.length).fill(null);
222
+ let prev = null;
223
+ let seedSum = 0;
224
+ for (let i = 0; i < data.length; i += 1) {
225
+ const point = data[i];
226
+ if (!point) continue;
227
+ const value = getIndicatorSourceValue(point, source);
228
+ if (i < length) {
229
+ seedSum += value;
230
+ if (i === length - 1) {
231
+ prev = seedSum / length;
232
+ result[i] = prev;
233
+ }
234
+ continue;
235
+ }
236
+ prev = prev === null ? value : (prev * (length - 1) + value) / length;
237
+ result[i] = prev;
238
+ }
239
+ return result;
240
+ };
241
+ var computeWmaSeriesFromValues = (values, length) => {
242
+ const result = new Array(values.length).fill(null);
243
+ const denominator = length * (length + 1) / 2;
244
+ for (let i = 0; i < values.length; i += 1) {
245
+ if (i < length - 1) continue;
246
+ let weighted = 0;
247
+ let valid = true;
248
+ for (let j = 0; j < length; j += 1) {
249
+ const value = values[i - length + 1 + j];
250
+ if (value == null) {
251
+ valid = false;
252
+ break;
253
+ }
254
+ weighted += value * (j + 1);
255
+ }
256
+ if (valid) {
257
+ result[i] = weighted / denominator;
258
+ }
259
+ }
260
+ return result;
261
+ };
262
+ var computeWmaSeries = (data, length, source) => {
263
+ const sourceValues = data.map((point) => getIndicatorSourceValue(point, source));
264
+ return computeWmaSeriesFromValues(sourceValues, length);
265
+ };
266
+ var computeVwmaSeries = (data, length, source) => {
267
+ const result = new Array(data.length).fill(null);
268
+ let sumPv = 0;
269
+ let sumV = 0;
270
+ for (let i = 0; i < data.length; i += 1) {
271
+ const point = data[i];
272
+ if (!point) continue;
273
+ const sourceValue = getIndicatorSourceValue(point, source);
274
+ const volume = Math.max(0, point.v ?? 0);
275
+ sumPv += sourceValue * volume;
276
+ sumV += volume;
277
+ if (i >= length) {
278
+ const oldPoint = data[i - length];
279
+ if (oldPoint) {
280
+ const oldSource = getIndicatorSourceValue(oldPoint, source);
281
+ const oldVolume = Math.max(0, oldPoint.v ?? 0);
282
+ sumPv -= oldSource * oldVolume;
283
+ sumV -= oldVolume;
284
+ }
285
+ }
286
+ if (i >= length - 1 && sumV > 0) {
287
+ result[i] = sumPv / sumV;
288
+ }
289
+ }
290
+ return result;
291
+ };
292
+ var computeHmaSeries = (data, length, source) => {
293
+ const halfLength = Math.max(1, Math.floor(length / 2));
294
+ const sqrtLength = Math.max(1, Math.round(Math.sqrt(length)));
295
+ const wmaHalf = computeWmaSeries(data, halfLength, source);
296
+ const wmaFull = computeWmaSeries(data, length, source);
297
+ const diff = wmaHalf.map((value, idx) => {
298
+ const full = wmaFull[idx];
299
+ if (value == null || full == null) return null;
300
+ return 2 * value - full;
301
+ });
302
+ return computeWmaSeriesFromValues(diff, sqrtLength);
303
+ };
304
+ var computeStdDevSeries = (data, length, source) => {
305
+ const result = new Array(data.length).fill(null);
306
+ const values = data.map((point) => point ? getIndicatorSourceValue(point, source) : 0);
307
+ for (let i = 0; i < values.length; i += 1) {
308
+ if (i < length - 1) continue;
309
+ let sum = 0;
310
+ for (let j = 0; j < length; j += 1) {
311
+ sum += values[i - j] ?? 0;
312
+ }
313
+ const mean = sum / length;
314
+ let variance = 0;
315
+ for (let j = 0; j < length; j += 1) {
316
+ const delta = (values[i - j] ?? 0) - mean;
317
+ variance += delta * delta;
318
+ }
319
+ result[i] = Math.sqrt(variance / length);
320
+ }
321
+ return result;
322
+ };
323
+ var computeRsiSeries = (data, length) => {
324
+ const result = new Array(data.length).fill(null);
325
+ if (data.length < 2) return result;
326
+ let avgGain = 0;
327
+ let avgLoss = 0;
328
+ let seeded = false;
329
+ for (let i = 1; i < data.length; i += 1) {
330
+ const point = data[i];
331
+ const prevPoint = data[i - 1];
332
+ if (!point || !prevPoint) continue;
333
+ const delta = point.c - prevPoint.c;
334
+ const gain = Math.max(0, delta);
335
+ const loss = Math.max(0, -delta);
336
+ if (i <= length) {
337
+ avgGain += gain;
338
+ avgLoss += loss;
339
+ if (i === length) {
340
+ avgGain /= length;
341
+ avgLoss /= length;
342
+ seeded = true;
343
+ }
344
+ } else if (seeded) {
345
+ avgGain = (avgGain * (length - 1) + gain) / length;
346
+ avgLoss = (avgLoss * (length - 1) + loss) / length;
347
+ }
348
+ if (seeded && i >= length) {
349
+ if (avgLoss === 0) {
350
+ result[i] = 100;
351
+ } else {
352
+ const rs = avgGain / avgLoss;
353
+ result[i] = 100 - 100 / (1 + rs);
354
+ }
355
+ }
356
+ }
357
+ return result;
358
+ };
359
+ var computeAtrSeries = (data, length) => {
360
+ const result = new Array(data.length).fill(null);
361
+ if (data.length === 0) return result;
362
+ const trueRanges = [];
363
+ for (let i = 0; i < data.length; i += 1) {
364
+ const point = data[i];
365
+ if (!point) continue;
366
+ const prevPoint = i > 0 ? data[i - 1] : null;
367
+ const prevClose = prevPoint ? prevPoint.c : point.c;
368
+ const tr = Math.max(point.h - point.l, Math.abs(point.h - prevClose), Math.abs(point.l - prevClose));
369
+ trueRanges.push(tr);
370
+ }
371
+ let seed = 0;
372
+ let prevAtr = null;
373
+ for (let i = 0; i < trueRanges.length; i += 1) {
374
+ const tr = trueRanges[i];
375
+ if (tr === void 0) continue;
376
+ if (i < length) {
377
+ seed += tr;
378
+ if (i === length - 1) {
379
+ prevAtr = seed / length;
380
+ result[i] = prevAtr;
381
+ }
382
+ continue;
383
+ }
384
+ prevAtr = prevAtr === null ? tr : (prevAtr * (length - 1) + tr) / length;
385
+ result[i] = prevAtr;
386
+ }
387
+ return result;
388
+ };
389
+ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
390
+ if (!renderContext.yFromPrice) return;
391
+ const yFromPrice = renderContext.yFromPrice;
392
+ ctx.save();
393
+ ctx.strokeStyle = color;
394
+ ctx.lineWidth = Math.max(1, width);
395
+ ctx.setLineDash([]);
396
+ let drawing = false;
397
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
398
+ const value = values[index];
399
+ if (!Number.isFinite(value ?? Number.NaN)) {
400
+ drawing = false;
401
+ continue;
402
+ }
403
+ const x = renderContext.xFromIndex(index);
404
+ const y = yFromPrice(value);
405
+ if (!drawing) {
406
+ ctx.beginPath();
407
+ ctx.moveTo(x, y);
408
+ drawing = true;
409
+ } else {
410
+ ctx.lineTo(x, y);
411
+ }
412
+ const nextValue = values[index + 1];
413
+ if (!Number.isFinite(nextValue ?? Number.NaN) || index === renderContext.endIndex) {
414
+ ctx.stroke();
415
+ drawing = false;
416
+ }
417
+ }
418
+ ctx.restore();
419
+ };
420
+ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines) => {
421
+ const visible = [];
422
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
423
+ const value = values[index];
424
+ if (Number.isFinite(value ?? Number.NaN)) {
425
+ visible.push(value);
426
+ }
427
+ }
428
+ if (visible.length === 0) return;
429
+ const minValue = minOverride ?? Math.min(...visible);
430
+ const maxValue = maxOverride ?? Math.max(...visible);
431
+ const range = maxValue - minValue || 1;
432
+ const yFromValue = (value) => {
433
+ const ratio = (value - minValue) / range;
434
+ return renderContext.chartBottom - ratio * renderContext.chartHeight;
435
+ };
436
+ if (guideLines && guideLines.length > 0) {
437
+ ctx.save();
438
+ ctx.strokeStyle = "rgba(148,163,184,0.35)";
439
+ ctx.lineWidth = 1;
440
+ ctx.setLineDash([4, 4]);
441
+ for (const guide of guideLines) {
442
+ const y = yFromValue(guide);
443
+ ctx.beginPath();
444
+ ctx.moveTo(renderContext.chartLeft, y);
445
+ ctx.lineTo(renderContext.chartRight, y);
446
+ ctx.stroke();
447
+ }
448
+ ctx.restore();
449
+ }
450
+ ctx.save();
451
+ ctx.strokeStyle = color;
452
+ ctx.lineWidth = Math.max(1, width);
453
+ ctx.setLineDash([]);
454
+ let drawing = false;
455
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
456
+ const value = values[index];
457
+ if (!Number.isFinite(value ?? Number.NaN)) {
458
+ drawing = false;
459
+ continue;
460
+ }
461
+ const x = renderContext.xFromIndex(index);
462
+ const y = yFromValue(value);
463
+ if (!drawing) {
464
+ ctx.beginPath();
465
+ ctx.moveTo(x, y);
466
+ drawing = true;
467
+ } else {
468
+ ctx.lineTo(x, y);
469
+ }
470
+ const nextValue = values[index + 1];
471
+ if (!Number.isFinite(nextValue ?? Number.NaN) || index === renderContext.endIndex) {
472
+ ctx.stroke();
473
+ drawing = false;
474
+ }
475
+ }
476
+ ctx.restore();
477
+ };
478
+ var BUILTIN_VOLUME_INDICATOR = {
479
+ id: "volume",
480
+ name: "Volume",
481
+ pane: "overlay",
482
+ paneHeightRatio: 0.22,
483
+ defaultInputs: {
484
+ upOpacity: 0.7,
485
+ downOpacity: 0.7,
486
+ minBarWidth: 1,
487
+ overlayHeightRatio: 0.22
488
+ },
489
+ draw: (ctx, renderContext, inputs) => {
490
+ const { data, startIndex, endIndex, chartTop, chartBottom, candleSpacing, xFromIndex, upColor, downColor } = renderContext;
491
+ const fullPaneHeight = Math.max(1, chartBottom - chartTop);
492
+ const overlayMode = renderContext.yFromPrice !== null;
493
+ const overlayHeightRatio = Math.min(0.45, Math.max(0.08, Number(inputs.overlayHeightRatio) || 0.22));
494
+ const paneHeight = overlayMode ? Math.max(20, Math.round(fullPaneHeight * overlayHeightRatio)) : fullPaneHeight;
495
+ const paneBottom = chartBottom;
496
+ const visiblePoints = data.slice(startIndex, endIndex + 1);
497
+ const maxVolume = Math.max(1, ...visiblePoints.map((point) => point.v ?? 0));
498
+ const barWidth = Math.max(
499
+ Math.max(1, Number(inputs.minBarWidth) || 1),
500
+ Math.min(Math.max(1, candleSpacing - 1), Math.floor(candleSpacing * 0.7))
501
+ );
502
+ const upOpacity = Math.min(1, Math.max(0.05, Number(inputs.upOpacity) || 0.7));
503
+ const downOpacity = Math.min(1, Math.max(0.05, Number(inputs.downOpacity) || 0.7));
504
+ for (let index = startIndex; index <= endIndex; index += 1) {
505
+ const point = data[index];
506
+ if (!point || point.v === void 0 || point.v <= 0) {
507
+ continue;
508
+ }
509
+ const ratio = Math.min(1, Math.max(0, point.v / maxVolume));
510
+ const volumeHeight = Math.max(1, Math.round(paneHeight * ratio));
511
+ const xCenter = xFromIndex(index);
512
+ const barX = Math.round(xCenter - barWidth / 2);
513
+ const barY = Math.round(paneBottom - volumeHeight);
514
+ const isUp = point.c >= point.o;
515
+ const opacity = isUp ? upOpacity : downOpacity;
516
+ ctx.save();
517
+ ctx.globalAlpha = opacity;
518
+ ctx.fillStyle = isUp ? upColor : downColor;
519
+ ctx.fillRect(barX, barY, Math.max(1, Math.round(barWidth)), volumeHeight);
520
+ ctx.restore();
521
+ }
522
+ }
523
+ };
524
+ var BUILTIN_SMA_INDICATOR = {
525
+ id: "sma",
526
+ name: "SMA",
527
+ pane: "overlay",
528
+ defaultInputs: { length: 20, source: "close", color: "#60a5fa", width: 2 },
529
+ draw: (ctx, renderContext, inputs) => {
530
+ const length = clampIndicatorLength(inputs.length, 20);
531
+ const values = computeSmaSeries(renderContext.data, length, inputs.source ?? "close");
532
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#60a5fa", Number(inputs.width) || 2);
533
+ }
534
+ };
535
+ var BUILTIN_EMA_INDICATOR = {
536
+ id: "ema",
537
+ name: "EMA",
538
+ pane: "overlay",
539
+ defaultInputs: { length: 20, source: "close", color: "#f59e0b", width: 2 },
540
+ draw: (ctx, renderContext, inputs) => {
541
+ const length = clampIndicatorLength(inputs.length, 20);
542
+ const values = computeEmaSeries(renderContext.data, length, inputs.source ?? "close");
543
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
544
+ }
545
+ };
546
+ var BUILTIN_WMA_INDICATOR = {
547
+ id: "wma",
548
+ name: "WMA",
549
+ pane: "overlay",
550
+ defaultInputs: { length: 20, source: "close", color: "#a78bfa", width: 2 },
551
+ draw: (ctx, renderContext, inputs) => {
552
+ const length = clampIndicatorLength(inputs.length, 20);
553
+ const values = computeWmaSeries(renderContext.data, length, inputs.source ?? "close");
554
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#a78bfa", Number(inputs.width) || 2);
555
+ }
556
+ };
557
+ var BUILTIN_VWMA_INDICATOR = {
558
+ id: "vwma",
559
+ name: "VWMA",
560
+ pane: "overlay",
561
+ defaultInputs: { length: 20, source: "close", color: "#ef4444", width: 2 },
562
+ draw: (ctx, renderContext, inputs) => {
563
+ const length = clampIndicatorLength(inputs.length, 20);
564
+ const values = computeVwmaSeries(renderContext.data, length, inputs.source ?? "close");
565
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#ef4444", Number(inputs.width) || 2);
566
+ }
567
+ };
568
+ var BUILTIN_RMA_INDICATOR = {
569
+ id: "rma",
570
+ name: "RMA",
571
+ pane: "overlay",
572
+ defaultInputs: { length: 14, source: "close", color: "#22c55e", width: 2 },
573
+ draw: (ctx, renderContext, inputs) => {
574
+ const length = clampIndicatorLength(inputs.length, 14);
575
+ const values = computeRmaSeries(renderContext.data, length, inputs.source ?? "close");
576
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#22c55e", Number(inputs.width) || 2);
577
+ }
578
+ };
579
+ var BUILTIN_HMA_INDICATOR = {
580
+ id: "hma",
581
+ name: "HMA",
582
+ pane: "overlay",
583
+ defaultInputs: { length: 21, source: "close", color: "#14b8a6", width: 2 },
584
+ draw: (ctx, renderContext, inputs) => {
585
+ const length = clampIndicatorLength(inputs.length, 21);
586
+ const values = computeHmaSeries(renderContext.data, length, inputs.source ?? "close");
587
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
588
+ }
589
+ };
590
+ var BUILTIN_STDDEV_INDICATOR = {
591
+ id: "stddev",
592
+ name: "StdDev",
593
+ pane: "separate",
594
+ paneHeightRatio: 0.16,
595
+ defaultInputs: { length: 20, source: "close", color: "#f97316", width: 2 },
596
+ draw: (ctx, renderContext, inputs) => {
597
+ const length = clampIndicatorLength(inputs.length, 20);
598
+ const values = computeStdDevSeries(renderContext.data, length, inputs.source ?? "close");
599
+ drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#f97316", Number(inputs.width) || 2);
600
+ }
601
+ };
602
+ var BUILTIN_ATR_INDICATOR = {
603
+ id: "atr",
604
+ name: "ATR",
605
+ pane: "separate",
606
+ paneHeightRatio: 0.16,
607
+ defaultInputs: { length: 14, color: "#eab308", width: 2 },
608
+ draw: (ctx, renderContext, inputs) => {
609
+ const length = clampIndicatorLength(inputs.length, 14);
610
+ const values = computeAtrSeries(renderContext.data, length);
611
+ drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#eab308", Number(inputs.width) || 2);
612
+ }
613
+ };
614
+ var BUILTIN_RSI_INDICATOR = {
615
+ id: "rsi",
616
+ name: "RSI",
617
+ pane: "separate",
618
+ paneHeightRatio: 0.18,
619
+ defaultInputs: { length: 14, color: "#3b82f6", width: 2 },
620
+ draw: (ctx, renderContext, inputs) => {
621
+ const length = clampIndicatorLength(inputs.length, 14);
622
+ const values = computeRsiSeries(renderContext.data, length);
623
+ drawSeparateSeries(
624
+ ctx,
625
+ renderContext,
626
+ values,
627
+ inputs.color ?? "#3b82f6",
628
+ Number(inputs.width) || 2,
629
+ 0,
630
+ 100,
631
+ [30, 50, 70]
632
+ );
633
+ }
634
+ };
635
+ var BUILTIN_INDICATORS = [
636
+ BUILTIN_VOLUME_INDICATOR,
637
+ BUILTIN_SMA_INDICATOR,
638
+ BUILTIN_EMA_INDICATOR,
639
+ BUILTIN_RSI_INDICATOR,
640
+ BUILTIN_WMA_INDICATOR,
641
+ BUILTIN_VWMA_INDICATOR,
642
+ BUILTIN_RMA_INDICATOR,
643
+ BUILTIN_HMA_INDICATOR,
644
+ BUILTIN_STDDEV_INDICATOR,
645
+ BUILTIN_ATR_INDICATOR
646
+ ];
174
647
  function createChart(element, options = {}) {
175
648
  const mergedOptions = {
176
649
  ...DEFAULT_OPTIONS,
@@ -221,6 +694,27 @@ function createChart(element, options = {}) {
221
694
  let orderDragRegions = [];
222
695
  let generatedPriceLineId = 1;
223
696
  let generatedOrderLineId = 1;
697
+ let generatedIndicatorId = 1;
698
+ const indicatorRegistry = /* @__PURE__ */ new Map();
699
+ for (const indicator of BUILTIN_INDICATORS) {
700
+ indicatorRegistry.set(indicator.id, indicator);
701
+ }
702
+ const normalizeIndicatorState = (indicator) => {
703
+ const plugin = indicatorRegistry.get(indicator.type);
704
+ const defaults = plugin?.defaultInputs ?? {};
705
+ return {
706
+ id: indicator.id ?? `indicator-${generatedIndicatorId++}`,
707
+ type: indicator.type,
708
+ visible: indicator.visible ?? true,
709
+ pane: indicator.pane ?? plugin?.pane ?? "overlay",
710
+ ...indicator.paneHeightRatio === void 0 ? {} : { paneHeightRatio: indicator.paneHeightRatio },
711
+ inputs: {
712
+ ...defaults,
713
+ ...indicator.inputs ?? {}
714
+ }
715
+ };
716
+ };
717
+ let indicators = (options.indicators ?? []).map((indicator) => normalizeIndicatorState(indicator));
224
718
  const orderWidgetWidthById = /* @__PURE__ */ new Map();
225
719
  const orderPriceTagWidthById = /* @__PURE__ */ new Map();
226
720
  let xCenter = 0;
@@ -233,6 +727,7 @@ function createChart(element, options = {}) {
233
727
  let watermarkImage = null;
234
728
  let watermarkImageReady = false;
235
729
  let drawState = null;
730
+ let plotBottomForHit = 0;
236
731
  let orderDragState = null;
237
732
  let actionDragState = null;
238
733
  let pointerDownInfo = null;
@@ -449,6 +944,39 @@ function createChart(element, options = {}) {
449
944
  const extra = index - (data.length - 1);
450
945
  return new Date(last.time.getTime() + extra * stepMs);
451
946
  };
947
+ const findNearestIndexForTimeMs = (timeMs) => {
948
+ if (!Number.isFinite(timeMs) || data.length === 0) {
949
+ return null;
950
+ }
951
+ let low = 0;
952
+ let high = data.length - 1;
953
+ while (low <= high) {
954
+ const mid = Math.floor((low + high) / 2);
955
+ const midPoint = data[mid];
956
+ if (!midPoint) {
957
+ break;
958
+ }
959
+ const midTime = midPoint.time.getTime();
960
+ if (midTime === timeMs) {
961
+ return mid;
962
+ }
963
+ if (midTime < timeMs) {
964
+ low = mid + 1;
965
+ } else {
966
+ high = mid - 1;
967
+ }
968
+ }
969
+ const lower = clamp(high, 0, data.length - 1);
970
+ const upper = clamp(low, 0, data.length - 1);
971
+ const lowerPoint = data[lower];
972
+ const upperPoint = data[upper];
973
+ if (!lowerPoint && !upperPoint) return null;
974
+ if (!lowerPoint) return upper;
975
+ if (!upperPoint) return lower;
976
+ const lowerDelta = Math.abs(lowerPoint.time.getTime() - timeMs);
977
+ const upperDelta = Math.abs(upperPoint.time.getTime() - timeMs);
978
+ return lowerDelta <= upperDelta ? lower : upper;
979
+ };
452
980
  const formatHoverTimeLabel = (time, mode) => {
453
981
  if (mode === "time") {
454
982
  return time.toLocaleTimeString(void 0, {
@@ -829,18 +1357,36 @@ function createChart(element, options = {}) {
829
1357
  const chartLeft = margin.left;
830
1358
  const chartTop = margin.top;
831
1359
  const chartWidth = width - margin.left - margin.right;
832
- const chartHeight = height - margin.top - margin.bottom;
833
- const chartBottom = chartTop + chartHeight;
1360
+ const fullChartHeight = height - margin.top - margin.bottom;
1361
+ const fullChartBottom = chartTop + fullChartHeight;
834
1362
  const chartRight = chartLeft + chartWidth;
835
1363
  const watermark = { ...DEFAULT_WATERMARK_OPTIONS, ...mergedOptions.watermark ?? {} };
1364
+ const paneGap = 8;
1365
+ const separatePaneSpacing = 6;
1366
+ const activeSeparateIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1367
+ (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "separate"
1368
+ );
1369
+ const separatePaneHeightDefaults = activeSeparateIndicators.map(({ indicator, plugin }) => {
1370
+ const ratio = Math.min(0.45, Math.max(0.08, indicator.paneHeightRatio ?? plugin.paneHeightRatio ?? 0.22));
1371
+ return Math.round(fullChartHeight * ratio);
1372
+ });
1373
+ const separatePaneDesiredTotal = separatePaneHeightDefaults.reduce((sum, value) => sum + value, 0) + Math.max(0, activeSeparateIndicators.length - 1) * separatePaneSpacing;
1374
+ const maxSeparatePaneTotal = Math.max(0, fullChartHeight - 140);
1375
+ const separatePaneScale = separatePaneDesiredTotal > 0 && separatePaneDesiredTotal > maxSeparatePaneTotal ? maxSeparatePaneTotal / separatePaneDesiredTotal : 1;
1376
+ const separatePaneHeights = separatePaneHeightDefaults.map((value) => Math.max(48, Math.round(value * separatePaneScale)));
1377
+ const separatePaneTotal = separatePaneHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, activeSeparateIndicators.length - 1) * separatePaneSpacing;
1378
+ const pricePaneGap = activeSeparateIndicators.length > 0 ? paneGap : 0;
1379
+ const chartHeight = Math.max(120, fullChartHeight - separatePaneTotal - pricePaneGap);
1380
+ const chartBottom = chartTop + chartHeight;
836
1381
  if (data.length === 0) {
1382
+ plotBottomForHit = fullChartBottom;
837
1383
  drawState = {
838
1384
  chartLeft,
839
1385
  chartTop,
840
1386
  chartRight,
841
- chartBottom,
1387
+ chartBottom: fullChartBottom,
842
1388
  chartWidth,
843
- chartHeight,
1389
+ chartHeight: fullChartHeight,
844
1390
  xStart: 0,
845
1391
  xSpan: 0,
846
1392
  yMin: 0,
@@ -908,6 +1454,7 @@ function createChart(element, options = {}) {
908
1454
  yMin,
909
1455
  yMax
910
1456
  };
1457
+ plotBottomForHit = fullChartBottom;
911
1458
  const yFromPrice = (price) => {
912
1459
  return chartBottom - (price - yMin) / yRange * chartHeight;
913
1460
  };
@@ -1000,7 +1547,7 @@ function createChart(element, options = {}) {
1000
1547
  ctx.strokeStyle = gridColor;
1001
1548
  ctx.beginPath();
1002
1549
  ctx.moveTo(crisp(x), crisp(chartTop));
1003
- ctx.lineTo(crisp(x), crisp(chartBottom));
1550
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
1004
1551
  ctx.stroke();
1005
1552
  }
1006
1553
  ctx.restore();
@@ -1028,6 +1575,36 @@ function createChart(element, options = {}) {
1028
1575
  ctx.fillStyle = candleColor;
1029
1576
  ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1030
1577
  }
1578
+ const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1579
+ (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
1580
+ );
1581
+ if (activeOverlayIndicators.length > 0) {
1582
+ const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1583
+ activeOverlayIndicators.forEach(({ indicator, plugin }) => {
1584
+ plugin.draw(
1585
+ ctx,
1586
+ {
1587
+ data,
1588
+ startIndex,
1589
+ endIndex,
1590
+ xStart,
1591
+ xSpan,
1592
+ chartLeft,
1593
+ chartRight,
1594
+ chartTop,
1595
+ chartBottom,
1596
+ chartWidth,
1597
+ chartHeight,
1598
+ xFromIndex,
1599
+ yFromPrice,
1600
+ candleSpacing,
1601
+ upColor: mergedOptions.upColor,
1602
+ downColor: mergedOptions.downColor
1603
+ },
1604
+ indicator.inputs
1605
+ );
1606
+ });
1607
+ }
1031
1608
  const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1032
1609
  if (crosshair.visible && crosshairPoint) {
1033
1610
  const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
@@ -1051,11 +1628,56 @@ function createChart(element, options = {}) {
1051
1628
  ctx.restore();
1052
1629
  }
1053
1630
  ctx.restore();
1631
+ if (activeSeparateIndicators.length > 0) {
1632
+ const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1633
+ let paneTopCursor = chartBottom + paneGap;
1634
+ activeSeparateIndicators.forEach(({ indicator, plugin }, paneIndex) => {
1635
+ const paneHeight = separatePaneHeights[paneIndex] ?? 80;
1636
+ const paneTop = paneTopCursor;
1637
+ const paneBottom = paneTop + paneHeight;
1638
+ paneTopCursor = paneBottom + separatePaneSpacing;
1639
+ ctx.save();
1640
+ ctx.beginPath();
1641
+ ctx.rect(chartLeft + 1, paneTop + 1, Math.max(0, chartWidth - 2), Math.max(0, paneHeight - 2));
1642
+ ctx.clip();
1643
+ plugin.draw(
1644
+ ctx,
1645
+ {
1646
+ data,
1647
+ startIndex,
1648
+ endIndex,
1649
+ xStart,
1650
+ xSpan,
1651
+ chartLeft,
1652
+ chartRight,
1653
+ chartTop: paneTop,
1654
+ chartBottom: paneBottom,
1655
+ chartWidth,
1656
+ chartHeight: paneHeight,
1657
+ xFromIndex,
1658
+ yFromPrice: null,
1659
+ candleSpacing,
1660
+ upColor: mergedOptions.upColor,
1661
+ downColor: mergedOptions.downColor
1662
+ },
1663
+ indicator.inputs
1664
+ );
1665
+ ctx.restore();
1666
+ ctx.save();
1667
+ ctx.strokeStyle = axis.lineColor;
1668
+ ctx.lineWidth = Math.max(1, axis.lineWidth);
1669
+ ctx.beginPath();
1670
+ ctx.moveTo(crisp(chartLeft), crisp(paneTop));
1671
+ ctx.lineTo(crisp(chartRight), crisp(paneTop));
1672
+ ctx.stroke();
1673
+ ctx.restore();
1674
+ });
1675
+ }
1054
1676
  ctx.strokeStyle = axis.lineColor;
1055
1677
  ctx.lineWidth = Math.max(1, axis.lineWidth);
1056
1678
  ctx.beginPath();
1057
- ctx.moveTo(crisp(chartLeft), crisp(chartBottom));
1058
- ctx.lineTo(crisp(chartRight), crisp(chartBottom));
1679
+ ctx.moveTo(crisp(chartLeft), crisp(fullChartBottom));
1680
+ ctx.lineTo(crisp(chartRight), crisp(fullChartBottom));
1059
1681
  ctx.moveTo(crisp(chartRight), crisp(chartTop));
1060
1682
  ctx.lineTo(crisp(chartRight), crisp(chartBottom));
1061
1683
  ctx.stroke();
@@ -1119,7 +1741,7 @@ function createChart(element, options = {}) {
1119
1741
  month: "short",
1120
1742
  day: "numeric"
1121
1743
  });
1122
- drawText(timeLabel, x, chartBottom + 8, "center", "top", axis.textColor);
1744
+ drawText(timeLabel, x, fullChartBottom + 8, "center", "top", axis.textColor);
1123
1745
  }
1124
1746
  if (crosshair.visible && crosshairPoint) {
1125
1747
  const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
@@ -1243,7 +1865,7 @@ function createChart(element, options = {}) {
1243
1865
  const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
1244
1866
  const timeWidth = Math.ceil(ctx.measureText(timeText).width) + labelPaddingX * 2;
1245
1867
  const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
1246
- const timeY = chartBottom + 8;
1868
+ const timeY = fullChartBottom + 8;
1247
1869
  ctx.fillStyle = labelBackground;
1248
1870
  fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, labelHeight, labelRadius);
1249
1871
  strokeCrosshairLabel(timeX, timeY, timeWidth);
@@ -1450,11 +2072,12 @@ function createChart(element, options = {}) {
1450
2072
  if (!drawState) {
1451
2073
  return "outside";
1452
2074
  }
1453
- const inPlot = x >= drawState.chartLeft && x <= drawState.chartRight && y >= drawState.chartTop && y <= drawState.chartBottom;
2075
+ const plotBottom = Math.max(drawState.chartBottom, plotBottomForHit);
2076
+ const inPlot = x >= drawState.chartLeft && x <= drawState.chartRight && y >= drawState.chartTop && y <= plotBottom;
1454
2077
  if (inPlot) {
1455
2078
  return "plot";
1456
2079
  }
1457
- const inXAxis = x >= drawState.chartLeft && x <= drawState.chartRight && y > drawState.chartBottom && y <= height;
2080
+ const inXAxis = x >= drawState.chartLeft && x <= drawState.chartRight && y > plotBottom && y <= height;
1458
2081
  if (inXAxis) {
1459
2082
  return "x-axis";
1460
2083
  }
@@ -1779,6 +2402,9 @@ function createChart(element, options = {}) {
1779
2402
  };
1780
2403
  const setData = (nextData) => {
1781
2404
  const hadData = data.length > 0;
2405
+ const previousCenterRounded = hadData ? Math.round(xCenter) : 0;
2406
+ const previousCenterFraction = hadData ? xCenter - previousCenterRounded : 0;
2407
+ const previousCenterTimeMs = hadData && previousCenterRounded >= 0 && previousCenterRounded < data.length ? data[previousCenterRounded]?.time.getTime() ?? null : null;
1782
2408
  data = parseData(nextData);
1783
2409
  if (data.length === 0) {
1784
2410
  xCenter = 0;
@@ -1792,6 +2418,12 @@ function createChart(element, options = {}) {
1792
2418
  resetYViewport();
1793
2419
  } else {
1794
2420
  if (mergedOptions.preserveViewportOnDataUpdate) {
2421
+ if (previousCenterTimeMs !== null) {
2422
+ const nextCenter = findNearestIndexForTimeMs(previousCenterTimeMs);
2423
+ if (nextCenter !== null) {
2424
+ xCenter = nextCenter + previousCenterFraction;
2425
+ }
2426
+ }
1795
2427
  clampXViewport();
1796
2428
  } else {
1797
2429
  fitXViewport();
@@ -1868,6 +2500,60 @@ function createChart(element, options = {}) {
1868
2500
  const setDoubleClickAction = (action) => {
1869
2501
  doubleClickAction = action;
1870
2502
  };
2503
+ const registerIndicator = (plugin) => {
2504
+ if (!plugin.id || typeof plugin.draw !== "function") {
2505
+ throw new Error("Invalid indicator plugin. Expected { id, draw }.");
2506
+ }
2507
+ indicatorRegistry.set(plugin.id, plugin);
2508
+ draw();
2509
+ };
2510
+ const unregisterIndicator = (type) => {
2511
+ indicatorRegistry.delete(type);
2512
+ indicators = indicators.filter((indicator) => indicator.type !== type);
2513
+ draw();
2514
+ };
2515
+ const setIndicators = (nextIndicators) => {
2516
+ indicators = nextIndicators.map((indicator) => normalizeIndicatorState(indicator));
2517
+ draw();
2518
+ };
2519
+ const addIndicator = (type, inputs = {}, options2 = {}) => {
2520
+ const plugin = indicatorRegistry.get(type);
2521
+ if (!plugin) {
2522
+ throw new Error(`Unknown indicator type "${type}". Register it first.`);
2523
+ }
2524
+ const next = normalizeIndicatorState({
2525
+ ...options2,
2526
+ type,
2527
+ inputs
2528
+ });
2529
+ indicators.push(next);
2530
+ draw();
2531
+ return next.id;
2532
+ };
2533
+ const updateIndicator = (id, patch) => {
2534
+ indicators = indicators.map((indicator) => {
2535
+ if (indicator.id !== id) {
2536
+ return indicator;
2537
+ }
2538
+ const plugin = indicatorRegistry.get(indicator.type);
2539
+ return {
2540
+ ...indicator,
2541
+ visible: patch.visible ?? indicator.visible,
2542
+ pane: patch.pane ?? indicator.pane ?? plugin?.pane ?? "overlay",
2543
+ ...patch.paneHeightRatio !== void 0 || indicator.paneHeightRatio !== void 0 ? { paneHeightRatio: patch.paneHeightRatio ?? indicator.paneHeightRatio } : {},
2544
+ type: patch.type ?? indicator.type,
2545
+ inputs: {
2546
+ ...indicator.inputs,
2547
+ ...patch.inputs ?? {}
2548
+ }
2549
+ };
2550
+ });
2551
+ draw();
2552
+ };
2553
+ const removeIndicator = (id) => {
2554
+ indicators = indicators.filter((indicator) => indicator.id !== id);
2555
+ draw();
2556
+ };
1871
2557
  const destroy = () => {
1872
2558
  canvas.removeEventListener("pointerdown", onPointerDown);
1873
2559
  canvas.removeEventListener("pointermove", onPointerMove);
@@ -1902,6 +2588,12 @@ function createChart(element, options = {}) {
1902
2588
  fitContent,
1903
2589
  setDoubleClickEnabled,
1904
2590
  setDoubleClickAction,
2591
+ registerIndicator,
2592
+ unregisterIndicator,
2593
+ addIndicator,
2594
+ updateIndicator,
2595
+ removeIndicator,
2596
+ setIndicators,
1905
2597
  resize,
1906
2598
  destroy
1907
2599
  };