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/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
- if (isLineStyleChart) {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperprop-charting-library",
3
- "version": "0.1.138",
3
+ "version": "0.1.139",
4
4
  "description": "Lightweight TypeScript charting core",
5
5
  "type": "module",
6
6
  "main": "./dist/hyperprop-charting-library.cjs",