|
| 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 | +} |
0 commit comments