add previous instances info chart view

This commit is contained in:
pa
2026-03-16 21:25:30 +09:00
parent cfda4c49d1
commit a8a14ae901
4 changed files with 552 additions and 4 deletions

View File

@@ -0,0 +1,424 @@
<template>
<div ref="chartContainerRef" class="w-full">
<div v-if="hasChartData" ref="chartRef"></div>
<div v-else class="flex items-center justify-center" style="min-height: 200px">
<DataTableEmpty type="nodata" />
</div>
</div>
</template>
<script setup>
defineOptions({ name: 'PreviousInstancesInfoChart' });
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { DataTableEmpty } from '@/components/ui/data-table';
import { storeToRefs } from 'pinia';
import dayjs from 'dayjs';
import { useAppearanceSettingsStore, useGameLogStore, useUserStore } from '../../../stores';
import { timeToText } from '../../../shared/utils';
import * as echarts from 'echarts';
import { showUserDialog } from '../../../coordinators/userCoordinator';
const { isDarkMode, dtHour12 } = storeToRefs(useAppearanceSettingsStore());
const { currentUser } = storeToRefs(useUserStore());
const { gameLogIsFriend, gameLogIsFavorite } = useGameLogStore();
const BAR_WIDTH = 12;
const props = defineProps({
chartData: {
type: Array,
required: true
}
});
const chartRef = ref(null);
const chartContainerRef = ref(null);
let echartsInstance = null;
const usersFirstActivity = ref(null);
const resizeObserver = ref(null);
const processedData = computed(() => {
if (!props.chartData || props.chartData.length === 0) return [];
return props.chartData.map((item) => ({
...item,
joinTime: dayjs(item.created_at).subtract(item.time, 'millisecond'),
leaveTime: dayjs(item.created_at),
time: item.time < 0 ? 0 : item.time,
isFriend: item.user_id === currentUser.value.id ? null : gameLogIsFriend(item),
isFavorite: item.user_id === currentUser.value.id ? null : gameLogIsFavorite(item)
}));
});
const hasChartData = computed(() => processedData.value.length > 0);
const startTimeStamp = computed(() => {
if (processedData.value.length === 0) return null;
let min = Infinity;
for (const entry of processedData.value) {
const val = entry.joinTime.valueOf();
if (val < min) min = val;
}
return min;
});
const endTimeStamp = computed(() => {
if (processedData.value.length === 0) return null;
let max = -Infinity;
for (const entry of processedData.value) {
const val = entry.leaveTime.valueOf();
if (val > max) max = val;
}
return max;
});
watch(
() => isDarkMode.value,
() => {
if (echartsInstance) {
echartsInstance.dispose();
echartsInstance = null;
initEcharts();
}
}
);
watch(
() => dtHour12.value,
() => {
if (echartsInstance) {
initEcharts();
}
}
);
watch(
() => props.chartData,
() => {
if (echartsInstance) {
echartsInstance.dispose();
echartsInstance = null;
}
nextTick(() => {
initEcharts();
});
}
);
initResizeObserver();
onMounted(async () => {
await nextTick();
initEcharts();
});
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect();
resizeObserver.value = null;
}
if (echartsInstance) {
echartsInstance.dispose();
echartsInstance = null;
}
});
function initResizeObserver() {
resizeObserver.value = new ResizeObserver((entries) => {
if (!echartsInstance) {
return;
}
for (const entry of entries) {
try {
echartsInstance.resize({
width: entry.contentRect.width,
animation: {
duration: 300
}
});
} catch (error) {
console.warn('Error resizing chart:', error);
}
}
});
}
async function initEcharts() {
const data = processedData.value;
if (!chartRef.value || data.length === 0) {
return;
}
const uniqueUsers = new Map();
for (const entry of data) {
if (!uniqueUsers.has(entry.user_id)) {
uniqueUsers.set(entry.user_id, entry);
}
}
const chartsHeight = uniqueUsers.size * (BAR_WIDTH + 10) + 200;
const chartDom = chartRef.value;
const afterInit = () => {
if (!echartsInstance) {
console.error('ECharts instance not initialized');
return;
}
try {
echartsInstance.resize({
height: chartsHeight,
animation: {
duration: 300
}
});
echartsInstance.off('click');
const options = getNewOption();
if (options && options.series && options.series.length > 0) {
echartsInstance.clear();
echartsInstance.setOption(options, { notMerge: true });
echartsInstance.on('click', 'yAxis', handleClickYAxisLabel);
} else {
echartsInstance.clear();
}
} catch (error) {
console.error('Error in afterInit:', error);
}
};
if (!echartsInstance) {
echartsInstance = echarts.init(chartDom, `${isDarkMode.value ? 'dark' : null}`, {
height: chartsHeight,
useDirtyRect: data.length > 80
});
if (resizeObserver.value) {
resizeObserver.value.observe(chartDom);
}
}
setTimeout(afterInit, 50);
}
function handleClickYAxisLabel(params) {
const userData = usersFirstActivity.value[params.dataIndex];
if (userData?.user_id) {
showUserDialog(userData.user_id);
}
}
function getNewOption() {
const data = processedData.value;
if (data.length === 0) {
return {
title: {
text: 'No data',
left: 'center',
top: 'middle'
}
};
}
if (!startTimeStamp.value || !endTimeStamp.value) {
return {
title: {
text: 'Invalid timestamp data',
left: 'center',
top: 'middle'
}
};
}
const userGroupedEntries = new Map();
const uniqueUserEntries = [];
// sort by joinTime for consistent ordering
const sortedData = [...data].sort((a, b) => {
const timeDiff = Math.abs(a.joinTime.diff(b.joinTime, 'second'));
return timeDiff < 3 ? a.leaveTime - b.leaveTime : a.joinTime - b.joinTime;
});
for (const entry of sortedData) {
if (!userGroupedEntries.has(entry.user_id)) {
userGroupedEntries.set(entry.user_id, []);
uniqueUserEntries.push(entry);
}
const elements = userGroupedEntries.get(entry.user_id);
const offset = Math.max(
0,
elements.length === 0
? entry.joinTime.valueOf() - startTimeStamp.value
: entry.joinTime.valueOf() - startTimeStamp.value - elements[elements.length - 1].tail
);
const tail =
elements.length === 0 ? offset + entry.time : elements[elements.length - 1].tail + offset + entry.time;
const element = { offset, time: entry.time, tail, entry };
elements.push(element);
}
usersFirstActivity.value = uniqueUserEntries;
const generateSeries = () => {
const maxEntryCount = Math.max(...Array.from(userGroupedEntries.values()).map((entries) => entries.length));
const placeholderSeries = (seriesData) => {
return {
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data: seriesData
};
};
const timeSeries = (seriesData) => {
return {
name: 'Time',
type: 'bar',
stack: 'Total',
colorBy: 'data',
barWidth: BAR_WIDTH,
emphasis: {
focus: 'self'
},
itemStyle: {
borderRadius: 2,
shadowBlur: 2,
shadowOffsetX: 0.7,
shadowOffsetY: 0.5
},
data: seriesData
};
};
const series = Array(maxEntryCount)
.fill(0)
.flatMap((_, index) => {
return [
placeholderSeries(
uniqueUserEntries.map((entry) => {
const element = userGroupedEntries.get(entry.user_id)[index];
return element ? element.offset : 0;
})
),
timeSeries(
uniqueUserEntries.map((entry) => {
const element = userGroupedEntries.get(entry.user_id)[index];
return element ? element.time : 0;
})
)
];
});
return series;
};
const friendOrFavIcon = (display_name) => {
const foundItem = data.find((item) => item.display_name === display_name);
if (!foundItem) {
return '';
}
if (foundItem.isFavorite) {
return '⭐';
}
if (foundItem.isFriend) {
return '💚';
}
return '';
};
const getTooltip = (params) => {
const param = params;
const userData = uniqueUserEntries[param.dataIndex];
const isTimeSeries = params.seriesIndex % 2 === 1;
if (!isTimeSeries) {
return '';
}
const targetEntryIndex = Math.floor(params.seriesIndex / 2);
if (!userData) {
return '';
}
const instanceData = userGroupedEntries.get(userData.user_id)[targetEntryIndex]?.entry;
if (!instanceData) {
return '';
}
const format = dtHour12.value ? 'hh:mm:ss A' : 'HH:mm:ss';
const formattedLeftDateTime = dayjs(instanceData.leaveTime).format(format);
const formattedJoinDateTime = dayjs(instanceData.joinTime).format(format);
const timeString = timeToText(instanceData.time, true);
const color = param.color;
return `
<div style="display: flex; align-items: center;">
<div style="width: 10px; height: 55px; background-color: ${color}; margin-right: 6px;"></div>
<div>
<div>${instanceData.display_name} ${friendOrFavIcon(instanceData.display_name)}</div>
<div>${formattedJoinDateTime} - ${formattedLeftDateTime}</div>
<div>${timeString}</div>
</div>
</div>
`;
};
const format = dtHour12.value ? 'hh:mm A' : 'HH:mm';
const echartsOption = {
tooltip: {
trigger: 'item',
axisPointer: {
type: 'shadow'
},
formatter: getTooltip
},
grid: {
top: 50,
left: 160,
right: 90
},
yAxis: {
type: 'category',
axisLabel: {
interval: 0,
formatter: (value) => {
const MAX_LENGTH = 20;
const len = value.length;
return `${friendOrFavIcon(value)} ${len > MAX_LENGTH ? `${value.substring(0, MAX_LENGTH)}...` : value}`;
}
},
inverse: true,
data: uniqueUserEntries.map((item) => item.display_name),
triggerEvent: true
},
xAxis: {
type: 'value',
min: 0,
max: endTimeStamp.value - startTimeStamp.value,
axisLine: { show: true },
axisLabel: {
formatter: (value) => dayjs(value + startTimeStamp.value).format(format)
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: generateSeries(),
backgroundColor: 'transparent'
};
return echartsOption;
}
</script>

View File

@@ -5,6 +5,7 @@
</DialogHeader>
<DataTableLayout
v-if="viewMode === 'table'"
class="min-w-0 w-full"
:table="table"
:loading="loading"
@@ -16,7 +17,31 @@
:on-sort-change="handleSortChange">
<template #toolbar>
<div style="display: flex; align-items: center; justify-content: space-between">
<Location :location="location.tag" class="text-sm" />
<div class="flex items-center gap-2 px-1 py-2">
<ToggleGroup
type="single"
:model-value="viewMode"
variant="outline"
@update:model-value="handleViewModeChange">
<TooltipWrapper :content="t('dialog.previous_instances.table_view')" side="bottom" :delay-duration="300">
<ToggleGroupItem
value="table"
class="px-2"
:class="viewMode === 'table' && 'bg-accent text-accent-foreground'">
<List class="size-4" />
</ToggleGroupItem>
</TooltipWrapper>
<TooltipWrapper :content="t('dialog.previous_instances.chart_view')" side="bottom" :delay-duration="300">
<ToggleGroupItem
value="chart"
class="px-2"
:class="viewMode === 'chart' && 'bg-accent text-accent-foreground'">
<BarChart3 class="size-4" />
</ToggleGroupItem>
</TooltipWrapper>
</ToggleGroup>
<Location :location="location.tag" class="text-sm" />
</div>
<InputGroupField
v-model="search"
:placeholder="t('dialog.previous_instances.search_placeholder')"
@@ -25,6 +50,44 @@
</div>
</template>
</DataTableLayout>
<div v-else-if="viewMode === 'chart'" class="flex flex-col min-w-0 w-full h-full">
<div class="flex items-center justify-between px-1 py-2">
<div class="flex items-center gap-2">
<ToggleGroup
type="single"
:model-value="viewMode"
variant="outline"
@update:model-value="handleViewModeChange">
<TooltipWrapper :content="t('dialog.previous_instances.table_view')" side="bottom" :delay-duration="300">
<ToggleGroupItem
value="table"
class="px-2"
:class="viewMode === 'table' && 'bg-accent text-accent-foreground'">
<List class="size-4" />
</ToggleGroupItem>
</TooltipWrapper>
<TooltipWrapper :content="t('dialog.previous_instances.chart_view')" side="bottom" :delay-duration="300">
<ToggleGroupItem
value="chart"
class="px-2"
:class="viewMode === 'chart' && 'bg-accent text-accent-foreground'">
<BarChart3 class="size-4" />
</ToggleGroupItem>
</TooltipWrapper>
</ToggleGroup>
<Location :location="location.tag" class="text-sm" />
</div>
</div>
<div class="flex-1 overflow-auto min-h-0">
<div v-if="chartLoading" class="flex items-center justify-center" style="min-height: 200px">
<span class="text-muted-foreground text-sm">{{ t('view.friends_locations.loading_more') }}</span>
</div>
<PreviousInstancesInfoChart
v-else
:chart-data="chartData" />
</div>
</div>
</div>
</template>
@@ -35,16 +98,21 @@
import { DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { BarChart3, List } from 'lucide-vue-next';
import { useGameLogStore, useInstanceStore, useSearchStore, useUserStore, useVrcxStore } from '../../../stores';
import { useGameLogStore, useInstanceStore, useSearchStore, useVrcxStore } from '../../../stores';
import { compareByCreatedAt, localeIncludes, parseLocation, timeToText } from '../../../shared/utils';
import { DataTableLayout } from '../../ui/data-table';
import { InputGroupField } from '../../../components/ui/input-group';
import { ToggleGroup, ToggleGroupItem } from '../../../components/ui/toggle-group';
import { TooltipWrapper } from '../../../components/ui/tooltip';
import { createColumns } from './previousInstancesInfoColumns.jsx';
import { database } from '../../../services/database';
import { useVrcxVueTable } from '../../../lib/table/useVrcxVueTable';
import { lookupUser } from '../../../coordinators/userCoordinator';
import PreviousInstancesInfoChart from './PreviousInstancesInfoChart.vue';
const { previousInstancesInfoDialog, previousInstancesInfoState } = storeToRefs(useInstanceStore());
const { gameLogIsFriend, gameLogIsFavorite } = useGameLogStore();
const { t } = useI18n();
@@ -107,7 +175,10 @@
});
const { stringComparer } = storeToRefs(useSearchStore());
const vrcxStore = useVrcxStore();
const viewMode = ref('table');
const chartData = ref([]);
const chartLoading = ref(false);
const displayRows = computed(() => {
const q = String(search.value ?? '')
@@ -168,6 +239,28 @@
sortBy.value = sorting;
};
function handleViewModeChange(value) {
if (value) {
viewMode.value = value;
if (value === 'chart' && chartData.value.length === 0) {
loadChartData();
}
}
}
async function loadChartData() {
chartLoading.value = true;
try {
const data = await database.getPlayerDetailFromInstance(location.value.tag);
chartData.value = data;
} catch (error) {
console.error('Failed to load chart data:', error);
chartData.value = [];
} finally {
chartLoading.value = false;
}
}
watch(
() => previousInstancesInfoDialog.value.visible,
(value) => {
@@ -176,6 +269,9 @@
init();
refreshPreviousInstancesInfoTable();
});
} else {
viewMode.value = 'table';
chartData.value = [];
}
},
{ immediate: true }

View File

@@ -2024,7 +2024,9 @@
"previous_instances": {
"header": "Previous Instances",
"info": "Previous Instance Info",
"search_placeholder": "Search"
"search_placeholder": "Search",
"table_view": "Table View",
"chart_view": "Chart View"
},
"group_member_moderation": {
"header": "Group Member Moderation",

View File

@@ -1306,6 +1306,32 @@ const gameLog = {
return players;
},
/**
* @param {string} location
* @returns {Promise<Array<{created_at: string, display_name: string, user_id: string, time: number}>>}
*/
async getPlayerDetailFromInstance(location) {
const entries = [];
await sqliteService.execute(
(dbRow) => {
entries.push({
created_at: dbRow[0],
display_name: dbRow[1],
user_id: dbRow[2],
time: dbRow[3] || 0
});
},
`SELECT created_at, display_name, user_id, time
FROM gamelog_join_leave
WHERE location = @location AND type = 'OnPlayerLeft'
ORDER BY created_at ASC`,
{
'@location': location
}
);
return entries;
},
async getPreviousDisplayNamesByUserId(ref) {
var data = new Map();
await sqliteService.execute(