mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 22:46:06 +02:00
feat: add activity heatmap to user dialog for online frequency visualization (#1198)
This commit is contained in:
@@ -42,6 +42,10 @@
|
|||||||
<UserDialogAvatarsTab ref="avatarsTabRef" />
|
<UserDialogAvatarsTab ref="avatarsTabRef" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="userDialog.id !== currentUser.id" #Activity>
|
||||||
|
<UserDialogActivityTab ref="activityTabRef" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #JSON>
|
<template #JSON>
|
||||||
<DialogJsonTab
|
<DialogJsonTab
|
||||||
:tree-data="treeData"
|
:tree-data="treeData"
|
||||||
@@ -97,6 +101,7 @@
|
|||||||
|
|
||||||
import DialogJsonTab from '../DialogJsonTab.vue';
|
import DialogJsonTab from '../DialogJsonTab.vue';
|
||||||
import SendInviteDialog from '../InviteDialog/SendInviteDialog.vue';
|
import SendInviteDialog from '../InviteDialog/SendInviteDialog.vue';
|
||||||
|
import UserDialogActivityTab from './UserDialogActivityTab.vue';
|
||||||
import UserDialogAvatarsTab from './UserDialogAvatarsTab.vue';
|
import UserDialogAvatarsTab from './UserDialogAvatarsTab.vue';
|
||||||
import UserDialogFavoriteWorldsTab from './UserDialogFavoriteWorldsTab.vue';
|
import UserDialogFavoriteWorldsTab from './UserDialogFavoriteWorldsTab.vue';
|
||||||
import UserDialogGroupsTab from './UserDialogGroupsTab.vue';
|
import UserDialogGroupsTab from './UserDialogGroupsTab.vue';
|
||||||
@@ -125,9 +130,15 @@
|
|||||||
if (userDialog.value.id !== currentUser.value.id && !currentUser.value.hasSharedConnectionsOptOut) {
|
if (userDialog.value.id !== currentUser.value.id && !currentUser.value.hasSharedConnectionsOptOut) {
|
||||||
tabs.splice(1, 0, { value: 'mutual', label: t('dialog.user.mutual_friends.header') });
|
tabs.splice(1, 0, { value: 'mutual', label: t('dialog.user.mutual_friends.header') });
|
||||||
}
|
}
|
||||||
|
if (userDialog.value.id !== currentUser.value.id) {
|
||||||
|
// Insert Activity before JSON
|
||||||
|
const jsonIdx = tabs.findIndex((tab) => tab.value === 'JSON');
|
||||||
|
tabs.splice(jsonIdx, 0, { value: 'Activity', label: t('dialog.user.activity.header') });
|
||||||
|
}
|
||||||
return tabs;
|
return tabs;
|
||||||
});
|
});
|
||||||
const infoTabRef = ref(null);
|
const infoTabRef = ref(null);
|
||||||
|
const activityTabRef = ref(null);
|
||||||
const favoriteWorldsTabRef = ref(null);
|
const favoriteWorldsTabRef = ref(null);
|
||||||
const mutualFriendsTabRef = ref(null);
|
const mutualFriendsTabRef = ref(null);
|
||||||
const worldsTabRef = ref(null);
|
const worldsTabRef = ref(null);
|
||||||
@@ -326,6 +337,8 @@
|
|||||||
userDialogLastFavoriteWorld.value = userId;
|
userDialogLastFavoriteWorld.value = userId;
|
||||||
favoriteWorldsTabRef.value?.getUserFavoriteWorlds(userId);
|
favoriteWorldsTabRef.value?.getUserFavoriteWorlds(userId);
|
||||||
}
|
}
|
||||||
|
} else if (tabName === 'Activity') {
|
||||||
|
activityTabRef.value?.loadOnlineFrequency(userId);
|
||||||
} else if (tabName === 'JSON') {
|
} else if (tabName === 'JSON') {
|
||||||
refreshUserDialogTreeData();
|
refreshUserDialogTreeData();
|
||||||
}
|
}
|
||||||
@@ -335,7 +348,14 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
function loadLastActiveTab() {
|
function loadLastActiveTab() {
|
||||||
handleUserDialogTab(userDialog.value.lastActiveTab);
|
let tab = userDialog.value.lastActiveTab;
|
||||||
|
// Activity tab is not available for own profile; fall back to Info
|
||||||
|
if (tab === 'Activity' && userDialog.value.id === currentUser.value.id) {
|
||||||
|
tab = 'Info';
|
||||||
|
userDialog.value.lastActiveTab = 'Info';
|
||||||
|
userDialog.value.activeTab = 'Info';
|
||||||
|
}
|
||||||
|
handleUserDialogTab(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col px-2" style="min-height: 200px">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="loadData">
|
||||||
|
<Spinner v-if="isLoading" />
|
||||||
|
<RefreshCw v-else />
|
||||||
|
</Button>
|
||||||
|
<span v-if="totalOnlineEvents > 0" style="margin-left: 6px" class="text-muted-foreground text-xs">
|
||||||
|
{{ t('dialog.user.activity.total_events', { count: totalOnlineEvents }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="peakDayText || peakTimeText" class="mt-2 mb-1 text-sm flex gap-4">
|
||||||
|
<div v-if="peakDayText">
|
||||||
|
<span class="text-muted-foreground">{{ t('dialog.user.activity.most_active_day') }}</span>
|
||||||
|
<span class="font-medium ml-1">{{ peakDayText }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="peakTimeText">
|
||||||
|
<span class="text-muted-foreground">{{ t('dialog.user.activity.most_active_time') }}</span>
|
||||||
|
<span class="font-medium ml-1">{{ peakTimeText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isLoading && totalOnlineEvents === 0" class="flex items-center justify-center flex-1 mt-8">
|
||||||
|
<DataTableEmpty type="nodata" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="totalOnlineEvents > 0"
|
||||||
|
ref="chartRef"
|
||||||
|
style="width: 100%; height: 240px"
|
||||||
|
@contextmenu.prevent="onChartRightClick">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, h, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||||
|
import { RefreshCw, Tractor } from 'lucide-vue-next';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { database } from '../../../services/database';
|
||||||
|
import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const { userDialog } = storeToRefs(useUserStore());
|
||||||
|
const { isDarkMode } = storeToRefs(useAppearanceSettingsStore());
|
||||||
|
|
||||||
|
const chartRef = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const totalOnlineEvents = ref(0);
|
||||||
|
const peakDayText = ref('');
|
||||||
|
const peakTimeText = ref('');
|
||||||
|
|
||||||
|
let echartsInstance = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
let lastLoadedUserId = '';
|
||||||
|
|
||||||
|
const dayLabels = computed(() => [
|
||||||
|
t('dialog.user.activity.days.sun'),
|
||||||
|
t('dialog.user.activity.days.mon'),
|
||||||
|
t('dialog.user.activity.days.tue'),
|
||||||
|
t('dialog.user.activity.days.wed'),
|
||||||
|
t('dialog.user.activity.days.thu'),
|
||||||
|
t('dialog.user.activity.days.fri'),
|
||||||
|
t('dialog.user.activity.days.sat')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reorder: Mon-Sun for display (row 0=Mon at top, row 6=Sun at bottom)
|
||||||
|
const displayDayLabels = computed(() => [
|
||||||
|
dayLabels.value[1], // Mon
|
||||||
|
dayLabels.value[2], // Tue
|
||||||
|
dayLabels.value[3], // Wed
|
||||||
|
dayLabels.value[4], // Thu
|
||||||
|
dayLabels.value[5], // Fri
|
||||||
|
dayLabels.value[6], // Sat
|
||||||
|
dayLabels.value[0] // Sun
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hourLabels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
||||||
|
|
||||||
|
function rebuildChart() {
|
||||||
|
if (echartsInstance) {
|
||||||
|
echartsInstance.dispose();
|
||||||
|
echartsInstance = null;
|
||||||
|
if (totalOnlineEvents.value > 0 && chartRef.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
echartsInstance = echarts.init(
|
||||||
|
chartRef.value,
|
||||||
|
isDarkMode.value ? 'dark' : null,
|
||||||
|
{ height: 240 }
|
||||||
|
);
|
||||||
|
initChart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => isDarkMode.value, rebuildChart);
|
||||||
|
watch(locale, rebuildChart);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposeChart();
|
||||||
|
});
|
||||||
|
|
||||||
|
function disposeChart() {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
}
|
||||||
|
if (echartsInstance) {
|
||||||
|
echartsInstance.dispose();
|
||||||
|
echartsInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} timestamps
|
||||||
|
* @returns {{ data: number[][], maxVal: number, peakText: string }}
|
||||||
|
*/
|
||||||
|
function aggregateHeatmapData(timestamps) {
|
||||||
|
const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
|
||||||
|
|
||||||
|
for (const ts of timestamps) {
|
||||||
|
const d = dayjs(ts);
|
||||||
|
const dayOfWeek = d.day(); // 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
const hour = d.hour();
|
||||||
|
grid[dayOfWeek][hour]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = [];
|
||||||
|
let maxVal = 0;
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
const count = grid[day][hour];
|
||||||
|
const displayDay = day === 0 ? 6 : day - 1;
|
||||||
|
data.push([hour, displayDay, count]);
|
||||||
|
if (count > maxVal) maxVal = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak day: sum each day across all hours
|
||||||
|
let peakDayResult = '';
|
||||||
|
const daySums = new Array(7).fill(0);
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
daySums[day] += grid[day][hour];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let maxDaySum = 0;
|
||||||
|
let maxDayIdx = 0;
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
if (daySums[day] > maxDaySum) {
|
||||||
|
maxDaySum = daySums[day];
|
||||||
|
maxDayIdx = day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxDaySum > 0) {
|
||||||
|
peakDayResult = dayLabels.value[maxDayIdx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak time: sum each hour across all days, find contiguous peak range
|
||||||
|
let peakTimeResult = '';
|
||||||
|
const hourSums = new Array(24).fill(0);
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
hourSums[hour] += grid[day][hour];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let maxHourSum = 0;
|
||||||
|
let maxHourIdx = 0;
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
if (hourSums[hour] > maxHourSum) {
|
||||||
|
maxHourSum = hourSums[hour];
|
||||||
|
maxHourIdx = hour;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxHourSum > 0) {
|
||||||
|
const threshold = maxHourSum * 0.7;
|
||||||
|
let startHour = maxHourIdx;
|
||||||
|
let endHour = maxHourIdx;
|
||||||
|
while (startHour > 0 && hourSums[startHour - 1] >= threshold) {
|
||||||
|
startHour--;
|
||||||
|
}
|
||||||
|
while (endHour < 23 && hourSums[endHour + 1] >= threshold) {
|
||||||
|
endHour++;
|
||||||
|
}
|
||||||
|
if (startHour === endHour) {
|
||||||
|
peakTimeResult = `${String(startHour).padStart(2, '0')}:00`;
|
||||||
|
} else {
|
||||||
|
peakTimeResult = `${String(startHour).padStart(2, '0')}:00–${String(endHour + 1).padStart(2, '0')}:00`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, maxVal, peakDayResult, peakTimeResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
if (!chartRef.value || !echartsInstance) return;
|
||||||
|
|
||||||
|
const { data, maxVal, peakDayResult, peakTimeResult } = aggregateHeatmapData(cachedTimestamps);
|
||||||
|
peakDayText.value = peakDayResult;
|
||||||
|
peakTimeText.value = peakTimeResult;
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
position: 'top',
|
||||||
|
formatter: (params) => {
|
||||||
|
const [hour, dayIdx, count] = params.data;
|
||||||
|
const dayName = displayDayLabels.value[dayIdx];
|
||||||
|
return `${dayName} ${hourLabels[hour]}<br/><b>${count}</b> ${t('dialog.user.activity.times_online')}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 6,
|
||||||
|
left: 42,
|
||||||
|
right: 16,
|
||||||
|
bottom: 32
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: hourLabels,
|
||||||
|
splitArea: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
interval: 2,
|
||||||
|
fontSize: 10
|
||||||
|
},
|
||||||
|
axisTick: { show: false }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: displayDayLabels.value,
|
||||||
|
splitArea: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11
|
||||||
|
},
|
||||||
|
axisTick: { show: false }
|
||||||
|
},
|
||||||
|
visualMap: {
|
||||||
|
min: 0,
|
||||||
|
max: Math.max(maxVal, 1),
|
||||||
|
calculable: false,
|
||||||
|
show: false,
|
||||||
|
inRange: {
|
||||||
|
color: isDarkMode.value
|
||||||
|
? [
|
||||||
|
'hsl(220, 15%, 12%)',
|
||||||
|
'hsl(160, 40%, 20%)',
|
||||||
|
'hsl(142, 60%, 38%)',
|
||||||
|
'hsl(142, 72%, 52%)',
|
||||||
|
'hsl(142, 80%, 62%)'
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'hsl(210, 30%, 95%)',
|
||||||
|
'hsl(160, 40%, 80%)',
|
||||||
|
'hsl(142, 55%, 55%)',
|
||||||
|
'hsl(142, 65%, 40%)',
|
||||||
|
'hsl(142, 76%, 30%)'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'heatmap',
|
||||||
|
data,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 6,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: isDarkMode.value ? 'hsl(220, 15%, 8%)' : 'hsl(0, 0%, 100%)',
|
||||||
|
borderRadius: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
};
|
||||||
|
|
||||||
|
echartsInstance.setOption(option, { notMerge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTimestamps = [];
|
||||||
|
let activeRequestId = 0;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const userId = userDialog.value.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const requestId = ++activeRequestId;
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const timestamps = await database.getOnlineFrequencyData(userId);
|
||||||
|
if (requestId !== activeRequestId) return;
|
||||||
|
if (userDialog.value.id !== userId) return;
|
||||||
|
cachedTimestamps = timestamps;
|
||||||
|
totalOnlineEvents.value = timestamps.length;
|
||||||
|
lastLoadedUserId = userId;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (timestamps.length > 0) {
|
||||||
|
if (!echartsInstance && chartRef.value) {
|
||||||
|
echartsInstance = echarts.init(
|
||||||
|
chartRef.value,
|
||||||
|
isDarkMode.value ? 'dark' : null,
|
||||||
|
{ height: 240 }
|
||||||
|
);
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (echartsInstance) {
|
||||||
|
echartsInstance.resize({
|
||||||
|
width: entry.contentRect.width
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(chartRef.value);
|
||||||
|
}
|
||||||
|
initChart();
|
||||||
|
} else {
|
||||||
|
peakDayText.value = '';
|
||||||
|
peakTimeText.value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading online frequency data:', error);
|
||||||
|
} finally {
|
||||||
|
if (requestId === activeRequestId) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
function loadOnlineFrequency(userId) {
|
||||||
|
if (lastLoadedUserId === userId && totalOnlineEvents.value > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChartRightClick() {
|
||||||
|
toast(t('dialog.user.activity.easter_egg'), { position: 'bottom-center', icon: h(Tractor) });
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ loadOnlineFrequency });
|
||||||
|
</script>
|
||||||
@@ -1280,6 +1280,23 @@
|
|||||||
"friend_order": "Friend Order"
|
"friend_order": "Friend Order"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"activity": {
|
||||||
|
"header": "Activity",
|
||||||
|
"total_events": "{count} online events",
|
||||||
|
"times_online": "times online",
|
||||||
|
"most_active_day": "Most active day:",
|
||||||
|
"most_active_time": "Peak hours:",
|
||||||
|
"days": {
|
||||||
|
"mon": "Mon",
|
||||||
|
"tue": "Tue",
|
||||||
|
"wed": "Wed",
|
||||||
|
"thu": "Thu",
|
||||||
|
"fri": "Fri",
|
||||||
|
"sat": "Sat",
|
||||||
|
"sun": "Sun"
|
||||||
|
},
|
||||||
|
"easter_egg": "Did you farm your green squares today?"
|
||||||
|
},
|
||||||
"note_memo": {
|
"note_memo": {
|
||||||
"header": "Edit Note And Memo",
|
"header": "Edit Note And Memo",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -572,6 +572,18 @@ const feed = {
|
|||||||
args
|
args
|
||||||
);
|
);
|
||||||
return feedDatabase;
|
return feedDatabase;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getOnlineFrequencyData(userId) {
|
||||||
|
const data = [];
|
||||||
|
await sqliteService.execute(
|
||||||
|
(dbRow) => {
|
||||||
|
data.push(dbRow[0]);
|
||||||
|
},
|
||||||
|
`SELECT created_at FROM ${dbVars.userPrefix}_feed_online_offline WHERE type = 'Online' AND user_id = @userId ORDER BY created_at`,
|
||||||
|
{ '@userId': userId }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user