@363045841yyt/klinechart 0.8.4 → 0.8.5

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.
Files changed (45) hide show
  1. package/dist/components/BatchStockDialog.vue.d.ts +13 -0
  2. package/dist/components/BatchStockDialog.vue.d.ts.map +1 -0
  3. package/dist/components/CompareSymbolSelector.vue.d.ts.map +1 -1
  4. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  5. package/dist/components/ExportProgressDialog.vue.d.ts +15 -0
  6. package/dist/components/ExportProgressDialog.vue.d.ts.map +1 -0
  7. package/dist/components/KLineChart.vue.d.ts +5 -9
  8. package/dist/components/KLineChart.vue.d.ts.map +1 -1
  9. package/dist/components/LeftToolbar.vue.d.ts.map +1 -1
  10. package/dist/components/SymbolSelector.vue.d.ts.map +1 -1
  11. package/dist/components/TopToolbar.vue.d.ts.map +1 -1
  12. package/dist/composables/chart/useChartTheme.d.ts +329 -0
  13. package/dist/composables/chart/useChartTheme.d.ts.map +1 -0
  14. package/dist/composables/chart/useDrawingManager.d.ts +86 -0
  15. package/dist/composables/chart/useDrawingManager.d.ts.map +1 -0
  16. package/dist/composables/chart/useIndicatorManager.d.ts +38 -0
  17. package/dist/composables/chart/useIndicatorManager.d.ts.map +1 -0
  18. package/dist/composables/chart/useRangeSelection.d.ts +65 -0
  19. package/dist/composables/chart/useRangeSelection.d.ts.map +1 -0
  20. package/dist/composables/useTeleportedPopup.d.ts +8 -0
  21. package/dist/composables/useTeleportedPopup.d.ts.map +1 -0
  22. package/dist/index.cjs +9 -2
  23. package/dist/index.css +1 -1
  24. package/dist/index.js +1722 -1060
  25. package/dist/tools/calcRangeOverlayPixel.d.ts +15 -0
  26. package/dist/tools/calcRangeOverlayPixel.d.ts.map +1 -0
  27. package/dist/tools/getKLineIndexByTimestamp.d.ts +4 -0
  28. package/dist/tools/getKLineIndexByTimestamp.d.ts.map +1 -0
  29. package/dist/web-component.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/BatchStockDialog.vue +293 -0
  32. package/src/components/CompareSymbolSelector.vue +35 -8
  33. package/src/components/Dropdown.vue +42 -19
  34. package/src/components/ExportProgressDialog.vue +226 -0
  35. package/src/components/KLineChart.vue +325 -396
  36. package/src/components/LeftToolbar.vue +2 -1
  37. package/src/components/SymbolSelector.vue +35 -8
  38. package/src/components/TopToolbar.vue +55 -2
  39. package/src/composables/chart/useChartTheme.ts +86 -0
  40. package/src/composables/chart/useDrawingManager.ts +67 -0
  41. package/src/composables/chart/useIndicatorManager.ts +307 -0
  42. package/src/composables/chart/useRangeSelection.ts +417 -0
  43. package/src/composables/useTeleportedPopup.ts +33 -0
  44. package/src/tools/calcRangeOverlayPixel.ts +28 -0
  45. package/src/tools/getKLineIndexByTimestamp.ts +40 -0
@@ -0,0 +1,417 @@
1
+ import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
2
+ import { formatTimestamp } from '@363045841yyt/klinechart-core'
3
+ import type { KLineData, ChartController, DataFetcher } from '@363045841yyt/klinechart-core/controllers'
4
+ import { calcRangeOverlayPixel } from '../../tools/calcRangeOverlayPixel'
5
+ import type { Bounds } from '../../tools/calcRangeOverlayPixel'
6
+ import {
7
+ getKLineIndexByTimestamp,
8
+ findNearestKLineIndex,
9
+ } from '../../tools/getKLineIndexByTimestamp'
10
+
11
+ interface RangeSelectionState {
12
+ startTimestamp: number | null
13
+ endTimestamp: number | null
14
+ isDragging: boolean
15
+ }
16
+
17
+ function fmtDate(item: KLineData | undefined): string {
18
+ if (!item) return '?'
19
+ if (item.date) return item.date
20
+ return new Date(item.timestamp).toISOString().slice(0, 10)
21
+ }
22
+
23
+ function toYYYYMMDD(ts: number): string {
24
+ const d = new Date(ts)
25
+ const y = d.getFullYear()
26
+ const m = String(d.getMonth() + 1).padStart(2, '0')
27
+ const day = String(d.getDate()).padStart(2, '0')
28
+ return `${y}${m}${day}`
29
+ }
30
+
31
+ function normalizeDateInput(input: string): string | null {
32
+ const parts = input.trim().split(/[-/]/)
33
+ if (parts.length !== 3) return null
34
+ const y = parts[0]!.padStart(4, '0')
35
+ const m = parts[1]!.padStart(2, '0')
36
+ const d = parts[2]!.padStart(2, '0')
37
+ return `${y}-${m}-${d}`
38
+ }
39
+
40
+ function toCsvCell(value: unknown): string {
41
+ if (value === null || value === undefined) return ''
42
+ const text = String(value)
43
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
44
+ }
45
+
46
+ function parseDateToTimestamp(input: string): number | null {
47
+ const normalized = normalizeDateInput(input)
48
+ if (!normalized) return null
49
+ const d = new Date(normalized)
50
+ return isNaN(d.getTime()) ? null : d.getTime()
51
+ }
52
+
53
+ export function useRangeSelection(options: {
54
+ controller: Ref<ChartController | null>
55
+ activeToolId: Ref<string>
56
+ containerRef: Ref<HTMLElement | null>
57
+ dataVersion: Ref<number>
58
+ viewportVersion: Ref<number>
59
+ dataFetcher: Ref<DataFetcher | null>
60
+ batchStockCodes: Ref<string[]>
61
+ }) {
62
+ const { controller, activeToolId, containerRef, dataVersion, viewportVersion, dataFetcher, batchStockCodes } = options
63
+
64
+ const containerScrollLeft = ref(0)
65
+ const customStartDate = ref('')
66
+ const customEndDate = ref('')
67
+ const resizeSide = ref<'left' | 'right' | null>(null)
68
+ const exportingProgress = ref<{ current: number; total: number; label: string } | null>(null)
69
+
70
+ const rangeSelection = ref<RangeSelectionState>({
71
+ startTimestamp: null,
72
+ endTimestamp: null,
73
+ isDragging: false,
74
+ })
75
+
76
+ const isRangeSelectActive = computed(() => activeToolId.value === 'range-select')
77
+
78
+ const rangeSelectionReady = computed(
79
+ () =>
80
+ rangeSelection.value.startTimestamp !== null &&
81
+ rangeSelection.value.endTimestamp !== null,
82
+ )
83
+
84
+ const rangeSelectionBounds: ComputedRef<Bounds | null> = computed(() => {
85
+ void dataVersion.value
86
+ const data = controller.value?.getData() ?? []
87
+ const { startTimestamp, endTimestamp } = rangeSelection.value
88
+ if (startTimestamp === null || endTimestamp === null || data.length === 0) return null
89
+
90
+ const rawStart = findNearestKLineIndex(data, startTimestamp, 'left')
91
+ const rawEnd = findNearestKLineIndex(data, endTimestamp, 'right')
92
+ if (rawStart === null || rawEnd === null) return null
93
+
94
+ return { start: Math.min(rawStart, rawEnd), end: Math.max(rawStart, rawEnd) }
95
+ })
96
+
97
+ const rangeSelectionStartLabel: ComputedRef<string> = computed(() => {
98
+ const bounds = rangeSelectionBounds.value
99
+ const data = controller.value?.getData() ?? []
100
+ if (!bounds || data.length === 0) return ''
101
+ return fmtDate(data[bounds.start])
102
+ })
103
+
104
+ const rangeSelectionEndLabel: ComputedRef<string> = computed(() => {
105
+ const bounds = rangeSelectionBounds.value
106
+ const data = controller.value?.getData() ?? []
107
+ if (!bounds || data.length === 0) return ''
108
+ return fmtDate(data[bounds.end])
109
+ })
110
+
111
+ const rangeSelectionOverlayStyle = computed(() => {
112
+ const bounds = rangeSelectionBounds.value
113
+ if (!bounds) return null
114
+
115
+ void containerScrollLeft.value
116
+ void viewportVersion.value
117
+
118
+ const ctrl = controller.value
119
+ const viewport = ctrl?.getViewport()
120
+ const container = containerRef.value
121
+ if (!ctrl || !viewport || !container) return null
122
+
123
+ const px = calcRangeOverlayPixel(bounds, ctrl, container, viewport)
124
+ return {
125
+ left: `${px.left}px`,
126
+ width: `${px.width}px`,
127
+ height: `${px.height}px`,
128
+ }
129
+ })
130
+
131
+ function clearRangeSelection() {
132
+ rangeSelection.value = { startTimestamp: null, endTimestamp: null, isDragging: false }
133
+ customStartDate.value = ''
134
+ customEndDate.value = ''
135
+ }
136
+
137
+ watch(customStartDate, (val) => {
138
+ const data = controller.value?.getData() ?? []
139
+ const targetTs = parseDateToTimestamp(val)
140
+ if (targetTs === null || data.length === 0) return
141
+ rangeSelection.value = { ...rangeSelection.value, startTimestamp: targetTs, isDragging: false }
142
+ if (targetTs < data[0]!.timestamp) {
143
+ controller.value?.ensureDataRange(targetTs)
144
+ }
145
+ })
146
+
147
+ watch(customEndDate, (val) => {
148
+ const data = controller.value?.getData() ?? []
149
+ const targetTs = parseDateToTimestamp(val)
150
+ if (targetTs === null || data.length === 0) return
151
+ rangeSelection.value = { ...rangeSelection.value, endTimestamp: targetTs, isDragging: false }
152
+ if (targetTs < data[0]!.timestamp) {
153
+ controller.value?.ensureDataRange(targetTs)
154
+ }
155
+ })
156
+
157
+ function getRangeSelectionIndex(e: PointerEvent, container: HTMLElement): number | null {
158
+ const data = controller.value?.getData() ?? []
159
+ if (data.length === 0) return null
160
+
161
+ const rect = container.getBoundingClientRect()
162
+ const rawIndex = controller.value?.getLogicalIndexAtX(e.clientX - rect.left)
163
+ if (rawIndex === null || rawIndex === undefined) return null
164
+ return Math.max(0, Math.min(rawIndex, data.length - 1))
165
+ }
166
+
167
+ function handleRangePointerDown(e: PointerEvent, container: HTMLElement): boolean {
168
+ if (!isRangeSelectActive.value) return false
169
+ if (
170
+ rangeSelection.value.startTimestamp !== null &&
171
+ rangeSelection.value.endTimestamp !== null &&
172
+ !rangeSelection.value.isDragging
173
+ ) {
174
+ return false
175
+ }
176
+ const index = getRangeSelectionIndex(e, container)
177
+ if (index === null) return true
178
+
179
+ const data = controller.value?.getData() ?? []
180
+ const ts = data[index]?.timestamp
181
+ if (ts === undefined) return true
182
+
183
+ rangeSelection.value = { startTimestamp: ts, endTimestamp: ts, isDragging: true }
184
+ customStartDate.value = ''
185
+ customEndDate.value = ''
186
+ container.setPointerCapture?.(e.pointerId)
187
+ e.preventDefault()
188
+ return true
189
+ }
190
+
191
+ function handleRangePointerMove(e: PointerEvent, container: HTMLElement): boolean {
192
+ if (!isRangeSelectActive.value || !rangeSelection.value.isDragging) return false
193
+ const index = getRangeSelectionIndex(e, container)
194
+ if (index !== null) {
195
+ const data = controller.value?.getData() ?? []
196
+ const ts = data[index]?.timestamp
197
+ if (ts !== undefined) {
198
+ rangeSelection.value = { ...rangeSelection.value, endTimestamp: ts }
199
+ }
200
+ }
201
+ e.preventDefault()
202
+ return true
203
+ }
204
+
205
+ function handleRangePointerUp(e: PointerEvent, container: HTMLElement): boolean {
206
+ if (!isRangeSelectActive.value || !rangeSelection.value.isDragging) return false
207
+ const index = getRangeSelectionIndex(e, container)
208
+ if (index !== null) {
209
+ const data = controller.value?.getData() ?? []
210
+ const ts = data[index]?.timestamp
211
+ if (ts !== undefined) {
212
+ rangeSelection.value = {
213
+ ...rangeSelection.value,
214
+ endTimestamp: ts,
215
+ isDragging: false,
216
+ }
217
+ } else {
218
+ rangeSelection.value = { ...rangeSelection.value, isDragging: false }
219
+ }
220
+ } else {
221
+ rangeSelection.value = { ...rangeSelection.value, isDragging: false }
222
+ }
223
+ container.releasePointerCapture?.(e.pointerId)
224
+ e.preventDefault()
225
+ return true
226
+ }
227
+
228
+ function onEdgePointerDown(side: 'left' | 'right', e: PointerEvent) {
229
+ if (!isRangeSelectActive.value) return
230
+ resizeSide.value = side
231
+ const el = e.currentTarget as HTMLElement
232
+ el.setPointerCapture?.(e.pointerId)
233
+ e.preventDefault()
234
+ }
235
+
236
+ function onEdgePointerMove(e: PointerEvent) {
237
+ if (
238
+ !resizeSide.value ||
239
+ rangeSelection.value.startTimestamp === null ||
240
+ rangeSelection.value.endTimestamp === null
241
+ )
242
+ return
243
+ const rect = containerRef.value?.getBoundingClientRect()
244
+ if (!rect) return
245
+ const data = controller.value?.getData() ?? []
246
+ if (!data.length) return
247
+ const rawIndex = controller.value?.getLogicalIndexAtX(e.clientX - rect.left)
248
+ if (rawIndex === null || rawIndex === undefined) return
249
+ const index = Math.max(0, Math.min(rawIndex, data.length - 1))
250
+ const ts = data[index]?.timestamp
251
+ if (ts === undefined) return
252
+
253
+ if (resizeSide.value === 'left') {
254
+ if (ts > rangeSelection.value.endTimestamp) {
255
+ rangeSelection.value = {
256
+ startTimestamp: rangeSelection.value.endTimestamp,
257
+ endTimestamp: ts,
258
+ isDragging: false,
259
+ }
260
+ resizeSide.value = 'right'
261
+ } else {
262
+ rangeSelection.value = { ...rangeSelection.value, startTimestamp: ts }
263
+ }
264
+ } else {
265
+ if (ts < rangeSelection.value.startTimestamp) {
266
+ rangeSelection.value = {
267
+ startTimestamp: ts,
268
+ endTimestamp: rangeSelection.value.startTimestamp,
269
+ isDragging: false,
270
+ }
271
+ resizeSide.value = 'left'
272
+ } else {
273
+ rangeSelection.value = { ...rangeSelection.value, endTimestamp: ts }
274
+ }
275
+ }
276
+ }
277
+
278
+ function onEdgePointerUp(e: PointerEvent) {
279
+ if (!resizeSide.value) return
280
+ const el = e.currentTarget as HTMLElement
281
+ el.releasePointerCapture?.(e.pointerId)
282
+ resizeSide.value = null
283
+ }
284
+
285
+ const CSV_FIELDS: Array<keyof KLineData> = [
286
+ 'timestamp',
287
+ 'open',
288
+ 'high',
289
+ 'low',
290
+ 'close',
291
+ 'volume',
292
+ 'turnover',
293
+ 'turnoverRate',
294
+ 'amplitude',
295
+ 'changePercent',
296
+ 'changeAmount',
297
+ ]
298
+
299
+ function downloadCsv(
300
+ items: ReadonlyArray<KLineData>,
301
+ prefix: string,
302
+ startTs: number,
303
+ endTs: number,
304
+ ) {
305
+ const header = `stockCode,time,${CSV_FIELDS.join(',')}`
306
+ const rows = [
307
+ header,
308
+ ...items.map((item) => {
309
+ const timeStr = toCsvCell(formatTimestamp(item.timestamp, { showTime: true }))
310
+ const code = toCsvCell(item.stockCode ?? prefix)
311
+ return `${code},${timeStr},${CSV_FIELDS.map((field) => toCsvCell(item[field])).join(',')}`
312
+ }),
313
+ ]
314
+ const blob = new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' })
315
+ const url = URL.createObjectURL(blob)
316
+ const a = document.createElement('a')
317
+ a.href = url
318
+ a.download = `${prefix}-${toYYYYMMDD(startTs)}-${toYYYYMMDD(endTs)}.csv`
319
+ document.body.appendChild(a)
320
+ a.click()
321
+ document.body.removeChild(a)
322
+ URL.revokeObjectURL(url)
323
+ }
324
+
325
+ async function exportRangeToCsv() {
326
+ const bounds = rangeSelectionBounds.value
327
+ const data = controller.value?.getData() ?? []
328
+ if (!bounds || data.length === 0) return
329
+
330
+ const startTs = data[bounds.start]!.timestamp
331
+ const endTs = data[bounds.end]!.timestamp
332
+ const mainStockCode = controller.value?.symbols.peek()?.[0]?.symbol ?? 'unknown'
333
+ const batchCodes = batchStockCodes.value
334
+ const total = 1 + batchCodes.length
335
+ const prefix = batchCodes.length > 0 ? `batch${total}` : mainStockCode
336
+
337
+ const allItems: KLineData[] = []
338
+
339
+ exportingProgress.value = { current: 0, total, label: '正在准备主品种数据...' }
340
+
341
+ // Main stock
342
+ for (const item of data.slice(bounds.start, bounds.end + 1)) {
343
+ allItems.push(item)
344
+ }
345
+ exportingProgress.value = { current: 1, total, label: '主品种数据已就绪' }
346
+
347
+ // Batch stocks (sequential)
348
+ const fetchFn = dataFetcher.value
349
+ if (fetchFn && batchCodes.length > 0) {
350
+ const spec = controller.value?.symbols.peek()?.[0]
351
+ const startDate = formatTimestamp(startTs)
352
+ const endDate = formatTimestamp(endTs)
353
+ const period = spec?.period ?? 'daily'
354
+ const adjust = spec?.adjust ?? 'none'
355
+ const exchange = spec?.exchange
356
+ const source = spec?.source ?? 'gotdx'
357
+
358
+ for (let i = 0; i < batchCodes.length; i++) {
359
+ const code = batchCodes[i]!
360
+ exportingProgress.value = { current: 1 + i, total, label: `正在获取 ${code}...` }
361
+ try {
362
+ const items = await fetchFn(source, {
363
+ symbol: code,
364
+ startDate,
365
+ endDate,
366
+ period,
367
+ adjust,
368
+ exchange,
369
+ })
370
+ for (const item of items) {
371
+ allItems.push(item)
372
+ }
373
+ } catch {
374
+ continue
375
+ }
376
+ }
377
+ }
378
+
379
+ exportingProgress.value = { current: total, total, label: '正在生成文件...' }
380
+ downloadCsv(allItems, prefix, startTs, endTs)
381
+ exportingProgress.value = { current: total, total, label: '导出完成' }
382
+ }
383
+
384
+ function onScroll() {
385
+ const cont = containerRef.value
386
+ if (cont) containerScrollLeft.value = cont.scrollLeft
387
+ }
388
+
389
+ function syncScrollLeft() {
390
+ const cont = containerRef.value
391
+ if (cont) containerScrollLeft.value = cont.scrollLeft
392
+ }
393
+
394
+ return {
395
+ rangeSelection,
396
+ customStartDate,
397
+ customEndDate,
398
+ containerScrollLeft,
399
+ isRangeSelectActive,
400
+ rangeSelectionReady,
401
+ rangeSelectionBounds,
402
+ rangeSelectionStartLabel,
403
+ rangeSelectionEndLabel,
404
+ rangeSelectionOverlayStyle,
405
+ clearRangeSelection,
406
+ handleRangePointerDown,
407
+ handleRangePointerMove,
408
+ handleRangePointerUp,
409
+ exportRangeToCsv,
410
+ exportingProgress,
411
+ onEdgePointerDown,
412
+ onEdgePointerMove,
413
+ onEdgePointerUp,
414
+ onScroll,
415
+ syncScrollLeft,
416
+ }
417
+ }
@@ -0,0 +1,33 @@
1
+ import { ref, type Ref } from 'vue'
2
+
3
+ export function useTeleportedPopup(
4
+ triggerRef: Ref<HTMLElement | null>,
5
+ popupRef: Ref<HTMLElement | null>,
6
+ gap = 4,
7
+ ) {
8
+ const popupStyle = ref<Record<string, string>>({})
9
+
10
+ function updatePosition() {
11
+ const trigger = triggerRef.value
12
+ if (!trigger) return
13
+ const rect = trigger.getBoundingClientRect()
14
+ popupStyle.value = {
15
+ position: 'fixed',
16
+ top: `${rect.bottom + gap}px`,
17
+ left: `${rect.left}px`,
18
+ }
19
+ }
20
+
21
+ function startPositionSync() {
22
+ updatePosition()
23
+ document.addEventListener('scroll', updatePosition, { capture: true, passive: true })
24
+ window.addEventListener('resize', updatePosition, { passive: true })
25
+ }
26
+
27
+ function stopPositionSync() {
28
+ document.removeEventListener('scroll', updatePosition, { capture: true })
29
+ window.removeEventListener('resize', updatePosition)
30
+ }
31
+
32
+ return { popupStyle, updatePosition, startPositionSync, stopPositionSync }
33
+ }
@@ -0,0 +1,28 @@
1
+ import type { ChartController } from '@363045841yyt/klinechart-core/controllers'
2
+
3
+ export interface Bounds {
4
+ start: number
5
+ end: number
6
+ }
7
+
8
+ export function calcRangeOverlayPixel(
9
+ bounds: Bounds,
10
+ controller: ChartController,
11
+ container: HTMLElement,
12
+ viewport: { scrollLeft: number; plotWidth: number; plotHeight: number },
13
+ ): { left: number; width: number; height: number } {
14
+ const { kWidth: currentKWidth, kGap: currentKGap } = controller.getKWidthKGap()
15
+ const dpr = controller.getCurrentDpr()
16
+ const kWidthPx = Math.max(
17
+ 1,
18
+ Math.round(currentKWidth * dpr) + (Math.round(currentKWidth * dpr) % 2 === 0 ? 1 : 0),
19
+ )
20
+ const kGapPx = Math.round(currentKGap * dpr)
21
+ const unitPx = kWidthPx + kGapPx
22
+ const startXPx = kGapPx
23
+
24
+ const leftBuffer = container.scrollLeft - viewport.scrollLeft
25
+ const left = leftBuffer + (startXPx + bounds.start * unitPx) / dpr
26
+ const right = leftBuffer + (startXPx + bounds.end * unitPx + kWidthPx) / dpr
27
+ return { left, width: right - left, height: viewport.plotHeight }
28
+ }
@@ -0,0 +1,40 @@
1
+ import type { KLineData } from '@363045841yyt/klinechart-core/controllers'
2
+
3
+ function binarySearch(
4
+ data: ReadonlyArray<KLineData>,
5
+ timestamp: number,
6
+ ): { low: number; high: number } {
7
+ let low = 0
8
+ let high = data.length - 1
9
+ while (low <= high) {
10
+ const mid = (low + high) >>> 1
11
+ const ts = data[mid]!.timestamp
12
+ if (ts === timestamp) return { low: mid, high: mid }
13
+ if (ts < timestamp) low = mid + 1
14
+ else high = mid - 1
15
+ }
16
+ return { low, high }
17
+ }
18
+
19
+ export function getKLineIndexByTimestamp(
20
+ data: ReadonlyArray<KLineData>,
21
+ timestamp: number,
22
+ ): number | null {
23
+ if (data.length === 0) return null
24
+ const { low, high } = binarySearch(data, timestamp)
25
+ return low === high ? low : null
26
+ }
27
+
28
+ export function findNearestKLineIndex(
29
+ data: ReadonlyArray<KLineData>,
30
+ timestamp: number,
31
+ direction: 'left' | 'right',
32
+ ): number | null {
33
+ if (data.length === 0) return null
34
+ const { low, high } = binarySearch(data, timestamp)
35
+ if (low === high) return low
36
+ if (direction === 'left') {
37
+ return high >= 0 ? high : (low < data.length ? low : null)
38
+ }
39
+ return low < data.length ? low : high
40
+ }