使用 JavaFX + Reactor 绘制函数图像并数值求解交点 ------ 原理与实现
在数值计算与可视化中,我们常常需要绘制函数曲线并标注其交点。本文将以一个完整的 Java 程序为例,深入剖析如何利用 Reactor 生成数据点、JavaFX 绘制曲线,并通过纯数值方法(符号检测 + 二分法)求解交点,最终在图上以空心圆圈和坐标标签的形式清晰显示。
代码全程不依赖任何数学求导公式,完全基于离散点采样与迭代逼近,具有通用性强、易于扩展的特点。
一、程序概览
我们定义两个函数:
- ( f1(x) = 3x^2 - 1 )(二次函数)
- ( f2(x) = 2x - 1 )(一次函数)
目标是绘制它们在区间 (-5, 5) 上的图像,并求出所有交点坐标,在图上用空心圆标注。
二、数据生成:Reactor 的 Flux 流
2.1 为何使用 Reactor?
Reactor 提供了一套响应式编程 API,其 Flux 可以方便地生成大量离散数据点,并以声明式的方式进行处理。与传统 for 循环相比,Flux 更易于组合、并行处理和链式操作。
2.2 代码解析
java
double step = 0.01;
double startX = -5.0;
double endX = 5.0;
int count = (int) ((endX - startX) / step) + 1;
Flux.range(0, count)
.map(i -> startX + i * step)
.doOnNext(x -> {
series1.getData().add(new XYChart.Data<>(x, f1(x)));
series2.getData().add(new XYChart.Data<>(x, f2(x)));
})
.blockLast();
Flux.range(0, count)生成一个整数序列 0 到 count-1。map(i -> startX + i * step)将每个索引映射为对应的 ( x ) 值,从而得到均匀分布的离散点。doOnNext是副作用操作,在每次发出 ( x ) 时,计算两个函数值并添加到 JavaFX 的Series数据列表中。blockLast()阻塞当前线程,直到所有数据生成完毕。由于数据量不大(约 1000 点),该操作对 UI 线程影响很小。
这样,我们便得到了绘制曲线所需的所有点。
三、绘图:JavaFX 的 LineChart
3.1 坐标轴设置
java
NumberAxis xAxis = new NumberAxis();
xAxis.setLowerBound(-5);
xAxis.setUpperBound(5);
xAxis.setAutoRanging(false);
固定 x 轴范围,确保定义域完整显示。y 轴则自动适应数据范围。
3.2 创建图表与系列
java
LineChart<Number, Number> chart = new LineChart<>(xAxis, yAxis);
XYChart.Series<Number, Number> series1 = new XYChart.Series<>();
series1.setName("f1(x) = 3x² - 1");
LineChart 会按照数据点顺序连接成折线,由于步长足够小(0.01),曲线将非常平滑。setCreateSymbols(false) 禁用默认数据点符号,避免干扰。
3.3 添加数据
在 doOnNext 中,我们直接将计算出的数据点加入 series 的 getData().add(),该方法会在 JavaFX 应用程序线程中更新,但 blockLast() 会阻塞 UI 线程直到所有点添加完毕,这个过程只持续几十毫秒,用户无感知。
四、数值求解交点
4.1 原理
两个连续函数的交点满足 ( f_1(x) = f_2(x) ),等价于差值函数 ( h(x) = f_1(x) - f_2(x) ) 的零点。若 ( h(x) ) 在某区间两端异号,则根据介值定理,该区间内必存在至少一个根。
4.2 实现步骤
- 离散采样 :在定义域内以步长
step采样,计算每个点的 ( h(x) )。 - 符号检测 :遍历相邻点,若
h1 * h2 < 0,则说明该区间内存在根。 - 二分法精化 :对每个含根区间,使用二分法(Bisection Method)迭代求根,直到区间宽度小于给定精度(
1e-10)。 - 处理端点:若某点 ( h(x) ) 恰好为零(或绝对值极小),直接视为交点。
java
private List<double[]> findIntersections(double step, double startX, double endX) {
List<double[]> result = new ArrayList<>();
int count = (int) ((endX - startX) / step) + 1;
double[] xVals = new double[count];
double[] hVals = new double[count];
for (int i = 0; i < count; i++) {
double x = startX + i * step;
xVals[i] = x;
hVals[i] = f1(x) - f2(x);
}
for (int i = 0; i < count - 1; i++) {
if (hVals[i] * hVals[i+1] < 0) {
double root = bisect(xVals[i], xVals[i+1]);
double y = f1(root);
result.add(new double[]{root, y});
}
if (Math.abs(hVals[i]) < 1e-12) {
double y = f1(xVals[i]);
result.add(new double[]{xVals[i], y});
}
}
// 检查最后一点
if (Math.abs(hVals[count-1]) < 1e-12) {
double y = f1(xVals[count-1]);
result.add(new double[]{xVals[count-1], y});
}
return result;
}
4.3 二分法细节
java
private double bisect(double a, double b) {
double eps = 1e-10;
double mid;
int maxIter = 100;
for (int i = 0; i < maxIter; i++) {
mid = (a + b) / 2;
double fa = f1(a) - f2(a);
double fm = f1(mid) - f2(mid);
if (Math.abs(fm) < eps) return mid;
if (fa * fm < 0) b = mid;
else a = mid;
}
return (a + b) / 2;
}
二分法保证收敛,且精度可控。对于本例,只需几次迭代即可达到 1e-10 精度。
五、交点标注:自定义节点
5.1 需求
- 使用空心圆圈标记交点位置。
- 在圆圈上方显示坐标标签,避免遮挡曲线。
5.2 实现
为每个交点数据创建一个 XYChart.Data,并通过 setNode 设置自定义的 StackPane。
java
XYChart.Data<Number, Number> data = new XYChart.Data<>(p[0], p[1]);
StackPane node = new StackPane();
Circle circle = new Circle(6);
circle.setFill(null); // 空心
circle.setStroke(Color.RED);
circle.setStrokeWidth(2);
Label label = new Label(String.format("(%.2f, %.2f)", p[0], p[1]));
label.setTextFill(Color.BLUE);
label.setTranslateY(-20); // 上移
node.getChildren().addAll(circle, label);
data.setNode(node);
5.3 为什么放在最上层?
先添加曲线系列(series1, series2),再添加交点系列,使得交点绘制在曲线之上,避免被覆盖。
六、为什么数值解与解析解一致?
令 ( f_1(x) = f_2(x) ):
3x\^2 - 1 = 2x - 1 \\Rightarrow 3x\^2 - 2x = 0 \\Rightarrow x=0 \\text{ 或 } x=\\frac{2}{3}
对应 y 值为 -1 和 1/3。
数值解在 1e-10 精度下完全吻合,验证了算法的正确性。这证明了在步长足够小、二分法精度足够高的情况下,数值方法能可靠地逼近解析解。
七、与之前学习内容的联系
Context/ContextView:虽未直接用于此程序,但理解上下文传递有助于在更复杂的响应式流中传递调试信息。SynchronousSink:Flux.generate曾用于创建无限流,而本例使用Flux.range生成有限点集,两者都是 Reactor 生成数据的常用方式。Fuseable:内部优化,对于大量数据点可提升性能,但本例数据量不大,未显式使用。- 数值求交:延续了之前对数函数导数、三角数流等数值计算的思想,体现了响应式流在科学计算中的实用性。
八、运行与效果
运行程序后,窗口显示两条平滑曲线,交点处出现红色空心圆圈,上方附有蓝色坐标标签,清晰无遮挡。控制台无额外输出,全部信息可视化。
九、总结
本文通过一个完整的 Java 程序,展示了如何结合 Reactor 和 JavaFX:
- 响应式生成大量离散点以绘制平滑曲线。
- 利用数值差分法(符号检测 + 二分法)求解函数交点。
- 自定义图表节点,实现空心圆圈与标签标注。
该方案不依赖解析公式,可推广至任意连续函数,具有广泛的应用价值。掌握这些技术,将为后续的数值分析与可视化开发奠定坚实基础。
完整代码
java
public class FunctionPlotter extends Application {
// 定义函数:导数 f1(x) = 3x² - 1
private double f1(double x) { return 3 * x * x - 1; }
// 定义函数:直线 f2(x) = 2x - 1
private double f2(double x) { return 2 * x - 1; }
@Override
public void start(Stage primaryStage) {
// 坐标轴
NumberAxis xAxis = new NumberAxis();
xAxis.setLabel("x");
xAxis.setLowerBound(-5);
xAxis.setUpperBound(5);
xAxis.setAutoRanging(false);
NumberAxis yAxis = new NumberAxis();
yAxis.setLabel("y");
yAxis.setAutoRanging(true);
// 图表
LineChart<Number, Number> chart = new LineChart<>(xAxis, yAxis);
chart.setTitle("导数函数与直线交点");
chart.setCreateSymbols(false); // 不显示数据点默认符号,避免干扰
double step = 0.01;
double startX = -5.0;
double endX = 5.0;
int count = (int) ((endX - startX) / step) + 1;
// 系列1:f1(x)
XYChart.Series<Number, Number> series1 = new XYChart.Series<>();
series1.setName("f1(x) = 3x² - 1");
// 系列2:f2(x)
XYChart.Series<Number, Number> series2 = new XYChart.Series<>();
series2.setName("f2(x) = 2x - 1");
// 使用 Reactor 生成数据点
Flux.range(0, count)
.map(i -> startX + i * step)
.doOnNext(x -> {
series1.getData().add(new XYChart.Data<>(x, f1(x)));
series2.getData().add(new XYChart.Data<>(x, f2(x)));
})
.blockLast();
// ---- 数值求交点 ----
List<double[]> intersections = findIntersections(step, startX, endX);
// 交点系列(自定义节点:空心圆圈 + 坐标标签)
XYChart.Series<Number, Number> intersectionSeries = new XYChart.Series<>();
intersectionSeries.setName("交点");
for (double[] p : intersections) {
XYChart.Data<Number, Number> data = new XYChart.Data<>(p[0], p[1]);
// 自定义节点:空心圆 + 标签
StackPane node = new StackPane();
Circle circle = new Circle(6);
circle.setFill(null); // 空心
circle.setStroke(Color.RED);
circle.setStrokeWidth(2);
Label label = new Label(String.format("(%.2f, %.2f)", p[0], p[1]));
label.setTextFill(Color.BLUE);
label.setStyle("-fx-font-size: 12px; -fx-font-weight: bold;");
label.setTranslateY(-20); // 标签上移,避免遮挡
node.getChildren().addAll(circle, label);
data.setNode(node);
intersectionSeries.getData().add(data);
}
// 添加所有系列到图表(先添加曲线,再添加交点,保证交点在最上层)
chart.getData().addAll(series1, series2, intersectionSeries);
// 可选:设置交点系列默认样式透明(以防默认样式干扰)
intersectionSeries.getNode().setStyle("-fx-stroke: transparent; -fx-background-color: transparent;");
// 显示窗口
Scene scene = new Scene(chart, 800, 600);
primaryStage.setScene(scene);
primaryStage.show();
}
/**
* 数值求解交点(差值函数 h(x)=f1(x)-f2(x) 的零点)
*/
private List<double[]> findIntersections(double step, double startX, double endX) {
List<double[]> result = new ArrayList<>();
int count = (int) ((endX - startX) / step) + 1;
double[] xVals = new double[count];
double[] hVals = new double[count];
for (int i = 0; i < count; i++) {
double x = startX + i * step;
xVals[i] = x;
hVals[i] = f1(x) - f2(x);
}
for (int i = 0; i < count - 1; i++) {
double h1 = hVals[i];
double h2 = hVals[i + 1];
if (h1 * h2 < 0) {
double root = bisect(xVals[i], xVals[i + 1]);
double y = f1(root);
result.add(new double[]{root, y});
}
// 处理恰好为零的点
if (Math.abs(h1) < 1e-12) {
double y = f1(xVals[i]);
result.add(new double[]{xVals[i], y});
}
}
if (Math.abs(hVals[count - 1]) < 1e-12) {
double y = f1(xVals[count - 1]);
result.add(new double[]{xVals[count - 1], y});
}
return result;
}
/**
* 二分法求根(精度 1e-10)
*/
private double bisect(double a, double b) {
double eps = 1e-10;
double mid;
int maxIter = 100;
for (int i = 0; i < maxIter; i++) {
mid = (a + b) / 2;
double fa = f1(a) - f2(a);
double fm = f1(mid) - f2(mid);
if (Math.abs(fm) < eps) return mid;
if (fa * fm < 0) b = mid;
else a = mid;
}
return (a + b) / 2;
}
public static void main(String[] args) {
launch(args);
}
}