Skip to content

Commit 55e111f

Browse files
authored
Merge pull request #370 from eweitz/pathway-diagrams
Show pathway diagrams for interacting genes
2 parents dc77903 + 082fbf4 commit 55e111f

10 files changed

Lines changed: 368 additions & 69 deletions

File tree

105 KB
Binary file not shown.
780 Bytes
Binary file not shown.

examples/vanilla/gene-leads-example-style.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ h1, .control-container, .wrapper, .why, .by {
1111
}
1212
h1 {
1313
font-size: 32px;
14-
margin: 60px auto 60px auto;
14+
margin: 10px auto 40px auto;
1515
}
1616
h1 small {
1717
font-size: 60%;
@@ -37,7 +37,7 @@ h1 small {
3737
font-size: 16px;
3838
text-align: center;
3939
color: #6f6f6f;
40-
margin: 40px 60px 0 60px;
40+
margin: 100px 60px 0 60px;
4141
}
4242

4343
a, a:visited {text-decoration: none;}

examples/vanilla/gene-leads.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ <h1>Gene Leads<small>Enrich your gene search</small></h1>
7878
</label>
7979
</div>
8080
</div>
81-
<br/><br/><br/>
81+
<br/><br/>
8282
<div class="wrapper">
8383
<div id="ideogram-container" style="visibility: hidden;"></div>
8484
</div>

src/js/init/write-container.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,13 @@ function getContainerSvgClass(ideo) {
5151
function handleEscape(event) {
5252
if (event.keyCode === 27) { // "Escape" key pressed
5353
const tooltip = document.querySelector('#_ideogramTooltip');
54-
if (!tooltip) return;
55-
tooltip.style.opacity = 0;
54+
if (tooltip) {
55+
tooltip.style.opacity = 0;
56+
}
57+
const pathwayContainer = document.querySelector('#ideo-pathway-container');
58+
if (pathwayContainer) {
59+
pathwayContainer.remove();
60+
}
5661
}
5762
}
5863

src/js/kit/pathway-viewer.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
const PVJS_URL = 'https://cdn.jsdelivr.net/npm/@wikipathways/pvjs@5.0.1/dist/pvjs.vanilla.js';
2+
const SVGPANZOOM_URL = 'https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.5.0/dist/svg-pan-zoom.min.js';
3+
const CONTAINER_ID = 'ideo-pathway-container';
4+
5+
/** Request pvjs / kaavio JSON for a WikiPathways biological pathway diagram */
6+
async function fetchPathwayViewerJson(pwId) {
7+
const origin = 'https://raw.githubusercontent.com'
8+
const repoAndBranch = '/wikipathways/wikipathways-assets/main';
9+
10+
// E.g. https://raw.githubusercontent.com/wikipathways/wikipathways-assets/main/pathways/WP5445/WP5445.json
11+
const url = `${origin}${repoAndBranch}/pathways/${pwId}/${pwId}.json`;
12+
13+
const response = await fetch(url);
14+
const pathwayJson = await response.json();
15+
16+
window.pathwayJson = pathwayJson;
17+
return pathwayJson;
18+
}
19+
20+
/**
21+
* Load pvjs via classic HTML <script> tag, dynamically written to page
22+
*
23+
* pvjs NPM package has several exports, but none quite work without significant
24+
* dependencies, e.g. React.
25+
*
26+
* TODO: Modify pvjs upstream to distribute module imports without e.g. React
27+
*/
28+
async function loadPvjsScript() {
29+
const pvjsScript = document.createElement('script');
30+
pvjsScript.setAttribute('src', PVJS_URL);
31+
document.querySelector('body').appendChild(pvjsScript);
32+
}
33+
34+
/**
35+
* Load svg-pan-zoom via classic HTML <script> tag, dynamically written to page
36+
*/
37+
async function loadSvgpanzoomScript() {
38+
const svgpanzoomScript = document.createElement('script');
39+
svgpanzoomScript.setAttribute('src', SVGPANZOOM_URL);
40+
document.querySelector('body').appendChild(svgpanzoomScript);
41+
}
42+
43+
/** Get pathway entities that have a term matching query text, e.g. a gene */
44+
function findEntitiesByText(text, pathwayJson) {
45+
const matchedEntities =
46+
Object.entries(pathwayJson.entitiesById).filter(([id, entity]) => {
47+
return entity.textContent?.split(' ').some(token => token === text);
48+
});
49+
return matchedEntities;
50+
}
51+
52+
/** Get IDs of entities that have a term matching query text, e.g. a gene */
53+
function getEntityIds(text, pathwayJson) {
54+
const matchedEntities = findEntitiesByText(text, pathwayJson);
55+
const entityIds = matchedEntities.map(e => e[0]);
56+
return entityIds;
57+
}
58+
59+
/** Get highlights to color nodes that match query text, e.g. a gene */
60+
function getHighlights(text, pathwayJson, color) {
61+
const entityIds = getEntityIds(text, pathwayJson);
62+
const highlights = entityIds.map(entityId => [null, entityId, color]);
63+
return highlights;
64+
}
65+
66+
function zoomToEntity(entityId, retryAttempt=0) {
67+
let entityDom = document.querySelector(`#${entityId}`);
68+
const parentClasses = Array.from(entityDom.parentNode.classList);
69+
const isInGroup = parentClasses.includes('Group');
70+
if (isInGroup) {
71+
entityDom = entityDom.parentNode;
72+
}
73+
74+
// Try drawing pathway, retry each .25 s for 10 s if Pvjs hasn't loaded yet
75+
if (typeof entityDom === 'undefined') {
76+
if (retryAttempt <= 40) {
77+
setTimeout(() => {
78+
zoomToEntity(entityId, retryAttempt++);
79+
}, 250);
80+
return;
81+
} else {
82+
throw Error(
83+
'Zoomed entity DOM is undefined. ' +
84+
'Possible causes include unavailable network or CDN.'
85+
);
86+
}
87+
}
88+
89+
const panZoom = svgPanZoom('.Diagram');
90+
// const clientRect = entityDom.getBoundingClientRect();
91+
92+
const svgMatrix = entityDom.transform.baseVal[0].matrix;
93+
94+
const transformLeft = svgMatrix.e;
95+
const transformTop = svgMatrix.f;
96+
// const scale = 0.5161290261053255
97+
98+
const viewport = document.querySelector('.svg-pan-zoom_viewport')
99+
100+
const viewportMatrix = viewport.transform.baseVal[0].matrix;
101+
const viewportScale = viewportMatrix.a;
102+
const viewportLeft = viewportMatrix.e;
103+
104+
// panZoom.zoomAtPointBy(3, {x: 282*0.47+213-30, y: 107.5*0.47-10});
105+
panZoom.zoomAtPointBy(3, {
106+
x: transformLeft * viewportScale + viewportLeft - 60,
107+
y: transformTop * viewportScale - 10
108+
});
109+
110+
window.viewport = viewport;
111+
window.panZoom = panZoom;
112+
window.entityDom = entityDom;
113+
// window.clientRect = clientRect
114+
window.svgMatrix = svgMatrix
115+
window.transformLeft = transformLeft
116+
window.transformTop = transformTop
117+
window.viewportScale = viewportScale
118+
window.viewportLeft = viewportLeft
119+
120+
// panZoom.zoomAtPoint(2, sourceEntityDom.getBoundingClientRect());
121+
// panZoom.zoomAtPoint(2, sourceEntityDom.getBoundingClientRect());
122+
}
123+
124+
/** Add header bar to pathway diagram with name, link, close button, etc. */
125+
function addHeader(pwId, pathwayJson, pathwayContainer) {
126+
const pathwayName = pathwayJson.pathway.name;
127+
const url = `https://wikipathways.org/pathways/${pwId}`;
128+
const linkAttrs = `href="${url}" target="_blank"`;
129+
130+
// Link to full page on WikiPathways, using pathway title
131+
const pathwayLink = `<a ${linkAttrs}>${pathwayName}</a>`;
132+
133+
// Close button
134+
const style =
135+
'style="float: right; background-color: #aaa; border: none; ' +
136+
'color: white; font-weight: bold; font-size: 16px; padding: 0px 4px; ' +
137+
'border-radius: 3px; cursor: pointer;"';
138+
const buttonAttrs = `class="_ideoPathwayCloseButton" ${style}`;
139+
const closeButton = `<button ${buttonAttrs}}>x</button>`;
140+
141+
const headerBar =
142+
`<div class="_ideoPathwayHeader">${pathwayLink}${closeButton}</div>`;
143+
pathwayContainer.insertAdjacentHTML('afterBegin', headerBar);
144+
145+
const closeButtonDom = document.querySelector('._ideoPathwayCloseButton');
146+
closeButtonDom.addEventListener('click', function(event) {
147+
const pathwayContainer = document.querySelector(`#${CONTAINER_ID}`);
148+
pathwayContainer.remove();
149+
});
150+
}
151+
152+
/** Fetch and render WikiPathways diagram for given pathway ID */
153+
export async function drawPathway(
154+
pwId, sourceGene, destGene, dimensions={height: 440, width: 900},
155+
retryAttempt=0
156+
) {
157+
const pvjsScript = document.querySelector(`script[src="${PVJS_URL}"]`);
158+
if (!pvjsScript) {loadPvjsScript();}
159+
160+
// const svgpanzoomScript =
161+
// document.querySelector(`script[src="${SVGPANZOOM_URL}"]`);
162+
// if (!svgpanzoomScript) {loadSvgpanzoomScript();}
163+
164+
const containerSelector = `#${CONTAINER_ID}`;
165+
166+
// Try drawing pathway, retry each .25 s for 10 s if Pvjs hasn't loaded yet
167+
if (
168+
typeof Pvjs === 'undefined'
169+
// || typeof svgPanZoom === 'undefined'
170+
) {
171+
if (retryAttempt <= 40) {
172+
setTimeout(() => {
173+
drawPathway(pwId, sourceGene, destGene, dimensions, retryAttempt++);
174+
}, 250);
175+
return;
176+
} else {
177+
throw Error(
178+
'Pvjs is undefined. ' +
179+
'Possible causes include unavailable network or CDN.'
180+
);
181+
}
182+
}
183+
184+
// Get pathway diagram data
185+
const pathwayJson = await fetchPathwayViewerJson(pwId);
186+
187+
const sourceEntityId = getEntityIds(sourceGene, pathwayJson)[0];
188+
const destEntityId = getEntityIds(destGene, pathwayJson)[0];
189+
190+
const sourceHighlights = getHighlights(sourceGene, pathwayJson, 'red');
191+
const destHighlights = getHighlights(destGene, pathwayJson, 'purple');
192+
const highlights = sourceHighlights.concat(destHighlights);
193+
194+
const oldPathwayContainer = document.querySelector(containerSelector);
195+
const ideoContainerDom = document.querySelector('#_ideogramOuterWrap');
196+
if (oldPathwayContainer) {
197+
oldPathwayContainer.remove();
198+
}
199+
200+
const dim = dimensions;
201+
const widthCss = `width: 900px;`
202+
const pvjsDimensions = `height: ${dim.height}px; ${widthCss}`;
203+
const containerDimensions = `height: ${dim.height + 20}px; ${widthCss}`;
204+
const style = `border: 0.5px solid #DDD; ${containerDimensions} margin: auto;`;
205+
const pvjsContainerHtml = `<div id="ideo-pvjs-container" style="${pvjsDimensions}"></div>`;
206+
const containerHtml = `<div id="${CONTAINER_ID}" style="${style}">${pvjsContainerHtml}</div>`;
207+
ideoContainerDom.insertAdjacentHTML('afterEnd', containerHtml);
208+
const pathwayContainer = document.querySelector(containerSelector);
209+
const pvjsContainer = document.querySelector('#ideo-pvjs-container');
210+
211+
// Pvjs parameters
212+
// Source: https://github.com/wikipathways/pvjs/blob/fb321e5b8796ecc3312c9a604f75b7ace94a81aa/src/Pvjs.tsx#L392
213+
// Docs: https://github.com/wikipathways/pvjs#-props
214+
const pvjsProps = {
215+
theme: 'plain',
216+
opacities: [],
217+
highlights,
218+
panned: [sourceEntityId], // TODO: Pvjs documents this, but it's unsupported
219+
zoomed: [sourceEntityId], // TODO: Pvjs documents this, but it's unsupported
220+
pathway: pathwayJson.pathway,
221+
entitiesById: pathwayJson.entitiesById,
222+
detailPanelOpen: false,
223+
// showPanZoomControls: false,
224+
selected: null
225+
};
226+
227+
// const pathwayViewer = new Pvjs(pvjsProps);
228+
const pathwayViewer = new Pvjs(pvjsContainer, pvjsProps);
229+
window.pathwayViewer = pathwayViewer;
230+
231+
addHeader(pwId, pathwayJson, pathwayContainer);
232+
233+
// zoomToEntity(sourceEntityId);
234+
}

src/js/kit/related-genes.js

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
import {decompressSync, strFromU8} from 'fflate';
2525
import tippy, {hideAll} from 'tippy.js';
2626
import {tippyCss, tippyLightCss} from './tippy-styles';
27+
// import {Pvjs} from 'eweitz-pvjs';
28+
import { drawPathway } from './pathway-viewer';
29+
2730

2831
import {
2932
initAnalyzeRelatedGenes, analyzePlotTimes, analyzeRelatedGenes, timeDiff,
@@ -48,6 +51,7 @@ import {getTissueHtml, addTissueListeners} from './tissue';
4851
// import {drawAnnotsByLayoutType} from '../annotations/draw';
4952
// import {organismMetadata} from '../init/organism-metadata';
5053

54+
5155
/** Sets DOM IDs for ideo.relatedAnnots; needed to associate labels */
5256
function setRelatedAnnotDomIds(ideo) {
5357
const updated = [];
@@ -365,10 +369,9 @@ function describeInteractions(gene, ixns, searchedGene) {
365369
pathwayNames.push(ixn.name);
366370
const attrs =
367371
`class="ideo-pathway-link" ` +
368-
`title="View in WikiPathways" ` +
369-
`data-pathway-id="${ixn.pathwayId}" ` +
370-
`target="_blank" ` +
371-
`href="${url}"`;
372+
`style="cursor: pointer" ` +
373+
`title="View pathway diagram from WikiPathways" ` +
374+
`data-pathway-id="${ixn.pathwayId}"`;
372375
return `<a ${attrs}>${ixn.name}</a>`;
373376
});
374377

@@ -1587,27 +1590,27 @@ function getAnnotByName(annotName, ideo) {
15871590
/**
15881591
* Manage click on pathway links in annotation tooltips
15891592
*/
1590-
// function managePathwayClickHandlers(searchedGene, ideo) {
1591-
// setTimeout(function() {
1592-
// const pathways = document.querySelectorAll('.ideo-pathway-link');
1593-
// if (pathways.length > 0 && !ideo.addedPathwayClickHandler) {
1594-
// pathways.forEach(pathway => {
1595-
// // pathway.removeEventListener('click', handlePathwayClick);
1596-
// pathway.addEventListener('click', function(event) {
1597-
// const target = event.target;
1598-
// const pathwayId = target.getAttribute('data-pathway-id');
1599-
// const pathwayName = target.getAttribute('data-pathway-name');
1600-
// const pathway = {id: pathwayId, name: pathwayName};
1601-
// plotPathwayGenes(searchedGene, pathway, ideo);
1602-
// });
1603-
// });
1604-
1605-
// // Ensures handler isn't added redundantly. This is used because
1606-
// // addEventListener options like {once: true} don't suffice
1607-
// // ideo.addedPathwayClickHandler = true;
1608-
// }
1609-
// }, 100);
1610-
// }
1593+
function addPathwayListeners(ideo) {
1594+
const pathways = document.querySelectorAll('.ideo-pathway-link');
1595+
if (pathways.length > 0 && !ideo.addedPathwayClickHandler) {
1596+
pathways.forEach(pathway => {
1597+
// pathway.removeEventListener('click', handlePathwayClick);
1598+
pathway.addEventListener('click', function(event) {
1599+
const target = event.target;
1600+
const pathwayId = target.getAttribute('data-pathway-id');
1601+
1602+
const searchedGene = getSearchedFromDescriptions(ideo);
1603+
const interactingGene =
1604+
document.querySelector('#ideo-related-gene').textContent;
1605+
// const pathwayName = target.getAttribute('data-pathway-name');
1606+
// const pathway = {id: pathwayId, name: pathwayName};
1607+
// plotPathwayGenes(searchedGene, pathway, ideo);
1608+
drawPathway(pathwayId, searchedGene, interactingGene);
1609+
event.stopPropagation();
1610+
});
1611+
});
1612+
}
1613+
}
16111614

16121615
/** Move tooltip mass to vertical center of viewport */
16131616
function centralizeTooltipPosition() {
@@ -1628,6 +1631,7 @@ function onDidShowAnnotTooltip() {
16281631
handleTooltipClick(ideo);
16291632
addGeneStructureListeners(ideo);
16301633
addTissueListeners(ideo);
1634+
addPathwayListeners(ideo);
16311635
ideo.tissueTippy =
16321636
tippy('._ideoGeneTissues[data-tippy-content]', getTippyConfig());
16331637
}
@@ -1828,8 +1832,6 @@ function decorateAnnot(annot) {
18281832

18291833
annot.displayName = originalDisplay;
18301834

1831-
// managePathwayClickHandlers(annot, ideo);
1832-
18331835
return annot;
18341836
}
18351837

@@ -1945,7 +1947,6 @@ function plotGeneHints() {
19451947
* @param {Object} config Ideogram configuration object
19461948
*/
19471949
function _initRelatedGenes(config, annotsInList) {
1948-
19491950
if (config.relatedGenesMode === 'leads') {
19501951
delete config.onDrawAnnots;
19511952
delete config.relatedGenesMode;

0 commit comments

Comments
 (0)