mejoras UI/UX leaderboard final
All checks were successful
build-and-deploy / build (push) Successful in 19s
build-and-deploy / deploy (push) Successful in 11s

This commit is contained in:
2025-08-29 16:55:38 -06:00
parent b07947b9d3
commit a5f1ba669a
2 changed files with 208 additions and 16 deletions

View File

@@ -96,15 +96,20 @@
</defs> </defs>
<circle :cx="center" :cy="center" :r="outerR" class="pie-base" /> <circle :cx="center" :cy="center" :r="outerR" class="pie-base" />
<g filter="url(#pieShadow)"> <g filter="url(#pieShadow)">
<path v-for="(seg, i) in currentPieSegments" :key="i" <transition-group name="pie-transition">
:d="describeArc(center, center, sliceR, seg.startAngle, seg.endAngle)" <path v-for="(seg, i) in animatedPieSegments"
:fill="EVENT_STYLES[seg.action]?.color || '#94a3b8'" :key="`${seg.action}-${i}`"
class="pie-slice" /> :d="describeArc(center, center, sliceR, seg.animatedStart, seg.animatedEnd)"
:fill="EVENT_STYLES[seg.action]?.color || '#94a3b8'"
class="pie-slice" />
</transition-group>
</g> </g>
<circle :cx="center" :cy="center" :r="innerR" class="pie-hole" /> <circle :cx="center" :cy="center" :r="innerR" class="pie-hole" />
<g class="pie-center"> <g class="pie-center">
<text :x="center" :y="center - 4" text-anchor="middle" class="pie-center-title">{{ currentPie?.name || '' }}</text> <text v-if="currentPieSegments.length === 0" :x="center" :y="center - 4" text-anchor="middle" class="pie-center-title">Sin datos</text>
<text :x="center" :y="center + 16" text-anchor="middle" class="pie-center-sub">{{ selectedPlayerUuid ? 'Jugador' : 'Global' }}</text> <text v-else :x="center" :y="center - 4" text-anchor="middle" class="pie-center-title">{{ currentPie?.name || '' }}</text>
<text v-if="currentPieSegments.length === 0" :x="center" :y="center + 16" text-anchor="middle" class="pie-center-sub">No hay eventos</text>
<text v-else :x="center" :y="center + 16" text-anchor="middle" class="pie-center-sub">{{ selectedPlayerUuid ? 'Jugador' : 'Global' }}</text>
</g> </g>
</svg> </svg>
</div> </div>
@@ -126,7 +131,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed, watch, watchEffect } from 'vue';
interface Props { interface Props {
filteredData?: { filteredData?: {
@@ -370,16 +375,140 @@ const slideTitle = computed(() => currentSlide.value === 0 ? 'Proporciones' : (c
const currentPieSegments = computed(() => { const currentPieSegments = computed(() => {
const segments: { startAngle: number; endAngle: number; action: string }[] = []; const segments: { startAngle: number; endAngle: number; action: string }[] = [];
if (!currentPie.value) return segments; if (!currentPie.value) return segments;
// Check if all values are 0
const hasAnyValue = currentPie.value.values.some((v: number) => v > 0);
if (!hasAnyValue) {
// If all values are 0, return empty segments (will show "No hay datos" message)
return segments;
}
// Filter out zero values for pie chart
const nonZeroIndices: number[] = [];
const nonZeroPercentages: number[] = [];
currentPie.value.percentages.forEach((pct: number, idx: number) => {
if (pct > 0) {
nonZeroIndices.push(idx);
nonZeroPercentages.push(pct);
}
});
// If only one value is non-zero, show full circle
if (nonZeroPercentages.length === 1) {
const idx = nonZeroIndices[0];
segments.push({
startAngle: -90,
endAngle: 270, // Full circle
action: currentPie.value!.actions[idx]
});
return segments;
}
// Normal case: multiple non-zero values
let angle = -90; // start at 12 o'clock let angle = -90; // start at 12 o'clock
(currentPie.value.percentages as number[]).forEach((pct, idx) => { nonZeroIndices.forEach((originalIdx, i) => {
const pct = nonZeroPercentages[i];
const span = (pct / 100) * 360; const span = (pct / 100) * 360;
const seg = { startAngle: angle, endAngle: angle + span, action: currentPie.value!.actions[idx] }; const seg = {
startAngle: angle,
endAngle: angle + span,
action: currentPie.value!.actions[originalIdx]
};
segments.push(seg); segments.push(seg);
angle += span; angle += span;
}); });
return segments; return segments;
}); });
// Animated pie segments
const animatedPieSegments = ref<Array<{
startAngle: number;
endAngle: number;
animatedStart: number;
animatedEnd: number;
action: string;
}>>([]);
// Animation frame ID for cleanup
let animationFrameId: number | null = null;
// Animate pie segments smoothly
watchEffect(() => {
const targetSegments = currentPieSegments.value;
// Initialize animated segments if empty
if (animatedPieSegments.value.length === 0 && targetSegments.length > 0) {
animatedPieSegments.value = targetSegments.map(seg => ({
...seg,
animatedStart: seg.startAngle,
animatedEnd: seg.startAngle // Start with 0 width for initial animation
}));
}
// Cancel previous animation
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
// Animate to target positions
const animate = () => {
let needsAnimation = false;
// Update or add segments
targetSegments.forEach((target, i) => {
let animated = animatedPieSegments.value.find(a => a.action === target.action);
if (!animated) {
// New segment - add it
animatedPieSegments.value.push({
...target,
animatedStart: target.startAngle,
animatedEnd: target.startAngle
});
animated = animatedPieSegments.value[animatedPieSegments.value.length - 1];
}
// Animate towards target
const speed = 0.15; // Animation speed
if (Math.abs(animated.animatedStart - target.startAngle) > 0.1) {
animated.animatedStart += (target.startAngle - animated.animatedStart) * speed;
animated.startAngle = target.startAngle;
needsAnimation = true;
} else {
animated.animatedStart = target.startAngle;
}
if (Math.abs(animated.animatedEnd - target.endAngle) > 0.1) {
animated.animatedEnd += (target.endAngle - animated.animatedEnd) * speed;
animated.endAngle = target.endAngle;
needsAnimation = true;
} else {
animated.animatedEnd = target.endAngle;
}
});
// Remove segments that are no longer in target
animatedPieSegments.value = animatedPieSegments.value.filter(animated =>
targetSegments.some(target => target.action === animated.action)
);
if (needsAnimation) {
animationFrameId = requestAnimationFrame(animate);
} else {
animationFrameId = null;
}
};
// Start animation after a small delay
setTimeout(() => {
animate();
}, 50);
});
// Pie geometry based on fixed viewBox (400x400); SVG scales with CSS // Pie geometry based on fixed viewBox (400x400); SVG scales with CSS
const center = 200; const center = 200;
const outerR = 192; const outerR = 192;
@@ -392,6 +521,14 @@ function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
} }
function describeArc(cx: number, cy: number, r: number, startAngle: number, endAngle: number) { function describeArc(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
// Special case for full circle (when only one value is non-zero)
if (endAngle - startAngle >= 360) {
// Draw a full circle as two semicircles
const start = polarToCartesian(cx, cy, r, 0);
const mid = polarToCartesian(cx, cy, r, 180);
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 0 0 ${mid.x} ${mid.y} A ${r} ${r} 0 0 0 ${start.x} ${start.y} Z`;
}
const start = polarToCartesian(cx, cy, r, endAngle); const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle); const end = polarToCartesian(cx, cy, r, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
@@ -748,7 +885,32 @@ function getLegendChipStyle(action: string) {
stroke: rgba(199,210,254,0.6); stroke: rgba(199,210,254,0.6);
stroke-width: 1; stroke-width: 1;
} }
.pie-slice { stroke: rgba(255,255,255,0.9); stroke-width: 1; } .pie-slice {
stroke: rgba(255,255,255,0.9);
stroke-width: 1;
opacity: 0.85;
transition: opacity 0.3s ease;
}
.pie-slice:hover {
opacity: 1;
}
/* Vue transition for pie slices */
.pie-transition-enter-active,
.pie-transition-leave-active {
transition: all 0.2s ;
}
.pie-transition-enter-from {
opacity: 0;
transform: scale(0.8);
}
.pie-transition-leave-to {
opacity: 0;
transform: scale(0.8);
}
.pie-hole { .pie-hole {
fill: rgba(255,255,255,0.9); fill: rgba(255,255,255,0.9);
stroke: rgba(229,231,235,0.8); stroke: rgba(229,231,235,0.8);

View File

@@ -50,6 +50,7 @@
> >
</button> </button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'm')">1m</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(10, 'm')">10m</button> <button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(10, 'm')">10m</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'h')">1h</button> <button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'h')">1h</button>
<button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'd')">1D</button> <button class="qs-btn" :disabled="filters.timeMode !== 'range'" @click="incrementFrom(1, 'd')">1D</button>
@@ -306,7 +307,11 @@ function updateRangeTo(value: string) {
function toggleLiveEnd() { function toggleLiveEnd() {
filters.value.liveEnd = !filters.value.liveEnd; filters.value.liveEnd = !filters.value.liveEnd;
if (filters.value.liveEnd) { if (filters.value.liveEnd) {
filters.value.rangeTo = formatLocal(new Date()); const now = new Date();
filters.value.rangeTo = formatLocal(now);
// Set rangeFrom to 1 minute before rangeTo
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000);
filters.value.rangeFrom = formatLocal(oneMinuteAgo);
} }
emitUpdate(); emitUpdate();
applyFilters(); applyFilters();
@@ -394,10 +399,10 @@ watchEffect(() => {
if (filters.value.liveEnd && filters.value.timeMode === 'range') { if (filters.value.liveEnd && filters.value.timeMode === 'range') {
if (liveTimer) clearInterval(liveTimer); if (liveTimer) clearInterval(liveTimer);
liveTimer = setInterval(() => { liveTimer = setInterval(() => {
filters.value.rangeTo = formatLocal(new Date()); // Just re-apply filters, don't update rangeTo
emitUpdate(); // applyFilters will use Date.now() when liveEnd is true
applyFilters(); applyFilters();
}, 15000); // Update every 15 seconds }, 1000); // Update every second for real-time updates
} else { } else {
if (liveTimer) { if (liveTimer) {
clearInterval(liveTimer); clearInterval(liveTimer);
@@ -444,7 +449,11 @@ function applyFilters() {
// Apply filters to events // Apply filters to events
const fromMs = Date.parse(filters.value.rangeFrom || ''); const fromMs = Date.parse(filters.value.rangeFrom || '');
const toMs = Date.parse(filters.value.rangeTo || ''); // If liveEnd is active, use current timestamp for toMs instead of rangeTo
let toMs = Date.parse(filters.value.rangeTo || '');
if (filters.value.liveEnd && filters.value.timeMode === 'range') {
toMs = Date.now(); // Use current time for real-time updates
}
result.events = sourceEvents.filter((ev: any) => { result.events = sourceEvents.filter((ev: any) => {
// Time filter (only for range mode) // Time filter (only for range mode)
@@ -579,7 +588,28 @@ if (!filters.value.rangeFrom) {
// Apply filters whenever raw data changes // Apply filters whenever raw data changes
watch(() => props.rawData, () => { watch(() => props.rawData, () => {
applyFilters(); applyFilters();
}, { deep: true }); }, { deep: true, immediate: true });
// Watch for specific nested changes that might not trigger deep watch
// These are critical for range mode to update in real-time
watch(() => props.rawData?.aggregated?.detailedEvents?.length, () => {
if (filters.value.timeMode === 'range') {
// Just apply filters, don't update rangeTo
applyFilters();
}
});
watch(() => props.rawData?.aggregatedEvents?.length, () => {
if (filters.value.timeMode === 'range') {
// Just apply filters, don't update rangeTo
applyFilters();
}
});
// Watch for player data changes
watch(() => props.rawData?.players?.length, () => {
applyFilters();
});
</script> </script>
<style scoped> <style scoped>