Skip to content

Commit f87f7e3

Browse files
authored
feat: add vega embed component and use for measles whitepaper (#980)
Implements #979 Need to make one cleanup pass, and we may want to adjust the vega spec to fit the expected dimensions better (I can elaborate), but this gets the job done: <img width="849" height="773" alt="image" src="https://github.com/user-attachments/assets/49004949-a72a-4d61-b089-e41739d4dc02" />
2 parents 73577ae + c7c93a4 commit f87f7e3

File tree

9 files changed

+44046
-24
lines changed

9 files changed

+44046
-24
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { VegaEmbed } from "./vegaEmbed";
2+
export type { VegaEmbedProps } from "./vegaEmbed";
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { bpUpSm } from "@databiosphere/findable-ui/lib/styles/common/mixins/breakpoints";
2+
import { FONT } from "@databiosphere/findable-ui/lib/styles/common/constants/font";
3+
import { PALETTE } from "@databiosphere/findable-ui/lib/styles/common/constants/palette";
4+
import styled from "@emotion/styled";
5+
6+
export const VegaEmbedContainer = styled.figure`
7+
margin: 16px 0;
8+
9+
.vega-container {
10+
margin: 0 auto;
11+
max-width: 100%;
12+
overflow-x: auto;
13+
width: 100%;
14+
}
15+
16+
.error {
17+
color: ${PALETTE.WARNING_MAIN};
18+
font: ${FONT.BODY_LARGE_400};
19+
margin-bottom: 16px;
20+
}
21+
22+
figcaption {
23+
color: ${PALETTE.INK_LIGHT};
24+
display: block;
25+
font: ${FONT.BODY_LARGE_400_2_LINES};
26+
margin-top: 16px;
27+
text-align: justify;
28+
29+
${bpUpSm} {
30+
display: flex;
31+
gap: 0 64px;
32+
margin-top: 16px;
33+
text-align: unset;
34+
35+
span {
36+
flex: 1;
37+
}
38+
}
39+
}
40+
`;
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"use client";
2+
3+
import { ReactNode, useEffect, useRef, useState } from "react";
4+
import embed from "vega-embed";
5+
import type { VisualizationSpec } from "vega-embed";
6+
import { VegaEmbedContainer } from "./vegaEmbed.styles";
7+
8+
export interface VegaEmbedProps {
9+
caption?: ReactNode;
10+
spec: string | VisualizationSpec;
11+
}
12+
13+
/**
14+
* Extract genome_pos extent from a single dataset array.
15+
* @param dataset - Array of data points.
16+
* @returns The min and max values found in this dataset.
17+
*/
18+
function getDatasetExtent(dataset: unknown[]): {
19+
max: number;
20+
min: number;
21+
} {
22+
let min = Infinity;
23+
let max = -Infinity;
24+
25+
for (const dataPoint of dataset) {
26+
const point = dataPoint as Record<string, unknown>;
27+
if (
28+
point.genome_pos !== undefined &&
29+
typeof point.genome_pos === "number"
30+
) {
31+
min = Math.min(min, point.genome_pos);
32+
max = Math.max(max, point.genome_pos);
33+
}
34+
}
35+
36+
return { max, min };
37+
}
38+
39+
/**
40+
* Calculate the data extent for genome_pos field in datasets.
41+
* @param vegaSpec - The Vega specification.
42+
* @returns The min and max values, or null if no data found.
43+
*/
44+
function calculateGenomePosExtent(
45+
vegaSpec: VisualizationSpec
46+
): [number, number] | null {
47+
let xMin = Infinity;
48+
let xMax = -Infinity;
49+
50+
if (vegaSpec.datasets) {
51+
const datasets = Object.values(
52+
vegaSpec.datasets as Record<string, unknown>
53+
);
54+
for (const dataset of datasets) {
55+
if (Array.isArray(dataset)) {
56+
const { max, min } = getDatasetExtent(dataset);
57+
xMin = Math.min(xMin, min);
58+
xMax = Math.max(xMax, max);
59+
}
60+
}
61+
}
62+
63+
return xMin !== Infinity && xMax !== -Infinity ? [xMin, xMax] : null;
64+
}
65+
66+
/**
67+
* Set explicit x-axis domain on a Vega spec to ensure proper centering.
68+
* @param vegaSpec - The Vega specification to modify.
69+
* @param domain - The domain to set [min, max].
70+
*/
71+
function setXAxisDomain(
72+
vegaSpec: VisualizationSpec,
73+
domain: [number, number]
74+
): void {
75+
const spec = vegaSpec as Record<string, unknown>;
76+
77+
if (spec.vconcat && Array.isArray(spec.vconcat)) {
78+
for (const view of spec.vconcat) {
79+
const v = view as Record<string, unknown>;
80+
const encoding = v.encoding as Record<string, unknown> | undefined;
81+
if (encoding?.x) {
82+
const x = encoding.x as Record<string, unknown>;
83+
encoding.x = {
84+
...x,
85+
scale: {
86+
...(x.scale as Record<string, unknown> | undefined),
87+
domain,
88+
nice: false,
89+
},
90+
};
91+
}
92+
}
93+
} else if (spec.encoding) {
94+
const encoding = spec.encoding as Record<string, unknown>;
95+
if (encoding.x) {
96+
const x = encoding.x as Record<string, unknown>;
97+
encoding.x = {
98+
...x,
99+
scale: {
100+
...(x.scale as Record<string, unknown> | undefined),
101+
domain,
102+
nice: false,
103+
},
104+
};
105+
}
106+
}
107+
}
108+
109+
/**
110+
* VegaEmbed component for rendering Vega-Lite visualizations.
111+
* @param props - Component props.
112+
* @param props.caption - Optional caption to display below the visualization.
113+
* @param props.spec - URL to a JSON spec or a Vega-Lite specification object.
114+
* @returns JSX element containing the Vega-Lite visualization.
115+
*/
116+
export const VegaEmbed = ({ caption, spec }: VegaEmbedProps): JSX.Element => {
117+
const containerRef = useRef<HTMLDivElement>(null);
118+
const [error, setError] = useState<string | null>(null);
119+
120+
useEffect(() => {
121+
let result: Awaited<ReturnType<typeof embed>> | null = null;
122+
123+
const loadSpec = async (): Promise<void> => {
124+
if (!containerRef.current) return;
125+
126+
try {
127+
let vegaSpec: VisualizationSpec;
128+
129+
// If spec is a string (URL), fetch it
130+
if (typeof spec === "string") {
131+
const response = await fetch(spec);
132+
if (!response.ok) {
133+
throw new Error(`Failed to fetch spec: ${response.statusText}`);
134+
}
135+
vegaSpec = await response.json();
136+
} else {
137+
vegaSpec = spec;
138+
}
139+
140+
// Calculate and set x-axis domain for proper centering
141+
const extent = calculateGenomePosExtent(vegaSpec);
142+
if (extent) {
143+
const [min, max] = extent;
144+
const range = max - min;
145+
const padding = range * 0.05;
146+
const paddedExtent: [number, number] = [min - padding, max + padding];
147+
setXAxisDomain(vegaSpec, paddedExtent);
148+
}
149+
150+
// Embed the visualization with responsive sizing
151+
result = await embed(containerRef.current, vegaSpec, {
152+
actions: {
153+
compiled: false,
154+
editor: false,
155+
export: true,
156+
source: false,
157+
},
158+
config: {
159+
autosize: {
160+
resize: true,
161+
type: "fit-x",
162+
},
163+
scale: {
164+
nice: false,
165+
},
166+
},
167+
});
168+
setError(null);
169+
} catch (err) {
170+
const errorMessage =
171+
err instanceof Error ? err.message : "Unknown error";
172+
setError(errorMessage);
173+
console.error("Error loading Vega-Lite spec:", err);
174+
}
175+
};
176+
177+
loadSpec();
178+
179+
return (): void => {
180+
if (result) {
181+
result.finalize();
182+
}
183+
};
184+
}, [spec]);
185+
186+
return (
187+
<VegaEmbedContainer>
188+
{error && (
189+
<div className="error">Error loading visualization: {error}</div>
190+
)}
191+
<div ref={containerRef} className="vega-container" />
192+
{caption && <figcaption>{caption}</figcaption>}
193+
</VegaEmbedContainer>
194+
);
195+
};

app/docs/common/mdx/constants.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { MDXComponents } from "mdx/types";
2+
import { Alert } from "@databiosphere/findable-ui/lib/components/common/Alert/alert";
23
import { AnchorLink } from "@databiosphere/findable-ui/lib/components/common/AnchorLink/anchorLink";
3-
import { SectionOverview } from "../../../components/Docs/components/SectionOverview/sectionOverview";
4-
import { Figure } from "../../../components/common/Figure/figure";
54
import { Link } from "../../../components/Docs/components/common/Link/link";
6-
import { Alert } from "@databiosphere/findable-ui/lib/components/common/Alert/alert";
7-
import { Video } from "../../../components/Docs/components/common/Video/video";
5+
import { SectionOverview } from "../../../components/Docs/components/SectionOverview/sectionOverview";
86
import { Table } from "../../../components/Docs/components/common/Table/table";
7+
import { Video } from "../../../components/Docs/components/common/Video/video";
8+
import { Figure } from "../../../components/common/Figure/figure";
9+
import { VegaEmbed } from "../../../components/common/VegaEmbed/vegaEmbed";
910

1011
export const MDX_COMPONENTS: MDXComponents = {
1112
Alert,
1213
AnchorLink,
1314
Figure,
1415
SectionOverview,
16+
VegaEmbed,
1517
Video,
1618
a: Link,
1719
table: Table,

app/docs/learn/featured-analyses/evolutionary-dynamics-of-coding-overlaps-in-measles.mdx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,7 @@ The workflow described above produced a list of variants observed in each sample
192192

193193
First we looked for general patterns of substitutions within P/C and P/V overlaps by simply plotting aggregate data for each substitutions (e.g., the number of samples a substitution occurs at, median, min, and max alternative allele frequencies and its effect, synonymous or non-synonymous, on each reading frame). This is presented in Fig. 5. One can see that in both cases the P reading frame appears to be more permissive—most substitutions are non-synonymous (“red”) in P and synonymous (“green”) in C or V.
194194

195-
<Figure
196-
alt="Nucleotide changes within P/C (top) and P/V (bottom) overlaps"
195+
<VegaEmbed
197196
caption={
198197
<figcaption>
199198
<span>
@@ -203,18 +202,14 @@ First we looked for general patterns of substitutions within P/C and P/V overlap
203202
for that frame; length of each tick represents the alternative allele
204203
frequency spread = the difference between min and max values. The
205204
opacity is the number of samples (from min = 2 to max = 225) the change
206-
is found in.
205+
is found in. This is an interactive visualization - hover over data
206+
points for details and use the controls to zoom and pan.
207207
</span>
208208
</figcaption>
209209
}
210-
src="/learn/featured-analyses/evolutionary-dynamics-of-coding-overlaps-in-measles/nucleotide-changes.png"
210+
spec="/learn/featured-analyses/evolutionary-dynamics-of-coding-overlaps-in-measles/nucleotide-changes-vega-spec.json"
211211
/>
212212

213-
<Alert icon={false} severity="info">
214-
You can view the interactive view of the above image
215-
[here](https://usegalaxy.org/visualizations/display?visualization=jupyterlite&dataset_id=f9cad7b01a47213516812bfc3ef85974)
216-
</Alert>
217-
218213
Nucleotide substitutions in the C-protein and the V-protein region downstream of the editing site appear to affect C and V protein function less than they affect protein P (Table 4). Variants in the C–P overlap (nucleotides ~1,836–2,370) are mostly synonymous in C but nonsynonymous in P. This suggests strong purifying selection on C (which contains nuclear localization, export, and SHCBP1-binding signals), while P tolerates or favors amino-acid changes in this region, indicating adaptive fine-tuning of P’s polymerase cofactor and regulatory functions while C’s integrity is maintained. Substitutions in the P–V overlap (nucleotides ~2,514–2,704) frequently show synonymous changes in V but nonsynonymous in P, again pointing to P-directed adaptation. However, some sites (e.g., 2514-A, 2580-G) show the opposite pattern (nonsynonymous in V, synonymous in P), possibly corresponding to residues within or adjacent to V/P amino-acid positions 100–120, a region known to mediate STAT1 and Jak1 binding [[8]](https://pubmed.ncbi.nlm.nih.gov/18385234/) [[9]](https://pubmed.ncbi.nlm.nih.gov/20980517/). These variants could represent V-specific adaptive evolution affecting interferon antagonism, while the broader pattern reflects the evolutionary compromise needed to maintain two overlapping reading frames with distinct but interdependent functions (see more on this in the evolutionary analysis section below).
219214

220215
**Table 4.** Nucleotide substitutions within P/C and P/V overlaps. Samples = number of samples this variant is observed in

0 commit comments

Comments
 (0)