ChatGPTに訊いてみた:分子量計算アプリを作りたい!(第11回)~カスタム官能基・芳香環ボタンの追加・削除と、原子量へのこだわり(ver.7.7)~
Q. 所長
前回の分二郎 ver.7.6 では、官能基ボタン・芳香環ボタン・–Hボタンを実装して、かなり実用的になりました。
ですが、研究室によって「オリジナルの官能基」や「特定の芳香環誘導体」をよく使うこともあります。
できれば、それらを自由に追加・削除できるようにしたいんですよ。
それともうひとつ気になるのが、分子量計算に使う原子量です。
IUPACと日本化学会で値が微妙に違うと聞きますが、どちらを使っているんですか?
A. ChatGPT
いい質問ですね、所長。今回 ver.7.7 での改良と、原子量周りの戦略をまとめてお話します。
🚀 機能面の改良ポイント
官能基/芳香環ボタンを、ユーザー自身で追加・削除できるように
削除時には確認ダイアログを出し、誤操作を防止
追加・削除情報は functional_groups.json および aromatic_rings.json に保存
–H ボタンによる水素除去や、これまでのベタ打ち/周期表入力を引き続きサポート
UIを整理し、見た目も使い勝手も向上
atomic_weights.csv → atomic_weights.json の変換機能をアプリ内に組み込み
🔬 原子量へのこだわり:中央値採用という選択
原子量(標準原子量)は、IUPAC や日本化学会で最新化が進んでおり、多くの元素に 範囲(interval)表記 が導入されています。
これは、自然界での 同位体比変動 を反映した考え方です。
しかし、実用アプリで分子量を使う際は、範囲そのものを扱うのではなく「代表値」を使うことが一般的です。
そこで本アプリでは、範囲をもつ元素は中央値を採用する方針を採りました。
🧩 補足解説:中央値 vs 標準値の考え方
一部の元素(例:C, N, O など)は変動幅が極めて小さいため、従来通り 単一の標準値 として扱われます。
→ 例:炭素は [12.0096, 12.0116] の範囲がありますが、教育・実務上は 12.011 を固定値とします。一方、硫黄 (S) や塩素 (Cl) のように同位体比変動が比較的大きい元素では、
標準原子量範囲の中央値 を代表値として用います。
⚛️ 例えば:

このように、範囲を中央値へ変換し、さらに 有効数字4桁(小数第3位) に丸めて採用しています。
📄 atomic_weights.csv(サンプル)
Symbol,Element,AtomicWeight
H,Hydrogen,1.008
C,Carbon,12.011
N,Nitrogen,14.007
O,Oxygen,15.999
S,Sulfur,32.067
Na,Sodium,22.990
Cl,Chlorine,35.452
Fe,Iron,55.845
Cu,Copper,63.546
Zn,Zinc,65.38この CSV をもとに、Flask 内の変換機能で atomic_weights.json を生成します。
📁 ファイル構成
myProject/
├─ app.py
├─ templates/
│ └─ index.html
└─ public/
├─ atomic_weights.csv
├─ functional_groups.json
├─ aromatic_rings.json
└─ atomic_weights.json
💻 Flaskでの更新機能追加
今回のver.7.7では、原子量表を管理・再生成できるように
Flaskアプリに管理用の手動変換機能(/convert_csv)を追加しました。
これにより、atomic_weights.csv を修正して保存すれば、
ブラウザ上から /convert_csv にアクセスするだけで
atomic_weights.json を再生成できます。
🔧 app.py(主要部抜粋・説明)
# --- CSV→JSON変換関数 ---
def convert_csv_to_json(csv_path="atomic_weights.csv", json_path="atomic_weights.json"):
"""atomic_weights.csv を atomic_weights.json に変換する"""
...
# --- 管理者用エンドポイント ---
@app.route("/convert_csv", methods=["GET"])
def convert_csv_endpoint():
"""atomic_weights.csv → atomic_weights.json の手動変換"""
result = convert_csv_to_json()
return jsonify(result)
➡ これにより、ファイル更新が容易になります。
アプリを再起動せずに新しい原子量データを反映可能です。
🐍 app.py
# -*- coding: utf-8 -*-
# app.py (ver.7.7)
from flask import Flask, request, jsonify, render_template
import json, re, os, webbrowser, csv
from threading import Timer
# --- ディレクトリ構成 ---
APP_DIR = os.path.dirname(os.path.abspath(__file__))
PUBLIC_DIR = os.path.join(APP_DIR, "public")
CSV_PATH = os.path.join(PUBLIC_DIR, "atomic_weights.csv")
JSON_PATH = os.path.join(PUBLIC_DIR, "atomic_weights.json")
app = Flask(__name__, template_folder="templates", static_folder=PUBLIC_DIR)
# --- CSV→JSON変換関数 ---
def convert_csv_to_json(csv_path=CSV_PATH, json_path=JSON_PATH):
"""atomic_weights.csv を atomic_weights.json に変換する"""
if not os.path.exists(csv_path):
return {"status": "error", "message": f"{csv_path} が見つかりません。"}
data = []
with open(csv_path, encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
if not row.get("Symbol") or not row.get("AtomicWeight"):
continue
try:
aw = float(row["AtomicWeight"])
except ValueError:
aw = None
data.append({
"symbol": row["Symbol"].strip(),
"name": row["Element"].strip().capitalize(),
"atomic_mass": aw
})
with open(json_path, "w", encoding="utf-8") as jf:
json.dump(data, jf, indent=2, ensure_ascii=False)
return {"status": "success", "message": f"{os.path.basename(json_path)} を再生成しました。", "count": len(data)}
@app.route("/convert_csv", methods=["GET"])
def convert_csv_endpoint():
"""管理者用:atomic_weights.csv → atomic_weights.json の手動変換"""
result = convert_csv_to_json()
return jsonify(result)
# --- JSON 読み込み/保存 ---
def load_json(filename):
path = os.path.join(PUBLIC_DIR, filename)
if not os.path.exists(path):
return []
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(filename, data):
with open(os.path.join(PUBLIC_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# --- データ読み込み ---
elements = load_json("atomic_weights.json")
functional_groups = load_json("functional_groups.json")
aromatic_rings = load_json("aromatic_rings.json")
# --- 原子量辞書作成 ---
atomic_weights = {e["symbol"]: e["atomic_mass"] for e in elements}
atomic_weights.update({g["symbol"]: g["atomic_mass"] for g in functional_groups})
atomic_weights.update({a["symbol"]: a["atomic_mass"] for a in aromatic_rings})
TOK_RE = re.compile(r"([A-Z][a-z]?)(\d*)")
def calculate_mw(formula: str) -> float:
mw, pos = 0.0, 0
for m in TOK_RE.finditer(formula):
el, num = m.group(1), m.group(2)
if el not in atomic_weights:
raise ValueError(f"未知の元素: {el}")
count = int(num) if num else 1
mw += atomic_weights[el] * count
pos = m.end()
if pos != len(formula):
raise ValueError("不正な文字が含まれています")
return mw
@app.route("/")
def index():
return render_template("index.html",
functional_groups=functional_groups,
aromatic_rings=aromatic_rings)
@app.route("/calculate", methods=["POST"])
def api_calculate():
data = request.get_json()
try:
mw = calculate_mw(data.get("formula", ""))
return jsonify({"formula": data["formula"], "mw": mw})
except Exception as e:
return jsonify({"error": str(e)}), 400
@app.route("/add_group", methods=["POST"])
def add_group():
data = request.get_json()
entry = data["entry"]
if data["type"] == "fg":
functional_groups.append(entry)
save_json("functional_groups.json", functional_groups)
else:
aromatic_rings.append(entry)
save_json("aromatic_rings.json", aromatic_rings)
return jsonify({"status": "ok"})
@app.route("/delete_group", methods=["POST"])
def delete_group():
data = request.get_json()
target = data["symbol"]
if data["type"] == "fg":
functional_groups[:] = [g for g in functional_groups if g["symbol"] != target]
save_json("functional_groups.json", functional_groups)
else:
aromatic_rings[:] = [r for r in aromatic_rings if r["symbol"] != target]
save_json("aromatic_rings.json", aromatic_rings)
return jsonify({"status": "deleted"})
def open_browser():
webbrowser.open("http://127.0.0.1:5000/")
if __name__ == "__main__":
Timer(1, open_browser).start()
app.run(host="127.0.0.1", port=5000, debug=True)🌐 index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>分二郎 ver.7.7</title>
<style>
body {
font-family: "Segoe UI", sans-serif;
text-align: center;
background-color: #fafafa;
}
h1 {
margin: 20px;
font-size: 2em;
font-weight: bold;
}
.tabs {
display: flex;
justify-content: center;
margin-bottom: 15px;
}
.tab {
padding: 10px 20px;
margin: 0 5px;
border: 1px solid #999;
border-radius: 6px;
background-color: #eee;
cursor: pointer;
}
.tab.active {
background-color: #ddd;
font-weight: bold;
}
.panel {
display: none;
margin: 0 auto;
}
.panel.active {
display: block;
}
.input-box {
width: 300px;
padding: 8px;
font-size: 18px;
margin-bottom: 10px;
}
.button-grid {
display: grid;
grid-template-columns: repeat(18, 40px);
gap: 4px;
justify-content: center;
margin-top: 10px;
}
.btn {
padding: 6px;
border: 1px solid #555;
border-radius: 5px;
background-color: #f0f0f0;
cursor: pointer;
}
.btn:hover {
background-color: #ddd;
}
/* --- 周期表色分け --- */
.alkali {
background-color: #ffcccc;
}
/* アルカリ金属 */
.alkaline {
background-color: #ffebcc;
}
/* アルカリ土類金属 */
.transition {
background-color: #cce0ff;
}
/* 遷移金属 */
.post-transition {
background-color: #e6e6e6;
}
/* 後遷移金属 */
.metalloid {
background-color: #e0f2e0;
}
/* 半金属 */
.nonmetal {
background-color: #ffffcc;
}
/* 非金属 */
.halogen {
background-color: #ffe0cc;
}
/* ハロゲン */
.noble {
background-color: #e6ccff;
}
/* 希ガス */
.lanthanoid {
background-color: #ffccff;
}
/* ランタノイド */
.actinoid {
background-color: #ffb3b3;
}
/* アクチノイド */
.num-grid {
display: grid;
grid-template-columns: repeat(10, 40px) 50px 50px 50px;
gap: 5px;
justify-content: center;
margin-top: 10px;
}
.num-btn {
background-color: #fce4ec;
border: 1px solid #666;
border-radius: 5px;
cursor: pointer;
padding: 6px;
}
.num-btn:hover {
background-color: #f8bbd0;
}
.special-btn {
background-color: #ffcdd2;
font-weight: bold;
}
.delete-btn {
color: red;
font-weight: bold;
margin-left: 4px;
cursor: pointer;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.4);
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 10px;
width: 300px;
}
</style>
</head>
<body>
<h1>分二郎 ver.7.7</h1>
<div class="tabs">
<div class="tab active" data-tab="manualTab">ベタ打ち入力</div>
<div class="tab" data-tab="periodicTab">周期表入力</div>
<div class="tab" data-tab="fgTab">官能基ボタン</div>
<div class="tab" data-tab="aromaticTab">芳香環ボタン</div>
</div>
<input id="formulaInput" class="input-box" type="text" placeholder="化学式を入力">
<div id="result">ここに結果が表示されます</div>
<!-- タブパネル -->
<div id="manualTab" class="panel active">
<p>化学式を直接入力してください。</p>
</div>
<div id="periodicTab" class="panel">
<div class="button-grid" id="periodicTable"></div>
</div>
<div id="fgTab" class="panel">
<div id="fgButtons"></div>
<button onclick="openModal('fg')">+追加</button>
</div>
<div id="aromaticTab" class="panel">
<div id="aromaticButtons"></div>
<button onclick="openModal('aromatic')">+追加</button>
</div>
<!-- モーダル -->
<div id="modal" class="modal">
<div class="modal-content">
<h3>新しいボタンを追加</h3>
<p><input id="symbol" placeholder="Symbol (例: SO3H)" style="width:90%;"></p>
<p><input id="name" placeholder="Name (例: Sulfonic acid)" style="width:90%;"></p>
<p><input id="formula" placeholder="Formula (例: SO3H)" style="width:90%;"></p>
<button onclick="addItem()">追加</button>
<button onclick="closeModal()">キャンセル</button>
</div>
</div>
<!-- 数字・AC・BS・-H -->
<div class="num-grid">
<button class="num-btn">1</button><button class="num-btn">2</button><button class="num-btn">3</button>
<button class="num-btn">4</button><button class="num-btn">5</button><button class="num-btn">6</button>
<button class="num-btn">7</button><button class="num-btn">8</button><button class="num-btn">9</button>
<button class="num-btn">0</button>
<button class="num-btn special-btn" id="acBtn">AC</button>
<button class="num-btn special-btn" id="bsBtn">BS</button>
<button class="num-btn special-btn" id="minusHBtn">-H</button>
</div>
<script>
// --- 初期データ ---
let fgList = [
{ symbol: "OH", name: "Hydroxyl", formula: "OH" },
{ symbol: "COOH", name: "Carboxyl", formula: "COOH" },
{ symbol: "NH2", name: "Amino", formula: "NH2" },
{ symbol: "NO2", name: "Nitro", formula: "NO2" }
];
let aromaticList = [
{ symbol: "C6H6", name: "Benzene", formula: "C6H6" },
{ symbol: "C10H8", name: "Naphthalene", formula: "C10H8" },
{ symbol: "C5H5N", name: "Pyridine", formula: "C5H5N" }
];
let currentType = "";
// --- ボタン描画 ---
function renderButtons() {
const fgDiv = document.getElementById("fgButtons");
fgDiv.innerHTML = "";
fgList.forEach((g, i) => {
const btn = document.createElement("button");
btn.className = "btn";
btn.textContent = g.symbol;
btn.onclick = () => appendFormula(g.formula);
const del = document.createElement("span");
del.className = "delete-btn";
del.textContent = "🗑";
del.onclick = () => {
if (confirm(`${g.symbol} を削除しますか?`)) {
fgList.splice(i, 1); renderButtons();
}
};
fgDiv.appendChild(btn);
fgDiv.appendChild(del);
});
const arDiv = document.getElementById("aromaticButtons");
arDiv.innerHTML = "";
aromaticList.forEach((a, i) => {
const btn = document.createElement("button");
btn.className = "btn";
btn.textContent = a.symbol;
btn.onclick = () => appendFormula(a.formula);
const del = document.createElement("span");
del.className = "delete-btn";
del.textContent = "🗑";
del.onclick = () => {
if (confirm(`${a.symbol} を削除しますか?`)) {
aromaticList.splice(i, 1); renderButtons();
}
};
arDiv.appendChild(btn);
arDiv.appendChild(del);
});
}
renderButtons();
// --- タブ切り替え ---
document.querySelectorAll(".tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
document.getElementById(tab.dataset.tab).classList.add("active");
});
});
function appendFormula(str) {
const input = document.getElementById("formulaInput");
input.value += str;
calculate(input.value);
}
async function calculate(formula) {
const res = await fetch("/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ formula })
});
const data = await res.json();
document.getElementById("result").textContent = res.ok ? `${data.formula} の分子量 = ${data.mw.toFixed(3)} g/mol` : "エラー: " + data.error;
}
// --- モーダル ---
function openModal(type) { currentType = type; document.getElementById("modal").style.display = "flex"; }
function closeModal() { document.getElementById("modal").style.display = "none"; }
function addItem() {
const s = document.getElementById("symbol").value.trim();
const n = document.getElementById("name").value.trim();
const f = document.getElementById("formula").value.trim();
if (!s || !f) { alert("SymbolとFormulaは必須です"); return; }
if (currentType === "fg") fgList.push({ symbol: s, name: n, formula: f });
if (currentType === "aromatic") aromaticList.push({ symbol: s, name: n, formula: f });
renderButtons();
closeModal();
document.getElementById("symbol").value = "";
document.getElementById("name").value = "";
document.getElementById("formula").value = "";
}
// --- 数字/AC/BS/-H ボタン ---
const formulaInput = document.getElementById("formulaInput");
// ★ 追加:ベタ打ち入力にも反応するようにする
formulaInput.addEventListener("input", () => calculate(formulaInput.value));
document.querySelectorAll(".num-btn").forEach(btn => {
btn.addEventListener("click", () => {
const val = btn.textContent;
if (val === "AC") {
formulaInput.value = "";
} else if (val === "BS") {
formulaInput.value = formulaInput.value.slice(0, -1);
} else if (val === "-H") {
let formula = formulaInput.value;
let match = formula.match(/H(\d+)$/);
if (match) {
let num = parseInt(match[1]);
if (num > 2) { formula = formula.replace(/H\d+$/, "H" + (num - 1)); }
else if (num === 2) { formula = formula.replace(/H2$/, "H"); }
else if (num === 1) { formula = formula.replace(/H1$/, ""); }
} else if (formula.endsWith("H")) {
formula = formula.slice(0, -1);
}
formulaInput.value = formula;
} else {
formulaInput.value += val;
}
calculate(formulaInput.value);
});
});
// --- 周期表データ ---
const periodicTableGrid = [
["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", "La", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", "Pb", "Bi", "Po", "At", "Rn"],
["Fr", "Ra", "Ac", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn", "Nh", "Fl", "Mc", "Lv", "Ts", "Og"],
["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""],
["", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "", "", ""],
["", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr", "", "", ""]
];
const colors = {
"H": "nonmetal", "He": "noble",
"Li": "alkali", "Na": "alkali", "K": "alkali", "Rb": "alkali", "Cs": "alkali", "Fr": "alkali",
"Be": "alkaline", "Mg": "alkaline", "Ca": "alkaline", "Sr": "alkaline", "Ba": "alkaline", "Ra": "alkaline",
"Sc": "transition", "Ti": "transition", "V": "transition", "Cr": "transition", "Mn": "transition", "Fe": "transition", "Co": "transition", "Ni": "transition", "Cu": "transition", "Zn": "transition",
"Y": "transition", "Zr": "transition", "Nb": "transition", "Mo": "transition", "Tc": "transition", "Ru": "transition", "Rh": "transition", "Pd": "transition", "Ag": "transition", "Cd": "transition",
"La": "lanthanoid", "Ce": "lanthanoid", "Pr": "lanthanoid", "Nd": "lanthanoid", "Pm": "lanthanoid", "Sm": "lanthanoid", "Eu": "lanthanoid", "Gd": "lanthanoid", "Tb": "lanthanoid", "Dy": "lanthanoid", "Ho": "lanthanoid", "Er": "lanthanoid", "Tm": "lanthanoid", "Yb": "lanthanoid", "Lu": "lanthanoid",
"Ac": "actinoid", "Th": "actinoid", "Pa": "actinoid", "U": "actinoid", "Np": "actinoid", "Pu": "actinoid", "Am": "actinoid", "Cm": "actinoid", "Bk": "actinoid", "Cf": "actinoid", "Es": "actinoid", "Fm": "actinoid", "Md": "actinoid", "No": "actinoid", "Lr": "actinoid",
"B": "metalloid", "Si": "metalloid", "Ge": "metalloid", "As": "metalloid", "Sb": "metalloid", "Te": "metalloid",
"C": "nonmetal", "N": "nonmetal", "O": "nonmetal", "P": "nonmetal", "S": "nonmetal", "Se": "nonmetal",
"F": "halogen", "Cl": "halogen", "Br": "halogen", "I": "halogen", "At": "halogen", "Ts": "halogen",
"Ne": "noble", "Ar": "noble", "Kr": "noble", "Xe": "noble", "Rn": "noble", "Og": "noble"
};
const periodicTableDiv = document.getElementById("periodicTable");
periodicTableGrid.forEach(row => {
row.forEach(el => {
const btn = document.createElement("button");
btn.className = "btn";
if (el) {
btn.textContent = el;
if (colors[el]) btn.classList.add(colors[el]);
btn.addEventListener("click", () => {
formulaInput.value += el;
calculate(formulaInput.value);
});
} else {
btn.style.visibility = "hidden";
}
periodicTableDiv.appendChild(btn);
});
});
</script>
<hr>
<div style="text-align:center; margin-top:20px;">
<button id="convertBtn"
style="background-color:#4CAF50;color:white;padding:8px 12px;
border:none;border-radius:4px;cursor:pointer;">
🔄 原子量JSON再生成(管理者用)
</button>
</div>
<script>
document.getElementById("convertBtn").addEventListener("click", async () => {
if (!confirm("atomic_weights.json を再生成しますか?")) return;
try {
const res = await fetch("/convert_csv");
const data = await res.json();
alert(`${data.status}: ${data.message}\n変換件数: ${data.count || "不明"}`);
} catch (e) {
alert("変換エラー: " + e);
}
});
</script>
</body>
</html>
従来通り、分子式入力欄・機能基登録・計算結果表示部分に変更はありません。
ただし、JSON変換後の再読み込みを行うことで、
最新の原子量データを即座に反映できます。
👨🔬 所長のコメント
「なるほど、原子量ひとつ取っても奥が深い!
範囲を中央値で扱うのは実用的でありながら、科学的にも誠実な選択ですね。
カスタム官能基も保存できるとは、分二郎もいよいよ研究室仕様になってきました。」
✅ 今回のまとめ
ver.7.7 では、カスタム官能基・芳香環の管理機能を実装
同位体比変動を考慮し、範囲をもつ元素には中央値を採用
atomic_weights.csv → atomic_weights.json の再生成機能を追加
教育・実用の両立を目指した設計
🔜 次回予告
👉 第12回は「分二郎のパッケージ化・配布」を扱います。
Flaskアプリをスタンドアロン化し、研究室・授業環境で簡単に導入できるよう整理します。
requirements.txt や exe 化、Mac対応なども詳説します。
