| const DIRECTION_TYPE = { |
| FRONT: 'FRONT', // scroll up or left |
| BEHIND: 'BEHIND' // scroll down or right |
| } |
| const CALC_TYPE = { |
| INIT: 'INIT', |
| FIXED: 'FIXED', |
| DYNAMIC: 'DYNAMIC' |
| } |
| const LEADING_BUFFER = 2 |
| |
| export default class Virtual { |
| constructor(param, callUpdate) { |
| this.init(param, callUpdate) |
| } |
| |
| init(param, callUpdate) { |
| // param data |
| this.param = param |
| this.callUpdate = callUpdate |
| |
| // size data |
| this.sizes = new Map() |
| this.firstRangeTotalSize = 0 |
| this.firstRangeAverageSize = 0 |
| this.lastCalcIndex = 0 |
| this.fixedSizeValue = 0 |
| this.calcType = CALC_TYPE.INIT |
| |
| // scroll data |
| this.offset = 0 |
| this.direction = '' |
| |
| // range data |
| this.range = Object.create(null) |
| if (param) { |
| this.checkRange(0, param.keeps - 1) |
| } |
| |
| // benchmark test data |
| // this.__bsearchCalls = 0 |
| // this.__getIndexOffsetCalls = 0 |
| } |
| |
| destroy() { |
| this.init(null, null) |
| } |
| |
| // return current render range |
| getRange() { |
| const range = Object.create(null) |
| range.start = this.range.start |
| range.end = this.range.end |
| range.padFront = this.range.padFront |
| range.padBehind = this.range.padBehind |
| return range |
| } |
| |
| isBehind() { |
| return this.direction === DIRECTION_TYPE.BEHIND |
| } |
| |
| isFront() { |
| return this.direction === DIRECTION_TYPE.FRONT |
| } |
| |
| // return start index offset |
| getOffset(start) { |
| return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize |
| } |
| |
| updateParam(key, value) { |
| if (this.param && (key in this.param)) { |
| // if uniqueIds change, find out deleted id and remove from size map |
| if (key === 'uniqueIds') { |
| this.sizes.forEach((v, key) => { |
| if (!value.includes(key)) { |
| this.sizes.delete(key) |
| } |
| }) |
| } |
| this.param[key] = value |
| } |
| } |
| |
| // save each size map by id |
| saveSize(id, size) { |
| this.sizes.set(id, size) |
| |
| // we assume size type is fixed at the beginning and remember first size value |
| // if there is no size value different from this at next coming saving |
| // we think it's a fixed size list, otherwise is dynamic size list |
| if (this.calcType === CALC_TYPE.INIT) { |
| this.fixedSizeValue = size |
| this.calcType = CALC_TYPE.FIXED |
| } else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) { |
| this.calcType = CALC_TYPE.DYNAMIC |
| // it's no use at all |
| delete this.fixedSizeValue |
| } |
| |
| // calculate the average size only in the first range |
| if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== 'undefined') { |
| if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) { |
| this.firstRangeTotalSize = this.firstRangeTotalSize + size |
| this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size) |
| } else { |
| // it's done using |
| delete this.firstRangeTotalSize |
| } |
| } |
| } |
| |
| // in some special situation (e.g. length change) we need to update in a row |
| // try going to render next range by a leading buffer according to current direction |
| handleDataSourcesChange() { |
| let start = this.range.start |
| |
| if (this.isFront()) { |
| start = start - LEADING_BUFFER |
| } else if (this.isBehind()) { |
| start = start + LEADING_BUFFER |
| } |
| |
| start = Math.max(start, 0) |
| |
| this.updateRange(this.range.start, this.getEndByStart(start)) |
| } |
| |
| // when slot size change, we also need force update |
| handleSlotSizeChange() { |
| this.handleDataSourcesChange() |
| } |
| |
| // calculating range on scroll |
| handleScroll(offset) { |
| this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND |
| this.offset = offset |
| |
| if (this.direction === DIRECTION_TYPE.FRONT) { |
| this.handleFront() |
| } else if (this.direction === DIRECTION_TYPE.BEHIND) { |
| this.handleBehind() |
| } |
| } |
| |
| // ----------- public method end ----------- |
| |
| handleFront() { |
| const overs = this.getScrollOvers() |
| // should not change range if start doesn't exceed overs |
| if (overs > this.range.start) { |
| return |
| } |
| |
| // move up start by a buffer length, and make sure its safety |
| const start = Math.max(overs - this.param.buffer, 0) |
| this.checkRange(start, this.getEndByStart(start)) |
| } |
| |
| handleBehind() { |
| const overs = this.getScrollOvers() |
| // range should not change if scroll overs within buffer |
| if (overs < this.range.start + this.param.buffer) { |
| return |
| } |
| |
| this.checkRange(overs, this.getEndByStart(overs)) |
| } |
| |
| // return the pass overs according to current scroll offset |
| getScrollOvers() { |
| // if slot header exist, we need subtract its size |
| const offset = this.offset - this.param.slotHeaderSize |
| if (offset <= 0) { |
| return 0 |
| } |
| |
| // if is fixed type, that can be easily |
| if (this.isFixedType()) { |
| return Math.floor(offset / this.fixedSizeValue) |
| } |
| |
| let low = 0 |
| let middle = 0 |
| let middleOffset = 0 |
| let high = this.param.uniqueIds.length |
| |
| while (low <= high) { |
| // this.__bsearchCalls++ |
| middle = low + Math.floor((high - low) / 2) |
| middleOffset = this.getIndexOffset(middle) |
| |
| if (middleOffset === offset) { |
| return middle |
| } else if (middleOffset < offset) { |
| low = middle + 1 |
| } else if (middleOffset > offset) { |
| high = middle - 1 |
| } |
| } |
| |
| return low > 0 ? --low : 0 |
| } |
| |
| // return a scroll offset from given index, can efficiency be improved more here? |
| // although the call frequency is very high, its only a superposition of numbers |
| getIndexOffset(givenIndex) { |
| if (!givenIndex) { |
| return 0 |
| } |
| |
| let offset = 0 |
| let indexSize = 0 |
| for (let index = 0; index < givenIndex; index++) { |
| // this.__getIndexOffsetCalls++ |
| indexSize = this.sizes.get(this.param.uniqueIds[index]) |
| offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize()) |
| } |
| |
| // remember last calculate index |
| this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1) |
| this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex()) |
| |
| return offset |
| } |
| |
| // is fixed size type |
| isFixedType() { |
| return this.calcType === CALC_TYPE.FIXED |
| } |
| |
| // return the real last index |
| getLastIndex() { |
| return this.param.uniqueIds.length - 1 |
| } |
| |
| // in some conditions range is broke, we need correct it |
| // and then decide whether need update to next range |
| checkRange(start, end) { |
| const keeps = this.param.keeps |
| const total = this.param.uniqueIds.length |
| |
| // datas less than keeps, render all |
| if (total <= keeps) { |
| start = 0 |
| end = this.getLastIndex() |
| } else if (end - start < keeps - 1) { |
| // if range length is less than keeps, current it base on end |
| start = end - keeps + 1 |
| } |
| |
| if (this.range.start !== start) { |
| this.updateRange(start, end) |
| } |
| } |
| |
| // setting to a new range and re-render |
| updateRange(start, end) { |
| this.range.start = start |
| this.range.end = end |
| this.range.padFront = this.getPadFront() |
| this.range.padBehind = this.getPadBehind() |
| this.callUpdate(this.getRange()) |
| } |
| |
| // return end base on start |
| getEndByStart(start) { |
| const theoryEnd = start + this.param.keeps - 1 |
| const trulyEnd = Math.min(theoryEnd, this.getLastIndex()) |
| return trulyEnd |
| } |
| |
| // return total front offset |
| getPadFront() { |
| if (this.isFixedType()) { |
| return this.fixedSizeValue * this.range.start |
| } else { |
| return this.getIndexOffset(this.range.start) |
| } |
| } |
| |
| // return total behind offset |
| getPadBehind() { |
| const end = this.range.end |
| const lastIndex = this.getLastIndex() |
| |
| if (this.isFixedType()) { |
| return (lastIndex - end) * this.fixedSizeValue |
| } |
| |
| // if it's all calculated, return the exactly offset |
| if (this.lastCalcIndex === lastIndex) { |
| return this.getIndexOffset(lastIndex) - this.getIndexOffset(end) |
| } else { |
| // if not, use a estimated value |
| return (lastIndex - end) * this.getEstimateSize() |
| } |
| } |
| |
| // get the item estimate size |
| getEstimateSize() { |
| return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize) |
| } |
| } |