hyperprop-charting-library 0.1.138 → 0.1.139
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/hyperprop-charting-library.cjs +337 -1
- package/dist/hyperprop-charting-library.d.ts +52 -1
- package/dist/hyperprop-charting-library.js +337 -1
- package/dist/index.cjs +337 -1
- package/dist/index.d.cts +52 -1
- package/dist/index.d.ts +52 -1
- package/dist/index.js +337 -1
- package/docs/API.md +51 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2821,6 +2821,7 @@ var DEFAULT_OPTIONS = {
|
|
|
2821
2821
|
doubleClickAction: "reset",
|
|
2822
2822
|
keyboard: { enabled: true, panBars: 1, deleteSelectedDrawing: true },
|
|
2823
2823
|
accessibility: { label: "Price chart", description: "", focusable: true },
|
|
2824
|
+
downsampling: { enabled: true, thresholdPx: 1.5 },
|
|
2824
2825
|
crosshair: DEFAULT_CROSSHAIR_OPTIONS,
|
|
2825
2826
|
grid: DEFAULT_GRID_OPTIONS,
|
|
2826
2827
|
watermark: DEFAULT_WATERMARK_OPTIONS,
|
|
@@ -2871,6 +2872,10 @@ var mergeChartOptions = (baseOptions, options = {}) => ({
|
|
|
2871
2872
|
...baseOptions.accessibility,
|
|
2872
2873
|
...options.accessibility ?? {}
|
|
2873
2874
|
},
|
|
2875
|
+
downsampling: {
|
|
2876
|
+
...baseOptions.downsampling,
|
|
2877
|
+
...options.downsampling ?? {}
|
|
2878
|
+
},
|
|
2874
2879
|
crosshair: {
|
|
2875
2880
|
...baseOptions.crosshair,
|
|
2876
2881
|
...options.crosshair ?? {}
|
|
@@ -3746,6 +3751,64 @@ function createChart(element, options = {}) {
|
|
|
3746
3751
|
heikinAshiFingerprint = fingerprint;
|
|
3747
3752
|
return result;
|
|
3748
3753
|
};
|
|
3754
|
+
let compareSeriesStates = [];
|
|
3755
|
+
const prepareCompareSeries = (options2) => {
|
|
3756
|
+
const rows = [];
|
|
3757
|
+
for (const point of options2.data ?? []) {
|
|
3758
|
+
const t = new Date(point.t).getTime();
|
|
3759
|
+
const c = Number(point.c);
|
|
3760
|
+
if (Number.isFinite(t) && Number.isFinite(c)) rows.push({ t, c });
|
|
3761
|
+
}
|
|
3762
|
+
rows.sort((a, b) => a.t - b.t);
|
|
3763
|
+
return {
|
|
3764
|
+
options: options2,
|
|
3765
|
+
times: rows.map((row) => row.t),
|
|
3766
|
+
closes: rows.map((row) => row.c),
|
|
3767
|
+
aligned: null,
|
|
3768
|
+
alignedFingerprint: ""
|
|
3769
|
+
};
|
|
3770
|
+
};
|
|
3771
|
+
const mainDataFingerprint = () => {
|
|
3772
|
+
const last = data[data.length - 1];
|
|
3773
|
+
return last ? `${data.length}|${data[0].time.getTime()}|${last.time.getTime()}` : "empty";
|
|
3774
|
+
};
|
|
3775
|
+
const getAlignedCompareValues = (state) => {
|
|
3776
|
+
const fingerprint = `${mainDataFingerprint()}|${state.times.length}|${state.times[state.times.length - 1] ?? 0}`;
|
|
3777
|
+
if (state.aligned && state.alignedFingerprint === fingerprint) {
|
|
3778
|
+
return state.aligned;
|
|
3779
|
+
}
|
|
3780
|
+
const aligned = new Float64Array(data.length).fill(Number.NaN);
|
|
3781
|
+
let cursor = 0;
|
|
3782
|
+
let lastValue = Number.NaN;
|
|
3783
|
+
for (let i = 0; i < data.length; i += 1) {
|
|
3784
|
+
const barTime = data[i].time.getTime();
|
|
3785
|
+
while (cursor < state.times.length && state.times[cursor] <= barTime) {
|
|
3786
|
+
lastValue = state.closes[cursor];
|
|
3787
|
+
cursor += 1;
|
|
3788
|
+
}
|
|
3789
|
+
aligned[i] = lastValue;
|
|
3790
|
+
}
|
|
3791
|
+
state.aligned = aligned;
|
|
3792
|
+
state.alignedFingerprint = fingerprint;
|
|
3793
|
+
return aligned;
|
|
3794
|
+
};
|
|
3795
|
+
const visibleCompareSeries = () => compareSeriesStates.filter((state) => state.options.visible !== false && state.times.length > 0);
|
|
3796
|
+
const setCompareSeries = (series) => {
|
|
3797
|
+
compareSeriesStates = (series ?? []).filter((entry) => entry && entry.id).map(prepareCompareSeries);
|
|
3798
|
+
scheduleDraw({ updateAutoScale: true });
|
|
3799
|
+
};
|
|
3800
|
+
const addCompareSeries = (series) => {
|
|
3801
|
+
if (!series?.id) return;
|
|
3802
|
+
compareSeriesStates = [...compareSeriesStates.filter((state) => state.options.id !== series.id), prepareCompareSeries(series)];
|
|
3803
|
+
scheduleDraw({ updateAutoScale: true });
|
|
3804
|
+
};
|
|
3805
|
+
const removeCompareSeries = (id) => {
|
|
3806
|
+
const next = compareSeriesStates.filter((state) => state.options.id !== id);
|
|
3807
|
+
if (next.length === compareSeriesStates.length) return;
|
|
3808
|
+
compareSeriesStates = next;
|
|
3809
|
+
scheduleDraw({ updateAutoScale: true });
|
|
3810
|
+
};
|
|
3811
|
+
const getCompareSeries = () => compareSeriesStates.map((state) => state.options);
|
|
3749
3812
|
const formatHoverTimeLabel = (time, mode) => {
|
|
3750
3813
|
if (mode === "time") {
|
|
3751
3814
|
return time.toLocaleTimeString(void 0, {
|
|
@@ -4351,6 +4414,31 @@ function createChart(element, options = {}) {
|
|
|
4351
4414
|
const chartType = mergedOptions.chartType;
|
|
4352
4415
|
const seriesData = chartType === "heikin-ashi" ? getHeikinAshiData() : data;
|
|
4353
4416
|
const isLineStyleChart = chartType === "line" || chartType === "area" || chartType === "baseline";
|
|
4417
|
+
const comparePlans = [];
|
|
4418
|
+
for (const state of visibleCompareSeries()) {
|
|
4419
|
+
const aligned = getAlignedCompareValues(state);
|
|
4420
|
+
let anchorIndex = -1;
|
|
4421
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
4422
|
+
if (Number.isFinite(aligned[index] ?? Number.NaN)) {
|
|
4423
|
+
anchorIndex = index;
|
|
4424
|
+
break;
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
if (anchorIndex < 0) continue;
|
|
4428
|
+
const baseCompare = aligned[anchorIndex];
|
|
4429
|
+
const baseMain = seriesData[anchorIndex]?.c ?? 0;
|
|
4430
|
+
const usePercent = (state.options.scale ?? "percent") !== "price";
|
|
4431
|
+
const toPrice = usePercent && baseCompare > 0 ? (value) => baseMain * value / baseCompare : (value) => value;
|
|
4432
|
+
let lastNativeValue = Number.NaN;
|
|
4433
|
+
for (let index = endIndex; index >= startIndex; index -= 1) {
|
|
4434
|
+
const value = aligned[index];
|
|
4435
|
+
if (value !== void 0 && Number.isFinite(value)) {
|
|
4436
|
+
lastNativeValue = value;
|
|
4437
|
+
break;
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
comparePlans.push({ state, aligned, toPrice, usePercent, baseCompare, lastNativeValue });
|
|
4441
|
+
}
|
|
4354
4442
|
const skipLatestIndex = mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1 ? data.length - 1 : -1;
|
|
4355
4443
|
const scanCandleRange = (from, to) => {
|
|
4356
4444
|
let min = Number.POSITIVE_INFINITY;
|
|
@@ -4396,6 +4484,23 @@ function createChart(element, options = {}) {
|
|
|
4396
4484
|
maxPrice = Math.max(maxPrice, weightedMax);
|
|
4397
4485
|
}
|
|
4398
4486
|
}
|
|
4487
|
+
for (const plan of comparePlans) {
|
|
4488
|
+
if (plan.state.options.includeInAutoScale === false) continue;
|
|
4489
|
+
let compareMin = Number.POSITIVE_INFINITY;
|
|
4490
|
+
let compareMax = Number.NEGATIVE_INFINITY;
|
|
4491
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
4492
|
+
const value = plan.aligned[index];
|
|
4493
|
+
if (value === void 0 || !Number.isFinite(value)) continue;
|
|
4494
|
+
const price = plan.toPrice(value);
|
|
4495
|
+
if (!Number.isFinite(price)) continue;
|
|
4496
|
+
if (price < compareMin) compareMin = price;
|
|
4497
|
+
if (price > compareMax) compareMax = price;
|
|
4498
|
+
}
|
|
4499
|
+
if (compareMin <= compareMax) {
|
|
4500
|
+
minPrice = Math.min(minPrice, compareMin);
|
|
4501
|
+
maxPrice = Math.max(maxPrice, compareMax);
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4399
4504
|
const priceRange = maxPrice - minPrice || 1;
|
|
4400
4505
|
const autoMin = minPrice - priceRange * 0.08;
|
|
4401
4506
|
const autoMax = maxPrice + priceRange * 0.08;
|
|
@@ -5507,7 +5612,106 @@ function createChart(element, options = {}) {
|
|
|
5507
5612
|
}
|
|
5508
5613
|
return data[index]?.v;
|
|
5509
5614
|
};
|
|
5510
|
-
|
|
5615
|
+
const downsamplingOptions = mergedOptions.downsampling ?? DEFAULT_OPTIONS.downsampling;
|
|
5616
|
+
const downsampleThresholdPx = Math.max(0.2, downsamplingOptions?.thresholdPx ?? 1.5);
|
|
5617
|
+
const shouldDownsample = (downsamplingOptions?.enabled ?? true) && candleSpacing < downsampleThresholdPx && endIndex - startIndex > 2;
|
|
5618
|
+
const seriesColumns = [];
|
|
5619
|
+
if (shouldDownsample) {
|
|
5620
|
+
let current = null;
|
|
5621
|
+
let currentColumn = Number.NaN;
|
|
5622
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
5623
|
+
const point = seriesData[index];
|
|
5624
|
+
if (!point) continue;
|
|
5625
|
+
const column = Math.round(chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth);
|
|
5626
|
+
if (!current || column !== currentColumn) {
|
|
5627
|
+
currentColumn = column;
|
|
5628
|
+
current = {
|
|
5629
|
+
x: column,
|
|
5630
|
+
open: point.o,
|
|
5631
|
+
high: point.h,
|
|
5632
|
+
low: point.l,
|
|
5633
|
+
close: point.c,
|
|
5634
|
+
minClose: point.c,
|
|
5635
|
+
maxClose: point.c,
|
|
5636
|
+
minCloseIndex: index,
|
|
5637
|
+
maxCloseIndex: index
|
|
5638
|
+
};
|
|
5639
|
+
seriesColumns.push(current);
|
|
5640
|
+
continue;
|
|
5641
|
+
}
|
|
5642
|
+
if (point.h > current.high) current.high = point.h;
|
|
5643
|
+
if (point.l < current.low) current.low = point.l;
|
|
5644
|
+
current.close = point.c;
|
|
5645
|
+
if (point.c < current.minClose) {
|
|
5646
|
+
current.minClose = point.c;
|
|
5647
|
+
current.minCloseIndex = index;
|
|
5648
|
+
}
|
|
5649
|
+
if (point.c > current.maxClose) {
|
|
5650
|
+
current.maxClose = point.c;
|
|
5651
|
+
current.maxCloseIndex = index;
|
|
5652
|
+
}
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5655
|
+
if (shouldDownsample && !isLineStyleChart) {
|
|
5656
|
+
const upColumns = new Path2D();
|
|
5657
|
+
const downColumns = new Path2D();
|
|
5658
|
+
for (const column of seriesColumns) {
|
|
5659
|
+
const path = column.close >= column.open ? upColumns : downColumns;
|
|
5660
|
+
const x = column.x + 0.5;
|
|
5661
|
+
const top = crisp(yFromPrice(column.high));
|
|
5662
|
+
const bottom = crisp(yFromPrice(column.low));
|
|
5663
|
+
path.moveTo(x, top);
|
|
5664
|
+
path.lineTo(x, bottom === top ? bottom + 1 : bottom);
|
|
5665
|
+
}
|
|
5666
|
+
ctx.lineWidth = 1;
|
|
5667
|
+
ctx.strokeStyle = mergedOptions.upColor;
|
|
5668
|
+
ctx.stroke(upColumns);
|
|
5669
|
+
ctx.strokeStyle = mergedOptions.downColor;
|
|
5670
|
+
ctx.stroke(downColumns);
|
|
5671
|
+
} else if (shouldDownsample && isLineStyleChart) {
|
|
5672
|
+
const seriesLineWidth = Math.max(1, mergedOptions.lineWidth);
|
|
5673
|
+
const linePath = new Path2D();
|
|
5674
|
+
let started = false;
|
|
5675
|
+
let firstX = 0;
|
|
5676
|
+
let lastX = 0;
|
|
5677
|
+
for (const column of seriesColumns) {
|
|
5678
|
+
const lowFirst = column.minCloseIndex <= column.maxCloseIndex;
|
|
5679
|
+
const first = lowFirst ? column.minClose : column.maxClose;
|
|
5680
|
+
const second = lowFirst ? column.maxClose : column.minClose;
|
|
5681
|
+
const x = column.x;
|
|
5682
|
+
if (!started) {
|
|
5683
|
+
linePath.moveTo(x, yFromPrice(first));
|
|
5684
|
+
firstX = x;
|
|
5685
|
+
started = true;
|
|
5686
|
+
} else {
|
|
5687
|
+
linePath.lineTo(x, yFromPrice(first));
|
|
5688
|
+
}
|
|
5689
|
+
if (second !== first) {
|
|
5690
|
+
linePath.lineTo(x, yFromPrice(second));
|
|
5691
|
+
}
|
|
5692
|
+
lastX = x;
|
|
5693
|
+
}
|
|
5694
|
+
if (started) {
|
|
5695
|
+
if (chartType === "area" || chartType === "baseline") {
|
|
5696
|
+
const baseY = chartType === "baseline" ? yFromPrice(mergedOptions.baselinePrice ?? (yMin + yMax) / 2) : chartBottom;
|
|
5697
|
+
const fillPath = new Path2D(linePath);
|
|
5698
|
+
fillPath.lineTo(lastX, baseY);
|
|
5699
|
+
fillPath.lineTo(firstX, baseY);
|
|
5700
|
+
fillPath.closePath();
|
|
5701
|
+
ctx.save();
|
|
5702
|
+
ctx.globalAlpha = Math.max(0, Math.min(1, mergedOptions.areaFillOpacity));
|
|
5703
|
+
ctx.fillStyle = chartType === "baseline" ? mergedOptions.upColor : mergedOptions.lineColor;
|
|
5704
|
+
ctx.fill(fillPath);
|
|
5705
|
+
ctx.restore();
|
|
5706
|
+
}
|
|
5707
|
+
ctx.save();
|
|
5708
|
+
ctx.strokeStyle = chartType === "baseline" ? mergedOptions.upColor : mergedOptions.lineColor;
|
|
5709
|
+
ctx.lineWidth = seriesLineWidth;
|
|
5710
|
+
ctx.lineJoin = "round";
|
|
5711
|
+
ctx.stroke(linePath);
|
|
5712
|
+
ctx.restore();
|
|
5713
|
+
}
|
|
5714
|
+
} else if (isLineStyleChart) {
|
|
5511
5715
|
const seriesLineWidth = Math.max(1, mergedOptions.lineWidth);
|
|
5512
5716
|
const linePath = new Path2D();
|
|
5513
5717
|
let started = false;
|
|
@@ -5673,6 +5877,105 @@ function createChart(element, options = {}) {
|
|
|
5673
5877
|
ctx.stroke(downHollowBodies);
|
|
5674
5878
|
}
|
|
5675
5879
|
}
|
|
5880
|
+
const compareAxisTags = [];
|
|
5881
|
+
for (const plan of comparePlans) {
|
|
5882
|
+
const color = plan.state.options.color ?? "#f59e0b";
|
|
5883
|
+
const lineWidth = Math.max(1, plan.state.options.lineWidth ?? 1.5);
|
|
5884
|
+
const path = new Path2D();
|
|
5885
|
+
let started = false;
|
|
5886
|
+
let firstX = 0;
|
|
5887
|
+
let lastX = 0;
|
|
5888
|
+
let lastY = 0;
|
|
5889
|
+
let lastPrice = Number.NaN;
|
|
5890
|
+
const emit = (x, y) => {
|
|
5891
|
+
if (!started) {
|
|
5892
|
+
path.moveTo(x, y);
|
|
5893
|
+
firstX = x;
|
|
5894
|
+
started = true;
|
|
5895
|
+
} else {
|
|
5896
|
+
path.lineTo(x, y);
|
|
5897
|
+
}
|
|
5898
|
+
lastX = x;
|
|
5899
|
+
lastY = y;
|
|
5900
|
+
};
|
|
5901
|
+
const xForIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
|
|
5902
|
+
if (shouldDownsample) {
|
|
5903
|
+
let column = Number.NaN;
|
|
5904
|
+
let minValue = 0;
|
|
5905
|
+
let maxValue = 0;
|
|
5906
|
+
let minIndex = 0;
|
|
5907
|
+
let maxIndex = 0;
|
|
5908
|
+
let hasColumn = false;
|
|
5909
|
+
const flushColumn = () => {
|
|
5910
|
+
if (!hasColumn) return;
|
|
5911
|
+
const lowFirst = minIndex <= maxIndex;
|
|
5912
|
+
const first = plan.toPrice(lowFirst ? minValue : maxValue);
|
|
5913
|
+
const second = plan.toPrice(lowFirst ? maxValue : minValue);
|
|
5914
|
+
if (Number.isFinite(first)) emit(column, yFromPrice(first));
|
|
5915
|
+
if (Number.isFinite(second) && second !== first) emit(column, yFromPrice(second));
|
|
5916
|
+
lastPrice = plan.toPrice(maxIndex >= minIndex ? maxValue : minValue);
|
|
5917
|
+
};
|
|
5918
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
5919
|
+
const value = plan.aligned[index];
|
|
5920
|
+
if (value === void 0 || !Number.isFinite(value)) continue;
|
|
5921
|
+
const nextColumn = Math.round(xForIndex(index));
|
|
5922
|
+
if (!hasColumn || nextColumn !== column) {
|
|
5923
|
+
flushColumn();
|
|
5924
|
+
column = nextColumn;
|
|
5925
|
+
minValue = value;
|
|
5926
|
+
maxValue = value;
|
|
5927
|
+
minIndex = index;
|
|
5928
|
+
maxIndex = index;
|
|
5929
|
+
hasColumn = true;
|
|
5930
|
+
continue;
|
|
5931
|
+
}
|
|
5932
|
+
if (value < minValue) {
|
|
5933
|
+
minValue = value;
|
|
5934
|
+
minIndex = index;
|
|
5935
|
+
}
|
|
5936
|
+
if (value > maxValue) {
|
|
5937
|
+
maxValue = value;
|
|
5938
|
+
maxIndex = index;
|
|
5939
|
+
}
|
|
5940
|
+
}
|
|
5941
|
+
flushColumn();
|
|
5942
|
+
} else {
|
|
5943
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
5944
|
+
const value = plan.aligned[index];
|
|
5945
|
+
if (value === void 0 || !Number.isFinite(value)) continue;
|
|
5946
|
+
const price = plan.toPrice(value);
|
|
5947
|
+
if (!Number.isFinite(price)) continue;
|
|
5948
|
+
lastPrice = price;
|
|
5949
|
+
emit(xForIndex(index), yFromPrice(price));
|
|
5950
|
+
}
|
|
5951
|
+
}
|
|
5952
|
+
if (!started) continue;
|
|
5953
|
+
if (plan.state.options.style === "area") {
|
|
5954
|
+
const fillPath = new Path2D(path);
|
|
5955
|
+
fillPath.lineTo(lastX, chartBottom);
|
|
5956
|
+
fillPath.lineTo(firstX, chartBottom);
|
|
5957
|
+
fillPath.closePath();
|
|
5958
|
+
ctx.save();
|
|
5959
|
+
ctx.globalAlpha = Math.max(0, Math.min(1, mergedOptions.areaFillOpacity)) * 0.6;
|
|
5960
|
+
ctx.fillStyle = color;
|
|
5961
|
+
ctx.fill(fillPath);
|
|
5962
|
+
ctx.restore();
|
|
5963
|
+
}
|
|
5964
|
+
ctx.save();
|
|
5965
|
+
ctx.strokeStyle = color;
|
|
5966
|
+
ctx.lineWidth = lineWidth;
|
|
5967
|
+
ctx.lineJoin = "round";
|
|
5968
|
+
ctx.stroke(path);
|
|
5969
|
+
ctx.restore();
|
|
5970
|
+
if (Number.isFinite(lastPrice)) {
|
|
5971
|
+
const label = plan.state.options.label ?? plan.state.options.id;
|
|
5972
|
+
const nativeLast = plan.lastNativeValue;
|
|
5973
|
+
const usePercentTag = plan.usePercent && plan.baseCompare > 0 && Number.isFinite(nativeLast);
|
|
5974
|
+
const changePct = usePercentTag ? (nativeLast / plan.baseCompare - 1) * 100 : 0;
|
|
5975
|
+
const shortText = usePercentTag ? `${changePct >= 0 ? "+" : ""}${changePct.toFixed(2)}%` : formatPrice(nativeLast);
|
|
5976
|
+
compareAxisTags.push({ y: lastY, text: `${label} ${shortText}`, shortText, color });
|
|
5977
|
+
}
|
|
5978
|
+
}
|
|
5676
5979
|
const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
|
|
5677
5980
|
(value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
|
|
5678
5981
|
).sort((a, b) => a.indicator.zIndex - b.indicator.zIndex);
|
|
@@ -6447,6 +6750,35 @@ function createChart(element, options = {}) {
|
|
|
6447
6750
|
drawText(countdownText, chartRight + 6, (fullChartBottom + height) / 2, "left", "middle", xAxis.textColor);
|
|
6448
6751
|
ctx.font = prevFont;
|
|
6449
6752
|
}
|
|
6753
|
+
if (compareAxisTags.length > 0 && width - chartRight > 0) {
|
|
6754
|
+
const prevFont = ctx.font;
|
|
6755
|
+
const tagFontSize = Math.max(9, (resolvedAxis.fontSize ?? 12) - 2);
|
|
6756
|
+
ctx.font = `${tagFontSize}px ${mergedOptions.fontFamily}`;
|
|
6757
|
+
ctx.textBaseline = "middle";
|
|
6758
|
+
ctx.textAlign = "left";
|
|
6759
|
+
const placed = [];
|
|
6760
|
+
const tagHeight = tagFontSize + 6;
|
|
6761
|
+
const gutterWidth = width - chartRight - 4;
|
|
6762
|
+
for (const tag of [...compareAxisTags].sort((a, b) => a.y - b.y)) {
|
|
6763
|
+
const text = measureTextWidth(tag.text) + 10 <= gutterWidth ? tag.text : tag.shortText;
|
|
6764
|
+
const tagWidth = Math.min(gutterWidth, measureTextWidth(text) + 10);
|
|
6765
|
+
if (tagWidth <= 0) continue;
|
|
6766
|
+
let tagY = clamp(tag.y - tagHeight / 2, chartTop, chartBottom - tagHeight);
|
|
6767
|
+
for (const slot of placed) {
|
|
6768
|
+
if (tagY < slot.bottom + 2 && tagY + tagHeight > slot.top - 2) {
|
|
6769
|
+
tagY = slot.bottom + 2;
|
|
6770
|
+
}
|
|
6771
|
+
}
|
|
6772
|
+
placed.push({ top: tagY, bottom: tagY + tagHeight });
|
|
6773
|
+
ctx.save();
|
|
6774
|
+
ctx.fillStyle = tag.color;
|
|
6775
|
+
fillRoundedRect(chartRight + 2, Math.round(tagY), Math.round(tagWidth), tagHeight, 3);
|
|
6776
|
+
ctx.fillStyle = labelTextColorOn(tag.color, "#ffffff");
|
|
6777
|
+
ctx.fillText(text, chartRight + 7, Math.round(tagY + tagHeight / 2));
|
|
6778
|
+
ctx.restore();
|
|
6779
|
+
}
|
|
6780
|
+
ctx.font = prevFont;
|
|
6781
|
+
}
|
|
6450
6782
|
if (baseCtx) {
|
|
6451
6783
|
if (baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height) {
|
|
6452
6784
|
baseCanvas.width = canvas.width;
|
|
@@ -9548,6 +9880,10 @@ function createChart(element, options = {}) {
|
|
|
9548
9880
|
getMagnetMode,
|
|
9549
9881
|
setPriceScale,
|
|
9550
9882
|
getPriceScale,
|
|
9883
|
+
setCompareSeries,
|
|
9884
|
+
addCompareSeries,
|
|
9885
|
+
removeCompareSeries,
|
|
9886
|
+
getCompareSeries,
|
|
9551
9887
|
cancelDrawing,
|
|
9552
9888
|
setTradeMarkers,
|
|
9553
9889
|
setSelectedDrawing,
|
package/docs/API.md
CHANGED
|
@@ -568,6 +568,7 @@ an up measurement, red for down.
|
|
|
568
568
|
- `cancelDrawing(): boolean` — abort an in-progress multi-click drawing (between the first click and the final point, e.g. a half-drawn trendline/ray/fib). Returns `true` if a draft was cancelled. Wire it to Escape.
|
|
569
569
|
- Hold **Shift** while drawing or dragging an endpoint of a `trendline`/`ray`/`arrow` to constrain it to the nearest 0/45/90° angle (e.g. a perfectly horizontal line).
|
|
570
570
|
- `setMagnetMode(mode): void` / `getMagnetMode()` — magnet/snap for all drawing tools. `"none"` off, `"weak"` snaps to a candle's OHLC only when the cursor is near a value, `"strong"` always snaps to the nearest OHLC of the bar under the cursor. Holding Cmd/Ctrl while drawing/dragging forces strong snapping regardless of the mode.
|
|
571
|
+
- `setCompareSeries(series: CompareSeriesOptions[]): void` / `addCompareSeries(series)` / `removeCompareSeries(id)` / `getCompareSeries()` — overlay other instruments on the main pane; see "Compare overlays" below
|
|
571
572
|
- `setPriceScale(options: { mode?: "linear" | "log" | "percent"; inverted?: boolean }): void` / `getPriceScale()` — price-scale modes for the main pane (TradingView-style). `"log"` maps prices through log10 (wide ranges get 1/2/5-per-decade axis marks). `"percent"` keeps the linear mapping but relabels the axis (ticks, last-price/PDC/H/L/bid/ask tags and the crosshair) as % change from the first visible bar's close, updating as you pan. `inverted: true` flips the axis top-to-bottom. All viewport state stays in plain price space, so switching modes never moves your zoom, and drawings/orders/indicators follow automatically. Indicator panes keep their own scales. Included in `saveState()` as `priceScale`.
|
|
572
573
|
- `setActiveDrawingTool(tool: DrawingToolType | null): void` (`DrawingToolType` = `"horizontal-line" | "vertical-line" | "trendline" | "ray" | "fib-retracement" | "fib-extension" | "long-position" | "short-position" | "price-range" | "rectangle" | "text" | "note" | "parallel-channel" | "ellipse" | "arrow" | "brush" | "callout" | "measure"`)
|
|
573
574
|
- `getActiveDrawingTool(): DrawingToolType | null`
|
|
@@ -610,6 +611,56 @@ link.click();
|
|
|
610
611
|
|
|
611
612
|
---
|
|
612
613
|
|
|
614
|
+
## Compare overlays (multi-series)
|
|
615
|
+
|
|
616
|
+
Overlay other instruments on the main pane. The host owns the data (it already
|
|
617
|
+
has a datafeed); the chart aligns each series to the main bars by timestamp and
|
|
618
|
+
draws it.
|
|
619
|
+
|
|
620
|
+
```ts
|
|
621
|
+
chart.setCompareSeries([
|
|
622
|
+
{ id: "NQ", label: "NQ1!", data: nqBars, color: "#22d3ee" },
|
|
623
|
+
{ id: "BTC", label: "BTCUSD", data: btcBars, color: "#a855f7", style: "area" },
|
|
624
|
+
]);
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
- **Alignment** is by timestamp, computed once per data change (a merge walk,
|
|
628
|
+
not a search per bar), so different session hours and holidays are fine — the
|
|
629
|
+
last known value carries forward across gaps, and bars before a compared
|
|
630
|
+
instrument starts are simply not drawn.
|
|
631
|
+
- **`scale`** defaults to `"percent"`: both instruments are normalised to their
|
|
632
|
+
first visible bar, so a $180 index and a $60,000 coin can be compared by
|
|
633
|
+
shape. It re-anchors as you pan, exactly like TradingView. Use `"price"` to
|
|
634
|
+
plot raw values on the main scale (only sensible for related instruments).
|
|
635
|
+
- **Autoscale** includes compare series unless `includeInAutoScale: false`.
|
|
636
|
+
- Each series gets a colored tag in the price gutter showing its own % move
|
|
637
|
+
(or value in price mode); tags stack instead of overlapping, and drop the
|
|
638
|
+
label when the gutter is too narrow.
|
|
639
|
+
- Compare data is **not** in `saveState()` — it can be megabytes and the host
|
|
640
|
+
re-fetches it anyway. Persist the symbol list yourself and re-supply data on
|
|
641
|
+
load.
|
|
642
|
+
|
|
643
|
+
## Deep zoom-out (viewport downsampling)
|
|
644
|
+
|
|
645
|
+
Once bars are narrower than `downsampling.thresholdPx` (default 1.5px), several
|
|
646
|
+
bars share a pixel column, and drawing each one separately is wasted work. The
|
|
647
|
+
chart aggregates each column into one min/max bucket, so path operations scale
|
|
648
|
+
with the chart's width rather than the number of bars. Extremes are preserved
|
|
649
|
+
exactly — a one-bar spike still reaches full height — because the bucket keeps
|
|
650
|
+
the true high and low rather than sampling. Candle and bar styles converge to a
|
|
651
|
+
single colored high-low column (the body is sub-pixel at that density anyway);
|
|
652
|
+
line/area/baseline keep a min/max envelope so the shape of the move survives.
|
|
653
|
+
|
|
654
|
+
Measured on the playground benchmark (50,000 bars, 120Hz display):
|
|
655
|
+
|
|
656
|
+
| Scenario | Per-bar (before) | Downsampled |
|
|
657
|
+
| --- | --- | --- |
|
|
658
|
+
| zoom 100 ↔ 20k bars | 21.3ms avg, 49.8ms p95, 47fps, 53 slow frames | 8.3ms avg, 10.3ms p95, 120fps, 0 slow frames |
|
|
659
|
+
| deep zoom-out, 50k bars | 52.7ms avg, 58.8ms p95, 19fps, 240 slow frames | 8.3ms avg, 10.2ms p95, 120fps, 0 slow frames |
|
|
660
|
+
|
|
661
|
+
Turn it off with `downsampling: { enabled: false }`, or tune when it kicks in
|
|
662
|
+
with `thresholdPx`.
|
|
663
|
+
|
|
613
664
|
## Context menus, selection toolbars and keyboard
|
|
614
665
|
|
|
615
666
|
Three pieces of chrome that every host previously rebuilt by hand. The chart
|