refactor: Organize Project Structure (#1211)

* refactor: Organize Project Structure

* fix

* fix

* rm security

* fix
This commit is contained in:
pa
2025-04-18 15:04:03 +09:00
committed by GitHub
parent 59d3ead781
commit 6bda44be52
106 changed files with 172 additions and 165 deletions
+32
View File
@@ -0,0 +1,32 @@
<template>
<div id="chart" class="x-container">
<div class="options-container" style="margin-top: 0">
<span class="header">{{ $t('view.charts.header') }}</span>
</div>
<InstanceActivity
:get-world-name="getWorldName"
:is-dark-mode="isDarkMode"
:dt-hour12="dtHour12"
:friends-map="friendsMap"
:local-favorite-friends="localFavoriteFriends"
@open-previous-instance-info-dialog="$emit('open-previous-instance-info-dialog', $event)" />
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
</div>
</template>
<script>
import InstanceActivity from './components/InstanceActivity.vue';
export default {
name: 'ChartsTab',
components: {
InstanceActivity
},
props: {
getWorldName: Function,
isDarkMode: Boolean,
dtHour12: Boolean,
friendsMap: Map,
localFavoriteFriends: Set
}
};
</script>
@@ -0,0 +1,826 @@
<template>
<div>
<div class="options-container instance-activity" style="margin-top: 0">
<div>
<span>{{ $t('view.charts.instance_activity.header') }}</span>
<el-popover placement="bottom-start" trigger="hover" width="300">
<div class="tips-popover">
<div>{{ $t('view.charts.instance_activity.tips.online_time') }}</div>
<div>{{ $t('view.charts.instance_activity.tips.click_Y_axis') }}</div>
<div>{{ $t('view.charts.instance_activity.tips.click_instance_name') }}</div>
<div>
<i class="el-icon-warning-outline"></i
><i>{{ $t('view.charts.instance_activity.tips.accuracy_notice') }}</i>
</div>
</div>
<i
slot="reference"
class="el-icon-info"
style="margin-left: 5px; font-size: 12px; opacity: 0.7"></i>
</el-popover>
</div>
<div>
<el-tooltip :content="$t('view.charts.instance_activity.refresh')" placement="top"
><el-button icon="el-icon-refresh" circle style="margin-right: 9px" @click="reloadData"></el-button
></el-tooltip>
<el-tooltip :content="$t('view.charts.instance_activity.settings.header')" placement="top">
<el-popover placement="bottom" trigger="click" style="margin-right: 9px">
<div class="settings">
<div>
<span>{{ $t('view.charts.instance_activity.settings.bar_width') }}</span>
<div>
<el-slider
v-model.lazy="barWidth"
:max="50"
:min="1"
@change="changeBarWidth"></el-slider>
</div>
</div>
<div>
<span>{{ $t('view.charts.instance_activity.settings.show_detail') }}</span>
<el-switch v-model="isDetailVisible" @change="changeIsDetailInstanceVisible">
</el-switch>
</div>
<div v-if="isDetailVisible">
<span>{{ $t('view.charts.instance_activity.settings.show_solo_instance') }}</span>
<el-switch v-model="isSoloInstanceVisible" @change="changeIsSoloInstanceVisible">
</el-switch>
</div>
<div v-if="isDetailVisible">
<span>{{ $t('view.charts.instance_activity.settings.show_no_friend_instance') }}</span>
<el-switch
v-model="isNoFriendInstanceVisible"
@change="changeIsNoFriendInstanceVisible">
</el-switch>
</div>
</div>
<el-button slot="reference" icon="el-icon-setting" circle></el-button>
</el-popover>
</el-tooltip>
<el-button-group style="margin-right: 10px">
<el-tooltip :content="$t('view.charts.instance_activity.previous_day')" placement="top">
<el-button
icon="el-icon-arrow-left"
:disabled="isPrevDayBtnDisabled"
@click="changeSelectedDateFromBtn(false)"></el-button>
</el-tooltip>
<el-tooltip :content="$t('view.charts.instance_activity.next_day')" placement="top">
<el-button :disabled="isNextDayBtnDisabled" @click="changeSelectedDateFromBtn(true)"
><i class="el-icon-arrow-right el-icon--right"></i
></el-button>
</el-tooltip>
</el-button-group>
<el-date-picker
v-model="selectedDate"
type="date"
:clearable="false"
align="right"
:picker-options="{
disabledDate: (time) => getDatePickerDisabledDate(time)
}"
@change="reloadData"></el-date-picker>
</div>
</div>
<div style="position: relative">
<el-statistic :title="$t('view.charts.instance_activity.online_time')">
<template #formatter>
<span :style="isDarkMode ? 'color:rgb(120,120,120)' : ''">{{ totalOnlineTime }}</span>
</template>
</el-statistic>
</div>
<div ref="activityChartRef" style="width: 100%"></div>
<div v-if="!isLoading && activityData.length === 0" class="nodata">
<span>No data here, try another day</span>
</div>
<transition name="el-fade-in-linear">
<div v-show="isDetailVisible && !isLoading && !activityData.length === 0" class="divider">
<el-divider>·</el-divider>
</div>
</transition>
<InstanceActivityDetail
v-for="arr in filteredActivityDetailData"
:key="arr[0].location + arr[0].created_at"
ref="activityDetailChartRef"
:activity-detail-data="arr"
:is-dark-mode="isDarkMode"
:dt-hour12="dtHour12"
:bar-width="barWidth"
@open-previous-instance-info-dialog="$emit('open-previous-instance-info-dialog', $event)" />
</div>
</template>
<script>
import dayjs from 'dayjs';
import database from '../../../service/database';
import utils from '../../../classes/utils';
import configRepository from '../../../service/config';
import InstanceActivityDetail from './InstanceActivityDetail.vue';
export default {
name: 'InstanceActivity',
components: {
InstanceActivityDetail
},
inject: ['API'],
props: {
getWorldName: { type: Function, default: () => [] },
isDarkMode: Boolean,
dtHour12: Boolean,
friendsMap: Map,
localFavoriteFriends: Set
},
data() {
return {
// echarts and observer
echarts: null,
echartsInstance: null,
resizeObserver: null,
intersectionObservers: [],
selectedDate: dayjs().add(-1, 'day'),
// data
activityData: [],
activityDetailData: [],
allDateOfActivity: new Set(),
worldNameArray: [],
// options
isLoading: true,
// settings
barWidth: 25,
isDetailVisible: true,
isSoloInstanceVisible: true,
isNoFriendInstanceVisible: true
};
},
computed: {
totalOnlineTime() {
return utils.timeToText(
this.activityData.reduce((acc, item) => acc + item.time, 0),
true
);
},
isNextDayBtnDisabled() {
return dayjs(this.selectedDate).isSameOrAfter(this.allDateOfActivityArray[0], 'day');
},
isPrevDayBtnDisabled() {
return dayjs(this.selectedDate).isSame(
this.allDateOfActivityArray[this.allDateOfActivityArray.length - 1],
'day'
);
},
allDateOfActivityArray() {
return this.allDateOfActivity
? Array.from(this.allDateOfActivity)
.map((item) => dayjs(item))
.sort((a, b) => b.valueOf() - a.valueOf())
: [];
},
filteredActivityDetailData() {
if (!this.isDetailVisible) {
return [];
}
let result = [...this.activityDetailData];
if (!this.isSoloInstanceVisible) {
result = result.filter((arr) => arr.length > 1);
}
if (!this.isNoFriendInstanceVisible) {
result = result.filter((arr) => {
// solo instance
if (arr.length === 1) {
return true;
}
return arr.some((item) => item.isFriend);
});
}
return result;
}
},
watch: {
isDarkMode() {
if (this.echartsInstance) {
this.echartsInstance.dispose();
this.echartsInstance = null;
this.initEcharts();
}
},
dtHour12() {
if (this.echartsInstance) {
this.initEcharts();
}
}
},
activated() {
// first time also call activated
if (this.echartsInstance) {
this.reloadData();
}
},
deactivated() {
// prevent resize animation when switch tab
this.resizeObserver.disconnect();
},
created() {
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.echartsInstance.resize({
width: entry.contentRect.width,
animation: {
duration: 300
}
});
}
});
configRepository.getInt('VRCX_InstanceActivityBarWidth', 25).then((value) => {
this.barWidth = value;
});
configRepository.getBool('VRCX_InstanceActivityDetailVisible', true).then((value) => {
this.isDetailVisible = value;
});
configRepository.getBool('VRCX_InstanceActivitySoloInstanceVisible', true).then((value) => {
this.isSoloInstanceVisible = value;
});
configRepository.getBool('VRCX_InstanceActivityNoFriendInstanceVisible', true).then((value) => {
this.isNoFriendInstanceVisible = value;
});
},
async mounted() {
try {
this.getAllDateOfActivity();
const [echartsModule] = await Promise.all([
// lazy load echarts
utils.loadEcharts().catch((error) => {
console.error('lazy load echarts failed', error);
return null;
}),
this.getActivityData()
]);
if (echartsModule) {
this.echarts = echartsModule;
}
if (echartsModule) {
// activity data is ready, but world name data isn't ready
// so init echarts with empty data, reduce the render time of init screen
this.initEcharts(true);
this.getWorldNameData();
} else {
this.isLoading = false;
}
} catch (error) {
console.error('error in mounted', error);
}
},
methods: {
async reloadData() {
this.isLoading = true;
await this.getActivityData();
this.getWorldNameData();
// possibility past 24:00
this.getAllDateOfActivity();
},
// echarts - start
initEcharts() {
const chartsHeight = this.activityData.length * (this.barWidth + 10) + 200;
const chartDom = this.$refs.activityChartRef;
const afterInit = () => {
this.echartsInstance.resize({
height: chartsHeight,
animation: {
duration: 300
}
});
const handleClickYAxisLabel = (params) => {
const detailDataIdx = this.filteredActivityDetailData.findIndex((arr) => {
const sameLocation = arr[0]?.location === this.activityData[params?.dataIndex]?.location;
const sameJoinTime = arr
.find((item) => item.user_id === this.API.currentUser.id)
?.joinTime.isSame(this.activityData[params?.dataIndex].joinTime);
return sameLocation && sameJoinTime;
});
if (detailDataIdx === -1) {
// no detail chart down below, it's hidden, so can't find instance data index
console.error(
"handleClickYAxisLabel failed, likely current user wasn't in this instance.",
params
);
} else {
this.$refs.activityDetailChartRef[detailDataIdx].$el.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
};
const options = this.activityData.length ? this.getNewOption() : {};
this.echartsInstance.setOption(options, { lazyUpdate: true });
this.echartsInstance.on('click', 'yAxis', handleClickYAxisLabel);
this.isLoading = false;
};
const initEchartsInstance = () => {
this.echartsInstance = this.echarts.init(chartDom, `${this.isDarkMode ? 'dark' : null}`, {
height: chartsHeight
});
this.resizeObserver.observe(chartDom);
};
const loadEchartsWithTimeout = () => {
const timeout = 5000;
let time = 0;
const timer = setInterval(() => {
if (this.echarts) {
initEchartsInstance();
afterInit();
clearInterval(timer);
return;
}
time += 100;
if (time >= timeout) {
clearInterval(timer);
console.error('echarts init timeout');
}
}, 100);
};
if (!this.echartsInstance) {
if (!this.echarts) {
loadEchartsWithTimeout();
} else {
initEchartsInstance();
afterInit();
}
} else {
afterInit();
}
},
getNewOption() {
const getTooltip = (params) => {
const activityData = this.activityData;
const param = params[1];
if (!activityData || !activityData[param.dataIndex]) {
return '';
}
const instanceData = activityData[param.dataIndex];
const format = this.dtHour12 ? 'hh:mm:ss A' : 'HH:mm:ss';
const formattedLeftDateTime = dayjs(instanceData.leaveTime).format(format);
const formattedJoinDateTime = dayjs(instanceData.joinTime).format(format);
const timeString = utils.timeToText(param.data, true);
const color = param.color;
const name = param.name;
const location = utils.parseLocation(instanceData.location);
return `
<div style="display: flex; align-items: center;">
<div style="width: 10px; height: 55px; background-color: ${color}; margin-right: 5px;"></div>
<div>
<div>${name} #${location.instanceName} ${location.accessTypeName}</div>
<div>${formattedJoinDateTime} - ${formattedLeftDateTime}</div>
<div>${timeString}</div>
</div>
</div>
`;
};
const format = this.dtHour12 ? 'hh:mm A' : 'HH:mm';
const echartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: getTooltip
},
grid: {
top: 50,
left: 160,
right: 90
},
yAxis: {
type: 'category',
axisLabel: {
interval: 0,
formatter: (value) => (value.length > 20 ? `${value.slice(0, 20)}...` : value)
},
inverse: true,
data: this.worldNameArray,
triggerEvent: true
},
xAxis: {
type: 'value',
min: 0,
// axisLabel max 24:00
max: 24 * 60 * 60 * 1000,
// axisLabel interval 3hr
interval: 3 * 60 * 60 * 1000,
axisLine: { show: true },
axisLabel: {
formatter: (value) => dayjs(value).utc().format(format)
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: [
{
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data: this.activityData.map((item, idx) => {
if (idx === 0) {
const midnight = dayjs.tz(this.selectedDate).startOf('day');
if (midnight.isAfter(item.joinTime)) {
return 0;
}
}
return item.joinTime - dayjs.tz(this.selectedDate).startOf('day');
})
},
{
name: 'Time',
type: 'bar',
stack: 'Total',
colorBy: 'data',
barWidth: this.barWidth,
emphasis: {
focus: 'self'
},
itemStyle: {
borderRadius: 2,
shadowBlur: 2,
shadowOffsetX: 0.7,
shadowOffsetY: 0.5
},
data: this.activityData.map((item, idx) => {
// If the joinTime of the first data is on the previous day,
// and the data traverses midnight, the duration starts at midnight
if (idx === 0) {
const midnight = dayjs.tz(this.selectedDate).startOf('day');
if (midnight.isAfter(item.joinTime)) {
return item.leaveTime - dayjs.tz(midnight);
}
}
return item.time;
})
}
],
backgroundColor: 'transparent'
};
return echartsOption;
},
// echarts - end
// settings - start
changeBarWidth(value) {
this.barWidth = value;
this.initEcharts();
configRepository.setInt('VRCX_InstanceActivityBarWidth', value).finally(() => {
this.handleChangeSettings();
});
},
changeIsDetailInstanceVisible(value) {
this.isDetailVisible = value;
configRepository.setBool('VRCX_InstanceActivityDetailVisible', value).finally(() => {
this.handleChangeSettings();
});
},
changeIsSoloInstanceVisible(value) {
this.isSoloInstanceVisible = value;
configRepository.setBool('VRCX_InstanceActivitySoloInstanceVisible', value).finally(() => {
this.handleChangeSettings();
});
},
changeIsNoFriendInstanceVisible(value) {
this.isNoFriendInstanceVisible = value;
configRepository.setBool('VRCX_InstanceActivityNoFriendInstanceVisible', value).finally(() => {
this.handleChangeSettings();
});
},
handleChangeSettings() {
this.$nextTick(() => {
if (this.$refs.activityDetailChartRef) {
this.$refs.activityDetailChartRef.forEach((child) => {
requestAnimationFrame(() => {
if (child.echartsInstance) {
child.initEcharts();
}
});
});
}
});
//rerender detail chart
},
// settings - end
// options - start
changeSelectedDateFromBtn(isNext = false) {
if (!this.allDateOfActivityArray || this.allDateOfActivityArray.length === 0) {
return;
}
const idx = this.allDateOfActivityArray.findIndex((date) => date.isSame(this.selectedDate, 'day'));
if (idx !== -1) {
const newIdx = isNext ? idx - 1 : idx + 1;
if (newIdx >= 0 && newIdx < this.allDateOfActivityArray.length) {
this.selectedDate = this.allDateOfActivityArray[newIdx];
this.reloadData();
return;
}
}
this.selectedDate = isNext
? this.allDateOfActivityArray[this.allDateOfActivityArray.length - 1]
: this.allDateOfActivityArray[0];
this.reloadData();
},
getDatePickerDisabledDate(time) {
if (
time > Date.now() ||
this.allDateOfActivityArray[this.allDateOfActivityArray.length - 1]
?.add('-1', 'day')
.isAfter(time, 'day') ||
!this.allDateOfActivity
) {
return true;
}
return !this.allDateOfActivity.has(dayjs(time).format('YYYY-MM-DD'));
},
// options - end
// data - start
async getWorldNameData() {
this.worldNameArray = await Promise.all(
this.activityData.map(async (item) => {
try {
return await this.getWorldName(item.location);
} catch {
// TODO: no notification
console.error('getWorldName failed location', item.location);
return 'Unknown world';
}
})
);
if (this.worldNameArray) {
this.initEcharts();
}
},
async getAllDateOfActivity() {
const utcDateStrings = await database.getDateOfInstanceActivity();
const uniqueDates = new Set();
for (const utcString of utcDateStrings) {
const formattedDate = dayjs.utc(utcString).tz().format('YYYY-MM-DD');
uniqueDates.add(formattedDate);
}
this.allDateOfActivity = uniqueDates;
},
async getActivityData() {
const localStartDate = dayjs.tz(this.selectedDate).startOf('day').toISOString();
const localEndDate = dayjs.tz(this.selectedDate).endOf('day').toISOString();
const dbData = await database.getInstanceActivity(localStartDate, localEndDate);
const transformData = (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 === this.API.currentUser.id ? null : this.friendsMap.has(item.user_id),
isFavorite:
item.user_id === this.API.currentUser.id ? null : this.localFavoriteFriends.has(item.user_id)
});
this.activityData = dbData.currentUserData.map(transformData);
const transformAndSort = (arr) => {
return arr.map(transformData).sort((a, b) => {
const timeDiff = Math.abs(a.joinTime.diff(b.joinTime, 'second'));
// recording delay, under 3s is considered the same time entry, beautify the chart
return timeDiff < 3 ? a.leaveTime - b.leaveTime : a.joinTime - b.joinTime;
});
};
const filterByLocation = (innerArray, locationSet) => {
return innerArray.every((innerObject) => locationSet.has(innerObject.location));
};
const locationSet = new Set(this.activityData.map((item) => item.location));
const preSplitActivityDetailData = Array.from(dbData.detailData.values())
.map(transformAndSort)
.filter((innerArray) => filterByLocation(innerArray, locationSet));
this.activityDetailData = this.handleSplitActivityDetailData(
preSplitActivityDetailData,
this.API.currentUser.id
);
if (this.activityDetailData.length) {
this.$nextTick(() => {
this.handleIntersectionObserver();
});
}
},
handleSplitActivityDetailData(activityDetailData, currentUserId) {
function countTargetIdOccurrences(innerArray, targetId) {
let count = 0;
for (const obj of innerArray) {
if (obj.user_id === targetId) {
count++;
}
}
return count;
}
function areIntervalsOverlapping(objA, objB) {
const isObj1EndTimeBeforeObj2StartTime = objA.leaveTime.isBefore(objB.joinTime, 'second');
const isObj2EndTimeBeforeObj1StartTime = objB.leaveTime.isBefore(objA.joinTime, 'second');
return !(isObj1EndTimeBeforeObj2StartTime || isObj2EndTimeBeforeObj1StartTime);
}
function buildOverlapGraph(innerArray) {
const numObjects = innerArray.length;
const adjacencyList = Array.from({ length: numObjects }, () => []);
for (let i = 0; i < numObjects; i++) {
for (let j = i + 1; j < numObjects; j++) {
if (areIntervalsOverlapping(innerArray[i], innerArray[j])) {
adjacencyList[i].push(j);
adjacencyList[j].push(i);
}
}
}
return adjacencyList;
}
function depthFirstSearch(nodeIndex, visited, graph, component) {
visited[nodeIndex] = true;
component.push(nodeIndex);
for (const neighborIndex of graph[nodeIndex]) {
if (!visited[neighborIndex]) {
depthFirstSearch(neighborIndex, visited, graph, component);
}
}
}
function findConnectedComponents(graph, numNodes) {
const visited = new Array(numNodes).fill(false);
const components = [];
for (let i = 0; i < numNodes; i++) {
if (!visited[i]) {
const component = [];
depthFirstSearch(i, visited, graph, component);
components.push(component);
}
}
return components;
}
function processOuterArrayWithTargetId(outerArray, targetId) {
let i = 0;
while (i < outerArray.length) {
let currentInnerArray = outerArray[i];
let targetIdCount = countTargetIdOccurrences(currentInnerArray, targetId);
if (targetIdCount > 1) {
let graph = buildOverlapGraph(currentInnerArray);
let connectedComponents = findConnectedComponents(graph, currentInnerArray.length);
let newInnerArrays = connectedComponents.map((componentIndices) => {
return componentIndices.map((index) => currentInnerArray[index]);
});
outerArray.splice(i, 1, ...newInnerArrays);
i += newInnerArrays.length;
} else {
i += 1;
}
}
return outerArray.sort((a, b) => a[0].joinTime - b[0].joinTime);
}
return processOuterArrayWithTargetId(activityDetailData, currentUserId);
},
// data - end
// intersection observer - start
handleIntersectionObserver() {
this.$refs.activityDetailChartRef?.forEach((child, index) => {
const observer = new IntersectionObserver(this.handleIntersection.bind(this, index));
observer.observe(child.$el);
this.intersectionObservers[index] = observer;
});
},
handleIntersection(index, entries) {
if (!entries) {
console.error('handleIntersection failed');
return;
}
entries.forEach((entry) => {
if (entry.isIntersecting && this.$refs.activityDetailChartRef[index]) {
this.$refs.activityDetailChartRef[index].initEcharts();
this.intersectionObservers[index].unobserve(entry.target);
}
});
}
// intersection observer - end
}
};
</script>
<style lang="scss" scoped>
%flex {
display: flex;
align-items: center;
}
%flex-between {
justify-content: space-between;
}
.instance-activity {
@extend %flex;
@extend %flex-between;
& > div:first-child {
@extend %flex-between;
}
& > div {
@extend %flex;
> span {
flex-shrink: 0;
}
}
}
.tips-popover {
& > div {
margin-bottom: 5px;
font-size: 12px;
}
& > div:last-child {
@extend %flex;
margin-top: 10px;
i {
margin-right: 3px;
}
}
& .el-icon-warning-outline {
font-size: 12px;
}
}
.settings {
& > div {
@extend %flex;
@extend %flex-between;
padding: 0 2px;
height: 30px;
> span {
flex-shrink: 0;
}
}
& > div:first-child {
> div {
width: 160px;
padding-left: 20px;
}
}
}
.nodata {
display: flex;
align-items: center;
justify-content: center;
margin-top: 100px;
color: #5c5c5c;
}
.divider {
padding: 0 400px;
transition: top 0.3s ease;
}
// override el-ui
.el-date-editor.el-input,
.el-date-editor.el-input__inner {
width: 200px;
}
.el-divider__text {
padding-left: 10px;
padding-right: 10px;
}
</style>
@@ -0,0 +1,343 @@
<template>
<div style="width: 100%">
<div style="height: 25px; margin-top: 60px">
<transition name="el-fade-in-linear">
<location
v-show="!isLoading"
class="location"
:location="activityDetailData[0].location"
is-open-previous-instance-info-dialog
@open-previous-instance-info-dialog="
$emit('open-previous-instance-info-dialog', $event)
"></location>
</transition>
</div>
<div ref="activityDetailChart"></div>
</div>
</template>
<script>
import dayjs from 'dayjs';
import utils from '../../../classes/utils';
import Location from '../../../components/Location.vue';
export default {
name: 'InstanceActivityDetail',
components: {
Location
},
inject: ['API', 'showUserDialog'],
props: {
activityDetailData: {
type: Array,
required: true
},
isDarkMode: {
type: Boolean,
required: true
},
dtHour12: {
type: Boolean,
required: true
},
barWidth: {
type: Number,
required: true,
default: 10
}
},
data() {
return {
echarts: null,
isLoading: true,
echartsInstance: null,
usersFirstActivity: null,
resizeObserver: null
};
},
computed: {
startTimeStamp() {
return this.activityDetailData
.find((item) => item.user_id === this.API.currentUser.id)
?.joinTime.valueOf();
},
endTimeStamp() {
return this.activityDetailData
.find((item) => item.user_id === this.API.currentUser.id)
?.leaveTime.valueOf();
}
},
watch: {
isDarkMode() {
if (this.echartsInstance) {
this.echartsInstance.dispose();
this.echartsInstance = null;
this.initEcharts();
}
},
dtHour12() {
if (this.echartsInstance) {
this.initEcharts();
}
}
},
created() {
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.echartsInstance.resize({
width: entry.contentRect.width,
animation: {
duration: 300
}
});
}
});
},
mounted() {
this.initEcharts(true);
},
deactivated() {
// prevent switch tab play resize animation
this.resizeObserver.disconnect();
},
methods: {
async initEcharts(isFirstLoad = false) {
if (!this.echarts) {
this.echarts = await utils.loadEcharts();
}
const chartsHeight = this.activityDetailData.length * (this.barWidth + 10) + 200;
const chartDom = this.$refs.activityDetailChart;
if (!this.echartsInstance) {
this.echartsInstance = this.echarts.init(chartDom, `${this.isDarkMode ? 'dark' : null}`, {
height: chartsHeight,
useDirtyRect: this.activityDetailData.length > 80
});
this.resizeObserver.observe(chartDom);
}
this.echartsInstance.resize({
height: chartsHeight,
animation: {
duration: 300
}
});
this.echartsInstance.setOption(isFirstLoad ? {} : this.getNewOption(), { lazyUpdate: true });
this.echartsInstance.on('click', 'yAxis', this.handleClickYAxisLabel);
setTimeout(() => {
this.isLoading = false;
}, 200);
},
handleClickYAxisLabel(params) {
const userData = this.usersFirstActivity[params.dataIndex];
if (userData?.user_id) {
this.showUserDialog(userData.user_id);
}
},
getNewOption() {
// grouping player activity entries by user_id and calculate below:
// 1. offset: the time from startTimeStamp or the previous entry's tail to the current entry's joinTime
// 2. time: the time the user spent in the instance
// 3. tail: the time from startTimeStamp to the current entry's leaveTime
// 4. entry: the original activity detail entry
const userGroupedEntries = new Map();
// uniqueUserEntries has each user's first entry and used to keep the order of the users calculated in InstanceActivity.vue
const uniqueUserEntries = [];
for (const entry of this.activityDetailData) {
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() - this.startTimeStamp
: entry.joinTime.valueOf() - this.startTimeStamp - 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);
}
this.usersFirstActivity = uniqueUserEntries;
const generateSeries = () => {
const maxEntryCount = Math.max(
...Array.from(userGroupedEntries.values()).map((entries) => entries.length)
);
const placeholderSeries = (data) => {
return {
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data
};
};
const timeSeries = (data) => {
return {
name: 'Time',
type: 'bar',
stack: 'Total',
colorBy: 'data',
barWidth: this.barWidth,
emphasis: {
focus: 'self'
},
itemStyle: {
borderRadius: 2,
shadowBlur: 2,
shadowOffsetX: 0.7,
shadowOffsetY: 0.5
},
data
};
};
// generate series having placeholder and time series for each user
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 = this.activityDetailData.find((item) => item.display_name === display_name);
if (!foundItem) {
return '';
}
if (foundItem.isFavorite) {
return '⭐';
}
if (foundItem.isFriend) {
return '💚';
}
return '';
};
const getTooltip = (params) => {
const activityDetailData = this.activityDetailData;
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 (!activityDetailData || !userData) {
return '';
}
// first, find the user's entries, then get the focused entry
const instanceData = userGroupedEntries.get(userData.user_id)[targetEntryIndex].entry;
const format = this.dtHour12 ? 'hh:mm:ss A' : 'HH:mm:ss';
const formattedLeftDateTime = dayjs(instanceData.leaveTime).format(format);
const formattedJoinDateTime = dayjs(instanceData.joinTime).format(format);
const timeString = utils.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: 5px;"></div>
<div>
<div>${instanceData.display_name} ${friendOrFavIcon(instanceData.display_name)}</div>
<div>${formattedJoinDateTime} - ${formattedLeftDateTime}</div>
<div>${timeString}</div>
</div>
</div>
`;
};
const format = this.dtHour12 ? '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: this.endTimeStamp - this.startTimeStamp,
axisLine: { show: true },
axisLabel: {
formatter: (value) => dayjs(value + this.startTimeStamp).format(format)
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: generateSeries(),
backgroundColor: 'rgba(0, 0, 0, 0)'
};
return echartsOption;
}
}
};
</script>
<style scoped>
.location {
display: flex;
align-items: center;
justify-content: center;
}
</style>