diff --git a/setup.cfg b/setup.cfg index 190886ef1..c594b09fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,6 +33,7 @@ install_requires = importlib-resources~=5.2 aiida-wannier90-workflows==2.3.0 pymatgen==2024.5.1 + anywidget==0.9.13 python_requires = >=3.9 [options.packages.find] diff --git a/src/aiidalab_qe/app/result/components/viewer/structure/structure.py b/src/aiidalab_qe/app/result/components/viewer/structure/structure.py index 3cae7cb85..6ccf6f19b 100644 --- a/src/aiidalab_qe/app/result/components/viewer/structure/structure.py +++ b/src/aiidalab_qe/app/result/components/viewer/structure/structure.py @@ -1,4 +1,7 @@ +import ipywidgets as ipw + from aiidalab_qe.common.panel import ResultsPanel +from aiidalab_qe.common.widgets import TableWidget from aiidalab_widgets_base.viewers import StructureDataViewer from .model import StructureResultsModel @@ -9,7 +12,26 @@ def _render(self): if not hasattr(self, "widget"): structure = self._model.get_structure() self.widget = StructureDataViewer(structure=structure) - self.results_container.children = [self.widget] + # Select the Cell tab by default + self.widget.configuration_box.selected_index = 2 + self.table_description = ipw.HTML(""" +

+ Structure table information: Atom coordinates in Å +

+

+ You can click on a row to select an atom. Multiple atoms + can be selected by clicking on additional rows. To unselect + an atom, click on the selected row again. +

+ """) + self.atom_coordinates_table = TableWidget() + self._generate_table(structure.get_ase()) + self.results_container.children = [ + self.widget, + self.table_description, + self.atom_coordinates_table, + ] + self.atom_coordinates_table.observe(self._change_selection, "selected_rows") # HACK to resize the NGL viewer in cases where it auto-rendered when its # container was not displayed, which leads to a null width. This hack restores @@ -17,3 +39,30 @@ def _render(self): ngl = self.widget._viewer ngl._set_size("100%", "300px") ngl.control.zoom(0.0) + + def _generate_table(self, structure): + data = [ + [ + "Atom index", + "Chemical symbol", + "Tag", + "x (Å)", + "y (Å)", + "z (Å)", + ] + ] + positions = structure.positions + chemical_symbols = structure.get_chemical_symbols() + tags = structure.get_tags() + + for index, (symbol, tag, position) in enumerate( + zip(chemical_symbols, tags, positions), start=1 + ): + # Format position values to two decimal places + formatted_position = [f"{coord:.2f}" for coord in position] + data.append([index, symbol, tag, *formatted_position]) + self.atom_coordinates_table.data = data + + def _change_selection(self, _): + selected_indices = self.atom_coordinates_table.selected_rows + self.widget.displayed_selection = selected_indices diff --git a/src/aiidalab_qe/common/widgets.py b/src/aiidalab_qe/common/widgets.py index 5b101eb7d..f4da89cba 100644 --- a/src/aiidalab_qe/common/widgets.py +++ b/src/aiidalab_qe/common/widgets.py @@ -13,6 +13,7 @@ from threading import Event, Lock, Thread from time import time +import anywidget import ase import ipywidgets as ipw import numpy as np @@ -1219,3 +1220,85 @@ def _show_missing_information_warning(self): def _hide_missing_information_warning(self): self.children = self.previous_children + + +class TableWidget(anywidget.AnyWidget): + _esm = """ + function render({ model, el }) { + let domElement = document.createElement("div"); + el.classList.add("custom-table"); + let selectedIndices = []; + + function drawTable() { + const data = model.get("data"); + domElement.innerHTML = ""; + let innerHTML = '' + data[0].map(header => ``).join('') + ''; + + for (let i = 1; i < data.length; i++) { + innerHTML += '' + data[i].map(cell => ``).join('') + ''; + } + + innerHTML += "
${header}
${cell}
"; + domElement.innerHTML = innerHTML; + + const rows = domElement.querySelectorAll('tr'); + rows.forEach((row, index) => { + if (index > 0) { + row.addEventListener('click', () => { + const rowIndex = index - 1; + if (selectedIndices.includes(rowIndex)) { + selectedIndices = selectedIndices.filter(i => i !== rowIndex); + row.classList.remove('selected-row'); + } else { + selectedIndices.push(rowIndex); + row.classList.add('selected-row'); + } + model.set('selected_rows', [...selectedIndices]); + model.save_changes(); + }); + + row.addEventListener('mouseover', () => { + if (!row.classList.contains('selected-row')) { + row.classList.add('hover-row'); + } + }); + + row.addEventListener('mouseout', () => { + row.classList.remove('hover-row'); + }); + } + }); + } + + drawTable(); + model.on("change:data", drawTable); + el.appendChild(domElement); + } + export default { render }; + """ + _css = """ + .custom-table table, .custom-table th, .custom-table td { + border: 1px solid black; + border-collapse: collapse; + text-align: left; + padding: 4px; + } + .custom-table th, .custom-table td { + min-width: 50px; + word-wrap: break-word; + } + .custom-table table { + width: 70%; + font-size: 1.0em; + } + /* Hover effect with light gray background */ + .custom-table tr.hover-row:not(.selected-row) { + background-color: #f0f0f0; + } + /* Selected row effect with light green background */ + .custom-table tr.selected-row { + background-color: #DFF0D8; + } + """ + data = traitlets.List().tag(sync=True) + selected_rows = traitlets.List().tag(sync=True)