@weng-lab/genomebrowser-ui 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClipPath,
|
|
3
|
+
CustomTrackConfig,
|
|
4
|
+
CustomTrackProps,
|
|
5
|
+
DisplayMode,
|
|
6
|
+
fetchBigBedUrl,
|
|
7
|
+
Rect,
|
|
8
|
+
renderSquishBigBedData,
|
|
9
|
+
TrackType,
|
|
10
|
+
useBrowserStore,
|
|
11
|
+
useInteraction,
|
|
12
|
+
useRowHeight,
|
|
13
|
+
useXTransform,
|
|
14
|
+
Vibrant,
|
|
15
|
+
useTrackStore,
|
|
16
|
+
} from "@weng-lab/genomebrowser";
|
|
17
|
+
import { DNALogo } from "logo-test";
|
|
18
|
+
import { useMemo, useState } from "react";
|
|
19
|
+
import motifData from "./TF-ChIP-Canonical-Motifs-w-Trimmed.json";
|
|
20
|
+
|
|
21
|
+
// --- Types ---
|
|
22
|
+
|
|
23
|
+
type OverlayData = {
|
|
24
|
+
primary: Rect[];
|
|
25
|
+
overlay: Rect[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type OverlayInteractionRect = Rect & {
|
|
29
|
+
source: "base" | "overlay";
|
|
30
|
+
matchedName?: string;
|
|
31
|
+
pwm?: number[][];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// The config type — extra fields go directly on the config
|
|
35
|
+
type OverlayBigBedConfig = CustomTrackConfig<OverlayInteractionRect> & {
|
|
36
|
+
primaryUrl: string;
|
|
37
|
+
overlayUrl: string;
|
|
38
|
+
baseColor?: string;
|
|
39
|
+
overlayColor?: string;
|
|
40
|
+
filter?: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// --- Helpers ---
|
|
44
|
+
|
|
45
|
+
function nameKey(name?: string) {
|
|
46
|
+
if (!name) return "";
|
|
47
|
+
return name.split(/[-_]/)[0].trim().toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildOverlayIndex(rects: Rect[]) {
|
|
51
|
+
return rects.reduce<Record<string, Rect[]>>((acc, rect) => {
|
|
52
|
+
const key = nameKey(rect.name);
|
|
53
|
+
if (!key) return acc;
|
|
54
|
+
(acc[key] ??= []).push(rect);
|
|
55
|
+
return acc;
|
|
56
|
+
}, {});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function intervalsOverlap(
|
|
60
|
+
aStart: number,
|
|
61
|
+
aEnd: number,
|
|
62
|
+
bStart: number,
|
|
63
|
+
bEnd: number,
|
|
64
|
+
) {
|
|
65
|
+
return aStart <= bEnd && bStart <= aEnd;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function darkenHexColor(color: string, score?: number) {
|
|
69
|
+
if (!color.startsWith("#") || color.length !== 7) return color;
|
|
70
|
+
const t = 1 - (0.7 * Math.min(Math.max(score ?? 0, 0), 1000)) / 1000;
|
|
71
|
+
const r = Math.round(parseInt(color.slice(1, 3), 16) * t);
|
|
72
|
+
const g = Math.round(parseInt(color.slice(3, 5), 16) * t);
|
|
73
|
+
const b = Math.round(parseInt(color.slice(5, 7), 16) * t);
|
|
74
|
+
if ([r, g, b].some(isNaN)) return color;
|
|
75
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Motif data lookup ---
|
|
79
|
+
|
|
80
|
+
type MotifEntry = { trimmed_ppm: number[][]; ppm: number[][] };
|
|
81
|
+
const motifLookup = motifData as Record<string, MotifEntry>;
|
|
82
|
+
|
|
83
|
+
function lookupPwm(name?: string): number[][] | undefined {
|
|
84
|
+
const key = nameKey(name)?.toUpperCase();
|
|
85
|
+
if (!key) return undefined;
|
|
86
|
+
const entry = motifLookup[key];
|
|
87
|
+
return entry?.trimmed_ppm ?? entry?.ppm;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Tooltip ---
|
|
91
|
+
|
|
92
|
+
function tfDisplayName(name?: string): string {
|
|
93
|
+
return nameKey(name)?.toUpperCase() || name || "Unknown";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function TfPeaksTooltip(rect: OverlayInteractionRect) {
|
|
97
|
+
const pwm = rect.pwm;
|
|
98
|
+
const label = tfDisplayName(rect.name);
|
|
99
|
+
if (!pwm || pwm.length === 0) {
|
|
100
|
+
return (
|
|
101
|
+
<g>
|
|
102
|
+
<rect
|
|
103
|
+
width={120}
|
|
104
|
+
height={24}
|
|
105
|
+
fill="white"
|
|
106
|
+
rx={2}
|
|
107
|
+
style={{ filter: "drop-shadow(0px 0px 4px rgba(0,0,0,0.25))" }}
|
|
108
|
+
/>
|
|
109
|
+
<text x={6} y={16} fontSize={12} fill="#333">
|
|
110
|
+
{label}
|
|
111
|
+
</text>
|
|
112
|
+
</g>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const logoWidth = pwm.length * 15;
|
|
116
|
+
const logoHeight = 130;
|
|
117
|
+
const totalHeight = logoHeight - 5;
|
|
118
|
+
return (
|
|
119
|
+
<g transform={`translate(0, ${-totalHeight})`}>
|
|
120
|
+
<rect
|
|
121
|
+
width={logoWidth + 10}
|
|
122
|
+
height={totalHeight}
|
|
123
|
+
fill="white"
|
|
124
|
+
rx={3}
|
|
125
|
+
style={{ filter: "drop-shadow(0px 0px 5px rgba(0,0,0,0.3))" }}
|
|
126
|
+
/>
|
|
127
|
+
<text x={5} y={16} fontSize={12} fontWeight="bold" fill="#333">
|
|
128
|
+
{label}
|
|
129
|
+
</text>
|
|
130
|
+
<g transform="translate(5, 5)">
|
|
131
|
+
<DNALogo
|
|
132
|
+
ppm={pwm}
|
|
133
|
+
mode="INFORMATION_CONTENT"
|
|
134
|
+
width={logoWidth}
|
|
135
|
+
height={logoHeight}
|
|
136
|
+
/>
|
|
137
|
+
</g>
|
|
138
|
+
</g>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Settings Panel ---
|
|
143
|
+
|
|
144
|
+
function TfPeaksSettings({ id }: { id: string }) {
|
|
145
|
+
const track = useTrackStore((state) => state.getTrack(id)) as
|
|
146
|
+
| OverlayBigBedConfig
|
|
147
|
+
| undefined;
|
|
148
|
+
const editTrack = useTrackStore((state) => state.editTrack);
|
|
149
|
+
const [input, setInput] = useState((track?.filter || []).join(", "));
|
|
150
|
+
|
|
151
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
152
|
+
const value = e.target.value;
|
|
153
|
+
setInput(value);
|
|
154
|
+
const names = value
|
|
155
|
+
.split(",")
|
|
156
|
+
.map((s) => s.trim().toUpperCase())
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
editTrack<OverlayBigBedConfig>(id, {
|
|
159
|
+
filter: names.length > 0 ? names : undefined,
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
style={{
|
|
166
|
+
display: "flex",
|
|
167
|
+
flexDirection: "column",
|
|
168
|
+
alignItems: "flex-start",
|
|
169
|
+
paddingBlock: "5px",
|
|
170
|
+
paddingInline: "10px",
|
|
171
|
+
gap: "3px",
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
<div style={{ fontWeight: "bold" }}>Filter TFs</div>
|
|
175
|
+
<input
|
|
176
|
+
value={input}
|
|
177
|
+
onChange={handleChange}
|
|
178
|
+
placeholder="e.g. CTCF, TP53, GATA1"
|
|
179
|
+
style={{ width: "100%" }}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Renderer ---
|
|
186
|
+
|
|
187
|
+
function OverlayBigBedRenderer(
|
|
188
|
+
props: CustomTrackProps<OverlayData> & OverlayBigBedConfig,
|
|
189
|
+
) {
|
|
190
|
+
const {
|
|
191
|
+
data,
|
|
192
|
+
dimensions,
|
|
193
|
+
id,
|
|
194
|
+
height: trackHeight,
|
|
195
|
+
color,
|
|
196
|
+
baseColor: baseColorProp,
|
|
197
|
+
overlayColor: overlayColorProp,
|
|
198
|
+
filter,
|
|
199
|
+
...rest
|
|
200
|
+
} = props;
|
|
201
|
+
const domain = useBrowserStore((state) => state.domain);
|
|
202
|
+
const { totalWidth, sideWidth } = dimensions;
|
|
203
|
+
const { x, reverseX } = useXTransform(totalWidth);
|
|
204
|
+
|
|
205
|
+
const filterSet = useMemo(
|
|
206
|
+
() =>
|
|
207
|
+
filter && filter.length > 0
|
|
208
|
+
? new Set(filter.map((f) => f.toUpperCase()))
|
|
209
|
+
: null,
|
|
210
|
+
[filter],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const rows = useMemo(() => {
|
|
214
|
+
let visible = (data?.primary || []).filter(
|
|
215
|
+
(r) => r.end >= domain.start && r.start <= domain.end,
|
|
216
|
+
);
|
|
217
|
+
if (filterSet) {
|
|
218
|
+
visible = visible.filter((r) =>
|
|
219
|
+
filterSet.has(nameKey(r.name)?.toUpperCase()),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return renderSquishBigBedData(visible, x);
|
|
223
|
+
}, [data, domain.end, domain.start, x, filterSet]);
|
|
224
|
+
|
|
225
|
+
const rowHeight = useRowHeight(rows.length, id);
|
|
226
|
+
const height = Math.max(trackHeight, rowHeight * Math.max(rows.length, 1));
|
|
227
|
+
const overlayByName = useMemo(
|
|
228
|
+
() => buildOverlayIndex(data?.overlay || []),
|
|
229
|
+
[data],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const baseColor = baseColorProp || color || Vibrant[0];
|
|
233
|
+
const overlayColor = overlayColorProp || Vibrant[3];
|
|
234
|
+
const cursor = rest.onClick ? "pointer" : "default";
|
|
235
|
+
|
|
236
|
+
const { handleClick, handleHover, handleLeave } =
|
|
237
|
+
useInteraction<OverlayInteractionRect>({
|
|
238
|
+
onClick: rest.onClick,
|
|
239
|
+
onHover: rest.onHover,
|
|
240
|
+
onLeave: rest.onLeave,
|
|
241
|
+
tooltip: rest.tooltip,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<g
|
|
246
|
+
width={totalWidth}
|
|
247
|
+
height={height}
|
|
248
|
+
clipPath={`url(#${id})`}
|
|
249
|
+
transform={`translate(-${sideWidth}, 0)`}
|
|
250
|
+
>
|
|
251
|
+
<rect width={totalWidth} height={height} fill="transparent" />
|
|
252
|
+
<defs>
|
|
253
|
+
<ClipPath id={id} width={totalWidth} height={height} />
|
|
254
|
+
</defs>
|
|
255
|
+
{rows.map((row, rowIndex) => {
|
|
256
|
+
const baseY = rowHeight * 0.2;
|
|
257
|
+
const baseH = rowHeight * 0.6;
|
|
258
|
+
return (
|
|
259
|
+
<g
|
|
260
|
+
key={`row_${rowIndex}`}
|
|
261
|
+
transform={`translate(0, ${rowIndex * rowHeight})`}
|
|
262
|
+
>
|
|
263
|
+
{row.map((rect, ri) => {
|
|
264
|
+
const realStart = Math.round(reverseX(rect.start));
|
|
265
|
+
const realEnd = Math.round(reverseX(rect.end));
|
|
266
|
+
const baseName = rect.rectname;
|
|
267
|
+
const attached = (
|
|
268
|
+
overlayByName[nameKey(rect.rectname)] || []
|
|
269
|
+
).filter((a) =>
|
|
270
|
+
intervalsOverlap(realStart, realEnd, a.start, a.end),
|
|
271
|
+
);
|
|
272
|
+
const fill = darkenHexColor(rect.color || baseColor, rect.score);
|
|
273
|
+
const baseRect: OverlayInteractionRect = {
|
|
274
|
+
source: "base",
|
|
275
|
+
start: realStart,
|
|
276
|
+
end: realEnd,
|
|
277
|
+
name: baseName,
|
|
278
|
+
color: fill,
|
|
279
|
+
score: rect.score,
|
|
280
|
+
pwm: lookupPwm(baseName),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<g key={`${id}_${rowIndex}_${ri}`}>
|
|
285
|
+
<rect
|
|
286
|
+
style={{ cursor }}
|
|
287
|
+
x={rect.start}
|
|
288
|
+
y={baseY}
|
|
289
|
+
width={Math.max(rect.end - rect.start, 1)}
|
|
290
|
+
height={baseH}
|
|
291
|
+
fill={fill}
|
|
292
|
+
opacity={0.9}
|
|
293
|
+
onClick={() => handleClick(baseRect)}
|
|
294
|
+
onMouseOver={(e) =>
|
|
295
|
+
handleHover(baseRect, baseRect.name || "", e)
|
|
296
|
+
}
|
|
297
|
+
onMouseOut={() => handleLeave(baseRect)}
|
|
298
|
+
/>
|
|
299
|
+
{attached.map((a, ai) => {
|
|
300
|
+
const left = x(a.start);
|
|
301
|
+
const right = x(a.end);
|
|
302
|
+
const overlayRect: OverlayInteractionRect = {
|
|
303
|
+
source: "overlay",
|
|
304
|
+
start: a.start,
|
|
305
|
+
end: a.end,
|
|
306
|
+
name: a.name,
|
|
307
|
+
color: a.color || overlayColor,
|
|
308
|
+
score: a.score,
|
|
309
|
+
matchedName: baseName,
|
|
310
|
+
pwm: lookupPwm(a.name),
|
|
311
|
+
};
|
|
312
|
+
return (
|
|
313
|
+
<rect
|
|
314
|
+
style={{ cursor }}
|
|
315
|
+
key={`${id}_${rowIndex}_${ri}_a_${ai}`}
|
|
316
|
+
x={left}
|
|
317
|
+
y={baseY}
|
|
318
|
+
width={Math.max(right - left, 1)}
|
|
319
|
+
height={baseH}
|
|
320
|
+
fill={a.color || overlayColor}
|
|
321
|
+
opacity={0.9}
|
|
322
|
+
onClick={() => handleClick(overlayRect)}
|
|
323
|
+
onMouseOver={(e) =>
|
|
324
|
+
handleHover(overlayRect, overlayRect.name || "", e)
|
|
325
|
+
}
|
|
326
|
+
onMouseOut={() => handleLeave(overlayRect)}
|
|
327
|
+
/>
|
|
328
|
+
);
|
|
329
|
+
})}
|
|
330
|
+
</g>
|
|
331
|
+
);
|
|
332
|
+
})}
|
|
333
|
+
</g>
|
|
334
|
+
);
|
|
335
|
+
})}
|
|
336
|
+
</g>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- Track config ---
|
|
341
|
+
|
|
342
|
+
export const PEAKS_BIGBED_URL =
|
|
343
|
+
"https://users.wenglab.org/gaomingshi/no_trim.TF_name.rPeaks.bb";
|
|
344
|
+
export const DECORATOR_BIGBED_URL =
|
|
345
|
+
"https://users.wenglab.org/gaomingshi/no_trim.TF_name.decorator.bb";
|
|
346
|
+
|
|
347
|
+
export const tfPeaksTrack: OverlayBigBedConfig = {
|
|
348
|
+
id: "custom-tf-peaks",
|
|
349
|
+
title: "TF Peaks (Overlay)",
|
|
350
|
+
shortLabel: "TF Peaks",
|
|
351
|
+
trackType: TrackType.Custom,
|
|
352
|
+
displayMode: DisplayMode.Full,
|
|
353
|
+
color: Vibrant[0],
|
|
354
|
+
height: 80,
|
|
355
|
+
primaryUrl: PEAKS_BIGBED_URL,
|
|
356
|
+
overlayUrl: DECORATOR_BIGBED_URL,
|
|
357
|
+
baseColor: "#d1d5db",
|
|
358
|
+
overlayColor: "#1e3a8a",
|
|
359
|
+
tooltip: TfPeaksTooltip,
|
|
360
|
+
settingsPanel: TfPeaksSettings,
|
|
361
|
+
renderers: {
|
|
362
|
+
[DisplayMode.Full]: OverlayBigBedRenderer,
|
|
363
|
+
},
|
|
364
|
+
fetcher: async (ctx) => {
|
|
365
|
+
const track = ctx.track as OverlayBigBedConfig;
|
|
366
|
+
const [primary, overlay] = await Promise.all([
|
|
367
|
+
fetchBigBedUrl(track.primaryUrl, ctx),
|
|
368
|
+
fetchBigBedUrl(track.overlayUrl, ctx),
|
|
369
|
+
]);
|
|
370
|
+
const error =
|
|
371
|
+
[primary.error, overlay.error].filter(Boolean).join("\n") || null;
|
|
372
|
+
return {
|
|
373
|
+
data: { primary: primary.data || [], overlay: overlay.data || [] },
|
|
374
|
+
error,
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
};
|
package/test/main.tsx
CHANGED
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
Track,
|
|
31
31
|
TrackType,
|
|
32
32
|
TranscriptConfig,
|
|
33
|
-
tfPeaksTrack,
|
|
34
33
|
} from "@weng-lab/genomebrowser";
|
|
35
34
|
|
|
36
35
|
// local
|
|
@@ -39,6 +38,7 @@ import type { BiosampleRowInfo } from "../src/TrackSelect/Folders/biosamples/sha
|
|
|
39
38
|
import type { GeneRowInfo } from "../src/TrackSelect/Folders/genes/shared/types";
|
|
40
39
|
import type { OtherTrackInfo } from "../src/TrackSelect/Folders/other-tracks/shared/types";
|
|
41
40
|
import { Exon } from "@weng-lab/genomebrowser/dist/components/tracks/transcript/types";
|
|
41
|
+
import { tfPeaksTrack } from "@weng-lab/genomebrowser/test/TfPeaks";
|
|
42
42
|
|
|
43
43
|
interface Transcript {
|
|
44
44
|
id: string;
|