3
js
(function () {
const canvas = document.getElementById("plane");
const ctx = canvas.getContext("2d");
const readout = document.getElementById("readout");
const angleValueEl = document.getElementById("angleValue");
let cx = 0;
let cy = 0;
let radius = 0;
let dpr = 1;
let pointerInside = false;
let px = 0;
let py = 0;
function resize() {
const wrap = canvas.parentElement;
const w = wrap.clientWidth;
const h = wrap.clientHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = w + "px";
canvas.style.height = h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cx = w / 2;
cy = h / 2;
radius = Math.min(w, h) * 0.42;
draw();
}
/** Ángulo en grados [0,360) desde +X, antihorario (convención matemática). */
function angleFromPositiveX(x, y) {
const dx = x - cx;
const dy = y - cy;
let deg = (Math.atan2(-dy, dx) * 180) / Math.PI;
if (deg < 0) deg += 360;
if (deg >= 360) deg -= 360;
return deg;
}
function isInsideCircle(x, y) {
const dx = x - cx;
const dy = y - cy;
return dx * dx + dy * dy <= radius * radius;
}
function drawAxes() {
const margin = 8;
ctx.strokeStyle = "rgba(26, 29, 36, 0.35)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin, cy);
ctx.lineTo(canvas.width / dpr - margin, cy);
ctx.moveTo(cx, margin);
ctx.lineTo(cx, canvas.height / dpr - margin);
ctx.stroke();
ctx.fillStyle = "rgba(107, 114, 128, 0.9)";
ctx.font = "600 11px DM Sans, system-ui, sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText("x", canvas.width / dpr - margin - 14, cy + 6);
ctx.textAlign = "left";
ctx.fillText("y", cx + 6, margin + 2);
}
function drawProtractor() {
const rOuter = radius;
const rBand = radius * 0.88;
const rTick10 = radius * 0.82;
const rTick5 = radius * 0.86;
const rTick1 = radius * 0.84;
const rLabel = radius * 0.94;
ctx.strokeStyle = "#2d3340";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, rOuter, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "rgba(45, 51, 64, 0.45)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, rBand, 0, Math.PI * 2);
ctx.stroke();
for (let n = 0; n < 360; n++) {
const rad = (n * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const xo = cx + rOuter * cos;
const yo = cy - rOuter * sin;
let rInner;
let lw;
if (n % 10 === 0) {
rInner = rTick10;
lw = 1.5;
} else if (n % 5 === 0) {
rInner = rTick5;
lw = 1.2;
} else {
rInner = rTick1;
lw = 1;
}
const xi = cx + rInner * cos;
const yi = cy - rInner * sin;
ctx.strokeStyle = n % 10 === 0 ? "#2d3340" : "rgba(45, 51, 64, 0.55)";
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(xi, yi);
ctx.lineTo(xo, yo);
ctx.stroke();
}
ctx.fillStyle = "#2d3340";
ctx.font = "600 12px DM Sans, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let n = 0; n < 360; n += 30) {
const rad = (n * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const lx = cx + rLabel * cos;
const ly = cy - rLabel * sin;
ctx.fillText(String(n), lx, ly);
}
}
function drawGuide() {
if (!pointerInside) return;
ctx.strokeStyle = "#0d9488";
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.shadowColor = "rgba(13, 148, 136, 0.45)";
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(px, py);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = "#0d9488";
ctx.beginPath();
ctx.arc(px, py, 5, 0, Math.PI * 2);
ctx.fill();
}
function draw() {
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.clearRect(0, 0, w, h);
drawAxes();
drawProtractor();
drawGuide();
}
function setReadout(degrees, active) {
if (active) {
readout.classList.remove("idle");
angleValueEl.textContent = degrees.toFixed(2);
} else {
readout.classList.add("idle");
angleValueEl.textContent = "—";
}
}
function canvasCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
}
function applyPointer(clientX, clientY) {
const { x, y } = canvasCoords(clientX, clientY);
px = x;
py = y;
pointerInside = isInsideCircle(x, y);
if (pointerInside) {
setReadout(angleFromPositiveX(x, y), true);
} else {
setReadout(0, false);
}
draw();
}
function isTouchLike(pointerType) {
return pointerType === "touch" || pointerType === "pen";
}
canvas.addEventListener(
"pointerdown",
function (e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
if (isTouchLike(e.pointerType)) {
try {
canvas.setPointerCapture(e.pointerId);
} catch (_) {}
e.preventDefault();
}
applyPointer(e.clientX, e.clientY);
},
{ passive: false }
);
canvas.addEventListener("pointermove", function (e) {
applyPointer(e.clientX, e.clientY);
});
canvas.addEventListener("pointerup", function (e) {
if (canvas.hasPointerCapture(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId);
}
if (isTouchLike(e.pointerType)) {
pointerInside = false;
setReadout(0, false);
draw();
} else {
applyPointer(e.clientX, e.clientY);
}
});
canvas.addEventListener("pointercancel", function (e) {
if (canvas.hasPointerCapture(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId);
}
pointerInside = false;
setReadout(0, false);
draw();
});
canvas.addEventListener("pointerleave", function (e) {
if (!canvas.hasPointerCapture(e.pointerId)) {
pointerInside = false;
setReadout(0, false);
draw();
}
});
window.addEventListener("resize", resize);
resize();
})();
Copiar