Skip to content

Commit 3e8b814

Browse files
authored
Add click map for genomic position (#33)
1 parent 1693999 commit 3e8b814

22 files changed

Lines changed: 1270 additions & 324 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@types/d3-array": "^3.2.1",
3939
"@types/d3-hierarchy": "^3.1.7",
4040
"@types/node": "^22.15.16",
41+
"@types/rbush": "^4.0.0",
4142
"@types/react": "^19.0.1",
4243
"chalk": "^5.3.0",
4344
"esbuild": "^0.25.0",
@@ -69,6 +70,7 @@
6970
"d3-hierarchy": "^3.1.2",
7071
"fast-deep-equal": "^3.1.3",
7172
"generic-filehandle2": "^2.0.1",
72-
"long": "^5.2.3"
73+
"long": "^5.2.3",
74+
"rbush": "^4.0.1"
7375
}
7476
}

src/BigMafAdapter/BigMafAdapter.ts

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export default class BigMafAdapter extends BaseFeatureDataAdapter {
5757

5858
getFeatures(query: Region, opts?: BaseOptions) {
5959
const { statusCallback = () => {} } = opts || {}
60+
// Pre-compile regex for better performance
61+
const WHITESPACE_REGEX = / +/
62+
6063
return ObservableCreate<Feature>(async observer => {
6164
const { adapter } = await this.setup()
6265
const features = await updateStatus(
@@ -68,48 +71,66 @@ export default class BigMafAdapter extends BaseFeatureDataAdapter {
6871
for (const feature of features) {
6972
const maf = feature.get('mafBlock') as string
7073
const blocks = maf.split(';')
71-
let aln: string | undefined
72-
const alns = [] as string[]
73-
const alignments = {} as Record<string, OrganismRecord>
74-
const blocks2 = [] as string[]
74+
75+
// Count sequence blocks first to pre-size arrays
76+
let sequenceBlockCount = 0
7577
for (const block of blocks) {
7678
if (block.startsWith('s')) {
77-
if (aln) {
78-
alns.push(block.split(/ +/)[6]!)
79-
blocks2.push(block)
80-
} else {
81-
aln = block.split(/ +/)[6]
82-
alns.push(aln!)
83-
blocks2.push(block)
84-
}
79+
sequenceBlockCount++
8580
}
8681
}
8782

88-
for (let i = 0; i < blocks2.length; i++) {
89-
const elt = blocks2[i]!
90-
const ad = elt.split(/ +/)
91-
const y = ad[1]!.split('.')
92-
const org = y[0]!
93-
const chr = y[1]!
94-
95-
alignments[org] = {
96-
chr: chr,
97-
start: +ad[1]!,
98-
srcSize: +ad[2]!,
99-
strand: ad[3] === '+' ? 1 : -1,
100-
unknown: +ad[4]!,
101-
seq: alns[i]!,
83+
// Pre-size arrays based on actual sequence block count
84+
const alns = new Array<string>(sequenceBlockCount)
85+
const alignments = {} as Record<string, OrganismRecord>
86+
87+
let sequenceIndex = 0
88+
let referenceSeq: string | undefined
89+
90+
// Single-pass processing: combine both loops
91+
for (const block of blocks) {
92+
if (block.startsWith('s')) {
93+
// Split once and cache the result
94+
const parts = block.split(WHITESPACE_REGEX)
95+
const sequence = parts[6]!
96+
const organismChr = parts[1]!
97+
98+
// Store sequence in pre-sized array
99+
alns[sequenceIndex] = sequence
100+
101+
// Set reference sequence from first block
102+
if (referenceSeq === undefined) {
103+
referenceSeq = sequence
104+
}
105+
106+
// Parse organism and chromosome once
107+
const dotIndex = organismChr.indexOf('.')
108+
const org = organismChr.slice(0, Math.max(0, dotIndex))
109+
const chr = organismChr.slice(Math.max(0, dotIndex + 1))
110+
111+
// Create alignment record directly
112+
alignments[org] = {
113+
chr,
114+
start: +parts[2]!,
115+
srcSize: +parts[3]!,
116+
strand: parts[4] === '+' ? 1 : -1,
117+
unknown: +parts[5]!,
118+
seq: sequence,
119+
}
120+
121+
sequenceIndex++
102122
}
103123
}
124+
104125
observer.next(
105126
new SimpleFeature({
106127
id: feature.id(),
107128
data: {
108129
start: feature.get('start'),
109130
end: feature.get('end'),
110131
refName: feature.get('refName'),
111-
seq: alns[0],
112-
alignments: alignments,
132+
seq: referenceSeq,
133+
alignments,
113134
},
114135
}),
115136
)

src/LinearMafDisplay/components/MAFTooltip.tsx

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,36 @@ import React from 'react'
22

33
import { SanitizedHTML } from '@jbrowse/core/ui'
44
import BaseTooltip from '@jbrowse/core/ui/BaseTooltip'
5-
import {
6-
getBpDisplayStr,
7-
getContainingView,
8-
toLocale,
9-
} from '@jbrowse/core/util'
5+
import { getContainingView } from '@jbrowse/core/util'
106
import { observer } from 'mobx-react'
117

8+
import { generateTooltipContent } from '../util'
9+
1210
import type { LinearMafDisplayModel } from '../stateModel'
11+
import type { HoveredInfo } from '../util'
1312
import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
1413

15-
interface MAFTooltipProps {
14+
const MAFTooltip = observer(function ({
15+
model,
16+
mouseX,
17+
origMouseX,
18+
}: {
1619
mouseY: number
1720
mouseX: number
1821
rowHeight: number
1922
sources: Record<string, any>[]
2023
model: LinearMafDisplayModel
2124
origMouseX?: number
22-
}
23-
24-
const MAFTooltip = observer(function ({
25-
model,
26-
mouseY,
27-
mouseX,
28-
origMouseX,
29-
rowHeight,
30-
sources,
31-
}: MAFTooltipProps) {
25+
}) {
26+
const { hoveredInfo } = model
3227
const view = getContainingView(model) as LinearGenomeViewModel
33-
const ret = Object.entries(sources[Math.floor(mouseY / rowHeight)] || {})
34-
.filter(([key]) => key !== 'color' && key !== 'id')
35-
.map(([key, value]) => `${key}:${value}`)
36-
.join('\n')
3728
const p1 = origMouseX ? view.pxToBp(origMouseX) : undefined
3829
const p2 = view.pxToBp(mouseX)
39-
return ret ? (
30+
31+
return hoveredInfo ? (
4032
<BaseTooltip>
4133
<SanitizedHTML
42-
html={[
43-
ret,
44-
...(p1
45-
? [
46-
`Start: ${p1.refName}:${toLocale(p1.coord)}`,
47-
`End: ${p2.refName}:${toLocale(p2.coord)}`,
48-
`Length: ${getBpDisplayStr(Math.abs(p1.coord - p2.coord))}`,
49-
]
50-
: [`${p2.refName}:${toLocale(p2.coord)}`]),
51-
]
52-
.filter(f => !!f)
53-
.join('<br/>')}
34+
html={generateTooltipContent(hoveredInfo as HoveredInfo, p1, p2)}
5435
/>
5536
</BaseTooltip>
5637
) : null

src/LinearMafDisplay/stateModel.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export default function stateModelFactory(
8585
}),
8686
)
8787
.volatile(() => ({
88+
/**
89+
* #volatile
90+
*/
91+
hoveredInfo: undefined as Record<string, unknown> | undefined,
8892
/**
8993
* #volatile
9094
*/
@@ -99,6 +103,12 @@ export default function stateModelFactory(
99103
volatileTree: undefined as any,
100104
}))
101105
.actions(self => ({
106+
/**
107+
* #action
108+
*/
109+
setHoveredInfo(arg?: Record<string, unknown>) {
110+
self.hoveredInfo = arg
111+
},
102112
/**
103113
* #action
104114
*/

src/LinearMafDisplay/util.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,65 @@
1+
import { getBpDisplayStr, toLocale } from '@jbrowse/core/util'
12
import { max } from 'd3-array'
23

34
import type { NodeWithIds } from './types'
45
import type { HierarchyNode } from 'd3-hierarchy'
56

7+
export interface HoveredInfo {
8+
sampleId: string
9+
pos: number
10+
base: string
11+
chr: string
12+
[key: string]: unknown // Allow additional properties for compatibility
13+
}
14+
15+
export interface GenomicPosition {
16+
refName: string
17+
coord: number
18+
}
19+
20+
/**
21+
* Generates tooltip HTML content for MAF alignments
22+
* Truncates long sequences to 50 characters, with ellipses added if display exceeds 20 characters
23+
* @param hoveredInfo - Information about the hovered base/position
24+
* @param p1 - Start position (for range selections)
25+
* @param p2 - End position (current mouse position)
26+
* @returns HTML string for tooltip content
27+
*/
28+
export function generateTooltipContent(
29+
hoveredInfo: HoveredInfo | undefined,
30+
p1: GenomicPosition | undefined,
31+
p2: GenomicPosition,
32+
): string {
33+
const contentLines: string[] = []
34+
35+
if (p1) {
36+
// Range selection mode
37+
contentLines.push(
38+
`Start: ${p1.refName}:${toLocale(p1.coord)}`,
39+
`End: ${p2.refName}:${toLocale(p2.coord)}`,
40+
`Length: ${getBpDisplayStr(Math.abs(p1.coord - p2.coord))}`,
41+
)
42+
} else {
43+
// Single position mode
44+
contentLines.push(`Ref: ${p2.refName}:${toLocale(p2.coord)}`)
45+
46+
if (hoveredInfo) {
47+
const { base, sampleId, pos, chr } = hoveredInfo
48+
const thresh = 20
49+
const len = base.length
50+
const lengthSuffix = len > 1 ? ` ${len}bp` : ''
51+
const baseDisplay =
52+
base.length > thresh ? base.slice(0, thresh) + '...' : base
53+
54+
contentLines.push(
55+
`Alt ${sampleId}: ${chr}:${pos.toLocaleString('en-US')} (${baseDisplay}${lengthSuffix})`,
56+
)
57+
}
58+
}
59+
60+
return contentLines.filter(line => !!line).join('<br/>')
61+
}
62+
663
// basically same as maxLength from https://observablehq.com/@d3/tree-of-life
764
export function maxLength(d: HierarchyNode<NodeWithIds>): number {
865
return (

src/LinearMafRenderer/LinearMafRenderer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,13 @@ export default class LinearMafRenderer extends FeatureRendererType {
4848
const features = await this.getFeatures(renderProps)
4949
const res = await updateStatus('Rendering alignment', statusCallback, () =>
5050
renderToAbstractCanvas(width, height, renderProps, ctx => {
51-
makeImageData({
51+
return makeImageData({
5252
ctx,
5353
renderArgs: {
5454
...renderProps,
5555
features,
5656
},
5757
})
58-
return undefined
5958
}),
6059
)
6160
const results = await super.render({
Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,81 @@
1-
import React from 'react'
1+
import React, { useMemo, useRef } from 'react'
22

33
import { PrerenderedCanvas } from '@jbrowse/core/ui'
44
import { observer } from 'mobx-react'
5+
import RBush from 'rbush'
56

7+
type SerializedRBush = any
8+
9+
interface RBushData {
10+
minX: number
11+
maxX: number
12+
minY: number
13+
maxY: number
14+
isInsertion: boolean
15+
}
616
const LinearMafRendering = observer(function (props: {
717
width: number
818
height: number
19+
displayModel: any
20+
rbush: SerializedRBush
921
}) {
10-
return <PrerenderedCanvas {...props} />
22+
const { displayModel, height, rbush } = props
23+
const ref = useRef<HTMLDivElement>(null)
24+
const rbush2 = useMemo(() => new RBush<RBushData>().fromJSON(rbush), [rbush])
25+
26+
function getFeatureUnderMouse(eventClientX: number, eventClientY: number) {
27+
let offsetX = 0
28+
let offsetY = 0
29+
if (ref.current) {
30+
const r = ref.current.getBoundingClientRect()
31+
offsetX = eventClientX - r.left
32+
offsetY = eventClientY - r.top - (displayModel?.scrollTop || 0)
33+
}
34+
35+
const x = rbush2.search({
36+
minX: offsetX,
37+
maxX: offsetX + 1,
38+
minY: offsetY,
39+
maxY: offsetY + 1,
40+
})
41+
if (x.length) {
42+
// prioritize insertions
43+
const { minX, minY, maxX, maxY, ...rest } =
44+
x.find(f => f.isInsertion) || x[0]!
45+
return rest
46+
} else {
47+
return undefined
48+
}
49+
}
50+
return (
51+
<div
52+
ref={ref}
53+
onMouseMove={e =>
54+
displayModel.setHoveredInfo?.(
55+
getFeatureUnderMouse(e.clientX, e.clientY),
56+
)
57+
}
58+
onMouseLeave={() => {
59+
displayModel.setHoveredInfo?.(undefined)
60+
}}
61+
onMouseOut={() => {
62+
displayModel.setHoveredInfo?.(undefined)
63+
}}
64+
style={{
65+
overflow: 'visible',
66+
position: 'relative',
67+
height,
68+
}}
69+
>
70+
<PrerenderedCanvas
71+
{...props}
72+
style={{
73+
position: 'absolute',
74+
left: 0,
75+
}}
76+
/>
77+
</div>
78+
)
1179
})
1280

1381
export default LinearMafRendering
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function minElt<T>(arr: Iterable<T>, cb: (arg: T) => number) {
2+
let min = Infinity
3+
let minElement: T | undefined
4+
for (const entry of arr) {
5+
const val = cb(entry)
6+
7+
if (val < min) {
8+
min = val
9+
minElement = entry
10+
}
11+
}
12+
return minElement
13+
}

0 commit comments

Comments
 (0)