UNPKG

@gmod/jbrowse

Version:

JBrowse - client-side genome browser

397 lines (335 loc) 14.1 kB
/** * Rectangle-layout manager that lays out rectangles using bitmaps at * resolution that, for efficiency, may be somewhat lower than that of * the coordinate system for the rectangles being laid out. `pitchX` * and `pitchY` are the ratios of input scale resolution to internal * bitmap resolution. */ // minimum excess size of the array at which we garbage collect const minSizeToBotherWith = 10000 const maxFeaturePitchWidth = 20000 // a single row in the layout class LayoutRow { constructor(rowNumber) { this.rowNumber = rowNumber this.padding = 1 this.sizeLimit = 1000000 // this.offset is the offset of the bits array relative to the genomic coordinates // (modified by pitchX, but we don't know that in this class) // this.bits is the array of items in the layout row, indexed by (x - this.offset) // this.min is the leftmost edge of all the rectangles we have in the layout // this.max is the rightmost edge of all the rectangles we have in the layout } log(msg) { //if (this.rowNumber === 0) console.log(`r${this.rowNumber}: ${msg}`) } setAllFilled(data) { this.allFilled = data } getItemAt(x) { if (this.allFilled) return this.allFilled // return ( // this.min !== undefined && // x >= this.min && // x <= this.max && // this.bits[x - this.min] // ) if (this.min === undefined) return undefined if (x < this.min) return undefined if (x >= this.max) return undefined const offset = x-this.offset // if (offset < 0) // debugger // if (offset >= this.bits.length) // debugger return this.bits[offset] } isRangeClear(left, right) { if (this.allFilled) return false if (this.min === undefined) return true if (right <= this.min || left >= this.max) return true // TODO: check right and middle before looping const maxX = Math.min(this.max, right) let x = Math.max(this.min, left) for (; x < right && x < maxX; x += 1) if (this.getItemAt(x)) return false return true } initialize(left,right) { // NOTE: this.min, this.max, and this.offset are interbase coordinates const rectWidth = right - left this.offset = left - rectWidth this.min = left this.max = right this.bits = new Array(right - left + 2 * rectWidth) // this.log(`initialize ${this.min} - ${this.max} (${this.bits.length})`) } addRect(rect, data) { const left = rect.l const right = rect.r + this.padding // only padding on the right // initialize if necessary if (this.min === undefined) { this.initialize(left, right) } else { // or check if we need to expand to the left and/or to the right // expand rightward by the feature length + whole current length if necessary const currLength = this.bits.length if (right - this.offset >= this.bits.length ) { const additionalLength = right - this.offset - this.bits.length + 1 + this.bits.length if (this.bits.length+additionalLength > this.sizeLimit) { console.warn(`Layout width limit exceeded, discarding old layout. Please be more careful about discarding unused blocks.`) this.initialize(left, right) } else if (additionalLength > 0) { this.bits = this.bits.concat(new Array(additionalLength)) // this.log(`expand right (${additionalLength}): ${this.offset} | ${this.min} - ${this.max}`) } } // expand by 2x leftward if necessary if (left < this.offset) { const additionalLength = this.offset - left + currLength if (this.bits.length+additionalLength > this.sizeLimit) { console.warn(`Layout width limit exceeded, discarding old layout. Please be more careful about discarding unused blocks.`) this.initialize(left, right) } else { this.bits = (new Array(additionalLength)).concat(this.bits) this.offset -= additionalLength // this.log(`expand left (${additionalLength}): ${this.offset} | ${this.min} - ${this.max}`) } } } // set the bits in the bitmask const oLeft = left - this.offset const oRight = right - this.offset // if (oLeft < 0) debugger // if (oRight < 0) debugger // if (oRight <= oLeft) debugger // if (oRight > this.bits.length) debugger if ((oRight - oLeft) > maxFeaturePitchWidth) { console.warn(`Layout X pitch set too low, feature spans ${oRight - oLeft} bits in a single row.`, rect, data) } for (let x = oLeft; x < oRight; x += 1) { //if (this.bits[x] && this.bits[x].get('name') !== data.get('name')) debugger this.bits[x] = data } if (left < this.min) this.min = left if (right > this.max) this.max = right //// this.log(`added ${leftX} - ${rightX}`) } /** * Given a range of interbase coordinates, deletes all data dealing with that range */ discardRange(left, right) { if (this.allFilled) return // allFilled is irrevocable currently // if we have no data, do nothing if (!this.bits) return // if doesn't overlap at all, do nothing if (right <= this.min || left >= this.max) return // if completely encloses range, discard everything if (left <= this.min && right >= this.max) { this.min = undefined this.max = undefined this.bits = undefined this.offset = undefined return } // if overlaps left edge, adjust the min if (right > this.min && left <= this.min) { this.min = right } // if overlaps right edge, adjust the max if (left < this.max && right >= this.max) { this.max = left } // now trim the left, right, or both sides of the array if (this.offset < (this.min - minSizeToBotherWith) && this.bits.length > (this.max+minSizeToBotherWith-this.offset)) { // trim both sides const leftTrimAmount = this.min - this.offset const rightTrimAmount = this.bits.length - 1 - (this.max - this.offset) // if (rightTrimAmount <= 0) debugger // if (leftTrimAmount <= 0) debugger // this.log(`trim both sides, ${leftTrimAmount} from left, ${rightTrimAmount} from right`) this.bits = this.bits.slice(leftTrimAmount, this.bits.length - rightTrimAmount) this.offset += leftTrimAmount // if (this.offset > this.min) debugger // if (this.bits.length <= this.max - this.offset) debugger } else if (this.offset < this.min - minSizeToBotherWith) { // trim left side const desiredOffset = this.min - Math.floor(minSizeToBotherWith/2) const trimAmount = desiredOffset - this.offset // this.log(`trim left side by ${trimAmount}`) this.bits.splice(0, trimAmount) this.offset += trimAmount // if (this.offset > this.min) debugger // if (this.bits.length <= this.max - this.offset) debugger } else if (this.bits.length > (this.max-this.offset+minSizeToBotherWith)) { // trim right side const desiredLength = this.max - this.offset + 1 + Math.floor(minSizeToBotherWith/2) // this.log(`trim right side by ${this.bits.length-desiredLength}`) // if (desiredLength > this.bits.length) debugger this.bits.length = desiredLength // if (this.offset > this.min) debugger // if (this.bits.length <= this.max - this.offset) debugger } // if (this.offset > this.min) debugger // if (this.bits.length <= this.max - this.offset) debugger // if range now enclosed in the new bounds, loop through and clear the bits const oLeft = Math.max(this.min, left) - this.offset // if (oLeft < 0) debugger // if (oLeft >= this.bits.length) debugger // if (oRight < 0) debugger // if (oRight >= this.bits.length) debugger const oRight = Math.min(right,this.max) - this.offset for (let x = oLeft; x >= 0 && x < oRight; x += 1) { this.bits[x] = undefined } } } define(['dojo/_base/declare'], declare => declare(null, { /** * @param args.pitchX layout grid pitch in the X direction * @param args.pitchY layout grid pitch in the Y direction * @param args.maxHeight maximum layout height, default Infinity (no max) */ constructor(args) { this.pitchX = args.pitchX || 10 this.pitchY = args.pitchY || 10 this.displayMode = args.displayMode // reduce the pitchY to try and pack the features tighter if (this.displayMode === 'compact') { this.pitchY = Math.round(this.pitchY / 4) || 1 this.pitchX = Math.round(this.pitchX / 4) || 1 } // console.log(`pitch: ${this.pitchX} / ${this.pitchY}`) this.bitmap = [] this.rectangles = {} this.maxHeight = Math.ceil((args.maxHeight || Infinity) / this.pitchY) this.pTotalHeight = 0 // total height, in units of bitmap squares (px/pitchY) }, /** * @returns {Number} top position for the rect, or Null if laying out the rect would exceed maxHeight */ addRect(id, left, right, height, data) { // if we have already laid it out, return its layout if (id in this.rectangles) { const storedRec = this.rectangles[id] if (storedRec.top === null) return null // add it to the bitmap again, since that bitmap range may have been discarded this._addRectToBitmap(storedRec, data) return storedRec.top * this.pitchY } const pLeft = Math.floor(left / this.pitchX) const pRight = Math.floor(right / this.pitchX) const pHeight = Math.ceil(height / this.pitchY) const midX = Math.floor((pLeft + pRight) / 2) const rectangle = { id, l: pLeft, r: pRight, mX: midX, h: pHeight } if (data) rectangle.data = data const maxTop = this.maxHeight - pHeight let top = 0 for (; top <= maxTop; top += 1) { if (!this._collides(rectangle, top)) break } if (top > maxTop) { rectangle.top = top = null this.rectangles[id] = rectangle this.pTotalHeight = Math.max(this.pTotalHeight || 0, top + pHeight) return null } rectangle.top = top this._addRectToBitmap(rectangle, data) this.rectangles[id] = rectangle this.pTotalHeight = Math.max(this.pTotalHeight || 0, top + pHeight) // console.log(`G2 ${data.get('name')} ${top}`) return top * this.pitchY }, _collides(rect, top) { if (this.displayMode === 'collapsed') return false const bitmap = this.bitmap // var mY = top + rect.h/2; // Y midpoint: ( top+height + top ) / 2 // test exhaustively const maxY = top + rect.h for (let y = top; y < maxY; y += 1) { const row = bitmap[y] if (row && !row.isRangeClear(rect.l, rect.r)) { return true } } return false }, /** * make a subarray if it does not exist * @private */ _autovivifyRow(bitmap, y) { let row = bitmap[y] if (!row) { row = new LayoutRow(y) bitmap[y] = row } return row }, _addRectToBitmap(rect, data) { if (rect.top === null) return data = data || true const bitmap = this.bitmap const av = this._autovivifyRow const yEnd = rect.top + rect.h if (rect.r - rect.l > maxFeaturePitchWidth) { // the rect is very big in relation to the view size, just // pretend, for the purposes of layout, that it extends // infinitely. this will cause weird layout if a user // scrolls manually for a very, very long time along the // genome at the same zoom level. but most users will not // do that. hopefully. for (let y = rect.top; y < yEnd; y += 1) { av(bitmap, y).setAllFilled(data) } } else { for (let y = rect.top; y < yEnd; y += 1) { av(bitmap, y).addRect(rect, data) } } }, /** * Given a range of X coordinates, deletes all data dealing with * the features. */ discardRange(left, right) { // console.log( 'discard', left, right ); const pLeft = Math.floor(left / this.pitchX) const pRight = Math.floor(right / this.pitchX) const bitmap = this.bitmap for (let y = 0; y < bitmap.length; y += 1) { const row = bitmap[y] if (row) row.discardRange(pLeft, pRight) } }, hasSeen(id) { return !!this.rectangles[id] }, getByCoord(x, y) { const pY = Math.floor(y / this.pitchY) const row = this.bitmap[pY] if (!row) return undefined const pX = Math.floor(x / this.pitchX) return row.getItemAt(pX) }, getByID(id) { const r = this.rectangles[id] if (r) { return r.data || true } return undefined }, cleanup() {}, getTotalHeight() { return this.pTotalHeight * this.pitchY }, }))