1const DIRECTION_TYPE = {
2  FRONT: 'FRONT', // scroll up or left
3  BEHIND: 'BEHIND' // scroll down or right
4}
5const CALC_TYPE = {
6  INIT: 'INIT',
7  FIXED: 'FIXED',
8  DYNAMIC: 'DYNAMIC'
9}
10const LEADING_BUFFER = 2
11
12export default class Virtual {
13  constructor(param, callUpdate) {
14    this.init(param, callUpdate)
15  }
16
17  init(param, callUpdate) {
18    // param data
19    this.param = param
20    this.callUpdate = callUpdate
21
22    // size data
23    this.sizes = new Map()
24    this.firstRangeTotalSize = 0
25    this.firstRangeAverageSize = 0
26    this.lastCalcIndex = 0
27    this.fixedSizeValue = 0
28    this.calcType = CALC_TYPE.INIT
29
30    // scroll data
31    this.offset = 0
32    this.direction = ''
33
34    // range data
35    this.range = Object.create(null)
36    if (param) {
37      this.checkRange(0, param.keeps - 1)
38    }
39
40    // benchmark test data
41    // this.__bsearchCalls = 0
42    // this.__getIndexOffsetCalls = 0
43  }
44
45  destroy() {
46    this.init(null, null)
47  }
48
49  // return current render range
50  getRange() {
51    const range = Object.create(null)
52    range.start = this.range.start
53    range.end = this.range.end
54    range.padFront = this.range.padFront
55    range.padBehind = this.range.padBehind
56    return range
57  }
58
59  isBehind() {
60    return this.direction === DIRECTION_TYPE.BEHIND
61  }
62
63  isFront() {
64    return this.direction === DIRECTION_TYPE.FRONT
65  }
66
67  // return start index offset
68  getOffset(start) {
69    return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize
70  }
71
72  updateParam(key, value) {
73    if (this.param && (key in this.param)) {
74      // if uniqueIds change, find out deleted id and remove from size map
75      if (key === 'uniqueIds') {
76        this.sizes.forEach((v, key) => {
77          if (!value.includes(key)) {
78            this.sizes.delete(key)
79          }
80        })
81      }
82      this.param[key] = value
83    }
84  }
85
86  // save each size map by id
87  saveSize(id, size) {
88    this.sizes.set(id, size)
89
90    // we assume size type is fixed at the beginning and remember first size value
91    // if there is no size value different from this at next coming saving
92    // we think it's a fixed size list, otherwise is dynamic size list
93    if (this.calcType === CALC_TYPE.INIT) {
94      this.fixedSizeValue = size
95      this.calcType = CALC_TYPE.FIXED
96    } else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) {
97      this.calcType = CALC_TYPE.DYNAMIC
98      // it's no use at all
99      delete this.fixedSizeValue
100    }
101
102    // calculate the average size only in the first range
103    if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== 'undefined') {
104      if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) {
105        this.firstRangeTotalSize = this.firstRangeTotalSize + size
106        this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size)
107      } else {
108        // it's done using
109        delete this.firstRangeTotalSize
110      }
111    }
112  }
113
114  // in some special situation (e.g. length change) we need to update in a row
115  // try going to render next range by a leading buffer according to current direction
116  handleDataSourcesChange() {
117    let start = this.range.start
118
119    if (this.isFront()) {
120      start = start - LEADING_BUFFER
121    } else if (this.isBehind()) {
122      start = start + LEADING_BUFFER
123    }
124
125    start = Math.max(start, 0)
126
127    this.updateRange(this.range.start, this.getEndByStart(start))
128  }
129
130  // when slot size change, we also need force update
131  handleSlotSizeChange() {
132    this.handleDataSourcesChange()
133  }
134
135  // calculating range on scroll
136  handleScroll(offset) {
137    this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND
138    this.offset = offset
139
140    if (this.direction === DIRECTION_TYPE.FRONT) {
141      this.handleFront()
142    } else if (this.direction === DIRECTION_TYPE.BEHIND) {
143      this.handleBehind()
144    }
145  }
146
147  // ----------- public method end -----------
148
149  handleFront() {
150    const overs = this.getScrollOvers()
151    // should not change range if start doesn't exceed overs
152    if (overs > this.range.start) {
153      return
154    }
155
156    // move up start by a buffer length, and make sure its safety
157    const start = Math.max(overs - this.param.buffer, 0)
158    this.checkRange(start, this.getEndByStart(start))
159  }
160
161  handleBehind() {
162    const overs = this.getScrollOvers()
163    // range should not change if scroll overs within buffer
164    if (overs < this.range.start + this.param.buffer) {
165      return
166    }
167
168    this.checkRange(overs, this.getEndByStart(overs))
169  }
170
171  // return the pass overs according to current scroll offset
172  getScrollOvers() {
173    // if slot header exist, we need subtract its size
174    const offset = this.offset - this.param.slotHeaderSize
175    if (offset <= 0) {
176      return 0
177    }
178
179    // if is fixed type, that can be easily
180    if (this.isFixedType()) {
181      return Math.floor(offset / this.fixedSizeValue)
182    }
183
184    let low = 0
185    let middle = 0
186    let middleOffset = 0
187    let high = this.param.uniqueIds.length
188
189    while (low <= high) {
190      // this.__bsearchCalls++
191      middle = low + Math.floor((high - low) / 2)
192      middleOffset = this.getIndexOffset(middle)
193
194      if (middleOffset === offset) {
195        return middle
196      } else if (middleOffset < offset) {
197        low = middle + 1
198      } else if (middleOffset > offset) {
199        high = middle - 1
200      }
201    }
202
203    return low > 0 ? --low : 0
204  }
205
206  // return a scroll offset from given index, can efficiency be improved more here?
207  // although the call frequency is very high, its only a superposition of numbers
208  getIndexOffset(givenIndex) {
209    if (!givenIndex) {
210      return 0
211    }
212
213    let offset = 0
214    let indexSize = 0
215    for (let index = 0; index < givenIndex; index++) {
216      // this.__getIndexOffsetCalls++
217      indexSize = this.sizes.get(this.param.uniqueIds[index])
218      offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize())
219    }
220
221    // remember last calculate index
222    this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1)
223    this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex())
224
225    return offset
226  }
227
228  // is fixed size type
229  isFixedType() {
230    return this.calcType === CALC_TYPE.FIXED
231  }
232
233  // return the real last index
234  getLastIndex() {
235    return this.param.uniqueIds.length - 1
236  }
237
238  // in some conditions range is broke, we need correct it
239  // and then decide whether need update to next range
240  checkRange(start, end) {
241    const keeps = this.param.keeps
242    const total = this.param.uniqueIds.length
243
244    // datas less than keeps, render all
245    if (total <= keeps) {
246      start = 0
247      end = this.getLastIndex()
248    } else if (end - start < keeps - 1) {
249      // if range length is less than keeps, current it base on end
250      start = end - keeps + 1
251    }
252
253    if (this.range.start !== start) {
254      this.updateRange(start, end)
255    }
256  }
257
258  // setting to a new range and re-render
259  updateRange(start, end) {
260    this.range.start = start
261    this.range.end = end
262    this.range.padFront = this.getPadFront()
263    this.range.padBehind = this.getPadBehind()
264    this.callUpdate(this.getRange())
265  }
266
267  // return end base on start
268  getEndByStart(start) {
269    const theoryEnd = start + this.param.keeps - 1
270    const trulyEnd = Math.min(theoryEnd, this.getLastIndex())
271    return trulyEnd
272  }
273
274  // return total front offset
275  getPadFront() {
276    if (this.isFixedType()) {
277      return this.fixedSizeValue * this.range.start
278    } else {
279      return this.getIndexOffset(this.range.start)
280    }
281  }
282
283  // return total behind offset
284  getPadBehind() {
285    const end = this.range.end
286    const lastIndex = this.getLastIndex()
287
288    if (this.isFixedType()) {
289      return (lastIndex - end) * this.fixedSizeValue
290    }
291
292    // if it's all calculated, return the exactly offset
293    if (this.lastCalcIndex === lastIndex) {
294      return this.getIndexOffset(lastIndex) - this.getIndexOffset(end)
295    } else {
296      // if not, use a estimated value
297      return (lastIndex - end) * this.getEstimateSize()
298    }
299  }
300
301  // get the item estimate size
302  getEstimateSize() {
303    return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize)
304  }
305}