Часы, которые мы создадим. Не волнуйтесь, вы можете оформить их по своему усмотрению. Ознакомьтесь: https://adskipper1337.github.io/vue-masterclass-clock/.

Я расскажу, как сделать полностью интерактивные часы с помощью обычного Vue 3, используя только основную библиотеку и базовые HTML, JavaScript и CSS.

Настройка проекта

Сначала создадим проект (просто нажмите Enter для каждого выбора, выбрав «нет») и запустим его:

$ npm create vue@latest --name vue-masterclass-clock
$ cd vue-masterclass-clock
$ npm install
$ npm run dev

Итак, у нас есть базовый проект. Теперь я очищу его, чтобы мы могли сосредоточиться на виджете. Прежде всего, в папке src удалим все ненужные файлы, чтобы файловая система выглядела следующим образом:

reset.css — это базовый сброс CSS:

/* http://meyerweb.com/eric/tools/css/reset/ 
v2.0 | 20110126
Лицензия: нет (публичный доступ)
*/

html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* Сброс роли отображения HTML5 для старых браузеров */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

main.css просто импортирует этот один файл:

@import './reset.css';

App.vue — это обертка вокруг компонента:

<script setup>
import McClock from './components/clock/McClock.vue'
</script>

<template>
<div class="app-wrapper">
<McClock></McClock>
</div>
</template>

<style>
.app-wrapper {
max-width: 90vh;
}
</style>

main.js не изменился:

import './assets/main.css'

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

Итак, простой компонент McClock.vue выглядит следующим образом:

<template>
<div>CLOCK</div>
</template>

Это просто шаблон, в который не добавлено пока ничего. Поэтому изначально приложение выглядит так:

Довольно скромное начало

Базовые часы

Теперь, когда мы разобрались с настройкой проекта, можем начать работу над часами. В подобных проектах я обычно предпочитаю начинать с базовой функциональности, а затем идти дальше. Поэтому создадим базовые часы с 3 стрелками, которые будут показывать время. Для этого нужно с самого начала решить две задачи.

  1. Привязать текущие значения часов, минут и секунд к реактивным переменным.
  2. Соотнести положение стрелки часов и числовое значение.

Начнем с первой задачи

Сначала создадим очень простую логику, которая будет вычислять часы, минуты и секунды на основе начальной даты (Date):

<script setup>
import { ref, computed } from "vue";

const currentDate = ref(new Date());
const hours = computed(() => currentDate.value.getHours());
const minutes = computed(() => currentDate.value.getMinutes());
const seconds = computed(() => currentDate.value.getSeconds());
</script>

<template>
<div>CLOCK</div>
<div>{{ hours }}</div>
<div>{{ minutes }}</div>
<div>{{ seconds }}</div>
</template>

Проблема с этой логикой заключается в том, что время не обновляется по мере того, как оно проходит. Поэтому нужно создать функцию, которая будет периодически обновлять его:

import { ref, computed, onMounted, onBeforeUnmount } from "vue";

//...

function updateCurrentDate() {
currentDate.value = new Date();
console.log("tick", currentDate.value.getMilliseconds());
getNextTick();
}
let timeout = null
function getNextTick() {
const milliseconds = (new Date()).getMilliseconds();
timeout = setTimeout(updateCurrentDate, 1000 - milliseconds);
}
onMounted(() => {
getNextTick();
})
onBeforeUnmount(() => {
clearTimeout(timeout);
})

Если вы посмотрите на консоль, то увидите, что функция эффективно вызывается сразу после второго изменения. Уберем консольный журнал. Он нужен только для того, чтобы показать, что функция вызывается в самом начале секунды, и с момента ее запуска проходит не более миллисекунды. Мы проверяем, сколько миллисекунд уже прошло, а затем ждем всю оставшуюся секунду, поэтому и производим вычитание из 1000.

Мы решили первую задачу и можем переходить ко второй

Нам нужно сделать компонент стрелки, который будет отображать время так, как задумано.

Сначала создадим контур часов:

<script setup>

//...

const clockMargin = ref("24px");
</script>

<template>
<div>CLOCK</div>
<div>{{ hours }}</div>
<div>{{ minutes }}</div>
<div>{{ seconds }}</div>

<div class="mc-clock-area">

</div>
</template>

<style scoped>
.mc-clock-area {
margin: v-bind(clockMargin);
position: relative;
width: calc(100% - v-bind(clockMargin) * 2);
aspect-ratio: 1;
background-color: green;
border-radius: 50%;
border-width: 16px;
border-style: solid;
border-color: blue;
box-sizing: border-box;
}
</style>

В нашем случае он будет выглядеть следующим образом:

Необработанные данные и фон

На этом этапе стоит обратить внимание на несколько моментов.

  • В части JavaScript задан параметр clockMargin. Это позволит установить отступ как свойство виджета. Таким образом, в будущем у нас может быть много гибких часов с одним и тем же компонентом, если мы этого захотим.
  • Позиция задана относительной, потому что мы будем использовать абсолютное позиционирование внутри стрелок, о которых речь пойдет дальше.
  • Для определения ширины компонента используется функция calc. calc — ваш партнер! Никогда не бойтесь calc. Это одна из самых удивительных составляющих CSS, которая позволит без особых усилий сделать то, что нам нужно.
  • Aspect-ratio — еще одна скрытая жемчужина CSS. Aspect-ratio позволяет не беспокоиться о высоте компонента, если он статичен.
  • Свойство border хорошо известно, поэтому вряд ли стоит объяснять его, но не забывайте о box-sizing! Box-sizing позволяет сделать так, чтобы граница помещалась в рамках ранее заданного размера, а не наоборот.

А теперь перейдем к стрелкам!

Создадим новый файл под названием McClockHand.vue и поместим его рядом с McClock.vue в папку clock:

<script setup>
const props = defineProps({
position: Number,
positionMax: Number
})
</script>

<template>
<div class="mc-clock-hand"></div>
</template>

<style scoped>
.mc-clock-hand {
position: absolute;
width: 10%;
height: 50%;
background-color: red;
top: 50%;
left: 50%;
transform-origin: top center;
transform: translate(-50%, 0%) rotate(180deg);
border-radius: 12px;
border-width: 4px;
border-style: solid;
border-color: black;
box-sizing: border-box;
}
</style>

Кроме того, мы включили его в основной компонент:

<script setup>
import McClockHand from "./McClockHand.vue"
// ...
</script>

<template>
<div>CLOCK</div>
<div>{{ hours }}</div>
<div>{{ minutes }}</div>
<div>{{ seconds }}</div>

<div class="mc-clock-area">
<McClockHand></McClockHand>
</div>
</template>

Результат выглядит следующим образом:

Базовые часы

Снова все довольно просто. Есть несколько моментов, на которые стоит обратить внимание. Рассмотрим их по очереди.

Абсолютная позиция позволяет перемещать стрелку как угодно. Однако этого недостаточно, если мы хотим, чтобы она еще и поворачивалась. Для этого нужны определенные трансформации. И в данном случае мы можем осуществить их с помощью следующей комбинации.

  • Масштабируем стрелку в соответствии с нашими потребностями — width: 10% и height: 50%. Таким образом, стрелка будет иметь правильный размер.
  • Далее мы перемещаем ее в нужное положение. Нам нужно, чтобы стрелка была привязана к центру часов, а это top: 50%left: 50%.
  • Затем трансформируем начало координат так, чтобы вращение стрелки происходило относительно середины тонкой стороны. Это делается с помощью transform-origin: top center.
  • Наконец, трансформируем стрелку часов, чтобы повернуть ее на месте: transform: translate(-50%, 0%) rotate(180deg).

Почти готово. Теперь нам осталось только перемещать стрелку часов в зависимости от времени. Для этого нужно создать еще 2 стрелки и передать их через поток данных. Кроме того, я внесу еще одно изменение: сделаю длину стрелки также свойством.

Для McClock.vue это выглядит следующим образом:

<div class="mc-clock-area">
<McClockHand :position="hours" :positionMax="24" length="41%" />
<McClockHand :position="minutes" :positionMax="60" length="48%" />
<McClockHand :position="seconds" :positionMax="60" length="55%" />
</div>

А для McClockHand.vue вот так:

<script setup>
import { computed } from "vue";
const props = defineProps({
position: Number,
positionMax: Number,
length: String
})

const rotation = computed(() =>
180 + Math.round((360 * props.position) / props.positionMax) + "deg"
);
</script>

<template>
<div class="mc-clock-hand"></div>
</template>

<style scoped>
.mc-clock-hand {
position: absolute;
width: 10%;
height: v-bind(length);
background-color: red;
top: 50%;
left: 50%;
transform-origin: top center;
transform: translate(-50%, 0%) rotate(v-bind(rotation));
border-radius: 12px;
border-width: 4px;
border-style: solid;
border-color: black;
box-sizing: border-box;
}
</style>

Часы теперь выглядят следующим образом:

Стрелки движутся по мере того, как проходит время

Здесь особенно примечательна одна вещь. Вычисление, которое производит вращение, заканчивается «градусом» (degree). Связанная переменная должна быть строкой, чтобы ее можно было внедрить в CSS с помощью v-bind.

Итак, «голые» часы готовы, и мы можем перейти к добавлению интерактивности. Поговорим об этом в следующем разделе.

Делаем часы интерактивными

Теперь, когда мы смогли отобразить время, установим его и поэкспериментируем с тем, что у нас получилось. Для этого сначала добавим чекбокс, который позволит отключить тиканье, и сделаем прописанные значения времени редактируемыми.

<script setup>
import McClockHand from "./McClockHand.vue"
import { ref, computed, onMounted, onBeforeUnmount } from "vue";

const currentDate = ref(new Date());
const hours = computed({
get: () => currentDate.value.getHours(),
set: (value) => {
if (value == "") return;
const date = currentDate.value;
date.setHours(value);
currentDate.value = new Date(date);
tick.value = false;
}
});
const minutes = computed({
get: () => currentDate.value.getMinutes(),
set: (value) => {
if (value == "") return;
const date = currentDate.value;
date.setMinutes(value)
currentDate.value = new Date(date);
tick.value = false;
}
});
const seconds = computed({
get: () => currentDate.value.getSeconds(),
set: (value) => {
if (value == "") return;
const date = currentDate.value;
date.setSeconds(value)
currentDate.value = new Date(date);
tick.value = false;
}
});
const tick = ref(true);

function updateCurrentDate() {
if (tick.value) currentDate.value = new Date();
getNextTick();
}

// ...
</script>

<template>
<div class="mc-clock">
<div>CLOCK </div>
<div>
<label><input v-model="tick" type="checkbox"></input> Tick </label>
</div>
<input v-model="hours" type="number" min="0" max="24"></input>
<input v-model="minutes" type="number" min="0" max="60"></input>
<input v-model="seconds" type="number" min="0" max="60"></input>

<div class="mc-clock-area">
<McClockHand :position="hours" :positionMax="24" length="41%" />
<McClockHand :position="minutes" :positionMax="60" length="48%" />
<McClockHand :position="seconds" :positionMax="60" length="55%" />
</div>
</div>
</template>

<style scoped>
.mc-clock {
overflow: hidden;
padding-bottom: 48px;
background-color: beige;
}

.mc-clock-area {
margin: v-bind(clockMargin);
position: relative;
width: calc(100% - v-bind(clockMargin) * 2);
aspect-ratio: 1;
background-color: green;
border-radius: 50%;
border-width: 16px;
border-style: solid;
border-color: blue;
box-sizing: border-box;
}
</style>

Сейчас часы выглядят следующим образом:

Вы можете изменить время в полях ввода, и часы будут показывать это время

Важно отметить, что все целочисленные значения положения стрелок часов мы превратили в интерактивные вычисляемые значения. Таким образом, на входе их можно как получить, так и установить. Важно отметить, что мы не можем просто изменить существующую дату, потому что тогда вычисляемый геттер не сработает повторно и анимация не будет отображаться должным образом. Самый простой способ уведомить систему реактивности — просто создать новую Date с измененным значением. Если какое-либо значение изменяется вручную, мы отключаем тиканье, чтобы оно не сбрасывалось сразу. Конечно, вы можете изменить значение вручную или отключить его с помощью чекбокса.

Кроме того, мы добавили фон и обрезали его по бокам, скрыв переполнение, потому что в противном случае наши модификации скоро сместят компонент и вызовут сбои.

Этот шаг также интересен тем, что он плавно переходит в следующий этап. Мы сделаем так, что стрелки можно будет двигать с помощью курсора.

«Что? Разве это не сложно сделать? Собираетесь добавить подвижные элементы управления?» — спросит кто-то из читателей. Да, собираюсь. На самом деле это довольно просто с Vue 3. Приступим.  

Запишем события касания и мышки

Теперь добавим кончики к стрелкам и сделаем их перетаскиваемыми как мышкой, так и прикосновением! Для этого добавим элемент tip в компонент курсора, а также слушателей событий.

McClockHand.vue выглядит следующим образом:

<script setup>
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
const props = defineProps({
position: Number,
positionMax: Number,
length: String
})

const rotation = computed(() =>
180 + Math.round((360 * props.position) / props.positionMax) + "deg"
);

const dragging = ref(false);

function startDrag() {
dragging.value = true
}

function endDrag() {
dragging.value = false;
}

function mouseDrag(event) {
if (!dragging.value) return;
console.log("drag", event);
}

function touchDrag(event) {
mouseDrag(event.changedTouches[0]);
}

onMounted(() => {
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
document.addEventListener("mousemove", mouseDrag)
document.addEventListener("touchmove", touchDrag)
})

onBeforeUnmount(() => {
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("mousemove", mouseDrag)
document.removeEventListener("touchmove", touchDrag)
})
</script>

<template>
<div class="mc-clock-hand">
<div class="mc-clock-hand-tip" @mousedown="startDrag" @touchstart="startDrag"></div>
</div>
</template>

<style scoped>
.mc-clock-hand {
position: absolute;
width: 10%;
height: v-bind(length);
background-color: red;
top: 50%;
left: 50%;
transform-origin: top center;
transform: translate(-50%, 0%) rotate(v-bind(rotation));
border-radius: 12px;
border-width: 4px;
border-style: solid;
border-color: black;
box-sizing: border-box;
user-drag: none;
-webkit-user-drag: none;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
touch-action: none;
}

.mc-clock-hand-tip {
position: absolute;
width: 140%;
aspect-ratio: 1;
background-color: blueviolet;
bottom: -10%;
left: -20%;
border-radius: 50%;
border-width: 4px;
border-style: solid;
border-color: black;
box-sizing: border-box;
cursor: pointer;
}

.mc-clock-hand-tip:hover {
transform: scale(1.1);
}
</style>

Результат теперь выглядит вот так:

Стрелки теперь имеют кончики, которые можно потрогать

Важные моменты, которые следует здесь отметить.

  • Пока что мы записываем только события перетаскивания, с логикой компонента ничего не происходит.
  • Мы добавили только начальные события в кончики стрелок, а все остальное поместили в документ. Это сделано не просто так: если мы не будем слушать документ, то в некоторых случаях события окажутся не зарегистрированными. Особенно это касается финального события: когда перетаскивание завершается, оно должно быть в документе. В противном случае событие может быть потеряно, и перетаскиваемый элемент окажется «прилипшим» к мышке. Поэтому нужно соблюдать осторожность — все, что добавлено в документ вручную, должно быть удалено также вручную. Большие возможности накладывают большую ответственность. Так же происходит и при ручном управлении памятью в C++.
  • Обратите внимание на то, что к стрелке добавились свойства: user-drag: none; и user-select: none;. Это важно, потому что в противном случае, если вы перетащите элемент, могут активироваться встроенные функции перетаскивания в браузере что приведет к сбоям в ручном управлении!

Теперь, когда мы записали события для движения мышки, пришло время добавить реактивность компоненту!

Реакция на события касания

Итак, сейчас нужно провести определенные расчеты. На этот раз я вставил комментарии в код McClockHand.vue, чтобы провести вас через все вычисления шаг за шагом:

<script setup>
// ...
const emit = defineEmits(["update:position"])
// ...
const mcClockHandRef = ref();
function mouseDrag(event) {
if (!dragging.value) return;

// получение ограничивающего прямоугольника для часов, который является родителем нашего ref
const rect = mcClockHandRef.value.parentElement.getBoundingClientRect();

// вычисление относительного положения мышки внутри циферблата часов
const x = event.clientX - rect.x;
const y = event.clientY - rect.y;

// вычисление положения мышки относительно центра часов
const dx = 2 * x - rect.width;
const dy = 2 * y - rect.height;

// вычисление угла положения мышки относительно центра
const angel = 180 - Math.atan2(dx, dy) * 180 / Math.PI;

// нормализация и округление угла в зависимости от количества необходимых шагов
const angelPosition = Math.round((angel * props.positionMax) / 360);

// выдача обновленной позиции - return вместо max, чтобы избежать переворота
if (angelPosition === props.positionMax) emit("update:position", 0)
else emit("update:position", angelPosition)
}
// ...
</script>

<template>
<div class="mc-clock-hand" ref="mcClockHandRef">
<div class="mc-clock-hand-tip" @mousedown="startDrag" @touchstart="startDrag"></div>
</div>
</template>

Кстати, emit делает привязку интерактивной. Так что теперь просто нужно изменить McClock.vue, чтобы учесть это:

<template>
<div class="mc-clock">
<!-- ... -->
<div class="mc-clock-area" ref="mcClockAreaRef">
<McClockHand v-model:position="hours" :positionMax="24" length="41%" />
<McClockHand v-model:position="minutes" :positionMax="60" length="48%" />
<McClockHand v-model:position="seconds" :positionMax="60" length="55%" />
</div>
</div>
</template>

Теперь все работает так, как нужно:

Полностью рабочий редактируемый компонент часов

Теперь, когда мы закончили с технической частью, следует признать: наши часы выглядят не очень! Не волнуйтесь. Мы займемся этим в последней части этого туториала.  

Придание часам привлекательного вида

Мы выполнили всю техническую работу, но, конечно, ни один клиент не согласится на такие неприглядные часы. Поэтому нам нужно быстро навести красоту, чтобы получить блестящий продукт, который можно показать людям и которым можно гордиться. К тому же вам наверняка хочется получить красивые часы, которые я поместил на фото к этой статье. 

Не беспокойтесь! Мы сейчас все сделаем.

Я сторонник следующего подхода: сначала нужно доводить до ума техническую часть и только потом приступать к облагораживанию внешнего вида. В противном случае есть риск разрушить то, что вы построили, столкнувшись с техническими ограничениями. Теперь, когда они устранены, мы можем делать все, что захотим.

Начнем с придания привлекательности стрелкам часов. 

Нарисуем несколько стрелок

Я сделал их все в Inkscape:

Секундная стрелка
Минутная стрелка
Часовая стрелка

Векторная графика — лучший друг веб-разработчика. Зачастую гораздо проще просто сделать несколько базовых векторных картинок с помощью Inkscape, чем пытаться создать что-то причудливое, используя нативный HTML. Я сделал стрелки очень быстро. Они отправились в общую папку!

Теперь осталось изменить код ClockHand, чтобы включить в него изображение:

<script setup>
// ...
const props = defineProps({
// ...
imgSrc: String,
imgClass: String,
});

</script>

<template>
<div class="mc-clock-hand" ref="mcClockHandRef">
<img :src="props.imgSrc" :class="props.imgClass" />
<div
:class="{
'mc-clock-hand-tip': true,
'mc-clock-hand-tip-shining': dragging,
}"
@mousedown="startDrag"
@touchstart="startDrag"
></div>
</div>
</template>

<style scoped>
.mc-clock-hand {
position: absolute;
width: 10%;
height: v-bind(length);
top: 50%;
left: 50%;
transform-origin: top center;
transform: translate(-50%, 0%) rotate(v-bind(rotation));
user-drag: none;
-webkit-user-drag: none;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
touch-action: none;
}

.mc-clock-hand-tip {
position: absolute;
width: 200%;
aspect-ratio: 1;
background-color: yellow;
bottom: -10%;
left: -50%;
border-radius: 50%;
box-sizing: border-box;
cursor: pointer;
opacity: 0;
filter: blur(5vw);
}

.mc-clock-hand-tip-shining,
.mc-clock-hand-tip:hover {
transform: scale(1.1);
opacity: 0.4;
}
</style>

На этом шаге произошли следующие изменения.

  • Мы передаем src и class  img извне — таким образом, мы можем настроить каждую стрелку часов отдельно, но при этом использовать один и тот же код для всех них!
  • Мы удалили цвет из стрелки и добавили вместо него img.
  • Мы изменили кончик стрелки, сделав его прозрачным по умолчанию и размывающимся ярко-желтым при наведении на него курсора.
  • Мы также добавили «mc-clock-hand-tip-shining» в качестве второго CSS-свойства для эффекта сияния, чтобы он всегда действовал при перетаскивании. Обратите внимание на используемую нами нотацию: это эффективный способ включения и отключения CSS-классов на основе переменных или условий с помощью Vue!

В то же время в McClock.vue мы все прописываем и меняем цвета, чтобы они соответствовали стрелкам часов:

<template>
<div class="mc-clock">
<div>CLOCK</div>
<div>
<label><input v-model="tick" type="checkbox" /> Tick </label>
</div>
<input v-model="hours" type="number" min="0" max="24" />
<input v-model="minutes" type="number" min="0" max="60" />
<input v-model="seconds" type="number" min="0" max="60" />

<div class="mc-clock-area" ref="mcClockAreaRef">
<McClockHand
v-model:position="hours"
:positionMax="24"
length="41%"
imgSrc="hours.svg"
imgClass="mc-clock-hand-hours"
/>
<McClockHand
v-model:position="minutes"
:positionMax="60"
length="48%"
imgSrc="minutes.svg"
imgClass="mc-clock-hand-minutes"
/>
<McClockHand
v-model:position="seconds"
:positionMax="60"
length="55%"
imgSrc="seconds.svg"
imgClass="mc-clock-hand-seconds"
/>
</div>
</div>
</template>

<style scoped>
.mc-clock {
overflow: hidden;
padding-bottom: 48px;
background-color: beige;
}

.mc-clock-area {
margin: v-bind(clockMargin);
position: relative;
width: calc(100% - v-bind(clockMargin) * 2);
aspect-ratio: 1;
background-color: gray;
border-radius: 50%;
border-width: 16px;
border-style: solid;
border-color: balck;
box-sizing: border-box;
}
</style>

В основном мы просто передаем src и классы. Но где же классы для каждой стрелки? Они находятся в файле main.css. Они глобальные, потому что привязаны к SVG и следят за тем, чтобы они соответствовали разрешению:

@import "./reset.css";

.mc-clock-hand-hours {
width: 600%;
transform: translate(-44%, -42%) rotate(180deg);
pointer-events: none;
}

.mc-clock-hand-minutes {
width: 750%;
transform: translate(-45%, -40%) rotate(180deg);
pointer-events: none;
}

.mc-clock-hand-seconds {
width: 1300%;
transform: translate(-47%, -53%) rotate(180deg);
pointer-events: none;
}

Каждый класс масштабирует собственное SVG-изображение, чтобы оно отображалось так, как нужно!

Теперь посмотрим на результат:

Нестандартный тип часов. Вы можете использовать разные виды стрелок, если создаете серьезное приложение!

Выглядит неплохо. Возможно, стиль не для всех, но это же технический туториал!

А теперь переходим к последней части.

Размещение цифр на часах

Создадим новый digit-файл, он будет называться McClockDigit.vue. Вот как он выглядит:

<script setup>
const props = defineProps({
digit: Number,
});
</script>

<template>
<div class="mc-clock-digit-line"></div>
<div class="mc-clock-digit-container">
<div class="mc-clock-digit">
{{ digit }}
</div>
</div>
</template>

<style scoped>
.mc-clock-digit-line {
position: absolute;
width: 1%;
height: 20%;
top: 50%;
left: 50%;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(calc(v-bind(digit) * 30deg)) translate(0%, -100%);
background-color: black;
}

.mc-clock-digit-container {
position: absolute;
width: 1%;
height: 50%;
top: 50%;
left: 50%;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(calc(v-bind(digit) * 30deg));
}

.mc-clock-digit {
position: absolute;
font-family: Arial, Helvetica, sans-serif;
font-size: 7.6vw;
width: 10vw;
height: 10vw;
line-height: 10vw;
text-align: center;
background-color: darkgrey;
border-radius: 50%;
border-width: 1px;
border-style: solid;
transform: translate(-50%, 0%) rotate(calc(v-bind(digit) * -30deg));
}
</style>

Обратите внимание:

  • Мы передаем только одно свойство, а обо всем остальном позаботятся CSS-инъекции и HTML.
  • Это статический компонент. Digit будет передан один раз, а затем будет отображено несколько цифр.
  • При рендеринге компонент использует трансформации, чтобы расположить цифру под нужным углом.
  • Затем фактическое отображение цифры поворачивается в противоположную сторону, чтобы правильно расположить ее в вертикальном положении.

Что касается McClock.vue, то вот как мы добавляем цифры:

<template>
<div class="mc-clock">
<div>CLOCK</div>
<div>
<label><input v-model="tick" type="checkbox" /> Tick </label>
</div>
<input v-model="hours" type="number" min="0" max="24" />
<input v-model="minutes" type="number" min="0" max="60" />
<input v-model="seconds" type="number" min="0" max="60" />

<div class="mc-clock-area" ref="mcClockAreaRef">
<McClockDigit
v-for="index in 12"
:key="indes"
:digit="index"
></McClockDigit>

<McClockHand
v-model:position="hours"
:positionMax="12"
length="41%"
imgSrc="hours.svg"
imgClass="mc-clock-hand-hours"
/>
<McClockHand
v-model:position="minutes"
:positionMax="60"
length="48%"
imgSrc="minutes.svg"
imgClass="mc-clock-hand-minutes"
/>
<McClockHand
v-model:position="seconds"
:positionMax="60"
length="55%"
imgSrc="seconds.svg"
imgClass="mc-clock-hand-seconds"
/>
</div>
</div>
</template>

<style scoped>
.mc-clock {
overflow: hidden;
background-color: beige;
}

.mc-clock-area {
margin: v-bind(clockMargin);
position: relative;
width: calc(100% - v-bind(clockMargin) * 2);
aspect-ratio: 1;
background-color: gray;
border-radius: 50%;
border-width: 1vw;
border-style: solid;
border-color: balck;
box-sizing: border-box;
}
</style>

Мы проводим итерацию по ним с помощью цикла v-for. Обратите внимание, что мы провели еще несколько косметических изменений. Так, я заметил, что у часов есть только 12 цифр, но часовая стрелка имеет 24 позиции. Хорошо, что наша архитектура устойчива в том смысле, что мы можем просто изменить число на 12, и все будет в порядке.

Вот как выглядит конечный результат:

Готовые аналоговые часы

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Nikolai Davydov: Making an interactive analogue clock with Vue3

Предыдущая статьяСоздание LLM-приложений: четкое пошаговое руководство
Следующая статья9 плагинов в Figma, которые нельзя упустить в 2024 году