UNPKG

@xulab-research/vue-anatomogram

Version:

Interactive anatomical diagrams for Vue.js applications - A Vue-compatible rewrite of EBI anatomogram

536 lines (456 loc) 19.2 kB
export class AnatomogramSvg { constructor(options = {}) { this.options = { species: 'homo_sapiens', // Default species is Homo sapiens selectedView: null, // Selected view (optional) idsWithMarkup: [], // Array of IDs with markup styles onMouseOver: () => {}, // Mouse over event handler onMouseOut: () => {}, // Mouse out event handler onClick: () => {}, // Click event handler atlasUrl: 'https://www.ebi.ac.uk/gxa/', // Base URL for atlas ...options }; this.svgElement = null; // SVG element this.container = null; // Container to mount the SVG this.eventListeners = new Map(); // Map to store event listeners this.elementCache = new Map(); // Cache to store found elements } async mount(container) { if (!container) { throw new Error('Container element is required'); // Error if no container is provided } this.container = container; this.clearContainer(); // Clear container before rendering new SVG try { await this.loadSvg(); // Load SVG asynchronously } catch (error) { this.renderError(error.message); // Render error if loading fails } return this; } clearContainer() { this.container.innerHTML = ` <div class="anatomogram-svg-wrapper"> <div class="anatomogram-loading">Loading SVG...</div> </div> `; // Show loading message in container } getSvgPaths() { const { species, selectedView } = this.options; const filename = `${species}${selectedView ? `.${selectedView}` : ''}.svg`; // Determine filename based on species and selected view let baseUrl = ''; try { const scriptTags = document.querySelectorAll('script'); for (const script of scriptTags) { if (script.src && script.src.includes('vue-anatomogram')) { const url = new URL(script.src); baseUrl = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1); // Determine base URL break; } } } catch (e) { console.warn('Failed to determine base URL:', e); // Warn if base URL detection fails } return [ `/svg/${filename}`, `/public/svg/${filename}`, `/assets/svg/${filename}`, `${baseUrl}svg/${filename}`, `${baseUrl}src/svg/${filename}`, `./svg/${filename}`, `./src/svg/${filename}`, `./assets/svg/${filename}`, `/node_modules/@xulab-research/vue-anatomogram/src/svg/${filename}`, `./node_modules/@xulab-research/vue-anatomogram/src/svg/${filename}`, `${this.options.atlasUrl}svg/${filename}`, `https://www.ebi.ac.uk/gxa/svg/${filename}` ]; // Return multiple possible paths for the SVG file } async loadSvg() { const paths = this.getSvgPaths(); let svgContent = null; for (const path of paths) { try { const response = await fetch(path); if (response.ok) { svgContent = await response.text(); break; // Exit loop once SVG is successfully loaded } } catch (error) { continue; // Continue trying other paths if the current one fails } } if (!svgContent) { throw new Error(`Unable to load SVG file: ${this.options.species}${this.options.selectedView ? `.${this.options.selectedView}` : ''}.svg`); } this.renderSvg(svgContent); // Render SVG once successfully loaded } renderSvg(svgContent) { const wrapper = this.container.querySelector('.anatomogram-svg-wrapper'); wrapper.innerHTML = ` <div class="anatomogram-svg-container"> ${svgContent} </div> `; // Insert SVG content into container this.svgElement = wrapper.querySelector('svg'); if (this.svgElement) { this.setupSvgStyles(); // Set up styles for the SVG this.setupInteractions(); // Set up interactions for the SVG elements } } setupSvgStyles() { if (!this.svgElement) return; this.svgElement.style.cssText = ` width: 100%; height: auto; max-width: 100%; display: block; `; // Set the SVG's CSS styles for responsive display } setupInteractions() { if (!this.svgElement) return; this.clearEventListeners(); // Clear previous event listeners this.elementCache.clear(); // Clear the element cache this.options.idsWithMarkup.forEach(({ id, markupNormal, markupUnderFocus }) => { const elements = this.findAllElementsById(id); // Find all elements with the given ID elements.forEach(element => { this.setupElementInteraction(element, id, markupNormal, markupUnderFocus); // Set up interaction for each element }); }); } findAllElementsById(id) { // First, check the cache if (this.elementCache.has(id)) { return this.elementCache.get(id); } const elements = new Set(); // Use a Set to avoid duplicates try { // Method 1: Standard getElementById const directElement = this.svgElement.getElementById(id); if (directElement) { elements.add(directElement); } // Method 2: Use attribute selectors to find all types of SVG elements const selectors = [ `#${this.escapeId(id)}`, `[id="${id}"]`, `g#${this.escapeId(id)}`, `g[id="${id}"]`, `path#${this.escapeId(id)}`, `path[id="${id}"]`, `circle#${this.escapeId(id)}`, `circle[id="${id}"]`, `ellipse#${this.escapeId(id)}`, `ellipse[id="${id}"]`, `polygon#${this.escapeId(id)}`, `polygon[id="${id}"]`, `rect#${this.escapeId(id)}`, `rect[id="${id}"]`, `line#${this.escapeId(id)}`, `line[id="${id}"]`, `polyline#${this.escapeId(id)}`, `polyline[id="${id}"]`, `use#${this.escapeId(id)}`, `use[id="${id}"]`, `text#${this.escapeId(id)}`, `text[id="${id}"]`, `image#${this.escapeId(id)}`, `image[id="${id}"]` ]; selectors.forEach(selector => { try { const foundElements = this.svgElement.querySelectorAll(selector); foundElements.forEach(element => elements.add(element)); } catch (e) { // Ignore invalid selectors } }); // Method 3: Find use elements that reference the ID const useElements = this.svgElement.querySelectorAll('use'); useElements.forEach(useElement => { const href = useElement.getAttribute('href') || useElement.getAttribute('xlink:href') || useElement.getAttributeNS('http://www.w3.org/1999/xlink', 'href'); if (href === `#${id}`) { elements.add(useElement); // Also try to find the referenced original element const referencedElement = this.svgElement.getElementById(id); if (referencedElement) { elements.add(referencedElement); } } }); // Method 4: Search within defs for elements const defsElements = this.svgElement.querySelectorAll('defs *'); defsElements.forEach(element => { if (element.id === id || element.getAttribute('id') === id) { elements.add(element); } }); // Method 5: Deep search all elements (last resort) const allElements = this.svgElement.querySelectorAll('*'); allElements.forEach(element => { if (element.id === id || element.getAttribute('id') === id) { elements.add(element); } }); } catch (error) { console.warn(`Error finding elements for ID ${id}:`, error); } const elementsArray = Array.from(elements); // Cache the result this.elementCache.set(id, elementsArray); return elementsArray; } // Safe ID escape escapeId(id) { try { return CSS.escape(id); // Use CSS.escape if supported } catch (e) { // Fallback to manual escape return id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&'); } } setupElementInteraction(element, id, markupNormal, markupUnderFocus) { if (!element) return; // Apply initial style if (markupNormal) { this.applyStyle(element, markupNormal); } // Create event handlers const handlers = { mouseover: (event) => { event.stopPropagation(); if (markupUnderFocus) { this.applyStyle(element, markupUnderFocus); } this.options.onMouseOver([id], event); }, mouseout: (event) => { event.stopPropagation(); if (markupNormal) { this.applyStyle(element, markupNormal); } this.options.onMouseOut([id], event); }, click: (event) => { event.preventDefault(); event.stopPropagation(); this.options.onClick([id], event); } }; // Add event listeners Object.entries(handlers).forEach(([event, handler]) => { element.addEventListener(event, handler); }); // Store handlers for cleanup this.eventListeners.set(element, handlers); // Set cursor style element.style.cursor = 'pointer'; // Add class for debugging element.classList.add('anatomogram-interactive'); } applyStyle(element, style) { if (!element || !style) return; const tagName = element.tagName.toLowerCase(); switch (tagName) { case 'g': this.applyGroupStyle(element, style); break; case 'use': this.applyUseStyle(element, style); break; case 'path': this.applyPathStyle(element, style); break; case 'circle': this.applyCircleStyle(element, style); break; case 'ellipse': this.applyEllipseStyle(element, style); break; case 'polygon': case 'polyline': this.applyPolygonStyle(element, style); break; case 'rect': this.applyRectStyle(element, style); break; case 'line': this.applyLineStyle(element, style); break; case 'text': this.applyTextStyle(element, style); break; default: this.applyGenericStyle(element, style); } } applyGroupStyle(groupElement, style) { // Apply style to group element and all its children this.setElementAttributes(groupElement, style); const children = groupElement.querySelectorAll('*'); children.forEach(child => { this.setElementAttributes(child, style); }); } applyUseStyle(useElement, style) { // Apply style to use element this.setElementAttributes(useElement, style); const href = useElement.getAttribute('href') || useElement.getAttribute('xlink:href') || useElement.getAttributeNS('http://www.w3.org/1999/xlink', 'href'); if (href && href.startsWith('#')) { const referencedId = href.slice(1); const referencedElement = this.svgElement.getElementById(referencedId); if (referencedElement) { this.setElementAttributes(referencedElement, style); if (referencedElement.tagName.toLowerCase() === 'g') { this.applyGroupStyle(referencedElement, style); } } } } applyPathStyle(pathElement, style) { this.setElementAttributes(pathElement, style); if (style.fill !== undefined) { pathElement.style.fill = style.fill; pathElement.setAttribute('fill', style.fill); } if (style.stroke !== undefined) { pathElement.style.stroke = style.stroke; pathElement.setAttribute('stroke', style.stroke); } } applyCircleStyle(circleElement, style) { this.setElementAttributes(circleElement, style); if (style.fill !== undefined) { circleElement.style.fill = style.fill; circleElement.setAttribute('fill', style.fill); } } applyEllipseStyle(ellipseElement, style) { this.setElementAttributes(ellipseElement, style); if (style.fill !== undefined) { ellipseElement.style.fill = style.fill; ellipseElement.setAttribute('fill', style.fill); } } applyPolygonStyle(polygonElement, style) { this.setElementAttributes(polygonElement, style); } applyRectStyle(rectElement, style) { this.setElementAttributes(rectElement, style); } applyLineStyle(lineElement, style) { this.setElementAttributes(lineElement, style); if (style.stroke !== undefined) { lineElement.style.stroke = style.stroke; lineElement.setAttribute('stroke', style.stroke); } } applyTextStyle(textElement, style) { this.setElementAttributes(textElement, style); if (style.fill !== undefined) { textElement.style.fill = style.fill; textElement.setAttribute('fill', style.fill); } } applyGenericStyle(element, style) { this.setElementAttributes(element, style); } setElementAttributes(element, style) { if (!element || !style) return; Object.entries(style).forEach(([key, value]) => { try { switch (key) { case 'fill': element.setAttribute('fill', value); element.style.fill = value; element.style.setProperty('fill', value, 'important'); break; case 'stroke': element.setAttribute('stroke', value); element.style.stroke = value; element.style.setProperty('stroke', value, 'important'); break; case 'strokeWidth': element.setAttribute('stroke-width', value); element.style.strokeWidth = value; element.style.setProperty('stroke-width', value, 'important'); break; case 'opacity': element.setAttribute('opacity', value); element.style.opacity = value; element.style.setProperty('opacity', value, 'important'); break; case 'fillOpacity': element.setAttribute('fill-opacity', value); element.style.fillOpacity = value; break; case 'strokeOpacity': element.setAttribute('stroke-opacity', value); element.style.strokeOpacity = value; break; default: element.setAttribute(key, value); if (typeof element.style[key] !== 'undefined') { element.style[key] = value; } } } catch (error) { console.warn(`Failed to set ${key}=${value} on element:`, element, error); } }); element.style.display = 'none'; element.offsetHeight; // Trigger reflow element.style.display = ''; } renderError(message) { const wrapper = this.container.querySelector('.anatomogram-svg-wrapper'); wrapper.innerHTML = ` <div class="anatomogram-error" style=" text-align: center; padding: 40px; color: #dc3545; border: 2px dashed #f5c6cb; border-radius: 8px; background: #fff5f5; "> <h4>Failed to load SVG</h4> <p>${message}</p> <small>Please ensure the SVG file is located in one of the following paths: /svg/, /public/svg/, ./svg/</small> </div> `; // Display error message if SVG loading fails } async update(newOptions = {}) { Object.assign(this.options, newOptions); if (newOptions.species || newOptions.selectedView !== undefined) { if (this.container) { await this.mount(this.container); // Remount if species or selectedView is updated } } else { this.setupInteractions(); // Reapply interactions if no major changes } return this; } clearEventListeners() { this.eventListeners.forEach((handlers, element) => { Object.entries(handlers).forEach(([event, handler]) => { element.removeEventListener(event, handler); // Remove all event listeners }); }); this.eventListeners.clear(); } destroy() { this.clearEventListeners(); // Clear event listeners on destroy this.elementCache.clear(); // Clear element cache if (this.container) { this.container.innerHTML = ''; // Clear container content this.container = null; } this.svgElement = null; // Clear SVG reference this.options = null; // Clear options } } export default AnatomogramSvg;