An NMR spectrum plots signal intensity versus chemical shift (in ppm) to reveal the electronic environment of atomic nuclei in a molecule. It is the primary analytical tool in organic chemistry for determining molecular structure, with peak positions indicating functional groups and splitting patterns (multiplets) revealing connectivity between neighboring atoms. The x-axis is conventionally reversed (high ppm on the left) and peaks appear as sharp signals rising from a baseline.

""" anyplot.ai
spectrum-nmr: NMR Spectrum (Nuclear Magnetic Resonance)
Library: altair 6.1.0 | Python 3.13.13
Quality: 90/100 | Updated: 2026-06-03
"""
import os
import altair as alt
import numpy as np
import pandas as pd
from PIL import Image
# Theme-adaptive chrome (Imprint palette)
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
# Imprint palette — first series is always #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
BRAND = IMPRINT_PALETTE[0]
# Synthetic 1H NMR spectrum of ethanol (CH3-CH2-OH)
np.random.seed(42)
chemical_shift = np.linspace(-0.5, 5.0, 5000)
w = 0.012 # Lorentzian half-width
intensity = np.zeros_like(chemical_shift)
# TMS reference peak at 0 ppm (singlet)
intensity += 0.3 / (1 + ((chemical_shift - 0.0) / 0.015) ** 2)
# CH3 triplet near 1.18 ppm (3 peaks, 1:2:1 ratio, J ~ 0.07 ppm)
j_ch3 = 0.07
intensity += 0.50 / (1 + ((chemical_shift - (1.18 - j_ch3)) / w) ** 2)
intensity += 1.00 / (1 + ((chemical_shift - 1.18) / w) ** 2)
intensity += 0.50 / (1 + ((chemical_shift - (1.18 + j_ch3)) / w) ** 2)
# OH singlet near 2.61 ppm
intensity += 0.35 / (1 + ((chemical_shift - 2.61) / 0.02) ** 2)
# CH2 quartet near 3.69 ppm (4 peaks, 1:3:3:1 ratio, J ~ 0.07 ppm)
j_ch2 = 0.07
intensity += 0.25 / (1 + ((chemical_shift - (3.69 - 1.5 * j_ch2)) / w) ** 2)
intensity += 0.75 / (1 + ((chemical_shift - (3.69 - 0.5 * j_ch2)) / w) ** 2)
intensity += 0.75 / (1 + ((chemical_shift - (3.69 + 0.5 * j_ch2)) / w) ** 2)
intensity += 0.25 / (1 + ((chemical_shift - (3.69 + 1.5 * j_ch2)) / w) ** 2)
intensity += np.random.normal(0, 0.003, len(chemical_shift))
intensity = np.clip(intensity, 0, None)
df = pd.DataFrame({"Chemical Shift (ppm)": chemical_shift, "Intensity": intensity})
labels_df = pd.DataFrame(
{
"Chemical Shift (ppm)": [0.0, 1.18, 2.61, 3.69],
"Intensity": [0.33, 1.08, 0.39, 0.80],
"label": ["TMS\n0.00 ppm", "CH₃ (triplet)\n1.18 ppm", "OH (singlet)\n2.61 ppm", "CH₂ (quartet)\n3.69 ppm"],
}
)
regions_data = pd.DataFrame(
{
"x_start": [-0.08, 1.02, 2.50, 3.52],
"x_end": [0.08, 1.34, 2.72, 3.86],
"y_start": [0.0, 0.0, 0.0, 0.0],
"y_end": [0.38, 1.12, 0.43, 0.84],
}
)
droplines_df = pd.DataFrame(
{"Chemical Shift (ppm)": [0.0, 1.18, 2.61, 3.69], "y_base": [0.0, 0.0, 0.0, 0.0], "y_top": [0.30, 1.00, 0.35, 0.75]}
)
# NMR convention: high ppm on left (reversed x-axis)
x_scale = alt.Scale(domain=[5.0, -0.5])
y_scale = alt.Scale(domain=[0, 1.35])
# Nearest-point selection for interactive highlight
nearest = alt.selection_point(nearest=True, on="pointerover", fields=["Chemical Shift (ppm)"], empty=False)
# Subtle background region shading for peak groups — theme-adaptive via BRAND
region_shading = (
alt.Chart(regions_data)
.mark_rect(opacity=0.08, color=BRAND, cornerRadius=3)
.encode(x=alt.X("x_start:Q", scale=x_scale), x2="x_end:Q", y=alt.Y("y_start:Q", scale=y_scale), y2="y_end:Q")
)
# Dashed vertical drop-lines at peak centers
drop_rules = (
alt.Chart(droplines_df)
.mark_rule(color=BRAND, opacity=0.25, strokeDash=[4, 4], strokeWidth=1)
.encode(x=alt.X("Chemical Shift (ppm):Q", scale=x_scale), y=alt.Y("y_base:Q", scale=y_scale), y2="y_top:Q")
)
# Spectrum line — Imprint brand green (#009E73) as first (only) series
spectrum = (
alt.Chart(df)
.mark_line(color=BRAND, strokeWidth=1.5)
.encode(
x=alt.X("Chemical Shift (ppm):Q", scale=x_scale, title="Chemical Shift (ppm)"),
y=alt.Y("Intensity:Q", title="Intensity (a.u.)", scale=y_scale),
tooltip=[alt.Tooltip("Chemical Shift (ppm):Q", format=".2f"), alt.Tooltip("Intensity:Q", format=".3f")],
)
)
# Interactive crosshair rule on hover — theme-adaptive muted ink
crosshair = (
alt.Chart(df)
.mark_rule(color=INK_MUTED, strokeWidth=0.8, strokeDash=[3, 3])
.encode(x=alt.X("Chemical Shift (ppm):Q", scale=x_scale))
.transform_filter(nearest)
)
# Hover point indicator
hover_point = (
alt.Chart(df)
.mark_circle(size=50, color=BRAND, opacity=0.8)
.encode(x=alt.X("Chemical Shift (ppm):Q", scale=x_scale), y=alt.Y("Intensity:Q", scale=y_scale))
.transform_filter(nearest)
)
# Invisible voronoi layer for nearest-point selection
selectors = (
alt.Chart(df)
.mark_point(size=1, opacity=0)
.encode(x=alt.X("Chemical Shift (ppm):Q", scale=x_scale))
.add_params(nearest)
)
# Peak annotation labels — theme-adaptive ink color
peak_labels = (
alt.Chart(labels_df)
.mark_text(
fontSize=11, fontWeight="bold", lineBreak="\n", align="center", dy=-18, font="Helvetica Neue, Arial, sans-serif"
)
.encode(
x=alt.X("Chemical Shift (ppm):Q", scale=x_scale),
y=alt.Y("Intensity:Q", scale=y_scale),
text="label:N",
color=alt.value(INK),
)
)
chart = (
alt.layer(region_shading, drop_rules, spectrum, selectors, crosshair, hover_point, peak_labels)
.properties(
width=620,
height=320,
background=PAGE_BG,
title=alt.Title(
"spectrum-nmr · altair · anyplot.ai",
fontSize=16,
fontWeight="bold",
anchor="middle",
font="Helvetica Neue, Arial, sans-serif",
color=INK,
subtitle="Ethanol ¹H NMR — Synthetic 300 MHz Spectrum",
subtitleFontSize=12,
subtitleColor=INK_SOFT,
subtitlePadding=4,
subtitleFont="Helvetica Neue, Arial, sans-serif",
subtitleFontStyle="italic",
),
)
.configure_view(fill=PAGE_BG, stroke=None, continuousWidth=620, continuousHeight=320)
.configure_axis(
labelFontSize=10,
titleFontSize=12,
titleColor=INK,
titleFont="Helvetica Neue, Arial, sans-serif",
titleFontWeight="normal",
labelColor=INK_SOFT,
labelFont="Helvetica Neue, Arial, sans-serif",
grid=False,
domainColor=INK_SOFT,
domainWidth=0.8,
tickColor=INK_SOFT,
tickSize=5,
tickWidth=0.8,
)
.configure_title(font="Helvetica Neue, Arial, sans-serif", color=INK)
.interactive()
)
# Save PNG with vl-convert, then pad to exact 3200×1800 target
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
raise SystemExit(
f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
f"Shrink chart .properties(width=, height=) values and re-render."
)
if _w < TW or _h < TH:
_canvas = Image.new("RGB", (TW, TH), PAGE_BG)
_canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
_canvas.save(f"plot-{THEME}.png")
chart.save(f"plot-{THEME}.html")
Part of NMR Spectrum (Nuclear Magnetic Resonance) on anyplot.ai.