feat: Rediseño completo de UI, SCORM y ajustes de experiencia
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Categoria } from '../../../types'
|
||||
|
||||
interface RuletaSVGProps {
|
||||
@@ -7,92 +8,232 @@ interface RuletaSVGProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/* ─── Constantes geométricas ──────────────────── */
|
||||
const CX = 250
|
||||
const CY = 265
|
||||
const SEGMENT_R = 195
|
||||
const RING_R = 207
|
||||
const RING_STROKE = 26
|
||||
const CENTER_R = 28
|
||||
const VIEWBOX_W = 500
|
||||
const VIEWBOX_H = 550
|
||||
const TOTAL_SEGMENTS = 4
|
||||
|
||||
/* ─── Estilos visuales por segmento ───────────── */
|
||||
interface SegmentStyle {
|
||||
bg: string
|
||||
textColor: string
|
||||
iconColor: string
|
||||
}
|
||||
|
||||
const SEGMENT_STYLES: SegmentStyle[] = [
|
||||
{ bg: '#DC2626', textColor: '#FFFFFF', iconColor: '#FFFFFF' }, // Agua — rojo
|
||||
{ bg: '#D1D5DB', textColor: '#1F2937', iconColor: '#374151' }, // Residuos — gris claro
|
||||
{ bg: '#374151', textColor: '#FFFFFF', iconColor: '#FBBF24' }, // Energía — gris oscuro
|
||||
{ bg: '#F9FAFB', textColor: '#1F2937', iconColor: '#2563EB' }, // Electromovilidad — blanco
|
||||
]
|
||||
|
||||
/* ─── Utilidad: polar → cartesiano ────────────── */
|
||||
function polarToCartesian(cx: number, cy: number, angle: number, radius: number) {
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
return {
|
||||
x: cx + radius * Math.cos(rad),
|
||||
y: cy + radius * Math.sin(rad),
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Path de sector circular ─────────────────── */
|
||||
function sectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
||||
const start = polarToCartesian(cx, cy, startAngle, r)
|
||||
const end = polarToCartesian(cx, cy, endAngle, r)
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0
|
||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
COMPONENTE PRINCIPAL
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }: RuletaSVGProps) {
|
||||
const cx = 200
|
||||
const cy = 200
|
||||
const r = 180
|
||||
const total = categorias.length
|
||||
const angleStep = 360 / total
|
||||
const angleStep = 360 / TOTAL_SEGMENTS
|
||||
|
||||
function polarToCartesian(angle: number, radius: number) {
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
return {
|
||||
x: cx + radius * Math.cos(rad),
|
||||
y: cy + radius * Math.sin(rad),
|
||||
/* Puntos decorativos del anillo exterior */
|
||||
const decorElements = useMemo(() => {
|
||||
const dots: { x: number; y: number; isStar: boolean }[] = []
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const angle = (i * 15)
|
||||
const pos = polarToCartesian(CX, CY, angle, RING_R)
|
||||
dots.push({ x: pos.x, y: pos.y, isStar: i % 3 === 0 })
|
||||
}
|
||||
}
|
||||
|
||||
function sectorPath(startAngle: number, endAngle: number) {
|
||||
const start = polarToCartesian(startAngle, r)
|
||||
const end = polarToCartesian(endAngle, r)
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0
|
||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||
}
|
||||
return dots
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="relative flex flex-col items-center justify-center w-full select-none">
|
||||
|
||||
{/* Indicador */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2 z-10">
|
||||
<div className="w-0 h-0 border-l-[12px] border-r-[12px] border-t-[24px] border-l-transparent border-r-transparent border-t-gray-800 drop-shadow-md" />
|
||||
{/* ─── Puntero (fuera del SVG, fijo arriba) ─── */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-20" style={{ marginTop: '35px' }}>
|
||||
<img
|
||||
src="./img/indicador.png"
|
||||
alt="Indicador"
|
||||
style={{ width: '86px', height: '96px' }}
|
||||
className="object-contain drop-shadow-[0_4px_6px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ruleta SVG */}
|
||||
{/* ─── SVG principal (ruleta + anillo + soporte) ─── */}
|
||||
<svg
|
||||
width="400"
|
||||
height="400"
|
||||
viewBox="0 0 400 400"
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}
|
||||
onClick={!isSpinning ? onClick : undefined}
|
||||
className={`select-none drop-shadow-xl ${!isSpinning ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
style={{
|
||||
transition: 'transform 4s cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
transform: `rotate(${rotacion}deg)`,
|
||||
}}
|
||||
className={`w-[90%] h-auto select-none drop-shadow-[0_10px_25px_rgba(0,0,0,0.4)] ${!isSpinning ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
>
|
||||
{categorias.map((cat, i) => {
|
||||
const startAngle = i * angleStep
|
||||
const endAngle = startAngle + angleStep
|
||||
const midAngle = startAngle + angleStep / 2
|
||||
const textPos = polarToCartesian(midAngle, r * 0.62)
|
||||
<defs>
|
||||
{/* Sombra del anillo */}
|
||||
<filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="0" dy="3" stdDeviation="6" floodColor="#000" floodOpacity="0.35" />
|
||||
</filter>
|
||||
{/* Sombra del botón central */}
|
||||
<filter id="centerShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#000" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
return (
|
||||
<g key={cat.id}>
|
||||
{/* Sector */}
|
||||
<path
|
||||
d={sectorPath(startAngle, endAngle)}
|
||||
fill={cat.color}
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{/* Emoji */}
|
||||
<text
|
||||
x={textPos.x}
|
||||
y={textPos.y - 14}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="28"
|
||||
>
|
||||
{cat.emoji}
|
||||
</text>
|
||||
{/* Nombre */}
|
||||
<text
|
||||
x={textPos.x}
|
||||
y={textPos.y + 16}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="13"
|
||||
fontWeight="bold"
|
||||
fill="white"
|
||||
>
|
||||
{cat.nombre}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* ══════ GRUPO GIRATORIO: Segmentos + centro ══════ */}
|
||||
<g
|
||||
style={{
|
||||
transformOrigin: `${CX}px ${CY}px`,
|
||||
transform: `rotate(${rotacion}deg)`,
|
||||
transition: 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)',
|
||||
}}
|
||||
>
|
||||
{categorias.slice(0, TOTAL_SEGMENTS).map((cat, i) => {
|
||||
const startAngle = i * angleStep
|
||||
const endAngle = startAngle + angleStep
|
||||
const midAngle = startAngle + angleStep / 2
|
||||
const style = SEGMENT_STYLES[i] ?? SEGMENT_STYLES[0]
|
||||
|
||||
{/* Centro */}
|
||||
<circle cx={cx} cy={cy} r={22} fill="white" stroke="#e5e7eb" strokeWidth="3" />
|
||||
<circle cx={cx} cy={cy} r={10} fill="#7C3AED" />
|
||||
// Posición de la imagen (~58% del radio)
|
||||
const imgCenter = polarToCartesian(CX, CY, midAngle, SEGMENT_R * 0.58)
|
||||
// Posición del texto: radio y offset por categoría
|
||||
const textRadius = i === 0 ? SEGMENT_R * 0.58 : SEGMENT_R * 0.84
|
||||
const textPosRaw = polarToCartesian(CX, CY, midAngle, textRadius)
|
||||
const textOffsetX = i === 0 ? 0 : i === 1 ? -40 : i === 2 ? 30 : i === 3 ? 38 : 0
|
||||
const textOffsetY = i === 0 ? -45 : i === 1 ? 10 : i === 3 ? -12 : 0
|
||||
const textPos = { x: textPosRaw.x + textOffsetX, y: textPosRaw.y + textOffsetY }
|
||||
const imgSize = 60
|
||||
|
||||
/* Imágenes por segmento */
|
||||
const imgs = ['agua.png', 'residuos.png', 'energia.png', 'movilidad.png']
|
||||
|
||||
/* Determinar si el texto es multilínea */
|
||||
const esMultilinea = cat.nombre.length > 20
|
||||
const lineas = esMultilinea ? cat.nombre.split(' y ') : [cat.nombre]
|
||||
return (
|
||||
<g key={cat.id}>
|
||||
{/* Sector de color base */}
|
||||
<path
|
||||
d={sectorPath(CX, CY, SEGMENT_R, startAngle, endAngle)}
|
||||
fill={style.bg}
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
|
||||
{/* Imagen PNG (arriba, más cerca del centro) */}
|
||||
<image
|
||||
href={`./img/${imgs[i]}`}
|
||||
x={imgCenter.x - imgSize / 2}
|
||||
y={imgCenter.y - imgSize / 2}
|
||||
width={imgSize}
|
||||
height={imgSize}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
|
||||
{/* Texto del nombre (debajo de la imagen) */}
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={style.textColor}
|
||||
fontWeight="800"
|
||||
fontSize={esMultilinea ? '11' : '14'}
|
||||
letterSpacing="0.5"
|
||||
>
|
||||
{lineas.map((linea, j) => (
|
||||
<tspan
|
||||
key={j}
|
||||
x={textPos.x}
|
||||
y={textPos.y}
|
||||
dy={j === 0 ? (esMultilinea ? -7 : 0) : 15}
|
||||
>
|
||||
{linea}{j < lineas.length - 1 ? ' y' : ''}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ─── Botón central ─── */}
|
||||
<g>
|
||||
{/* Círculo exterior (borde rojo) */}
|
||||
<circle cx={CX} cy={CY} r={CENTER_R + 6} fill="#DC2626" filter="url(#centerShadow)" />
|
||||
{/* Círculo blanco */}
|
||||
<circle cx={CX} cy={CY} r={CENTER_R} fill="#FFFFFF" />
|
||||
{/* Logo TIP */}
|
||||
<image
|
||||
href="./img/logo-solo.png"
|
||||
x={CX - 18}
|
||||
y={CY - 18}
|
||||
width="36"
|
||||
height="36"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* ══════ ANILLO EXTERIOR (estático) ══════ */}
|
||||
<g filter="url(#ringShadow)">
|
||||
<circle
|
||||
cx={CX} cy={CY} r={RING_R}
|
||||
fill="none"
|
||||
stroke="#2D2D2D"
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
{/* Borde interior del anillo */}
|
||||
<circle
|
||||
cx={CX} cy={CY} r={RING_R - RING_STROKE / 2 + 1}
|
||||
fill="none"
|
||||
stroke="#1F2937"
|
||||
strokeWidth="2"
|
||||
opacity="0.6"
|
||||
/>
|
||||
{/* Borde exterior del anillo */}
|
||||
<circle
|
||||
cx={CX} cy={CY} r={RING_R + RING_STROKE / 2 - 1}
|
||||
fill="none"
|
||||
stroke="#1F2937"
|
||||
strokeWidth="2"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* ══════ ELEMENTOS DECORATIVOS DEL ANILLO ══════ */}
|
||||
{decorElements.map((d, i) => (
|
||||
<g key={i}>
|
||||
{d.isStar ? (
|
||||
/* Estrella de 4 puntas */
|
||||
<g transform={`translate(${d.x}, ${d.y})`}>
|
||||
<path
|
||||
d="M0,-7 L1.5,-2 L7,0 L1.5,2 L0,7 L-1.5,2 L-7,0 L-1.5,-2 Z"
|
||||
fill="#F9FAFB"
|
||||
opacity="0.85"
|
||||
/>
|
||||
</g>
|
||||
) : (
|
||||
/* Círculo decorativo */
|
||||
<circle cx={d.x} cy={d.y} r="3.5" fill="#9CA3AF" opacity="0.7" />
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user