Files
corrosion-admin-panel/frontend/src/views/admin/MapAnalyticsView.vue
Vantz Stockwell b42a2d7ea7
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:34:46 -04:00

420 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import type { MapAnalyticsSummary } from '@/types'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi()
const timeRange = ref<'30d' | '90d' | 'all'>('90d')
const loading = ref(true)
const analytics = ref<MapAnalyticsSummary | null>(null)
const performanceChart = ref<HTMLElement | null>(null)
let performanceChartInstance: ECharts | null = null
const loadMapAnalytics = async () => {
loading.value = true
try {
const response = await api.get<MapAnalyticsSummary>(`/analytics/maps?range=${timeRange.value}`)
analytics.value = response
await nextTick()
renderCharts()
} catch (error) {
console.error('Failed to load map analytics:', error)
} finally {
loading.value = false
}
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => {
if (!analytics.value || analytics.value.maps.length === 0) return
// Map performance bar chart (avg players per map)
if (performanceChart.value) {
if (performanceChartInstance) {
performanceChartInstance.dispose()
}
performanceChartInstance = echarts.init(performanceChart.value)
const mapNames = analytics.value.maps.map(m => m.map_name)
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
const accent = cssVar('--accent') || '#CE422B'
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
const axisLine = cssVar('--border-default') || '#404040'
const labelColor = cssVar('--text-tertiary') || '#808080'
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
const mono = 'JetBrains Mono, monospace'
performanceChartInstance.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['Avg Players', 'Peak Players'],
textStyle: { color: labelColor, fontFamily: mono },
top: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: mapNames,
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
name: 'Players',
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
},
series: [
{
name: 'Avg Players',
type: 'bar',
data: avgPlayers,
itemStyle: { color: accent },
barGap: '10%'
},
{
name: 'Peak Players',
type: 'bar',
data: peakPlayers,
itemStyle: { color: '#10b981' },
barGap: '10%'
}
]
})
}
}
const sortedMaps = computed(() => {
if (!analytics.value) return []
return [...analytics.value.maps].sort((a, b) => b.avg_players - a.avg_players)
})
const downloadCSV = () => {
if (!analytics.value) return
const headers = ['Map Name', 'Seed', 'Times Used', 'Avg Players', 'Peak Players', 'Effectiveness Score (%)']
const rows = analytics.value.maps.map(m => [
m.map_name,
m.seed ?? 'N/A',
m.times_used,
safeFixed(m.avg_players, 1),
m.peak_players,
safeFixed(m.effectiveness_score, 1)
])
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `map_analytics_${timeRange.value}.csv`
a.click()
window.URL.revokeObjectURL(url)
}
watch(timeRange, () => {
loadMapAnalytics()
})
onMounted(() => {
loadMapAnalytics()
})
</script>
<template>
<div class="map-analytics-view">
<!-- Header -->
<div class="map-analytics-view__header">
<h1 class="map-analytics-view__title">Map analytics</h1>
<div class="map-analytics-view__controls">
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
Export CSV
</Button>
<Tabs
:items="[
{ value: '30d', label: '30d' },
{ value: '90d', label: '90d' },
{ value: 'all', label: 'All' }
]"
v-model="timeRange"
variant="pill"
/>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="map-analytics-view__loading">
<span class="map-analytics-view__loading-text">Loading map analytics...</span>
</div>
<template v-else-if="analytics">
<!-- Summary cards -->
<div class="map-analytics-view__stats">
<StatCard
label="Best performing map"
:value="analytics.best_performing_map ?? 'No data'"
icon="award"
:note="analytics.maps.length > 0 ? `Avg ${safeFixed(analytics?.maps?.[0]?.avg_players, 1)} players` : undefined"
/>
<StatCard
label="Rotation effectiveness"
:value="safeFixed(analytics?.rotation_effectiveness, 1)"
unit="%"
icon="target"
note="Overall rotation health"
/>
<StatCard
label="Total maps tracked"
:value="analytics.maps.length"
icon="trending-up"
:note="`Last ${timeRange}`"
/>
</div>
<!-- Performance chart -->
<Panel title="Map performance comparison">
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="map-analytics-view__chart-area"></div>
<EmptyState
v-else
icon="map"
title="No map data"
description="No map data available for this time range."
/>
</Panel>
<!-- Map performance table -->
<Panel title="Detailed map metrics" :flush-body="sortedMaps.length > 0">
<div v-if="sortedMaps.length > 0" class="map-analytics-view__table-wrap">
<table class="map-analytics-view__table">
<thead>
<tr class="map-analytics-view__thead-row">
<th class="map-analytics-view__th map-analytics-view__th--left">Map name</th>
<th class="map-analytics-view__th map-analytics-view__th--left">Seed</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Times used</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Avg players</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Peak players</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Effectiveness</th>
</tr>
</thead>
<tbody>
<tr
v-for="map in sortedMaps"
:key="map.map_id"
class="map-analytics-view__row"
>
<td class="map-analytics-view__td map-analytics-view__td--primary">{{ map.map_name }}</td>
<td class="map-analytics-view__td">{{ map.seed ?? '—' }}</td>
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.times_used }}</td>
<td class="map-analytics-view__td map-analytics-view__td--num">{{ safeFixed(map.avg_players, 1) }}</td>
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.peak_players }}</td>
<td class="map-analytics-view__td map-analytics-view__td--right">
<Badge
:tone="map.effectiveness_score >= 80 ? 'online' : map.effectiveness_score >= 60 ? 'warn' : 'offline'"
mono
>
{{ safeFixed(map.effectiveness_score, 1) }}%
</Badge>
</td>
</tr>
</tbody>
</table>
</div>
<EmptyState
v-else
icon="map"
title="No map data"
description="No map data available."
/>
</Panel>
<!-- Insights section -->
<Panel title="Actionable insights">
<div class="map-analytics-view__insights">
<Alert
v-if="analytics.best_performing_map"
tone="accent"
:title="`Best map: ${analytics.best_performing_map}`"
>
Consider featuring this map more frequently in your rotation for maximum player engagement.
</Alert>
<Alert
v-if="analytics.rotation_effectiveness < 70"
tone="warn"
title="Rotation effectiveness is below optimal"
>
Consider removing low-performing maps (effectiveness &lt; 60%) and testing new maps to improve overall rotation health.
</Alert>
<Alert
v-else-if="analytics.rotation_effectiveness >= 80"
tone="online"
title="Excellent rotation effectiveness"
>
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
</Alert>
</div>
</Panel>
</template>
</div>
</template>
<style scoped>
.map-analytics-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.map-analytics-view__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.map-analytics-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.map-analytics-view__controls {
display: flex;
align-items: center;
gap: 10px;
}
.map-analytics-view__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.map-analytics-view__loading-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.map-analytics-view__stats {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 768px) {
.map-analytics-view__stats {
grid-template-columns: repeat(3, 1fr);
}
}
.map-analytics-view__chart-area {
height: 320px;
}
.map-analytics-view__table-wrap {
overflow-x: auto;
}
.map-analytics-view__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.map-analytics-view__thead-row {
border-bottom: 1px solid var(--border-subtle);
}
.map-analytics-view__th {
padding: 12px 16px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
}
.map-analytics-view__th--left {
text-align: left;
}
.map-analytics-view__th--right {
text-align: right;
}
.map-analytics-view__row {
border-bottom: 1px solid var(--border-subtle);
transition: background var(--dur-fast) var(--ease-standard);
}
.map-analytics-view__row:hover {
background: var(--surface-hover);
}
.map-analytics-view__td {
padding: 12px 16px;
color: var(--text-secondary);
}
.map-analytics-view__td--primary {
color: var(--text-primary);
font-weight: 500;
}
.map-analytics-view__td--num {
text-align: right;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.map-analytics-view__td--right {
text-align: right;
}
.map-analytics-view__insights {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>