2026-07-28 15:29:37 -06:00
|
|
|
import { useMemo } from 'react'
|
2026-07-25 01:35:36 -06:00
|
|
|
import type { Categoria } from '../../../types'
|
|
|
|
|
|
|
|
|
|
interface RuletaSVGProps {
|
|
|
|
|
categorias: Categoria[]
|
|
|
|
|
rotacion: number
|
|
|
|
|
isSpinning: boolean
|
|
|
|
|
onClick: () => void
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:29:37 -06:00
|
|
|
/* ─── 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
|
|
|
|
|
]
|
2026-07-25 01:35:36 -06:00
|
|
|
|
2026-07-28 15:29:37 -06:00
|
|
|
/* ─── 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),
|
2026-07-25 01:35:36 -06:00
|
|
|
}
|
2026-07-28 15:29:37 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ─── 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 angleStep = 360 / TOTAL_SEGMENTS
|
|
|
|
|
|
|
|
|
|
/* 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 })
|
|
|
|
|
}
|
|
|
|
|
return dots
|
|
|
|
|
}, [])
|
2026-07-25 01:35:36 -06:00
|
|
|
|
|
|
|
|
return (
|
2026-07-28 15:29:37 -06:00
|
|
|
<div className="relative flex flex-col items-center justify-center w-full select-none">
|
2026-07-25 01:35:36 -06:00
|
|
|
|
2026-07-28 15:29:37 -06:00
|
|
|
{/* ─── 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)]"
|
|
|
|
|
/>
|
2026-07-25 01:35:36 -06:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-28 15:29:37 -06:00
|
|
|
{/* ─── SVG principal (ruleta + anillo + soporte) ─── */}
|
2026-07-25 01:35:36 -06:00
|
|
|
<svg
|
2026-07-28 15:29:37 -06:00
|
|
|
viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}
|
2026-07-25 01:35:36 -06:00
|
|
|
onClick={!isSpinning ? onClick : undefined}
|
2026-07-28 15:29:37 -06:00
|
|
|
className={`w-[90%] h-auto select-none drop-shadow-[0_10px_25px_rgba(0,0,0,0.4)] ${!isSpinning ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
2026-07-25 01:35:36 -06:00
|
|
|
>
|
2026-07-28 15:29:37 -06:00
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{/* ══════ 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]
|
|
|
|
|
|
|
|
|
|
// 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>
|
|
|
|
|
))}
|
2026-07-25 01:35:36 -06:00
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|