diff --git a/pyproject.toml b/pyproject.toml index e543fb0..b7d65fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,8 +42,8 @@ dependencies = [ "ase~=3.22", "pandas~=2.1", "requests~=2.31", - "widget_periodictable~=3.1", "semver~=3.0", + "anywidget~=0.9", ] [project.optional-dependencies] diff --git a/src/ipyoptimade/subwidgets/_periodic_table/__init__.py b/src/ipyoptimade/subwidgets/_periodic_table/__init__.py new file mode 100644 index 0000000..7eafccf --- /dev/null +++ b/src/ipyoptimade/subwidgets/_periodic_table/__init__.py @@ -0,0 +1,132 @@ +import anywidget +import traitlets as tl +import pathlib +import copy + +from .utils import CHEMICAL_ELEMENTS, color_as_rgb + + +class PeriodicTableWidget(anywidget.AnyWidget): + _esm = pathlib.Path(__file__).parent / "index.js" + _css = pathlib.Path(__file__).parent / "style.css" + + selected_elements = tl.Dict({}).tag(sync=True) + disabled_elements = tl.List([]).tag(sync=True) + display_names_replacements = tl.Dict({}).tag(sync=True) + disabled_color = tl.Unicode("gray").tag(sync=True) + unselected_color = tl.Unicode("pink").tag(sync=True) + states = tl.Int(1).tag(sync=True) + selected_colors = tl.List([]).tag(sync=True) + border_color = tl.Unicode("#cc7777").tag(sync=True) + disabled = tl.Bool(False, help="Enable or disable user changes.").tag(sync=True) + width = tl.Unicode("38px").tag(sync=True) + allElements = tl.List(CHEMICAL_ELEMENTS).tag(sync=True) + + _STANDARD_COLORS = [ + "#a6cee3", + "#b2df8a", + "#fdbf6f", + "#6a3d9a", + "#b15928", + "#e31a1c", + "#1f78b4", + "#33a02c", + "#ff7f00", + "#cab2d6", + "#ffff99", + ] + + def __init__( + self, + states=1, + selected_elements=None, + disabled_elements=None, + disabled_color=None, + unselected_color=None, + selected_colors=[], + border_color=None, + width=None, + layout=None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.states = states if states else 1 + self.selected_elements = selected_elements if selected_elements else {} + self.disabled_elements = disabled_elements if disabled_elements else [] + self.disabled_color = disabled_color if disabled_color else "gray" + self.unselected_color = unselected_color if unselected_color else "pink" + self.selected_colors = ( + selected_colors if selected_colors else self._STANDARD_COLORS + ) + self.border_color = border_color if border_color else "#cc7777" + self.width = width if width else "38px" + + if layout is not None: + self.layout = layout + + if len(selected_colors) < states: + self.selected_colors = selected_colors + self._STANDARD_COLORS * ( + 1 + (states - len(selected_colors)) // len(self._STANDARD_COLORS) + ) + self.selected_colors = self.selected_colors[:states] + + def set_element_state(self, elementName, state): + if elementName not in self.allElements: + raise tl.TraitError("Element not found") + if state not in range(self.states): + raise tl.TraitError("State value is wrong") + x = copy.deepcopy(self.selected_elements) + x[elementName] = state + self.selected_elements = x + + @tl.validate("disabled_color", "unselected_color", "border_color") + def _color_change(self, proposal): + """Convert to rgb(X, Y, Z) type color""" + return color_as_rgb(proposal["value"]) + + @tl.validate("selected_colors") + def _selectedColors_change(self, proposal): + """Convert to rgb(X, Y, Z) type color""" + res = [] + for color in proposal["value"]: + res.append(color_as_rgb(color)) + return res + + @tl.validate("selected_elements") + def _selectedElements_change(self, proposal): + for x, y in proposal["value"].items(): + if x not in self.allElements and x != "Du": + raise tl.TraitError("Element not found") + if not isinstance(y, int) or y not in range(self.states): + raise tl.TraitError("State value is wrong") + return proposal["value"] + + @tl.observe("disabled_elements") + def _disabledList_change(self, change): + for i in change["new"]: + if i in self.selected_elements: + del self.selected_elements[i] + + @tl.observe("states") + def _states_change(self, change): + if change["new"] < 1: + raise tl.TraitError("State value cannot smaller than 1") + else: + if len(self.selected_colors) < change["new"]: + self.selected_colors = self.selected_colors + self._STANDARD_COLORS * ( + 1 + + (change["new"] - len(self.selected_colors)) + // len(self._STANDARD_COLORS) + ) + self.selected_colors = self.selected_colors[: change["new"]] + elif len(self.selected_colors) > change["new"]: + self.selected_colors = self.selected_colors[: change["new"]] + + def get_elements_by_state(self, state): + if state not in range(self.states): + raise tl.TraitError("State value is wrong") + else: + return [ + i for i in self.selected_elements if self.selected_elements[i] == state + ] diff --git a/src/ipyoptimade/subwidgets/_periodic_table/index.js b/src/ipyoptimade/subwidgets/_periodic_table/index.js new file mode 100644 index 0000000..c6a0c0d --- /dev/null +++ b/src/ipyoptimade/subwidgets/_periodic_table/index.js @@ -0,0 +1,449 @@ +import _ from 'https://cdn.jsdelivr.net/npm/underscore@1.13.6/+esm' + +// List of lists of elements, used to render the periodic table +// Only values accepted: +// - strings (should be valid elements, not checked); +// - empty strings (empty space, nothing rendered); +// - '*' character (printed as a disabled element). +// These assumptions are used both in the generation of the elementList +// and in the template. +const elementTable = [ + ["H", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "He"], + [ + "Li", + "Be", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "B", + "C", + "N", + "O", + "F", + "Ne" + ], + [ + "Na", + "Mg", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "Al", + "Si", + "P", + "S", + "Cl", + "Ar" + ], + [ + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Ge", + "As", + "Se", + "Br", + "Kr" + ], + [ + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Sb", + "Te", + "I", + "Xe" + ], + [ + "Cs", + "Ba", + "*", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "At", + "Rn" + ], + [ + "Fr", + "Ra", + "#", + "Rf", + "Db", + "Sg", + "Bh", + "Hs", + "Mt", + "Ds", + "Rg", + "Cn", + "Nh", + "Fi", + "Mc", + "Lv", + "Ts", + "Og" + ], + ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""], + [ + "", + "", + "*", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu" + ], + [ + "", + "", + "#", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr" + ] +] + +// Flat list of elements, used for validation and cleaning up of the +// selectedElements list. +const elementList = [] +for (const elementRow of elementTable) { + for (const elementName of elementRow) { + if (elementName === "" || elementName === "*") { + continue + } else { + elementList.push(elementName) + } + } +} + +// TODO: move template to external file to make it more readable, see +// http://codebeerstartups.com/2012/12/how-to-improve-templates-in-backbone-js-learning-backbone-js/ +const tableTemplate = _.template( + "<% for (let elementRow of elementTable) { " + + "print(\"
\"); " + + "for (let elementName of elementRow) { " + + 'if ( (elementName === "") || (elementName == "*" ) || (elementName == "#" ) ) { %>' + + ' <%= elementName %>' + + "<% } else { %>" + + ' ' + + ' noselect element-<%= elementName %><% if (selectedElements.includes(elementName) && (! disabledElements.includes(elementName)) ) { print(" elementOn"); } %>" ' + + 'style="width: <%= elementWidth %>; height: <%= elementWidth %>; ' + + 'border-color: <% if (disabled) { colors = borderColor.replace(/[^\\d,]/g, "").split(","); ' + + "red = Math.round(255 - 0.38 * ( 255 - parseInt(colors[0], 10) )); " + + "green = Math.round(255 - 0.38 * ( 255 - parseInt(colors[1], 10) )); " + + "blue = Math.round(255 - 0.38 * ( 255 - parseInt(colors[2], 10) )); " + + 'print("rgb(" + red.toString(10) + "," + green.toString(10) + "," + blue.toString(10) + ")"); ' + + "} else { print(borderColor); } %>; " + + "background-color: <% if (disabledElements.includes(elementName)) { color = disabledColor; } " + + "else if (selectedElements.includes(elementName)) { " + + "i = selectedElements.indexOf(elementName); color = selectedColors[selectedStates[i]]; " + + "} else { color = unselectedColor; } " + + 'if (disabled) { colors = color.replace(/[^\\d,]/g, "").split(","); ' + + "red = Math.round(255 - 0.38 * ( 255 - parseInt(colors[0], 10) )); " + + "green = Math.round(255 - 0.38 * ( 255 - parseInt(colors[1], 10) )); " + + "blue = Math.round(255 - 0.38 * ( 255 - parseInt(colors[2], 10) )); " + + 'print("rgb(" + red.toString(10) + "," + green.toString(10) + "," + blue.toString(10) + ")"); ' + + '} else { print(color); } %>"' + + // 'title="state: <% if (selectedElements.includes(elementName)) { i = selectedElements.indexOf(elementName); print(selectedStates[i]);} '+ + // 'else if (disabledElements.includes(elementName)){print("disabled");} else {print("unselected");} %>" ><% '+ + "><% print(displayNamesReplacements[elementName] || elementName); %>" + + '<% } }; print("
"); } %>' +) + +class PeriodicTableView { + + constructor({ el, model }) { + this.el = el + this.model = model + } + + + render() { + // add event listener + + // I render the widget + this.rerenderScratch() + + // I bind on_change events + this.model.on("change:selected_elements", this.rerenderScratch, this) + this.model.on("change:disabled_elements", this.rerenderScratch, this) + this.model.on( + "change:display_names_replacements", + this.rerenderScratch, + this + ) + this.model.on("change:border_color", this.rerenderScratch, this) + this.model.on("change:width", this.rerenderScratch, this) + this.model.on("change:disabled", this.rerenderScratch, this) + } + + rerenderScratch() { + // Re-render full widget when the list of selected elements + // changed from python + const selectedElements = this.model.get("selected_elements") + const disabledElements = this.model.get("disabled_elements") + const disabledColor = this.model.get("disabled_color") + const unselectedColor = this.model.get("unselected_color") + const selectedColors = this.model.get("selected_colors") + const newSelectedColors = selectedColors.slice() + const elementWidth = this.model.get("width") + const borderColor = this.model.get("border_color") + + let newSelectedElements = [] + const newSelectedStates = [] + + if ("Du" in selectedElements) { + return + } + + for (const key in selectedElements) { + newSelectedElements.push(key) + newSelectedStates.push(selectedElements[key]) + } + + if (newSelectedElements.length !== newSelectedStates.length) { + return + } + + // Here I want to clean up the two elements lists, to avoid + // to have unknown elements in the selectedElements, and + // to remove disabled Elements from the selectedElements list. + // I use s variable to check if anything changed, so I send + // back the data to python only if needed + + const selectedElementsLength = newSelectedElements.length + // Remove disabled elements from the selectedElements list + newSelectedElements = _.difference(newSelectedElements, disabledElements) + // Remove unknown elements from the selectedElements list + newSelectedElements = _.intersection(newSelectedElements, elementList) + + const changed = newSelectedElements.length !== selectedElementsLength + + // call the update (to python) only if I actually removed/changed + // something + if (changed) { + // Make a copy before setting + // while (newSelectedElements.length > newSelectedStates.length){ + // newSelectedStates.push(0); + // }; + + for (const key in selectedElements) { + if (!newSelectedElements.includes(key)) { + delete selectedElements[key] + } + } + + this.model.set("selected_elements", selectedElements) + this.touch() + } + + // Render the full widget using the template + this.el.innerHTML = + '
' + + tableTemplate({ + elementTable: elementTable, + displayNamesReplacements: this.model.get("display_names_replacements"), + selectedElements: newSelectedElements, + disabledElements: disabledElements, + disabledColor: disabledColor, + unselectedColor: unselectedColor, + selectedColors: newSelectedColors, + selectedStates: newSelectedStates, + elementWidth: elementWidth, + borderColor: borderColor, + disabled: this.model.get("disabled") + }) + + "
" + + function myFunction(event) { + console.log("tttttt") + const classNames = _.map(event.target.classList, a => { + return a + }) + const elementName = _.chain(classNames) + .filter(a => { + return a.startsWith("element-") + }) + .map(a => { + return a.slice("element-".length) + }) + .first() + .value() + + const isOn = _.includes(classNames, "elementOn") + const isDisabled = _.includes(classNames, "periodic-table-disabled") + // If this button is disabled, do not do anything + // (Actually, this function should not be triggered if the button + // is disabled, this is just a safety measure) + + const states = this.model.get("states") + const disabled = this.model.get("disabled") + + if (disabled) { + return + } + + // Check if we understood which element we are + if (typeof elementName !== "undefined") { + const currentList = this.model.get("selected_elements") + // NOTE! it is essential to duplicate the list, + // otherwise the value will not be updated. + + let newList = [] + const newStatesList = [] + + for (const key in currentList) { + newList.push(key) + newStatesList.push(currentList[key]) + } + + const num = newList.indexOf(elementName) + + if (isOn) { + // remove the element from the selected_elements + + if (newStatesList[num] < states - 1) { + newStatesList[num]++ + currentList[elementName] = newStatesList[num] + } else { + newList = _.without(newList, elementName) + newStatesList.splice(num, 1) + delete currentList[elementName] + // Swap CSS state + event.target.classList.remove("elementOn") + } + } else if (!isDisabled) { + // add the element from the selected_elements + newList.push(elementName) + newStatesList.push(0) + currentList[elementName] = 0 + // Swap CSS state + event.target.classList.add("elementOn") + } else { + return + } + + // Update the model (send back data to python) + // I have to make some changes, since there is some issue + // for Dict in Traitlets, which cannot trigger the update + this.model.set("selected_elements", { Du: 0 }) + this.touch() + this.model.set("selected_elements", currentList) + this.touch() + } + } + + this.elementEntries = this.el.querySelectorAll(".periodic-table-entry") + + // add listenter to each element + for (const elementEntry of this.elementEntries) { + console.log("tttt") + elementEntry.addEventListener("click", myFunction.bind(this)) + } + } + + destroy() { + // TODO: remove event listeners + } +} + +function modelWithSerializers(model, serializers) { + return { + get(key) { + const value = model.get(key); + const serializer = serializers[key]; + if (serializer) return serializer.deserialize(value); + return value; + }, + set(key, value) { + const serializer = serializers[key]; + if (serializer) value = serializer.serialize(value); + model.set(key, value); + }, + on: model.on.bind(model), + save_changes: model.save_changes.bind(model), + send: model.send.bind(model), + } +} + +async function render({ model, el }) { + const view = new PeriodicTableView({ + el: el, + model: modelWithSerializers(model, { + // TODO: add serializers + }), + }); + view.render(); + return () => view.destroy(); +} + +export default { render } \ No newline at end of file diff --git a/src/ipyoptimade/subwidgets/_periodic_table/style.css b/src/ipyoptimade/subwidgets/_periodic_table/style.css new file mode 100644 index 0000000..6777e01 --- /dev/null +++ b/src/ipyoptimade/subwidgets/_periodic_table/style.css @@ -0,0 +1,65 @@ +.periodic-table-entry { + border: 1px solid; + border-color: #cc7777; + border-radius: 3px; + width: 38px; + height: 38px; + display: table-cell; + text-align: center; + vertical-align: middle; + background-color: #ffaaaa; +} + +.periodic-table-disabled { + border-radius: 3px; + width: 38px; + height: 38px; + display: table-cell; + text-align: center; + vertical-align: middle; + background-color: #999999; +} + +.periodic-table-empty { + border: 0px; + width: 38px; + height: 38px; + display: table-cell; + text-align: center; + vertical-align: middle; +} + +.periodic-table-row { + display: table-row; +} + +.periodic-table-body { + display: table; border-spacing: 4px; +} + +.periodic-table-entry:hover { + background-color: #cc7777; +} + +.periodic-table-entry.elementOn { + background-color: #aaaaff; + border: 1px solid #7777cc; + border-radius: 4px; +} + + +.periodic-table-entry.elementOn:hover { + background-color: #7777cc; + border: 1px solid #7777cc; + border-radius: 4px; +} + +.noselect { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome and Opera */ +} \ No newline at end of file diff --git a/src/ipyoptimade/subwidgets/_periodic_table/utils.py b/src/ipyoptimade/subwidgets/_periodic_table/utils.py new file mode 100644 index 0000000..ad48ca4 --- /dev/null +++ b/src/ipyoptimade/subwidgets/_periodic_table/utils.py @@ -0,0 +1,166 @@ +import re + + +HTML_COLOR_MAP = { + "white": (255,) * 3, + "silver": tuple(round(0.75 * i) for i in (255,) * 3), + "gray": tuple(round(0.5 * i) for i in (255,) * 3), + "grey": tuple(round(0.5 * i) for i in (255,) * 3), + "black": (0,) * 3, + "red": (255, 0, 0), + "maroon": (round(0.5 * 255), 0, 0), + "yellow": (255, 255, 0), + "olive": tuple(round(0.5 * i) for i in (255, 255, 0)), + "lime": (0, 255, 0), + "green": (0, round(0.5 * 255), 0), + "aqua": (0, 255, 255), + "teal": tuple(round(0.5 * i) for i in (0, 255, 255)), + "blue": (0, 0, 255), + "navy": (0, 0, round(0.5 * 255)), + "fuchsia": (255, 0, 255), + "purple": tuple(round(0.5 * i) for i in (255, 0, 255)), + "pink": (255, 192, 203), +} + + +def color_as_rgb(color: str) -> str: + """Convert hex and named color to rgb formatting""" + if not color: + return "" + + if re.match(r"#[a-fA-F0-9]{6}", color): + # Hex color + color = color.lstrip("#") + color = tuple(int(color[i : i + 2], 16) for i in (0, 2, 4)) + elif re.match(r"rgb\([0-9]+,[0-9]+,[0-9]+\)", color): + # RGB color + return color + else: + # Color name + color = HTML_COLOR_MAP.get(color) + + if color is None: + return "" + return "".join(f"rgb{color!r}".split(" ")) + + +CHEMICAL_ELEMENTS = [ + "H", + "He", + "Li", + "Be", + "B", + "C", + "N", + "O", + "F", + "Ne", + "Na", + "Mg", + "Al", + "Si", + "P", + "S", + "Cl", + "Ar", + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Ge", + "As", + "Se", + "Br", + "Kr", + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Sb", + "Te", + "I", + "Xe", + "Cs", + "Ba", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "At", + "Rn", + "Fr", + "Ra", + "Rf", + "Db", + "Sg", + "Bh", + "Hs", + "Mt", + "Ds", + "Rg", + "Cn", + "Nh", + "Fi", + "Mc", + "Lv", + "Ts", + "Og", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr", +] diff --git a/src/ipyoptimade/subwidgets/periodic_table.py b/src/ipyoptimade/subwidgets/periodic_table.py index a2b8aee..166d2a2 100644 --- a/src/ipyoptimade/subwidgets/periodic_table.py +++ b/src/ipyoptimade/subwidgets/periodic_table.py @@ -1,16 +1,16 @@ import ipywidgets as ipw -from widget_periodictable import PTableWidget - from ipyoptimade.logger import LOGGER from ipyoptimade.utils import ButtonStyle +from ._periodic_table import PeriodicTableWidget + __all__ = ("PeriodicTable",) class PeriodicTable(ipw.VBox): - """Wrapper-widget for PTableWidget""" + """Wrapper-widget for PeriodicTableWidget""" def __init__(self, extended: bool = True, **kwargs): self._disabled = kwargs.get("disabled", False) @@ -30,7 +30,7 @@ def __init__(self, extended: bool = True, **kwargs): layout={"width": "auto"}, disabled=self.disabled, ) - self.ptable = PTableWidget(**kwargs) + self.ptable = PeriodicTableWidget(**kwargs) self.ptable_container = ipw.VBox( children=(self.select_any_all, self.ptable), layout={ @@ -49,9 +49,9 @@ def __init__(self, extended: bool = True, **kwargs): @property def value(self) -> dict: - """Return value for wrapped PTableWidget""" + """Return value for wrapped PeriodicTableWidget""" LOGGER.debug( - "PeriodicTable: PTableWidget.selected_elements = %r", + "PeriodicTable: PeriodicTableWidget.selected_elements = %r", self.ptable.selected_elements, ) LOGGER.debug(