All checks were successful
Test Asgard Runner / test (push) Successful in 3s
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>
420 lines
12 KiB
Vue
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 < 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>
|