Add activity widget
This commit is contained in:
		
							parent
							
								
									d9986b7a2f
								
							
						
					
					
						commit
						0508d5f643
					
				
					 6 changed files with 276 additions and 1 deletions
				
			
		| 
						 | 
				
			
			@ -606,7 +606,8 @@ export default Vue.extend({
 | 
			
		|||
				'calendar',
 | 
			
		||||
				'rss',
 | 
			
		||||
				'trends',
 | 
			
		||||
				'clock'
 | 
			
		||||
				'clock',
 | 
			
		||||
				'activity',
 | 
			
		||||
			];
 | 
			
		||||
 | 
			
		||||
			this.$root.menu({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										84
									
								
								src/client/widgets/activity.calendar.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/client/widgets/activity.calendar.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,84 @@
 | 
			
		|||
<template>
 | 
			
		||||
<svg viewBox="0 0 21 7">
 | 
			
		||||
	<rect v-for="record in data" class="day"
 | 
			
		||||
		width="1" height="1"
 | 
			
		||||
		:x="record.x" :y="record.date.weekday"
 | 
			
		||||
		rx="1" ry="1"
 | 
			
		||||
		fill="transparent">
 | 
			
		||||
		<title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title>
 | 
			
		||||
	</rect>
 | 
			
		||||
	<rect v-for="record in data" class="day"
 | 
			
		||||
		:width="record.v" :height="record.v"
 | 
			
		||||
		:x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
 | 
			
		||||
		rx="1" ry="1"
 | 
			
		||||
		:fill="record.color"
 | 
			
		||||
		style="pointer-events: none;"/>
 | 
			
		||||
	<rect class="today"
 | 
			
		||||
		width="1" height="1"
 | 
			
		||||
		:x="data[0].x" :y="data[0].date.weekday"
 | 
			
		||||
		rx="1" ry="1"
 | 
			
		||||
		fill="none"
 | 
			
		||||
		stroke-width="0.1"
 | 
			
		||||
		stroke="#f73520"/>
 | 
			
		||||
</svg>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	props: ['data'],
 | 
			
		||||
	created() {
 | 
			
		||||
		for (const d of this.data) {
 | 
			
		||||
			d.total = d.notes + d.replies + d.renotes;
 | 
			
		||||
		}
 | 
			
		||||
		const peak = Math.max.apply(null, this.data.map(d => d.total));
 | 
			
		||||
 | 
			
		||||
		const now = new Date();
 | 
			
		||||
		const year = now.getFullYear();
 | 
			
		||||
		const month = now.getMonth();
 | 
			
		||||
		const day = now.getDate();
 | 
			
		||||
 | 
			
		||||
		let x = 20;
 | 
			
		||||
		this.data.slice().forEach((d, i) => {
 | 
			
		||||
			d.x = x;
 | 
			
		||||
 | 
			
		||||
			const date = new Date(year, month, day - i);
 | 
			
		||||
			d.date = {
 | 
			
		||||
				year: date.getFullYear(),
 | 
			
		||||
				month: date.getMonth(),
 | 
			
		||||
				day: date.getDate(),
 | 
			
		||||
				weekday: date.getDay()
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			d.v = peak == 0 ? 0 : d.total / (peak / 2);
 | 
			
		||||
			if (d.v > 1) d.v = 1;
 | 
			
		||||
			const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170;
 | 
			
		||||
			const cs = d.v * 100;
 | 
			
		||||
			const cl = 15 + ((1 - d.v) * 80);
 | 
			
		||||
			d.color = `hsl(${ch}, ${cs}%, ${cl}%)`;
 | 
			
		||||
 | 
			
		||||
			if (d.date.weekday == 0) x--;
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
svg {
 | 
			
		||||
	display: block;
 | 
			
		||||
	padding: 16px;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
	> rect {
 | 
			
		||||
		transform-origin: center;
 | 
			
		||||
 | 
			
		||||
		&.day {
 | 
			
		||||
			&:hover {
 | 
			
		||||
				fill: rgba(#000, 0.05);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										108
									
								
								src/client/widgets/activity.chart.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/client/widgets/activity.chart.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
<template>
 | 
			
		||||
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown">
 | 
			
		||||
	<polyline
 | 
			
		||||
		:points="pointsNote"
 | 
			
		||||
		fill="none"
 | 
			
		||||
		stroke-width="1"
 | 
			
		||||
		stroke="#41ddde"/>
 | 
			
		||||
	<polyline
 | 
			
		||||
		:points="pointsReply"
 | 
			
		||||
		fill="none"
 | 
			
		||||
		stroke-width="1"
 | 
			
		||||
		stroke="#f7796c"/>
 | 
			
		||||
	<polyline
 | 
			
		||||
		:points="pointsRenote"
 | 
			
		||||
		fill="none"
 | 
			
		||||
		stroke-width="1"
 | 
			
		||||
		stroke="#a1de41"/>
 | 
			
		||||
	<polyline
 | 
			
		||||
		:points="pointsTotal"
 | 
			
		||||
		fill="none"
 | 
			
		||||
		stroke-width="1"
 | 
			
		||||
		stroke="#555"
 | 
			
		||||
		stroke-dasharray="2 2"/>
 | 
			
		||||
</svg>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import i18n from '../i18n';
 | 
			
		||||
 | 
			
		||||
function dragListen(fn) {
 | 
			
		||||
	window.addEventListener('mousemove',  fn);
 | 
			
		||||
	window.addEventListener('mouseleave', dragClear.bind(null, fn));
 | 
			
		||||
	window.addEventListener('mouseup',    dragClear.bind(null, fn));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function dragClear(fn) {
 | 
			
		||||
	window.removeEventListener('mousemove',  fn);
 | 
			
		||||
	window.removeEventListener('mouseleave', dragClear);
 | 
			
		||||
	window.removeEventListener('mouseup',    dragClear);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	i18n,
 | 
			
		||||
	props: ['data'],
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			viewBoxX: 147,
 | 
			
		||||
			viewBoxY: 60,
 | 
			
		||||
			zoom: 1,
 | 
			
		||||
			pos: 0,
 | 
			
		||||
			pointsNote: null,
 | 
			
		||||
			pointsReply: null,
 | 
			
		||||
			pointsRenote: null,
 | 
			
		||||
			pointsTotal: null
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		for (const d of this.data) {
 | 
			
		||||
			d.total = d.notes + d.replies + d.renotes;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.render();
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		render() {
 | 
			
		||||
			const peak = Math.max.apply(null, this.data.map(d => d.total));
 | 
			
		||||
			if (peak != 0) {
 | 
			
		||||
				const data = this.data.slice().reverse();
 | 
			
		||||
				this.pointsNote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.notes / peak)) * this.viewBoxY}`).join(' ');
 | 
			
		||||
				this.pointsReply = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' ');
 | 
			
		||||
				this.pointsRenote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.renotes / peak)) * this.viewBoxY}`).join(' ');
 | 
			
		||||
				this.pointsTotal = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ');
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		onMousedown(e) {
 | 
			
		||||
			const clickX = e.clientX;
 | 
			
		||||
			const clickY = e.clientY;
 | 
			
		||||
			const baseZoom = this.zoom;
 | 
			
		||||
			const basePos = this.pos;
 | 
			
		||||
 | 
			
		||||
			// 動かした時
 | 
			
		||||
			dragListen(me => {
 | 
			
		||||
				let moveLeft = me.clientX - clickX;
 | 
			
		||||
				let moveTop = me.clientY - clickY;
 | 
			
		||||
 | 
			
		||||
				this.zoom = baseZoom + (-moveTop / 20);
 | 
			
		||||
				this.pos = basePos + moveLeft;
 | 
			
		||||
				if (this.zoom < 1) this.zoom = 1;
 | 
			
		||||
				if (this.pos > 0) this.pos = 0;
 | 
			
		||||
				if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX);
 | 
			
		||||
 | 
			
		||||
				this.render();
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
svg {
 | 
			
		||||
	display: block;
 | 
			
		||||
	padding: 16px;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
	cursor: all-scroll;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										80
									
								
								src/client/widgets/activity.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/client/widgets/activity.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,80 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div>
 | 
			
		||||
	<mk-container :show-header="props.design === 0" :naked="props.design === 2">
 | 
			
		||||
		<template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template>
 | 
			
		||||
		<template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template>
 | 
			
		||||
 | 
			
		||||
		<div class="">
 | 
			
		||||
			<mk-loading v-if="fetching"/>
 | 
			
		||||
			<template v-else>
 | 
			
		||||
				<x-calendar v-show="props.view === 0" :data="[].concat(activity)"/>
 | 
			
		||||
				<x-chart v-show="props.view === 1" :data="[].concat(activity)"/>
 | 
			
		||||
			</template>
 | 
			
		||||
		</div>
 | 
			
		||||
	</mk-container>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { faChartBar, faSort } from '@fortawesome/free-solid-svg-icons';
 | 
			
		||||
import MkContainer from '../components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import i18n from '../i18n';
 | 
			
		||||
import XCalendar from './activity.calendar.vue';
 | 
			
		||||
import XChart from './activity.chart.vue';
 | 
			
		||||
 | 
			
		||||
export default define({
 | 
			
		||||
	name: 'activity',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		design: 0,
 | 
			
		||||
		view: 0
 | 
			
		||||
	})
 | 
			
		||||
}).extend({
 | 
			
		||||
	i18n,
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
		XCalendar,
 | 
			
		||||
		XChart,
 | 
			
		||||
	},
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			activity: null,
 | 
			
		||||
			faChartBar, faSort
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.$root.api('charts/user/notes', {
 | 
			
		||||
			userId: this.$store.state.i.id,
 | 
			
		||||
			span: 'day',
 | 
			
		||||
			limit: 7 * 21
 | 
			
		||||
		}).then(activity => {
 | 
			
		||||
			this.activity = activity.diffs.normal.map((_, i) => ({
 | 
			
		||||
				total: activity.diffs.normal[i] + activity.diffs.reply[i] + activity.diffs.renote[i],
 | 
			
		||||
				notes: activity.diffs.normal[i],
 | 
			
		||||
				replies: activity.diffs.reply[i],
 | 
			
		||||
				renotes: activity.diffs.renote[i]
 | 
			
		||||
			}));
 | 
			
		||||
			this.fetching = false;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		func() {
 | 
			
		||||
			if (this.props.design == 2) {
 | 
			
		||||
				this.props.design = 0;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.props.design++;
 | 
			
		||||
			}
 | 
			
		||||
			this.save();
 | 
			
		||||
		},
 | 
			
		||||
		toggleView() {
 | 
			
		||||
			if (this.props.view == 1) {
 | 
			
		||||
				this.props.view = 0;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.props.view++;
 | 
			
		||||
			}
 | 
			
		||||
			this.save();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			@ -7,3 +7,4 @@ Vue.component('mkw-calendar', () => import('./calendar.vue').then(m => m.default
 | 
			
		|||
Vue.component('mkw-rss', () => import('./rss.vue').then(m => m.default));
 | 
			
		||||
Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default));
 | 
			
		||||
Vue.component('mkw-clock', () => import('./clock.vue').then(m => m.default));
 | 
			
		||||
Vue.component('mkw-activity', () => import('./activity.vue').then(m => m.default));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue