Basis Vectors of Spherical Coordinates

# 交互后端:优先 widget(需 ipympl,适用于 VS Code / Jupyter / Quarto);
# 无 ipympl 时回退 inline(静态图,滑块不可交互);非交互环境自动用 Agg
try:
    _ip = get_ipython()
    try:
        _ip.run_line_magic('matplotlib', 'widget')
    except Exception:
        print('ipympl 未安装,回退 inline(滑块不可交互);请 pip install ipympl 后重启内核')
        _ip.run_line_magic('matplotlib', 'inline')
except NameError:
    import matplotlib
    matplotlib.use('Agg')

import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False

import numpy as np
from matplotlib.widgets import Slider
r = 1
phi = np.linspace(0, 2 * np.pi, 50)
theta = np.linspace(0, np.pi, 50)

THETA, PHI = np.meshgrid(theta, phi)

x = r * np.sin(THETA) * np.cos(PHI)
y = r * np.sin(THETA) * np.sin(PHI)
z = r * np.cos(THETA)
fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection="3d")

ax.plot_surface(x, y, z, alpha=0.2, color="green")

ax.set_xlim(-1.5, 1.5)
ax.set_ylim(-1.5, 1.5)
ax.set_zlim(-1.5, 1.5)
ax.set_box_aspect((1, 1, 1))
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")

ax.view_init(elev=30, azim=45)
ax.set_title("Basis Vectors in Spherical and Cartesian Coordinates", fontsize=14)
Text(0.5, 0.92, 'Basis Vectors in Spherical and Cartesian Coordinates')
# ---- 基矢方向与标注 ----

def p_xyz(theta, phi):
    """球面点 P (r=1)。"""
    return (np.sin(theta) * np.cos(phi),
            np.sin(theta) * np.sin(phi),
            np.cos(theta))


def basis_spherical(theta, phi):
    """球基矢单位方向(θ 从 +z 轴量起、φ 从 x 轴量起)。"""
    rhat = (np.sin(theta) * np.cos(phi),
            np.sin(theta) * np.sin(phi),
            np.cos(theta))
    thhat = (np.cos(theta) * np.cos(phi),
             np.cos(theta) * np.sin(phi),
             -np.sin(theta))
    phat = (-np.sin(phi), np.cos(phi), 0.0)
    return rhat, thhat, phat


xhat = (1.0, 0.0, 0.0)
yhat = (0.0, 1.0, 0.0)
zhat = (0.0, 0.0, 1.0)

dynamic = []  # 每次重绘产生的 artist,更新前移除


def draw(theta, phi):
    for a in dynamic:
        a.remove()
    dynamic.clear()


    px, py, pz = p_xyz(theta, phi)
    pt = ax.scatter([px], [py], [pz], color='black', s=40, depthshade=False)
    dynamic.append(pt)
    
    # 原点 O 标记与 OP 连线(灰色虚线,示意位置向量)
    origin = ax.scatter([0], [0], [0], color='dimgray', s=30, depthshade=False)
    dynamic.append(origin)
    op_line = ax.plot([0, px], [0, py], [0, pz], color='dimgray', linestyle='--')[0]
    dynamic.append(op_line)
    rhat, thhat, phat = basis_spherical(theta, phi)

    # 球基矢:统一红色实线
    q1 = ax.quiver([px] * 3, [py] * 3, [pz] * 3,
                   [rhat[0], thhat[0], phat[0]],
                   [rhat[1], thhat[1], phat[1]],
                   [rhat[2], thhat[2], phat[2]],
                   length=0.5, arrow_length_ratio=0.2, pivot='tail',
                   color='tab:red')
    dynamic.append(q1)

    # 直角基矢:统一蓝色虚线(颜色+线型双编码,色盲友好)
    q2 = ax.quiver([px] * 3, [py] * 3, [pz] * 3,
                   [xhat[0], yhat[0], zhat[0]],
                   [xhat[1], yhat[1], zhat[1]],
                   [xhat[2], yhat[2], zhat[2]],
                   length=0.5, arrow_length_ratio=0.2, pivot='tail',
                   color='tab:blue', linestyle='--')
    dynamic.append(q2)

    # 六个基矢符号标签
    labels = [r'$\hat{r}$', r'$\hat{\theta}$', r'$\hat{\phi}$',
              r'$\hat{x}$', r'$\hat{y}$', r'$\hat{z}$']
    dirs = [rhat, thhat, phat, xhat, yhat, zhat]
    for lab, (dx, dy, dz) in zip(labels, dirs):
        t = ax.text(px + 0.7 * dx, py + 0.7 * dy, pz + 0.7 * dz,
                    lab, fontsize=12)
        dynamic.append(t)

    # θ 弧:φ 所在经线平面内,从 +z 轴到 r 方向
    alpha = np.linspace(0, theta, 30)
    arc_theta = ax.plot(0.35 * np.sin(alpha) * np.cos(phi),
                        0.35 * np.sin(alpha) * np.sin(phi),
                        0.35 * np.cos(alpha),
                        color='black')[0]
    dynamic.append(arc_theta)
    am = 0.5 * theta
    t_theta = ax.text(0.35 * 1.5 * np.sin(am) * np.cos(phi),
                      0.35 * 1.5 * np.sin(am) * np.sin(phi),
                      0.35 * 1.5 * np.cos(am),
                      r'$\theta$', fontsize=12)
    dynamic.append(t_theta)

    # φ 弧:xy 平面内,从 x 轴到投影方向
    beta = np.linspace(0, phi, 30)
    arc_phi = ax.plot(0.35 * np.cos(beta), 0.35 * np.sin(beta),
                      np.zeros_like(beta), color='dimgray')[0]
    dynamic.append(arc_phi)
    bm = 0.5 * phi
    t_phi = ax.text(0.35 * 1.5 * np.cos(bm), 0.35 * 1.5 * np.sin(bm), 0,
                    r'$\phi$', fontsize=12)
    dynamic.append(t_phi)

    # 视角随 φ 转动:azim = φ + 视角偏移(s_azim_off 滑块调节,默认 35°),
    # 错开正对方向从斜侧面观察,避免"起点在球心"错觉;elev 不变 ⇒ 不随 θ 转动
    ax.view_init(azim=np.degrees(phi) + np.degrees(s_azim_off.val))
    fig.canvas.draw_idle()


# ---- Slider 与事件绑定 ----

plt.subplots_adjust(bottom=0.42)

ax_azim_slider = plt.axes([0.2, 0.30, 0.6, 0.03])
ax_theta_slider = plt.axes([0.2, 0.20, 0.6, 0.03])
ax_phi_slider = plt.axes([0.2, 0.10, 0.6, 0.03])

s_azim_off = Slider(ax_azim_slider, r'$\phi_{\rm off}$', -np.pi, np.pi, valinit=np.radians(35))
s_theta = Slider(ax_theta_slider, r'$\theta$', 0, np.pi, valinit=np.pi / 4)
s_phi = Slider(ax_phi_slider, r'$\phi$', 0, 2 * np.pi, valinit=np.pi / 4)

s_theta.on_changed(lambda v: draw(v, s_phi.val))
s_phi.on_changed(lambda v: draw(s_theta.val, v))
s_azim_off.on_changed(lambda v: draw(s_theta.val, s_phi.val))

# θ=0/π、φ=0 时弧退化为点,零长线可正常绘制,无需特判;
# θ=π/2、φ=0 时 r̂ 与 x̂ 重合属几何必然
draw(np.pi / 4, np.pi / 4)
plt.show()