Interactive Periodic Table

import json

import os

import tkinter as tk

from tkinter import ttk, messagebox

import webbrowser

from functools import partial

import threading


import matplotlib.pyplot as plt

from mpl_toolkits.mplot3d import Axes3D  # noqa: F401 unused import (needed by matplotlib 3D)

import numpy as np


# ----------------- Config -----------------

DATA_FILE = "elements.json"

GRID_COLUMNS = 18

GRID_ROWS = 9  # we'll use rows 1-7 + an extra for Lanthanides/Actinides placeholder


# ----------------- Default JSON Creator -----------------

DEFAULT_ELEMENTS = {

    # symbol: data

    "H": {"name": "Hydrogen", "atomic_number": 1, "atomic_mass": 1.008, "group": 1, "period": 1,

          "category": "Nonmetal", "uses": ["Fuel", "Chemical feedstock"], "electron_configuration": "1s1"},

    "He": {"name": "Helium", "atomic_number": 2, "atomic_mass": 4.0026, "group": 18, "period": 1,

           "category": "Noble Gas", "uses": ["Balloons", "Cryogenics"], "electron_configuration": "1s2"},

    "Li": {"name": "Lithium", "atomic_number": 3, "atomic_mass": 6.94, "group": 1, "period": 2,

           "category": "Alkali Metal", "uses": ["Batteries"], "electron_configuration": "[He] 2s1"},

    "Be": {"name": "Beryllium", "atomic_number": 4, "atomic_mass": 9.0122, "group": 2, "period": 2,

           "category": "Alkaline Earth Metal", "uses": ["Aerospace alloys"], "electron_configuration": "[He] 2s2"},

    "B": {"name": "Boron", "atomic_number": 5, "atomic_mass": 10.81, "group": 13, "period": 2,

          "category": "Metalloid", "uses": ["Glass", "Detergents"], "electron_configuration": "[He] 2s2 2p1"},

    "C": {"name": "Carbon", "atomic_number": 6, "atomic_mass": 12.011, "group": 14, "period": 2,

          "category": "Nonmetal", "uses": ["Organic chemistry", "Materials"], "electron_configuration": "[He] 2s2 2p2"},

    "N": {"name": "Nitrogen", "atomic_number": 7, "atomic_mass": 14.007, "group": 15, "period": 2,

          "category": "Nonmetal", "uses": ["Fertilizers", "Industrial gas"], "electron_configuration": "[He] 2s2 2p3"},

    "O": {"name": "Oxygen", "atomic_number": 8, "atomic_mass": 15.999, "group": 16, "period": 2,

          "category": "Nonmetal", "uses": ["Respiration", "Combustion"], "electron_configuration": "[He] 2s2 2p4"},

    "F": {"name": "Fluorine", "atomic_number": 9, "atomic_mass": 18.998, "group": 17, "period": 2,

          "category": "Halogen", "uses": ["Toothpaste (fluoride)"], "electron_configuration": "[He] 2s2 2p5"},

    "Ne": {"name": "Neon", "atomic_number": 10, "atomic_mass": 20.1797, "group": 18, "period": 2,

           "category": "Noble Gas", "uses": ["Neon signs"], "electron_configuration": "[He] 2s2 2p6"},

    "Na": {"name": "Sodium", "atomic_number": 11, "atomic_mass": 22.989, "group": 1, "period": 3,

           "category": "Alkali Metal", "uses": ["Salt", "Chemicals"], "electron_configuration": "[Ne] 3s1"},

    "Mg": {"name": "Magnesium", "atomic_number": 12, "atomic_mass": 24.305, "group": 2, "period": 3,

           "category": "Alkaline Earth Metal", "uses": ["Alloys", "Electronics"], "electron_configuration": "[Ne] 3s2"},

    "Al": {"name": "Aluminium", "atomic_number": 13, "atomic_mass": 26.9815, "group": 13, "period": 3,

           "category": "Post-transition Metal", "uses": ["Packaging", "Aerospace"], "electron_configuration": "[Ne] 3s2 3p1"},

    "Si": {"name": "Silicon", "atomic_number": 14, "atomic_mass": 28.085, "group": 14, "period": 3,

           "category": "Metalloid", "uses": ["Semiconductors"], "electron_configuration": "[Ne] 3s2 3p2"},

    "P": {"name": "Phosphorus", "atomic_number": 15, "atomic_mass": 30.9738, "group": 15, "period": 3,

          "category": "Nonmetal", "uses": ["Fertilizers"], "electron_configuration": "[Ne] 3s2 3p3"},

    "S": {"name": "Sulfur", "atomic_number": 16, "atomic_mass": 32.06, "group": 16, "period": 3,

          "category": "Nonmetal", "uses": ["Sulfuric acid", "Fertilizers"], "electron_configuration": "[Ne] 3s2 3p4"},

    "Cl": {"name": "Chlorine", "atomic_number": 17, "atomic_mass": 35.45, "group": 17, "period": 3,

           "category": "Halogen", "uses": ["Water purification"], "electron_configuration": "[Ne] 3s2 3p5"},

    "Ar": {"name": "Argon", "atomic_number": 18, "atomic_mass": 39.948, "group": 18, "period": 3,

           "category": "Noble Gas", "uses": ["Lighting", "Welding"], "electron_configuration": "[Ne] 3s2 3p6"},

    "K": {"name": "Potassium", "atomic_number": 19, "atomic_mass": 39.0983, "group": 1, "period": 4,

          "category": "Alkali Metal", "uses": ["Fertilizers"], "electron_configuration": "[Ar] 4s1"},

    "Ca": {"name": "Calcium", "atomic_number": 20, "atomic_mass": 40.078, "group": 2, "period": 4,

           "category": "Alkaline Earth Metal", "uses": ["Bones", "Construction"], "electron_configuration": "[Ar] 4s2"}

}


def ensure_data_file():

    if not os.path.exists(DATA_FILE):

        with open(DATA_FILE, "w", encoding="utf-8") as f:

            json.dump(DEFAULT_ELEMENTS, f, indent=2)

        print(f"Created default {DATA_FILE} with first 20 elements. You can edit/expand it.")


# ----------------- Utility: Load Data -----------------

def load_elements():

    ensure_data_file()

    with open(DATA_FILE, "r", encoding="utf-8") as f:

        data = json.load(f)

    # Normalize: attach group/period if missing

    elements = {}

    for sym, info in data.items():

        elements[sym] = info

    return elements


# ----------------- Orbital Visualizer -----------------

def show_orbital_visual(element_data):

    """

    Very simple 3D shells visualization.

    Each shell (n) is drawn as a translucent sphere surface of radius proportional to n.

    Electron counts per shell approximated from electron_configuration (simple parse).

    """

    # parse approximate shells from electron_configuration string by counting numbers in brackets or 's','p','d'

    cfg = element_data.get("electron_configuration", "")

    # crude parsing to get principal quantum numbers occurrences like 1s2 2s2 2p6 etc.

    shells = []

    import re

    matches = re.findall(r'(\d)(?:[spdf])\d*', cfg)

    if matches:

        # get unique shell numbers and sort

        shells = sorted(set(int(m) for m in matches))

    else:

        # fallback: estimate shells from atomic number

        an = element_data.get("atomic_number", 1)

        # very rough heuristic: shell count = ceil(sqrt(Z)/2) — just for visualization

        shells = list(range(1, int(max(1, round((an**0.5)/2))) + 2))


    fig = plt.figure(figsize=(6,6))

    ax = fig.add_subplot(111, projection='3d')

    ax.set_facecolor("white")

    ax._axis3don = False


    # Draw concentric sphere surfaces for shells

    for n in shells:

        # create sphere

        u = np.linspace(0, 2 * np.pi, 60)

        v = np.linspace(0, np.pi, 30)

        r = 0.6 * n  # radius scale

        x = r * np.outer(np.cos(u), np.sin(v))

        y = r * np.outer(np.sin(u), np.sin(v))

        z = r * np.outer(np.ones_like(u), np.cos(v))

        ax.plot_surface(x, y, z, color=plt.cm.viridis(n/ max(1, len(shells))), alpha=0.15, linewidth=0)

        # put some electron points randomly distributed on shell

        np.random.seed(n*10)

        m = min(12, 4 + n*4)

        theta = np.random.uniform(0, 2*np.pi, m)

        phi = np.random.uniform(0, np.pi, m)

        ex = r * np.sin(phi) * np.cos(theta)

        ey = r * np.sin(phi) * np.sin(theta)

        ez = r * np.cos(phi)

        ax.scatter(ex, ey, ez, s=30, color=plt.cm.viridis(n / max(1, len(shells))))


    ax.set_title(f"Shells for {element_data.get('name', '')} ({element_data.get('symbol','')})", pad=20)

    max_r = 0.6 * (max(shells) + 1)

    ax.set_xlim(-max_r, max_r)

    ax.set_ylim(-max_r, max_r)

    ax.set_zlim(-max_r, max_r)

    plt.show()


# ----------------- GUI -----------------

class PeriodicTableApp:

    def __init__(self, root):

        self.root = root

        self.root.title("Interactive Periodic Table")

        self.elements = load_elements()  # key: symbol

        # create a mapping by atomic_number for quick lookup

        self.by_atomic = {info["atomic_number"]: sym for sym, info in self.elements.items() if "atomic_number" in info}

        self.buttons = {}  # symbol -> button

        self.build_ui()


    def build_ui(self):

        top = tk.Frame(self.root)

        top.pack(padx=8, pady=8, anchor="w")


        tk.Label(top, text="Search (symbol / name / atomic #):").pack(side="left")

        self.search_var = tk.StringVar()

        search_entry = tk.Entry(top, textvariable=self.search_var)

        search_entry.pack(side="left", padx=4)

        search_entry.bind("<Return>", lambda e: self.do_search())

        tk.Button(top, text="Search", command=self.do_search).pack(side="left", padx=4)

        tk.Button(top, text="Open elements.json", command=self.open_data_file).pack(side="left", padx=6)


        # Table frame

        table_frame = tk.Frame(self.root)

        table_frame.pack(padx=8, pady=8)


        # create empty grid

        for r in range(1, 8):  # periods 1..7

            for c in range(1, GRID_COLUMNS + 1):

                placeholder = tk.Frame(table_frame, width=46, height=36, bd=0)

                placeholder.grid(row=r, column=c, padx=1, pady=1)


        # Place element buttons based on group/period columns

        for sym, info in self.elements.items():

            group = info.get("group")

            period = info.get("period")

            if group is None or period is None:

                # skip elements without position (lanthanides/actinides not positioned)

                continue

            r = period

            c = group

            btn = tk.Button(table_frame, text=f"{sym}\n{info.get('atomic_number','')}",

                            width=6, height=3, command=partial(self.show_element_popup, sym))

            btn.grid(row=r, column=c, padx=1, pady=1)

            self.buttons[sym] = btn


        # Add legend / info area

        info_frame = tk.Frame(self.root)

        info_frame.pack(fill="x", padx=8, pady=6)

        tk.Label(info_frame, text="Click an element to view details and 3D shell visualization.", fg="gray").pack(side="left")


    def open_data_file(self):

        path = os.path.abspath(DATA_FILE)

        if os.path.exists(path):

            webbrowser.open(f"file://{path}")

        else:

            messagebox.showinfo("Info", f"{DATA_FILE} not found.")


    def do_search(self):

        q = self.search_var.get().strip().lower()

        if not q:

            return

        # search by symbol

        sym_match = None

        for sym, info in self.elements.items():

            if sym.lower() == q:

                sym_match = sym

                break

        if sym_match:

            self.flash_button(sym_match)

            self.show_element_popup(sym_match)

            return

        # search by name

        for sym, info in self.elements.items():

            if info.get("name", "").lower() == q:

                self.flash_button(sym)

                self.show_element_popup(sym)

                return

        # search by atomic number

        if q.isdigit():

            an = int(q)

            sym = self.by_atomic.get(an)

            if sym:

                self.flash_button(sym)

                self.show_element_popup(sym)

                return

        # partial name match

        for sym, info in self.elements.items():

            if q in info.get("name", "").lower():

                self.flash_button(sym)

                self.show_element_popup(sym)

                return

        messagebox.showinfo("Search", "No matching element found.")


    def flash_button(self, sym):

        btn = self.buttons.get(sym)

        if not btn:

            return

        orig = btn.cget("bg")

        def _flash():

            for _ in range(4):

                btn.config(bg="yellow")

                btn.update()

                self.root.after(200)

                btn.config(bg=orig)

                btn.update()

                self.root.after(200)

        threading.Thread(target=_flash, daemon=True).start()


    def show_element_popup(self, sym):

        info = self.elements.get(sym)

        if not info:

            messagebox.showerror("Error", "Element data not found.")

            return

        popup = tk.Toplevel(self.root)

        popup.title(f"{sym} - {info.get('name','')}")

        popup.geometry("420x320")


        left = tk.Frame(popup)

        left.pack(side="left", fill="both", expand=True, padx=8, pady=8)


        tk.Label(left, text=f"{info.get('name','')} ({sym})", font=("Arial", 16, "bold")).pack(anchor="w")

        tk.Label(left, text=f"Atomic Number: {info.get('atomic_number','')}", font=("Arial", 12)).pack(anchor="w")

        tk.Label(left, text=f"Atomic Mass: {info.get('atomic_mass','')}", font=("Arial", 12)).pack(anchor="w")

        tk.Label(left, text=f"Category: {info.get('category','')}", font=("Arial", 12)).pack(anchor="w")

        tk.Label(left, text=f"Electron Configuration: {info.get('electron_configuration','')}", font=("Arial", 11)).pack(anchor="w", pady=6)

        uses = info.get("uses", [])

        tk.Label(left, text="Uses / Applications:", font=("Arial", 12, "underline")).pack(anchor="w", pady=(6,0))

        uses_text = tk.Text(left, height=6, wrap="word")

        uses_text.pack(fill="both", expand=True)

        uses_text.insert("1.0", "\n".join(uses) if isinstance(uses, list) else str(uses))

        uses_text.config(state="disabled")


        # right side controls

        right = tk.Frame(popup)

        right.pack(side="right", fill="y", padx=6, pady=6)

        tk.Button(right, text="Visualize Shells (3D)", command=lambda: threading.Thread(target=show_orbital_visual, args=(info,), daemon=True).start()).pack(pady=6)

        tk.Button(right, text="Close", command=popup.destroy).pack(pady=6)


# ----------------- Run App -----------------

def main():

    root = tk.Tk()

    app = PeriodicTableApp(root)

    root.mainloop()


if __name__ == "__main__":

    main()


No comments: