@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;