@gmod/jbrowse
Version:
JBrowse - client-side genome browser
397 lines (335 loc) • 14.1 kB
JavaScript
/**
* 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
},
}))