hyperprop-charting-library 0.1.20 → 0.1.21

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,476 @@ 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: "separate",
482
+ paneHeightRatio: 0.22,
483
+ defaultInputs: {
484
+ upOpacity: 0.7,
485
+ downOpacity: 0.7,
486
+ minBarWidth: 1
487
+ },
488
+ draw: (ctx, renderContext, inputs) => {
489
+ const { data, startIndex, endIndex, chartTop, chartBottom, candleSpacing, xFromIndex, upColor, downColor } = renderContext;
490
+ const paneHeight = Math.max(1, chartBottom - chartTop);
491
+ const visiblePoints = data.slice(startIndex, endIndex + 1);
492
+ const maxVolume = Math.max(1, ...visiblePoints.map((point) => point.v ?? 0));
493
+ const barWidth = Math.max(
494
+ Math.max(1, Number(inputs.minBarWidth) || 1),
495
+ Math.min(Math.max(1, candleSpacing - 1), Math.floor(candleSpacing * 0.7))
496
+ );
497
+ const upOpacity = Math.min(1, Math.max(0.05, Number(inputs.upOpacity) || 0.7));
498
+ const downOpacity = Math.min(1, Math.max(0.05, Number(inputs.downOpacity) || 0.7));
499
+ for (let index = startIndex; index <= endIndex; index += 1) {
500
+ const point = data[index];
501
+ if (!point || point.v === void 0 || point.v <= 0) {
502
+ continue;
503
+ }
504
+ const ratio = Math.min(1, Math.max(0, point.v / maxVolume));
505
+ const volumeHeight = Math.max(1, Math.round(paneHeight * ratio));
506
+ const xCenter = xFromIndex(index);
507
+ const barX = Math.round(xCenter - barWidth / 2);
508
+ const barY = Math.round(chartBottom - volumeHeight);
509
+ const isUp = point.c >= point.o;
510
+ const opacity = isUp ? upOpacity : downOpacity;
511
+ ctx.save();
512
+ ctx.globalAlpha = opacity;
513
+ ctx.fillStyle = isUp ? upColor : downColor;
514
+ ctx.fillRect(barX, barY, Math.max(1, Math.round(barWidth)), volumeHeight);
515
+ ctx.restore();
516
+ }
517
+ }
518
+ };
519
+ var BUILTIN_SMA_INDICATOR = {
520
+ id: "sma",
521
+ name: "SMA",
522
+ pane: "overlay",
523
+ defaultInputs: { length: 20, source: "close", color: "#60a5fa", width: 2 },
524
+ draw: (ctx, renderContext, inputs) => {
525
+ const length = clampIndicatorLength(inputs.length, 20);
526
+ const values = computeSmaSeries(renderContext.data, length, inputs.source ?? "close");
527
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#60a5fa", Number(inputs.width) || 2);
528
+ }
529
+ };
530
+ var BUILTIN_EMA_INDICATOR = {
531
+ id: "ema",
532
+ name: "EMA",
533
+ pane: "overlay",
534
+ defaultInputs: { length: 20, source: "close", color: "#f59e0b", width: 2 },
535
+ draw: (ctx, renderContext, inputs) => {
536
+ const length = clampIndicatorLength(inputs.length, 20);
537
+ const values = computeEmaSeries(renderContext.data, length, inputs.source ?? "close");
538
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
539
+ }
540
+ };
541
+ var BUILTIN_WMA_INDICATOR = {
542
+ id: "wma",
543
+ name: "WMA",
544
+ pane: "overlay",
545
+ defaultInputs: { length: 20, source: "close", color: "#a78bfa", width: 2 },
546
+ draw: (ctx, renderContext, inputs) => {
547
+ const length = clampIndicatorLength(inputs.length, 20);
548
+ const values = computeWmaSeries(renderContext.data, length, inputs.source ?? "close");
549
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#a78bfa", Number(inputs.width) || 2);
550
+ }
551
+ };
552
+ var BUILTIN_VWMA_INDICATOR = {
553
+ id: "vwma",
554
+ name: "VWMA",
555
+ pane: "overlay",
556
+ defaultInputs: { length: 20, source: "close", color: "#ef4444", width: 2 },
557
+ draw: (ctx, renderContext, inputs) => {
558
+ const length = clampIndicatorLength(inputs.length, 20);
559
+ const values = computeVwmaSeries(renderContext.data, length, inputs.source ?? "close");
560
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#ef4444", Number(inputs.width) || 2);
561
+ }
562
+ };
563
+ var BUILTIN_RMA_INDICATOR = {
564
+ id: "rma",
565
+ name: "RMA",
566
+ pane: "overlay",
567
+ defaultInputs: { length: 14, source: "close", color: "#22c55e", width: 2 },
568
+ draw: (ctx, renderContext, inputs) => {
569
+ const length = clampIndicatorLength(inputs.length, 14);
570
+ const values = computeRmaSeries(renderContext.data, length, inputs.source ?? "close");
571
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#22c55e", Number(inputs.width) || 2);
572
+ }
573
+ };
574
+ var BUILTIN_HMA_INDICATOR = {
575
+ id: "hma",
576
+ name: "HMA",
577
+ pane: "overlay",
578
+ defaultInputs: { length: 21, source: "close", color: "#14b8a6", width: 2 },
579
+ draw: (ctx, renderContext, inputs) => {
580
+ const length = clampIndicatorLength(inputs.length, 21);
581
+ const values = computeHmaSeries(renderContext.data, length, inputs.source ?? "close");
582
+ drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
583
+ }
584
+ };
585
+ var BUILTIN_STDDEV_INDICATOR = {
586
+ id: "stddev",
587
+ name: "StdDev",
588
+ pane: "separate",
589
+ paneHeightRatio: 0.16,
590
+ defaultInputs: { length: 20, source: "close", color: "#f97316", width: 2 },
591
+ draw: (ctx, renderContext, inputs) => {
592
+ const length = clampIndicatorLength(inputs.length, 20);
593
+ const values = computeStdDevSeries(renderContext.data, length, inputs.source ?? "close");
594
+ drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#f97316", Number(inputs.width) || 2);
595
+ }
596
+ };
597
+ var BUILTIN_ATR_INDICATOR = {
598
+ id: "atr",
599
+ name: "ATR",
600
+ pane: "separate",
601
+ paneHeightRatio: 0.16,
602
+ defaultInputs: { length: 14, color: "#eab308", width: 2 },
603
+ draw: (ctx, renderContext, inputs) => {
604
+ const length = clampIndicatorLength(inputs.length, 14);
605
+ const values = computeAtrSeries(renderContext.data, length);
606
+ drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#eab308", Number(inputs.width) || 2);
607
+ }
608
+ };
609
+ var BUILTIN_RSI_INDICATOR = {
610
+ id: "rsi",
611
+ name: "RSI",
612
+ pane: "separate",
613
+ paneHeightRatio: 0.18,
614
+ defaultInputs: { length: 14, color: "#3b82f6", width: 2 },
615
+ draw: (ctx, renderContext, inputs) => {
616
+ const length = clampIndicatorLength(inputs.length, 14);
617
+ const values = computeRsiSeries(renderContext.data, length);
618
+ drawSeparateSeries(
619
+ ctx,
620
+ renderContext,
621
+ values,
622
+ inputs.color ?? "#3b82f6",
623
+ Number(inputs.width) || 2,
624
+ 0,
625
+ 100,
626
+ [30, 50, 70]
627
+ );
628
+ }
629
+ };
630
+ var BUILTIN_INDICATORS = [
631
+ BUILTIN_VOLUME_INDICATOR,
632
+ BUILTIN_SMA_INDICATOR,
633
+ BUILTIN_EMA_INDICATOR,
634
+ BUILTIN_RSI_INDICATOR,
635
+ BUILTIN_WMA_INDICATOR,
636
+ BUILTIN_VWMA_INDICATOR,
637
+ BUILTIN_RMA_INDICATOR,
638
+ BUILTIN_HMA_INDICATOR,
639
+ BUILTIN_STDDEV_INDICATOR,
640
+ BUILTIN_ATR_INDICATOR
641
+ ];
174
642
  function createChart(element, options = {}) {
175
643
  const mergedOptions = {
176
644
  ...DEFAULT_OPTIONS,
@@ -221,6 +689,26 @@ function createChart(element, options = {}) {
221
689
  let orderDragRegions = [];
222
690
  let generatedPriceLineId = 1;
223
691
  let generatedOrderLineId = 1;
692
+ let generatedIndicatorId = 1;
693
+ const indicatorRegistry = /* @__PURE__ */ new Map();
694
+ for (const indicator of BUILTIN_INDICATORS) {
695
+ indicatorRegistry.set(indicator.id, indicator);
696
+ }
697
+ const normalizeIndicatorState = (indicator) => {
698
+ const plugin = indicatorRegistry.get(indicator.type);
699
+ const defaults = plugin?.defaultInputs ?? {};
700
+ return {
701
+ id: indicator.id ?? `indicator-${generatedIndicatorId++}`,
702
+ type: indicator.type,
703
+ visible: indicator.visible ?? true,
704
+ pane: indicator.pane ?? plugin?.pane ?? "overlay",
705
+ inputs: {
706
+ ...defaults,
707
+ ...indicator.inputs ?? {}
708
+ }
709
+ };
710
+ };
711
+ let indicators = (options.indicators ?? []).map((indicator) => normalizeIndicatorState(indicator));
224
712
  const orderWidgetWidthById = /* @__PURE__ */ new Map();
225
713
  const orderPriceTagWidthById = /* @__PURE__ */ new Map();
226
714
  let xCenter = 0;
@@ -233,6 +721,7 @@ function createChart(element, options = {}) {
233
721
  let watermarkImage = null;
234
722
  let watermarkImageReady = false;
235
723
  let drawState = null;
724
+ let plotBottomForHit = 0;
236
725
  let orderDragState = null;
237
726
  let actionDragState = null;
238
727
  let pointerDownInfo = null;
@@ -449,6 +938,39 @@ function createChart(element, options = {}) {
449
938
  const extra = index - (data.length - 1);
450
939
  return new Date(last.time.getTime() + extra * stepMs);
451
940
  };
941
+ const findNearestIndexForTimeMs = (timeMs) => {
942
+ if (!Number.isFinite(timeMs) || data.length === 0) {
943
+ return null;
944
+ }
945
+ let low = 0;
946
+ let high = data.length - 1;
947
+ while (low <= high) {
948
+ const mid = Math.floor((low + high) / 2);
949
+ const midPoint = data[mid];
950
+ if (!midPoint) {
951
+ break;
952
+ }
953
+ const midTime = midPoint.time.getTime();
954
+ if (midTime === timeMs) {
955
+ return mid;
956
+ }
957
+ if (midTime < timeMs) {
958
+ low = mid + 1;
959
+ } else {
960
+ high = mid - 1;
961
+ }
962
+ }
963
+ const lower = clamp(high, 0, data.length - 1);
964
+ const upper = clamp(low, 0, data.length - 1);
965
+ const lowerPoint = data[lower];
966
+ const upperPoint = data[upper];
967
+ if (!lowerPoint && !upperPoint) return null;
968
+ if (!lowerPoint) return upper;
969
+ if (!upperPoint) return lower;
970
+ const lowerDelta = Math.abs(lowerPoint.time.getTime() - timeMs);
971
+ const upperDelta = Math.abs(upperPoint.time.getTime() - timeMs);
972
+ return lowerDelta <= upperDelta ? lower : upper;
973
+ };
452
974
  const formatHoverTimeLabel = (time, mode) => {
453
975
  if (mode === "time") {
454
976
  return time.toLocaleTimeString(void 0, {
@@ -829,18 +1351,36 @@ function createChart(element, options = {}) {
829
1351
  const chartLeft = margin.left;
830
1352
  const chartTop = margin.top;
831
1353
  const chartWidth = width - margin.left - margin.right;
832
- const chartHeight = height - margin.top - margin.bottom;
833
- const chartBottom = chartTop + chartHeight;
1354
+ const fullChartHeight = height - margin.top - margin.bottom;
1355
+ const fullChartBottom = chartTop + fullChartHeight;
834
1356
  const chartRight = chartLeft + chartWidth;
835
1357
  const watermark = { ...DEFAULT_WATERMARK_OPTIONS, ...mergedOptions.watermark ?? {} };
1358
+ const paneGap = 8;
1359
+ const separatePaneSpacing = 6;
1360
+ const activeSeparateIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1361
+ (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "separate"
1362
+ );
1363
+ const separatePaneHeightDefaults = activeSeparateIndicators.map(({ plugin }) => {
1364
+ const ratio = Math.min(0.45, Math.max(0.08, plugin.paneHeightRatio ?? 0.22));
1365
+ return Math.round(fullChartHeight * ratio);
1366
+ });
1367
+ const separatePaneDesiredTotal = separatePaneHeightDefaults.reduce((sum, value) => sum + value, 0) + Math.max(0, activeSeparateIndicators.length - 1) * separatePaneSpacing;
1368
+ const maxSeparatePaneTotal = Math.max(0, fullChartHeight - 140);
1369
+ const separatePaneScale = separatePaneDesiredTotal > 0 && separatePaneDesiredTotal > maxSeparatePaneTotal ? maxSeparatePaneTotal / separatePaneDesiredTotal : 1;
1370
+ const separatePaneHeights = separatePaneHeightDefaults.map((value) => Math.max(48, Math.round(value * separatePaneScale)));
1371
+ const separatePaneTotal = separatePaneHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, activeSeparateIndicators.length - 1) * separatePaneSpacing;
1372
+ const pricePaneGap = activeSeparateIndicators.length > 0 ? paneGap : 0;
1373
+ const chartHeight = Math.max(120, fullChartHeight - separatePaneTotal - pricePaneGap);
1374
+ const chartBottom = chartTop + chartHeight;
836
1375
  if (data.length === 0) {
1376
+ plotBottomForHit = fullChartBottom;
837
1377
  drawState = {
838
1378
  chartLeft,
839
1379
  chartTop,
840
1380
  chartRight,
841
- chartBottom,
1381
+ chartBottom: fullChartBottom,
842
1382
  chartWidth,
843
- chartHeight,
1383
+ chartHeight: fullChartHeight,
844
1384
  xStart: 0,
845
1385
  xSpan: 0,
846
1386
  yMin: 0,
@@ -908,6 +1448,7 @@ function createChart(element, options = {}) {
908
1448
  yMin,
909
1449
  yMax
910
1450
  };
1451
+ plotBottomForHit = fullChartBottom;
911
1452
  const yFromPrice = (price) => {
912
1453
  return chartBottom - (price - yMin) / yRange * chartHeight;
913
1454
  };
@@ -1000,7 +1541,7 @@ function createChart(element, options = {}) {
1000
1541
  ctx.strokeStyle = gridColor;
1001
1542
  ctx.beginPath();
1002
1543
  ctx.moveTo(crisp(x), crisp(chartTop));
1003
- ctx.lineTo(crisp(x), crisp(chartBottom));
1544
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
1004
1545
  ctx.stroke();
1005
1546
  }
1006
1547
  ctx.restore();
@@ -1028,6 +1569,36 @@ function createChart(element, options = {}) {
1028
1569
  ctx.fillStyle = candleColor;
1029
1570
  ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1030
1571
  }
1572
+ const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1573
+ (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
1574
+ );
1575
+ if (activeOverlayIndicators.length > 0) {
1576
+ const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1577
+ activeOverlayIndicators.forEach(({ indicator, plugin }) => {
1578
+ plugin.draw(
1579
+ ctx,
1580
+ {
1581
+ data,
1582
+ startIndex,
1583
+ endIndex,
1584
+ xStart,
1585
+ xSpan,
1586
+ chartLeft,
1587
+ chartRight,
1588
+ chartTop,
1589
+ chartBottom,
1590
+ chartWidth,
1591
+ chartHeight,
1592
+ xFromIndex,
1593
+ yFromPrice,
1594
+ candleSpacing,
1595
+ upColor: mergedOptions.upColor,
1596
+ downColor: mergedOptions.downColor
1597
+ },
1598
+ indicator.inputs
1599
+ );
1600
+ });
1601
+ }
1031
1602
  const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1032
1603
  if (crosshair.visible && crosshairPoint) {
1033
1604
  const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
@@ -1051,11 +1622,57 @@ function createChart(element, options = {}) {
1051
1622
  ctx.restore();
1052
1623
  }
1053
1624
  ctx.restore();
1625
+ if (activeSeparateIndicators.length > 0) {
1626
+ const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1627
+ let paneTopCursor = chartBottom + paneGap;
1628
+ activeSeparateIndicators.forEach(({ indicator, plugin }, paneIndex) => {
1629
+ const paneHeight = separatePaneHeights[paneIndex] ?? 80;
1630
+ const paneTop = paneTopCursor;
1631
+ const paneBottom = paneTop + paneHeight;
1632
+ paneTopCursor = paneBottom + separatePaneSpacing;
1633
+ ctx.save();
1634
+ ctx.beginPath();
1635
+ ctx.rect(chartLeft + 1, paneTop + 1, Math.max(0, chartWidth - 2), Math.max(0, paneHeight - 2));
1636
+ ctx.clip();
1637
+ plugin.draw(
1638
+ ctx,
1639
+ {
1640
+ data,
1641
+ startIndex,
1642
+ endIndex,
1643
+ xStart,
1644
+ xSpan,
1645
+ chartLeft,
1646
+ chartRight,
1647
+ chartTop: paneTop,
1648
+ chartBottom: paneBottom,
1649
+ chartWidth,
1650
+ chartHeight: paneHeight,
1651
+ xFromIndex,
1652
+ yFromPrice: null,
1653
+ candleSpacing,
1654
+ upColor: mergedOptions.upColor,
1655
+ downColor: mergedOptions.downColor
1656
+ },
1657
+ indicator.inputs
1658
+ );
1659
+ ctx.restore();
1660
+ ctx.save();
1661
+ ctx.strokeStyle = axis.lineColor;
1662
+ ctx.lineWidth = Math.max(1, axis.lineWidth);
1663
+ ctx.beginPath();
1664
+ ctx.moveTo(crisp(chartLeft), crisp(paneTop));
1665
+ ctx.lineTo(crisp(chartRight), crisp(paneTop));
1666
+ ctx.stroke();
1667
+ ctx.restore();
1668
+ drawText(plugin.name.toUpperCase(), chartLeft + 6, paneTop + 12, "left", "middle", axis.textColor);
1669
+ });
1670
+ }
1054
1671
  ctx.strokeStyle = axis.lineColor;
1055
1672
  ctx.lineWidth = Math.max(1, axis.lineWidth);
1056
1673
  ctx.beginPath();
1057
- ctx.moveTo(crisp(chartLeft), crisp(chartBottom));
1058
- ctx.lineTo(crisp(chartRight), crisp(chartBottom));
1674
+ ctx.moveTo(crisp(chartLeft), crisp(fullChartBottom));
1675
+ ctx.lineTo(crisp(chartRight), crisp(fullChartBottom));
1059
1676
  ctx.moveTo(crisp(chartRight), crisp(chartTop));
1060
1677
  ctx.lineTo(crisp(chartRight), crisp(chartBottom));
1061
1678
  ctx.stroke();
@@ -1119,7 +1736,7 @@ function createChart(element, options = {}) {
1119
1736
  month: "short",
1120
1737
  day: "numeric"
1121
1738
  });
1122
- drawText(timeLabel, x, chartBottom + 8, "center", "top", axis.textColor);
1739
+ drawText(timeLabel, x, fullChartBottom + 8, "center", "top", axis.textColor);
1123
1740
  }
1124
1741
  if (crosshair.visible && crosshairPoint) {
1125
1742
  const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
@@ -1243,7 +1860,7 @@ function createChart(element, options = {}) {
1243
1860
  const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
1244
1861
  const timeWidth = Math.ceil(ctx.measureText(timeText).width) + labelPaddingX * 2;
1245
1862
  const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
1246
- const timeY = chartBottom + 8;
1863
+ const timeY = fullChartBottom + 8;
1247
1864
  ctx.fillStyle = labelBackground;
1248
1865
  fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, labelHeight, labelRadius);
1249
1866
  strokeCrosshairLabel(timeX, timeY, timeWidth);
@@ -1450,11 +2067,12 @@ function createChart(element, options = {}) {
1450
2067
  if (!drawState) {
1451
2068
  return "outside";
1452
2069
  }
1453
- const inPlot = x >= drawState.chartLeft && x <= drawState.chartRight && y >= drawState.chartTop && y <= drawState.chartBottom;
2070
+ const plotBottom = Math.max(drawState.chartBottom, plotBottomForHit);
2071
+ const inPlot = x >= drawState.chartLeft && x <= drawState.chartRight && y >= drawState.chartTop && y <= plotBottom;
1454
2072
  if (inPlot) {
1455
2073
  return "plot";
1456
2074
  }
1457
- const inXAxis = x >= drawState.chartLeft && x <= drawState.chartRight && y > drawState.chartBottom && y <= height;
2075
+ const inXAxis = x >= drawState.chartLeft && x <= drawState.chartRight && y > plotBottom && y <= height;
1458
2076
  if (inXAxis) {
1459
2077
  return "x-axis";
1460
2078
  }
@@ -1779,6 +2397,9 @@ function createChart(element, options = {}) {
1779
2397
  };
1780
2398
  const setData = (nextData) => {
1781
2399
  const hadData = data.length > 0;
2400
+ const previousCenterRounded = hadData ? Math.round(xCenter) : 0;
2401
+ const previousCenterFraction = hadData ? xCenter - previousCenterRounded : 0;
2402
+ const previousCenterTimeMs = hadData && previousCenterRounded >= 0 && previousCenterRounded < data.length ? data[previousCenterRounded]?.time.getTime() ?? null : null;
1782
2403
  data = parseData(nextData);
1783
2404
  if (data.length === 0) {
1784
2405
  xCenter = 0;
@@ -1792,6 +2413,12 @@ function createChart(element, options = {}) {
1792
2413
  resetYViewport();
1793
2414
  } else {
1794
2415
  if (mergedOptions.preserveViewportOnDataUpdate) {
2416
+ if (previousCenterTimeMs !== null) {
2417
+ const nextCenter = findNearestIndexForTimeMs(previousCenterTimeMs);
2418
+ if (nextCenter !== null) {
2419
+ xCenter = nextCenter + previousCenterFraction;
2420
+ }
2421
+ }
1795
2422
  clampXViewport();
1796
2423
  } else {
1797
2424
  fitXViewport();
@@ -1868,6 +2495,59 @@ function createChart(element, options = {}) {
1868
2495
  const setDoubleClickAction = (action) => {
1869
2496
  doubleClickAction = action;
1870
2497
  };
2498
+ const registerIndicator = (plugin) => {
2499
+ if (!plugin.id || typeof plugin.draw !== "function") {
2500
+ throw new Error("Invalid indicator plugin. Expected { id, draw }.");
2501
+ }
2502
+ indicatorRegistry.set(plugin.id, plugin);
2503
+ draw();
2504
+ };
2505
+ const unregisterIndicator = (type) => {
2506
+ indicatorRegistry.delete(type);
2507
+ indicators = indicators.filter((indicator) => indicator.type !== type);
2508
+ draw();
2509
+ };
2510
+ const setIndicators = (nextIndicators) => {
2511
+ indicators = nextIndicators.map((indicator) => normalizeIndicatorState(indicator));
2512
+ draw();
2513
+ };
2514
+ const addIndicator = (type, inputs = {}, options2 = {}) => {
2515
+ const plugin = indicatorRegistry.get(type);
2516
+ if (!plugin) {
2517
+ throw new Error(`Unknown indicator type "${type}". Register it first.`);
2518
+ }
2519
+ const next = normalizeIndicatorState({
2520
+ ...options2,
2521
+ type,
2522
+ inputs
2523
+ });
2524
+ indicators.push(next);
2525
+ draw();
2526
+ return next.id;
2527
+ };
2528
+ const updateIndicator = (id, patch) => {
2529
+ indicators = indicators.map((indicator) => {
2530
+ if (indicator.id !== id) {
2531
+ return indicator;
2532
+ }
2533
+ const plugin = indicatorRegistry.get(indicator.type);
2534
+ return {
2535
+ ...indicator,
2536
+ visible: patch.visible ?? indicator.visible,
2537
+ pane: patch.pane ?? indicator.pane ?? plugin?.pane ?? "overlay",
2538
+ type: patch.type ?? indicator.type,
2539
+ inputs: {
2540
+ ...indicator.inputs,
2541
+ ...patch.inputs ?? {}
2542
+ }
2543
+ };
2544
+ });
2545
+ draw();
2546
+ };
2547
+ const removeIndicator = (id) => {
2548
+ indicators = indicators.filter((indicator) => indicator.id !== id);
2549
+ draw();
2550
+ };
1871
2551
  const destroy = () => {
1872
2552
  canvas.removeEventListener("pointerdown", onPointerDown);
1873
2553
  canvas.removeEventListener("pointermove", onPointerMove);
@@ -1902,6 +2582,12 @@ function createChart(element, options = {}) {
1902
2582
  fitContent,
1903
2583
  setDoubleClickEnabled,
1904
2584
  setDoubleClickAction,
2585
+ registerIndicator,
2586
+ unregisterIndicator,
2587
+ addIndicator,
2588
+ updateIndicator,
2589
+ removeIndicator,
2590
+ setIndicators,
1905
2591
  resize,
1906
2592
  destroy
1907
2593
  };