UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

1,230 lines (998 loc) 32.3 kB
// @ts-nocheck import boxIntersect from 'box-intersect'; import { median, range } from 'd3-array'; import { scaleBand, scaleLinear } from 'd3-scale'; import { zoomIdentity } from 'd3-zoom'; import classifyPoint from 'robust-point-in-polygon'; import HorizontalTiled1DPixiTrack from './HorizontalTiled1DPixiTrack'; // Services import { tileProxy } from './services'; // Utils import { colorDomainToRgbaArray, colorToHex, segmentsToRows, trackUtils, valueToColor, } from './utils'; // Configs import { GLOBALS, HEATED_OBJECT_MAP } from './configs'; const GENE_RECT_HEIGHT = 16; const MAX_TEXTS = 50; const MAX_TILE_ENTRIES = 5000; const STAGGERED_OFFSET = 5; const FONT_SIZE = 14; // the label text should have a white outline so that it's more // visible against a similar colored background const TEXT_STYLE = { align: 'center', fontSize: `${FONT_SIZE}px`, fontFamily: 'Arial', stroke: 'white', strokeThickness: 2, fontWeight: 400, dropShadow: true, dropShadowColor: 'white', dropShadowDistance: 0, dropShadowBlur: 2, }; /** Scale a polygon * */ export const polyToPoly = (poly, kx, px, ky, py) => { const newArr = []; while (poly.length) { const [x, y] = poly.splice(0, 2); newArr.push([x * kx + px, y * ky + py]); } return newArr; }; const hashFunc = (s) => { let hash = 0; if (s.length === 0) { return hash; } for (let i = 0; i < s.length; i++) { const char = s.charCodeAt(i); hash = (hash << 5) - hash + char; hash &= hash; // Convert to 32bit integer } return hash; }; const scaleScalableGraphics = (graphics, xScale, drawnAtScale) => { const tileK = (drawnAtScale.domain()[1] - drawnAtScale.domain()[0]) / (xScale.domain()[1] - xScale.domain()[0]); const newRange = xScale.domain().map(drawnAtScale); const posOffset = newRange[0]; graphics.scale.x = tileK; graphics.position.x = -posOffset * tileK; }; export const uniqueify = (elements) => { const byUid = {}; for (let i = 0; i < elements.length; i++) { byUid[elements[i].uid] = elements[i]; } return Object.values(byUid); }; export class TextManager { constructor(track) { this.track = track; this.texts = {}; // store a list of already created texts so that we don't // have to recreate new ones each time this.textsList = []; this.textWidths = {}; this.textHeights = {}; this.textGraphics = new GLOBALS.PIXI.Graphics(); this.track.pMain.addChild(this.textGraphics); // Some default font size that will get overwriten when // this.textWidths and this.textHeights are set this.fontSize = 9; } hideOverlaps() { const [allBoxes, allTexts] = [this.allBoxes, this.allTexts]; // Calculate overlaps from the bounding boxes of the texts boxIntersect(allBoxes, (i, j) => { if (allTexts[i].importance > allTexts[j].importance) { if (allTexts[i].text.visible) { allTexts[j].text.visible = false; } } else if (allTexts[j].text.visible) { allTexts[i].text.visible = false; } }); } startDraw() { this.allBoxes = []; this.allTexts = []; } lightUpdateSingleText(td, xMiddle, yMiddle, textInfo) { if (!this.texts[td.uid]) return; if (!this.track.options.showTexts) return; const text = this.texts[td.uid]; const TEXT_MARGIN = 3; text.position.x = xMiddle; text.position.y = yMiddle; text.visible = true; this.allBoxes.push([ text.position.x - TEXT_MARGIN, text.position.y - this.textHeights[td.uid] / 2, text.position.x + this.textWidths[td.uid] + TEXT_MARGIN, text.position.y + this.textHeights[td.uid] / 2, ]); this.allTexts.push({ text, ...textInfo, }); } updateSingleText(td, xMiddle, yMiddle, textText) { if (!this.texts[td.uid]) return; const text = this.texts[td.uid]; text.position.x = xMiddle; text.position.y = yMiddle; text.nominalY = yMiddle; const fontColor = this.track.options.fontColor !== undefined ? colorToHex(this.track.options.fontColor) : 'black'; const newFontSize = +this.track.options.fontSize || TEXT_STYLE.fontSize; if (newFontSize !== this.fontSize) { // New font size means different text widths and heights this.fontSize = newFontSize; this.textWidths = {}; this.textHeights = {}; } text.style = { ...TEXT_STYLE, fill: fontColor, fontSize: this.fontSize, }; text.text = textText; if (!(td.uid in this.textWidths)) { text.updateTransform(); const textWidth = text.getBounds().width; const textHeight = text.getBounds().height; // the text size adjustment compensates for the extra // size that the show gives it const TEXT_SIZE_ADJUSTMENT = 5; this.textWidths[td.uid] = textWidth; this.textHeights[td.uid] = textHeight - TEXT_SIZE_ADJUSTMENT; } } updateTexts() { if (this.track.options.showTexts) { this.texts = {}; let yRange = [ (0 - this.track.vertY) / (this.track.vertK * this.track.prevK), (this.track.dimensions[1] - this.track.vertY) / (this.track.vertK * this.track.prevK), ]; const yRangeWidth = yRange[1] - yRange[0]; yRange = [yRange[0] - yRangeWidth * 0.8, yRange[1] + yRangeWidth * 0.8]; const relevantSegments = this.track.uniqueSegments.filter( (x) => !x.yMiddle || (x.yMiddle > yRange[0] && x.yMiddle < yRange[1]), ); relevantSegments.forEach((td, i) => { // don't draw too many texts so they don't bog down the frame rate if (i >= (+this.track.options.maxTexts || MAX_TEXTS)) { return; } let text = this.textsList[i]; if (!text) { text = new GLOBALS.PIXI.Text(); this.textsList.push(text); this.textGraphics.addChild(text); } text.style = { ...TEXT_STYLE, fontSize: +this.track.options.fontSize || TEXT_STYLE.fontSize, }; // geneInfo[3] is the gene symbol if (this.track.isLeftModified) { text.scale.x = -1; } text.anchor.x = 0.5; text.anchor.y = 0.5; this.texts[td.uid] = text; }); while ( this.textsList.length > Math.min( relevantSegments.length, +this.track.options.maxTexts || MAX_TEXTS, ) ) { const text = this.textsList.pop(); this.textGraphics.removeChild(text); } } } } class BedLikeTrack extends HorizontalTiled1DPixiTrack { constructor(context, options) { super(context, options); this.valueScaleTransform = zoomIdentity; this.textManager = new TextManager(this); this.vertY = 1; this.vertK = 0; this.prevY = 0; this.prevK = 1; // we're setting these functions to null so that value scale // locking doesn't try to get values from them this.minRawValue = null; this.maxRawValue = null; this.rectGraphics = new GLOBALS.PIXI.Graphics(); this.pMain.addChild(this.rectGraphics); this.selectedRect = null; this.uniqueSegments = []; } /** Factor out some initialization code for the track. This is necessary because we can now load tiles synchronously and so we have to check if the track is initialized in renderTiles and not in the constructor */ initialize() { if (this.initialized) return; [this.prevK, this.vertK, this.vertY] = [1, 1, 0]; if (!this.drawnRects) { this.drawnRects = {}; } if (!this.colorScale) { if (this.options.colorRange) { this.colorScale = colorDomainToRgbaArray(this.options.colorRange); } else { this.colorScale = HEATED_OBJECT_MAP; } } this.initialized = true; } updateExistingGraphics() { const errors = this._checkForErrors(); let plusStrandRows = []; let minusStrandRows = []; if (errors.length > 0) { this.draw(); return; } // Object.values(this.fetchedTiles this.uniqueSegments = uniqueify( Object.values(this.fetchedTiles) .filter((x) => x.tileData.length) .flatMap((x) => x.tileData), ); this.uniqueSegments.forEach((td) => { // A random importance helps with selective hiding // of overlapping texts if (!td.importance) { td.importance = hashFunc(td.uid.toString()); } }); this.uniqueSegments.sort((a, b) => b.importance - a.importance); if (!this.options || !this.options.valueColumn) { // no value column so we can break entries up into separate // plus and minus strand segments const segments = this.uniqueSegments.map((x) => { const chrOffset = +x.chrOffset; return { from: +x.fields[1] + chrOffset, to: +x.fields[2] + chrOffset, value: x, text: x.fields[3], strand: x.fields.length >= 6 && x.fields[5] === '-' ? '-' : '+', }; }); plusStrandRows = segmentsToRows(segments.filter((x) => x.strand === '+')); minusStrandRows = segmentsToRows( segments.filter((x) => x.strand === '-'), ); } else { plusStrandRows = [this.uniqueSegments.map((x) => ({ value: x }))]; } this.plusStrandRows = plusStrandRows; this.minusStrandRows = minusStrandRows; this.textManager.updateTexts(); this.render(); } selectRect(uid) { this.selectedRect = uid; this.render(); this.animate(); } /** There was a click outside the track so unselect the * the current selection */ clickOutside() { this.selectRect(null); } initTile(tile) {} /** * Remove the tile's rectangles from the list of drawnRects so that they * can be drawn again. */ // removeTileRects(tile) { // const zoomLevel = +tile.tileId.split('.')[0]; // tile.rectGraphics.clear(); // tile.rendered = false; // if (tile.tileData && tile.tileData.length) { // tile.tileData.forEach((td, i) => { // if (this.drawnRects[zoomLevel] && this.drawnRects[zoomLevel][td.uid]) { // if (this.drawnRects[zoomLevel][td.uid][2] === tile.tileId) { // // this was the tile that drew that rectangle // delete this.drawnRects[zoomLevel][td.uid]; // } // } // }); // } // } destroyTile(tile) {} removeTiles(toRemoveIds) { super.removeTiles(toRemoveIds); // Pete: we're going to rerender after destroying tiles to make sure // any rectangles that were listed under 'drawnRects' don't get // ignored // Fritz: this line is causing unnecessary rerenderings. Seems to work fine // without rerendering anyway, so I disabled it. // if (toRemoveIds.length > 0) this.rerender(this.options); } drawTile(tile) { if (this.options?.valueColumn) { // there might no be a value scale if no valueColumn was specified if (this.valueScale) this.drawAxis(this.valueScale); } } rerender(options, force) { super.rerender(options, force); // this will get instantiated if a value column is specified this.valueScale = null; this.drawnRects = {}; if (this.options.colorRange) { this.colorScale = colorDomainToRgbaArray(this.options.colorRange); } else { this.colorScale = HEATED_OBJECT_MAP; } this.updateExistingGraphics(); } updateTile(tile) { // this.destroyTile(tile); // if (this.areAllVisibleTilesLoaded()) { // this.destroyTile(tile); // this.initTile(tile); // this.renderTile(tile); // } } /** * Use this only when there's one row * * @return {[type]} [description] */ allVisibleRects() { const allRects = {}; Object.values(this.fetchedTiles).forEach((x) => { if (!x.plusStrandRows) return; for (const row of x.plusStrandRows[0]) { if (!allRects[row.value.uid]) { allRects[row.value.uid] = row; } } }); const sortedRows = Object.values(allRects).sort((a, b) => a.from - b.from); let startPos = 0; let startStaggered = 0; // find if any values have staggeredStartPosition set for (let i = 0; i < sortedRows.length; i++) { if (sortedRows[i].staggeredStartPosition !== undefined) { startPos = i; startStaggered = sortedRows[i].staggeredStartPosition; break; } } for (let i = startPos; i < sortedRows.length; i++) { sortedRows[i].staggeredStartPosition = (startStaggered + i - startPos) % 2; } for (let i = startPos; i >= 0 && sortedRows.length; i--) { sortedRows[i].staggeredStartPosition = (startStaggered + startPos - i) % 2; } return allRects; } drawSegmentStyle(xStartPos, xEndPos, rectY, rectHeight, strand) { const hw = 0.1; // half width of the line const centerY = rectY + rectHeight / 2; const poly = [ xStartPos, rectY, // upper left xStartPos + 2 * hw, rectY, // upper right xStartPos + 2 * hw, centerY - hw, xEndPos - 2 * hw, centerY - hw, xEndPos - 2 * hw, rectY, xEndPos, rectY, xEndPos, rectY + rectHeight, xEndPos - 2 * hw, rectY + rectHeight, xEndPos - 2 * hw, centerY + hw, xStartPos + 2 * hw, centerY + hw, xStartPos + 2 * hw, rectY + rectHeight, xStartPos, rectY + rectHeight, ]; this.rectGraphics.drawPolygon(poly); return poly; } drawPoly(xStartPos, xEndPos, rectY, rectHeight, strand) { let drawnPoly = null; if (this.options.annotationStyle === 'segment') { return this.drawSegmentStyle( xStartPos, xEndPos, rectY, rectHeight, strand, ); } if ( (strand === '+' || strand === '-') && xEndPos - xStartPos < GENE_RECT_HEIGHT / 2 ) { // only draw if it's not too wide drawnPoly = [ xStartPos, rectY, // top xStartPos + rectHeight / 2, rectY + rectHeight / 2, // right point xStartPos, rectY + rectHeight, // bottom ]; if (strand === '+') { this.rectGraphics.drawPolygon(drawnPoly); } else { drawnPoly = [ xEndPos, rectY, // top xEndPos - rectHeight / 2, rectY + rectHeight / 2, // left point xEndPos, rectY + rectHeight, // bottom ]; this.rectGraphics.drawPolygon(drawnPoly); } } else { if (strand === '+') { drawnPoly = [ xStartPos, rectY, // left top xEndPos - rectHeight / 2, rectY, // right top xEndPos, rectY + rectHeight / 2, xEndPos - rectHeight / 2, rectY + rectHeight, // right bottom xStartPos, rectY + rectHeight, // left bottom ]; } else if (strand === '-') { drawnPoly = [ xStartPos + rectHeight / 2, rectY, // left top xEndPos, rectY, // right top xEndPos, rectY + rectHeight, // right bottom xStartPos + rectHeight / 2, rectY + rectHeight, // left bottom xStartPos, rectY + rectHeight / 2, ]; } else { drawnPoly = [ xStartPos, rectY, // left top xEndPos, rectY, // right top xEndPos, rectY + rectHeight, // right bottom xStartPos, rectY + rectHeight, // left bottom ]; } this.rectGraphics.drawPolygon(drawnPoly); } return drawnPoly; } /** The value scale is used to arrange annotations vertically based on a value */ setValueScale() { this.valueScale = null; if (this.options?.valueColumn) { /** * These intervals come with some y-value that we want to plot */ const min = this.options.colorEncodingRange ? +this.options.colorEncodingRange[0] : this.minVisibleValueInTiles(+this.options.valueColumn); const max = this.options.colorEncodingRange ? +this.options.colorEncodingRange[1] : this.maxVisibleValueInTiles(+this.options.valueColumn); if (this.options.valueColumn) { [this.valueScale] = this.makeValueScale( min, this.calculateMedianVisibleValue(+this.options.valueColumn), max, ); } } } /** The color value scale is used to map some value to a coloring */ setColorValueScale() { this.colorValueScale = null; if ( this.options?.colorEncoding && this.options.colorEncoding !== 'itemRgb' ) { const min = this.options.colorEncodingRange ? +this.options.colorEncodingRange[0] : this.minVisibleValueInTiles(+this.options.colorEncoding); const max = this.options.colorEncodingRange ? +this.options.colorEncodingRange[1] : this.maxVisibleValueInTiles(+this.options.colorEncoding); this.colorValueScale = scaleLinear().domain([min, max]).range([0, 255]); } } renderRows(rows, maxRows, startY, endY, fill) { let maxValue = Number.MIN_SAFE_INTEGER; this.initialize(); const rowScale = scaleBand().domain(range(maxRows)).range([startY, endY]); // .paddingOuter(0.2); // .paddingInner(0.3) this.allVisibleRects(); let allRects = null; if (this.options.staggered) { allRects = this.allVisibleRects(); } for (let j = 0; j < rows.length; j++) { for (let i = 0; i < rows[j].length; i++) { // rendered += 1; const td = rows[j][i].value; const geneInfo = td.fields; const txStart = +td.xStart; const txEnd = +td.xEnd; const txMiddle = (txStart + txEnd) / 2; let yMiddle = rowScale(j) + rowScale.bandwidth() / 2; let rectHeight = this.options.annotationHeight || 'scaled'; if (rectHeight === 'scaled') { rectHeight = rowScale.bandwidth(); if (this.options.maxAnnotationHeight) { rectHeight = Math.min( rectHeight, +this.options.maxAnnotationHeight, ); } } if ( this.options && this.options.colorEncoding === 'itemRgb' && td.fields[8] ) { let parts = []; try { parts = td.fields[8].split(','); // eslint-disable-next-line } catch {} if (parts.length === 3) { const color = `rgb(${td.fields[8]})`; fill = color; } } else if (this.colorValueScale) { const rgb = valueToColor( this.colorValueScale, this.colorScale, 0, // pseudocounts -Number.MIN_VALUE, )(+geneInfo[+this.options.colorEncoding - 1]); fill = `rgba(${rgb.join(',')})`; } else if ( this.options && this.options.colorEncoding === 'itemRgb' && td.fields[8] ) { const parts = td.fields[8].split(','); if (parts.length === 3) { const color = `rgb(${td.fields[8]})`; fill = color; } } if (this.valueScale) { const value = +geneInfo[+this.options.valueColumn - 1]; if (value > maxValue) { maxValue = value; } yMiddle = this.valueScale(value); } const opacity = this.options.fillOpacity || 0.3; if (this.selectedRect === td.uid) { this.rectGraphics.lineStyle(3, 0, 0.75); } else { this.rectGraphics.lineStyle(1, colorToHex(fill), opacity); } this.rectGraphics.beginFill(colorToHex(fill), opacity); let rectY = yMiddle - rectHeight / 2; const xStartPos = this._xScale(txStart); const xEndPos = this._xScale(txEnd); if (this.options.staggered) { const rect = allRects[td.uid]; if (rect.staggeredStartPosition) { rectY -= STAGGERED_OFFSET / 2; } else { rectY += STAGGERED_OFFSET / 2; } } const drawnPoly = this.drawPoly( xStartPos, xEndPos, rectY * this.prevK, rectHeight * this.prevK, geneInfo[5], ); this.drawnRects[td.uid] = [ drawnPoly, { start: txStart, end: txEnd, value: td, fill, }, ]; td.yMiddle = yMiddle; if (!this.options.showTexts) { continue; } // don't draw too many texts so they don't bog down the frame rate if (i >= (+this.options.maxTexts || MAX_TEXTS)) continue; this.textManager.updateSingleText( td, this._xScale(txMiddle), rectY + rectHeight / 2, td.fields[3], ); } } this.textManager.updateTexts(); } render() { const maxPlusRows = this.plusStrandRows ? this.plusStrandRows.length : 1; const maxMinusRows = this.minusStrandRows ? this.minusStrandRows.length : 1; this.prevVertY = this.vertY; const oldRectGraphics = this.rectGraphics; this.rectGraphics = new GLOBALS.PIXI.Graphics(); // store the scale at while the tile was drawn at so that // we only resize it when redrawing this.drawnAtScale = this._xScale.copy(); // configure vertical positioning of annotations if // this.options.valueColumn is set this.setValueScale(); // configure coloring of annotations if // this.options.colorEncoding is set this.setColorValueScale(); const fill = this.options.plusStrandColor || this.options.fillColor || 'blue'; const minusStrandFill = this.options.minusStrandColor || this.options.fillColor || 'purple'; const MIDDLE_SPACE = 0; let plusHeight = 0; if (this.options.separatePlusMinusStrands) { plusHeight = (maxPlusRows * this.dimensions[1]) / (maxPlusRows + maxMinusRows) - MIDDLE_SPACE / 2; } else { plusHeight = this.dimensions[1]; } this.renderRows(this.plusStrandRows, maxPlusRows, 0, plusHeight, fill); this.renderRows( this.minusStrandRows, maxMinusRows, this.options.separatePlusMinusStrands ? plusHeight + MIDDLE_SPACE / 2 : 0, this.dimensions[1], minusStrandFill, ); this.pMain.removeChild(oldRectGraphics); // this.pMain.removeChild(oldTextGraphics); this.pMain.addChild(this.rectGraphics); // this.pMain.addChild(this.textGraphics); scaleScalableGraphics(this.rectGraphics, this._xScale, this.drawnAtScale); // scaleScalableGraphics(this.textGraphics, this._xScale, this.drawnAtScale); } calculateZoomLevel() { // offset by 2 because 1D tiles are more dense than 2D tiles // 1024 points per tile vs 256 for 2D tiles const xZoomLevel = tileProxy.calculateZoomLevel( this._xScale, this.tilesetInfo.min_pos[0], this.tilesetInfo.max_pos[0], ); let zoomLevel = Math.min(xZoomLevel, this.maxZoom); zoomLevel = Math.max(zoomLevel, 0); return zoomLevel; } minVisibleValueInTiles(valueColumn) { let visibleAndFetchedIds = this.visibleAndFetchedIds(); if (visibleAndFetchedIds.length === 0) { visibleAndFetchedIds = Object.keys(this.fetchedTiles); } let min = Math.min.apply( null, visibleAndFetchedIds .map((x) => this.fetchedTiles[x]) .filter((x) => x.tileData?.length) .map((x) => Math.min.apply( null, x.tileData .sort((a, b) => b.importance - a.importance) .slice(0, MAX_TILE_ENTRIES) .map((y) => +y.fields[valueColumn - 1]) .filter((y) => !Number.isNaN(y)), ), ), ); // if there's no data, use null if (min === Number.MAX_SAFE_INTEGER) { min = null; } return min; } maxVisibleValueInTiles(valueColumn) { let visibleAndFetchedIds = this.visibleAndFetchedIds(); if (visibleAndFetchedIds.length === 0) { visibleAndFetchedIds = Object.keys(this.fetchedTiles); } let max = Math.max.apply( null, visibleAndFetchedIds .map((x) => this.fetchedTiles[x]) .filter((x) => x.tileData?.length) .map((x) => Math.max.apply( null, x.tileData .sort((a, b) => b.importance - a.importance) .slice(0, MAX_TILE_ENTRIES) .map((y) => +y.fields[valueColumn - 1]) .filter((y) => !Number.isNaN(y)), ), ), ); // if there's no data, use null if (max === Number.MIN_SAFE_INTEGER) { max = null; } return max; } calculateMedianVisibleValue(valueColumn) { if (this.areAllVisibleTilesLoaded()) { this.allTilesLoaded(); } let visibleAndFetchedIds = this.visibleAndFetchedIds(); if (visibleAndFetchedIds.length === 0) { visibleAndFetchedIds = Object.keys(this.fetchedTiles); } const values = [] .concat( ...visibleAndFetchedIds .map((x) => this.fetchedTiles[x]) .filter((x) => x.tileData?.length) .map((x) => x.tileData .sort((a, b) => b.importance - a.importance) .slice(0, MAX_TILE_ENTRIES) .map((y) => +y.fields[valueColumn - 1]), ), ) .filter((x) => x > 0); this.medianVisibleValue = median(values); } draw() { super.draw(); this.textManager.startDraw(); // these values control vertical scaling and they // need to be set in the draw method otherwise when // the window is resized, the zoomedY method won't // be called this.rectGraphics.scale.y = this.vertK; this.rectGraphics.position.y = this.vertY; // hasn't been rendered yet if (!this.drawnAtScale) { return; } scaleScalableGraphics(this.rectGraphics, this._xScale, this.drawnAtScale); // scaleScalableGraphics(this.textGraphics, this._xScale, this.drawnAtScale); if (this.uniqueSegments?.length) { this.uniqueSegments.forEach((td) => { const geneInfo = td.fields; const geneName = geneInfo[3]; const xMiddle = this._xScale((td.xStart + td.xEnd) / 2); if (this.textManager.texts[td.uid]) { const yMiddle = this.textManager.texts[td.uid].nominalY * (this.vertK * this.prevK) + this.vertY; this.textManager.lightUpdateSingleText(td, xMiddle, yMiddle, { importance: td.importance, caption: geneName, strand: geneInfo[5], }); } }); } this.textManager.hideOverlaps(); } setPosition(newPosition) { super.setPosition(newPosition); [this.pMain.position.x, this.pMain.position.y] = this.position; } setDimensions(newDimensions) { super.setDimensions(newDimensions); } zoomed(newXScale, newYScale) { this.xScale(newXScale); this.yScale(newYScale); this.refreshTiles(); this.draw(); } exportSVG() { let track = null; let base = null; if (super.exportSVG) { [base, track] = super.exportSVG(); } else { base = document.createElement('g'); track = base; } const output = document.createElement('g'); output.setAttribute( 'transform', `translate(${this.position[0]},${this.position[1]})`, ); track.appendChild(output); const rectOutput = document.createElement('g'); const textOutput = document.createElement('g'); output.appendChild(rectOutput); output.appendChild(textOutput); this.uniqueSegments.forEach((td) => { const gTile = document.createElement('g'); gTile.setAttribute( 'transform', `translate(${this.rectGraphics.position.x},${this.rectGraphics.position.y})scale(${this.rectGraphics.scale.x},${this.rectGraphics.scale.y})`, ); rectOutput.appendChild(gTile); if (this.drawnRects && td.uid in this.drawnRects) { const rect = this.drawnRects[td.uid][0]; const r = document.createElement('path'); let d = `M ${rect[0]} ${rect[1]}`; for (let i = 2; i < rect.length; i += 2) { d += ` L ${rect[i]} ${rect[i + 1]}`; } const fill = this.drawnRects[td.uid][1].fill; const fontColor = this.options.fontColor !== undefined ? colorToHex(this.options.fontColor) : fill; r.setAttribute('d', d); r.setAttribute('fill', fill); r.setAttribute('opacity', 0.3); r.style.stroke = fill; r.style.strokeWidth = '1px'; gTile.appendChild(r); if (this.textManager.texts[td.uid]) { const text = this.textManager.texts[td.uid]; if (!text.visible) { return; } const g = document.createElement('g'); const t = document.createElement('text'); textOutput.appendChild(g); g.appendChild(t); g.setAttribute( 'transform', `translate(${text.x},${text.y})scale(${text.scale.x},1)`, ); t.setAttribute('text-anchor', 'middle'); t.setAttribute('font-family', TEXT_STYLE.fontFamily); t.setAttribute( 'font-size', +this.options.fontSize || TEXT_STYLE.fontSize, ); t.setAttribute('font-weight', 'bold'); t.setAttribute('dy', '5px'); t.setAttribute('fill', fontColor); t.setAttribute('stroke', TEXT_STYLE.stroke); t.setAttribute('stroke-width', '0.4'); t.setAttribute('text-shadow', '0px 0px 2px grey'); t.innerHTML = text.text; } } }); return [base, base]; } /** Move event for the y-axis */ movedY(dY) { const vst = this.valueScaleTransform; const { y, k } = vst; const height = this.dimensions[1]; // clamp at the bottom and top if (y + dY / k > -(k - 1) * height && y + dY / k < 0) { this.valueScaleTransform = vst.translate(0, dY / k); } this.rectGraphics.position.y = this.valueScaleTransform.y; this.vertY = this.valueScaleTransform.y; this.animate(); if (this.vertY - this.prevVertY > this.dimensions[1] / 2) { this.render(); } } /** Zoomed along the y-axis */ zoomedY(yPos, kMultiplier) { const newTransform = trackUtils.zoomedY( yPos, kMultiplier, this.valueScaleTransform, this.dimensions[1], ); this.valueScaleTransform = newTransform; let k1 = newTransform.k; const t1 = newTransform.y; let toStretch = false; k1 /= this.prevK; if (k1 > 1.5 || k1 < 1 / 1.5) { // this is to make sure that annotations aren't getting // too stretched vertically this.prevK *= k1; k1 = 1; toStretch = true; } this.vertK = k1; this.vertY = t1; if (toStretch) { this.render(); } this.rectGraphics.scale.y = k1; this.rectGraphics.position.y = t1; // this.textGraphics.scale.y = k1; // this.textGraphics.position.y = t1; this.draw(); this.animate(); } getMouseOverHtml(trackX, trackY) { if (!this.tilesetInfo) { return ''; } if (!this.drawnRects) { return ''; } const closestText = ''; const point = [trackX, trackY]; const visibleRects = Object.values(this.drawnRects); for (let i = 0; i < visibleRects.length; i++) { const rect = visibleRects[i][0].slice(0); const newArr = polyToPoly( rect, this.rectGraphics.scale.x, this.rectGraphics.position.x, this.rectGraphics.scale.y, this.rectGraphics.position.y, ); const pc = classifyPoint(newArr, point); if (pc === -1) { const parts = visibleRects[i][1].value.fields; return parts.join(' '); } } return closestText; } } export default BedLikeTrack;