Я делаю таймер Pomodoro, используя HTML+CSS+JavaScript.
Я хочу иметь анимацию прогресса вокруг круга, которая начинается в верхней части круга и вращается по часовой стрелке до завершения полного круга.
Я уже пробовал много разных способов написать код, но что бы я ни делал, я не могу заставить его работать правильно.
Вот мой код:
const bells = new Audio('./sounds/bell.wav');
const startBtn = document.querySelector('.btn-start');
const pauseBtn = document.querySelector('.btn-pause');
const resetBtn = document.querySelector('.btn-reset');
const session = document.querySelector('.minutes');
const sessionInput = document.querySelector('#session-length');
const breakInput = document.querySelector('#break-length');
let myInterval;
let state = true;
let isPaused = false
let totalSeconds;
let initialSeconds;
const updateTimerDisplay = () => {
const minuteDiv = document.querySelector('.minutes');
const secondDiv = document.querySelector('.seconds');
let minutesLeft = Math.floor(totalSeconds / 60);
let secondsLeft = totalSeconds % 60;
secondDiv.textContent = secondsLeft < 10 ? '0' + secondsLeft : secondsLeft;
minuteDiv.textContent = `${minutesLeft}`;
// Update the circle animation
const leftSide = document.querySelector('.left-side');
const rightSide = document.querySelector('.right-side');
const duration = initialSeconds; // Total duration in seconds
const elapsed = initialSeconds - totalSeconds;
const percentage = (elapsed / duration) * 100;
let rotationRight, rotationLeft;
if (percentage <= 50) {
// First half: rotate right side down from top to bottom
rotationRight = (percentage / 50) * 180 - 90;
rotationLeft = -90; // Keep left side at the top
} else {
// Second half: rotate left side up from bottom to top
rotationRight = 90; // Keep right side at the bottom
rotationLeft = ((percentage - 50) / 50) * 180 + 90;
}
rightSide.style.transform = `rotate(${rotationRight}deg)`;
leftSide.style.transform = `rotate(${rotationLeft}deg)`;
};
const appTimer = () => {
const sessionAmount = Number.parseInt(sessionInput.value)
if (isNaN(sessionAmount) || sessionAmount <= 0) {
alert('Please enter a valid session duration.');
return;
}
session.textContent = sessionAmount; // Update the session display
if (state) {
state = false;
totalSeconds = sessionAmount * 60;
initialSeconds = totalSeconds;
myInterval = setInterval(() => {
if (!isPaused) {
totalSeconds--;
updateTimerDisplay();
if (totalSeconds <= 0) {
bells.play();
clearInterval(myInterval);
startBreakTimer();
}
}
}, 1000);
} else {
alert('Session has already started.');
}
};
const pauseTimer = () => {
if (!state) {
isPaused = !isPaused;
pauseBtn.textContent = isPaused ? 'resume' : 'pause';
}
}
const startBreakTimer = () => {
const breakAmount = Number.parseInt(breakInput.value);
if (isNaN(breakAmount) || breakAmount <= 0) {
alert('Please enter a valid break duration.');
return;
}
totalSeconds = breakAmount * 60;
initialSeconds = totalSeconds;
myInterval = setInterval(() => {
if (!isPaused) {
totalSeconds--;
updateTimerDisplay();
if (totalSeconds <= 0) {
bells.play();
clearInterval(myInterval);
state = true;
}
}
}, 1000);
};
const resetTimer = () => {
clearInterval(myInterval);
state = true;
isPaused = false;
pauseBtn.textContent = 'pause';
const minuteDiv = document.querySelector('.minutes');
const secondDiv = document.querySelector('.seconds');
minuteDiv.textContent = sessionInput.value;;
secondDiv.textContent = '00';
// Reset circle animation
const leftSide = document.querySelector('.left-side');
const rightSide = document.querySelector('.right-side');
leftSide.style.transform = 'rotate(-90deg)';
rightSide.style.transform = 'rotate(-90deg)';
}
startBtn.addEventListener('click', appTimer);
pauseBtn.addEventListener('click', pauseTimer);
resetBtn.addEventListener('click', resetTimer);
html {
font-family: 'Fira Sans', sans-serif;
font-size: 20px;
letter-spacing: 0.8px;
min-height: 100vh;
color: #d8e9ef;
background-image: linear-gradient(-20deg, #025159 0%, #733b36 100%);
background-size: cover;
}
h1 {
margin: 0 auto 10px auto;
color: #d8e9ef;
}
p {
margin: 0;
}
.app-message {
height: 20px;
margin: 10px auto 20px auto;
}
.app-container {
width: 250px;
height: 420px;
margin: 40px auto;
text-align: center;
border-radius: 5px;
padding: 20px;
}
/*@keyframes rotate-right-from-top {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(90deg);
}
}
@keyframes rotate-left-from-top {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(90deg);
}
}*/
.app-circle {
position: relative;
margin: 0 auto;
width: 200px;
height: 200px;
}
.circle-shape {
pointer-events: none;
}
.semi-circle {
position: absolute;
width: 100px;
height: 200px;
box-sizing: border-box;
border: solid 6px;
transform: rotate(-90deg);
transform-origin: 50% 100%; /* Set the transform origin to the bottom center */
}
.left-side {
top: 0;
left: 0;
transform-origin: right center;
/*transform: rotate(0deg);*/
border-top-left-radius: 100px;
border-bottom-left-radius: 100px;
border-right: none;
z-index: 1;
/*animation-name: rotate-left-from-top;*/
}
.right-side {
top: 0;
left: 100px;
transform-origin: left center;
/*transform: rotate(0deg);*/
border-top-right-radius: 100px;
border-bottom-right-radius: 100px;
border-left: none;
/*animation-name: rotate-right-from-top;*/
}
.circle {
border-color: #bf5239;
}
.circle-mask {
border-color: #e85a71;
}
.app-counter-box {
font-family: 'Droid Sans Mono', monospace;
font-size: 250%;
position: relative;
top: 50px;
color: #d8e9ef;
}
button {
position: relative;
top: 50px;
font-size: 80%;
text-transform: uppercase;
letter-spacing: 1px;
border: none;
background: none;
outline: none;
color: #d8e9ef;
margin: 5px;
}
button:hover {
color: #90c0d1;
}
.btn-pause, .btn-reset {
display: inline-block;
}
.settings {
position: relative;
top: 100px;
display: flex;
flex-direction: column;
align-items: center;
}
.settings label {
margin: 5px 0;
color: #d8e9ef;
}
<!DOCTYPE html>
<html lang = "en">
<head>
<link rel = "preconnect" href = "https://fonts.googleapis.com" />
<link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin />
<link href = "https://fonts.googleapis.com/css2?family=EG+Garamond:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&display=swap" rel = "stylesheet" />
<link rel = "stylesheet" href = "./style.css" />
<title>Pomodoro App</title>
</head>
<body>
<div class = "app-container">
<h1>pomodoro</h1>
<div class = "app-message">press start to begin</div>
<div class = "app-circle">
<div class = "circle-shape">
<div class = "semi-circle right-side circle-mask"></div>
<div class = "semi-circle right-side circle"></div>
<div class = "semi-circle left-side circle-mask"></div>
<div class = "semi-circle left-side circle"></div>
</div>
<div class = "app-counter-box">
<p><span class = "minutes">25</span>:<span class = "seconds">00</span></p>
</div>
<button class = "btn-start">start</button>
<button class = "btn-pause">pause</button>
<button class = "btn-reset">reset</button>
<div class = "settings">
<label>Session (minutes): <input type = "number" id = "session-length" value = "25"></label>
<label>Break (minutes): <input type = "number" id = "break-length" value = "5"></label>
</div>
</div>
</div>
</body>
<script src = "./app.js"></script>
</html>
Сейчас ничего не происходит, пока таймер не достигнет половины общего времени, затем он заполнит верхнюю половину круга, и анимация начнется с середины левой части круга.
Может кто-нибудь помочь мне с этим, пожалуйста?
Спасибо!
Вот возможное решение этой проблемы.
Вместо ваших 4 элементов div я использовал подход svg, который кажется более простым в использовании. Этот svg-круг использует css-свойство DashArray, которое создает тире в обводке круга.
Используя ваш расчет, я делаю так, чтобы в обводке круга было только 1 тире. в нем измеряется процент * кругПеримитер
Вот он в действии (будьте осторожны, так как это всего лишь POC, и от вашего кода может быть какой-то мертвый код/остатки)
const bells = new Audio('./sounds/bell.wav');
const startBtn = document.querySelector('.btn-start');
const pauseBtn = document.querySelector('.btn-pause');
const resetBtn = document.querySelector('.btn-reset');
const session = document.querySelector('.minutes');
const sessionInput = document.querySelector('#session-length');
const breakInput = document.querySelector('#break-length');
let myInterval;
let state = true;
let isPaused = false
let totalSeconds;
let initialSeconds;
const updateTimerDisplay = () => {
const minuteDiv = document.querySelector('.minutes');
const secondDiv = document.querySelector('.seconds');
let minutesLeft = Math.floor(totalSeconds / 60);
let secondsLeft = totalSeconds % 60;
secondDiv.textContent = secondsLeft < 10 ? '0' + secondsLeft : secondsLeft;
minuteDiv.textContent = `${minutesLeft}`;
// Update the circle animation
const circle = document.querySelector('.circle-shape');
const duration = initialSeconds; // Total duration in seconds
const elapsed = initialSeconds - totalSeconds;
const length = document.querySelector('.circle-shape > circle').getTotalLength()
const percentage = (elapsed / duration) * 100;
circle.style.strokeDasharray = `${length * (1 - percentage/100)} ${length * (percentage/100)}`;
};
const appTimer = () => {
const sessionAmount = Number.parseInt(sessionInput.value)
if (isNaN(sessionAmount) || sessionAmount <= 0) {
alert('Please enter a valid session duration.');
return;
}
session.textContent = sessionAmount; // Update the session display
if (state) {
state = false;
totalSeconds = sessionAmount * 60;
initialSeconds = totalSeconds;
myInterval = setInterval(() => {
if (!isPaused) {
totalSeconds--;
updateTimerDisplay();
if (totalSeconds <= 0) {
bells.play();
clearInterval(myInterval);
startBreakTimer();
}
}
}, 1000);
} else {
alert('Session has already started.');
}
};
const pauseTimer = () => {
if (!state) {
isPaused = !isPaused;
pauseBtn.textContent = isPaused ? 'resume' : 'pause';
}
}
const startBreakTimer = () => {
const breakAmount = Number.parseInt(breakInput.value);
if (isNaN(breakAmount) || breakAmount <= 0) {
alert('Please enter a valid break duration.');
return;
}
totalSeconds = breakAmount * 60;
initialSeconds = totalSeconds;
myInterval = setInterval(() => {
if (!isPaused) {
totalSeconds--;
updateTimerDisplay();
if (totalSeconds <= 0) {
bells.play();
clearInterval(myInterval);
state = true;
}
}
}, 1000);
};
const resetTimer = () => {
clearInterval(myInterval);
state = true;
isPaused = false;
pauseBtn.textContent = 'pause';
const minuteDiv = document.querySelector('.minutes');
const secondDiv = document.querySelector('.seconds');
minuteDiv.textContent = sessionInput.value;;
secondDiv.textContent = '00';
}
startBtn.addEventListener('click', appTimer);
pauseBtn.addEventListener('click', pauseTimer);
resetBtn.addEventListener('click', resetTimer);
html {
font-family: 'Fira Sans', sans-serif;
font-size: 20px;
letter-spacing: 0.8px;
min-height: 100vh;
color: #d8e9ef;
background-image: linear-gradient(-20deg, #025159 0%, #733b36 100%);
background-size: cover;
}
h1 {
margin: 0 auto 10px auto;
color: #d8e9ef;
}
p {
margin: 0;
}
.app-message {
height: 20px;
margin: 10px auto 20px auto;
}
.app-container {
width: 250px;
height: 420px;
margin: 40px auto;
text-align: center;
border-radius: 5px;
padding: 20px;
}
/*@keyframes rotate-right-from-top {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(90deg);
}
}
@keyframes rotate-left-from-top {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(90deg);
}
}*/
.app-circle {
position: relative;
margin: 0 auto;
width: 200px;
height: 200px;
}
.circle-shape {
position: absolute;
pointer-events: none;
stroke-width: 6px;
background: transparent;
fill: transparent;
stroke: red;
aspect-ratio: 1;
box-sizing: border-box;
inset: 0;
transform: rotateY(180deg) rotate(-90deg);
}
.left-side {
top: 0;
left: 0;
transform-origin: right center;
/*transform: rotate(0deg);*/
border-top-left-radius: 100px;
border-bottom-left-radius: 100px;
border-right: none;
z-index: 1;
/*animation-name: rotate-left-from-top;*/
}
.right-side {
top: 0;
left: 100px;
transform-origin: left center;
/*transform: rotate(0deg);*/
border-top-right-radius: 100px;
border-bottom-right-radius: 100px;
border-left: none;
/*animation-name: rotate-right-from-top;*/
}
.circle {
border-color: #bf5239;
}
.circle-mask {
border-color: #e85a71;
}
.app-counter-box {
font-family: 'Droid Sans Mono', monospace;
font-size: 250%;
position: relative;
top: 50px;
color: #d8e9ef;
}
button {
position: relative;
top: 50px;
font-size: 80%;
text-transform: uppercase;
letter-spacing: 1px;
border: none;
background: none;
outline: none;
color: #d8e9ef;
margin: 5px;
}
button:hover {
color: #90c0d1;
}
.btn-pause, .btn-reset {
display: inline-block;
}
.settings {
position: relative;
top: 100px;
display: flex;
flex-direction: column;
align-items: center;
}
.settings label {
margin: 5px 0;
color: #d8e9ef;
}
<!DOCTYPE html>
<html lang = "en">
<head>
<link rel = "preconnect" href = "https://fonts.googleapis.com" />
<link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin />
<link href = "https://fonts.googleapis.com/css2?family=EG+Garamond:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&display=swap" rel = "stylesheet" />
<link rel = "stylesheet" href = "./style.css" />
<title>Pomodoro App</title>
</head>
<body>
<div class = "app-container">
<h1>pomodoro</h1>
<div class = "app-message">press start to begin</div>
<div class = "app-circle">
<svg class = "circle-shape" viewBox = "0 0 100 100" stroke-width>
<circle r = "40" cx = "50" cy = "50" />
</svg>
<div class = "app-counter-box">
<p><span class = "minutes">25</span>:<span class = "seconds">00</span></p>
</div>
<button class = "btn-start">start</button>
<button class = "btn-pause">pause</button>
<button class = "btn-reset">reset</button>
<div class = "settings">
<label>Session (minutes): <input type = "number" id = "session-length" value = "25"></label>
<label>Break (minutes): <input type = "number" id = "break-length" value = "5"></label>
</div>
</div>
</div>
</body>
<script src = "./app.js"></script>
</html>
Обратите внимание, что это обновляется только каждую секунду (как это называется из текстового обновления). Я хотел добиться плавной анимации. Вы можете использовать тот же подход, используя css-анимацию для dash-array
, и получать доступ к свойству animation-duration
только в соответствии с продолжительностью вашего таймера.
Большое спасибо за ваш ответ! Я только что обнаружил одну проблему: анимация начиналась снизу круга, а не сверху. Я исправил это с помощью transform: rotate(90deg);
to transform: rotate(-90deg);
in circle-shape
. Однако анимация движется влево, хотя я хотел, чтобы она двигалась по часовой стрелке.
Я отредактировал свой ответ, это был всего лишь вопрос ротации (я испытал настоящую боль)
Этот вопрос похож на: Можно ли в CSS нарисовать частичный контур круга (форма открытого кольца)?. Если вы считаете, что это другое, отредактируйте вопрос, поясните, чем он отличается и/или как ответы на этот вопрос не помогают решить вашу проблему.