@363045841yyt/klinechart-core 0.7.3 → 0.7.5-alpha.2
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/README.md +201 -201
- package/README.zh-CN.md +201 -201
- package/dist/engine/renderers/webgl/candleSurface.js +47 -47
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -2
- package/dist/version.js.map +1 -1
- package/package.json +129 -122
- package/src/__tests__/signal.test.ts +124 -124
- package/src/config/chartSettings.ts +66 -66
- package/src/controllers/__tests__/drawing.test.ts +214 -214
- package/src/controllers/__tests__/indicatorSelector.test.ts +481 -481
- package/src/controllers/__tests__/toolbar.test.ts +225 -225
- package/src/controllers/createChartController.ts +665 -665
- package/src/controllers/createDrawingController.ts +96 -96
- package/src/controllers/createIndicatorSelectorController.ts +307 -307
- package/src/controllers/createToolbarController.ts +146 -146
- package/src/controllers/index.ts +19 -19
- package/src/controllers/types.ts +284 -284
- package/src/engine/__tests__/chart.dpr.test.ts +401 -401
- package/src/engine/__tests__/paneRenderer.resize.test.ts +92 -92
- package/src/engine/chart-store.ts +121 -121
- package/src/engine/chart.d.ts +617 -617
- package/src/engine/chart.ts +2815 -2815
- package/src/engine/controller/__tests__/interaction.dpr.test.ts +259 -259
- package/src/engine/controller/interaction.ts +722 -722
- package/src/engine/controller/markerInteraction.ts +130 -130
- package/src/engine/controller/pinchTracker.ts +82 -82
- package/src/engine/controller/tooltipPosition.ts +48 -48
- package/src/engine/draw/__tests__/pixelAlign.spec.ts +176 -176
- package/src/engine/draw/pixelAlign.ts +259 -259
- package/src/engine/drawing/index.ts +655 -655
- package/src/engine/drawing/interaction.ts +842 -842
- package/src/engine/drawing/plugin.ts +343 -343
- package/src/engine/indicators/__tests__/__fixtures__/golden/atr.json +38 -38
- package/src/engine/indicators/__tests__/__fixtures__/golden/dema.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/hma.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/index.ts +55 -55
- package/src/engine/indicators/__tests__/__fixtures__/golden/kama.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/tema.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/wma.json +40 -40
- package/src/engine/indicators/__tests__/__fixtures__/synthetic.ts +65 -65
- package/src/engine/indicators/__tests__/_propertyAssertions.ts +76 -76
- package/src/engine/indicators/__tests__/atr.test.ts +153 -153
- package/src/engine/indicators/__tests__/calculators.test.ts +614 -614
- package/src/engine/indicators/__tests__/cmf-mfi.test.ts +100 -100
- package/src/engine/indicators/__tests__/dema.test.ts +73 -73
- package/src/engine/indicators/__tests__/donchian.test.ts +70 -70
- package/src/engine/indicators/__tests__/hma.test.ts +73 -73
- package/src/engine/indicators/__tests__/ichimoku.test.ts +105 -105
- package/src/engine/indicators/__tests__/kama.test.ts +80 -80
- package/src/engine/indicators/__tests__/keltner.test.ts +65 -65
- package/src/engine/indicators/__tests__/pivot-fib.test.ts +110 -110
- package/src/engine/indicators/__tests__/roc.test.ts +68 -68
- package/src/engine/indicators/__tests__/sar.test.ts +86 -86
- package/src/engine/indicators/__tests__/scheduler.test.ts +831 -831
- package/src/engine/indicators/__tests__/soa.test.ts +533 -533
- package/src/engine/indicators/__tests__/structure.test.ts +110 -110
- package/src/engine/indicators/__tests__/supertrend.test.ts +65 -65
- package/src/engine/indicators/__tests__/tema.test.ts +68 -68
- package/src/engine/indicators/__tests__/trix.test.ts +70 -70
- package/src/engine/indicators/__tests__/volatility.test.ts +117 -117
- package/src/engine/indicators/__tests__/volume.test.ts +115 -115
- package/src/engine/indicators/__tests__/volumeProfile.test.ts +74 -74
- package/src/engine/indicators/__tests__/vwap.test.ts +69 -69
- package/src/engine/indicators/__tests__/wma.test.ts +112 -112
- package/src/engine/indicators/__tests__/zones.test.ts +95 -95
- package/src/engine/indicators/atrState.ts +27 -27
- package/src/engine/indicators/bollState.ts +51 -51
- package/src/engine/indicators/calculators.ts +2593 -2593
- package/src/engine/indicators/cciState.ts +25 -25
- package/src/engine/indicators/chaikinVolState.ts +32 -32
- package/src/engine/indicators/cmfState.ts +27 -27
- package/src/engine/indicators/demaState.ts +27 -27
- package/src/engine/indicators/donchianState.ts +43 -43
- package/src/engine/indicators/eneState.ts +43 -43
- package/src/engine/indicators/expmaState.ts +43 -43
- package/src/engine/indicators/fastkState.ts +25 -25
- package/src/engine/indicators/fibState.ts +41 -41
- package/src/engine/indicators/hmaState.ts +27 -27
- package/src/engine/indicators/hvState.ts +28 -28
- package/src/engine/indicators/ichimokuState.ts +70 -70
- package/src/engine/indicators/indicator.worker.ts +169 -169
- package/src/engine/indicators/indicatorDefinitionRegistry.ts +62 -62
- package/src/engine/indicators/indicatorMetadata.ts +110 -110
- package/src/engine/indicators/indicatorRegistry.ts +106 -106
- package/src/engine/indicators/indicatorRuntime.ts +1548 -1548
- package/src/engine/indicators/kamaState.ts +34 -34
- package/src/engine/indicators/keltnerState.ts +49 -49
- package/src/engine/indicators/kstState.ts +42 -42
- package/src/engine/indicators/maState.ts +36 -36
- package/src/engine/indicators/macdState.ts +76 -76
- package/src/engine/indicators/mfiState.ts +27 -27
- package/src/engine/indicators/momState.ts +25 -25
- package/src/engine/indicators/obvState.ts +25 -25
- package/src/engine/indicators/parkinsonState.ts +28 -28
- package/src/engine/indicators/pivotState.ts +51 -51
- package/src/engine/indicators/pvtState.ts +25 -25
- package/src/engine/indicators/rocState.ts +27 -27
- package/src/engine/indicators/rsiState.ts +65 -65
- package/src/engine/indicators/sarState.ts +41 -41
- package/src/engine/indicators/scheduler.ts +1205 -1205
- package/src/engine/indicators/soa.ts +352 -352
- package/src/engine/indicators/stateComposer.ts +1262 -1262
- package/src/engine/indicators/stochState.ts +26 -26
- package/src/engine/indicators/structureState.ts +69 -69
- package/src/engine/indicators/supertrendState.ts +37 -37
- package/src/engine/indicators/temaState.ts +27 -27
- package/src/engine/indicators/trixState.ts +35 -35
- package/src/engine/indicators/vmaState.ts +27 -27
- package/src/engine/indicators/volumeProfileState.ts +63 -63
- package/src/engine/indicators/vwapState.ts +29 -29
- package/src/engine/indicators/wmaState.ts +27 -27
- package/src/engine/indicators/wmsrState.ts +25 -25
- package/src/engine/indicators/workerProtocol.ts +613 -613
- package/src/engine/indicators/zonesState.ts +47 -47
- package/src/engine/layout/pane.ts +161 -161
- package/src/engine/marker/registry.ts +265 -265
- package/src/engine/paneRenderer.ts +169 -169
- package/src/engine/renderers/Indicator/atr.ts +237 -237
- package/src/engine/renderers/Indicator/boll.ts +317 -317
- package/src/engine/renderers/Indicator/cci.ts +275 -275
- package/src/engine/renderers/Indicator/chaikinVol.ts +138 -138
- package/src/engine/renderers/Indicator/cmf.ts +137 -137
- package/src/engine/renderers/Indicator/dema.ts +136 -136
- package/src/engine/renderers/Indicator/donchian.ts +137 -137
- package/src/engine/renderers/Indicator/ene.ts +271 -271
- package/src/engine/renderers/Indicator/expma.ts +197 -197
- package/src/engine/renderers/Indicator/fastk.ts +316 -316
- package/src/engine/renderers/Indicator/fib.ts +141 -141
- package/src/engine/renderers/Indicator/hma.ts +136 -136
- package/src/engine/renderers/Indicator/hv.ts +124 -124
- package/src/engine/renderers/Indicator/ichimoku.ts +181 -181
- package/src/engine/renderers/Indicator/index.ts +241 -241
- package/src/engine/renderers/Indicator/indicatorData.ts +650 -650
- package/src/engine/renderers/Indicator/kama.ts +136 -136
- package/src/engine/renderers/Indicator/keltner.ts +137 -137
- package/src/engine/renderers/Indicator/kst.ts +302 -302
- package/src/engine/renderers/Indicator/ma.ts +200 -200
- package/src/engine/renderers/Indicator/macd.ts +477 -477
- package/src/engine/renderers/Indicator/macdLegend.ts +141 -141
- package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +272 -272
- package/src/engine/renderers/Indicator/mfi.ts +142 -142
- package/src/engine/renderers/Indicator/mom.ts +311 -311
- package/src/engine/renderers/Indicator/obv.ts +123 -123
- package/src/engine/renderers/Indicator/parkinson.ts +124 -124
- package/src/engine/renderers/Indicator/pivot.ts +131 -131
- package/src/engine/renderers/Indicator/pvt.ts +123 -123
- package/src/engine/renderers/Indicator/roc.ts +143 -143
- package/src/engine/renderers/Indicator/rsi.ts +390 -390
- package/src/engine/renderers/Indicator/sar.ts +113 -113
- package/src/engine/renderers/Indicator/scale/atr_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/cci_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/fastk_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/indicator_scale.ts +204 -204
- package/src/engine/renderers/Indicator/scale/kst_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/macd_scale.ts +22 -22
- package/src/engine/renderers/Indicator/scale/mom_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/rsi_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/stoch_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/volume_scale.ts +26 -26
- package/src/engine/renderers/Indicator/scale/wmsr_scale.ts +19 -19
- package/src/engine/renderers/Indicator/stoch.ts +359 -359
- package/src/engine/renderers/Indicator/structure.ts +126 -126
- package/src/engine/renderers/Indicator/subPaneConfig.ts +265 -265
- package/src/engine/renderers/Indicator/supertrend.ts +115 -115
- package/src/engine/renderers/Indicator/tema.ts +136 -136
- package/src/engine/renderers/Indicator/trix.ts +158 -158
- package/src/engine/renderers/Indicator/vma.ts +124 -124
- package/src/engine/renderers/Indicator/volumeProfile.ts +125 -125
- package/src/engine/renderers/Indicator/vwap.ts +123 -123
- package/src/engine/renderers/Indicator/wma.ts +136 -136
- package/src/engine/renderers/Indicator/wmsr.ts +328 -328
- package/src/engine/renderers/Indicator/zones.ts +104 -104
- package/src/engine/renderers/__tests__/boll.renderer.test.ts +314 -314
- package/src/engine/renderers/__tests__/ene.renderer.test.ts +305 -305
- package/src/engine/renderers/__tests__/expma.renderer.test.ts +279 -279
- package/src/engine/renderers/__tests__/ma.renderer.test.ts +426 -426
- package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +502 -502
- package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +173 -173
- package/src/engine/renderers/candle.ts +459 -459
- package/src/engine/renderers/crosshair.ts +69 -69
- package/src/engine/renderers/customMarkers.ts +162 -162
- package/src/engine/renderers/extremaMarkers.ts +246 -246
- package/src/engine/renderers/gridLines.ts +90 -90
- package/src/engine/renderers/lastPrice.ts +97 -97
- package/src/engine/renderers/paneTitle.ts +136 -136
- package/src/engine/renderers/subVolume.ts +236 -236
- package/src/engine/renderers/timeAxis.ts +121 -121
- package/src/engine/renderers/webgl/candleSurface.ts +955 -955
- package/src/engine/renderers/webgl/sharedWebGLSurface.ts +146 -146
- package/src/engine/renderers/yAxis.ts +105 -105
- package/src/engine/scale/__tests__/logFormula.spec.ts +148 -148
- package/src/engine/scale/logFormula.ts +130 -130
- package/src/engine/scale/price.ts +39 -39
- package/src/engine/scale/priceScale.ts +264 -264
- package/src/engine/subPaneManager.ts +427 -427
- package/src/engine/theme/colors.ts +642 -642
- package/src/engine/theme/fonts.ts +20 -20
- package/src/engine/utils/klineConfig.ts +49 -49
- package/src/engine/utils/tickCount.ts +11 -11
- package/src/engine/utils/tickPosition.ts +214 -214
- package/src/engine/utils/zoom.ts +83 -83
- package/src/engine/viewport/viewport.ts +67 -67
- package/src/index.ts +3 -3
- package/src/plugin/ConfigManager.ts +93 -93
- package/src/plugin/EventBus.ts +77 -77
- package/src/plugin/HookSystem.ts +106 -106
- package/src/plugin/PluginHost.ts +243 -243
- package/src/plugin/PluginRegistry.ts +92 -92
- package/src/plugin/StateStore.ts +73 -73
- package/src/plugin/index.ts +19 -19
- package/src/plugin/rendererPluginManager.ts +368 -368
- package/src/plugin/stateKeys.ts +8 -8
- package/src/plugin/types.ts +526 -526
- package/src/reactivity/index.ts +2 -2
- package/src/reactivity/signal.ts +119 -119
- package/src/semantic/controller.ts +251 -251
- package/src/semantic/drawShape.ts +260 -260
- package/src/semantic/index.ts +28 -28
- package/src/semantic/schema.json +256 -256
- package/src/semantic/types.ts +251 -251
- package/src/semantic/validator.ts +349 -349
- package/src/types/kLine.ts +13 -13
- package/src/types/price.ts +56 -56
- package/src/types/volumePrice.ts +33 -33
- package/src/utils/dateFormat.ts +208 -208
- package/src/utils/kLineDraw/axis.ts +562 -562
- package/src/utils/priceToY.ts +34 -34
- package/src/utils/volumePrice.ts +202 -202
- package/src/version.ts +1 -1
|
@@ -1,401 +1,401 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { Chart, type ChartDom, type ChartOptions } from '@/core/chart'
|
|
3
|
-
|
|
4
|
-
class ResizeObserverMock {
|
|
5
|
-
static instances: ResizeObserverMock[] = []
|
|
6
|
-
static failWithDevicePixelBox = false
|
|
7
|
-
|
|
8
|
-
private callback: ResizeObserverCallback
|
|
9
|
-
observe = vi.fn((target: Element, options?: ResizeObserverOptions) => {
|
|
10
|
-
if (options?.box === 'device-pixel-content-box' && ResizeObserverMock.failWithDevicePixelBox) {
|
|
11
|
-
throw new Error('device-pixel-content-box not supported')
|
|
12
|
-
}
|
|
13
|
-
})
|
|
14
|
-
disconnect = vi.fn()
|
|
15
|
-
|
|
16
|
-
constructor(callback: ResizeObserverCallback) {
|
|
17
|
-
this.callback = callback
|
|
18
|
-
ResizeObserverMock.instances.push(this)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
emit(entry: Partial<ResizeObserverEntry>) {
|
|
22
|
-
this.callback([entry as ResizeObserverEntry], this as unknown as ResizeObserver)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
static reset() {
|
|
26
|
-
ResizeObserverMock.instances = []
|
|
27
|
-
ResizeObserverMock.failWithDevicePixelBox = false
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const defaultOptions: ChartOptions = {
|
|
32
|
-
kWidth: 10,
|
|
33
|
-
kGap: 2,
|
|
34
|
-
yPaddingPx: 0,
|
|
35
|
-
rightAxisWidth: 0,
|
|
36
|
-
bottomAxisHeight: 24,
|
|
37
|
-
minKWidth: 2,
|
|
38
|
-
maxKWidth: 50,
|
|
39
|
-
panes: [{ id: 'main', ratio: 1 }],
|
|
40
|
-
priceLabelWidth: 60,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function createCanvasContextStub() {
|
|
44
|
-
return {
|
|
45
|
-
setTransform: vi.fn(),
|
|
46
|
-
scale: vi.fn(),
|
|
47
|
-
clearRect: vi.fn(),
|
|
48
|
-
save: vi.fn(),
|
|
49
|
-
restore: vi.fn(),
|
|
50
|
-
beginPath: vi.fn(),
|
|
51
|
-
moveTo: vi.fn(),
|
|
52
|
-
lineTo: vi.fn(),
|
|
53
|
-
stroke: vi.fn(),
|
|
54
|
-
fillRect: vi.fn(),
|
|
55
|
-
strokeRect: vi.fn(),
|
|
56
|
-
fillText: vi.fn(),
|
|
57
|
-
measureText: vi.fn(() => ({ width: 40 })),
|
|
58
|
-
} as unknown as CanvasRenderingContext2D
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function createWebGLStub(): WebGL2RenderingContext {
|
|
62
|
-
const noop = () => {}
|
|
63
|
-
return new Proxy({} as unknown as WebGL2RenderingContext, {
|
|
64
|
-
get(_, prop) {
|
|
65
|
-
if (typeof prop !== 'string') return undefined
|
|
66
|
-
if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 0
|
|
67
|
-
if (prop === 'getShaderInfoLog' || prop === 'getProgramInfoLog') return () => ''
|
|
68
|
-
if (prop === 'getShaderParameter' || prop === 'getProgramParameter') return () => true
|
|
69
|
-
if (prop === 'getError') return () => 0
|
|
70
|
-
if (prop === 'getSupportedExtensions') return () => []
|
|
71
|
-
if (prop === 'getContextAttributes') return () => ({})
|
|
72
|
-
if (prop === 'getParameter') return () => 0
|
|
73
|
-
if (prop === 'getUniformLocation' || prop === 'getAttribLocation') return () => 0
|
|
74
|
-
if (prop.startsWith('create') || prop === 'getExtension') return () => ({ __webglStub: true })
|
|
75
|
-
if (prop === 'drawingBufferWidth' || prop === 'drawingBufferHeight') return 300
|
|
76
|
-
return noop
|
|
77
|
-
},
|
|
78
|
-
}) as WebGL2RenderingContext
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function createDom(width: number, height: number): ChartDom {
|
|
82
|
-
const container = document.createElement('div')
|
|
83
|
-
const canvasLayer = document.createElement('div')
|
|
84
|
-
const rightAxisLayer = document.createElement('div')
|
|
85
|
-
const xAxisCanvas = document.createElement('canvas')
|
|
86
|
-
|
|
87
|
-
Object.defineProperty(container, 'clientWidth', { configurable: true, value: width })
|
|
88
|
-
Object.defineProperty(container, 'clientHeight', { configurable: true, value: height })
|
|
89
|
-
Object.defineProperty(container, 'scrollLeft', { configurable: true, writable: true, value: 0 })
|
|
90
|
-
|
|
91
|
-
container.appendChild(canvasLayer)
|
|
92
|
-
container.appendChild(rightAxisLayer)
|
|
93
|
-
canvasLayer.appendChild(xAxisCanvas)
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
container: container as HTMLDivElement,
|
|
97
|
-
canvasLayer: canvasLayer as HTMLDivElement,
|
|
98
|
-
rightAxisLayer: rightAxisLayer as HTMLDivElement,
|
|
99
|
-
xAxisCanvas,
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
describe('Chart DPR pipeline', () => {
|
|
104
|
-
const originalResizeObserver = globalThis.ResizeObserver
|
|
105
|
-
const originalDevicePixelRatio = window.devicePixelRatio
|
|
106
|
-
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
|
107
|
-
|
|
108
|
-
beforeEach(() => {
|
|
109
|
-
ResizeObserverMock.reset()
|
|
110
|
-
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver
|
|
111
|
-
|
|
112
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
113
|
-
configurable: true,
|
|
114
|
-
writable: true,
|
|
115
|
-
value: 1,
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
HTMLCanvasElement.prototype.getContext = vi.fn(function (
|
|
119
|
-
this: HTMLCanvasElement,
|
|
120
|
-
type: string,
|
|
121
|
-
) {
|
|
122
|
-
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
|
|
123
|
-
return createWebGLStub() as unknown as RenderingContext
|
|
124
|
-
}
|
|
125
|
-
return createCanvasContextStub() as unknown as RenderingContext
|
|
126
|
-
}) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
afterEach(async () => {
|
|
130
|
-
globalThis.ResizeObserver = originalResizeObserver
|
|
131
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
132
|
-
configurable: true,
|
|
133
|
-
writable: true,
|
|
134
|
-
value: originalDevicePixelRatio,
|
|
135
|
-
})
|
|
136
|
-
HTMLCanvasElement.prototype.getContext = originalGetContext
|
|
137
|
-
vi.restoreAllMocks()
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('falls back to default observe when device-pixel-content-box observe fails', async () => {
|
|
141
|
-
ResizeObserverMock.failWithDevicePixelBox = true
|
|
142
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
143
|
-
|
|
144
|
-
const ro = ResizeObserverMock.instances[0]
|
|
145
|
-
expect(ro).toBeDefined()
|
|
146
|
-
expect(ro?.observe).toHaveBeenCalledTimes(2)
|
|
147
|
-
expect(ro?.observe).toHaveBeenNthCalledWith(1, chart.getDom().container, { box: 'device-pixel-content-box' })
|
|
148
|
-
expect(ro?.observe).toHaveBeenNthCalledWith(2, chart.getDom().container)
|
|
149
|
-
|
|
150
|
-
await chart.destroy()
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('prefers precise DPR from ResizeObserver devicePixelContentBoxSize', async () => {
|
|
154
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
155
|
-
configurable: true,
|
|
156
|
-
writable: true,
|
|
157
|
-
value: 1,
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
161
|
-
const ro = ResizeObserverMock.instances[0]
|
|
162
|
-
|
|
163
|
-
ro?.emit({
|
|
164
|
-
contentRect: { width: 1000, height: 600 } as DOMRectReadOnly,
|
|
165
|
-
devicePixelContentBoxSize: [{ inlineSize: 2000, blockSize: 1200 }] as unknown as ResizeObserverSize[],
|
|
166
|
-
contentBoxSize: [{ inlineSize: 1000, blockSize: 600 }] as unknown as ResizeObserverSize[],
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
expect(chart.getCurrentDpr()).toBe(2)
|
|
170
|
-
|
|
171
|
-
await chart.destroy()
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('falls back to rounded window.devicePixelRatio when precise DPR is unavailable', async () => {
|
|
175
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
176
|
-
configurable: true,
|
|
177
|
-
writable: true,
|
|
178
|
-
value: 1.234,
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
182
|
-
const ro = ResizeObserverMock.instances[0]
|
|
183
|
-
|
|
184
|
-
ro?.emit({
|
|
185
|
-
contentRect: { width: 1000, height: 600 } as DOMRectReadOnly,
|
|
186
|
-
contentBoxSize: [{ inlineSize: 1000, blockSize: 600 }] as unknown as ResizeObserverSize[],
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
expect(chart.getCurrentDpr()).toBe(Math.round(1.234 * 64) / 64)
|
|
190
|
-
|
|
191
|
-
await chart.destroy()
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
it('clamps DPR to at least 1', async () => {
|
|
195
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
196
|
-
configurable: true,
|
|
197
|
-
writable: true,
|
|
198
|
-
value: 0.5,
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
202
|
-
const ro = ResizeObserverMock.instances[0]
|
|
203
|
-
|
|
204
|
-
ro?.emit({
|
|
205
|
-
contentRect: { width: 1000, height: 600 } as DOMRectReadOnly,
|
|
206
|
-
contentBoxSize: [{ inlineSize: 1000, blockSize: 600 }] as unknown as ResizeObserverSize[],
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
expect(chart.getCurrentDpr()).toBe(1)
|
|
210
|
-
|
|
211
|
-
await chart.destroy()
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
it('reduces viewport DPR when requested pixels exceed MAX_CANVAS_PIXELS', async () => {
|
|
215
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
216
|
-
configurable: true,
|
|
217
|
-
writable: true,
|
|
218
|
-
value: 3,
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
const chart = new Chart(createDom(6000, 4000), defaultOptions)
|
|
222
|
-
chart.resize()
|
|
223
|
-
|
|
224
|
-
const viewport = chart.getViewport()
|
|
225
|
-
expect(viewport).not.toBeNull()
|
|
226
|
-
expect(viewport!.dpr).toBeLessThan(3)
|
|
227
|
-
|
|
228
|
-
await chart.destroy()
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it('disconnects ResizeObserver on destroy', async () => {
|
|
232
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
233
|
-
const ro = ResizeObserverMock.instances[0]
|
|
234
|
-
|
|
235
|
-
await chart.destroy()
|
|
236
|
-
|
|
237
|
-
expect(ro?.disconnect).toHaveBeenCalledTimes(1)
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
it('does not emit viewport change repeatedly for identical viewport draws', async () => {
|
|
241
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
242
|
-
const onViewportChange = vi.fn()
|
|
243
|
-
|
|
244
|
-
chart.setOnViewportChange(onViewportChange)
|
|
245
|
-
chart.draw()
|
|
246
|
-
chart.draw()
|
|
247
|
-
|
|
248
|
-
expect(onViewportChange).toHaveBeenCalledTimes(1)
|
|
249
|
-
|
|
250
|
-
await chart.destroy()
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
it('does not schedule redraw for identical render state', async () => {
|
|
254
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
255
|
-
const scheduleDrawSpy = vi.spyOn(chart, 'scheduleDraw')
|
|
256
|
-
|
|
257
|
-
chart.applyRenderState(12, 3, 2)
|
|
258
|
-
chart.applyRenderState(12, 3, 2)
|
|
259
|
-
|
|
260
|
-
expect(scheduleDrawSpy).toHaveBeenCalledTimes(1)
|
|
261
|
-
|
|
262
|
-
await chart.destroy()
|
|
263
|
-
})
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
describe('Chart pane layout regressions', () => {
|
|
267
|
-
const originalResizeObserver = globalThis.ResizeObserver
|
|
268
|
-
const originalDevicePixelRatio = window.devicePixelRatio
|
|
269
|
-
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
|
270
|
-
|
|
271
|
-
beforeEach(() => {
|
|
272
|
-
ResizeObserverMock.reset()
|
|
273
|
-
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver
|
|
274
|
-
|
|
275
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
276
|
-
configurable: true,
|
|
277
|
-
writable: true,
|
|
278
|
-
value: 1,
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
HTMLCanvasElement.prototype.getContext = vi.fn(function (
|
|
282
|
-
this: HTMLCanvasElement,
|
|
283
|
-
type: string,
|
|
284
|
-
) {
|
|
285
|
-
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
|
|
286
|
-
return createWebGLStub() as unknown as RenderingContext
|
|
287
|
-
}
|
|
288
|
-
return createCanvasContextStub() as unknown as RenderingContext
|
|
289
|
-
}) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
afterEach(async () => {
|
|
293
|
-
globalThis.ResizeObserver = originalResizeObserver
|
|
294
|
-
Object.defineProperty(window, 'devicePixelRatio', {
|
|
295
|
-
configurable: true,
|
|
296
|
-
writable: true,
|
|
297
|
-
value: originalDevicePixelRatio,
|
|
298
|
-
})
|
|
299
|
-
HTMLCanvasElement.prototype.getContext = originalGetContext
|
|
300
|
-
vi.restoreAllMocks()
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
it('allocates initial pane ratios as 3:1:1 for main+MACD+RSI', async () => {
|
|
304
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
305
|
-
chart.resize()
|
|
306
|
-
|
|
307
|
-
expect(chart.createSubPane('MACD_0', 'MACD')).toBe(true)
|
|
308
|
-
expect(chart.createSubPane('RSI_0', 'RSI')).toBe(true)
|
|
309
|
-
|
|
310
|
-
const specs = chart.getPaneLayoutSpecs().filter((pane) => pane.visible !== false)
|
|
311
|
-
expect(specs).toHaveLength(3)
|
|
312
|
-
|
|
313
|
-
const byId = new Map(specs.map((pane) => [pane.id, pane]))
|
|
314
|
-
expect(byId.get('main')?.ratio ?? 0).toBeCloseTo(7 / 12, 6)
|
|
315
|
-
expect(byId.get('MACD_0')?.ratio ?? 0).toBeCloseTo(5 / 24, 6)
|
|
316
|
-
expect(byId.get('RSI_0')?.ratio ?? 0).toBeCloseTo(5 / 24, 6)
|
|
317
|
-
|
|
318
|
-
await chart.destroy()
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
it('keeps indicator pane heights equal for main+MACD+RSI', async () => {
|
|
322
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
323
|
-
chart.resize()
|
|
324
|
-
chart.createSubPane('MACD_0', 'MACD')
|
|
325
|
-
chart.createSubPane('RSI_0', 'RSI')
|
|
326
|
-
chart.resize()
|
|
327
|
-
|
|
328
|
-
const panes = chart.getPaneRenderers().map((renderer) => renderer.getPane())
|
|
329
|
-
const macd = panes.find((pane) => pane.id === 'MACD_0')
|
|
330
|
-
const rsi = panes.find((pane) => pane.id === 'RSI_0')
|
|
331
|
-
|
|
332
|
-
expect(macd).toBeDefined()
|
|
333
|
-
expect(rsi).toBeDefined()
|
|
334
|
-
expect(Math.abs((macd?.height ?? 0) - (rsi?.height ?? 0))).toBeLessThanOrEqual(1)
|
|
335
|
-
|
|
336
|
-
await chart.destroy()
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
it('keeps visible ratio sum at 1 after boundary resize', async () => {
|
|
340
|
-
const chart = new Chart(createDom(1000, 800), defaultOptions)
|
|
341
|
-
chart.resize()
|
|
342
|
-
chart.createSubPane('MACD_0', 'MACD')
|
|
343
|
-
chart.createSubPane('RSI_0', 'RSI')
|
|
344
|
-
chart.resize()
|
|
345
|
-
|
|
346
|
-
const resized = chart.resizePaneBoundary('MACD_0', 20)
|
|
347
|
-
expect(resized).toBe(true)
|
|
348
|
-
|
|
349
|
-
const visible = chart.getPaneLayoutSpecs().filter((pane) => pane.visible !== false)
|
|
350
|
-
const sum = visible.reduce((acc, pane) => acc + pane.ratio, 0)
|
|
351
|
-
expect(sum).toBeCloseTo(1, 6)
|
|
352
|
-
|
|
353
|
-
await chart.destroy()
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
it('returns false and keeps layout unchanged for invalid boundary resize input', async () => {
|
|
357
|
-
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
358
|
-
chart.resize()
|
|
359
|
-
chart.createSubPane('MACD_0', 'MACD')
|
|
360
|
-
chart.createSubPane('RSI_0', 'RSI')
|
|
361
|
-
chart.resize()
|
|
362
|
-
|
|
363
|
-
const before = chart.getPaneLayoutSpecs()
|
|
364
|
-
const invalidId = chart.resizePaneBoundary('missing-pane-id', 20)
|
|
365
|
-
const zeroDelta = chart.resizePaneBoundary('main', 0)
|
|
366
|
-
const after = chart.getPaneLayoutSpecs()
|
|
367
|
-
|
|
368
|
-
expect(invalidId).toBe(false)
|
|
369
|
-
expect(zeroDelta).toBe(false)
|
|
370
|
-
expect(after).toEqual(before)
|
|
371
|
-
|
|
372
|
-
await chart.destroy()
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
it('normalizes only visible panes in updatePaneLayout', async () => {
|
|
376
|
-
const chart = new Chart(createDom(1000, 800), defaultOptions)
|
|
377
|
-
chart.updatePaneLayout([
|
|
378
|
-
{ id: 'main', ratio: 3, visible: true, role: 'price' },
|
|
379
|
-
{ id: 'sub_MACD', ratio: 1, visible: true, role: 'indicator' },
|
|
380
|
-
{ id: 'sub_RSI', ratio: 100, visible: false, role: 'indicator' },
|
|
381
|
-
])
|
|
382
|
-
|
|
383
|
-
const specs = chart.getPaneLayoutSpecs()
|
|
384
|
-
const main = specs.find((pane) => pane.id === 'main')
|
|
385
|
-
const macd = specs.find((pane) => pane.id === 'sub_MACD')
|
|
386
|
-
const rsi = specs.find((pane) => pane.id === 'sub_RSI')
|
|
387
|
-
|
|
388
|
-
// updatePaneLayout is an explicit layout replacement — incoming ratios MUST
|
|
389
|
-
// be honoured (3:1 → 0.75:0.25 after visible normalization). Earlier this was
|
|
390
|
-
// weakened to `main > macd` because syncPaneRatiosFromSpecs preserved a stale
|
|
391
|
-
// prev value for `main`; fixed by clearing paneRatios in updatePaneLayout.
|
|
392
|
-
expect((main?.ratio ?? 0) + (macd?.ratio ?? 0)).toBeCloseTo(1, 6)
|
|
393
|
-
expect(main?.ratio).toBeCloseTo(0.75, 6)
|
|
394
|
-
expect(macd?.ratio).toBeCloseTo(0.25, 6)
|
|
395
|
-
// Hidden pane preserves its incoming raw ratio (not normalized against visible);
|
|
396
|
-
// it will be folded into the layout only if/when re-shown.
|
|
397
|
-
expect(rsi?.visible).toBe(false)
|
|
398
|
-
|
|
399
|
-
await chart.destroy()
|
|
400
|
-
})
|
|
401
|
-
})
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { Chart, type ChartDom, type ChartOptions } from '@/core/chart'
|
|
3
|
+
|
|
4
|
+
class ResizeObserverMock {
|
|
5
|
+
static instances: ResizeObserverMock[] = []
|
|
6
|
+
static failWithDevicePixelBox = false
|
|
7
|
+
|
|
8
|
+
private callback: ResizeObserverCallback
|
|
9
|
+
observe = vi.fn((target: Element, options?: ResizeObserverOptions) => {
|
|
10
|
+
if (options?.box === 'device-pixel-content-box' && ResizeObserverMock.failWithDevicePixelBox) {
|
|
11
|
+
throw new Error('device-pixel-content-box not supported')
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
disconnect = vi.fn()
|
|
15
|
+
|
|
16
|
+
constructor(callback: ResizeObserverCallback) {
|
|
17
|
+
this.callback = callback
|
|
18
|
+
ResizeObserverMock.instances.push(this)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
emit(entry: Partial<ResizeObserverEntry>) {
|
|
22
|
+
this.callback([entry as ResizeObserverEntry], this as unknown as ResizeObserver)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static reset() {
|
|
26
|
+
ResizeObserverMock.instances = []
|
|
27
|
+
ResizeObserverMock.failWithDevicePixelBox = false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultOptions: ChartOptions = {
|
|
32
|
+
kWidth: 10,
|
|
33
|
+
kGap: 2,
|
|
34
|
+
yPaddingPx: 0,
|
|
35
|
+
rightAxisWidth: 0,
|
|
36
|
+
bottomAxisHeight: 24,
|
|
37
|
+
minKWidth: 2,
|
|
38
|
+
maxKWidth: 50,
|
|
39
|
+
panes: [{ id: 'main', ratio: 1 }],
|
|
40
|
+
priceLabelWidth: 60,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createCanvasContextStub() {
|
|
44
|
+
return {
|
|
45
|
+
setTransform: vi.fn(),
|
|
46
|
+
scale: vi.fn(),
|
|
47
|
+
clearRect: vi.fn(),
|
|
48
|
+
save: vi.fn(),
|
|
49
|
+
restore: vi.fn(),
|
|
50
|
+
beginPath: vi.fn(),
|
|
51
|
+
moveTo: vi.fn(),
|
|
52
|
+
lineTo: vi.fn(),
|
|
53
|
+
stroke: vi.fn(),
|
|
54
|
+
fillRect: vi.fn(),
|
|
55
|
+
strokeRect: vi.fn(),
|
|
56
|
+
fillText: vi.fn(),
|
|
57
|
+
measureText: vi.fn(() => ({ width: 40 })),
|
|
58
|
+
} as unknown as CanvasRenderingContext2D
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createWebGLStub(): WebGL2RenderingContext {
|
|
62
|
+
const noop = () => {}
|
|
63
|
+
return new Proxy({} as unknown as WebGL2RenderingContext, {
|
|
64
|
+
get(_, prop) {
|
|
65
|
+
if (typeof prop !== 'string') return undefined
|
|
66
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 0
|
|
67
|
+
if (prop === 'getShaderInfoLog' || prop === 'getProgramInfoLog') return () => ''
|
|
68
|
+
if (prop === 'getShaderParameter' || prop === 'getProgramParameter') return () => true
|
|
69
|
+
if (prop === 'getError') return () => 0
|
|
70
|
+
if (prop === 'getSupportedExtensions') return () => []
|
|
71
|
+
if (prop === 'getContextAttributes') return () => ({})
|
|
72
|
+
if (prop === 'getParameter') return () => 0
|
|
73
|
+
if (prop === 'getUniformLocation' || prop === 'getAttribLocation') return () => 0
|
|
74
|
+
if (prop.startsWith('create') || prop === 'getExtension') return () => ({ __webglStub: true })
|
|
75
|
+
if (prop === 'drawingBufferWidth' || prop === 'drawingBufferHeight') return 300
|
|
76
|
+
return noop
|
|
77
|
+
},
|
|
78
|
+
}) as WebGL2RenderingContext
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createDom(width: number, height: number): ChartDom {
|
|
82
|
+
const container = document.createElement('div')
|
|
83
|
+
const canvasLayer = document.createElement('div')
|
|
84
|
+
const rightAxisLayer = document.createElement('div')
|
|
85
|
+
const xAxisCanvas = document.createElement('canvas')
|
|
86
|
+
|
|
87
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: width })
|
|
88
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: height })
|
|
89
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, writable: true, value: 0 })
|
|
90
|
+
|
|
91
|
+
container.appendChild(canvasLayer)
|
|
92
|
+
container.appendChild(rightAxisLayer)
|
|
93
|
+
canvasLayer.appendChild(xAxisCanvas)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
container: container as HTMLDivElement,
|
|
97
|
+
canvasLayer: canvasLayer as HTMLDivElement,
|
|
98
|
+
rightAxisLayer: rightAxisLayer as HTMLDivElement,
|
|
99
|
+
xAxisCanvas,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('Chart DPR pipeline', () => {
|
|
104
|
+
const originalResizeObserver = globalThis.ResizeObserver
|
|
105
|
+
const originalDevicePixelRatio = window.devicePixelRatio
|
|
106
|
+
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
ResizeObserverMock.reset()
|
|
110
|
+
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver
|
|
111
|
+
|
|
112
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
113
|
+
configurable: true,
|
|
114
|
+
writable: true,
|
|
115
|
+
value: 1,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(function (
|
|
119
|
+
this: HTMLCanvasElement,
|
|
120
|
+
type: string,
|
|
121
|
+
) {
|
|
122
|
+
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
|
|
123
|
+
return createWebGLStub() as unknown as RenderingContext
|
|
124
|
+
}
|
|
125
|
+
return createCanvasContextStub() as unknown as RenderingContext
|
|
126
|
+
}) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
afterEach(async () => {
|
|
130
|
+
globalThis.ResizeObserver = originalResizeObserver
|
|
131
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
132
|
+
configurable: true,
|
|
133
|
+
writable: true,
|
|
134
|
+
value: originalDevicePixelRatio,
|
|
135
|
+
})
|
|
136
|
+
HTMLCanvasElement.prototype.getContext = originalGetContext
|
|
137
|
+
vi.restoreAllMocks()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('falls back to default observe when device-pixel-content-box observe fails', async () => {
|
|
141
|
+
ResizeObserverMock.failWithDevicePixelBox = true
|
|
142
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
143
|
+
|
|
144
|
+
const ro = ResizeObserverMock.instances[0]
|
|
145
|
+
expect(ro).toBeDefined()
|
|
146
|
+
expect(ro?.observe).toHaveBeenCalledTimes(2)
|
|
147
|
+
expect(ro?.observe).toHaveBeenNthCalledWith(1, chart.getDom().container, { box: 'device-pixel-content-box' })
|
|
148
|
+
expect(ro?.observe).toHaveBeenNthCalledWith(2, chart.getDom().container)
|
|
149
|
+
|
|
150
|
+
await chart.destroy()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('prefers precise DPR from ResizeObserver devicePixelContentBoxSize', async () => {
|
|
154
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
155
|
+
configurable: true,
|
|
156
|
+
writable: true,
|
|
157
|
+
value: 1,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
161
|
+
const ro = ResizeObserverMock.instances[0]
|
|
162
|
+
|
|
163
|
+
ro?.emit({
|
|
164
|
+
contentRect: { width: 1000, height: 600 } as DOMRectReadOnly,
|
|
165
|
+
devicePixelContentBoxSize: [{ inlineSize: 2000, blockSize: 1200 }] as unknown as ResizeObserverSize[],
|
|
166
|
+
contentBoxSize: [{ inlineSize: 1000, blockSize: 600 }] as unknown as ResizeObserverSize[],
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(chart.getCurrentDpr()).toBe(2)
|
|
170
|
+
|
|
171
|
+
await chart.destroy()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('falls back to rounded window.devicePixelRatio when precise DPR is unavailable', async () => {
|
|
175
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
176
|
+
configurable: true,
|
|
177
|
+
writable: true,
|
|
178
|
+
value: 1.234,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
182
|
+
const ro = ResizeObserverMock.instances[0]
|
|
183
|
+
|
|
184
|
+
ro?.emit({
|
|
185
|
+
contentRect: { width: 1000, height: 600 } as DOMRectReadOnly,
|
|
186
|
+
contentBoxSize: [{ inlineSize: 1000, blockSize: 600 }] as unknown as ResizeObserverSize[],
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(chart.getCurrentDpr()).toBe(Math.round(1.234 * 64) / 64)
|
|
190
|
+
|
|
191
|
+
await chart.destroy()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('clamps DPR to at least 1', async () => {
|
|
195
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
196
|
+
configurable: true,
|
|
197
|
+
writable: true,
|
|
198
|
+
value: 0.5,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
202
|
+
const ro = ResizeObserverMock.instances[0]
|
|
203
|
+
|
|
204
|
+
ro?.emit({
|
|
205
|
+
contentRect: { width: 1000, height: 600 } as DOMRectReadOnly,
|
|
206
|
+
contentBoxSize: [{ inlineSize: 1000, blockSize: 600 }] as unknown as ResizeObserverSize[],
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
expect(chart.getCurrentDpr()).toBe(1)
|
|
210
|
+
|
|
211
|
+
await chart.destroy()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('reduces viewport DPR when requested pixels exceed MAX_CANVAS_PIXELS', async () => {
|
|
215
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
216
|
+
configurable: true,
|
|
217
|
+
writable: true,
|
|
218
|
+
value: 3,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const chart = new Chart(createDom(6000, 4000), defaultOptions)
|
|
222
|
+
chart.resize()
|
|
223
|
+
|
|
224
|
+
const viewport = chart.getViewport()
|
|
225
|
+
expect(viewport).not.toBeNull()
|
|
226
|
+
expect(viewport!.dpr).toBeLessThan(3)
|
|
227
|
+
|
|
228
|
+
await chart.destroy()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('disconnects ResizeObserver on destroy', async () => {
|
|
232
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
233
|
+
const ro = ResizeObserverMock.instances[0]
|
|
234
|
+
|
|
235
|
+
await chart.destroy()
|
|
236
|
+
|
|
237
|
+
expect(ro?.disconnect).toHaveBeenCalledTimes(1)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('does not emit viewport change repeatedly for identical viewport draws', async () => {
|
|
241
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
242
|
+
const onViewportChange = vi.fn()
|
|
243
|
+
|
|
244
|
+
chart.setOnViewportChange(onViewportChange)
|
|
245
|
+
chart.draw()
|
|
246
|
+
chart.draw()
|
|
247
|
+
|
|
248
|
+
expect(onViewportChange).toHaveBeenCalledTimes(1)
|
|
249
|
+
|
|
250
|
+
await chart.destroy()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('does not schedule redraw for identical render state', async () => {
|
|
254
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
255
|
+
const scheduleDrawSpy = vi.spyOn(chart, 'scheduleDraw')
|
|
256
|
+
|
|
257
|
+
chart.applyRenderState(12, 3, 2)
|
|
258
|
+
chart.applyRenderState(12, 3, 2)
|
|
259
|
+
|
|
260
|
+
expect(scheduleDrawSpy).toHaveBeenCalledTimes(1)
|
|
261
|
+
|
|
262
|
+
await chart.destroy()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('Chart pane layout regressions', () => {
|
|
267
|
+
const originalResizeObserver = globalThis.ResizeObserver
|
|
268
|
+
const originalDevicePixelRatio = window.devicePixelRatio
|
|
269
|
+
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
|
270
|
+
|
|
271
|
+
beforeEach(() => {
|
|
272
|
+
ResizeObserverMock.reset()
|
|
273
|
+
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver
|
|
274
|
+
|
|
275
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
276
|
+
configurable: true,
|
|
277
|
+
writable: true,
|
|
278
|
+
value: 1,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(function (
|
|
282
|
+
this: HTMLCanvasElement,
|
|
283
|
+
type: string,
|
|
284
|
+
) {
|
|
285
|
+
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
|
|
286
|
+
return createWebGLStub() as unknown as RenderingContext
|
|
287
|
+
}
|
|
288
|
+
return createCanvasContextStub() as unknown as RenderingContext
|
|
289
|
+
}) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
afterEach(async () => {
|
|
293
|
+
globalThis.ResizeObserver = originalResizeObserver
|
|
294
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
295
|
+
configurable: true,
|
|
296
|
+
writable: true,
|
|
297
|
+
value: originalDevicePixelRatio,
|
|
298
|
+
})
|
|
299
|
+
HTMLCanvasElement.prototype.getContext = originalGetContext
|
|
300
|
+
vi.restoreAllMocks()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('allocates initial pane ratios as 3:1:1 for main+MACD+RSI', async () => {
|
|
304
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
305
|
+
chart.resize()
|
|
306
|
+
|
|
307
|
+
expect(chart.createSubPane('MACD_0', 'MACD')).toBe(true)
|
|
308
|
+
expect(chart.createSubPane('RSI_0', 'RSI')).toBe(true)
|
|
309
|
+
|
|
310
|
+
const specs = chart.getPaneLayoutSpecs().filter((pane) => pane.visible !== false)
|
|
311
|
+
expect(specs).toHaveLength(3)
|
|
312
|
+
|
|
313
|
+
const byId = new Map(specs.map((pane) => [pane.id, pane]))
|
|
314
|
+
expect(byId.get('main')?.ratio ?? 0).toBeCloseTo(7 / 12, 6)
|
|
315
|
+
expect(byId.get('MACD_0')?.ratio ?? 0).toBeCloseTo(5 / 24, 6)
|
|
316
|
+
expect(byId.get('RSI_0')?.ratio ?? 0).toBeCloseTo(5 / 24, 6)
|
|
317
|
+
|
|
318
|
+
await chart.destroy()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('keeps indicator pane heights equal for main+MACD+RSI', async () => {
|
|
322
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
323
|
+
chart.resize()
|
|
324
|
+
chart.createSubPane('MACD_0', 'MACD')
|
|
325
|
+
chart.createSubPane('RSI_0', 'RSI')
|
|
326
|
+
chart.resize()
|
|
327
|
+
|
|
328
|
+
const panes = chart.getPaneRenderers().map((renderer) => renderer.getPane())
|
|
329
|
+
const macd = panes.find((pane) => pane.id === 'MACD_0')
|
|
330
|
+
const rsi = panes.find((pane) => pane.id === 'RSI_0')
|
|
331
|
+
|
|
332
|
+
expect(macd).toBeDefined()
|
|
333
|
+
expect(rsi).toBeDefined()
|
|
334
|
+
expect(Math.abs((macd?.height ?? 0) - (rsi?.height ?? 0))).toBeLessThanOrEqual(1)
|
|
335
|
+
|
|
336
|
+
await chart.destroy()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('keeps visible ratio sum at 1 after boundary resize', async () => {
|
|
340
|
+
const chart = new Chart(createDom(1000, 800), defaultOptions)
|
|
341
|
+
chart.resize()
|
|
342
|
+
chart.createSubPane('MACD_0', 'MACD')
|
|
343
|
+
chart.createSubPane('RSI_0', 'RSI')
|
|
344
|
+
chart.resize()
|
|
345
|
+
|
|
346
|
+
const resized = chart.resizePaneBoundary('MACD_0', 20)
|
|
347
|
+
expect(resized).toBe(true)
|
|
348
|
+
|
|
349
|
+
const visible = chart.getPaneLayoutSpecs().filter((pane) => pane.visible !== false)
|
|
350
|
+
const sum = visible.reduce((acc, pane) => acc + pane.ratio, 0)
|
|
351
|
+
expect(sum).toBeCloseTo(1, 6)
|
|
352
|
+
|
|
353
|
+
await chart.destroy()
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('returns false and keeps layout unchanged for invalid boundary resize input', async () => {
|
|
357
|
+
const chart = new Chart(createDom(1000, 600), defaultOptions)
|
|
358
|
+
chart.resize()
|
|
359
|
+
chart.createSubPane('MACD_0', 'MACD')
|
|
360
|
+
chart.createSubPane('RSI_0', 'RSI')
|
|
361
|
+
chart.resize()
|
|
362
|
+
|
|
363
|
+
const before = chart.getPaneLayoutSpecs()
|
|
364
|
+
const invalidId = chart.resizePaneBoundary('missing-pane-id', 20)
|
|
365
|
+
const zeroDelta = chart.resizePaneBoundary('main', 0)
|
|
366
|
+
const after = chart.getPaneLayoutSpecs()
|
|
367
|
+
|
|
368
|
+
expect(invalidId).toBe(false)
|
|
369
|
+
expect(zeroDelta).toBe(false)
|
|
370
|
+
expect(after).toEqual(before)
|
|
371
|
+
|
|
372
|
+
await chart.destroy()
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('normalizes only visible panes in updatePaneLayout', async () => {
|
|
376
|
+
const chart = new Chart(createDom(1000, 800), defaultOptions)
|
|
377
|
+
chart.updatePaneLayout([
|
|
378
|
+
{ id: 'main', ratio: 3, visible: true, role: 'price' },
|
|
379
|
+
{ id: 'sub_MACD', ratio: 1, visible: true, role: 'indicator' },
|
|
380
|
+
{ id: 'sub_RSI', ratio: 100, visible: false, role: 'indicator' },
|
|
381
|
+
])
|
|
382
|
+
|
|
383
|
+
const specs = chart.getPaneLayoutSpecs()
|
|
384
|
+
const main = specs.find((pane) => pane.id === 'main')
|
|
385
|
+
const macd = specs.find((pane) => pane.id === 'sub_MACD')
|
|
386
|
+
const rsi = specs.find((pane) => pane.id === 'sub_RSI')
|
|
387
|
+
|
|
388
|
+
// updatePaneLayout is an explicit layout replacement — incoming ratios MUST
|
|
389
|
+
// be honoured (3:1 → 0.75:0.25 after visible normalization). Earlier this was
|
|
390
|
+
// weakened to `main > macd` because syncPaneRatiosFromSpecs preserved a stale
|
|
391
|
+
// prev value for `main`; fixed by clearing paneRatios in updatePaneLayout.
|
|
392
|
+
expect((main?.ratio ?? 0) + (macd?.ratio ?? 0)).toBeCloseTo(1, 6)
|
|
393
|
+
expect(main?.ratio).toBeCloseTo(0.75, 6)
|
|
394
|
+
expect(macd?.ratio).toBeCloseTo(0.25, 6)
|
|
395
|
+
// Hidden pane preserves its incoming raw ratio (not normalized against visible);
|
|
396
|
+
// it will be folded into the layout only if/when re-shown.
|
|
397
|
+
expect(rsi?.visible).toBe(false)
|
|
398
|
+
|
|
399
|
+
await chart.destroy()
|
|
400
|
+
})
|
|
401
|
+
})
|