Files
NotesAgentic/backend/app/plot/render.py
T

519 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Function Plot → 静态 SVG 渲染 + 共享几何计算。
只输出纯几何与 <text> 的 SVG(无 script/foreignObject/内联事件),可安全内嵌 HTML。
所有文本与颜色都经过转义/校验,不把用户输入直接拼进标记。
几何计算(范围解析、采样、刻度、非有限点分段)统一收敛到 ``compute_geometry``
返回像素坐标的 ``PlotGeometry````render_svg`` 只做 SVG 序列化,reportlab 后端
``render_reportlab.py``)消费同一份几何,保证 PDF 与 SVG 视觉一致。
"""
from __future__ import annotations
import html
import math
import re
from dataclasses import dataclass
from app.plot.model import FunctionPlot, StaticRenderResult
from app.plot.parser import PlotParseError, evaluate, parse_expression
_WIDTH = 640
_HEIGHT = 480
_MARGIN = 52 # 四周留白,放轴刻度与标签
_SAMPLES = 400
_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"]
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
# 绘图矩形(像素,SVG y-down):曲线与坐标轴所在区域,坐标轴/网格均在此范围内
_PLOT_X0 = _MARGIN
_PLOT_Y0 = _MARGIN
_PLOT_X1 = _WIDTH - _MARGIN
_PLOT_Y1 = _HEIGHT - _MARGIN
def _safe_color(color: str | None, fallback: str) -> str:
return color.strip() if color and _COLOR_RE.match(color.strip()) else fallback
def _valid_span(lo: float, hi: float) -> bool:
"""范围跨度有效:端点有限、跨度有限且大于零。
端点相减可能溢出为 ``inf``(如 ``-1e308`` 到 ``1e308``),需单独校验跨度,
否则后续坐标换算会生成含 ``nan`` 的 SVG。
"""
span = hi - lo
return math.isfinite(lo) and math.isfinite(hi) and math.isfinite(span) and span > 0
def _fmt_num(v: float) -> str:
if v == 0:
return "0"
if abs(v) >= 1e6 or abs(v) < 1e-6:
return f"{v:.2e}"
return f"{v:.6g}"
def _nice_step(span: float, target_ticks: int = 6) -> float:
raw = abs(span) / target_ticks
if not math.isfinite(raw) or raw <= 0:
return 1.0 # 兜底步长,避免 span 为 0/inf 时产生非法刻度
mag = 10 ** math.floor(math.log10(raw))
for m in (1, 2, 5, 10):
if raw <= m * mag:
return m * mag
return 10 * mag
def _ticks(lo: float, hi: float, step: float) -> list[float]:
# 防御:非法步长直接返回空,避免除零
if not math.isfinite(step) or step <= 0:
return []
first = math.ceil(lo / step) * step
values: list[float] = []
v = first
# 有上限的整数索引推进 + 步长推进校验,防止浮点精度导致 v+step==v 的死循环
for _ in range(1000):
if v > hi + step * 1e-9:
break
values.append(v)
nxt = v + step
if nxt <= v:
break # 步长小于当前数值的浮点精度,已无法推进
v = nxt
return values
def _compute_range(
fns: list[tuple[object, object]],
xmin: float,
xmax: float,
) -> tuple[float, float]:
"""采样确定 y 范围;取有限样本的 min/max 加 5% 余量。"""
ys: list[float] = []
for _expr, tree in fns:
for i in range(_SAMPLES + 1):
x = xmin + (xmax - xmin) * i / _SAMPLES
try:
y = evaluate(tree, x) # type: ignore[arg-type]
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
continue
# 复数等非实数结果直接跳过,不参与范围统计
if isinstance(y, (int, float)) and math.isfinite(y):
ys.append(y)
if not ys:
return -10.0, 10.0
lo, hi = min(ys), max(ys)
if lo == hi:
lo -= 1.0
hi += 1.0
pad = (hi - lo) * 0.05
return lo - pad, hi + pad
def _sx(x: float, xmin: float, xmax: float) -> float:
"""数据 x → 像素 xSVG y-down 约定,原点左上)。"""
return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
def _sy(y: float, ymin: float, ymax: float) -> float:
"""数据 y → 像素 ySVG y-down 约定,原点左上)。"""
return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN)
@dataclass
class PlotGeometry:
"""已解析的几何:范围、轴位置、刻度、曲线像素点段、标签与 warnings。
像素坐标统一为 SVG y-down 约定;reportlab 后端(y-up)自行翻转 y。
"""
width: int
height: int
xmin: float
xmax: float
ymin: float
ymax: float
x_axis_y: float # 数据空间里 x 轴所在 y(过原点则 0,否则贴边)
y_axis_x: float # 数据空间里 y 轴所在 x(过原点则 0,否则贴边)
xticks: list[float]
yticks: list[float]
polylines: list[list[list[tuple[float, float]]]] # 按表达式分组:段 → 像素点
colors: list[str] # 与 polylines 对齐
xlabel: str | None
ylabel: str | None
grid: bool
warnings: list[str]
def _clip_segment(
p0: tuple[float, float],
p1: tuple[float, float],
x0: float,
y0: float,
x1: float,
y1: float,
) -> tuple[tuple[float, float], tuple[float, float]] | None:
"""Liang-Barsky:把线段裁剪到轴对齐矩形 [x0,x1]×[y0,y1],完全在外返回 None。"""
dx = p1[0] - p0[0]
dy = p1[1] - p0[1]
p = (-dx, dx, -dy, dy)
q = (p0[0] - x0, x1 - p0[0], p0[1] - y0, y1 - p0[1])
u1, u2 = 0.0, 1.0
for pk, qk in zip(p, q):
if pk == 0:
if qk < 0:
return None
else:
r = qk / pk
if pk < 0:
if r > u2:
return None
if r > u1:
u1 = r
else:
if r < u1:
return None
if r < u2:
u2 = r
if u1 > u2:
return None
return (p0[0] + u1 * dx, p0[1] + u1 * dy), (p0[0] + u2 * dx, p0[1] + u2 * dy)
def _points_close(
a: tuple[float, float], b: tuple[float, float], eps: float = 1e-9
) -> bool:
return abs(a[0] - b[0]) < eps and abs(a[1] - b[1]) < eps
def _clip_polyline(
points: list[tuple[float, float]],
x0: float,
y0: float,
x1: float,
y1: float,
) -> list[list[tuple[float, float]]]:
"""把折线裁剪到矩形,返回若干连续子段;相邻点不衔接处自动断段。"""
if not points:
return []
segments: list[list[tuple[float, float]]] = []
current: list[tuple[float, float]] = []
for i in range(len(points) - 1):
clipped = _clip_segment(points[i], points[i + 1], x0, y0, x1, y1)
if clipped is None:
if current:
segments.append(current)
current = []
continue
a, b = clipped
# 共享点被裁剪修改(折线短暂越界后折返)时,a 与上一段末点不衔接,需断段
if current and not _points_close(a, current[-1]):
segments.append(current)
current = []
if not current:
current.append(a)
current.append(b)
if current:
segments.append(current)
return segments
_REFINE_MAX_DEPTH = 24
_REFINE_MAX_EVALUATIONS = 256
_CURVE_MAX_REFINEMENT_EVALUATIONS = 8192
def _refine_crossing(tree, left, right, ymin, ymax, budget=None):
"""Adaptively check both halves of a crossing; None explicitly breaks a path.
A visible midpoint is not a continuity proof. Accept a visible chord only
when its midpoint error is within a quarter pixel; otherwise subdivide both
halves. Depth, evaluation and floating-point limits always break unresolved
intervals instead of joining them. Entirely off-screen triples can be culled.
"""
remaining = _REFINE_MAX_EVALUATIONS
if budget is None:
budget = [_REFINE_MAX_EVALUATIONS]
tolerance = (ymax - ymin) / (_PLOT_Y1 - _PLOT_Y0) / 4
def refine(a, b, depth):
nonlocal remaining
x = a[0] + (b[0] - a[0]) / 2
if depth >= _REFINE_MAX_DEPTH or remaining == 0 or budget[0] == 0 or not a[0] < x < b[0]:
return [a, None, b]
remaining -= 1
budget[0] -= 1
try:
y = evaluate(tree, x)
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
y = math.nan
if not isinstance(y, (int, float)):
y = math.nan
mid = (x, y)
values = (a[1], y, b[1])
if all(math.isfinite(v) for v in values):
if max(values) < ymin or min(values) > ymax:
return [a, None, b] # No visible chord; do not connect across it.
error = abs(y - (a[1] / 2 + b[1] / 2))
if any(ymin <= v <= ymax for v in values) and error <= tolerance:
return [a, mid, b]
# Refine either side of a nonfinite midpoint too: dropping the whole
# interval would erase valid branches between the original samples.
first = refine(a, mid, depth + 1)
second = refine(mid, b, depth + 1)
return first + second[1:]
return refine(left, right, 0)
def _sample_segments(
tree: object,
xmin: float,
xmax: float,
ymin: float,
ymax: float,
warnings: list[str] | None = None,
) -> list[list[tuple[float, float]]]:
"""采样并映射为像素点段,再裁剪到绘图矩形。
每个相邻有限采样区间都检查中点,避免端点在可见范围内的渐近线漏判。
自适应细分受区间与整条曲线预算限制,未解析区间以断点保守处理。
"""
segments: list[list[tuple[float, float]]] = []
points: list[tuple[float, float]] = []
prev_y: float | None = None
prev_x = xmin
budget = [_CURVE_MAX_REFINEMENT_EVALUATIONS]
for i in range(_SAMPLES + 1):
x = xmin + (xmax - xmin) * i / _SAMPLES
try:
y = evaluate(tree, x) # type: ignore[arg-type]
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
y = math.nan
if not isinstance(y, (int, float)) or not math.isfinite(y):
if points:
segments.append(points)
points = []
prev_y = None
continue
px = _sx(x, xmin, xmax)
py = _sy(y, ymin, ymax)
# 映射后的坐标必须有限:显式 range 下极端 y 值可能让像素坐标溢出为 inf
if not (math.isfinite(px) and math.isfinite(py)):
if points:
segments.append(points)
points = []
prev_y = None
continue
if prev_y is not None:
refined = _refine_crossing(tree, (prev_x, prev_y), (x, y), ymin, ymax, budget)
samples = refined[1:] # The previous endpoint is already in points.
else:
samples = [(x, y)]
for sample in samples:
mapped = None if sample is None else (
_sx(sample[0], xmin, xmax), _sy(sample[1], ymin, ymax)
)
if mapped is None or not all(math.isfinite(value) for value in mapped):
if points:
segments.append(points)
points = []
else:
points.append(mapped)
prev_y = y
prev_x = x
if points:
segments.append(points)
if budget[0] == 0 and warnings is not None:
warning = "曲线细分达到求值上限,未解析区间已断开;请缩小 domain 后重试"
if warning not in warnings:
warnings.append(warning)
# 裁剪到绘图矩形:reportlab 无 SVG viewport 那样的自动裁剪,超出显式 range 的
# 曲线会覆盖页面其他内容,故在共享几何层统一裁剪(SVG 也一并收敛到绘图区)。
clipped: list[list[tuple[float, float]]] = []
for seg in segments:
clipped.extend(_clip_polyline(seg, _PLOT_X0, _PLOT_Y0, _PLOT_X1, _PLOT_Y1))
return clipped
def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
"""解析并计算几何,供 SVG 与 reportlab 后端复用。"""
warnings: list[str] = []
xmin, xmax = plot.domain
if not _valid_span(xmin, xmax):
warnings.append("domain 无效,回退到 [-10, 10]")
xmin, xmax = -10.0, 10.0
# 重新解析并编译表达式(parse_source 已校验,这里异常只在模型被绕过时触发)
fns: list[tuple[object, object]] = []
for expr in plot.expressions:
try:
tree = parse_expression(expr.expression)
except PlotParseError as exc:
warnings.append(f"表达式无法渲染,已跳过:{expr.expression}{exc.diagnostic.message}")
continue
fns.append((expr, tree))
# 纵轴范围:显式 range 有效则用之;无效(退化/非有限/跨度溢出)丢弃并自动采样重算
if plot.range is not None:
lo, hi = float(plot.range[0]), float(plot.range[1])
if _valid_span(lo, hi):
ymin, ymax = lo, hi
else:
warnings.append("range 无效,改用自动范围")
ymin, ymax = _compute_range(fns, xmin, xmax)
else:
ymin, ymax = _compute_range(fns, xmin, xmax)
# 最终防线:自动范围在极端样本下也可能溢出,坐标映射前必须保证跨度有限且大于零
if not _valid_span(ymin, ymax):
warnings.append("y 范围跨度无法表示,回退到 [-10, 10]")
ymin, ymax = -10.0, 10.0
x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin
y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin
xticks = _ticks(xmin, xmax, _nice_step(xmax - xmin))
yticks = _ticks(ymin, ymax, _nice_step(ymax - ymin))
polylines: list[list[list[tuple[float, float]]]] = []
colors: list[str] = []
for i, (expr, tree) in enumerate(fns):
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
colors.append(color)
polylines.append(_sample_segments(tree, xmin, xmax, ymin, ymax, warnings))
return PlotGeometry(
width=_WIDTH,
height=_HEIGHT,
xmin=xmin,
xmax=xmax,
ymin=ymin,
ymax=ymax,
x_axis_y=x_axis_y,
y_axis_x=y_axis_x,
xticks=xticks,
yticks=yticks,
polylines=polylines,
colors=colors,
xlabel=plot.axes.xlabel,
ylabel=plot.axes.ylabel,
grid=plot.axes.grid,
warnings=warnings,
)
# --- SVG 序列化(与 compute_geometry 共用,保证字节级稳定) ---
def _grid_svg(geo: PlotGeometry) -> str:
sx = lambda x: _sx(x, geo.xmin, geo.xmax)
sy = lambda y: _sy(y, geo.ymin, geo.ymax)
parts: list[str] = []
for x in geo.xticks:
parts.append(
f'<line x1="{sx(x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(x):.2f}" '
f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
for y in geo.yticks:
parts.append(
f'<line x1="{sx(geo.xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(geo.xmax):.2f}" '
f'y2="{sy(y):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
return "".join(parts)
def _axes_svg(geo: PlotGeometry) -> str:
sx = lambda x: _sx(x, geo.xmin, geo.xmax)
sy = lambda y: _sy(y, geo.ymin, geo.ymax)
parts: list[str] = []
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
parts.append(
f'<line x1="{sx(geo.xmin):.2f}" y1="{sy(geo.x_axis_y):.2f}" x2="{sx(geo.xmax):.2f}" '
f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a" class="plot-axis"/>'
)
parts.append(
f'<line x1="{sx(geo.y_axis_x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(geo.y_axis_x):.2f}" '
f'y2="{sy(geo.ymax):.2f}" stroke="#57606a" class="plot-axis"/>'
)
# x 轴刻度数字(画在轴下方)
for x in geo.xticks:
parts.append(
f'<text x="{sx(x):.2f}" y="{sy(geo.x_axis_y) + 14:.2f}" text-anchor="middle" '
f'font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
)
# y 轴刻度数字(画在轴左侧)
for y in geo.yticks:
parts.append(
f'<text x="{sx(geo.y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" '
f'font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
)
return "".join(parts)
def _polylines_svg(geo: PlotGeometry) -> str:
parts: list[str] = []
for index, (segments, color) in enumerate(zip(geo.polylines, geo.colors)):
for seg in segments:
points = " ".join(f"{px:.2f},{py:.2f}" for px, py in seg)
parts.append(f'<polyline points="{points}" fill="none" stroke="{color}" class="plot-curve-{index % 6}"/>')
return "".join(parts)
def _labels_svg(geo: PlotGeometry) -> str:
parts: list[str] = []
if geo.xlabel:
parts.append(
f'<text x="{geo.width / 2:.2f}" y="{geo.height - 10:.2f}" text-anchor="middle" '
f'font-size="12" fill="#1f2328">{html.escape(geo.xlabel)}</text>'
)
if geo.ylabel:
parts.append(
f'<text x="16" y="{geo.height / 2:.2f}" text-anchor="middle" font-size="12" '
f'fill="#1f2328" transform="rotate(-90 16 {geo.height / 2:.2f})">'
f'{html.escape(geo.ylabel)}</text>'
)
return "".join(parts)
def render_svg(plot: FunctionPlot, theme_id: str = 'light') -> StaticRenderResult:
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
geo = compute_geometry(plot)
legend_height = ((len(plot.expressions) + 1) // 2) * 24
height = geo.height + legend_height
parts: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {geo.width} {height}" role="img" class="function-plot-svg">'
]
if geo.grid:
parts.append(_grid_svg(geo))
parts.append(_axes_svg(geo))
parts.append(_polylines_svg(geo))
parts.append(_labels_svg(geo))
for index, expression in enumerate(plot.expressions):
x = 24 + (index % 2) * 310
y = geo.height + 18 + (index // 2) * 24
label = html.escape(expression.label or ('y = ' + expression.expression))
parts.append(f'<text x="{x}" y="{y}" font-size="12" fill="{geo.colors[index]}" class="plot-legend-{index % 6}">{label}</text>')
parts.append("</svg>")
return StaticRenderResult(
content=theme_svg("".join(parts), theme_id),
width=geo.width,
height=height,
warnings=geo.warnings,
)
def theme_svg(svg: str, theme_id: str) -> str:
from app.export.themes import PALETTES
palette = PALETTES.get(theme_id, PALETTES['light'])
for source, target in [('#eaeef2', palette[5]), ('#57606a', palette[3]), ('#1f2328', palette[2])]:
svg = svg.replace(source, target)
if theme_id in {'dark', 'midnight-purple'}:
for source, target in zip(_PALETTE, ['#79c0ff','#ff9b9b','#7ee787','#d2a8ff','#f2cc60','#ffa657']):
svg = svg.replace(source, target)
background = '<rect width="100%" height="100%" fill="' + palette[1] + '"/>'
if re.search(r'<rect width="100%" height="100%" fill="[^"]*"/>', svg):
return re.sub(r'<rect width="100%" height="100%" fill="[^"]*"/>', background, svg, count=1)
return svg.replace('role="img" class="function-plot-svg">', 'role="img" class="function-plot-svg">' + background)