"use client"; import { useMemo, useRef, useState } from "react"; import type { PuntoSerie } from "@/lib/crm/tipos"; import { entero, fecha, fechaCorta } from "@/components/admin/formato"; import { EstadoVacio } from "@/components/admin/ui"; import { COLOR_GANADAS, COLOR_OPORTUNIDADES, COLOR_RETICULA, COLOR_SUPERFICIE, } from "@/components/admin/graficas/paleta"; /** * Serie de tiempo de oportunidades y ganadas. SVG escrito a mano, sin librerías. * * La geometría vive en un `viewBox` de 0-100 en los dos ejes con * `preserveAspectRatio="none"`: así el trazo se estira con el contenedor sin * romperse —los trazos llevan `vector-effect="non-scaling-stroke"`, de modo que * 2 px siguen siendo 2 px— y todo el texto es HTML real, que no se encoge a * tamaños ilegibles en un teléfono de 320 px. * * Accesible: `role="img"` con un resumen del dato, `
` con las * cifras clave y una tabla equivalente para lector de pantalla. La capa de * cruceta y globo enriquece; nunca es el único camino al número. */ const ALTO_TRAZO = 2; function maximoLimpio(v: number): number { if (v <= 4) return 4; const magnitud = Math.pow(10, Math.floor(Math.log10(v))); for (const paso of [1, 2, 2.5, 5, 10]) { const candidato = paso * magnitud; if (candidato >= v) return candidato; } return 10 * magnitud; } export function SerieTiempo({ serie, dias }: { serie: PuntoSerie[]; dias: number }) { const cajaRef = useRef(null); const [activo, setActivo] = useState(null); const modelo = useMemo(() => { const maxDato = serie.reduce((m, p) => Math.max(m, p.oportunidades, p.ganadas), 0); const max = maximoLimpio(maxDato); const n = serie.length; const x = (i: number) => (n <= 1 ? 50 : (i / (n - 1)) * 100); const y = (v: number) => 100 - (v / max) * 100; return { max, x, y, totalOportunidades: serie.reduce((s, p) => s + p.oportunidades, 0), totalGanadas: serie.reduce((s, p) => s + p.ganadas, 0), pico: serie.reduce( (mejor, p) => (p.oportunidades > mejor.oportunidades ? p : mejor), serie[0] ?? { fecha: "", oportunidades: 0, ganadas: 0 }, ), }; }, [serie]); if (serie.length === 0) { return ( ); } const { max, x, y, totalOportunidades, totalGanadas, pico } = modelo; const lineaOportunidades = serie .map((p, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(3)},${y(p.oportunidades).toFixed(3)}`) .join(" "); const areaOportunidades = `${lineaOportunidades} L${x(serie.length - 1).toFixed(3)},100 L${x(0).toFixed(3)},100 Z`; const lineaGanadas = serie .map((p, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(3)},${y(p.ganadas).toFixed(3)}`) .join(" "); const resumen = `Serie de ${dias} días: ${entero(totalOportunidades)} oportunidades y ${entero( totalGanadas, )} ganadas. El día más alto fue ${fecha(pico.fecha)} con ${entero(pico.oportunidades)} oportunidades.`; const marcas = [0, 0.25, 0.5, 0.75, 1]; const indicesX = serie.length <= 4 ? serie.map((_, i) => i) : [0, Math.round((serie.length - 1) / 2), serie.length - 1]; function alMover(e: React.PointerEvent) { const caja = cajaRef.current; if (!caja || serie.length === 0) return; const r = caja.getBoundingClientRect(); if (r.width === 0) return; const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); setActivo(Math.round(frac * (serie.length - 1))); } function alTecla(e: React.KeyboardEvent) { if (e.key === "ArrowRight" || e.key === "ArrowLeft") { e.preventDefault(); const base = activo ?? (e.key === "ArrowRight" ? -1 : serie.length); const siguiente = Math.min( serie.length - 1, Math.max(0, base + (e.key === "ArrowRight" ? 1 : -1)), ); setActivo(siguiente); } if (e.key === "Escape") setActivo(null); } const punto = activo != null ? serie[activo] : null; return (
setActivo(null)} onKeyDown={alTecla} onBlur={() => setActivo(null)} className="relative h-44 w-full min-w-0 cursor-crosshair rounded-[2px] sm:h-56" style={{ WebkitTapHighlightColor: "transparent", touchAction: "pan-y" }} > {/* rótulos del eje Y, en el canal reservado por el padre */} {marcas.map((m) => ( ))} {/* puntas y punto activo: HTML, para que el círculo no se deforme */} {activo != null && punto ? ( <> ) : null}
{/* eje X */}
{indicesX.map((i) => ( ))}
{/* globo: el valor manda, el nombre de la serie acompaña */} {punto ? ( ) : null}
{entero(totalOportunidades)} oportunidades y {entero(totalGanadas)} ganadas en{" "} {dias} días. Día más alto: {fecha(pico.fecha)} con {entero(pico.oportunidades)}.
{serie.map((p) => ( ))}
Oportunidades y ganadas por día
Día Oportunidades Ganadas
{fecha(p.fecha)} {entero(p.oportunidades)} {entero(p.ganadas)}
); } function Punto({ izquierda, arriba, color, }: { izquierda: number; arriba: number; color: string; }) { return (