
Edu Analytic Geometry
- 272 installs
- 803 repo stars
- Updated June 29, 2026
- wy51ai/edulab
Generate exact analytic-geometry solutions (conics, parameterized chords, range over m) with SymPy for math education content and interactive problem engines.
About
Edu Analytic Geometry is a specialized agent skill packaging a SymPy analytic kernel for conic-section problems common in advanced secondary math. The design philosophy is a single source of truth: every answer, coordinate, intermediate numeric step, and theoretical range for frontend sliders must export from exact symbolic computation, not hand calculation. The core method uses lines in the form x = m·y + c through a fixed point, substitutes into the conic implicit equation, applies Vieta on the resulting quadratic in y, and expresses targets such as dot products, chord length, area, or slope products as functions of m before calling range_over_m with correct endpoint openness. The readme documents geometric meaning of m as cotangent of inclination, including vertical and horizontal limits. Solo builders in edtech, tutoring products, or interactive geometry demos install it when agents must author or verify problem banks and LaTeX writeups. It is not a general CAD or plotting UI skill; it expects Python, SymPy, and the edulab conics companion module in the workspace.
- Single-source SymPy pipeline: answers, coordinates, step values, and frontend range bounds stay consistent
- Parameterized line x = m·y + c through fixed points, Vieta on y, range_over_m with open/closed endpoints
- m interpreted as cot θ with vertical (m=0) and horizontal (m→∞) edge cases documented
- Helpers: tex(), fnum(), is_clean filtering for “nice” rational/root answers in random problem generation
- Built on shared conics module with chord_setup and implicit curve substitution
Edu Analytic Geometry by the numbers
- 272 all-time installs (skills.sh)
- +36 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #601 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wy51ai/edulab --skill edu-analytic-geometryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 272 |
|---|---|
| repo stars | ★ 803 |
| Last updated | June 29, 2026 |
| Repository | wy51ai/edulab ↗ |
What it does
Generate exact analytic-geometry solutions (conics, parameterized chords, range over m) with SymPy for math education content and interactive problem engines.
Files
解析几何解题 → 交互网页
这个技能产出什么
一个可直接用浏览器打开的单页 HTML(三栏):
- 左栏:题面 + 动态控制台 —— 一个可变参数滑块(如直线倾斜角 θ / 动点参数 t)驱动实时
重算的几何量(交点坐标、斜率、数量积、弦长、面积…),以及"理论范围条"或"定值指示"。
- 中栏:分步解析(公式用 KaTeX),可一键收起把空间让给画板。
- 右栏:2D Canvas 动态几何画板(圆锥曲线 + 动直线/动点 + 向量 + 点标注 + 网格坐标轴),
叠加画笔涂鸦工具栏。
形态与目标模板 /Users/wuyi/code/code2026/6/template/code_artifact.html 一致。
依赖(重要)
计算核心 lib/analytic_kernel.py 依赖 sympy。运行脚本前先确认有能 import sympy 的 解释器:python3 -c "import sympy"(本机用 /opt/homebrew/bin/python3.11,sympy 1.14)。
缺库时:若 import 报错(sympy 或后续任何库),先询问用户是否安装,同意后再装 (python3 -m pip install <库名>)或换一个已装该库的解释器;不要未经询问直接装。 下文 python3 均指这个能跑通依赖的解释器。
工作流程
第 1 步:得到 problem spec(三入口归一)
把题目整理成结构化 spec(曲线类型与参数、已知点/条件、所求类型与对象、语言)。
- 文字题:直接抽取。
- 图片:视觉读图抽取,并把识别到的题目回显给用户确认(题面/曲线/参数/所求/语言)再继续。
- 随机出题:选曲线 + 题型,随机参数 → kernel 求解,用
analytic_kernel.is_clean(...)判答案
是否规整,不规整就重抽。
输出语言跟随提示词语言:英文提示 → 英文网页,中文 → 中文。spec 记下 language。第 2 步:用 kernel 精确计算(不要心算)
按 references/conventions.md 的解法配方,调用 lib/analytic_kernel.py 与 lib/conics.py:
conics.ellipse/hyperbola/parabola/circle(...)得曲线对象(精确 a,b,c、焦点、顶点、准线、
渐近线、eq_latex、以及给前端引擎的 board dict)。
chord_setup(conic, through)联立含参直线x=my+c得 y 的二次方程 + 韦达量(精确)。- 目标量:
dot_product_expr/chord_len_sq_expr/triangle_area_expr/slope_product_central… - 取值范围:
range_over_m(expr, horizontal_valid=?)—— 含开闭端点判定(关键正确性点,见下)。 - 定值:
is_constant_in_m(expr)。
可命令行自检 kernel:
python3 lib/analytic_kernel.py # 旗舰题内置断言自检⚠️ 端点开闭 = 正确性命门:过焦点的弦,水平线(x 轴,θ=0)与竖直线(θ=90)都是合法直线,
它们取到的端点要计入。例:椭圆 MA·MB 题,x 轴取到 −3、竖直线取到 7/4,故答案是闭区间
[-3, 7/4](很多教辅误写成开的(-3, 7/4])。range_over_m已据此判定,且这样答案与交互
工具一致——拖滑块到 0° 就读到 −3。抛物线焦点弦的"轴方向"是退化线(只交一点),其极限端点
不计入(horizontal_valid=False 或限制 param 范围)。第 3 步:组装数据并注入模板
📍 输出位置 & 唯一产物(最重要):交付给用户的只有一个 `.html`,写到**当前工作目录
(Path.cwd())(除非用户显式指定路径)。cwd 里不要留任何别的文件**——构建脚本(.py)、
__pycache__、自检截图(.png)、临时文件都不是交付物,一律放/tmp或用完即删。
也绝不要写进技能自身目录(skills/edu-analytic-geometry/output/ 是技能内部样例)。把"组装数据 + 注入模板"的构建脚本写到临时目录(如 /tmp/ag_build.py),让它只把 `.html` 写到 cwd;脚本拼出 lesson / steps / board 数据(schema 见 references/problem-schema.md), 调用 generate.render_html(data, out) 注入 template/board.html,跑完即删脚本:
# 构建脚本放 /tmp(不要放 cwd):/tmp/ag_build.py
import sys; sys.dont_write_bytecode = True # 不生成 __pycache__
sys.path.insert(0, "<技能目录>/scripts")
import generate
from pathlib import Path
data = {"lesson": {...}, "steps": [...], "board": {...}}
out = Path.cwd() / "solution-<题目简述>.html" # 唯一产物,落在用户当前目录
generate.render_html(data, out)python3 -B /tmp/ag_build.py && rm -f /tmp/ag_build.py # -B 不写字节码;跑完删临时脚本,cwd 只剩 .htmlsteps[*].content里的数值直接引用 kernel 结果(用K.tex(...)输出 LaTeX),模型只负责
组织讲解文字(按目标语言)。
board用 kernel 给的曲线boarddict、精确点坐标、param、derived构造序列、readouts、
rangeBar(范围题)/ constant(定值题)/ answerBand(形状参数题,如离心率范围)。
- 形状参数题(滑块=离心率 e 等):自然动态量是曲线本身的形状而非动直线/动点时,让滑块=该参数,
把曲线 a/b/c、焦点、动点坐标写成 @param 的表达式字符串(引擎每帧重绘曲线/焦点/渐近线), 配 status 读数显示不等式状态、answerBand 在参数轴高亮答案区间。见 conventions「形状参数题」。
- 可直接照抄的范本:
scripts/generate.py里 6 个build_*覆盖各类交互范式:
ellipse_dot_range(范围条)、ellipse_chord_range、ellipse_area_max、 ellipse_slopeprod_const(定值·中心对称)、parabola_dot_const(定值·抛物线)、 hyperbola_ecc_range(形状参数:滑块=e,曲线随之重绘 + status + answerBand)。
已注册题直接出(-B 不写字节码;不传路径默认写技能 output,交付给用户时务必改成 cwd 下的 .html):
python3 -B scripts/generate.py list # 列出题型
python3 -B scripts/generate.py ellipse_dot_range ./sol.html
python3 -B scripts/generate.py all ./out_dir # 全部题型第 4 步:自检(正确性方案)
- kernel 答案 == 答案卡
lesson.answer== 末步骤展示值 == JS 标准位/扫段重算值,四者一致
(build_* 内已加 assert)。
rangeBar端点来自 kernel 的range_over_m;constant值来自 kernel 的定值。- 起本地静态服务(服务输出文件所在目录,即 cwd)用预览检查:无控制台报错、KaTeX 正常、
滑块实时重算正确、范围条/定值/定点/轨迹行为符合、画笔与收起面板可用。 (技能仓库内开发时可用 .claude/launch.json 的 ag-preview,端口 4601;别处运行就对 cwd 起 一个临时静态服务。)
- 自检截图只给你自己看:preview 工具直接返回图像,不要把 `.png` 存到 cwd;本地静态服务只读不写、
不产生文件。自检产生的任何临时文件(构建脚本 .py、截图 .png、__pycache__ 等)交付前一律清掉。
⚠️ 必须关闭你开过的端口/服务:预览一结束立即停掉,绝不留占用端口的进程。
- preview 工具开的:preview_stop(传 serverId)。- 直接起的http.server:用完kill,或lsof -nP -iTCP:<port> -sTCP:LISTEN确认已释放。
- 交付前确认端口已释放再告诉用户。开了不关 = 未完成自检。
第 5 步:交付
成品写在用户当前工作目录(cwd),命名形如 solution-<题目简述>.html,把路径告诉用户, 可直接浏览器打开。交付前确认:(1) 成品在 cwd、不在技能目录;(2) 没有遗留本次预览 开启的本地服务/端口;(3) cwd 里只新增了这一个 `.html`——没有 .py / .png / __pycache__ / 临时文件(用 git status 或 ls 核一眼,有就删掉)。
扩展
- 加题型:在
analytic_kernel.py加目标量函数(写成 m 的表达式)+ 复用range_over_m/
is_constant_in_m;在 generate.py 加一个 build_*,选定交互范式(范围条 / 定值 / 定点 / 轨迹 trace / 形状参数 answerBand)。见 references/conventions.md 配方表。
- 加曲线:
conics.py已有椭圆/双曲线/抛物线/圆;前端board.html引擎已支持四类渲染、
渐近线、准线方向。新曲线在两处各加一份即可。
- 加交互构造:
board.html的buildSceneswitch 是构造库(line_through_angle、
intersect_line_conic、point_on_conic、point_reflect、tangent_at、foot_perp…), 按需扩充并在 schema 文档登记。
目录
template/board.html— 数据驱动模板(通用 2D 渲染器 + 参数引擎 + 数据岛__LESSON_DATA__)lib/conics.py— 圆锥曲线 sympy 定义库(特殊点 / LaTeX / board dict)lib/analytic_kernel.py— sympy 精确求解核心(联立·韦达·范围·定值)scripts/generate.py— 注入模板 + 5 个 build_* 范本 + 批量/单题出题references/problem-schema.md— 数据格式(board 引擎 schema)references/conventions.md— 标准式、解法配方表、韦达/换元套路、端点开闭、自检
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
analytic_kernel.py — 解析几何 sympy 精确求解核心。
设计哲学(单一数据源):答案、坐标、步骤数值、前端交互引擎的"理论范围"全部从这里的
sympy 精确结果导出,杜绝心算与不一致。
核心套路:含参直线 x = m·y + c(c 由"过定点"确定),代入圆锥曲线得关于 y 的二次方程,
韦达定理给出 y1+y2, y1·y2(精确),由此把目标量(数量积/弦长/面积/斜率积…)写成 m 的
表达式,再用 range_over_m 求其取值范围(含开闭端点判定)。
m 的几何含义:直线 x=my+c 的斜率为 1/m,即 m=cotθ(θ 为倾斜角)。
- m=0 → 竖直线(θ=90°)
- m→∞ → 水平线(θ=0°,前端滑块拖到 0° 即此情形)
"""
import sympy as sp
import conics
from conics import x, y
m = sp.symbols('m', real=True)
u = sp.symbols('u', nonnegative=True) # u = m^2
# ---------------- 通用工具 ----------------
def tex(e):
return sp.latex(sp.nsimplify(sp.simplify(e)))
def fnum(e):
return float(sp.N(e))
def is_clean(e):
"""答案是否"规整"(有理数或最简根式),用于随机出题筛选。"""
e = sp.nsimplify(sp.simplify(e))
return e.is_rational or (e.free_symbols == set() and sp.simplify(e - sp.nsimplify(e)) == 0)
def interval_latex(lo, hi, lo_closed, hi_closed):
lb = '[' if lo_closed else '('
rb = ']' if hi_closed else ')'
lo_s = r'-\infty' if lo == -sp.oo else tex(lo)
hi_s = r'+\infty' if hi == sp.oo else tex(hi)
return r"%s%s,\ %s%s" % (lb, lo_s, hi_s, rb)
# ---------------- 含参直线 ∩ 圆锥曲线 + 韦达 ----------------
def chord_setup(conic, through):
"""直线 x = m·y + c 过点 through,与 conic 联立 → 关于 y 的二次。返回韦达量等。"""
x0, y0 = sp.nsimplify(through[0]), sp.nsimplify(through[1])
c = x0 - m * y0
sub = sp.expand(conic['implicit'].subs(x, m * y + c))
num = sp.numer(sp.together(sub))
poly = sp.Poly(num, y)
coeffs = poly.all_coeffs()
if len(coeffs) != 3:
raise ValueError(f"联立未得到 y 的二次方程:{poly}")
A, B, C = coeffs
return {
'm': m, 'c': c, 'A': A, 'B': B, 'C': C, 'poly': poly,
'ysum': sp.simplify(-B / A), 'yprod': sp.simplify(C / A),
'disc': sp.simplify(B**2 - 4 * A * C),
}
def _xy_from_y(cs, M=None):
"""由韦达量给出 x1+x2, x1x2(用于把目标量化成 m 的表达式)。"""
ys, yp = cs['ysum'], cs['yprod']
xs = m * ys + 2 * cs['c'] # x1+x2
xp = m**2 * yp + m * cs['c'] * ys + cs['c']**2 # x1·x2
return sp.simplify(xs), sp.simplify(xp)
# ---------------- 目标量(写成 m 的表达式)----------------
def dot_product_expr(conic, through, M):
"""以 M 为顶点,A、B 为交点的数量积 MA·MB 关于 m 的表达式。"""
cs = chord_setup(conic, through)
ys, yp = cs['ysum'], cs['yprod']
xs, xp = _xy_from_y(cs)
Mx, My = sp.nsimplify(M[0]), sp.nsimplify(M[1])
# (x1-Mx)(x2-Mx)+(y1-My)(y2-My) = xp - Mx*xs + Mx^2 + yp - My*ys + My^2
expr = xp - Mx * xs + Mx**2 + yp - My * ys + My**2
return sp.simplify(expr), cs
def chord_len_sq_expr(conic, through):
"""弦长平方 |AB|^2 关于 m 的表达式。|AB|^2=(1+m^2)[(y1+y2)^2-4y1y2]。"""
cs = chord_setup(conic, through)
expr = (1 + m**2) * (cs['ysum']**2 - 4 * cs['yprod'])
return sp.simplify(expr), cs
def triangle_area_expr(conic, through, vertex):
"""△(vertex,A,B) 面积 = 1/2·|AB|·d(vertex,l)。返回面积关于 m 的表达式。"""
cs = chord_setup(conic, through)
chord = sp.sqrt((1 + m**2) * (cs['ysum']**2 - 4 * cs['yprod']))
# 直线 x - m y - c = 0,点到直线距离 d=|vx - m vy - c|/sqrt(1+m^2)
vx, vy = sp.nsimplify(vertex[0]), sp.nsimplify(vertex[1])
d = sp.Abs(vx - m * vy - cs['c']) / sp.sqrt(1 + m**2)
return sp.simplify(sp.Rational(1, 2) * chord * d), cs
# ---------------- 取值范围(关键:含开闭端点判定)----------------
def range_over_m(expr, horizontal_valid=True):
"""目标量 expr(m) 在 m∈ℝ 上的取值范围;horizontal_valid=True 表示水平线(m→∞)
也是合法直线(如椭圆过内点的弦),其极限值也被取到(端点闭)。
返回 dict:lo,hi(sympy)、lo_closed,hi_closed、latex、lo_f,hi_f(float)、argmax/argmin。
"""
g = sp.simplify(expr)
cand = [] # (value, attained?)
# 驻点
dg = sp.together(sp.diff(g, m))
for r in sp.solve(sp.numer(dg), m):
if r.is_real:
cand.append((sp.simplify(g.subs(m, r)), True))
# m=0(竖直线,总是合法)
cand.append((sp.simplify(g.subs(m, 0)), True))
# m→±∞(水平线)
Lp = sp.limit(g, m, sp.oo)
cand.append((sp.simplify(Lp), bool(horizontal_valid) and Lp.is_finite))
finite = [(v, a) for (v, a) in cand if v.is_finite]
lo_val = min(finite, key=lambda t: fnum(t[0]))[0]
hi_val = max(finite, key=lambda t: fnum(t[0]))[0]
# 无界判定
hi = sp.oo if (Lp == sp.oo or any((not v.is_finite and v == sp.oo) for v, _ in cand)) else hi_val
lo = -sp.oo if (Lp == -sp.oo) else lo_val
lo_closed = (lo != -sp.oo) and any(a for (v, a) in cand if sp.simplify(v - lo) == 0)
hi_closed = (hi != sp.oo) and any(a for (v, a) in cand if sp.simplify(v - hi) == 0)
return {
'lo': lo, 'hi': hi, 'lo_closed': lo_closed, 'hi_closed': hi_closed,
'lo_f': (None if lo == -sp.oo else fnum(lo)),
'hi_f': (None if hi == sp.oo else fnum(hi)),
'latex': interval_latex(lo, hi, lo_closed, hi_closed),
}
# ---------------- 定值(与 m 无关)----------------
def is_constant_in_m(expr):
e = sp.simplify(expr)
return sp.simplify(sp.diff(e, m)) == 0, sp.simplify(e.subs(m, 0))
def slope_product_central(conic, P):
"""P 为曲线上定点,A 为曲线上动点,B 为 A 关于中心的对称点。
返回 k_PA·k_PB(应为定值 -b²/a²,与 A 位置无关)。"""
t = sp.symbols('t', real=True)
cx, cy = conic['center']
if conic['kind'] == 'ellipse':
A = (cx + conic['a'] * sp.cos(t), cy + conic['b'] * sp.sin(t))
else:
raise ValueError("slope_product_central 目前支持椭圆")
B = (2 * cx - A[0], 2 * cy - A[1])
Px, Py = sp.nsimplify(P[0]), sp.nsimplify(P[1])
kA = (A[1] - Py) / (A[0] - Px)
kB = (B[1] - Py) / (B[0] - Px)
prod = sp.simplify(sp.trigsimp(kA * kB))
return prod
# ---------------- 离心率范围(形状参数题)----------------
def ecc_range_focal_ratio(k):
"""双曲线右支上存在 P 使 |PF₁| = k|PF₂|(k>1)求 e 的范围。
右支焦半径:|PF₁|−|PF₂|=2a 且 |PF₂|≥c−a。由 k|PF₂|−|PF₂|=2a 得 |PF₂|=2a/(k−1),
代入 |PF₂|≥c−a 得 e ≤ (k+1)/(k−1);又 e>1。故 e ∈ (1, (k+1)/(k−1)]。
返回 dict:lo,hi,lo_closed,hi_closed,lo_f,hi_f,latex,k。
"""
k = sp.nsimplify(k)
if k <= 1:
raise ValueError("k 必须 > 1")
hi = sp.simplify((k + 1) / (k - 1))
lo = sp.Integer(1)
return {
'lo': lo, 'hi': hi, 'lo_closed': False, 'hi_closed': True,
'lo_f': 1.0, 'hi_f': fnum(hi),
'latex': interval_latex(lo, hi, False, True), 'k': k,
}
# =====================================================================
# 自检:旗舰题(椭圆 x²/4+y²/3=1,M(-1,0),过 F(1,0) 的弦,MA·MB 范围)
# =====================================================================
if __name__ == "__main__":
E = conics.ellipse(2, sp.sqrt(3))
print("椭圆:", E['eq_latex'], " 焦点", E['foci'])
expr, cs = dot_product_expr(E, (1, 0), (-1, 0))
print("\n二次方程系数 (A,B,C):", cs['A'], cs['B'], cs['C'])
print("韦达 y1+y2 =", cs['ysum'], " y1y2 =", cs['yprod'])
print("MA·MB(m) =", expr, "=", sp.simplify(expr.rewrite(sp.Add)))
print("化简:", sp.apart(expr, m))
rg = range_over_m(expr, horizontal_valid=True)
print("\n范围:", rg['latex'], " floats:", rg['lo_f'], rg['hi_f'],
" closed:", rg['lo_closed'], rg['hi_closed'])
assert rg['lo_f'] == -3.0 and abs(rg['hi_f'] - 1.75) < 1e-12, "旗舰范围应为 [-3, 7/4]"
assert rg['lo_closed'] and rg['hi_closed'], "两端均应闭(水平线取到 -3,竖直线取到 7/4)"
print("\n✅ 旗舰自检通过:MA·MB ∈", rg['latex'])
# 弦长(过焦点)范围自检:椭圆通径 2b²/a=3 为最短,长轴 2a=4 为最长
cl2, _ = chord_len_sq_expr(E, (1, 0))
rgc = range_over_m(cl2, horizontal_valid=True)
print("弦长² 范围:", rgc['latex'], "→ 弦长 ∈ [",
sp.sqrt(rgc['lo']), ",", sp.sqrt(rgc['hi']), "]")
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
conics.py — 圆锥曲线的 sympy 精确定义库。
每个构造函数返回一个 dict,含:
kind / center / 半轴(a,b) 或 r / p ... —— 几何参数(sympy 精确)
c, foci, vertices, ecc, asymptotes, directrix —— 特殊量(按曲线类型)
implicit : 关于 x,y 的隐式表达式 = 0
eq_latex : 标准方程 LaTeX
board : 注入前端引擎 board.conics[*] 的 dict(浮点)
约定:a = x 方向半轴,b = y 方向半轴(椭圆);双曲线 a=实半轴, b=虚半轴 + orient。
大多数高考题中心在原点,这里以原点为主,圆支持任意圆心。
"""
import sympy as sp
x, y = sp.symbols('x y', real=True)
def _f(e):
"""sympy 精确量 → float。"""
return float(sp.N(e))
def _sq_latex(e):
"""把 a^2 写成尽量整洁的 LaTeX(整数直接显示)。"""
e = sp.nsimplify(e)
return sp.latex(sp.simplify(e))
def _term(numer_latex, denom):
"""分式项:分母为 1 时折叠为分子本身(x^2/1 → x^2)。"""
denom = sp.simplify(sp.nsimplify(denom))
if denom == 1:
return numer_latex
return r"\frac{%s}{%s}" % (numer_latex, _sq_latex(denom))
def ellipse(a, b, center=(0, 0)):
"""椭圆 (x-cx)^2/a^2 + (y-cy)^2/b^2 = 1。a=x 半轴, b=y 半轴。焦点在长轴上。"""
a, b = sp.nsimplify(a), sp.nsimplify(b)
cx, cy = sp.nsimplify(center[0]), sp.nsimplify(center[1])
if a == b:
raise ValueError("a==b 是圆,请用 circle()")
if a > b: # 焦点在 x 轴
c = sp.sqrt(a**2 - b**2)
foci = {'F1': (cx - c, cy), 'F2': (cx + c, cy)}
ecc = c / a
verts = {'A1': (cx - a, cy), 'A2': (cx + a, cy), 'B1': (cx, cy - b), 'B2': (cx, cy + b)}
else: # 焦点在 y 轴
c = sp.sqrt(b**2 - a**2)
foci = {'F1': (cx, cy - c), 'F2': (cx, cy + c)}
ecc = c / b
verts = {'A1': (cx, cy - b), 'A2': (cx, cy + b), 'B1': (cx - a, cy), 'B2': (cx + a, cy)}
implicit = (x - cx)**2 / a**2 + (y - cy)**2 / b**2 - 1
if cx == 0 and cy == 0:
eq_latex = r"%s+%s=1" % (_term("x^2", a**2), _term("y^2", b**2))
else:
eq_latex = r"\frac{(x-%s)^2}{%s}+\frac{(y-%s)^2}{%s}=1" % (
sp.latex(cx), _sq_latex(a**2), sp.latex(cy), _sq_latex(b**2))
return {
'kind': 'ellipse', 'a': a, 'b': b, 'c': c, 'center': (cx, cy),
'foci': foci, 'vertices': verts, 'ecc': ecc,
'implicit': implicit, 'eq_latex': eq_latex,
'board': {'kind': 'ellipse', 'a': _f(a), 'b': _f(b), 'center': [_f(cx), _f(cy)]},
}
def hyperbola(a, b, center=(0, 0), orient='x'):
"""双曲线。orient='x': (x)^2/a^2-(y)^2/b^2=1(焦点在 x 轴);'y' 反之。a=实半轴,b=虚半轴。"""
a, b = sp.nsimplify(a), sp.nsimplify(b)
cx, cy = sp.nsimplify(center[0]), sp.nsimplify(center[1])
c = sp.sqrt(a**2 + b**2)
if orient == 'x':
foci = {'F1': (cx - c, cy), 'F2': (cx + c, cy)}
verts = {'A1': (cx - a, cy), 'A2': (cx + a, cy)}
implicit = (x - cx)**2 / a**2 - (y - cy)**2 / b**2 - 1
asym = (b / a, -b / a)
eq_latex = r"%s-%s=1" % (_term("x^2", a**2), _term("y^2", b**2))
else:
foci = {'F1': (cx, cy - c), 'F2': (cx, cy + c)}
verts = {'A1': (cx, cy - a), 'A2': (cx, cy + a)}
implicit = (y - cy)**2 / a**2 - (x - cx)**2 / b**2 - 1
asym = (a / b, -a / b)
eq_latex = r"%s-%s=1" % (_term("y^2", a**2), _term("x^2", b**2))
return {
'kind': 'hyperbola', 'a': a, 'b': b, 'c': c, 'center': (cx, cy), 'orient': orient,
'foci': foci, 'vertices': verts, 'ecc': c / a, 'asymptote_slopes': asym,
'implicit': implicit, 'eq_latex': eq_latex,
'board': {'kind': 'hyperbola', 'a': _f(a), 'b': _f(b), 'center': [_f(cx), _f(cy)],
'orient': orient, 'asymptotes': True},
}
def parabola(p, vertex=(0, 0), axis='x'):
"""抛物线。axis='x': (y-cy)^2=2p(x-cx)(开口随 p 符号);'y': (x-cx)^2=2p(y-cy)。"""
p = sp.nsimplify(p)
cx, cy = sp.nsimplify(vertex[0]), sp.nsimplify(vertex[1])
if axis == 'x':
focus = (cx + p / 2, cy)
directrix = ('x', cx - p / 2) # 准线 x = cx - p/2
implicit = (y - cy)**2 - 2 * p * (x - cx)
eq_latex = (r"y^2=%s x" % sp.latex(2 * p)) if (cx == 0 and cy == 0) else \
(r"(y-%s)^2=%s(x-%s)" % (sp.latex(cy), sp.latex(2 * p), sp.latex(cx)))
else:
focus = (cx, cy + p / 2)
directrix = ('y', cy - p / 2)
implicit = (x - cx)**2 - 2 * p * (y - cy)
eq_latex = (r"x^2=%s y" % sp.latex(2 * p)) if (cx == 0 and cy == 0) else \
(r"(x-%s)^2=%s(y-%s)" % (sp.latex(cx), sp.latex(2 * p), sp.latex(cy))
)
return {
'kind': 'parabola', 'p': p, 'vertex': (cx, cy), 'axis': axis,
'focus': focus, 'directrix': directrix,
'implicit': implicit, 'eq_latex': eq_latex,
'board': {'kind': 'parabola', 'p': _f(p), 'center': [_f(cx), _f(cy)], 'axis': axis},
}
def circle(center, r):
"""圆 (x-cx)^2+(y-cy)^2=r^2。"""
cx, cy = sp.nsimplify(center[0]), sp.nsimplify(center[1])
r = sp.nsimplify(r)
implicit = (x - cx)**2 + (y - cy)**2 - r**2
if cx == 0 and cy == 0:
eq_latex = r"x^2+y^2=%s" % _sq_latex(r**2)
else:
eq_latex = r"(x-%s)^2+(y-%s)^2=%s" % (sp.latex(cx), sp.latex(cy), _sq_latex(r**2))
return {
'kind': 'circle', 'center': (cx, cy), 'r': r,
'implicit': implicit, 'eq_latex': eq_latex,
'board': {'kind': 'circle', 'r': _f(r), 'center': [_f(cx), _f(cy)]},
}
if __name__ == "__main__":
e = ellipse(2, sp.sqrt(3))
print("ellipse:", e['eq_latex'], "| foci", e['foci'], "| e =", e['ecc'])
h = hyperbola(1, sp.sqrt(3))
print("hyperbola:", h['eq_latex'], "| foci", h['foci'], "| asym", h['asymptote_slopes'])
pa = parabola(2)
print("parabola:", pa['eq_latex'], "| focus", pa['focus'], "| directrix", pa['directrix'])
print("circle:", circle((0, 0), 2)['eq_latex'])
约定与解法配方(conventions)
1. 坐标系与曲线标准式
解析几何直接用数学平面坐标(x 右、y 上),无需像立体几何那样做 z↔y 换轴。前端引擎把数学 坐标 (x,y) 映射到屏幕:sx = offX + x·scale,sy = offY − y·scale(y 翻转)。scale 由 view 视窗自适应,不影响数值。
conics.py 的标准建系(中心/顶点默认原点):
ellipse(a, b)—x²/a² + y²/b² = 1,a=x 半轴、b=y 半轴;焦点在长轴上,c=√|a²−b²|。hyperbola(a, b, orient)—orient='x':x²/a²−y²/b²=1;'y':y²/a²−x²/b²=1。a=实半轴、
b=虚半轴,c=√(a²+b²),渐近线斜率 ±b/a(x 向)。
parabola(p, axis)—axis='x':y²=2px,焦点(p/2,0),准线x=−p/2;'y':x²=2py。circle(center, r)—(x−h)²+(y−k)²=r²。
2. 核心套路:设含参直线 + 联立 + 韦达
首选 `x = m·y + c`(而非 y = kx + b):天然包含竖直线(m=0),避免"斜率不存在"的讨论; 水平线对应 m→∞(前端滑块拖到 θ=0° 即此情形)。过定点 (x₀,y₀) 时 c = x₀ − m·y₀。
chord_setup(conic, through) 把 x=my+c 代入曲线,得关于 y 的二次方程,返回精确的 A,B,C 系数、ysum=−B/A、yprod=C/A、判别式 disc。由韦达量把目标量写成 m 的表达式:
x₁+x₂ = m·ysum + 2c,x₁x₂ = m²·yprod + m·c·ysum + c²。
3. 解法配方(query.type → kernel → 交互范式)
| query.type | kernel 函数 / 公式 | 交互范式 |
|---|---|---|
standard_equation 求标准方程 | 由离心率/过点/焦点解 a,b,c(或 h,k,r) | 静态标注 |
chord_length 弦长(范围) | chord_len_sq_expr,` | AB |
dot_product 数量积(范围/定值) | dot_product_expr + range_over_m/is_constant_in_m | 转直线 + 范围条/定值 |
triangle_area 面积(最值) | `triangle_area_expr=½· | AB |
slope_product 斜率之积(定值) | slope_product_central(中心对称)等 | 转动点 + 定值 |
fixed_value 定值 | 目标写成 m 表达式 → is_constant_in_m | 转参数 + 定值 |
fixed_point 定点 | 含参直线令"参数项系数=0"解出定点 | 转参数 + 动直线穿定点(emphasis) |
locus 轨迹 | 设动点 (x,y),消参得方程 | 拖驱动点 + trace 描路径 + 叠加方程 |
tangent 切线 | 判别式=0 / 点切式(tangent_at) | 静态 / 转切点 |
eccentricity 离心率(值/范围) | e=c/a + 条件不等式(如 ecc_range_focal_ratio) | 滑块=e·曲线随形 + status + answerBand |
辅助:tex(expr)(LaTeX 输出)、fnum(expr)(float)、is_clean(expr)(随机出题判规整)、 interval_latex(lo,hi,lo_closed,hi_closed)。
形状参数题(滑块直接驱动曲线,如离心率范围)
有些题的自然动态量不是"动直线/动点",而是曲线本身的形状参数(最常见是离心率 e)。此时让 滑块 = 该参数,把曲线的 a/b/c、焦点、动点坐标写成该参数的表达式字符串(schema 3.1/3.2/3.3b), 引擎每帧重算重绘曲线、焦点、渐近线。配套套路:
- 固定一个标度(如取
a=1,则c=e、b="sqrt(e*e-1)"),其余量用表达式表示; - 用
status读数显示存在性/不等式是否成立(如右支上 P 存在 ⇔e≤2);表达式算出NaN的点会
自动隐藏,直观表现"不存在";
- 用
answerBand在参数轴上高亮答案区间(端点由 kernel 给,如ecc_range_focal_ratio(3)→e∈(1,2])。
范例见 generate.py 的 build_hyperbola_ecc_range。
4. 端点开闭判定(正确性命门)
range_over_m(expr, horizontal_valid=True) 在 m∈ℝ 上求范围,并判定端点开/闭:
- 收集驻点值、
m=0(竖直线,恒合法)、m→±∞(水平线)的极限; - 端点是否"闭" = 是否被某条真实合法直线取到。
horizontal_valid=True:水平线也是合法弦(如椭圆过内部点的弦),其极限端点计入(闭)。
例:椭圆 MA·MB,x 轴取到 −3、竖直线取到 7/4 → `[-3, 7/4]`(勿写成开的 (-3, 7/4])。
horizontal_valid=False:水平/退化线不合法或会使图形退化(如△OAB共线面积 0、抛物线焦点弦
的轴方向只交一点),该极限端点不计入(开)。例:△OAB 面积 (0, 3/2]。
这条同时保证答案与交互工具一致:滑块能拖到端点对应的 θ 时,读数应正好等于答案端点值。
5. 正确性自检(必须)
- kernel 算出的答案 == 答案卡
lesson.answer== 末步骤展示值 == 前端 JS 标准位/扫段重算值,
四者一致;scripts/generate.py 的每个 build_* 已内置 assert,照此为新题型加断言。
rangeBar端点来自range_over_m;constant值来自is_constant_in_m/对应 kernel 函数。- 随机题:与 kernel 生成时的标准答案比对。
- 生成后起本地静态服务用预览检查:无控制台报错、KaTeX 渲染正常、滑块实时重算正确、范围条/
定值/定点/轨迹行为符合、画笔与收起面板可用。预览完务必关端口。
6. 视窗(view)经验值
让曲线主体留约 10–15% 边距:椭圆 xRange≈[-1.8a,1.8a];抛物线开口方向多留(如 y²=4x 用 xRange:[-2,7]);双曲线含渐近线时视窗别太大以免曲线过扁。param 范围要避开退化值(抛物线 焦点弦避开轴方向 θ≈0/180;中心对称斜率积避开动点与定点重合的参数)。
数据格式参考(problem-schema)
三入口(文字 / 图片 / 随机)最终归一成同一份数据,注入模板 template/board.html 的数据岛 <script id="lesson-data">__LESSON_DATA__</script>。数据是一个 JSON 对象,三段: lesson / steps / board。
1. lesson(题面 / 答案 / 界面文案)
"lesson": {
"language": "zh-CN", // 跟随提示词语言:zh-CN / en
"title": "椭圆与动态向量积范围", // 左上标题
"problem": "<p>……题面 HTML,行内公式 $…$,块公式 $$…$$……</p>",
"answerLabel": "向量数量积取值范围", // 答案文字说明(自检用,可不显示)
"answer": "$\\left[-3,\\ \\dfrac{7}{4}\\right]$", // 最终答案 LaTeX(自检用)
"ui": { "solutionTitle": "Solution", "collapse": "Collapse", ... } // 可选:英文输出时覆盖界面文案
}界面文案默认中文,键见 board.html 顶部 UI:consoleTitle / solutionTitle / collapse / expand / current / theoRange。英文输出时设 lesson.language="en" 并填 lesson.ui。
2. steps(分步解析)
"steps": [
{ "title": "联立 + 韦达定理", "content": "<p>HTML,公式用 $…$ / $$…$$</p>" },
...
]每步自动编号(01、02…)。content 里的数值应来自 kernel(analytic_kernel.tex(expr)), 讲解文字由模型按目标语言书写。
3. board(场景 + 交互模型)—— 新核心
"board": {
"view": { "xRange": [-3.6, 3.6], "yRange": [-2.6, 2.6] }, // 数学坐标视窗,引擎自适应缩放
"conics": [ { "name":"C", "kind":"ellipse", "a":2, "b":1.732, "center":[0,0],
"color":"curve", "label":"C: x²/4+y²/3=1" } ],
"points": { "M": {"xy":[-1,0], "color":"vecA", "label":"M(-1,0)"}, "F":[1,0] },
"param": { "name":"e", "label":"离心率 $e$", "min":1.05,"max":3,"step":0.01,
"value":1.5, "unit":"", "standard":1.5, "ticks":["1","2","3"] },
"scalars": [ { "name":"b", "expr":"sqrt(e*e-1)" } ], // 可选:由 @param 派生的命名标量,按序求值
"derived": [ /* 参数实时驱动的构造序列,见下 */ ],
"readouts": [ /* 控制台实时数值,见下 */ ],
"rangeBar": { "of":"dot", "min":-3, "max":1.75, "label":"$[-3,\\ \\frac74]$" }, // 范围/最值题
"constant": { "of":"kprod", "label":"$-\\dfrac34$" }, // 定值题
"answerBand":{ "min":1,"max":3,"lo":1,"hi":2,"label":"$e\\in(1,2]$" }, // 形状参数题:参数轴上高亮答案区间
"trace": { "of":"Q", "color":"locus" }, // 轨迹题(可选)
"legend": [ { "color":"line", "text":"动直线 l" } ] // 画板左下图例(可选)
}rangeBar/constant/answerBand三者按题型择一。
3.1 conics[*](圆锥曲线)
| kind | 必填参数 | 说明 |
|---|---|---|
ellipse | a(x 半轴), b(y 半轴), center | |
hyperbola | a(实半轴), b(虚半轴), center, orient("x"/"y") | asymptotes:true 画渐近线 |
parabola | p, center(顶点), axis("x"/"y") | (y-cy)²=2p(x-cx) 或 (x-cx)²=2p(y-cy) |
circle | r, center |
通用可选:color、label(图例文字)、dashed、hidden、legend:false。 直接用 `conics.py` 返回对象的 `board` 字段,再补 name/color/label 即可。
参数化曲线(形状参数题,如离心率):a/b/c/r/p与center各坐标可写成表达式字符串
(用@param名或别名p,及sqrt/sin/cos/abs/pow/min/max/PI与+ - * / ^),引擎每帧按
当前滑块值重算并重绘曲线、焦点、渐近线。例:双曲线{"a":1,"b":"sqrt(e*e-1)"}随e变形。
3.2 points(静态命名点)
值可为 [x,y],或 {xy:[x,y], color, label, emphasis, hidden}。emphasis:true 画大一圈带白边 (定点用);hidden:true 只参与构造不显示;label 省略则用点名。 坐标也可为表达式字符串(随 @param 变),如 "xy":["e","0"]、"xy":["2/e","sqrt((e*e-1)*(4/(e*e)-1))"]; 当表达式算出 NaN(如根号下为负)时该点自动隐藏,依赖它的线段/向量/读数一并消失(天然表达"不存在")。
3.3 param(可变参数,省略=静态图)
min/max/step/value,unit(显示后缀),standard(题目设定值,"重置"按钮归到此), label(可含 LaTeX),ticks(滑块下方刻度文字数组)。参数当前值在引擎里记作 @param, 在表达式里用参数名(须是合法标识符,如 e/t/k)或别名 p 引用。
3.3b scalars(可选:由参数派生的命名标量)
[{name, expr}],按数组顺序求值(后者可引用前者);算出的标量加入表达式环境,供 conics / points / readouts 的表达式引用。例:[{"name":"c","expr":"e"},{"name":"b","expr":"sqrt(e*e-1)"}]。
3.4 derived(构造序列,按顺序求解,可引用前面的结果)
type 一览(引擎构造库,与 analytic_kernel 对应):
| type | 字段 | 产出 |
|---|---|---|
line_through_angle | name, point, angle(数或"@param") | 直线(过点、给倾斜角) |
line_through_slope | name, point, slope | 直线(过点、给斜率) |
line_x_eq_my_c | name, m, c | 直线 x=my+c |
line_through_points | name, a, b(点名) | 两点连线 |
line_through_point_dir | name, point, dir:[dx,dy] | 过点给方向 |
point_on_conic | name, conic, t(角度°/参数) | 曲线上参数点 |
intersect_line_conic | name:[n1,n2], line, conic, colors | 直线∩曲线(按 t 升序两点;不足则缺省隐藏) |
intersect_line_line | name, a, b(线名) | 两线交点 |
midpoint | name, a, b | 中点 |
point_reflect | name, of, center | 中心对称点 2·center − of |
foot_perp | name, point, line | 垂足 |
reflect | name, point, line | 关于直线的反射 |
tangent_at | name, conic, point | 曲线在其上一点的切线 |
vector | name, from, to(点名) | 向量箭头 |
segment | name, a, b, dashed, color | 线段 |
polygon | name, pts:[...], color, stroke | 多边形(半透明填充,三角形面积用) |
构造对象可加 color(语义名或 hex)、label、dashed。
3.5 readouts(控制台实时数值)
每项 {id, label, type, ..., color, highlight}。highlight:true 用青色徽标突出(通常是目标量)。 id 供 rangeBar.of / constant.of 跟踪。
| type | 字段 | 显示 |
|---|---|---|
coord | of(点名) | (x, y) |
length | a,b(点) 或 of(向量名) | 长度 |
distance | a,b(点) | 两点距离 |
dot | a,b(向量名) | 数量积 |
slope | of(线名) | 斜率(竖直显示"不存在") |
slope_product | a,b(线名) | 斜率之积 |
area_triangle | pts:[p,q,r] | 三角形面积 |
distance_point_line | point, line | 点到直线距离 |
expr | expr(表达式), digits | 表达式数值(可用 @param/scalars,如半焦距 c) |
status | expr, op, rhs, okText, badText | 不等式状态:expr op rhs(op∈ < <= > >= ==) 成立→绿色"满足",否则红色"不满足" |
3.6 rangeBar / constant / answerBand / trace
rangeBar(范围、最值题):of跟踪一个标量 readout 的id,min/max为 kernel 给的
理论范围浮点,label 为区间 LaTeX(带 $…$)。指针随当前值在 min–max 间移动。
constant(定值题):of跟踪一个 readout,label为定值 LaTeX(带$…$),显示"恒为定值 ≡ …"。answerBand(形状参数题,如离心率范围):在参数轴上画[min,max],高亮答案子区间[lo,hi],
指针=当前参数值,label 为答案 LaTeX(带 $…$)。用于"求 e 的取值范围"这类滑块本身即是答案变量的题。
trace(轨迹题):of为某个derived点名;引擎在 param 全程采样描出该点路径(可叠加 kernel
的轨迹方程作为一条 conics 曲线对照)。
3.7 颜色语义名(COLORS)
curve(金黄·主曲线) · curve2(粉·次曲线) · line(青·动直线) · line2(天蓝) · aux(灰辅助) · asymptote · directrix · ptA(红) · ptB(蓝) · point(浅灰) · given(紫) · fixed(翠绿·定点) · vecA(红) · vecB(蓝) · vec(琥珀) · locus · area(青半透明)。也可直接写 hex。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
generate.py — 把结构化数据注入 template/board.html,产出单页解析几何交互网页。
数据由 lib/analytic_kernel.py 的 sympy 精确计算驱动(单一数据源):答案、坐标、步骤数值、
交互引擎初值与"理论范围/定值"严格一致。每个 build_* 内置自检断言。
依赖 sympy。用能 import sympy 的解释器运行(本机:/opt/homebrew/bin/python3.11):
python3 scripts/generate.py <题型key> [输出.html]
python3 scripts/generate.py list
python3 scripts/generate.py all <输出目录> # 生成全部已注册题型
"""
import json
import sys
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent
TEMPLATE = SKILL_DIR / "template" / "board.html"
PLACEHOLDER = "__LESSON_DATA__"
sys.path.insert(0, str(SKILL_DIR / "lib"))
import sympy as sp # noqa: E402
import conics # noqa: E402
import analytic_kernel as K # noqa: E402
def render_html(data: dict, out_path: Path) -> Path:
template = TEMPLATE.read_text(encoding="utf-8")
if PLACEHOLDER not in template:
raise RuntimeError(f"模板中未找到占位符 {PLACEHOLDER}")
out_path.write_text(template.replace(PLACEHOLDER, json.dumps(data, ensure_ascii=False)),
encoding="utf-8")
return out_path
# ---------------- 小工具 ----------------
def f(e):
return float(sp.N(e))
def pt(xy, color="point", label=None, emphasis=False):
return {"xy": [f(xy[0]), f(xy[1])], "color": color, "label": label, "emphasis": emphasis}
def itv(rg):
return "$" + rg["latex"] + "$"
def conic_board(c, color="curve", label=None):
b = dict(c["board"]); b["name"] = "C"; b["color"] = color
if label:
b["label"] = label
return b
# =====================================================================
# 1) 椭圆 · 数量积 · 取值范围(旗舰)
# =====================================================================
def build_ellipse_dot_range() -> dict:
E = conics.ellipse(2, sp.sqrt(3))
M, F = (-1, 0), (1, 0)
expr, cs = K.dot_product_expr(E, F, M)
rg = K.range_over_m(expr)
# 自检:水平线(x 轴)取到 -3,竖直线取到 7/4
assert (rg["lo_f"], rg["hi_f"]) == (-3.0, 1.75) and rg["lo_closed"] and rg["hi_closed"]
board = {
"view": {"xRange": [-3.6, 3.6], "yRange": [-2.6, 2.6]},
"conics": [conic_board(E, label="C: x²/4 + y²/3 = 1")],
"points": {"M": pt(M, "vecA", "M(-1,0)"), "F": pt(F, "point", "F(1,0)"),
"P": pt((1, 1.5), "given", "P")},
"param": {"name": "θ", "label": "旋转倾斜角 $\\theta$", "min": 0, "max": 180,
"step": 0.5, "value": 45, "unit": "°", "standard": 45,
"ticks": ["0° (x 轴)", "90°", "180°"]},
"derived": [
{"type": "line_through_angle", "name": "l", "point": "F", "angle": "@param", "color": "line"},
{"type": "intersect_line_conic", "name": ["A", "B"], "line": "l", "conic": "C", "colors": ["ptA", "ptB"]},
{"type": "vector", "name": "vMA", "from": "M", "to": "A", "color": "vecA"},
{"type": "vector", "name": "vMB", "from": "M", "to": "B", "color": "vecB"},
],
"readouts": [
{"id": "F", "label": "右焦点 F 坐标", "type": "coord", "of": "F"},
{"id": "k", "label": "直线斜率 k", "type": "slope", "of": "l"},
{"id": "A", "label": "交点 A", "type": "coord", "of": "A", "color": "ptA"},
{"id": "B", "label": "交点 B", "type": "coord", "of": "B", "color": "ptB"},
{"id": "dot", "label": "数量积 $\\vec{MA}\\cdot\\vec{MB}$", "type": "dot", "a": "vMA", "b": "vMB", "highlight": True},
],
"rangeBar": {"of": "dot", "min": rg["lo_f"], "max": rg["hi_f"], "label": itv(rg)},
"legend": [{"color": "line", "text": "动直线 l"}, {"color": "vecA", "text": "向量 MA (红)"},
{"color": "vecB", "text": "向量 MB (蓝)"}],
}
lesson = {
"language": "zh-CN", "title": "椭圆与动态向量积范围",
"problem": ("<p class='font-medium text-slate-800'>【题目】</p>"
"<p>椭圆 $C:\\dfrac{x^2}{a^2}+\\dfrac{y^2}{b^2}=1\\,(a>b>0)$ 离心率 $e=\\dfrac12$,"
"过点 $P\\left(1,\\dfrac32\\right)$。$M(-1,0)$,过右焦点 $F$ 的直线 $l$ 交 $C$ 于 $A,B$。</p>"
"<ol class='list-decimal pl-5 space-y-1'><li>求 $C$ 的标准方程;</li>"
"<li>求 $\\vec{MA}\\cdot\\vec{MB}$ 的取值范围。</li></ol>"),
"answerLabel": "向量数量积取值范围", "answer": itv(rg),
}
steps = [
{"title": "求椭圆 C 的标准方程",
"content": ("<p>$e=\\dfrac{c}{a}=\\dfrac12,\\ c^2=a^2-b^2\\Rightarrow a^2=\\dfrac43b^2$;"
"代入 $P\\left(1,\\dfrac32\\right)$ 得 $b^2=3,\\ a^2=4$。</p>"
"<div class='text-center py-3 bg-indigo-50 border border-indigo-100 rounded-xl text-indigo-900 font-bold'>"
f"$$ {E['eq_latex']} $$</div>")},
{"title": "联立 + 韦达定理",
"content": ("<p>$F(1,0)$。设 $l:\\,x=my+1$(含竖直线,避免讨论斜率),代入椭圆:</p>"
f"<p class='text-center'>$$ {K.tex(cs['A'])}\\,y^2 + {K.tex(cs['B'])}\\,y {K.tex(cs['C'])} = 0 $$</p>"
f"<p>韦达:$y_1+y_2={K.tex(cs['ysum'])},\\ y_1y_2={K.tex(cs['yprod'])}$。</p>")},
{"title": "数量积化简并求范围",
"content": (f"<p>$\\vec{{MA}}\\cdot\\vec{{MB}}=(m^2+1)y_1y_2+2m(y_1+y_2)+4={K.tex(sp.apart(expr, K.m))}$。</p>"
"<p>由 $3m^2+4\\ge4$ 知 $\\dfrac{19}{3m^2+4}\\in\\left(0,\\dfrac{19}{4}\\right]$,"
"竖直线取到 $\\dfrac74$;$l$ 为 $x$ 轴时 $A(2,0),B(-2,0)$ 取到 $-3$。</p>"
"<div class='text-center py-3 bg-emerald-50 border border-emerald-100 rounded-xl text-emerald-900 font-bold'>"
f"$$ \\vec{{MA}}\\cdot\\vec{{MB}}\\in {rg['latex']} $$</div>")},
]
return {"lesson": lesson, "steps": steps, "board": board}
# =====================================================================
# 2) 椭圆 · 过焦点弦长 · 取值范围
# =====================================================================
def build_ellipse_chord_range() -> dict:
E = conics.ellipse(2, sp.sqrt(3))
F = (1, 0)
cl2, cs = K.chord_len_sq_expr(E, F)
rg2 = K.range_over_m(cl2) # |AB|^2 ∈ [9,16]
lo, hi = sp.sqrt(rg2["lo"]), sp.sqrt(rg2["hi"])
chord_latex = K.interval_latex(lo, hi, rg2["lo_closed"], rg2["hi_closed"])
assert (f(lo), f(hi)) == (3.0, 4.0)
board = {
"view": {"xRange": [-3.6, 3.6], "yRange": [-2.6, 2.6]},
"conics": [conic_board(E, label="C: x²/4 + y²/3 = 1")],
"points": {"F1": pt((-1, 0), "point", "F₁(-1,0)"), "F": pt(F, "given", "F(1,0)")},
"param": {"name": "θ", "label": "弦 AB 倾斜角 $\\theta$", "min": 0, "max": 180,
"step": 0.5, "value": 90, "unit": "°", "standard": 90,
"ticks": ["0° (长轴)", "90° (通径)", "180°"]},
"derived": [
{"type": "line_through_angle", "name": "l", "point": "F", "angle": "@param", "color": "line"},
{"type": "intersect_line_conic", "name": ["A", "B"], "line": "l", "conic": "C", "colors": ["ptA", "ptB"]},
{"type": "segment", "name": "AB", "a": "A", "b": "B", "color": "line2"},
],
"readouts": [
{"id": "k", "label": "直线斜率 k", "type": "slope", "of": "l"},
{"id": "A", "label": "交点 A", "type": "coord", "of": "A", "color": "ptA"},
{"id": "B", "label": "交点 B", "type": "coord", "of": "B", "color": "ptB"},
{"id": "len", "label": "焦点弦长 $|AB|$", "type": "length", "a": "A", "b": "B", "highlight": True},
],
"rangeBar": {"of": "len", "min": f(lo), "max": f(hi), "label": "$" + chord_latex + "$"},
"legend": [{"color": "line", "text": "过焦点动弦 l"}],
}
lesson = {
"language": "zh-CN", "title": "椭圆过焦点的弦长范围",
"problem": ("<p class='font-medium text-slate-800'>【题目】</p>"
"<p>椭圆 $C:\\dfrac{x^2}{4}+\\dfrac{y^2}{3}=1$,过右焦点 $F(1,0)$ 的直线 $l$ 交 $C$ 于 $A,B$。"
"求弦长 $|AB|$ 的取值范围。</p>"),
"answerLabel": "焦点弦长取值范围", "answer": "$|AB|\\in" + chord_latex + "$",
}
steps = [
{"title": "联立 + 韦达定理",
"content": ("<p>设 $l:\\,x=my+1$,代入椭圆得 "
f"$ {K.tex(cs['A'])}y^2+{K.tex(cs['B'])}y{K.tex(cs['C'])}=0 $,</p>"
f"<p>$y_1+y_2={K.tex(cs['ysum'])},\\ y_1y_2={K.tex(cs['yprod'])}$。</p>")},
{"title": "弦长公式",
"content": ("<p>$|AB|^2=(1+m^2)\\left[(y_1+y_2)^2-4y_1y_2\\right]="
f"{K.tex(sp.simplify(cl2))}$。</p>"
"<p>令 $u=m^2\\ge0$:$|AB|^2=\\dfrac{144(u+1)}{(3u+4)^2}$,关于 $u$ 单调,"
"$u=0$(竖直·通径)得最小 $9$,$u\\to\\infty$(水平·长轴)得最大 $16$。</p>"
"<div class='text-center py-3 bg-emerald-50 border border-emerald-100 rounded-xl text-emerald-900 font-bold'>"
f"$$ |AB|\\in {chord_latex} $$</div>")},
]
return {"lesson": lesson, "steps": steps, "board": board}
# =====================================================================
# 3) 椭圆 · △OAB 面积 · 最值(过焦点弦)
# =====================================================================
def build_ellipse_area_max() -> dict:
E = conics.ellipse(2, sp.sqrt(3))
F = (1, 0)
area, cs = K.triangle_area_expr(E, F, (0, 0))
rg = K.range_over_m(sp.simplify(area), horizontal_valid=False) # (0, 3/2]
assert rg["hi_f"] == 1.5 and rg["hi_closed"] and not rg["lo_closed"]
board = {
"view": {"xRange": [-3.6, 3.6], "yRange": [-2.6, 2.6]},
"conics": [conic_board(E, label="C: x²/4 + y²/3 = 1")],
"points": {"O": pt((0, 0), "point", "O"), "F": pt(F, "given", "F(1,0)")},
"param": {"name": "θ", "label": "弦 AB 倾斜角 $\\theta$", "min": 0, "max": 180,
"step": 0.5, "value": 90, "unit": "°", "standard": 90,
"ticks": ["0°", "90°", "180°"]},
"derived": [
{"type": "line_through_angle", "name": "l", "point": "F", "angle": "@param", "color": "line"},
{"type": "intersect_line_conic", "name": ["A", "B"], "line": "l", "conic": "C", "colors": ["ptA", "ptB"]},
{"type": "polygon", "name": "tri", "pts": ["O", "A", "B"], "color": "area", "stroke": "line2"},
],
"readouts": [
{"id": "A", "label": "交点 A", "type": "coord", "of": "A", "color": "ptA"},
{"id": "B", "label": "交点 B", "type": "coord", "of": "B", "color": "ptB"},
{"id": "len", "label": "弦长 $|AB|$", "type": "length", "a": "A", "b": "B"},
{"id": "area", "label": "$S_{\\triangle OAB}$", "type": "area_triangle", "pts": ["O", "A", "B"], "highlight": True},
],
"rangeBar": {"of": "area", "min": 0.0, "max": rg["hi_f"], "label": itv(rg)},
"legend": [{"color": "line", "text": "过焦点动弦 l"}, {"color": "area", "text": "△OAB"}],
}
lesson = {
"language": "zh-CN", "title": "椭圆中三角形面积的最值",
"problem": ("<p class='font-medium text-slate-800'>【题目】</p>"
"<p>椭圆 $C:\\dfrac{x^2}{4}+\\dfrac{y^2}{3}=1$,$O$ 为原点,过右焦点 $F(1,0)$ 的直线 $l$ 交 $C$ 于 $A,B$。"
"求 $\\triangle OAB$ 面积的最大值。</p>"),
"answerLabel": "△OAB 面积最大值", "answer": "$\\dfrac{3}{2}$",
}
steps = [
{"title": "面积表达式",
"content": ("<p>设 $l:\\,x=my+1$。$S=\\dfrac12|AB|\\cdot d(O,l)=\\dfrac12\\,\\dfrac{|c|}{\\sqrt{1+m^2}}\\,|AB|$。</p>"
f"<p>代入韦达 $y_1+y_2={K.tex(cs['ysum'])},\\ y_1y_2={K.tex(cs['yprod'])}$,化简得 "
f"$S={K.tex(sp.simplify(area))}$。</p>")},
{"title": "求最大值",
"content": ("<p>令 $u=m^2\\ge0$:$S=\\dfrac{6\\sqrt{u+1}}{3u+4}$,$S^2=\\dfrac{36(u+1)}{(3u+4)^2}$ 在 $u\\ge0$ "
"单调递减,故 $u=0$(竖直弦)时取最大。</p>"
"<div class='text-center py-3 bg-emerald-50 border border-emerald-100 rounded-xl text-emerald-900 font-bold'>"
"$$ S_{\\max}=\\dfrac{3}{2} $$</div>"
"<p class='text-slate-500 text-sm'>($l$ 趋于 $x$ 轴时 $O,A,B$ 共线,面积趋于 0,故 "
f"$S\\in {rg['latex']}$。)</p>")},
]
return {"lesson": lesson, "steps": steps, "board": board}
# =====================================================================
# 4) 椭圆 · 斜率之积 · 定值(中心对称弦)
# =====================================================================
def build_ellipse_slopeprod_const() -> dict:
E = conics.ellipse(2, sp.sqrt(3))
P = (2, 0)
val = K.slope_product_central(E, P) # -3/4
assert sp.simplify(val + sp.Rational(3, 4)) == 0
board = {
"view": {"xRange": [-3.2, 3.2], "yRange": [-2.4, 2.4]},
"conics": [conic_board(E, label="C: x²/4 + y²/3 = 1")],
"points": {"P": pt(P, "fixed", "P(2,0)", emphasis=True)},
"param": {"name": "t", "label": "动点 A 的参数角 $t$", "min": 12, "max": 168,
"step": 1, "value": 60, "unit": "°", "standard": 60,
"ticks": ["12°", "90°", "168°"]},
"derived": [
{"type": "point_on_conic", "name": "A", "conic": "C", "t": "@param", "color": "ptA", "emphasis": True},
{"type": "point_reflect", "name": "B", "of": "A", "center": [0, 0], "color": "ptB", "emphasis": True},
{"type": "line_through_points", "name": "PA", "a": "P", "b": "A", "color": "vecA"},
{"type": "line_through_points", "name": "PB", "a": "P", "b": "B", "color": "vecB"},
{"type": "segment", "name": "AB", "a": "A", "b": "B", "color": "aux", "dashed": True},
],
"readouts": [
{"id": "A", "label": "动点 A", "type": "coord", "of": "A", "color": "ptA"},
{"id": "B", "label": "对称点 B", "type": "coord", "of": "B", "color": "ptB"},
{"id": "kPA", "label": "斜率 $k_{PA}$", "type": "slope", "of": "PA"},
{"id": "kprod", "label": "斜率之积 $k_{PA}\\cdot k_{PB}$", "type": "slope_product", "a": "PA", "b": "PB", "highlight": True},
],
"constant": {"of": "kprod", "label": "$-\\dfrac{3}{4}$"},
"legend": [{"color": "vecA", "text": "直线 PA"}, {"color": "vecB", "text": "直线 PB"},
{"color": "aux", "text": "AB 过中心 O"}],
}
lesson = {
"language": "zh-CN", "title": "椭圆中斜率之积的定值",
"problem": ("<p class='font-medium text-slate-800'>【题目】</p>"
"<p>椭圆 $C:\\dfrac{x^2}{4}+\\dfrac{y^2}{3}=1$,$P(2,0)$ 为右顶点。$A,B$ 是 $C$ 上关于原点对称的两点"
"($B=-A$)。求证 $k_{PA}\\cdot k_{PB}$ 为定值。</p>"),
"answerLabel": "斜率之积定值", "answer": "$k_{PA}\\cdot k_{PB}=-\\dfrac{3}{4}$",
}
steps = [
{"title": "设点",
"content": ("<p>设 $A(x_0,y_0)$,则 $B(-x_0,-y_0)$,且 $\\dfrac{x_0^2}{4}+\\dfrac{y_0^2}{3}=1$,"
"即 $y_0^2=3\\left(1-\\dfrac{x_0^2}{4}\\right)=\\dfrac{3(4-x_0^2)}{4}$。</p>")},
{"title": "计算斜率之积",
"content": ("<p>$k_{PA}\\cdot k_{PB}=\\dfrac{y_0-0}{x_0-2}\\cdot\\dfrac{-y_0-0}{-x_0-2}"
"=\\dfrac{-y_0^2}{(x_0-2)(-x_0-2)}=\\dfrac{-y_0^2}{4-x_0^2}$。</p>"
"<p>代入 $y_0^2=\\dfrac{3(4-x_0^2)}{4}$:</p>"
"<div class='text-center py-3 bg-emerald-50 border border-emerald-100 rounded-xl text-emerald-900 font-bold'>"
"$$ k_{PA}\\cdot k_{PB}=\\dfrac{-\\frac{3}{4}(4-x_0^2)}{4-x_0^2}=-\\dfrac{3}{4} $$</div>"
"<p class='text-slate-500 text-sm'>与 $A$ 的位置无关,恒为定值。拖动滑块即可观察。</p>")},
]
return {"lesson": lesson, "steps": steps, "board": board}
# =====================================================================
# 5) 抛物线 · 焦点弦 OA·OB · 定值
# =====================================================================
def build_parabola_dot_const() -> dict:
PB = conics.parabola(2) # y^2 = 4x, focus (1,0)
F = (1, 0)
expr, cs = K.dot_product_expr(PB, F, (0, 0))
const, val = K.is_constant_in_m(expr)
assert const and sp.simplify(val + 3) == 0
board = {
"view": {"xRange": [-2.0, 7.0], "yRange": [-4.0, 4.0]},
"conics": [conic_board(PB, label="C: y² = 4x")],
"points": {"O": pt((0, 0), "point", "O"), "F": pt(F, "fixed", "F(1,0)", emphasis=True)},
"param": {"name": "θ", "label": "焦点弦倾斜角 $\\theta$", "min": 18, "max": 162,
"step": 0.5, "value": 60, "unit": "°", "standard": 60,
"ticks": ["18°", "90°", "162°"]},
"derived": [
{"type": "line_through_angle", "name": "l", "point": "F", "angle": "@param", "color": "line"},
{"type": "intersect_line_conic", "name": ["A", "B"], "line": "l", "conic": "C", "colors": ["ptA", "ptB"]},
{"type": "vector", "name": "vOA", "from": "O", "to": "A", "color": "vecA"},
{"type": "vector", "name": "vOB", "from": "O", "to": "B", "color": "vecB"},
],
"readouts": [
{"id": "A", "label": "交点 A", "type": "coord", "of": "A", "color": "ptA"},
{"id": "B", "label": "交点 B", "type": "coord", "of": "B", "color": "ptB"},
{"id": "k", "label": "直线斜率 k", "type": "slope", "of": "l"},
{"id": "dot", "label": "$\\vec{OA}\\cdot\\vec{OB}$", "type": "dot", "a": "vOA", "b": "vOB", "highlight": True},
],
"constant": {"of": "dot", "label": "$-3$"},
"legend": [{"color": "line", "text": "过焦点弦 l"}, {"color": "vecA", "text": "向量 OA"},
{"color": "vecB", "text": "向量 OB"}],
}
lesson = {
"language": "zh-CN", "title": "抛物线焦点弦的数量积定值",
"problem": ("<p class='font-medium text-slate-800'>【题目】</p>"
"<p>抛物线 $C:y^2=4x$,$O$ 为原点,过焦点 $F(1,0)$ 的直线 $l$ 交 $C$ 于 $A,B$。"
"求证 $\\vec{OA}\\cdot\\vec{OB}$ 为定值。</p>"),
"answerLabel": "数量积定值", "answer": "$\\vec{OA}\\cdot\\vec{OB}=-3$",
}
steps = [
{"title": "联立 + 韦达定理",
"content": ("<p>设 $l:\\,x=my+1$,代入 $y^2=4x$ 得 "
f"$ y^2-4my-4=0 $,故 $y_1+y_2={K.tex(cs['ysum'])},\\ y_1y_2={K.tex(cs['yprod'])}$。</p>"
"<p>又 $x_i=\\dfrac{y_i^2}{4}$,故 $x_1x_2=\\dfrac{(y_1y_2)^2}{16}=1$。</p>")},
{"title": "计算数量积",
"content": ("<p>$\\vec{OA}\\cdot\\vec{OB}=x_1x_2+y_1y_2=1+(-4)$。</p>"
"<div class='text-center py-3 bg-emerald-50 border border-emerald-100 rounded-xl text-emerald-900 font-bold'>"
"$$ \\vec{OA}\\cdot\\vec{OB}=-3 $$</div>"
"<p class='text-slate-500 text-sm'>与直线倾斜角无关,恒为定值。</p>")},
]
return {"lesson": lesson, "steps": steps, "board": board}
# =====================================================================
# 6) 双曲线 · 离心率 · 取值范围("形状参数"题:滑块直接驱动 e,曲线随之重绘)
# =====================================================================
def build_hyperbola_ecc_range() -> dict:
k = 3 # |PF₁| = k|PF₂|
rg = K.ecc_range_focal_ratio(k) # e ∈ (1, 2]
assert rg["hi_f"] == 2.0 and rg["hi_closed"] and not rg["lo_closed"]
# 取 a=1,则 c=e,b=√(e²−1);曲线/焦点/P 的坐标都写成 e 的表达式,前端随滑块重算重绘。
board = {
"view": {"xRange": [-4.2, 4.2], "yRange": [-3.0, 3.0]},
"conics": [{"name": "C", "kind": "hyperbola", "a": 1, "b": "sqrt(e*e-1)",
"center": [0, 0], "orient": "x", "asymptotes": True,
"color": "curve", "label": "C: x² − y²/(e²−1) = 1 (a=1)"}],
"points": {
"F1": {"xy": ["-e", "0"], "color": "point", "label": "F₁"},
"F2": {"xy": ["e", "0"], "color": "given", "label": "F₂"},
"P": {"xy": ["2/e", "sqrt((e*e-1)*(4/(e*e)-1))"], "color": "ptA", "label": "P", "emphasis": True},
},
"param": {"name": "e", "label": "离心率 $e$", "min": 1.05, "max": 3, "step": 0.01,
"value": 1.5, "unit": "", "standard": 1.5, "ticks": ["1", "2", "3"]},
"derived": [
{"type": "segment", "name": "PF1", "a": "F1", "b": "P", "color": "vecA"},
{"type": "segment", "name": "PF2", "a": "F2", "b": "P", "color": "vecB"},
],
"readouts": [
{"id": "c", "label": "半焦距 $c=ae$", "type": "expr", "expr": "e", "digits": 2},
{"id": "pf2", "label": "$|PF_2|$", "type": "distance", "a": "F2", "b": "P"},
{"id": "pf1", "label": "$|PF_1|$", "type": "distance", "a": "F1", "b": "P"},
{"id": "cond", "label": "右支上 $P$ 存在", "type": "status",
"expr": "e", "op": "<=", "rhs": 2, "okText": "满足 ✓", "badText": "不满足 ✗", "highlight": True},
],
"answerBand": {"min": 1, "max": 3, "lo": float(rg["lo"]), "hi": rg["hi_f"],
"label": "$e\\in" + rg["latex"] + "$"},
"legend": [{"color": "curve", "text": "双曲线 C(随 e 变形)"},
{"color": "vecA", "text": "|PF₁| = 3|PF₂|"}, {"color": "vecB", "text": "|PF₂|"}],
}
lesson = {
"language": "zh-CN", "title": "双曲线离心率的取值范围",
"problem": ("<p class='font-medium text-slate-800'>【题目】</p>"
"<p>双曲线 $C:\\dfrac{x^2}{a^2}-\\dfrac{y^2}{b^2}=1\\,(a,b>0)$,左右焦点 $F_1,F_2$。"
"若 $C$ 的右支上<strong>存在</strong>点 $P$ 使得 $|PF_1|=3|PF_2|$,求离心率 $e$ 的取值范围。</p>"
"<p class='text-slate-500 text-sm'>(拖动滑块改变 $e$,双曲线随之变形;当 $e>2$ 时这样的 $P$ 不再存在。)</p>"),
"answerLabel": "离心率取值范围", "answer": "$e\\in" + rg["latex"] + "$",
}
steps = [
{"title": "右支焦半径关系",
"content": ("<p>设右支上点 $P$,由双曲线定义 $|PF_1|-|PF_2|=2a$,且右支上 $|PF_2|\\ge c-a$(最小值在右顶点取到)。</p>")},
{"title": "代入条件求范围",
"content": ("<p>由 $|PF_1|=3|PF_2|$ 与 $|PF_1|-|PF_2|=2a$ 得 $2|PF_2|=2a$,即 $|PF_2|=a$。</p>"
"<p>代入 $|PF_2|\\ge c-a$:$a\\ge c-a\\Rightarrow c\\le 2a\\Rightarrow e=\\dfrac{c}{a}\\le 2$。又双曲线 $e>1$。</p>"
"<div class='text-center py-3 bg-emerald-50 border border-emerald-100 rounded-xl text-emerald-900 font-bold'>"
"$$ e\\in" + rg["latex"] + " $$</div>"
"<p class='text-slate-500 text-sm'>滑块拖过 $e=2$ 时,$P$ 越过右顶点而消失,状态变为"
"“不满足”,指针离开绿色区间——与答案一致。</p>")},
]
return {"lesson": lesson, "steps": steps, "board": board}
REGISTRY = {
"ellipse_dot_range": build_ellipse_dot_range,
"ellipse_chord_range": build_ellipse_chord_range,
"ellipse_area_max": build_ellipse_area_max,
"ellipse_slopeprod_const": build_ellipse_slopeprod_const,
"parabola_dot_const": build_parabola_dot_const,
"hyperbola_ecc_range": build_hyperbola_ecc_range,
}
def main(argv):
if not argv or argv[0] == "list":
print("已注册题型:")
for k in REGISTRY:
print(" -", k)
return
if argv[0] == "all":
out_dir = Path(argv[1]) if len(argv) > 1 else (SKILL_DIR / "output")
out_dir.mkdir(parents=True, exist_ok=True)
for k, fn in REGISTRY.items():
render_html(fn(), out_dir / f"{k}.html")
print("written:", out_dir / f"{k}.html")
return
key = argv[0]
if key not in REGISTRY:
print(f"未知题型 {key};可用: {', '.join(REGISTRY)}")
sys.exit(1)
out = Path(argv[1]) if len(argv) > 1 else (SKILL_DIR / "output" / f"{key}.html")
render_html(REGISTRY[key](), out)
print("written:", out)
if __name__ == "__main__":
main(sys.argv[1:])
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>解析几何互动学习</title>
<!-- Tailwind 现代排版 -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- KaTeX 极速公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"></script>
<style>
body { background-color: #f8fafc; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #f1f5f9; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.katex { font-size: 1.02em; }
</style>
</head>
<body class="min-h-screen lg:h-screen text-slate-800 lg:overflow-hidden">
<div class="flex flex-col lg:flex-row gap-3 p-3 min-h-screen lg:h-screen lg:overflow-hidden">
<!-- ===== 左栏:题目 + 交互控制台 ===== -->
<aside class="w-full lg:w-[340px] flex-shrink-0 flex flex-col gap-3 lg:overflow-hidden">
<!-- 题目 -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-300 p-5 flex-shrink-0">
<h2 id="prob-title" class="text-xl font-bold mb-4 text-slate-900"></h2>
<div id="prob-body" class="prose text-slate-600 leading-relaxed space-y-3 text-sm md:text-base"></div>
</div>
<!-- 控制台 -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-300 p-5 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
<h3 class="text-base font-bold text-slate-900 flex items-center gap-2 mb-4">
<span class="w-2.5 h-2.5 bg-blue-500 rounded-full"></span>
<span id="console-title">动态控制台</span>
</h3>
<div class="space-y-6">
<!-- 参数滑块(无参数题型自动隐藏) -->
<div id="param-block" class="hidden">
<div class="flex justify-between items-center mb-2">
<label id="param-label" class="text-sm font-semibold text-slate-600"></label>
<span id="param-val" class="bg-blue-50 text-blue-600 px-2.5 py-1 rounded-md text-xs font-mono font-bold border border-blue-100"></span>
</div>
<input type="range" id="param-slider" class="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600 focus:outline-none">
<div id="param-ticks" class="flex justify-between text-[11px] text-slate-400 mt-1.5 font-mono"></div>
</div>
<!-- 实时数值监控 -->
<div id="readouts" class="bg-slate-50 border border-slate-200/60 rounded-xl p-4 space-y-3 text-xs md:text-sm font-mono text-slate-700"></div>
</div>
<!-- 理论范围指示器(区间/最值题才出现) -->
<div id="rangebar-block" class="hidden mt-6 border-t border-slate-100 pt-4">
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span id="rangebar-cur">当前位置:</span>
<span>理论范围: <span id="rangebar-range" class="font-semibold"></span></span>
</div>
<div class="relative w-full h-4 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
<div class="absolute top-0 bottom-0 bg-gradient-to-r from-red-200 via-amber-200 to-emerald-200 rounded-full" style="left:6%; right:6%"></div>
<div id="rangebar-ptr" class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-teal-600 rounded-full shadow-md border-2 border-white transition-all duration-75" style="left:50%"></div>
</div>
</div>
<!-- 定值指示器(定值题才出现) -->
<div id="constant-block" class="hidden mt-6 border-t border-slate-100 pt-4">
<div class="flex items-center gap-2 bg-emerald-50 border border-emerald-200 rounded-xl px-4 py-3 text-sm">
<span class="text-emerald-600">💡</span>
<span class="text-slate-700">拖动滑块,下方数值恒为定值</span>
<span id="constant-val" class="ml-auto font-bold text-emerald-700"></span>
</div>
</div>
<!-- 参数轴·答案区间(离心率范围等"形状参数"题才出现) -->
<div id="answerband-block" class="hidden mt-6 border-t border-slate-100 pt-4">
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span id="answerband-cur">当前值:</span>
<span>答案区间: <span id="answerband-label" class="font-semibold text-emerald-700"></span></span>
</div>
<div class="relative w-full h-4 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
<div id="answerband-fill" class="absolute top-0 bottom-0 bg-emerald-200/80" style="left:0%; right:0%"></div>
<div id="answerband-ptr" class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-indigo-600 rounded-full shadow-md border-2 border-white transition-all duration-75" style="left:50%"></div>
</div>
</div>
</div>
</aside>
<!-- ===== 中栏:分步解析 ===== -->
<section id="solution-panel" class="w-full lg:w-[360px] flex-shrink-0 lg:overflow-y-auto bg-white rounded-2xl shadow-sm border border-slate-300 p-5 space-y-5 transition-[width] duration-300">
<div id="sol-header" class="flex items-center justify-between gap-2">
<h3 id="sol-title" class="text-base font-bold text-slate-900 flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-indigo-500 rounded-full"></span>
<span id="sol-title-text">详细解析</span>
</h3>
<button id="toggle-solution" type="button" title="收起解析" class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg border border-slate-300 bg-slate-50 text-slate-500 shadow-sm hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition-colors cursor-pointer">
<svg id="sol-chevron" class="w-4 h-4 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
<span id="sol-vlabel" class="hidden font-bold text-slate-700 text-sm tracking-widest [writing-mode:vertical-rl]">详细解析</span>
</div>
<div id="solution-body" class="space-y-5"></div>
</section>
<!-- ===== 右栏:动态几何画板 ===== -->
<main class="w-full lg:flex-1 min-w-0 bg-white rounded-2xl shadow-sm border border-slate-300 p-5 flex flex-col lg:overflow-hidden">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h3 class="text-base font-bold text-slate-900 flex items-center gap-2">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
数形结合·动态交互几何画板
</h3>
<button onclick="resetPlot()" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition-colors">重置</button>
</div>
<div class="relative flex-grow min-h-[360px] lg:min-h-0 bg-slate-900 rounded-xl overflow-hidden shadow-inner flex items-center justify-center">
<canvas id="geometry-canvas" class="absolute inset-0 w-full h-full"></canvas>
<canvas id="draw-canvas" class="absolute inset-0 w-full h-full" style="pointer-events:none; touch-action:none;"></canvas>
<!-- 画笔工具栏 -->
<div class="absolute top-4 right-4 z-10 flex items-center gap-1 bg-slate-900/85 backdrop-blur-md p-1.5 rounded-xl border border-slate-700 shadow-lg">
<button id="pen-btn" type="button" title="画笔" class="w-8 h-8 flex items-center justify-center rounded-lg text-slate-300 hover:bg-slate-700 hover:text-white transition-colors cursor-pointer">
<svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
</button>
<button id="eraser-btn" type="button" title="橡皮擦" class="w-8 h-8 flex items-center justify-center rounded-lg text-slate-300 hover:bg-slate-700 hover:text-white transition-colors cursor-pointer">
<svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m7 21-4.3-4.3a1.7 1.7 0 0 1 0-2.4l9.6-9.6a1.7 1.7 0 0 1 2.4 0l5.6 5.6a1.7 1.7 0 0 1 0 2.4L13 21"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 21H7M5 11l8 8"/></svg>
</button>
<span class="w-px h-5 bg-slate-700 mx-0.5"></span>
<div class="flex items-center gap-1">
<button type="button" class="draw-swatch w-5 h-5 rounded-full cursor-pointer ring-2 ring-white" data-color="#ffffff" style="background:#ffffff" title="白色"></button>
<button type="button" class="draw-swatch w-5 h-5 rounded-full cursor-pointer ring-1 ring-slate-600" data-color="#2dd4bf" style="background:#2dd4bf" title="青色"></button>
<button type="button" class="draw-swatch w-5 h-5 rounded-full cursor-pointer ring-1 ring-slate-600" data-color="#fbbf24" style="background:#fbbf24" title="琥珀"></button>
<button type="button" class="draw-swatch w-5 h-5 rounded-full cursor-pointer ring-1 ring-slate-600" data-color="#fb7185" style="background:#fb7185" title="玫红"></button>
</div>
<span class="w-px h-5 bg-slate-700 mx-0.5"></span>
<input id="pen-size" type="range" min="1" max="14" value="3" title="画笔粗细" class="w-14 h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-white">
<span class="w-px h-5 bg-slate-700 mx-0.5"></span>
<button id="undo-btn" type="button" title="撤销" class="w-8 h-8 flex items-center justify-center rounded-lg text-slate-300 hover:bg-slate-700 hover:text-white transition-colors cursor-pointer">
<svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14 4 9l5-5"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 9h11a5 5 0 0 1 0 10h-4"/></svg>
</button>
<button id="clear-draw-btn" type="button" title="清除涂鸦" class="w-8 h-8 flex items-center justify-center rounded-lg text-slate-300 hover:bg-rose-600 hover:text-white transition-colors cursor-pointer">
<svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
<!-- 图例(由数据生成) -->
<div id="legend" class="absolute bottom-4 left-4 pointer-events-none bg-slate-900/80 backdrop-blur-md text-[11px] text-slate-300 p-3 rounded-lg border border-slate-700 space-y-1"></div>
</div>
</main>
</div>
<!-- 数据岛:由 generate.py 注入 -->
<script id="lesson-data" type="application/json">__LESSON_DATA__</script>
<script>
"use strict";
const DATA = JSON.parse(document.getElementById('lesson-data').textContent);
const BOARD = DATA.board || {};
const UI = Object.assign({
consoleTitle: "动态控制台", solutionTitle: "详细解析",
collapse: "收起解析", expand: "展开解析", current: "当前位置:", theoRange: "理论范围:"
}, (DATA.lesson && DATA.lesson.ui) || {});
// ===== 语义色板(数据里 color 可填语义名或直接 hex)=====
const COLORS = {
curve:'#facc15', curve2:'#f472b6', line:'#2dd4bf', line2:'#38bdf8',
aux:'#94a3b8', asymptote:'#64748b', directrix:'#a3a3a3',
ptA:'#f87171', ptB:'#60a5fa', point:'#e2e8f0', given:'#a78bfa',
fixed:'#34d399', focus:'#e2e8f0', vecA:'#f87171', vecB:'#60a5fa',
vec:'#fbbf24', locus:'#38bdf8', area:'rgba(45,212,191,0.16)'
};
const col = c => (c && COLORS[c]) ? COLORS[c] : (c || '#e2e8f0');
// ===== 数值工具 =====
const EPS = 1e-9;
function fmt(x, d=2){
if (x === null || x === undefined || !isFinite(x)) return '—';
let s = x.toFixed(d);
if (s === '-' + (0).toFixed(d)) s = (0).toFixed(d); // 去掉 -0.00
return s;
}
const toRad = deg => deg*Math.PI/180;
// ===== 安全表达式求值(参数化曲线/点/读数:随 @param 实时重算)=====
// 表达式由 generate.py 生成(可信本地内容);可用 @param 名(或别名 p)、+ - * / ^、括号、
// 及 sqrt/sin/cos/abs/pow/min/max/PI 等白名单函数。
const MATHENV = {sqrt:Math.sqrt,cbrt:Math.cbrt,abs:Math.abs,sin:Math.sin,cos:Math.cos,tan:Math.tan,
asin:Math.asin,acos:Math.acos,atan:Math.atan,atan2:Math.atan2,exp:Math.exp,log:Math.log,
pow:Math.pow,min:Math.min,max:Math.max,sign:Math.sign,floor:Math.floor,ceil:Math.ceil,hypot:Math.hypot,PI:Math.PI};
const _exprCache={};
function evalExpr(expr, env){
if(typeof expr!=='string') return expr; // 已是数值
const scope=Object.assign({}, MATHENV, env);
const names=Object.keys(scope);
const key=expr+'|'+names.join(',');
let fn=_exprCache[key];
if(!fn){ try{ fn=Function(...names,'"use strict";return ('+expr.replace(/\^/g,'**')+');'); }
catch(e){ fn=()=>NaN; } _exprCache[key]=fn; }
try{ const v=fn(...names.map(n=>scope[n])); return (typeof v==='number')?v:NaN; }catch(e){ return NaN; }
}
const resolveVal=(v,env)=> (typeof v==='string')? evalExpr(v,env) : v;
function resolveConic(cn, env){ // 把曲线各数值字段(可为表达式)按 env 解析为数值
const r=Object.assign({}, cn);
['a','b','c','r','p'].forEach(k=>{ if(k in r) r[k]=resolveVal(r[k],env); });
if(Array.isArray(r.center)) r.center=r.center.map(z=>resolveVal(z,env));
return r;
}
// ===================== 几何基元 =====================
// 圆锥曲线 → 隐式系数 A x² + C y² + D x + E y + F = 0(均轴对齐,无 xy 项)
function conicImplicit(cn){
const cx = (cn.center && cn.center[0]) || 0, cy = (cn.center && cn.center[1]) || 0;
if (cn.kind === 'circle'){
const r = cn.r;
return {A:1, C:1, D:-2*cx, E:-2*cy, F:cx*cx+cy*cy-r*r};
}
if (cn.kind === 'ellipse'){
const a=cn.a, b=cn.b;
return {A:1/(a*a), C:1/(b*b), D:-2*cx/(a*a), E:-2*cy/(b*b), F:cx*cx/(a*a)+cy*cy/(b*b)-1};
}
if (cn.kind === 'hyperbola'){
const a=cn.a, b=cn.b; // a=实半轴, b=虚半轴
if ((cn.orient||'x') === 'x'){ // (x-cx)²/a² - (y-cy)²/b² = 1
return {A:1/(a*a), C:-1/(b*b), D:-2*cx/(a*a), E:2*cy/(b*b), F:cx*cx/(a*a)-cy*cy/(b*b)-1};
} else { // (y-cy)²/a² - (x-cx)²/b² = 1
return {A:-1/(b*b), C:1/(a*a), D:2*cx/(b*b), E:-2*cy/(a*a), F:-cx*cx/(b*b)+cy*cy/(a*a)-1};
}
}
if (cn.kind === 'parabola'){
// axis x: (y-cy)² = 2p(x-cx); axis y: (x-cx)² = 2p(y-cy)
const p = cn.p;
if ((cn.axis||'x') === 'x') return {A:0, C:1, D:-2*p, E:-2*cy, F:cy*cy+2*p*cx};
else return {A:1, C:0, D:-2*cx, E:-2*p, F:cx*cx+2*p*cy};
}
return {A:0,C:0,D:0,E:0,F:0};
}
// 采样圆锥曲线为若干条折线(用于绘制);考虑视窗,双曲线分两支
function sampleConic(cn, view){
const cx=(cn.center&&cn.center[0])||0, cy=(cn.center&&cn.center[1])||0;
const N=240;
if (cn.kind==='circle'){
const pts=[]; for(let i=0;i<=N;i++){const t=2*Math.PI*i/N; pts.push([cx+cn.r*Math.cos(t), cy+cn.r*Math.sin(t)]);} return [pts];
}
if (cn.kind==='ellipse'){
const pts=[]; for(let i=0;i<=N;i++){const t=2*Math.PI*i/N; pts.push([cx+cn.a*Math.cos(t), cy+cn.b*Math.sin(t)]);} return [pts];
}
if (cn.kind==='parabola'){
const p=cn.p, pts=[];
if ((cn.axis||'x')==='x'){
for(let i=0;i<=N;i++){const y=view.yRange[0]+(view.yRange[1]-view.yRange[0])*i/N; pts.push([cx+(y-cy)*(y-cy)/(2*p), y]);}
} else {
for(let i=0;i<=N;i++){const x=view.xRange[0]+(view.xRange[1]-view.xRange[0])*i/N; pts.push([x, cy+(x-cx)*(x-cx)/(2*p)]);}
}
return [pts];
}
if (cn.kind==='hyperbola'){
const a=cn.a,b=cn.b; const branches=[];
// 估算需要的参数范围 smax,使曲线覆盖视窗
const spanX=Math.max(Math.abs(view.xRange[0]-cx),Math.abs(view.xRange[1]-cx));
const spanY=Math.max(Math.abs(view.yRange[0]-cy),Math.abs(view.yRange[1]-cy));
let smax;
if ((cn.orient||'x')==='x') smax=Math.max(Math.acosh(Math.max(spanX/a,1.0001)), Math.asinh(spanY/b));
else smax=Math.max(Math.acosh(Math.max(spanY/a,1.0001)), Math.asinh(spanX/b));
smax=Math.min(Math.max(smax,1.2),4.5);
for(const sgn of [1,-1]){
const pts=[]; for(let i=0;i<=N;i++){const s=-smax+2*smax*i/N;
if ((cn.orient||'x')==='x') pts.push([cx+sgn*a*Math.cosh(s), cy+b*Math.sinh(s)]);
else pts.push([cx+b*Math.sinh(s), cy+sgn*a*Math.cosh(s)]);
}
branches.push(pts);
}
return branches;
}
return [];
}
// 双曲线渐近线(两条),返回 line 对象数组
function hyperbolaAsymptotes(cn){
const cx=(cn.center&&cn.center[0])||0, cy=(cn.center&&cn.center[1])||0, a=cn.a,b=cn.b;
const slope=((cn.orient||'x')==='x') ? b/a : a/b;
return [ {p0:[cx,cy],dir:[1, slope]}, {p0:[cx,cy],dir:[1,-slope]} ];
}
// ---- 直线表示:{p0:[x,y], dir:[dx,dy]} ----
const lineFromAngle = (p, deg) => ({p0:p.slice(), dir:[Math.cos(toRad(deg)), Math.sin(toRad(deg))]});
const lineFromSlope = (p, k) => ({p0:p.slice(), dir:[1, k]});
const lineXMyC = (m, c) => ({p0:[c,0], dir:[m,1]}); // x = m y + c
const lineThroughPoints = (a, b) => ({p0:a.slice(), dir:[b[0]-a[0], b[1]-a[1]]});
const lineThroughDir = (p, d) => ({p0:p.slice(), dir:d.slice()});
// 直线 → ax+by+c=0
function lineABC(L){ const a=L.dir[1], b=-L.dir[0]; const c=-(a*L.p0[0]+b*L.p0[1]); return {a,b,c}; }
// 直线裁剪到视窗,返回 [[x,y],[x,y]] 或 null
function clipLine(L, view){
const cand=[]; const [dx,dy]=L.dir, [x0,y0]=L.p0;
const push=t=>{const x=x0+t*dx,y=y0+t*dy;
if(x>=view.xRange[0]-EPS&&x<=view.xRange[1]+EPS&&y>=view.yRange[0]-EPS&&y<=view.yRange[1]+EPS) cand.push([t,x,y]);};
if(Math.abs(dx)>EPS){push((view.xRange[0]-x0)/dx);push((view.xRange[1]-x0)/dx);}
if(Math.abs(dy)>EPS){push((view.yRange[0]-y0)/dy);push((view.yRange[1]-y0)/dy);}
if(cand.length<2) return null;
cand.sort((p,q)=>p[0]-q[0]);
const A=cand[0], B=cand[cand.length-1];
return [[A[1],A[2]],[B[1],B[2]]];
}
// 直线 ∩ 圆锥曲线:代入参数式得 t 二次方程,返回按 t 升序的交点(0/1/2 个)
function intersectLineConic(L, cn){
const {A,C,D,E,F}=conicImplicit(cn); const [x0,y0]=L.p0, [dx,dy]=L.dir;
const qa = A*dx*dx + C*dy*dy;
const qb = 2*A*x0*dx + 2*C*y0*dy + D*dx + E*dy;
const qc = A*x0*x0 + C*y0*y0 + D*x0 + E*y0 + F;
const roots=[];
if (Math.abs(qa)<EPS){
if (Math.abs(qb)>EPS) roots.push(-qc/qb);
} else {
const disc=qb*qb-4*qa*qc;
if (disc>=-1e-7){ const sd=Math.sqrt(Math.max(disc,0));
roots.push((-qb-sd)/(2*qa)); roots.push((-qb+sd)/(2*qa)); }
}
roots.sort((u,v)=>u-v);
return roots.map(t=>[x0+t*dx, y0+t*dy]);
}
function intersectLineLine(L1,L2){
const e1=lineABC(L1), e2=lineABC(L2);
const det=e1.a*e2.b-e2.a*e1.b;
if(Math.abs(det)<EPS) return null;
return [(-e1.c*e2.b+e2.c*e1.b)/det, (-e1.a*e2.c+e2.a*e1.c)/det];
}
const midpoint=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
function footPerp(P,L){ const {a,b,c}=lineABC(L); const d=(a*P[0]+b*P[1]+c)/(a*a+b*b); return [P[0]-a*d, P[1]-b*d]; }
function reflectPt(P,L){ const f=footPerp(P,L); return [2*f[0]-P[0], 2*f[1]-P[1]]; }
// 圆锥曲线在其上一点的切线(隐式梯度法)
function tangentAt(cn,P){ const {A,C,D,E}=conicImplicit(cn); const gx=2*A*P[0]+D, gy=2*C*P[1]+E; return {p0:P.slice(), dir:[-gy,gx]}; }
// 圆锥曲线参数点(t:椭圆/圆为角度°;抛物线为自由坐标;双曲线为参数 s)
function pointOnConic(cn,t){
const cx=(cn.center&&cn.center[0])||0, cy=(cn.center&&cn.center[1])||0;
if(cn.kind==='circle') return [cx+cn.r*Math.cos(toRad(t)), cy+cn.r*Math.sin(toRad(t))];
if(cn.kind==='ellipse') return [cx+cn.a*Math.cos(toRad(t)), cy+cn.b*Math.sin(toRad(t))];
if(cn.kind==='parabola'){ const p=cn.p; return (cn.axis||'x')==='x' ? [cx+t*t/(2*p), cy+t] : [cx+t, cy+t*t/(2*p)]; }
if(cn.kind==='hyperbola'){ const a=cn.a,b=cn.b; return (cn.orient||'x')==='x' ? [cx+a*Math.cosh(t), cy+b*Math.sinh(t)] : [cx+b*Math.sinh(t), cy+a*Math.cosh(t)]; }
return [cx,cy];
}
// ===================== 场景构建(derived 实时求解)=====================
const conicDefs = {}; (BOARD.conics||[]).forEach(c=>conicDefs[c.name]=c);
// 参数名作为表达式变量(须是合法标识符,如 e / t / k;θ 等非标识符则只用别名 p)
const PARAM_NAME = (BOARD.param && /^[A-Za-z_$][\w$]*$/.test(BOARD.param.name||'')) ? BOARD.param.name : null;
function normPoints(env){ // 静态点 → {xy,color,label,emphasis,hidden};坐标可为表达式
const reg={};
const src=BOARD.points||{};
const xyOf = v => [resolveVal(v[0],env), resolveVal(v[1],env)];
for(const name in src){ const v=src[name];
if(Array.isArray(v)) reg[name]={xy:xyOf(v), color:'point', label:name, emphasis:false, hidden:false};
else reg[name]={xy:xyOf(v.xy), color:v.color||'point', label:(v.label!==undefined?v.label:name), emphasis:!!v.emphasis, hidden:!!v.hidden};
}
return reg;
}
function buildScene(pval){
const env={ p: pval }; if(PARAM_NAME) env[PARAM_NAME]=pval; // 滑块值 → 表达式变量
(BOARD.scalars||[]).forEach(s=>{ env[s.name]=evalExpr(s.expr, env); }); // 命名标量,按序求值
const conics={}; (BOARD.conics||[]).forEach(c=>{ conics[c.name]=resolveConic(c, env); }); // 本帧数值曲线
const pts=normPoints(env); // name -> {xy,...}
const lines={}, vectors={}, segments={}, polys=[];
const P = n => (pts[n] ? pts[n].xy : null);
const resolveNum = v => (v==='@param') ? pval : (typeof v==='string'? evalExpr(v, env) : v);
const addPt = (n,xy,o={}) => { pts[n]=Object.assign({xy:xy?xy.slice():[NaN,NaN], color:'point', label:n, emphasis:false, hidden:false}, o); };
(BOARD.derived||[]).forEach(d=>{
switch(d.type){
case 'line_through_angle': lines[d.name]={L:lineFromAngle(P(d.point), resolveNum(d.angle)), color:d.color||'line', label:d.label, dashed:d.dashed}; break;
case 'line_through_slope': lines[d.name]={L:lineFromSlope(P(d.point), resolveNum(d.slope)), color:d.color||'line', label:d.label, dashed:d.dashed}; break;
case 'line_x_eq_my_c': lines[d.name]={L:lineXMyC(resolveNum(d.m), d.c!==undefined?d.c:0), color:d.color||'line', label:d.label, dashed:d.dashed}; break;
case 'line_through_points': lines[d.name]={L:lineThroughPoints(P(d.a),P(d.b)), color:d.color||'line', label:d.label, dashed:d.dashed}; break;
case 'line_through_point_dir': lines[d.name]={L:lineThroughDir(P(d.point), d.dir), color:d.color||'line', label:d.label, dashed:d.dashed}; break;
case 'point_on_conic': addPt(d.name, pointOnConic(conics[d.conic], resolveNum(d.t)), {color:d.color||'point', label:d.label||d.name, emphasis:d.emphasis}); break;
case 'intersect_line_conic': {
const ip=intersectLineConic(lines[d.line].L, conics[d.conic]);
const names=d.name, cols=d.colors||['ptA','ptB'];
names.forEach((nm,i)=>addPt(nm, ip[i]||null, {color:cols[i]||'point', label:(d.labels&&d.labels[i])||nm}));
break; }
case 'intersect_line_line': addPt(d.name, intersectLineLine(lines[d.a].L, lines[d.b].L), {color:d.color||'point', label:d.label||d.name}); break;
case 'midpoint': addPt(d.name, midpoint(P(d.a),P(d.b)), {color:d.color||'point', label:d.label||d.name, emphasis:d.emphasis}); break;
case 'foot_perp': addPt(d.name, footPerp(P(d.point), lines[d.line].L), {color:d.color||'point', label:d.label||d.name}); break;
case 'reflect': addPt(d.name, reflectPt(P(d.point), lines[d.line].L), {color:d.color||'point', label:d.label||d.name}); break;
case 'point_reflect': { const ctr=d.center?(Array.isArray(d.center)?d.center:P(d.center)):[0,0]; const o=P(d.of); addPt(d.name,[2*ctr[0]-o[0],2*ctr[1]-o[1]],{color:d.color||'point',label:d.label||d.name,emphasis:d.emphasis}); break; }
case 'tangent_at': lines[d.name]={L:tangentAt(conics[d.conic], P(d.point)), color:d.color||'line', label:d.label, dashed:d.dashed}; break;
case 'vector': vectors[d.name]={from:P(d.from), to:P(d.to), color:d.color||'vec', label:d.label}; break;
case 'segment': segments[d.name]={a:P(d.a), b:P(d.b), color:d.color||'aux', dashed:d.dashed, label:d.label}; break;
case 'polygon': polys.push({pts:(d.pts||[]).map(P), color:d.color||'area', stroke:d.stroke}); break;
}
});
return {pts, lines, vectors, segments, polys, conics, env};
}
// ===================== 读数 =====================
function vecOf(scene,name){ const v=scene.vectors[name]; return v? [v.to[0]-v.from[0], v.to[1]-v.from[1]] : null; }
function computeReadouts(scene){
const out=[];
(BOARD.readouts||[]).forEach(r=>{
let text='—', scalar=null, dyncolor=null;
const P=n=>scene.pts[n]?scene.pts[n].xy:null;
try{
switch(r.type){
case 'coord':{ const p=P(r.of); text=p?`(${fmt(p[0])}, ${fmt(p[1])})`:'—'; break; }
case 'length':{ let d; if(r.of){const v=vecOf(scene,r.of); d=Math.hypot(v[0],v[1]);} else {const a=P(r.a),b=P(r.b); d=Math.hypot(a[0]-b[0],a[1]-b[1]);} scalar=d; text=fmt(d,3); break; }
case 'distance':{ const a=P(r.a),b=P(r.b); scalar=Math.hypot(a[0]-b[0],a[1]-b[1]); text=fmt(scalar,3); break; }
case 'dot':{ const u=vecOf(scene,r.a), v=vecOf(scene,r.b); scalar=u[0]*v[0]+u[1]*v[1]; text=fmt(scalar,3); break; }
case 'slope':{ const L=scene.lines[r.of].L; scalar=(Math.abs(L.dir[0])<1e-4)?null:L.dir[1]/L.dir[0]; text=(scalar===null)?'不存在(垂直)':fmt(scalar); break; }
case 'slope_product':{ const L1=scene.lines[r.a].L,L2=scene.lines[r.b].L; const k1=L1.dir[1]/L1.dir[0],k2=L2.dir[1]/L2.dir[0]; scalar=k1*k2; text=fmt(scalar,3); break; }
case 'area_triangle':{ const a=P(r.pts[0]),b=P(r.pts[1]),c=P(r.pts[2]); scalar=Math.abs((b[0]-a[0])*(c[1]-a[1])-(c[0]-a[0])*(b[1]-a[1]))/2; text=fmt(scalar,3); break; }
case 'distance_point_line':{ const p=P(r.point); const {a,b,c}=lineABC(scene.lines[r.line].L); scalar=Math.abs(a*p[0]+b*p[1]+c)/Math.hypot(a,b); text=fmt(scalar,3); break; }
case 'expr':{ scalar=evalExpr(r.expr, scene.env); text=fmt(scalar, r.digits!=null?r.digits:2); break; }
case 'status':{ const v=evalExpr(r.expr, scene.env), rhs=(r.rhs!=null?r.rhs:0), op=r.op||'>';
const ok = op==='<'? v<rhs-1e-9 : op==='<='? v<=rhs+1e-9 : op==='>'? v>rhs+1e-9 : op==='>='? v>=rhs-1e-9 : op==='=='? Math.abs(v-rhs)<1e-6 : !!v;
text = ok?(r.okText||'满足'):(r.badText||'不满足'); scalar=ok?1:0; dyncolor=ok?'#059669':'#dc2626'; break; }
}
}catch(e){ text='—'; }
out.push({id:r.id, label:r.label, text, scalar, highlight:!!r.highlight, color:r.color, dyncolor});
});
return out;
}
// ===================== Canvas 渲染 =====================
const canvas=document.getElementById('geometry-canvas');
const ctx=canvas.getContext('2d');
let scale=100, offX=0, offY=0, cssW=0, cssH=0;
const view = BOARD.view || {xRange:[-5,5], yRange:[-4,4]};
const SX = x => offX + x*scale; // 数学→屏幕
const SY = y => offY - y*scale;
function resizeCanvas(){
const rect=canvas.parentElement.getBoundingClientRect();
const dpr=window.devicePixelRatio||1;
canvas.width=rect.width*dpr; canvas.height=rect.height*dpr;
canvas.style.width=rect.width+'px'; canvas.style.height=rect.height+'px';
ctx.setTransform(1,0,0,1,0,0); ctx.scale(dpr,dpr);
cssW=rect.width; cssH=rect.height;
const spanX=view.xRange[1]-view.xRange[0], spanY=view.yRange[1]-view.yRange[0];
scale=Math.min((rect.width-40)/spanX, (rect.height-40)/spanY);
offX=rect.width/2 - ((view.xRange[0]+view.xRange[1])/2)*scale;
offY=rect.height/2 + ((view.yRange[0]+view.yRange[1])/2)*scale;
update(); resizeDrawCanvas();
}
function drawPolyline(poly, breakOnLeave=false){
let started=false;
ctx.beginPath();
for(const [x,y] of poly){
const inView = x>=view.xRange[0]-2 && x<=view.xRange[1]+2 && y>=view.yRange[0]-2 && y<=view.yRange[1]+2;
if(breakOnLeave && !inView){ started=false; continue; }
if(!started){ ctx.moveTo(SX(x),SY(y)); started=true; } else ctx.lineTo(SX(x),SY(y));
}
ctx.stroke();
}
function drawArrow(from,to,color){
const fx=SX(from[0]),fy=SY(from[1]),tx=SX(to[0]),ty=SY(to[1]);
const ang=Math.atan2(ty-fy,tx-fx), hl=10;
ctx.strokeStyle=color; ctx.fillStyle=color; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(fx,fy); ctx.lineTo(tx,ty); ctx.stroke();
ctx.beginPath(); ctx.moveTo(tx,ty);
ctx.lineTo(tx-hl*Math.cos(ang-Math.PI/6), ty-hl*Math.sin(ang-Math.PI/6));
ctx.lineTo(tx-hl*Math.cos(ang+Math.PI/6), ty-hl*Math.sin(ang+Math.PI/6));
ctx.fill();
}
function draw(scene){
ctx.clearRect(0,0,cssW,cssH);
// 网格
ctx.strokeStyle='#334155'; ctx.lineWidth=0.5;
for(let x=Math.ceil(view.xRange[0]);x<=view.xRange[1];x++){ctx.beginPath();ctx.moveTo(SX(x),0);ctx.lineTo(SX(x),cssH);ctx.stroke();}
for(let y=Math.ceil(view.yRange[0]);y<=view.yRange[1];y++){ctx.beginPath();ctx.moveTo(0,SY(y));ctx.lineTo(cssW,SY(y));ctx.stroke();}
// 坐标轴
ctx.strokeStyle='#94a3b8'; ctx.fillStyle='#94a3b8'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(10,SY(0)); ctx.lineTo(cssW-12,SY(0)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cssW-12,SY(0)); ctx.lineTo(cssW-22,SY(0)-5); ctx.lineTo(cssW-22,SY(0)+5); ctx.fill();
ctx.beginPath(); ctx.moveTo(SX(0),cssH-10); ctx.lineTo(SX(0),12); ctx.stroke();
ctx.beginPath(); ctx.moveTo(SX(0),12); ctx.lineTo(SX(0)-5,22); ctx.lineTo(SX(0)+5,22); ctx.fill();
ctx.font='italic 14px "Times New Roman"'; ctx.fillStyle='#cbd5e1';
ctx.fillText('x',cssW-16,SY(0)+16); ctx.fillText('y',SX(0)-16,16); ctx.fillText('O',SX(0)-14,SY(0)+16);
// 圆锥曲线 + 渐近线(本帧解析出的数值曲线,随 @param 重绘)
Object.values(scene.conics||{}).forEach(cn=>{
if(cn.hidden) return;
if(cn.kind==='hyperbola' && cn.asymptotes){
ctx.strokeStyle=col('asymptote'); ctx.lineWidth=1; ctx.setLineDash([5,5]);
hyperbolaAsymptotes(cn).forEach(L=>{const seg=clipLine(L,view); if(seg){ctx.beginPath();ctx.moveTo(SX(seg[0][0]),SY(seg[0][1]));ctx.lineTo(SX(seg[1][0]),SY(seg[1][1]));ctx.stroke();}});
ctx.setLineDash([]);
}
ctx.strokeStyle=col(cn.color||'curve'); ctx.lineWidth=3;
if(cn.dashed) ctx.setLineDash([7,5]);
sampleConic(cn,view).forEach(poly=>drawPolyline(poly, cn.kind==='hyperbola'||cn.kind==='parabola'));
ctx.setLineDash([]);
});
// 轨迹(locus trace)
if(BOARD.trace){ const tr=BOARD.trace; const p=BOARD.param; const path=[];
const steps=160; for(let i=0;i<=steps;i++){const v=p.min+(p.max-p.min)*i/steps; const sc=buildScene(v); const pt=sc.pts[tr.of]; if(pt&&isFinite(pt.xy[0])) path.push(pt.xy);}
ctx.strokeStyle=col(tr.color||'locus'); ctx.lineWidth=2; ctx.setLineDash([2,3]); drawPolyline(path,true); ctx.setLineDash([]);
}
// 多边形(三角形等,半透明填充)
scene.polys.forEach(pg=>{ if(pg.pts.some(p=>!p||!isFinite(p[0])||!isFinite(p[1]))) return;
ctx.beginPath(); pg.pts.forEach((p,i)=> i?ctx.lineTo(SX(p[0]),SY(p[1])):ctx.moveTo(SX(p[0]),SY(p[1]))); ctx.closePath();
ctx.fillStyle=col(pg.color); ctx.fill();
if(pg.stroke){ctx.strokeStyle=col(pg.stroke); ctx.lineWidth=1.5; ctx.stroke();}
});
// 线段
for(const k in scene.segments){ const s=scene.segments[k]; if(!s.a||!s.b||!isFinite(s.a[0])||!isFinite(s.b[0])) continue;
ctx.strokeStyle=col(s.color); ctx.lineWidth=2; if(s.dashed)ctx.setLineDash([6,5]);
ctx.beginPath(); ctx.moveTo(SX(s.a[0]),SY(s.a[1])); ctx.lineTo(SX(s.b[0]),SY(s.b[1])); ctx.stroke(); ctx.setLineDash([]);
}
// 直线(裁剪到视窗)
for(const k in scene.lines){ const ln=scene.lines[k]; const seg=clipLine(ln.L,view); if(!seg) continue;
ctx.strokeStyle=col(ln.color); ctx.lineWidth=2; if(ln.dashed)ctx.setLineDash([6,5]);
ctx.beginPath(); ctx.moveTo(SX(seg[0][0]),SY(seg[0][1])); ctx.lineTo(SX(seg[1][0]),SY(seg[1][1])); ctx.stroke(); ctx.setLineDash([]);
}
// 向量
for(const k in scene.vectors){ const v=scene.vectors[k]; if(!v.from||!v.to||!isFinite(v.from[0])||!isFinite(v.to[0])) continue; drawArrow(v.from,v.to,col(v.color)); }
// 点 + 标签
for(const name in scene.pts){ const pt=scene.pts[name]; if(pt.hidden||!pt.xy||!isFinite(pt.xy[0])) continue;
const r=pt.emphasis?7:5;
ctx.fillStyle=col(pt.color); ctx.beginPath(); ctx.arc(SX(pt.xy[0]),SY(pt.xy[1]),r,0,2*Math.PI); ctx.fill();
ctx.strokeStyle=pt.emphasis?'#fff':'#1e293b'; ctx.lineWidth=pt.emphasis?2:1.5; ctx.stroke();
if(pt.label){ ctx.fillStyle='#f8fafc'; ctx.font='bold 11px sans-serif'; ctx.fillText(pt.label, SX(pt.xy[0])+8, SY(pt.xy[1])-7); }
}
}
// ===================== 控制台 DOM(由数据生成)=====================
function renderKatex(el){ if(typeof renderMathInElement!=='undefined') renderMathInElement(el,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\(',right:'\\)',display:false},{left:'\\[',right:'\\]',display:true}],throwOnError:false}); }
function buildConsole(){
// 题目
document.getElementById('prob-title').textContent = (DATA.lesson&&DATA.lesson.title)||'';
document.getElementById('prob-body').innerHTML = (DATA.lesson&&DATA.lesson.problem)||'';
document.getElementById('console-title').textContent = BOARD.consoleTitle || UI.consoleTitle;
document.getElementById('sol-title-text').textContent = UI.solutionTitle;
document.getElementById('sol-vlabel').textContent = UI.solutionTitle;
// 参数滑块
const p=BOARD.param;
if(p){
document.getElementById('param-block').classList.remove('hidden');
document.getElementById('param-label').innerHTML = (p.label||p.name)+' :';
const s=document.getElementById('param-slider');
s.min=p.min; s.max=p.max; s.step=p.step||0.5; s.value=(p.value!==undefined?p.value:p.min);
const ticks=document.getElementById('param-ticks');
ticks.innerHTML = (p.ticks||[`${p.min}${p.unit||''}`, `${p.max}${p.unit||''}`]).map(t=>`<span>${t}</span>`).join('');
s.addEventListener('input', update);
}
// 读数行容器(值在 update 里填)
const ro=document.getElementById('readouts');
ro.innerHTML = (BOARD.readouts||[]).map(r=>{
if(r.highlight) return `<div class="flex justify-between items-center pt-1"><span class="text-slate-800 font-semibold">${r.label}:</span><span id="ro-${r.id}" class="text-base font-bold bg-teal-50 text-teal-700 px-3 py-1 rounded-lg border border-teal-200">—</span></div>`;
return `<div class="flex justify-between border-b border-dashed border-slate-200 pb-2"><span>${r.label}:</span><span id="ro-${r.id}" class="font-bold" style="color:${r.color?col(r.color):'#0f172a'}">—</span></div>`;
}).join('');
// 范围条
if(BOARD.rangeBar){ document.getElementById('rangebar-block').classList.remove('hidden');
document.getElementById('rangebar-cur').textContent = UI.current;
const rl=document.getElementById('rangebar-range'); rl.innerHTML = BOARD.rangeBar.label||''; }
// 定值指示器
if(BOARD.constant){ document.getElementById('constant-block').classList.remove('hidden');
document.getElementById('constant-val').innerHTML = '≡ '+(BOARD.constant.label||''); }
// 答案区间(参数轴):高亮 [lo,hi] 子区间
if(BOARD.answerBand){ const ab=BOARD.answerBand;
document.getElementById('answerband-block').classList.remove('hidden');
document.getElementById('answerband-cur').textContent = '当前 '+((BOARD.param&&BOARD.param.name)||'值')+':';
document.getElementById('answerband-label').innerHTML = ab.label||'';
const L=((ab.lo-ab.min)/(ab.max-ab.min))*100, R=((ab.max-ab.hi)/(ab.max-ab.min))*100;
const fill=document.getElementById('answerband-fill');
fill.style.left=Math.max(L,0)+'%'; fill.style.right=Math.max(R,0)+'%'; }
// 图例
const legend=document.getElementById('legend'); const items=[];
(BOARD.conics||[]).forEach(c=>{ if(c.legend!==false) items.push(`<div class="flex items-center gap-2"><span class="w-3 h-1.5 inline-block rounded" style="background:${col(c.color||'curve')}"></span> ${c.label||c.name}</div>`); });
(BOARD.legend||[]).forEach(it=> items.push(`<div class="flex items-center gap-2"><span class="w-3 h-1.5 inline-block rounded" style="background:${col(it.color)}"></span> ${it.text}</div>`));
legend.innerHTML=items.join('');
// 解析步骤
const sb=document.getElementById('solution-body');
sb.innerHTML=(DATA.steps||[]).map((st,i)=>{
const no=String(i+1).padStart(2,'0');
return `<div class="${i>0?'border-t border-slate-100 pt-5':''}">
<div class="flex items-start gap-4 mb-4">
<span class="w-8 h-8 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center font-bold font-mono text-sm flex-shrink-0">${no}</span>
<h3 class="text-lg font-bold text-slate-900 pt-0.5">${st.title||''}</h3>
</div>
<div class="prose max-w-none text-slate-700 leading-relaxed text-sm md:text-base space-y-3">${st.content||''}</div>
</div>`;
}).join('');
renderKatex(document.body);
}
// ===================== 主更新循环 =====================
let curParam = BOARD.param ? (BOARD.param.value!==undefined?BOARD.param.value:BOARD.param.min) : 0;
function update(){
if(BOARD.param){ const s=document.getElementById('param-slider'); curParam=parseFloat(s.value);
const p=BOARD.param; const dec=p.step?(p.step<0.1?2:(p.step<1?1:0)):0;
document.getElementById('param-val').textContent = fmt(curParam, dec)+(p.unit||''); }
const scene=buildScene(curParam);
const ros=computeReadouts(scene);
ros.forEach(r=>{ const el=document.getElementById('ro-'+r.id); if(el){ el.textContent=r.text; if(r.dyncolor) el.style.color=r.dyncolor; } });
// 范围条指针
if(BOARD.rangeBar){ const rb=BOARD.rangeBar; const tracked=ros.find(r=>r.id===rb.of);
if(tracked && tracked.scalar!==null){ const pct=((tracked.scalar-rb.min)/(rb.max-rb.min))*88+6;
document.getElementById('rangebar-ptr').style.left=Math.min(Math.max(pct,0),100)+'%'; } }
// 答案区间指针(参数轴)
if(BOARD.answerBand){ const ab=BOARD.answerBand; const pct=((curParam-ab.min)/(ab.max-ab.min))*100;
document.getElementById('answerband-ptr').style.left=Math.min(Math.max(pct,0),100)+'%'; }
draw(scene);
}
function resetPlot(){ const p=BOARD.param; if(!p) return; const s=document.getElementById('param-slider');
s.value=(p.standard!==undefined?p.standard:(p.value!==undefined?p.value:p.min)); update(); }
window.resetPlot=resetPlot;
// ===================== 画笔涂鸦叠加层 =====================
const drawCanvas=document.getElementById('draw-canvas'); const dctx=drawCanvas.getContext('2d');
let penMode=null,isDrawing=false,penColor='#ffffff',penSize=3,strokes=[],curStroke=null;
function resizeDrawCanvas(){ const rect=drawCanvas.parentElement.getBoundingClientRect(); const dpr=window.devicePixelRatio||1;
drawCanvas.width=rect.width*dpr; drawCanvas.height=rect.height*dpr; drawCanvas.style.width=rect.width+'px'; drawCanvas.style.height=rect.height+'px';
dctx.setTransform(1,0,0,1,0,0); dctx.scale(dpr,dpr); redrawStrokes(rect.width,rect.height); }
function redrawStrokes(w,h){ dctx.clearRect(0,0,w,h); dctx.lineCap='round'; dctx.lineJoin='round';
strokes.forEach(s=>{ if(!s.pts.length)return; const sx=w/s.w,sy=h/s.h;
dctx.globalCompositeOperation=s.eraser?'destination-out':'source-over'; dctx.strokeStyle=s.color; dctx.lineWidth=s.size;
dctx.beginPath(); dctx.moveTo(s.pts[0].x*sx,s.pts[0].y*sy);
if(s.pts.length===1) dctx.lineTo(s.pts[0].x*sx+0.01,s.pts[0].y*sy+0.01); else for(let i=1;i<s.pts.length;i++)dctx.lineTo(s.pts[i].x*sx,s.pts[i].y*sy);
dctx.stroke(); }); dctx.globalCompositeOperation='source-over'; }
function drawLiveSegment(){ const p=curStroke.pts,n=p.length; dctx.lineCap='round'; dctx.lineJoin='round';
dctx.globalCompositeOperation=curStroke.eraser?'destination-out':'source-over'; dctx.strokeStyle=curStroke.color; dctx.lineWidth=curStroke.size;
dctx.beginPath(); if(n===1){dctx.moveTo(p[0].x,p[0].y);dctx.lineTo(p[0].x+0.01,p[0].y+0.01);} else {dctx.moveTo(p[n-2].x,p[n-2].y);dctx.lineTo(p[n-1].x,p[n-1].y);} dctx.stroke(); dctx.globalCompositeOperation='source-over'; }
const getDrawPos=e=>{const rect=drawCanvas.getBoundingClientRect(); return {x:e.clientX-rect.left,y:e.clientY-rect.top};};
drawCanvas.addEventListener('pointerdown',e=>{ if(!penMode)return; isDrawing=true; drawCanvas.setPointerCapture(e.pointerId); const rect=drawCanvas.getBoundingClientRect();
curStroke={color:penColor,size:penSize,eraser:penMode==='eraser',pts:[getDrawPos(e)],w:rect.width,h:rect.height}; strokes.push(curStroke); drawLiveSegment(); });
drawCanvas.addEventListener('pointermove',e=>{ if(!isDrawing||!penMode)return; curStroke.pts.push(getDrawPos(e)); drawLiveSegment(); });
function endStroke(){isDrawing=false;curStroke=null;}
drawCanvas.addEventListener('pointerup',endStroke); drawCanvas.addEventListener('pointercancel',endStroke); drawCanvas.addEventListener('pointerleave',()=>{if(isDrawing)endStroke();});
const penBtn=document.getElementById('pen-btn'),eraserBtn=document.getElementById('eraser-btn'),clearDrawBtn=document.getElementById('clear-draw-btn'),undoBtn=document.getElementById('undo-btn'),penSizeInput=document.getElementById('pen-size'),swatches=document.querySelectorAll('.draw-swatch');
function updateToolbarUI(){ penBtn.classList.toggle('bg-slate-600',penMode==='pen'); penBtn.classList.toggle('text-white',penMode==='pen'); eraserBtn.classList.toggle('bg-slate-600',penMode==='eraser'); eraserBtn.classList.toggle('text-white',penMode==='eraser');
if(penMode){drawCanvas.style.pointerEvents='auto'; drawCanvas.style.cursor=penMode==='eraser'?'cell':'crosshair';} else drawCanvas.style.pointerEvents='none'; }
function setMode(m){penMode=(penMode===m)?null:m; updateToolbarUI();}
penBtn.addEventListener('click',()=>setMode('pen')); eraserBtn.addEventListener('click',()=>setMode('eraser'));
clearDrawBtn.addEventListener('click',()=>{strokes=[]; const rect=drawCanvas.getBoundingClientRect(); redrawStrokes(rect.width,rect.height);});
undoBtn.addEventListener('click',()=>{if(!strokes.length)return; strokes.pop(); const rect=drawCanvas.getBoundingClientRect(); redrawStrokes(rect.width,rect.height);});
penSizeInput.addEventListener('input',()=>{penSize=parseInt(penSizeInput.value,10);});
swatches.forEach(sw=>sw.addEventListener('click',()=>{penColor=sw.dataset.color; swatches.forEach(s=>{s.classList.remove('ring-2','ring-white');s.classList.add('ring-1','ring-slate-600');}); sw.classList.remove('ring-1','ring-slate-600'); sw.classList.add('ring-2','ring-white'); if(penMode!=='pen')setMode('pen');}));
updateToolbarUI();
// ===================== 解析面板收起 / 展开 =====================
const solutionPanel=document.getElementById('solution-panel'),solutionBody=document.getElementById('solution-body'),toggleSolutionBtn=document.getElementById('toggle-solution'),solTitle=document.getElementById('sol-title'),solVlabel=document.getElementById('sol-vlabel'),solHeader=document.getElementById('sol-header'),solChevron=document.getElementById('sol-chevron');
let solutionCollapsed=false;
function applySolutionState(){ solutionBody.classList.toggle('hidden',solutionCollapsed); solChevron.classList.toggle('rotate-180',solutionCollapsed);
solutionPanel.classList.toggle('lg:w-[360px]',!solutionCollapsed); solutionPanel.classList.toggle('lg:w-14',solutionCollapsed); solutionPanel.classList.toggle('lg:px-2',solutionCollapsed);
solHeader.classList.toggle('lg:flex-col',solutionCollapsed); solHeader.classList.toggle('lg:gap-3',solutionCollapsed); solTitle.classList.toggle('lg:hidden',solutionCollapsed); solVlabel.classList.toggle('lg:block',solutionCollapsed);
toggleSolutionBtn.title=solutionCollapsed?UI.expand:UI.collapse; requestAnimationFrame(resizeCanvas); }
solutionPanel.addEventListener('transitionend',e=>{if(e.propertyName==='width')resizeCanvas();});
toggleSolutionBtn.addEventListener('click',()=>{solutionCollapsed=!solutionCollapsed; applySolutionState();});
// ===================== 启动 =====================
window.addEventListener('resize',resizeCanvas);
window.onload=()=>{ buildConsole(); resizeCanvas(); };
</script>
</body>
</html>