Blog Pages

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:

Post a Comment