# Sistema Masa-Resorte-Amortiguador
Un **sistema masa-resorte-amortiguador** es uno de los modelos físicos más importantes en la teoría de ecuaciones diferenciales. Describe el movimiento de una masa sujeta a un resorte con fricción, y aparece en aplicaciones que van desde suspensiones de vehículos hasta sistemas de control y circuitos eléctricos.
## Modelo matemático
Consideremos una masa $m$ unida a un resorte con constante elástica $k$, sometida a una fuerza de amortiguamiento proporcional a la velocidad con coeficiente $c$.
Por la segunda ley de Newton, la ecuación del movimiento es:
$$
m\frac{d^2x}{dt^2} + c\frac{dx}{dt} + kx = 0
$$
donde:
- $x(t)$ es la posición de la masa (desplazamiento desde el equilibrio)
- $m$ es la masa del objeto
- $c$ es el coeficiente de amortiguamiento
- $k$ es la constante del resorte
Dividiendo por $m$ y definiendo:
- $\omega_0 = \sqrt{\frac{k}{m}}$ (frecuencia natural)
- $\gamma = \frac{c}{2m}$ (coeficiente de amortiguamiento)
Obtenemos la **forma canónica**:
$$
\frac{d^2x}{dt^2} + 2\gamma\frac{dx}{dt} + \omega_0^2 x = 0
$$
## Clasificación según el amortiguamiento
El comportamiento del sistema depende del **discriminante reducido** de la ecuación característica $r^2 + 2\gamma r + \omega_0^2 = 0$ (es decir, $\Delta_{quad}/4$):
$$
\Delta = \gamma^2 - \omega_0^2
$$
Esto nos da cuatro regímenes distintos:
### 1. Sobreamortiguado ($\gamma > \omega_0$)
Amortiguamiento **excesivo**. El sistema retorna al equilibrio **sin oscilar**.
**Raíces reales distintas:**
$$
r_{1,2} = -\gamma \pm \sqrt{\gamma^2 - \omega_0^2}
$$
**Solución general:**
$$
x(t) = c_1 e^{r_1 t} + c_2 e^{r_2 t}
$$
**Características:**
- Decaimiento exponencial sin oscilaciones
- Retorno lento al equilibrio
- Aplicación: Puertas con cierre automático
### 2. Críticamente amortiguado ($\gamma = \omega_0$)
Amortiguamiento **crítico**. El sistema retorna al equilibrio lo más **rápido posible sin oscilar**.
**Raíz doble:**
$$
r = -\gamma
$$
**Solución general:**
$$
x(t) = (c_1 + c_2 t)e^{-\gamma t}
$$
**Características:**
- Retorno más rápido al equilibrio sin oscilación
- Caso límite entre oscilatorio y no oscilatorio
- Aplicación: Instrumentos de control, servomecanismos de precisión
### 3. Subamortiguado ($\gamma < \omega_0$)
Amortiguamiento **insuficiente**. El sistema **oscila** con amplitud decreciente.
**Raíces complejas conjugadas:**
$$
r_{1,2} = -\gamma \pm i\omega_d, \quad \omega_d = \sqrt{\omega_0^2 - \gamma^2}
$$
donde $\omega_d$ es la **frecuencia amortiguada**.
**Solución general:**
$$
x(t) = e^{-\gamma t}(c_1 \cos(\omega_d t) + c_2 \sin(\omega_d t))
$$
o equivalentemente:
$$
x(t) = A e^{-\gamma t}\cos(\omega_d t - \phi)
$$
donde $A$ es la amplitud de la oscilación y $\phi$ es la fase.
**Características:**
- Oscilaciones con amplitud decreciente exponencialmente
- Periodo: $T = \frac{2\pi}{\omega_d}$
- Aplicación: Instrumentos de medida, relojes mecánicos
### 4. Sin amortiguamiento ($\gamma = 0$)
Caso ideal sin fricción. El sistema **oscila indefinidamente**.
**Raíces imaginarias puras:**
$$
r_{1,2} = \pm i\omega_0
$$
**Solución general:**
$$
x(t) = c_1 \cos(\omega_0 t) + c_2 \sin(\omega_0 t) = A\cos(\omega_0 t - \phi)
$$
**Características:**
- Oscilación armónica simple sin decaimiento
- Amplitud constante
- Aplicación: Modelo teórico, péndulo ideal
## Visualización interactiva
::: {.content-visible when-format="html"}
::: {.callout-tip collapse="true" title="Exploración de regímenes"}
```{ojs}
//| echo: false
//| label: muelle-sliders
viewof muelle_params_mk = Inputs.form({
m: Inputs.range([0.1, 5], {value: 4.5, step: 0.1, label: "m — masa (kg)"}),
k: Inputs.range([1, 70], {value: 10, step: 1, label: "k — rigidez (N/m)"})
}, {
template: (inputs) => html`
<div style="margin-bottom: 10px;">
<label style="font-weight: bold; display: block; margin-bottom: 6px; color: #2c3e50;">Parámetros del sistema</label>
<div style="display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; align-items: center;">
${inputs.m}
${inputs.k}
</div>
</div>
`
})
viewof muelle_params_ct = Inputs.form({
c: Inputs.range([0, 20], {value: 2, step: 0.1, label: "c — amortiguamiento (kg/s)"}),
t: Inputs.range([5, 30], {value: 15, step: 1, label: "Tiempo máximo (s)"})
}, {
template: (inputs) => html`
<div style="display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; align-items: center; margin: 6px 0;">
${inputs.c}
${inputs.t}
</div>
`
})
viewof muelle_params_ic = Inputs.form({
x0: Inputs.range([0, 5], {value: 2, step: 0.1, label: "x₀ — posición inicial (m)"}),
v0: Inputs.range([-5, 5], {value: 0, step: 0.1, label: "v₀ — velocidad inicial (m/s)"})
}, {
template: (inputs) => html`
<div style="margin-bottom: 4px;">
<label style="font-weight: bold; display: block; margin-bottom: 6px; color: #2c3e50;">Condiciones iniciales</label>
<div style="display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; align-items: center;">
${inputs.x0}
${inputs.v0}
</div>
</div>
`
})
m_muelle = muelle_params_mk.m
k_muelle = muelle_params_mk.k
c_muelle = muelle_params_ct.c
t_max_muelle = muelle_params_ct.t
x0_muelle = muelle_params_ic.x0
v0_muelle = muelle_params_ic.v0
```
```{ojs}
//| echo: false
//| label: muelle-calc
omega_0 = Math.sqrt(k_muelle / m_muelle);
c_critico = 2 * Math.sqrt(k_muelle * m_muelle);
c_efectivo = c_muelle;
zeta_efectiva = c_critico > 0 ? (c_efectivo / c_critico) : 0;
gamma_muelle = c_efectivo / (2 * m_muelle);
omega_d = Math.sqrt(Math.abs(omega_0 * omega_0 - gamma_muelle * gamma_muelle));
discriminante = gamma_muelle * gamma_muelle - omega_0 * omega_0;
eps_crit = omega_0 * omega_0 * 0.001;
tipo_regimen = discriminante > eps_crit ? "Sobreamortiguado" :
Math.abs(discriminante) <= eps_crit ? "Críticamente amortiguado" :
gamma_muelle > omega_0 * 0.001 ? "Subamortiguado" :
"Sin amortiguamiento";
muelle_data = {
const points = [];
const dt = 0.01;
for (let t = 0; t <= t_max_muelle; t += dt) {
let x;
if (discriminante > eps_crit) {
// Sobreamortiguado
const r1 = -gamma_muelle + Math.sqrt(discriminante);
const r2 = -gamma_muelle - Math.sqrt(discriminante);
const c1 = (v0_muelle - r2 * x0_muelle) / (r1 - r2);
const c2 = x0_muelle - c1;
x = c1 * Math.exp(r1 * t) + c2 * Math.exp(r2 * t);
}
else if (Math.abs(discriminante) <= eps_crit) {
// Críticamente amortiguado
const c1 = x0_muelle;
const c2 = v0_muelle + gamma_muelle * x0_muelle;
x = (c1 + c2 * t) * Math.exp(-gamma_muelle * t);
}
else if (gamma_muelle > omega_0 * 0.001) {
// Subamortiguado
const A = Math.sqrt(x0_muelle * x0_muelle + Math.pow((v0_muelle + gamma_muelle * x0_muelle) / omega_d, 2));
const phi = Math.atan2(v0_muelle + gamma_muelle * x0_muelle, omega_d * x0_muelle);
x = A * Math.exp(-gamma_muelle * t) * Math.cos(omega_d * t - phi);
}
else {
// Sin amortiguamiento
const A = Math.sqrt(x0_muelle * x0_muelle + (v0_muelle / omega_0) * (v0_muelle / omega_0));
const phi = Math.atan2(v0_muelle, omega_0 * x0_muelle);
x = A * Math.cos(omega_0 * t - phi);
}
points.push({t: t, x: x});
}
return points;
}
// Envolvente de decaimiento (solo para subamortiguado)
envolvente_data = {
if (gamma_muelle > omega_0 * 0.001 && discriminante < -eps_crit) {
const A = Math.sqrt(x0_muelle * x0_muelle + Math.pow((v0_muelle + gamma_muelle * x0_muelle) / omega_d, 2));
const points_pos = [];
const points_neg = [];
for (let t = 0; t <= t_max_muelle; t += 0.05) {
const env = A * Math.exp(-gamma_muelle * t);
points_pos.push({t: t, x: env});
points_neg.push({t: t, x: -env});
}
return {pos: points_pos, neg: points_neg};
} else {
return null;
}
}
```
```{ojs}
//| echo: false
//| label: muelle-plot
Plot.plot({
width: 700,
height: 400,
marginLeft: 60,
grid: true,
x: {
label: "Tiempo t (s) →",
domain: [0, t_max_muelle]
},
y: {
label: "↑ Posición x(t) (m)",
domain: [
Math.min(...muelle_data.map(d => d.x)) * 1.2,
Math.max(...muelle_data.map(d => d.x)) * 1.2
]
},
marks: [
// Envolventes (solo subamortiguado)
...(envolvente_data ? [
Plot.line(envolvente_data.pos, {
x: "t",
y: "x",
stroke: "#999",
strokeWidth: 1.5,
strokeDasharray: "4,4"
}),
Plot.line(envolvente_data.neg, {
x: "t",
y: "x",
stroke: "#999",
strokeWidth: 1.5,
strokeDasharray: "4,4"
})
] : []),
// Posición de equilibrio
Plot.ruleY([0], {
stroke: "#666",
strokeWidth: 1,
strokeDasharray: "2,2"
}),
// Trayectoria del sistema
Plot.line(muelle_data, {
x: "t",
y: "x",
stroke: tipo_regimen === "Sobreamortiguado" ? "#dc3545" :
tipo_regimen === "Críticamente amortiguado" ? "#fd7e14" :
tipo_regimen === "Subamortiguado" ? "#0066cc" :
"#28a745",
strokeWidth: 2.5
}),
// Punto inicial
Plot.dot([{t: 0, x: x0_muelle}], {
x: "t",
y: "x",
fill: tipo_regimen === "Sobreamortiguado" ? "#dc3545" :
tipo_regimen === "Críticamente amortiguado" ? "#fd7e14" :
tipo_regimen === "Subamortiguado" ? "#0066cc" :
"#28a745",
r: 5
})
]
})
```
**Parámetros calculados:**
- Frecuencia natural: $\omega_0 = \sqrt{k/m} =$ `{ojs} omega_0.toFixed(3)` rad/s
- Amortiguamiento crítico: $c_{cr} = 2\sqrt{km} =$ `{ojs} c_critico.toFixed(3)` kg/s
- Amortiguamiento efectivo: $c =$ `{ojs} c_efectivo.toFixed(3)` kg/s
- Razón de amortiguamiento: $\zeta = c/c_{cr} =$ `{ojs} zeta_efectiva.toFixed(3)`
- Coeficiente de amortiguamiento: $\gamma = c/(2m) =$ `{ojs} gamma_muelle.toFixed(3)` s⁻¹
- Discriminante: $\Delta = \gamma^2 - \omega_0^2 =$ `{ojs} discriminante.toFixed(3)`
- **Régimen:** `{ojs} tipo_regimen`
```{ojs}
//| echo: false
omega_d > 0 && tipo_regimen === "Subamortiguado" ?
md`- Frecuencia amortiguada: $\\omega_d = \\sqrt{\\omega_0^2 - \\gamma^2} =$ ${omega_d.toFixed(3)} rad/s
- Periodo: $T = 2\\pi/\\omega_d =$ ${(2*Math.PI/omega_d).toFixed(3)} s` : ""
```
**Observaciones:**
```{ojs}
//| echo: false
tipo_regimen === "Sobreamortiguado" ? md`El sistema retorna al equilibrio **lentamente sin oscilar**. Amortiguamiento excesivo.` :
tipo_regimen === "Críticamente amortiguado" ? md`El sistema retorna al equilibrio lo **más rápido posible sin oscilar**. Amortiguamiento óptimo.` :
tipo_regimen === "Subamortiguado" ? md`El sistema **oscila** con amplitud decreciente. Las envolventes muestran el decaimiento exponencial $x_{env} = Ae^{-\\gamma t}$.` :
md`El sistema **oscila indefinidamente** con amplitud constante. Caso ideal sin fricción.`
```
:::
:::
::: {.content-visible when-format="pdf"}
{{< include _interactive_note.qmd >}}
:::
## Comparación de regímenes
::: {.content-visible when-format="html"}
::: {.callout-tip collapse="true" title="Comparación visual de los cuatro regímenes"}
```{ojs}
//| echo: false
//| label: comparacion-sliders
viewof comp_params_mk = Inputs.form({
m: Inputs.range([0.1, 5], {value: 1, step: 0.1, label: "m — masa (kg)"}),
k: Inputs.range([1, 100], {value: 25, step: 1, label: "k — rigidez (N/m)"})
}, {
template: (inputs) => html`
<div style="margin-bottom: 10px;">
<label style="font-weight: bold; display: block; margin-bottom: 6px; color: #2c3e50;">Parámetros del sistema</label>
<div style="display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; align-items: center;">
${inputs.m}
${inputs.k}
</div>
</div>
`
})
viewof comp_params_ic = Inputs.form({
x0: Inputs.range([0, 5], {value: 2, step: 0.1, label: "x₀ — posición inicial (m)"}),
v0: Inputs.range([-5, 5], {value: 0, step: 0.1, label: "v₀ — velocidad inicial (m/s)"})
}, {
template: (inputs) => html`
<div style="margin-bottom: 10px;">
<label style="font-weight: bold; display: block; margin-bottom: 6px; color: #2c3e50;">Condiciones iniciales</label>
<div style="display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; align-items: center;">
${inputs.x0}
${inputs.v0}
</div>
</div>
`
})
viewof t_max_comp_m = Inputs.range([5, 30], {
value: 15,
step: 1,
label: "Tiempo máximo (s)"
})
m_comp_m = comp_params_mk.m
k_comp_m = comp_params_mk.k
x0_comp_m = comp_params_ic.x0
v0_comp_m = comp_params_ic.v0
```
```{ojs}
//| echo: false
//| label: comparacion-calc
omega_0_comp = Math.sqrt(k_comp_m / m_comp_m);
gamma_critico = omega_0_comp;
gamma_sobre = omega_0_comp * 1.5;
gamma_sub = omega_0_comp * 0.3;
comparacion_muelle_data = {
const all_data = [];
const dt = 0.01;
const configs = [
{gamma: 0, label: "Sin amortiguamiento", color: "#28a745"},
{gamma: gamma_sub, label: "Subamortiguado", color: "#0066cc"},
{gamma: gamma_critico, label: "Críticamente amortiguado", color: "#fd7e14"},
{gamma: gamma_sobre, label: "Sobreamortiguado", color: "#dc3545"}
];
configs.forEach(config => {
const gamma = config.gamma;
const discriminante = gamma * gamma - omega_0_comp * omega_0_comp;
const eps = omega_0_comp * omega_0_comp * 0.001;
for (let t = 0; t <= t_max_comp_m; t += dt) {
let x;
if (discriminante > eps) {
// Sobreamortiguado
const r1 = -gamma + Math.sqrt(discriminante);
const r2 = -gamma - Math.sqrt(discriminante);
const c1 = (v0_comp_m - r2 * x0_comp_m) / (r1 - r2);
const c2 = x0_comp_m - c1;
x = c1 * Math.exp(r1 * t) + c2 * Math.exp(r2 * t);
}
else if (Math.abs(discriminante) <= eps) {
// Críticamente amortiguado
const c1 = x0_comp_m;
const c2 = v0_comp_m + gamma * x0_comp_m;
x = (c1 + c2 * t) * Math.exp(-gamma * t);
}
else if (gamma > omega_0_comp * 0.001) {
// Subamortiguado
const omega_d = Math.sqrt(-discriminante);
const A = Math.sqrt(x0_comp_m * x0_comp_m + Math.pow((v0_comp_m + gamma * x0_comp_m) / omega_d, 2));
const phi = Math.atan2(v0_comp_m + gamma * x0_comp_m, omega_d * x0_comp_m);
x = A * Math.exp(-gamma * t) * Math.cos(omega_d * t - phi);
}
else {
// Sin amortiguamiento
const A = Math.sqrt(x0_comp_m * x0_comp_m + (v0_comp_m / omega_0_comp) * (v0_comp_m / omega_0_comp));
const phi = Math.atan2(v0_comp_m, omega_0_comp * x0_comp_m);
x = A * Math.cos(omega_0_comp * t - phi);
}
all_data.push({t: t, x: x, regimen: config.label});
}
});
return all_data;
}
```
```{ojs}
//| echo: false
//| label: comparacion-plot
Plot.plot({
width: 700,
height: 450,
marginLeft: 60,
marginRight: 150,
grid: true,
x: {
label: "Tiempo t (s) →",
domain: [0, t_max_comp_m]
},
y: {
label: "↑ Posición x(t) (m)",
domain: [
Math.min(...comparacion_muelle_data.map(d => d.x)) * 1.2,
Math.max(...comparacion_muelle_data.map(d => d.x)) * 1.2
]
},
color: {
legend: true,
domain: ["Sin amortiguamiento", "Subamortiguado", "Críticamente amortiguado", "Sobreamortiguado"],
range: ["#28a745", "#0066cc", "#fd7e14", "#dc3545"]
},
marks: [
// Equilibrio
Plot.ruleY([0], {
stroke: "#666",
strokeWidth: 1,
strokeDasharray: "2,2"
}),
// Trayectorias
Plot.line(comparacion_muelle_data, {
x: "t",
y: "x",
stroke: "regimen",
strokeWidth: 2.5
}),
// Puntos iniciales
Plot.dot(comparacion_muelle_data.filter(d => d.t === 0), {
x: "t",
y: "x",
fill: "regimen",
r: 5
})
]
})
```
**Observaciones comparativas:**
- **Verde (sin amortiguamiento):** Oscilación armónica pura, amplitud constante
- **Azul (subamortiguado):** Oscilaciones con decaimiento exponencial
- **Naranja (crítico):** Retorno más rápido al equilibrio sin oscilación
- **Rojo (sobreamortiguado):** Retorno lento sin oscilación
El amortiguamiento crítico representa el **equilibrio óptimo** entre velocidad de retorno y ausencia de oscilaciones.
:::
:::
::: {.content-visible when-format="pdf"}
{{< include _interactive_note.qmd >}}
:::
## Aplicaciones prácticas
::: {.small}
| Régimen | Aplicación | Objetivo | Ejemplo |
|---------|-----------|----------|---------|
| **Sin amortiguamiento** | Sistemas ideales | Oscilación perfecta | Péndulo en el vacío, relojes de péndulo |
| **Subamortiguado** | Instrumentación | Lectura rápida con pocas oscilaciones | Sismógrafos, suspensiones de vehículos |
| **Críticamente amortiguado** | Control óptimo | Retorno rápido sin oscilación | Instrumentos de aguja, servomecanismos |
| **Sobreamortiguado** | Seguridad | Evitar oscilaciones completamente | Puertas con cierre automático, sistemas de seguridad |
:::
<!-- ## Animación
::: {.content-visible when-format="html"}
::: {.callout-tip collapse="true" title="Animación del sistema y proyección sobre la gráfica"}
```{ojs}
//| echo: false
//| label: muelle-anim-controls
viewof play_muelle_anim = Inputs.toggle({
label: "Reproducir",
value: true
})
viewof velocidad_muelle_anim = Inputs.range([0.25, 2.5], {
label: "Velocidad",
value: 1,
step: 0.05
})
viewof escala_muelle_anim = Inputs.range([0.5, 3], {
label: "Escala (multiplicador)",
value: 1,
step: 0.05
})
viewof t_muelle_anim = Inputs.range([0, t_max_muelle], {
label: "t (s) (útil al pausar)",
value: 0,
step: 0.01
})
```
```{ojs}
//| echo: false
//| label: muelle-anim-canvas
muelle_anim_canvas = {
// Depende de: muelle_data, t_max_muelle, tipo_regimen
// (definidos en la visualización interactiva anterior)
const width = 700;
const height = 520;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.maxWidth = "100%";
canvas.style.height = "auto";
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = true;
const colorForRegime = (reg) =>
reg === "Sobreamortiguado" ? "#dc3545" :
reg === "Críticamente amortiguado" ? "#fd7e14" :
reg === "Subamortiguado" ? "#0066cc" :
"#28a745";
const stroke = colorForRegime(tipo_regimen);
const dt = (muelle_data?.length ?? 0) > 1 ? (muelle_data[1].t - muelle_data[0].t) : 0.01;
const xMax = Math.max(...muelle_data.map(d => d.x));
const xMin = Math.min(...muelle_data.map(d => d.x));
// Layout
const simTop = 20;
const simHeight = 220;
const plotTop = simTop + simHeight + 30;
const plotHeight = 230;
const plotLeft = 70;
const plotRight = 30;
const plotBottom = 40;
// Mapeos para la gráfica
const tToX = (t) => plotLeft + (t / t_max_muelle) * (width - plotLeft - plotRight);
const yDomainMin = xMin * 1.2;
const yDomainMax = xMax * 1.2;
const pxPerMeterGraph = (plotHeight - plotBottom) / (yDomainMax - yDomainMin);
const xToY = (x) => {
const h = plotHeight - plotBottom;
return plotTop + h - ((x - yDomainMin) / (yDomainMax - yDomainMin)) * h;
};
// Geometría del sistema
const wallX = 70;
const eqX = 320;
const baseY = simTop + 110;
const massW = 70;
const massH = 50;
// Curva precalculada para dibujar (más rápido que recalcular solución)
const curve = muelle_data.map(d => ({
px: tToX(d.t),
py: xToY(d.x)
}));
let t = 0;
let last = performance.now();
let rafId = 0;
function valueAt(time) {
const i = Math.max(0, Math.min(muelle_data.length - 1, Math.round(time / dt)));
return muelle_data[i]?.x ?? 0;
}
function drawSpring(x1, y1, x2, y2, turns = 10) {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy);
const ux = dx / len;
const uy = dy / len;
const nx = -uy;
const ny = ux;
const amp = 8;
const pad = 12;
const startX = x1 + ux * pad;
const startY = y1 + uy * pad;
const endX = x2 - ux * pad;
const endY = y2 - uy * pad;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(startX, startY);
for (let k = 1; k <= turns; k++) {
const a = k / turns;
const px = startX + (endX - startX) * a;
const py = startY + (endY - startY) * a;
const s = (k % 2 === 0) ? -1 : 1;
ctx.lineTo(px + nx * amp * s, py + ny * amp * s);
}
ctx.lineTo(endX, endY);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function drawDamper(x1, y1, x2, y2) {
// Damper horizontal simple
const mid = (x1 + x2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(mid - 25, y1);
ctx.stroke();
// cuerpo
ctx.strokeRect(mid - 25, y1 - 10, 50, 20);
// pistón
ctx.beginPath();
ctx.moveTo(mid + 25, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function drawAxes() {
const x0 = plotLeft;
const y0 = plotTop + (plotHeight - plotBottom);
const x1 = width - plotRight;
const y1 = plotTop;
// marco
ctx.strokeStyle = "#ddd";
ctx.lineWidth = 1;
ctx.strokeRect(x0, y1, x1 - x0, y0 - y1);
// eje y=0 si cae dentro del dominio
if (0 >= yDomainMin && 0 <= yDomainMax) {
ctx.setLineDash([3, 3]);
ctx.strokeStyle = "#999";
ctx.beginPath();
ctx.moveTo(x0, xToY(0));
ctx.lineTo(x1, xToY(0));
ctx.stroke();
ctx.setLineDash([]);
}
// labels
ctx.fillStyle = "#333";
ctx.font = "12px sans-serif";
ctx.fillText("t (s)", x1 - 32, y0 + 26);
ctx.fillText("x(t)", x0 - 40, y1 + 12);
}
function drawCurve() {
ctx.strokeStyle = stroke;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < curve.length; i++) {
const p = curve[i];
if (i === 0) ctx.moveTo(p.px, p.py);
else ctx.lineTo(p.px, p.py);
}
ctx.stroke();
}
function drawProjection(time, xVal) {
const xPix = tToX(time);
const yPix = xToY(xVal);
const y0 = plotTop + (plotHeight - plotBottom);
const y1 = plotTop;
// línea vertical
ctx.setLineDash([4, 3]);
ctx.strokeStyle = "#666";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xPix, y1);
ctx.lineTo(xPix, y0);
ctx.stroke();
ctx.setLineDash([]);
// punto
ctx.fillStyle = stroke;
ctx.beginPath();
ctx.arc(xPix, yPix, 4.5, 0, 2 * Math.PI);
ctx.fill();
}
function drawSystem(xVal) {
// Escala alineada: 1 m (en gráfica) ~= pxPerMeterGraph píxeles
// `escala_muelle_anim` permite ajustar visualmente sin romper proporcionalidad.
let dispPx = xVal * pxPerMeterGraph * escala_muelle_anim;
// Clamp para que el bloque siempre sea visible.
const minMassCenter = wallX + 120;
const maxMassCenter = width - 60;
dispPx = Math.max(minMassCenter - eqX, Math.min(maxMassCenter - eqX, dispPx));
const massX = eqX + dispPx;
const massLeft = massX - massW / 2;
const massRight = massX + massW / 2;
// pared
ctx.strokeStyle = "#444";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(wallX, simTop + 35);
ctx.lineTo(wallX, simTop + simHeight - 35);
ctx.stroke();
// rayado pared
ctx.lineWidth = 1;
for (let y = simTop + 40; y <= simTop + simHeight - 40; y += 10) {
ctx.beginPath();
ctx.moveTo(wallX - 10, y - 6);
ctx.lineTo(wallX, y);
ctx.stroke();
}
// equilibrio
ctx.setLineDash([3, 3]);
ctx.strokeStyle = "#999";
ctx.beginPath();
ctx.moveTo(eqX, simTop + 25);
ctx.lineTo(eqX, simTop + simHeight - 25);
ctx.stroke();
ctx.setLineDash([]);
// resorte y amortiguador
ctx.strokeStyle = "#333";
ctx.lineWidth = 2;
drawSpring(wallX, baseY - 20, massLeft, baseY - 20);
drawDamper(wallX, baseY + 25, massLeft, baseY + 25);
// masa
ctx.fillStyle = "#f2f2f2";
ctx.strokeStyle = stroke;
ctx.lineWidth = 2;
ctx.fillRect(massLeft, baseY - massH / 2, massW, massH);
ctx.strokeRect(massLeft, baseY - massH / 2, massW, massH);
// guía
ctx.strokeStyle = "#bbb";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(wallX - 5, baseY);
ctx.lineTo(width - 30, baseY);
ctx.stroke();
}
function draw(time) {
const xVal = valueAt(time);
// fondo
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
// título + estado
ctx.fillStyle = "#111";
ctx.font = "13px sans-serif";
ctx.fillText(`Régimen: ${tipo_regimen}`, 14, 18);
ctx.fillStyle = "#333";
ctx.fillText(`t = ${time.toFixed(2)} s x(t) = ${xVal.toFixed(3)} m`, 14, 38);
// sistema
drawSystem(xVal);
// gráfica
drawAxes();
drawCurve();
drawProjection(time, xVal);
return xVal;
}
function frame(now) {
const dtSec = (now - last) / 1000;
last = now;
if (play_muelle_anim) {
t = (t + dtSec * velocidad_muelle_anim) % t_max_muelle;
} else {
t = t_muelle_anim;
}
draw(t);
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
invalidation.then(() => cancelAnimationFrame(rafId));
return canvas;
}
```
:::
:::
-->
## Animación
::: {.content-visible when-format="html"}
::: {.callout-tip collapse="false" title="Animación sincronizada con trazado progresivo"}
La masa se mueve (vertical) y su posición se proyecta en la gráfica de $x(t)$, que se dibuja solo hasta el instante actual.
```{ojs}
//| echo: false
//| label: muelle-sync-controls
viewof sync_params_mk = Inputs.form({
m: Inputs.range([0.1, 5], {value: 1, step: 0.05, label: "m — masa (kg)"}),
k: Inputs.range([1, 100], {value: 25, step: 1, label: "k — rigidez (N/m)"})
}, {
template: (inputs) => html`
<div style="margin-bottom: 10px;">
<label style="font-weight: bold; display: block; margin-bottom: 6px; color: #2c3e50;">Parámetros del sistema</label>
<div style="display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; align-items: center;">
${inputs.m}
${inputs.k}
</div>
</div>
`
})
viewof sync_params_cv = Inputs.form({
c: Inputs.range([0, 20], {value: 2, step: 0.1, label: "c — amortiguamiento (kg/s)"}),
v: Inputs.range([0.25, 3], {value: 1, step: 0.05, label: "Velocidad de animación"})
}, {
template: (inputs) => html`
<div style="display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; align-items: center; margin: 6px 0;">
${inputs.c}
${inputs.v}
</div>
`
})
viewof t_manual_muelle_sync = Inputs.range([0, t_max_muelle], {
value: 0,
step: 0.05,
label: "t (s) al pausar"
})
viewof muelle_sync_toggles = Inputs.form({
play: Inputs.toggle({label: "▶ Reproducir", value: true}),
trazo: Inputs.toggle({label: "Mostrar trazo acumulado", value: true})
}, {
template: (inputs) => html`
<div style="display: flex; gap: 32px; align-items: center; margin: 8px 0 12px;">
${inputs.play}
${inputs.trazo}
</div>
`
})
play_muelle_sync = muelle_sync_toggles.play
mostrar_trazo_muelle_sync = muelle_sync_toggles.trazo
m_sync = sync_params_mk.m
k_sync = sync_params_mk.k
c_sync = sync_params_cv.c
velocidad_muelle_sync = sync_params_cv.v
```
```{ojs}
//| echo: false
//| label: muelle-sync-calc
omega_0_sync = Math.sqrt(k_sync / m_sync);
gamma_sync = c_sync / (2 * m_sync);
discriminante_sync = gamma_sync * gamma_sync - omega_0_sync * omega_0_sync;
omega_d_sync = Math.sqrt(Math.abs(omega_0_sync * omega_0_sync - gamma_sync * gamma_sync));
eps_crit_sync = omega_0_sync * omega_0_sync * 0.001;
muelle_sync_data = {
const points = [];
const dt = 0.01;
for (let t = 0; t <= t_max_muelle; t += dt) {
let x;
if (discriminante_sync > eps_crit_sync) {
// Sobreamortiguado
const r1 = -gamma_sync + Math.sqrt(discriminante_sync);
const r2 = -gamma_sync - Math.sqrt(discriminante_sync);
const c1 = (v0_muelle - r2 * x0_muelle) / (r1 - r2);
const c2 = x0_muelle - c1;
x = c1 * Math.exp(r1 * t) + c2 * Math.exp(r2 * t);
}
else if (Math.abs(discriminante_sync) <= eps_crit_sync) {
// Crítico
const c1 = x0_muelle;
const c2 = v0_muelle + gamma_sync * x0_muelle;
x = (c1 + c2 * t) * Math.exp(-gamma_sync * t);
}
else if (gamma_sync > omega_0_sync * 0.001) {
// Subamortiguado
const A = Math.sqrt(x0_muelle * x0_muelle + Math.pow((v0_muelle + gamma_sync * x0_muelle) / omega_d_sync, 2));
const phi = Math.atan2(v0_muelle + gamma_sync * x0_muelle, omega_d_sync * x0_muelle);
x = A * Math.exp(-gamma_sync * t) * Math.cos(omega_d_sync * t - phi);
}
else {
// Sin amortiguamiento
const A = Math.sqrt(x0_muelle * x0_muelle + (v0_muelle / omega_0_sync) * (v0_muelle / omega_0_sync));
const phi = Math.atan2(v0_muelle, omega_0_sync * x0_muelle);
x = A * Math.cos(omega_0_sync * t - phi);
}
points.push({t: t, x: x});
}
return points;
}
```
```{ojs}
//| echo: false
//| label: muelle-sync-canvas
muelle_sync_canvas = {
// Usa muelle_sync_data (dependiente de m, k, c específicos)
const width = 840;
const height = 480;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.maxWidth = "100%";
canvas.style.height = "auto";
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = true;
const plot = {left: 280, right: 24, top: 32, bottom: 48};
const plotW = width - plot.left - plot.right;
const plotH = height - plot.top - plot.bottom;
const dt = muelle_sync_data.length > 1 ? (muelle_sync_data[1].t - muelle_sync_data[0].t) : 0.01;
const xMin = Math.min(...muelle_sync_data.map(d => d.x));
const xMax = Math.max(...muelle_sync_data.map(d => d.x));
const yDomainMin = xMin * 1.25;
const yDomainMax = xMax * 1.25;
const tToX = t => plot.left + (t / t_max_muelle) * plotW;
const yToPx = x => plot.top + plotH - ((x - yDomainMin) / (yDomainMax - yDomainMin)) * plotH;
const eqY = yToPx(0);
const wallX = 120;
const massW = 100;
const massH = 60;
const pxPerMeter = plotH / (yDomainMax - yDomainMin);
const floorY = Math.max(
eqY + massH * 0.6,
eqY - xMin * pxPerMeter + massH / 2 + 24
);
const curve = muelle_sync_data.map(d => ({
t: d.t,
px: tToX(d.t),
py: yToPx(d.x),
x: d.x
}));
function valueAt(time) {
const idx = Math.max(0, Math.min(curve.length - 1, Math.round(time / dt)));
return curve[idx];
}
function drawSpring(x1, y1, x2, y2, turns = 10) {
const len = Math.hypot(x2 - x1, y2 - y1);
const ux = (x2 - x1) / len;
const uy = (y2 - y1) / len;
const nx = -uy;
const ny = ux;
const amp = 10;
const pad = 14;
const sx = x1 + ux * pad;
const sy = y1 + uy * pad;
const ex = x2 - ux * pad;
const ey = y2 - uy * pad;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(sx, sy);
for (let k = 1; k <= turns; k++) {
const a = k / turns;
const px = sx + (ex - sx) * a;
const py = sy + (ey - sy) * a;
const s = (k % 2 === 0) ? -1 : 1;
ctx.lineTo(px + nx * amp * s, py + ny * amp * s);
}
ctx.lineTo(ex, ey);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function drawAxes() {
const x0 = plot.left;
const y0 = plot.top + plotH;
const x1 = plot.left + plotW;
const y1 = plot.top;
ctx.strokeStyle = "#e0e0e0";
ctx.lineWidth = 1;
ctx.strokeRect(x0, y1, plotW, plotH);
ctx.setLineDash([3, 3]);
ctx.strokeStyle = "#999";
ctx.beginPath();
ctx.moveTo(x0, eqY);
ctx.lineTo(x1, eqY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#333";
ctx.font = "12px sans-serif";
ctx.fillText("t (s)", x1 - 34, y0 + 28);
ctx.fillText("x(t)", x0 - 34, y1 + 10);
}
function drawPartialPath(time) {
const count = Math.max(1, Math.min(curve.length, Math.floor(time / dt) + 1));
if (mostrar_trazo_muelle_sync) {
ctx.strokeStyle = "#a0c4ff";
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < count; i++) {
const p = curve[i];
if (i === 0) ctx.moveTo(p.px, p.py);
else ctx.lineTo(p.px, p.py);
}
ctx.stroke();
}
// Guía tenue del resto de la curva
ctx.setLineDash([4, 3]);
ctx.strokeStyle = "#d0d0d0";
ctx.lineWidth = 1.2;
ctx.beginPath();
for (let i = count - 1; i < curve.length; i++) {
const p = curve[i];
if (i === count - 1) ctx.moveTo(p.px, p.py);
else ctx.lineTo(p.px, p.py);
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawDamperVertical(x, y1, y2) {
const mid = (y1 + y2) / 2;
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x, mid - 16);
ctx.stroke();
ctx.strokeRect(x - 10, mid - 16, 20, 32);
ctx.beginPath();
ctx.moveTo(x, mid + 16);
ctx.lineTo(x, y2);
ctx.stroke();
}
function drawSystem(state) {
const {x, px, py} = state;
const massCenterY = eqY - x * pxPerMeter;
const massCenterX = wallX + 80;
const massLeft = massCenterX - massW / 2;
const massTop = massCenterY - massH / 2;
const massBottom = massCenterY + massH / 2;
// suelo
ctx.strokeStyle = "#444";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(wallX - 40, floorY);
ctx.lineTo(plot.left - 20, floorY);
ctx.stroke();
// resorte vertical (anclado al suelo)
ctx.strokeStyle = "#555";
ctx.lineWidth = 2.3;
drawSpring(massCenterX - 30, floorY, massCenterX - 30, massBottom, 11);
// amortiguador vertical
ctx.strokeStyle = "#888";
ctx.lineWidth = 2;
drawDamperVertical(massCenterX + 30, floorY, massBottom);
// masa
ctx.fillStyle = "#c7e5ff";
ctx.strokeStyle = "#111";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(massLeft, massTop, massW, massH, 10);
ctx.fill();
ctx.stroke();
// etiqueta masa
ctx.fillStyle = "#111";
ctx.font = "16px serif";
ctx.fillText("m", massCenterX - 6, massCenterY + 6);
// guía de equilibrio
ctx.setLineDash([3, 3]);
ctx.strokeStyle = "#b0b0b0";
ctx.beginPath();
ctx.moveTo(wallX - 8, eqY);
ctx.lineTo(plot.left + plotW + 6, eqY);
ctx.stroke();
ctx.setLineDash([]);
// proyección hacia la gráfica
ctx.setLineDash([5, 4]);
ctx.strokeStyle = "#888";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(massCenterX + massW / 2 + 10, massCenterY);
ctx.lineTo(px - 8, massCenterY);
ctx.stroke();
ctx.setLineDash([]);
// punto en la gráfica
ctx.fillStyle = "#0066cc";
ctx.beginPath();
ctx.arc(px, py, 4.5, 0, 2 * Math.PI);
ctx.fill();
// línea vertical en la gráfica
ctx.setLineDash([4, 3]);
ctx.strokeStyle = "#666";
ctx.beginPath();
ctx.moveTo(px, plot.top);
ctx.lineTo(px, plot.top + plotH);
ctx.stroke();
ctx.setLineDash([]);
}
function draw(time) {
const state = valueAt(time);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#111";
ctx.font = "13px sans-serif";
ctx.fillText(`t = ${state.t.toFixed(2)} s`, 16, 22);
ctx.fillStyle = "#333";
ctx.fillText(`x(t) = ${state.x.toFixed(3)} m`, 16, 40);
drawSystem(state);
drawAxes();
drawPartialPath(time);
}
let t = 0;
let last = performance.now();
let rafId = 0;
function frame(now) {
const dtSec = (now - last) / 1000;
last = now;
if (play_muelle_sync) {
t += dtSec * velocidad_muelle_sync;
if (t > t_max_muelle) t = 0;
} else {
t = Math.min(t_manual_muelle_sync, t_max_muelle);
}
draw(t);
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
invalidation.then(() => cancelAnimationFrame(rafId));
return canvas;
}
```
:::
:::
::: {.content-visible when-format="pdf"}
{{< include _interactive_note.qmd >}}
:::
{{< include footer.qmd >}}