mejoras UI/UX leaderboard final
This commit is contained in:
@@ -96,15 +96,20 @@
|
||||
</defs>
|
||||
<circle :cx="center" :cy="center" :r="outerR" class="pie-base" />
|
||||
<g filter="url(#pieShadow)">
|
||||
<path v-for="(seg, i) in currentPieSegments" :key="i"
|
||||
:d="describeArc(center, center, sliceR, seg.startAngle, seg.endAngle)"
|
||||
:fill="EVENT_STYLES[seg.action]?.color || '#94a3b8'"
|
||||
class="pie-slice" />
|
||||
<transition-group name="pie-transition">
|
||||
<path v-for="(seg, i) in animatedPieSegments"
|
||||
:key="`${seg.action}-${i}`"
|
||||
:d="describeArc(center, center, sliceR, seg.animatedStart, seg.animatedEnd)"
|
||||
:fill="EVENT_STYLES[seg.action]?.color || '#94a3b8'"
|
||||
class="pie-slice" />
|
||||
</transition-group>
|
||||
</g>
|
||||
<circle :cx="center" :cy="center" :r="innerR" class="pie-hole" />
|
||||
<g class="pie-center">
|
||||
<text :x="center" :y="center - 4" text-anchor="middle" class="pie-center-title">{{ currentPie?.name || '' }}</text>
|
||||
<text :x="center" :y="center + 16" text-anchor="middle" class="pie-center-sub">{{ selectedPlayerUuid ? 'Jugador' : 'Global' }}</text>
|
||||
<text v-if="currentPieSegments.length === 0" :x="center" :y="center - 4" text-anchor="middle" class="pie-center-title">Sin datos</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>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -126,7 +131,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch, watchEffect } from 'vue';
|
||||
|
||||
interface Props {
|
||||
filteredData?: {
|
||||
@@ -370,16 +375,140 @@ const slideTitle = computed(() => currentSlide.value === 0 ? 'Proporciones' : (c
|
||||
const currentPieSegments = computed(() => {
|
||||
const segments: { startAngle: number; endAngle: number; action: string }[] = [];
|
||||
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
|
||||
(currentPie.value.percentages as number[]).forEach((pct, idx) => {
|
||||
nonZeroIndices.forEach((originalIdx, i) => {
|
||||
const pct = nonZeroPercentages[i];
|
||||
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);
|
||||
angle += span;
|
||||
});
|
||||
|
||||
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
|
||||
const center = 200;
|
||||
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) {
|
||||
// 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 end = polarToCartesian(cx, cy, r, startAngle);
|
||||
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
|
||||
@@ -748,7 +885,32 @@ function getLegendChipStyle(action: string) {
|
||||
stroke: rgba(199,210,254,0.6);
|
||||
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 {
|
||||
fill: rgba(255,255,255,0.9);
|
||||
stroke: rgba(229,231,235,0.8);
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
>
|
||||
⏱
|
||||
</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(1, 'h')">1h</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() {
|
||||
filters.value.liveEnd = !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();
|
||||
applyFilters();
|
||||
@@ -394,10 +399,10 @@ watchEffect(() => {
|
||||
if (filters.value.liveEnd && filters.value.timeMode === 'range') {
|
||||
if (liveTimer) clearInterval(liveTimer);
|
||||
liveTimer = setInterval(() => {
|
||||
filters.value.rangeTo = formatLocal(new Date());
|
||||
emitUpdate();
|
||||
// Just re-apply filters, don't update rangeTo
|
||||
// applyFilters will use Date.now() when liveEnd is true
|
||||
applyFilters();
|
||||
}, 15000); // Update every 15 seconds
|
||||
}, 1000); // Update every second for real-time updates
|
||||
} else {
|
||||
if (liveTimer) {
|
||||
clearInterval(liveTimer);
|
||||
@@ -444,7 +449,11 @@ function applyFilters() {
|
||||
|
||||
// Apply filters to events
|
||||
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) => {
|
||||
// Time filter (only for range mode)
|
||||
@@ -579,7 +588,28 @@ if (!filters.value.rangeFrom) {
|
||||
// Apply filters whenever raw data changes
|
||||
watch(() => props.rawData, () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user