Add activity widget
This commit is contained in:
		
							parent
							
								
									d9986b7a2f
								
							
						
					
					
						commit
						0508d5f643
					
				
					 6 changed files with 276 additions and 1 deletions
				
			
		|  | @ -526,6 +526,7 @@ _widgets: | ||||||
|   trends: "トレンド" |   trends: "トレンド" | ||||||
|   clock: "時計" |   clock: "時計" | ||||||
|   rss: "RSSリーダー" |   rss: "RSSリーダー" | ||||||
|  |   activity: "アクティビティ" | ||||||
| 
 | 
 | ||||||
| _cw: | _cw: | ||||||
|   hide: "隠す" |   hide: "隠す" | ||||||
|  |  | ||||||
|  | @ -606,7 +606,8 @@ export default Vue.extend({ | ||||||
| 				'calendar', | 				'calendar', | ||||||
| 				'rss', | 				'rss', | ||||||
| 				'trends', | 				'trends', | ||||||
| 				'clock' | 				'clock', | ||||||
|  | 				'activity', | ||||||
| 			]; | 			]; | ||||||
| 
 | 
 | ||||||
| 			this.$root.menu({ | 			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-rss', () => import('./rss.vue').then(m => m.default)); | ||||||
| Vue.component('mkw-trends', () => import('./trends.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-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