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