summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/app.vue3
-rw-r--r--src/client/widgets/activity.calendar.vue84
-rw-r--r--src/client/widgets/activity.chart.vue108
-rw-r--r--src/client/widgets/activity.vue80
-rw-r--r--src/client/widgets/index.ts1
5 files changed, 275 insertions, 1 deletions
diff --git a/src/client/app.vue b/src/client/app.vue
index a23b6e1289..e88979f001 100644
--- a/src/client/app.vue
+++ b/src/client/app.vue
@@ -606,7 +606,8 @@ export default Vue.extend({
'calendar',
'rss',
'trends',
- 'clock'
+ 'clock',
+ 'activity',
];
this.$root.menu({
diff --git a/src/client/widgets/activity.calendar.vue b/src/client/widgets/activity.calendar.vue
new file mode 100644
index 0000000000..dfc0d29d3b
--- /dev/null
+++ b/src/client/widgets/activity.calendar.vue
@@ -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>
diff --git a/src/client/widgets/activity.chart.vue b/src/client/widgets/activity.chart.vue
new file mode 100644
index 0000000000..0278e02ae7
--- /dev/null
+++ b/src/client/widgets/activity.chart.vue
@@ -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>
diff --git a/src/client/widgets/activity.vue b/src/client/widgets/activity.vue
new file mode 100644
index 0000000000..5f18c17d48
--- /dev/null
+++ b/src/client/widgets/activity.vue
@@ -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>
diff --git a/src/client/widgets/index.ts b/src/client/widgets/index.ts
index d6af41e2f8..9f8bbc8882 100644
--- a/src/client/widgets/index.ts
+++ b/src/client/widgets/index.ts
@@ -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));