Editor
Vista previa
Biblioteca de bloques
Tip: Editor con pestañas HTML, CSS y JS. El código se guarda solo mientras la sesión está abierta (si cierras el navegador o la pestaña, se borra). Al recargar la página en la misma sesión sí se mantiene. Atajos en HTML: ! + Tab, bs5 + Tab.
1 html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Transportador cartesiano</title>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <header>
    <h1>Transportador cartesiano</h1>
    <p>Ángulo respecto al eje X positivo (0°–360°, antihorario)</p>
  </header>
  <main>
    <div class="canvas-wrap">
      <canvas id="plane" aria-label="Plano cartesiano con transportador"></canvas>
    </div>
    <div class="readout idle" id="readout" role="status" aria-live="polite">
      <span class="label">Ángulo</span>
      <span class="value" id="angleValue">—</span>
      <span class="unit">°</span>
    </div>
  </main>
  <script src="app.js"></script>
</body>
</html>
2 css
:root {
  --bg: #f4f5f7;
  --panel: #ffffff;
  --text: #1a1d24;
  --muted: #6b7280;
  --accent: #0d9488;
  --accent-glow: rgba(13, 148, 136, 0.25);
  --shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
  --radius: 16px;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  height: 100%;
  overflow: hidden;
}

html,
body {
  font-family: "DM Sans", system-ui, -apple-system, sans-serif;
  background: var(--bg);
  color: var(--text);
}

body {
  display: flex;
  flex-direction: column;
  height: 100dvh;
  max-height: 100dvh;
  min-height: 100dvh;
  overflow: hidden;
  padding-bottom: env(safe-area-inset-bottom, 0);
}

header {
  flex-shrink: 0;
  padding: 0.65rem 1rem;
  text-align: center;
  border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  background: var(--panel);
}

header h1 {
  font-size: clamp(1rem, 2.8vw, 1.125rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.25;
}

header p {
  font-size: clamp(0.7rem, 2vw, 0.8125rem);
  color: var(--muted);
  margin-top: 0.15rem;
  line-height: 1.3;
}

main {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 0.5rem 0.75rem 0.6rem;
  gap: 0.5rem;
}

.canvas-wrap {
  position: relative;
  flex: 0 1 auto;
  min-width: 0;
  min-height: 0;
  /* Lado del cuadrado: cabe en ancho y no consume toda la altura (cabecera + ángulo + márgenes) */
  --layout-chrome: clamp(7.5rem, 22vh, 10rem);
  width: min(100%, 92vw, 720px, calc(100dvh - var(--layout-chrome)));
  aspect-ratio: 1;
  max-height: calc(100dvh - var(--layout-chrome));
  border-radius: var(--radius);
  background: var(--panel);
  box-shadow: var(--shadow);
  overflow: hidden;
}

#plane {
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
  touch-action: none;
}

.readout {
  flex-shrink: 0;
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.55rem 1.25rem;
  background: var(--panel);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  min-width: 200px;
}

.readout .label {
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
}

.readout .value {
  font-variant-numeric: tabular-nums;
  font-size: clamp(1.35rem, 4.5vw, 1.75rem);
  font-weight: 700;
  color: var(--accent);
  text-shadow: 0 0 24px var(--accent-glow);
}

.readout .unit {
  font-size: 1rem;
  font-weight: 600;
  color: var(--muted);
}

.readout.idle .value {
  color: var(--muted);
  text-shadow: none;
}
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();
})();