From ee3f408c7d25accb5812c4f442ba7f4531e4b681 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Sat, 20 May 2023 03:38:07 +0900
Subject: [PATCH] feat: impl IdlingRenderScheduler (#10547)

* feat: impl IdleRender

* test: pin time on Chromatic

* test: pin time on Chromatic

* fix: typo

* style: rename

* style: rename

* chore: back to setTimeout

* style: linebreak

* refactor: remove unused budget option

* refactor: use raw unix time

* fix: conflict error

* fix: floor

* fix: subtract

* Revert "fix: subtract"

This reverts commit 2ef4afaafc69d2fb8329b04c1b124dfa97b7e863.

* Revert "fix: floor"

This reverts commit bef8ecdf45c6afc52138921d16e2caca78cfd38d.

* Revert "refactor: use raw unix time"

This reverts commit 5199e13cb2829f3036101f95445cca3cb9c83703.
---
 packages/frontend/.storybook/generate.tsx     |  1 +
 .../components/MkAnalogClock.stories.impl.ts  |  2 +-
 .../frontend/src/components/MkAnalogClock.vue | 34 ++++++++---------
 .../components/MkDigitalClock.stories.impl.ts | 32 ++++++++++++++++
 .../src/components/MkDigitalClock.vue         | 21 +++++-----
 .../frontend/src/components/global/MkTime.vue |  1 -
 packages/frontend/src/scripts/idle-render.ts  | 38 +++++++++++++++++++
 7 files changed, 100 insertions(+), 29 deletions(-)
 create mode 100644 packages/frontend/src/components/MkDigitalClock.stories.impl.ts
 create mode 100644 packages/frontend/src/scripts/idle-render.ts

diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index 7c51d4c00c..f442422109 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -397,6 +397,7 @@ function toStories(component: string): string {
 Promise.all([
 	glob('src/components/global/*.vue'),
 	glob('src/components/Mk{A,B}*.vue'),
+	glob('src/components/MkDigitalClock.vue'),
 	glob('src/components/MkGalleryPostPreview.vue'),
 	glob('src/components/MkSignupServerRules.vue'),
 	glob('src/components/MkUserSetupDialog.vue'),
diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
index e7fbb47284..0aebdccf4f 100644
--- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
+++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/explicit-function-return-type */
 import { StoryObj } from '@storybook/vue3';
+import isChromatic from 'chromatic/isChromatic';
 import MkAnalogClock from './MkAnalogClock.vue';
-import isChromatic from 'chromatic';
 export const Default = {
 	render(args) {
 		return {
diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue
index f12020f810..05caffe7d0 100644
--- a/packages/frontend/src/components/MkAnalogClock.vue
+++ b/packages/frontend/src/components/MkAnalogClock.vue
@@ -39,6 +39,7 @@
 	-->
 
 	<line
+		ref="sLine"
 		:class="[$style.s, { [$style.animate]: !disableSAnimate && sAnimation !== 'none', [$style.elastic]: sAnimation === 'elastic', [$style.easeOut]: sAnimation === 'easeOut' }]"
 		:x1="5 - (0 * (sHandLengthRatio * handsTailLength))"
 		:y1="5 + (1 * (sHandLengthRatio * handsTailLength))"
@@ -73,9 +74,10 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onBeforeUnmount } from 'vue';
+import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
 import tinycolor from 'tinycolor2';
 import { globalEvents } from '@/events.js';
+import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
 
 // https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
 const angleDiff = (a: number, b: number) => {
@@ -145,6 +147,7 @@ let mAngle = $ref<number>(0);
 let sAngle = $ref<number>(0);
 let disableSAnimate = $ref(false);
 let sOneRound = false;
+const sLine = ref<SVGPathElement>();
 
 function tick() {
 	const now = props.now();
@@ -160,17 +163,21 @@ function tick() {
 	}
 	hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6);
 	mAngle = Math.PI * (m + s / 60) / 30;
-	if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
+	if (sOneRound && sLine.value) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
 		sAngle = Math.PI * 60 / 30;
-		window.setTimeout(() => {
+		defaultIdlingRenderScheduler.delete(tick);
+		sLine.value.addEventListener('transitionend', () => {
 			disableSAnimate = true;
-			window.setTimeout(() => {
+			requestAnimationFrame(() => {
 				sAngle = 0;
-				window.setTimeout(() => {
+				requestAnimationFrame(() => {
 					disableSAnimate = false;
-				}, 100);
-			}, 100);
-		}, 700);
+					if (enabled) {
+						defaultIdlingRenderScheduler.add(tick);
+					}
+				});
+			});
+		}, { once: true });
 	} else {
 		sAngle = Math.PI * s / 30;
 	}
@@ -194,20 +201,13 @@ function calcColors() {
 calcColors();
 
 onMounted(() => {
-	const update = () => {
-		if (enabled) {
-			tick();
-			window.setTimeout(update, 1000);
-		}
-	};
-	update();
-
+	defaultIdlingRenderScheduler.add(tick);
 	globalEvents.on('themeChanged', calcColors);
 });
 
 onBeforeUnmount(() => {
 	enabled = false;
-
+	defaultIdlingRenderScheduler.delete(tick);
 	globalEvents.off('themeChanged', calcColors);
 });
 </script>
diff --git a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts
new file mode 100644
index 0000000000..344f6de47c
--- /dev/null
+++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts
@@ -0,0 +1,32 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import isChromatic from 'chromatic/isChromatic';
+import MkDigitalClock from './MkDigitalClock.vue';
+export const Default = {
+	render(args) {
+		return {
+			components: {
+				MkDigitalClock,
+			},
+			setup() {
+				return {
+					args,
+				};
+			},
+			computed: {
+				props() {
+					return {
+						...this.args,
+					};
+				},
+			},
+			template: '<MkDigitalClock v-bind="props" />',
+		};
+	},
+	args: {
+		now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined,
+	},
+	parameters: {
+		layout: 'centered',
+	},
+} satisfies StoryObj<typeof MkDigitalClock>;
diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue
index 278dc8a5e7..aea20f2489 100644
--- a/packages/frontend/src/components/MkDigitalClock.vue
+++ b/packages/frontend/src/components/MkDigitalClock.vue
@@ -11,19 +11,21 @@
 </template>
 
 <script lang="ts" setup>
-import { onUnmounted, ref, watch } from 'vue';
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
 
 const props = withDefaults(defineProps<{
 	showS?: boolean;
 	showMs?: boolean;
 	offset?: number;
+	now?: () => Date;
 }>(), {
 	showS: true,
 	showMs: false,
 	offset: 0 - new Date().getTimezoneOffset(),
+	now: () => new Date(),
 });
 
-let intervalId;
 const hh = ref('');
 const mm = ref('');
 const ss = ref('');
@@ -39,9 +41,9 @@ watch(showColon, (v) => {
 	}
 });
 
-const tick = () => {
-	const now = new Date();
-	now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
+const tick = (): void => {
+	const now = props.now();
+	now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset);
 	hh.value = now.getHours().toString().padStart(2, '0');
 	mm.value = now.getMinutes().toString().padStart(2, '0');
 	ss.value = now.getSeconds().toString().padStart(2, '0');
@@ -52,13 +54,12 @@ const tick = () => {
 
 tick();
 
-watch(() => props.showMs, () => {
-	if (intervalId) window.clearInterval(intervalId);
-	intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
-}, { immediate: true });
+onMounted(() => {
+	defaultIdlingRenderScheduler.add(tick);
+});
 
 onUnmounted(() => {
-	window.clearInterval(intervalId);
+	defaultIdlingRenderScheduler.delete(tick);
 });
 </script>
 
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
index 261cc0ee18..dfc3c89798 100644
--- a/packages/frontend/src/components/global/MkTime.vue
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -58,7 +58,6 @@ function tick() {
 
 if (props.mode === 'relative' || props.mode === 'detail') {
 	tick();
-
 	onUnmounted(() => {
 		window.clearTimeout(tickId);
 	});
diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/scripts/idle-render.ts
new file mode 100644
index 0000000000..ccce8b02bf
--- /dev/null
+++ b/packages/frontend/src/scripts/idle-render.ts
@@ -0,0 +1,38 @@
+class IdlingRenderScheduler {
+	#renderers: Set<FrameRequestCallback>;
+	#rafId: number;
+	#ricId: number;
+
+	constructor() {
+		this.#renderers = new Set();
+		this.#rafId = 0;
+		this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline));
+	}
+
+	#schedule(deadline: IdleDeadline): void {
+		if (deadline.timeRemaining()) {
+			this.#rafId = requestAnimationFrame((time) => {
+				for (const renderer of this.#renderers) {
+					renderer(time);
+				}
+			});
+		}
+		this.#ricId = requestIdleCallback((arg) => this.#schedule(arg));
+	}
+
+	add(renderer: FrameRequestCallback): void {
+		this.#renderers.add(renderer);
+	}
+
+	delete(renderer: FrameRequestCallback): void {
+		this.#renderers.delete(renderer);
+	}
+
+	dispose(): void {
+		this.#renderers.clear();
+		cancelAnimationFrame(this.#rafId);
+		cancelIdleCallback(this.#ricId);
+	}
+}
+
+export const defaultIdlingRenderScheduler = new IdlingRenderScheduler();