@xulab-research/vue-anatomogram
Version:
Interactive anatomical diagrams for Vue.js applications - A Vue-compatible rewrite of EBI anatomogram
433 lines (381 loc) • 13.3 kB
JavaScript
// src/Main.js
import AnatomogramSvg from './AnatomogramSvg.js'
import Assets, { getDefaultView, supportedSpecies } from './Assets.js'
export default class Main {
constructor(options = {}) {
this.options = {
species: 'homo_sapiens', // Default species is Homo sapiens
selectedView: null, // Selected view (optional)
showIds: [], // IDs of organs to show
highlightIds: [], // IDs of organs to highlight
selectIds: [], // IDs of selected organs
showTitle: true, // Option to show title
showControls: true, // Option to show controls
showColour: '#808080', // Default colour for showing organs
highlightColour: '#ff0000', // Colour for highlighted organs
selectColour: '#800080', // Colour for selected organs
showOpacity: 0.4, // Opacity for showing organs
highlightOpacity: 0.6, // Opacity for highlighted organs
selectOpacity: 0.8, // Opacity for selected organs
onClick: () => {}, // Click event handler
onMouseOver: () => {}, // Mouse over event handler
onMouseOut: () => {}, // Mouse out event handler
onViewChanged: () => {}, // View changed event handler
atlasUrl: 'https://www.ebi.ac.uk/gxa/', // Base URL for atlas
...options
}
if (!this.options.selectedView) {
this.options.selectedView = getDefaultView(this.options.species) // Set default view if not provided
}
this.container = null // Container to mount the anatomogram
this.anatomogramSvg = null // SVG element of the anatomogram
this.assets = null // Assets related to the anatomogram
this.mounted = false // Flag indicating if the anatomogram is mounted
this.destroyed = false // Flag indicating if the anatomogram is destroyed
}
generateMarkup() {
const idsWithMarkup = []
// Process organs to show
this.options.showIds.forEach(id => {
idsWithMarkup.push({
id: id,
markupNormal: {
fill: this.options.showColour,
opacity: this.options.showOpacity,
stroke: '#333333',
strokeWidth: 1
},
markupUnderFocus: {
fill: this.options.showColour,
opacity: Math.min(this.options.showOpacity + 0.2, 1.0),
stroke: '#000000',
strokeWidth: 2
}
})
})
// Process highlighted organs
this.options.highlightIds.forEach(id => {
idsWithMarkup.push({
id: id,
markupNormal: {
fill: this.options.highlightColour,
opacity: this.options.highlightOpacity,
stroke: '#333333',
strokeWidth: 1
},
markupUnderFocus: {
fill: this.options.highlightColour,
opacity: Math.min(this.options.highlightOpacity + 0.2, 1.0),
stroke: '#000000',
strokeWidth: 2
}
})
})
// Process selected organs
this.options.selectIds.forEach(id => {
idsWithMarkup.push({
id: id,
markupNormal: {
fill: this.options.selectColour,
opacity: this.options.selectOpacity,
stroke: '#000000',
strokeWidth: 2
},
markupUnderFocus: {
fill: this.options.selectColour,
opacity: Math.min(this.options.selectOpacity + 0.1, 1.0),
stroke: '#000000',
strokeWidth: 3
}
})
})
return idsWithMarkup // Return all the generated markup for the organs
}
async mount(container) {
if (this.destroyed) {
throw new Error('Cannot mount destroyed anatomogram instance') // Error if the instance is destroyed
}
if (!container) {
throw new Error('Container element is required') // Error if no container is provided
}
try {
this.container = container
container.innerHTML = ''
// Initialize assets
this.assets = new Assets()
await this.assets.init()
// Check if species is supported
if (!supportedSpecies.includes(this.options.species)) {
throw new Error(`Species "${this.options.species}" is not supported. Supported species: ${supportedSpecies.join(', ')}`)
}
// Create wrapper
const wrapper = document.createElement('div')
wrapper.className = 'anatomogram-wrapper'
wrapper.style.cssText = `
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
font-family: Arial, sans-serif;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
background: #ffffff;
`
// Add title
if (this.options.showTitle) {
this.createTitle(wrapper)
}
// Add controls
if (this.options.showControls) {
this.createControls(wrapper)
}
// Add SVG container
const svgContainer = document.createElement('div')
svgContainer.className = 'anatomogram-svg-container'
svgContainer.style.cssText = `
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
overflow: hidden;
padding: 20px;
background: #fafafa;
`
// Initialize SVG
this.anatomogramSvg = new AnatomogramSvg({
species: this.options.species,
selectedView: this.options.selectedView,
idsWithMarkup: this.generateMarkup(),
onMouseOver: this.options.onMouseOver,
onMouseOut: this.options.onMouseOut,
onClick: this.options.onClick,
atlasUrl: this.options.atlasUrl
})
// Mount SVG
await this.anatomogramSvg.mount(svgContainer)
wrapper.appendChild(svgContainer)
// Add to container
container.appendChild(wrapper)
this.mounted = true
} catch (error) {
console.error('Failed to mount anatomogram:', error)
this.showError(container, error.message) // Show error message if mounting fails
throw error
}
}
createTitle(wrapper) {
const titleElement = document.createElement('div')
titleElement.className = 'anatomogram-title'
titleElement.style.cssText = `
padding: 15px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: bold;
font-size: 16px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
`
const speciesName = this.options.species.replace('_', ' ')
.replace(/\b\w/g, l => l.toUpperCase())
const viewName = this.options.selectedView ?
` - ${this.options.selectedView.charAt(0).toUpperCase() + this.options.selectedView.slice(1)}` : ''
titleElement.textContent = `🔬 Anatomogram: ${speciesName}${viewName}`
wrapper.appendChild(titleElement)
}
createControls(wrapper) {
const controlsElement = document.createElement('div')
controlsElement.className = 'anatomogram-controls'
controlsElement.style.cssText = `
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
`
// View selector
const views = this.assets.getAnatomogramViews(this.options.species)
if (views && views.length > 1) {
const viewLabel = document.createElement('label')
viewLabel.textContent = 'View: '
viewLabel.style.cssText = 'font-weight: 500; color: #495057;'
const viewSelect = document.createElement('select')
viewSelect.style.cssText = `
padding: 6px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
background: white;
font-size: 14px;
cursor: pointer;
`
views.forEach(view => {
const option = document.createElement('option')
option.value = view
option.textContent = view.charAt(0).toUpperCase() + view.slice(1)
option.selected = view === this.options.selectedView
viewSelect.appendChild(option)
})
viewSelect.addEventListener('change', (e) => {
this.switchView(e.target.value) // Switch view on selection change
})
controlsElement.appendChild(viewLabel)
controlsElement.appendChild(viewSelect)
}
// Status indicator
const statusElement = document.createElement('div')
statusElement.style.cssText = `
margin-left: auto;
display: flex;
gap: 10px;
align-items: center;
font-size: 13px;
color: #6c757d;
`
const counts = {
shown: this.options.showIds.length,
highlighted: this.options.highlightIds.length,
selected: this.options.selectIds.length
}
statusElement.innerHTML = `
<span>📊 Organs:
<span style="color: ${this.options.showColour};">Shown: ${counts.shown}</span> |
<span style="color: ${this.options.highlightColour};">Highlighted: ${counts.highlighted}</span> |
<span style="color: ${this.options.selectColour};">Selected: ${counts.selected}</span>
</span>
`
// Clear button
if (counts.shown + counts.highlighted + counts.selected > 0) {
const clearButton = document.createElement('button')
clearButton.textContent = '🗑️ Clear All'
clearButton.style.cssText = `
padding: 6px 12px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
`
clearButton.addEventListener('click', () => {
this.clearAll() // Clear all organs
})
statusElement.appendChild(clearButton)
}
controlsElement.appendChild(statusElement)
wrapper.appendChild(controlsElement)
}
switchView(view) {
if (this.anatomogramSvg) {
this.options.selectedView = view
this.anatomogramSvg.update({
selectedView: view // Update the selected view
})
this.options.onViewChanged(view)
}
}
update(newOptions = {}) {
if (!this.mounted || this.destroyed) return
// Merge new options
const oldSpecies = this.options.species
Object.assign(this.options, newOptions)
// If species changes, remount the anatomogram
if (newOptions.species && newOptions.species !== oldSpecies) {
if (this.container) {
this.mount(this.container)
}
return
}
// Update SVG markup
if (this.anatomogramSvg) {
this.anatomogramSvg.update({
selectedView: this.options.selectedView,
idsWithMarkup: this.generateMarkup(),
onMouseOver: this.options.onMouseOver,
onMouseOut: this.options.onMouseOut,
onClick: this.options.onClick
})
}
}
clearAll() {
this.update({
showIds: [],
highlightIds: [],
selectIds: []
}) // Clear all organ selections
}
showError(container, message) {
container.innerHTML = `
<div style="
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 300px;
color: #dc3545;
text-align: center;
padding: 40px;
background: #fff5f5;
border: 2px dashed #f5c6cb;
border-radius: 10px;
margin: 20px;
">
<div style="font-size: 48px; margin-bottom: 20px;">❌</div>
<h3 style="margin: 0 0 15px 0; color: #721c24;">Error Loading Anatomogram</h3>
<p style="margin: 0; color: #856464; max-width: 400px; line-height: 1.5;">${message}</p>
<button onclick="location.reload()" style="
margin-top: 20px;
padding: 10px 20px;
background: #dc3545;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
">🔄 Reload Page</button>
</div>
` // Display error message if there is an issue loading the anatomogram
}
destroy() {
if (this.anatomogramSvg) {
this.anatomogramSvg.destroy?.()
this.anatomogramSvg = null
}
if (this.container) {
this.container.innerHTML = ''
this.container = null
}
this.assets = null
this.mounted = false
this.destroyed = true // Set destroyed flag to true
}
// Helper methods
show(ids) {
if (!Array.isArray(ids)) ids = [ids]
this.update({
showIds: [...new Set([...this.options.showIds, ...ids])] // Add new IDs to show
})
}
highlight(ids) {
if (!Array.isArray(ids)) ids = [ids]
this.update({
highlightIds: [...new Set([...this.options.highlightIds, ...ids])] // Add new IDs to highlight
})
}
select(ids) {
if (!Array.isArray(ids)) ids = [ids]
this.update({
selectIds: [...new Set([...this.options.selectIds, ...ids])] // Add new IDs to select
})
}
hide(ids) {
if (!Array.isArray(ids)) ids = [ids]
this.update({
showIds: this.options.showIds.filter(id => !ids.includes(id)),
highlightIds: this.options.highlightIds.filter(id => !ids.includes(id)),
selectIds: this.options.selectIds.filter(id => !ids.includes(id)) // Remove specified IDs from show, highlight, and select lists
})
}
}