From 2cd70b80a21de9bf072af726449480c8d229440c Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Fri, 5 Aug 2022 23:51:15 +0900
Subject: [PATCH] enhance(client): improve clock widgets

---
 .../client/src/components/analog-clock.vue    | 88 +++++++++++++------
 packages/client/src/scripts/timezones.ts      | 49 +++++++++++
 packages/client/src/widgets/clock.vue         | 63 +++++++++++--
 packages/client/src/widgets/digital-clock.vue | 34 +++++++
 4 files changed, 203 insertions(+), 31 deletions(-)
 create mode 100644 packages/client/src/scripts/timezones.ts

diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue
index 18dd1e3f4..2ea7680dd 100644
--- a/packages/client/src/components/analog-clock.vue
+++ b/packages/client/src/components/analog-clock.vue
@@ -1,13 +1,29 @@
 <template>
 <svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
-	<circle v-for="(angle, i) in graduations"
-					:key="i"
-					:cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
-					:cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
-					:r="i % 5 == 0 ? 0.125 : 0.05"
-					:fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor"
+	<circle
+		v-for="(angle, i) in graduations"
+		:key="i"
+		:cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
+		:cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
+		:r="i % 5 == 0 ? 0.125 : 0.05"
+		:fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor"
 	/>
 
+	<template v-if="props.numbers">
+		<text
+			v-for="(angle, i) in texts"
+			:x="5 + (Math.sin(angle) * (5 - textsPadding))"
+			:y="5 - (Math.cos(angle) * (5 - textsPadding))"
+			text-anchor="middle"
+			dominant-baseline="middle"
+			font-family="Verdana"
+			font-size="0.75"
+			fill="currentColor"
+		>
+			{{ i === 0 ? (props.twentyfour ? '24' : '12') : i }}
+		</text>
+	</template>
+
 	<line
 		:x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
 		:y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
@@ -44,22 +60,50 @@
 import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
 import tinycolor from 'tinycolor2';
 
-withDefaults(defineProps<{
-	thickness: number;
+const graduationsPadding = 0.5;
+const textsPadding = 0.5;
+const handsPadding = 1;
+const handsTailLength = 0.7;
+const hHandLengthRatio = 0.75;
+const mHandLengthRatio = 1;
+const sHandLengthRatio = 1;
+const graduations = (() => {
+	const angles: number[] = [];
+	for (let i = 0; i < 60; i++) {
+		const angle = Math.PI * i / 30;
+		angles.push(angle);
+	}
+
+	return angles;
+})();
+
+const props = withDefaults(defineProps<{
+	numbers?: boolean;
+	thickness?: number;
+	offset?: number;
+	twentyfour?: boolean;
 }>(), {
+	numbers: false,
 	thickness: 0.1,
+	offset: 0 - new Date().getTimezoneOffset(),
+	twentyfour: false,
+});
+
+const texts = computed(() => {
+	const angles: number[] = [];
+	const times = props.twentyfour ? 24 : 12;
+	for (let i = 0; i < times; i++) {
+		const angle = Math.PI * i / (times / 2);
+		angles.push(angle);
+	}
+	return angles;
 });
 
 const now = ref(new Date());
-const enabled = ref(true);
-const graduationsPadding = ref(0.5);
-const handsPadding = ref(1);
-const handsTailLength = ref(0.7);
-const hHandLengthRatio = ref(0.75);
-const mHandLengthRatio = ref(1);
-const sHandLengthRatio = ref(1);
-const computedStyle = getComputedStyle(document.documentElement);
+now.value.setMinutes(now.value.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
 
+const enabled = ref(true);
+const computedStyle = getComputedStyle(document.documentElement);
 const dark = computed(() => tinycolor(computedStyle.getPropertyValue('--bg')).isDark());
 const majorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)');
 const minorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)');
@@ -69,21 +113,13 @@ const hHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--ac
 const s = computed(() => now.value.getSeconds());
 const m = computed(() => now.value.getMinutes());
 const h = computed(() => now.value.getHours());
-const hAngle = computed(() => Math.PI * (h.value % 12 + (m.value + s.value / 60) / 60) / 6);
+const hAngle = computed(() => Math.PI * (h.value % (props.twentyfour ? 24 : 12) + (m.value + s.value / 60) / 60) / (props.twentyfour ? 12 : 6));
 const mAngle = computed(() => Math.PI * (m.value + s.value / 60) / 30);
 const sAngle = computed(() => Math.PI * s.value / 30);
-const graduations = computed(() => {
-	const angles: number[] = [];
-	for (let i = 0; i < 60; i++) {
-		const angle = Math.PI * i / 30;
-		angles.push(angle);
-	}
-
-	return angles;
-});
 
 function tick() {
 	now.value = new Date();
+	now.value.setMinutes(now.value.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
 }
 
 onMounted(() => {
diff --git a/packages/client/src/scripts/timezones.ts b/packages/client/src/scripts/timezones.ts
new file mode 100644
index 000000000..8ce07323f
--- /dev/null
+++ b/packages/client/src/scripts/timezones.ts
@@ -0,0 +1,49 @@
+export const timezones = [{
+	name: 'UTC',
+	abbrev: 'UTC',
+	offset: 0,
+}, {
+	name: 'Europe/Berlin',
+	abbrev: 'CET',
+	offset: 60,
+}, {
+	name: 'Asia/Tokyo',
+	abbrev: 'JST',
+	offset: 540,
+}, {
+	name: 'Asia/Seoul',
+	abbrev: 'KST',
+	offset: 540,
+}, {
+	name: 'Asia/Shanghai',
+	abbrev: 'CST',
+	offset: 480,
+}, {
+	name: 'Australia/Sydney',
+	abbrev: 'AEST',
+	offset: 600,
+}, {
+	name: 'Australia/Darwin',
+	abbrev: 'ACST',
+	offset: 570,
+}, {
+	name: 'Australia/Perth',
+	abbrev: 'AWST',
+	offset: 480,
+}, {
+	name: 'America/New_York',
+	abbrev: 'EST',
+	offset: -300,
+}, {
+	name: 'America/Mexico_City',
+	abbrev: 'CST',
+	offset: -360,
+}, {
+	name: 'America/Phoenix',
+	abbrev: 'MST',
+	offset: -420,
+}, {
+	name: 'America/Los_Angeles',
+	abbrev: 'PST',
+	offset: -480,
+}];
diff --git a/packages/client/src/widgets/clock.vue b/packages/client/src/widgets/clock.vue
index fbd2f9e89..3b40674e4 100644
--- a/packages/client/src/widgets/clock.vue
+++ b/packages/client/src/widgets/clock.vue
@@ -1,17 +1,20 @@
 <template>
 <MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-clock">
 	<div class="vubelbmv">
-		<MkAnalogClock class="clock" :thickness="widgetProps.thickness"/>
+		<div v-if="widgetProps.showLabel" class="label abbrev">{{ tzAbbrev }}</div>
+		<MkAnalogClock class="clock" :thickness="widgetProps.thickness" :offset="tzOffset" :numbers="widgetProps.numbers" :twentyfour="widgetProps.twentyFour"/>
+		<div v-if="widgetProps.showLabel" class="label offset">{{ tzOffsetLabel }}</div>
 	</div>
 </MkContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
 import MkContainer from '@/components/ui/container.vue';
 import MkAnalogClock from '@/components/analog-clock.vue';
+import { timezones } from '@/scripts/timezones';
 
 const name = 'clock';
 
@@ -24,11 +27,34 @@ const widgetPropsDef = {
 		type: 'radio' as const,
 		default: 0.1,
 		options: [{
-			value: 0.1, label: 'thin'
+			value: 0.1, label: 'thin',
 		}, {
-			value: 0.2, label: 'medium'
+			value: 0.2, label: 'medium',
 		}, {
-			value: 0.3, label: 'thick'
+			value: 0.3, label: 'thick',
+		}],
+	},
+	numbers: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	twentyFour: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	showLabel: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	timezone: {
+		type: 'enum' as const,
+		default: null,
+		enum: [...timezones.map((tz) => ({
+			label: tz.name,
+			value: tz.name.toLowerCase(),
+		})), {
+			label: '(auto)',
+			value: null,
 		}],
 	},
 };
@@ -47,6 +73,16 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
 	emit,
 );
 
+const tzAbbrev = $computed(() => (widgetProps.timezone === null
+	? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
+
+const tzOffset = $computed(() => widgetProps.timezone === null
+	? 0 - new Date().getTimezoneOffset()
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
+
+const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
+
 defineExpose<WidgetComponentExpose>({
 	name,
 	configure,
@@ -57,6 +93,23 @@ defineExpose<WidgetComponentExpose>({
 <style lang="scss" scoped>
 .vubelbmv {
 	padding: 8px;
+	position: relative;
+
+	> .label {
+		opacity: 0.7;
+
+		&.abbrev {
+			position: absolute;
+			top: 14px;
+			left: 14px;
+		}
+
+		&.offset {
+			position: absolute;
+			bottom: 14px;
+			right: 14px;
+		}
+	}
 
 	> .clock {
 		height: 150px;
diff --git a/packages/client/src/widgets/digital-clock.vue b/packages/client/src/widgets/digital-clock.vue
index 743c5657b..009167421 100644
--- a/packages/client/src/widgets/digital-clock.vue
+++ b/packages/client/src/widgets/digital-clock.vue
@@ -1,5 +1,6 @@
 <template>
 <div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
+	<div v-if="widgetProps.showLabel" class="label">{{ tzAbbrev }}</div>
 	<div class="time">
 		<span v-text="hh"></span>
 		<span class="colon" :class="{ showColon }">:</span>
@@ -9,6 +10,7 @@
 		<span v-if="widgetProps.showMs" class="colon" :class="{ showColon }">:</span>
 		<span v-if="widgetProps.showMs" v-text="ms"></span>
 	</div>
+	<div v-if="widgetProps.showLabel" class="label">{{ tzOffsetLabel }}</div>
 </div>
 </template>
 
@@ -16,6 +18,7 @@
 import { onUnmounted, ref, watch } from 'vue';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 import { GetFormResultType } from '@/scripts/form';
+import { timezones } from '@/scripts/timezones';
 
 const name = 'digitalClock';
 
@@ -33,6 +36,21 @@ const widgetPropsDef = {
 		type: 'boolean' as const,
 		default: true,
 	},
+	showLabel: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	timezone: {
+		type: 'enum' as const,
+		default: null,
+		enum: [...timezones.map((tz) => ({
+			label: tz.name,
+			value: tz.name.toLowerCase(),
+		})), {
+			label: '(auto)',
+			value: null,
+		}],
+	},
 };
 
 type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
@@ -49,6 +67,16 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
 	emit,
 );
 
+const tzAbbrev = $computed(() => (widgetProps.timezone === null
+	? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
+
+const tzOffset = $computed(() => widgetProps.timezone === null
+	? 0 - new Date().getTimezoneOffset()
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
+
+const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
+
 let intervalId;
 const hh = ref('');
 const mm = ref('');
@@ -67,6 +95,7 @@ watch(showColon, (v) => {
 
 const tick = () => {
 	const now = new Date();
+	now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + tzOffset));
 	hh.value = now.getHours().toString().padStart(2, '0');
 	mm.value = now.getMinutes().toString().padStart(2, '0');
 	ss.value = now.getSeconds().toString().padStart(2, '0');
@@ -98,6 +127,11 @@ defineExpose<WidgetComponentExpose>({
 	padding: 16px 0;
 	text-align: center;
 
+	> .label {
+		font-size: 65%;
+		opacity: 0.7;
+	}
+
 	> .time {
 		> .colon {
 			opacity: 0;