介绍
今天,我运用拉格朗日插值法绘制了一条曲线。然而,我并未止步于静态展示,而是引入了一个定时器,每隔一段时间便对曲线上的点进行动态更新,从而赋予曲线生命般的动态变化。
然而,在刷新过程中,我敏锐地察觉到曲线之间的切换显得过于突兀,缺乏流畅感(请见下图)。于是,一个大胆的想法在我脑海中闪现:何不尝试构造一个曲线过渡算法,以实现曲线切换时的平滑过渡?这不仅将提升视觉效果,更将为动态曲线的展示增添一抹细腻与和谐。
在具体实现之前,我们先了解下拉格朗日插值法。
拉格朗日插值法
拉格朗日插值法是一种用于在给定数据点之间进行多项式插值的方法。
该方法可以找到一个多项式,该多项式恰好穿过二维平面上若干个给定数据点。
拉格朗日插值多项式
给定 n + 1 n+1 n+1 个点 ( x 0 , y 0 ) , ( x 1 , y 1 ) , ⋯ , ( x n , y n ) (x_0,y_0), (x_1,y_1),\cdots,(x_n,y_n) (x0,y0),(x1,y1),⋯,(xn,yn),则存在一个 n n n 次多项式 P ( x ) P(x) P(x) 使得 P ( x i ) = y i i = 0 , 1 , ⋯ , n P(x_i)=y_i \quad i=0,1,\cdots,n P(xi)=yii=0,1,⋯,n,即:
P ( x ) = ∑ i = 0 n y i l i ( x ) P(x)=\sum_{i=0}^n y_i l_i(x) P(x)=i=0∑nyili(x)
其中 l i ( x ) l_i(x) li(x) 是拉格朗日基函数,定义为:
l i ( x ) = ∏ j = 0 , j ≠ i n x − x j x i − x j l_i(x)=\prod_{j=0,j\neq i}^n \frac{x-x_j}{x_i-x_j} li(x)=j=0,j=i∏nxi−xjx−xj
拉格朗日插值多项式的代码实现
function lagrange(x, points) {
const n = points.length;
const result = [];
for (let i = 0; i < n; i++) {
let tmp = points[i].y;
for (let j = 0; j < n; j++) {
if (j !== i) {
tmp *= (x - points[j].x) / (points[i].x - points[j].x);
}
}
result.push(tmp);
}
return result.reduce((sum, cur) => sum + cur, 0);
}
实现曲线突兀切换
我们首先完整实现一下开头介绍部分图片所展示的效果代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f0f0;
}
canvas {
border-radius: 15px;
background-color: #ffffff;
}
</style>
</head>
<body>
<canvas id="demo-canvas" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('demo-canvas');
const ctx = canvas.getContext('2d');
let points = [];
function drawLine(x1, y1, x2, y2, color) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color;
ctx.stroke();
}
function lagrange(x, points) {
const n = points.length;
const result = [];
for (let i = 0; i < n; i++) {
let tmp = points[i].y;
for (let j = 0; j < n; j++) {
if (j !== i) {
tmp *= (x - points[j].x) / (points[i].x - points[j].x);
}
}
result.push(tmp);
}
return result.reduce((sum, cur) => sum + cur, 0);
}
function fillPoints() {
const randomNumber = (min, max) => {
const randomBuffer = new Uint32Array(1);
window.crypto.getRandomValues(randomBuffer);
const number = randomBuffer[0] / (0xffffffff + 1);
return number * (max - min + 1) + min;
}
points = [];
const count = 7;
for (let i = 0; i < count; i++) {
points.push({
x: (i + 1) * 100,
y: randomNumber(200, 400)
});
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
fillPoints();
const step = 1;
for (let x = points[0].x; x < points[points.length - 1].x; x += step) {
drawLine(x, lagrange(x, points), x + step, lagrange(x + step, points), 'red');
}
setTimeout(draw, 1000);
}
draw();
</script>
</body>
</html>
实现曲线平滑切换
简单构思一下,解决方案其实非常简单:只需保存当前曲线与下一条曲线,然后在每个横坐标 x
值上,两条曲线分别具有两个纵坐标 y
值,通过利用这两个 y
值,我们可以构建一条
1
1
1 阶贝塞尔曲线进行插值,其他位置上的点重复同样的步骤,在相同的时间内完成插值即可实现曲线的平滑切换。
原理图如下:
开始行动,我们首先构造 1 1 1 阶贝塞尔曲线:
B ( t ) = ( 1 − t ) P 0 + t P 1 0 ≤ t ≤ 1 B(t) = (1 - t)P_0 + tP_1 \quad 0 \leq t \leq 1 B(t)=(1−t)P0+tP10≤t≤1
其中 P 0 P_0 P0 为当前曲线的纵坐标, P 1 P_1 P1 为下一条曲线的纵坐标, t t t 为插值系数。
function bezier(t, y0, y1) {
return (1 - t) * y0 + t * y1;
}
然后,我们构造用于保存下一条曲线控制点的数组 nextPoints
:
let nextPoints = [];
对应的填充曲线控制点的函数 fillPoints
也需要做相应调整:
function fillPoints() {
const randomNumber = (min, max) => {
const randomBuffer = new Uint32Array(1);
window.crypto.getRandomValues(randomBuffer);
const number = randomBuffer[0] / (0xffffffff + 1);
return number * (max - min + 1) + min;
}
const count = 7;
if (points.length === 0 && nextPoints.length === 0) {
for (let i = 0; i < count; i++) {
points.push({
x: (i + 1) * 100,
y: randomNumber(200, 400)
});
nextPoints.push({
x: (i + 1) * 100,
y: randomNumber(200, 400)
});
}
}
else {
points = [];
points = nextPoints;
nextPoints = [];
for (let i = 0; i < count; i++) {
nextPoints.push({
x: (i + 1) * 100,
y: randomNumber(200, 400)
});
}
}
}
fillPoints
函数在第一次运行时填充两条曲线控制点,之后每次运行时,先将 nextPoints
中的数据复制到 points
中,最后填充下一条曲线控制点到 nextPoints
中。
然后,我们构造用于平滑切换的动画函数 animate
:
let t = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const step = 1;
for (let x = points[0].x; x < points[points.length - 1].x; x += step) {
const y = bezier(t, lagrange(x, points), lagrange(x, nextPoints));
const y_step = bezier(t, lagrange(x + step, points), lagrange(x + step, nextPoints));
drawLine(x, y, x + step, y_step, 'red');
}
t += 0.05;
if (t < 1) {
requestAnimationFrame(animate);
}
}
animate
函数在每次调用中的第一次运行时需要保证 t
值为 0
,然后通过调用 requestAnimationFrame(animate)
函数反复执行 animate
函数完成动画绘制,直到 t
值达到 1
时,动画结束。
最后,我们对绘制函数 draw
做相应调整:
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
fillPoints();
const step = 1;
t = 0;
for (let x = points[0].x; x < points[points.length - 1].x; x += step) {
drawLine(x, lagrange(x, points), x + step, lagrange(x + step, points), 'red');
}
animate();
setTimeout(draw, 1000);
}
保证绘制完当前的曲线后,立即调用 animate
函数完成平滑切换,最后通过 setTimeout
函数定时反复调用 draw
函数完成动画循环。
完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f0f0;
}
canvas {
border-radius: 15px;
background-color: #ffffff;
}
</style>
</head>
<body>
<canvas id="demo-canvas" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('demo-canvas');
const ctx = canvas.getContext('2d');
let points = [], nextPoints = [];
function drawLine(x1, y1, x2, y2, color) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color;
ctx.stroke();
}
function lagrange(x, points) {
const n = points.length;
const result = [];
for (let i = 0; i < n; i++) {
let tmp = points[i].y;
for (let j = 0; j < n; j++) {
if (j !== i) {
tmp *= (x - points[j].x) / (points[i].x - points[j].x);
}
}
result.push(tmp);
}
return result.reduce((sum, cur) => sum + cur, 0);
}
function bezier(t, y0, y1) {
return (1 - t) * y0 + t * y1;
}
function fillPoints() {
const randomNumber = (min, max) => {
const randomBuffer = new Uint32Array(1);
window.crypto.getRandomValues(randomBuffer);
const number = randomBuffer[0] / (0xffffffff + 1);
return number * (max - min + 1) + min;
}
const count = 7;
if (points.length === 0 && nextPoints.length === 0) {
for (let i = 0; i < count; i++) {
points.push({
x: (i + 1) * 100,
y: randomNumber(200, 400)
});
nextPoints.push({
x: (i + 1) * 100,
y: randomNumber(200, 400)
});
}
}
else {
points = [];
points = nextPoints;
nextPoints = [];
for (let i = 0; i < count; i++) {
nextPoints.push({
x: (i + 1) * 100,
y: randomNumber(200, 400)
});
}
}
}
let t = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const step = 1;
for (let x = points[0].x; x < points[points.length - 1].x; x += step) {
const y = bezier(t, lagrange(x, points), lagrange(x, nextPoints));
const y_step = bezier(t, lagrange(x + step, points), lagrange(x + step, nextPoints));
drawLine(x, y, x + step, y_step, 'red');
}
t += 0.05;
if (t < 1) {
requestAnimationFrame(animate);
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
fillPoints();
const step = 1;
t = 0;
for (let x = points[0].x; x < points[points.length - 1].x; x += step) {
drawLine(x, lagrange(x, points), x + step, lagrange(x + step, points), 'red');
}
animate();
setTimeout(draw, 1000);
}
draw();
</script>
</body>
</html>