diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0873457
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules
+.venv
+dist
+
+# Python
+__pycache__
+.ipynb_checkpoints
+
+src/widget_periodictable/static
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..da857da
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# widget_periodictable
+
+```sh
+pip install widget_periodictable
+```
diff --git a/examples/introduction.ipynb b/examples/introduction.ipynb
new file mode 100644
index 0000000..b216a5b
--- /dev/null
+++ b/examples/introduction.ipynb
@@ -0,0 +1,339 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# The widget periodic table"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import ipywidgets as widgets\n",
+ "from widget_periodictable import PTableWidget\n",
+ "from ipywidgets import Layout"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Visualize the element grid"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Show the widget\n",
+ "widget = PTableWidget(states = 3, selected_elements = {\"C\": 0, \"Si\": 1, \"Ge\": 2}, \n",
+ " selected_colors = ['red', 'green', 'cyan'], \n",
+ " disabled_elements = ['B', 'Al', 'Ga'],\n",
+ " unselected_color='blue', border_color = 'white', width = '40px')\n",
+ "display(widget)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Set the states of the elements\n",
+ "\n",
+ "The periodic table allows users to customize the states of the selected elements. \n",
+ "If one do not give the selected element's state, it will set the state as zero."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Init the selected elements by using a dictionary:\n",
+ "\n",
+ "```python\n",
+ "widget.selected_elements = {\"La\": 0, \"Ce\": 1, \"Pr\": 2}\n",
+ "```"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "widget.selected_elements = {\"La\": 0, \"Ce\": 1, \"Pr\": 2}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Change or set element state by:\n",
+ "\n",
+ "```python\n",
+ "widget.set_element_state(\"Nd\",0)\n",
+ "```"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "widget.set_element_state(\"Nd\",0)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "However, you cannot use widget.selected_elements[\"Nd\"] = 1 to set the states of the elements."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Get the elements have the same state:\n",
+ "\n",
+ "```python\n",
+ "widget.get_elements_by_state(0)\n",
+ "```"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "widget.get_elements_by_state(0)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Get the selected values in python"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Check which elements are currently selected"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "output = widgets.Output()\n",
+ "\n",
+ "def on_get_in_python(event):\n",
+ " output.clear_output()\n",
+ " with output:\n",
+ " print(\n",
+ " \"Currently selected values:\", \n",
+ " widget.selected_elements)\n",
+ "\n",
+ "button2 = widgets.Button(\n",
+ " description=\"Get the currently selected values\", \n",
+ " button_style='success',\n",
+ " layout={'width': '300px'}\n",
+ ")\n",
+ "button2.on_click(on_get_in_python)\n",
+ "vbox = widgets.VBox([button2, output])\n",
+ "vbox"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Play with enabling/disabling some elements"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "toggle_disabled = widgets.Checkbox(\n",
+ " value=\"O\" in widget.disabled_elements,\n",
+ " description='Disable oxygen',\n",
+ " disabled=False\n",
+ ")\n",
+ "\n",
+ "def on_change_disabled(event):\n",
+ " if toggle_disabled.value:\n",
+ " # It's set, meaning we want to disable oxygen\n",
+ " widget.disabled_elements = [\"O\"]\n",
+ " else:\n",
+ " widget.disabled_elements = []\n",
+ "toggle_disabled.observe(on_change_disabled, names='value')\n",
+ "\n",
+ "def on_change(event):\n",
+ " \"\"\"\n",
+ " Update the toggle value if manually changing the disabled_elements list.\n",
+ " \"\"\"\n",
+ " toggle_disabled.value = \"O\" in widget.disabled_elements\n",
+ "widget.observe(on_change, names='disabled_elements', type='change') \n",
+ " \n",
+ "toggle_disabled"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Freeze (disable clicking events) the whole periodic table "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "toggle_freeze = widgets.Checkbox(\n",
+ " value= False,\n",
+ " description='Freeze periodic table:',\n",
+ " disabled=False\n",
+ ")\n",
+ "\n",
+ "def on_freeze_change(event):\n",
+ " widget.disabled = toggle_freeze.value\n",
+ " \n",
+ "toggle_freeze.observe(on_freeze_change, names='value')\n",
+ "\n",
+ "toggle_freeze"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Set the selected values from python\n",
+ "Choose the selected values from python"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def on_set_from_python(event):\n",
+ " # NOTE! If you put an element which does not exist, it will stay forever in the list, but it's ignored\n",
+ " widget.selected_elements = {\"Li\":0, \"H\":0}\n",
+ "\n",
+ "button = widgets.Button(\n",
+ " description=\"Select only Li and H (from python)\", \n",
+ " button_style='success',\n",
+ " layout={'width': '300px'}\n",
+ ")\n",
+ "button.on_click(on_set_from_python)\n",
+ "button"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Change the displayed string for some elements\n",
+ "Note that you should pass valid HTML strings, as they will not be escaped. On the other hand this allows to use HTML to change the class, color, ..."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def get_noble_gases_state():\n",
+ " label_deactivate = \"Make noble gases bold\"\n",
+ " label_activate = \"Make noble gases not bold\"\n",
+ " def deactivate_noble_gases(event):\n",
+ " widget.display_names_replacements = {}\n",
+ " def activate_noble_gases(event):\n",
+ " widget.display_names_replacements = {\n",
+ " elem_name: \"{}\".format(elem_name)\n",
+ " for elem_name in ['He', 'Ne', 'Ar', 'Kr', 'Xe', 'Rn', 'Og']\n",
+ " }\n",
+ "\n",
+ " if 'He' in widget.display_names_replacements:\n",
+ " return {\n",
+ " 'is_active': True,\n",
+ " 'toggler_function': deactivate_noble_gases,\n",
+ " 'toggled_label': label_deactivate,\n",
+ " 'current_label': label_activate\n",
+ " }\n",
+ " else:\n",
+ " return {\n",
+ " 'is_active': True,\n",
+ " 'toggler_function': activate_noble_gases,\n",
+ " 'toggled_label': label_activate,\n",
+ " 'current_label': label_deactivate\n",
+ " }\n",
+ "\n",
+ "button_noble = widgets.Button(\n",
+ " description=get_noble_gases_state()['current_label'], \n",
+ " button_style='success',\n",
+ " layout={'width': '300px'}\n",
+ ")\n",
+ " \n",
+ "def on_toggle_noble_gases(event):\n",
+ " \"\"\"Toggle the state of the button and of the .\"\"\"\n",
+ " state = get_noble_gases_state() \n",
+ " # Change the table\n",
+ " state['toggler_function'](event)\n",
+ " # Change the button description\n",
+ " button_noble.description = state['toggled_label']\n",
+ " \n",
+ "button_noble.on_click(on_toggle_noble_gases)\n",
+ "button_noble"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.7"
+ },
+ "voila": {
+ "authors": "Dou Du, Casper Welzel Andersen and Giovanni Pizzi"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/js/widget.css b/js/widget.css
new file mode 100644
index 0000000..9ea7a37
--- /dev/null
+++ b/js/widget.css
@@ -0,0 +1,68 @@
+.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;
+ margin: 10px;
+}
+
+.periodic-table-entry:hover {
+ background-color: #cc7777;
+ transform: scale(1.4);
+}
+
+.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 */
+ }
diff --git a/js/widget.js b/js/widget.js
new file mode 100644
index 0000000..d2fc0e5
--- /dev/null
+++ b/js/widget.js
@@ -0,0 +1,257 @@
+import * as _ from 'underscore';
+import $ from 'jquery';
+import './widget.css';
+
+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']];
+
+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("
"); } %>',
+);
+
+const elementList = [];
+for (const elementRow of elementTable) {
+ for (const elementName of elementRow) {
+ if (elementName === '' || elementName === '*') {
+ continue;
+ } else {
+ elementList.push(elementName);
+ }
+ }
+}
+
+function render({ model, el }) {
+ rerenderScratch({ el, model });
+
+ model.on('change:selected_elements', () => {
+ rerenderScratch({ el, model });
+ });
+
+ model.on('change:disabled_elements', () => {
+ rerenderScratch({ el, model });
+ });
+
+ model.on('change:display_names_replacements', () => {
+ rerenderScratch({ el, model });
+ });
+
+ model.on('change:border_color', () => {
+ renderBorder(model.get('border_color'));
+ });
+
+ model.on('change:width', () => {
+ rerenderScratch({ el, model });
+ });
+
+ model.on('change:disabled', () => {
+ rerenderScratch({ el, model });
+ });
+}
+
+function rerenderScratch({ el, model }) {
+ // Re-render full widget when the list of selected elements
+ // changed from python
+ const selectedElements = model.get('selected_elements');
+ const disabledElements = model.get('disabled_elements');
+ const disabledColor = model.get('disabled_color');
+ const unselectedColor = model.get('unselected_color');
+ const selectedColors = model.get('selected_colors');
+ const newSelectedColors = selectedColors.slice();
+ const elementWidth = model.get('width');
+ const borderColor = 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];
+ }
+ }
+
+ model.set('selected_elements', selectedElements);
+ model.save_changes();
+ }
+
+ // Render the full widget using the template
+ el.innerHTML =
+ '' +
+ tableTemplate({
+ elementTable: elementTable,
+ displayNamesReplacements: model.get('display_names_replacements'),
+ selectedElements: newSelectedElements,
+ disabledElements: disabledElements,
+ disabledColor: disabledColor,
+ unselectedColor: unselectedColor,
+ selectedColors: newSelectedColors,
+ selectedStates: newSelectedStates,
+ elementWidth: elementWidth,
+ borderColor: borderColor,
+ disabled: model.get('disabled'),
+ }) +
+ '
';
+
+ $(() => {
+ $('.periodic-table-entry').on('click', (event) => {
+ toggleElement({ el, model, event });
+ });
+ });
+};
+
+function toggleElement({ el, model, event }) {
+ 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 = model.get('states');
+ const disabled = model.get('disabled');
+
+ if (disabled) {
+ return;
+ };
+
+ // Check if we understood which element we are
+ if (typeof elementName !== 'undefined') {
+ const currentList = 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
+ model.set('selected_elements', { Du: 0 });
+ model.set('selected_elements', currentList);
+ model.save_changes();
+ };
+};
+
+function renderBorder(color) {
+ const a = document.getElementsByClassName('periodic-table-entry');
+
+ for (let i = 0; i < a.length; i++) {
+ a[i].style.border = '1px solid ' + color;
+ }
+};
+
+export default { render };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7f70cbb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "scripts": {
+ "dev": "npm run build -- --sourcemap=inline --watch",
+ "build": "esbuild js/widget.js --minify --format=esm --bundle --outdir=src/widget_periodictable/static"
+ },
+ "dependencies": {
+ "underscore": "^1.13.6",
+ "jquery": "^3.7.1"
+ },
+ "devDependencies": {
+ "esbuild": "^0.20.0",
+ "eslint": "^8.57.0",
+ "eslint-config-google": "^0.14.0"
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b11432a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,31 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "widget_periodictable"
+version = "4.0.5"
+dependencies = ["anywidget>=0.9.3"]
+readme = "README.md"
+
+[project.optional-dependencies]
+dev = ["watchfiles", "jupyterlab"]
+
+# automatically add the dev feature to the default env (e.g., hatch shell)
+[tool.hatch.envs.default]
+features = ["dev"]
+
+
+[tool.hatch.build]
+only-packages = true
+artifacts = ["src/widget_periodictable/static/*"]
+
+[tool.hatch.build.hooks.jupyter-builder]
+build-function = "hatch_jupyter_builder.npm_builder"
+ensured-targets = ["src/widget_periodictable/static/widget.js"]
+skip-if-exists = ["src/widget_periodictable/static/widget.js"]
+dependencies = ["hatch-jupyter-builder>=0.5.0"]
+
+[tool.hatch.build.hooks.jupyter-builder.build-kwargs]
+npm = "npm"
+build_cmd = "build"
diff --git a/src/widget_periodictable/__init__.py b/src/widget_periodictable/__init__.py
new file mode 100644
index 0000000..7b9a622
--- /dev/null
+++ b/src/widget_periodictable/__init__.py
@@ -0,0 +1,139 @@
+import importlib.metadata
+import pathlib
+
+import anywidget
+import traitlets
+from traitlets import validate, observe, TraitError
+
+from .utils import color_as_rgb, CHEMICAL_ELEMENTS
+from copy import deepcopy
+
+try:
+ __version__ = importlib.metadata.version("widget_periodictable")
+except importlib.metadata.PackageNotFoundError:
+ __version__ = "unknown"
+
+
+class PTableWidget(anywidget.AnyWidget):
+ _esm = pathlib.Path(__file__).parent / "static" / "widget.js"
+ _css = pathlib.Path(__file__).parent / "static" / "widget.css"
+
+ selected_elements = traitlets.Dict({}).tag(sync=True)
+ disabled_elements = traitlets.List([]).tag(sync=True)
+ display_names_replacements = traitlets.Dict({}).tag(sync=True)
+ disabled_color = traitlets.Unicode('gray').tag(sync=True)
+ unselected_color = traitlets.Unicode('pink').tag(sync=True)
+ states = traitlets.Int(1).tag(sync=True)
+ selected_colors = traitlets.List([]).tag(sync=True)
+ border_color = traitlets.Unicode('#cc7777').tag(sync=True)
+ disabled = traitlets.Bool(False, help="Enable or disable user changes.").tag(sync=True)
+ width = traitlets.Unicode('38px').tag(sync=True)
+ allElements = traitlets.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,
+ ):
+ super(PTableWidget, self).__init__()
+ 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 TraitError('Element not found')
+ if state not in range(self.states):
+ raise TraitError('State value is wrong')
+ x = deepcopy(self.selected_elements)
+ x[elementName] = state
+ self.selected_elements = x
+
+ @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'])
+
+ @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
+
+ @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 TraitError('Element not found')
+ if not isinstance(y, int) or y not in range(self.states):
+ raise TraitError('State value is wrong')
+ return proposal['value']
+
+ @observe('disabled_elements')
+ def _disabledList_change(self, change):
+ for i in change['new']:
+ if i in self.selected_elements:
+ del self.selected_elements[i]
+
+ @observe('states')
+ def _states_change(self, change):
+ if change['new'] < 1:
+ raise 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 TraitError("State value is wrong")
+ else:
+ return [
+ i for i in self.selected_elements if self.selected_elements[i] == state
+ ]
+
diff --git a/src/widget_periodictable/utils.py b/src/widget_periodictable/utils.py
new file mode 100644
index 0000000..ad48ca4
--- /dev/null
+++ b/src/widget_periodictable/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",
+]