Skip to content

Commit 10da73f

Browse files
Add two specific files for mutation diagram zoom functionality
1 parent ae04d13 commit 10da73f

File tree

3 files changed

+168
-52
lines changed

3 files changed

+168
-52
lines changed

packages/react-mutation-mapper/src/component/lollipopPlot/LollipopPlot.tsx

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
import * as React from 'react';
24
import { observer } from 'mobx-react';
35
import { observable, computed, makeObservable, action } from 'mobx';
@@ -45,7 +47,8 @@ export default class LollipopPlot extends React.Component<
4547
{}
4648
> {
4749
@observable private hitZoneConfig: HitZoneConfig = defaultHitzoneConfig();
48-
@observable private mirrorVisible: boolean = false; // lollipop and mirror lollipop share tooltip visibility
50+
@observable private mirrorVisible = false; // lollipop and mirror lollipop share tooltip visibility
51+
@observable private tooltipUpdating = false; // flag to prevent tooltip flicker during updates
4952

5053
private plot: LollipopPlotNoTooltip | undefined;
5154
private handlers: any;
@@ -59,7 +62,12 @@ export default class LollipopPlot extends React.Component<
5962

6063
makeObservable<
6164
LollipopPlot,
62-
'hitZoneConfig' | 'tooltipVisible' | 'hitZone' | 'mirrorHitZone'
65+
| 'hitZoneConfig'
66+
| 'tooltipVisible'
67+
| 'hitZone'
68+
| 'mirrorHitZone'
69+
| 'mirrorVisible'
70+
| 'tooltipUpdating'
6371
>(this);
6472

6573
this.handlers = {
@@ -84,8 +92,8 @@ export default class LollipopPlot extends React.Component<
8492
onMouseOver?: () => void,
8593
onClick?: () => void,
8694
onMouseOut?: () => void,
87-
cursor: string = 'pointer',
88-
tooltipPlacement: string = 'top'
95+
cursor = 'pointer',
96+
tooltipPlacement = 'top'
8997
) => {
9098
this.hitZoneConfig = {
9199
hitRect,
@@ -110,11 +118,12 @@ export default class LollipopPlot extends React.Component<
110118
this.hitZoneConfig.onMouseOut &&
111119
this.hitZoneConfig.onMouseOut();
112120
},
121+
onZoomOrMove: this.handleZoomOrMove,
113122
};
114123
}
115124

116125
@computed private get tooltipVisible() {
117-
return !!this.hitZoneConfig.content;
126+
return !!this.hitZoneConfig.content && !this.tooltipUpdating;
118127
}
119128

120129
@computed private get hitZone() {
@@ -135,6 +144,49 @@ export default class LollipopPlot extends React.Component<
135144
@action.bound
136145
private onTooltipVisibleChange(visible: boolean) {
137146
this.mirrorVisible = visible;
147+
148+
// When manually hiding the tooltip, also reset hit zone config
149+
if (!visible && this.hitZoneConfig.content) {
150+
this.hitZoneConfig = defaultHitzoneConfig();
151+
}
152+
}
153+
154+
@action.bound
155+
private handleZoomOrMove() {
156+
if (this.hitZoneConfig.content && this.plot) {
157+
this.tooltipUpdating = true;
158+
159+
setTimeout(() => {
160+
// Use the new public methods
161+
const lollipopComponent = Object.values(
162+
this.plot!.getAllLollipopComponents()
163+
).find(
164+
component =>
165+
component &&
166+
component.props.spec.tooltip ===
167+
this.hitZoneConfig.content
168+
);
169+
170+
if (lollipopComponent) {
171+
const mirrorLollipopComponent = this.plot!.findMirrorLollipop(
172+
lollipopComponent.props.spec.codon
173+
);
174+
175+
this.hitZoneConfig = {
176+
...this.hitZoneConfig,
177+
hitRect: lollipopComponent.circleHitRect,
178+
mirrorHitRect: mirrorLollipopComponent?.circleHitRect,
179+
tooltipPlacement:
180+
lollipopComponent.circleHitRect.y >
181+
lollipopComponent.props.stickBaseY
182+
? 'bottom'
183+
: 'top',
184+
};
185+
}
186+
187+
this.tooltipUpdating = false;
188+
}, 50);
189+
}
138190
}
139191

140192
public toSVGDOMNode(): Element {
@@ -191,6 +243,7 @@ export default class LollipopPlot extends React.Component<
191243
setHitZone={this.handlers.setHitZone}
192244
onMouseLeave={this.handlers.onMouseLeave}
193245
onBackgroundMouseMove={this.handlers.onBackgroundMouseMove}
246+
onZoomOrMove={this.handlers.onZoomOrMove}
194247
{...this.props}
195248
/>
196249
</div>

packages/react-mutation-mapper/src/component/lollipopPlot/LollipopPlotNoTooltip.tsx

Lines changed: 110 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
import * as React from 'react';
24
import { SyntheticEvent } from 'react';
35
import $ from 'jquery';
@@ -38,6 +40,7 @@ export type LollipopPlotNoTooltipProps = LollipopPlotProps & {
3840
) => void;
3941
onMouseLeave?: () => void;
4042
onBackgroundMouseMove?: () => void;
43+
onZoomOrMove?: () => void; // Add this prop for zoom/move events
4144
};
4245

4346
const DELETE_FOR_DOWNLOAD_CLASS = 'delete-for-download';
@@ -55,7 +58,8 @@ export default class LollipopPlotNoTooltip extends React.Component<
5558
private sequenceComponents: Sequence[] = [];
5659

5760
private svg: SVGElement | undefined;
58-
private shiftPressed: boolean = false;
61+
private shiftPressed = false;
62+
private zoomBehavior: any; // Store zoom behavior
5963

6064
private lollipopLabelPadding = 20;
6165
private domainPadding = 5;
@@ -82,6 +86,20 @@ export default class LollipopPlotNoTooltip extends React.Component<
8286
showYAxis: true,
8387
};
8488

89+
public getLollipopComponent(index: string): Lollipop | undefined {
90+
return this.lollipopComponents[index];
91+
}
92+
93+
public getAllLollipopComponents(): { [key: string]: Lollipop } {
94+
return this.lollipopComponents;
95+
}
96+
97+
public findMirrorLollipop(codon: number): Lollipop | undefined {
98+
return Object.values(this.lollipopComponents).find(
99+
l => l.props.spec.codon === codon
100+
);
101+
}
102+
85103
constructor(props: any) {
86104
super(props);
87105
makeObservable(this);
@@ -90,6 +108,47 @@ export default class LollipopPlotNoTooltip extends React.Component<
90108
@autobind
91109
protected ref(svg: SVGElement) {
92110
this.svg = svg;
111+
112+
// Set up zoom behavior after the SVG is rendered
113+
if (svg) {
114+
this.setupZoom();
115+
}
116+
}
117+
118+
// Set up D3 zoom behavior
119+
private setupZoom() {
120+
if (this.svg) {
121+
const d3 = require('d3');
122+
123+
// Create zoom behavior
124+
this.zoomBehavior = d3
125+
.zoom()
126+
.scaleExtent([0.5, 8]) // Allow zooming from 0.5x to 8x
127+
.on('zoom', this.onZoom);
128+
129+
// Apply zoom behavior to SVG
130+
d3.select(this.svg).call(this.zoomBehavior);
131+
}
132+
}
133+
134+
@action.bound
135+
private onZoom(event: any) {
136+
// Apply the zoom transform to the content group
137+
if (this.svg) {
138+
const d3 = require('d3');
139+
const contentGroup = d3
140+
.select(this.svg)
141+
.select('g.lollipop-content-group');
142+
143+
if (!contentGroup.empty()) {
144+
contentGroup.attr('transform', event.transform);
145+
146+
// Notify parent component about zoom/move
147+
if (this.props.onZoomOrMove) {
148+
this.props.onZoomOrMove();
149+
}
150+
}
151+
}
93152
}
94153

95154
@action.bound
@@ -118,14 +177,14 @@ export default class LollipopPlotNoTooltip extends React.Component<
118177
}
119178

120179
@action.bound
121-
protected onKeyDown(e: JQueryKeyEventObject) {
180+
protected onKeyDown(e: JQuery.KeyDownEvent) {
122181
if (e.which === 16) {
123182
this.shiftPressed = true;
124183
}
125184
}
126185

127186
@action.bound
128-
protected onKeyUp(e: JQueryKeyEventObject) {
187+
protected onKeyUp(e: JQuery.KeyUpEvent) {
129188
if (e.which === 16) {
130189
this.shiftPressed = false;
131190
}
@@ -266,7 +325,7 @@ export default class LollipopPlotNoTooltip extends React.Component<
266325
return (codon / this.props.xMax) * this.props.vizWidth;
267326
}
268327

269-
private countToHeight(count: number, yMax: number, zeroHeight: number = 0) {
328+
private countToHeight(count: number, yMax: number, zeroHeight = 0) {
270329
return zeroHeight + Math.min(1, count / yMax) * this.yAxisHeight;
271330
}
272331

@@ -554,7 +613,7 @@ export default class LollipopPlotNoTooltip extends React.Component<
554613

555614
let start = 0;
556615

557-
let segments = _.map(this.props.domains, (domain: DomainSpec) => {
616+
const segments = _.map(this.props.domains, (domain: DomainSpec) => {
558617
const segment = {
559618
start,
560619
end: this.codonToX(domain.startCodon), // segment ends at the start of the current domain
@@ -710,7 +769,7 @@ export default class LollipopPlotNoTooltip extends React.Component<
710769
ticks: Tick[],
711770
placement?: LollipopPlacement,
712771
groupName?: string,
713-
symbol: string = '#'
772+
symbol = '#'
714773
) {
715774
let label;
716775
if (this.props.yAxisLabelFormatter) {
@@ -862,47 +921,52 @@ export default class LollipopPlotNoTooltip extends React.Component<
862921
onClick={this.onBackgroundClick}
863922
onMouseMove={this.onBackgroundMouseMove}
864923
/>
865-
{
866-
// Originally this had tooltips by having separate segments
867-
// with hit zones. We disabled those separate segments with
868-
// tooltips (this.sequenceSegments) and instead just draw
869-
// one rectangle
870-
// this.sequenceSegments
871-
}
872-
<rect
873-
fill="#BABDB6"
874-
x={this.geneX}
875-
y={this.geneY}
876-
height={this.geneHeight}
877-
width={
878-
// the x-axis start from 0, so the rectangle size should be (width + 1)
879-
this.props.vizWidth + 1
924+
925+
{/* Wrap all content in a group for zooming */}
926+
<g className="lollipop-content-group">
927+
{
928+
// Originally this had tooltips by having separate segments
929+
// with hit zones. We disabled those separate segments with
930+
// tooltips (this.sequenceSegments) and instead just draw
931+
// one rectangle
932+
// this.sequenceSegments
880933
}
881-
/>
882-
{this.lollipops}
883-
{this.domains}
884-
{this.xAxisOnTop && this.xAxis(0, LollipopPlacement.TOP)}
885-
{this.xAxisOnBottom &&
886-
this.xAxis(this.xAxisY, LollipopPlacement.BOTTOM)}
887-
{this.props.showYAxis &&
888-
this.yAxis(
889-
this.yAxisY,
890-
this.yMax,
891-
this.yTicks,
892-
LollipopPlacement.TOP,
893-
this.topGroupName,
894-
this.topGroupSymbol
895-
)}
896-
{this.props.showYAxis &&
897-
this.needBottomPlacement &&
898-
this.yAxis(
899-
this.bottomYAxisY,
900-
this.bottomYMax,
901-
this.bottomYTicks,
902-
LollipopPlacement.BOTTOM,
903-
this.bottomGroupName,
904-
this.bottomGroupSymbol
905-
)}
934+
<rect
935+
fill="#BABDB6"
936+
x={this.geneX}
937+
y={this.geneY}
938+
height={this.geneHeight}
939+
width={
940+
// the x-axis start from 0, so the rectangle size should be (width + 1)
941+
this.props.vizWidth + 1
942+
}
943+
/>
944+
{this.lollipops}
945+
{this.domains}
946+
{this.xAxisOnTop &&
947+
this.xAxis(0, LollipopPlacement.TOP)}
948+
{this.xAxisOnBottom &&
949+
this.xAxis(this.xAxisY, LollipopPlacement.BOTTOM)}
950+
{this.props.showYAxis &&
951+
this.yAxis(
952+
this.yAxisY,
953+
this.yMax,
954+
this.yTicks,
955+
LollipopPlacement.TOP,
956+
this.topGroupName,
957+
this.topGroupSymbol
958+
)}
959+
{this.props.showYAxis &&
960+
this.needBottomPlacement &&
961+
this.yAxis(
962+
this.bottomYAxisY,
963+
this.bottomYMax,
964+
this.bottomYTicks,
965+
LollipopPlacement.BOTTOM,
966+
this.bottomGroupName,
967+
this.bottomGroupSymbol
968+
)}
969+
</g>
906970
</svg>
907971
</div>
908972
);

src/shared/components/mutationMapper/mutationMapper.module.scss.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@ declare const styles: {
33
readonly "removeFilterButton": string;
44
};
55
export = styles;
6-

0 commit comments

Comments
 (0)