iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 2
1

任務目標

做一個會動的時鐘. 沒錯, 一個能夠顯示現在時間的時鐘.
一個時鐘會有什麼呢? 鐘面, 時針, 分針, 秒針, 鐘心.
HTML結構如下, 來吧!

<div class="clock">
  <div class="clock-face">
    <div class="hand hour-hand"></div>
    <div class="hand min-hand"></div>
    <div class="hand second-hand"></div>
  </div>
</div>

作法

來點CSS設定, 首先我們希望鐘外觀是圓的! border-radius: 50% 可以做到這點. padding: 2rem 讓鐘面和時鐘保持間距, 指針才不會碰到邊緣

.clock {
  width: 30rem;
  height: 30rem;
  border: 20px solid black;
  border-radius:50%;
  padding: 2rem;
}

鐘面是指針們的活動空間, 用 width , height 將剩餘空間撐滿. 接著設定 position: relative.
為什麼呢? postion: absolute為絕對定位, 被絕對定位的元素發生兩件事, 第一件事, 它會脫離Normal Flow, 不會像正常的block一樣佔空間, 往下堆疊. 第二件事, 它的定位從此只會以離他最近的父層且非position: static的元素做為基準.
待會內部指針, 鐘心會使用 position: absolute 定位, 將以鐘面作為定位基準.

.clock-face {
  position: relative;
  width: 100%;
  height: 100%;
}

接下來是所有指針的初始設定, 寫在hand類別內.
指針定位設為position:absolute, 根據上述內容, 定位參考元素為具有clock-face類別的元素.
因此width: 50%會讓所有指針預設都是鐘面寬度的一半.
注意定位的原點皆為元素的最左上角, 當設定top: 50%時, 指針們上緣會距離clock-face內部上緣約鐘面高度50%的距離.
現在所有指針會停在九點鐘方向的位置.
慢著, 這樣指針們不就沒有準確置中了嗎?

.hand {
  width:50%;
  height:6px;
  background:black;
  position: absolute;
  top:50%;
}

好問題, 因為指針們有各自的寬度, 我們會在各自的類別中用transform: translateY()調整, 讓指針們個別上移自己寬度的一半, 這樣就完全置中了!

.hour-hand {
  width: 30%;
  left: 20%;
  transform: translateY(-3px);
}

.min-hand {
  transform: translateY(-3px);
}

.second-hand {
  background-color: red;
  transform: translateY(-1px);
  height: 2px;
}

transform可以讓元素產生空間變形的效果, 例如放大, 旋轉, 翻轉...

說到 transform如何變形的 ,就要稍微提一下Stacking Context.

正常的Stacking Context, 可以把瀏覽器視窗內想像成一個3D的座標空間, X軸方向向右, Y軸方向向下, Z軸方向由螢幕內指向螢幕外, 元素按照Z軸的方向在堆疊.

對某個元素給了transform屬性並指定變形方式時, 會產生一個小型的Stacking Context包裹住該元素, 接著根據變形的方式, Stacking Context的座標軸會被變形, 裡面的元素因為座標軸發生改變而變形.

聽起來很抽象, 用比喻的話, 把水裝在氣球裡面, 你捏氣球, 水因為氣球的形狀改變, 也跟著改變了!

在未載入程式碼前, 我們希望指針都指向12點鐘方向, 因此還需要加點好料...

.hand {
  /* 前略 */
  transform: rotate(90deg);
  transform-origin: right;
}

transform-origin: right將旋轉的支點移到指針右邊, 就是鐘面中心, 若不指定, 預設旋轉支點是元素的中心. transform: rotate(90deg) 把指針往順時針方向轉90度. 負值會往逆時針方向轉. 初始畫面完成了! 接下來就要加入程式碼了!

首先選取時針, 分針, 秒針, 然後將其存到變數內, 方便我們之後做操控.

// 時針, 分針, 秒針
const hrHand = document.querySelector(`.hour-hand`);
const minHand = document.querySelector(`.min-hand`);
const secHand = document.querySelector(`.second-hand`);

接著, 設定計時器, 每秒鐘執行一次自訂的回呼函數updateTime, 更新指針的位置.
setInterval是window物件的全域方法, 裡面兩個參數, 第一個參數是每過一段時間要執行的函式, 第二個參數是時間間隔.

setInterval一但設定了, 就會一直跑. 若希望將來能夠手動移除該計時器, 可以將其指定給一個變數, 例如tick, 然後用clearInterval(tick)就能移除!

const tick = setInterval(updateTime, 1000);

我們希望時鐘的指針每秒能存取當下的時間, 並且更新到正確的位置, 所以updateTime函式的內容如下:

  1. 存取現在的時間
  2. 把時間的時, 分, 秒的值個別存在對應的變數中
  3. 把時, 分, 秒轉變為指針該旋轉的度數, 並存到對應的變數中
  4. 操控CSS讓指針旋轉到正確的度數

寫完大概如下:

function updateTime() {
  const now = new Date();
  const hr = now.getHours();
  const min = now.getMinutes();
  const sec = now.getSeconds();

  const hrDeg = hr * 360/12 + min * 360/12/60 + sec * 360/12/60/60 + 90;
  const minDeg = min * 360/60 + sec * 360/60/60 + 90;
  const secDeg = sec * 360/60 + 90;
  
  hrHand.style.transform = `translateY(-3px) rotate(${hrDeg}deg)`; 
  minHand.style.transform = `translateY(-3px) rotate(${minDeg}deg)`;
  secHand.style.transform = `translateY(-1px) rotate(${secDeg}deg)`;
}

new Date()

Date物件記錄了任何有關當下時間的資訊, 要取得物件只能以建構式實例化(intantiate)的方式執行. 像這樣const now = new Date(), Date() 就是Date物件的建構式, 用new運算子將其實例化. 用比喻來說, 建構子就像工廠模具, 被實例化的物件就像產品, new 運算子就像工廠的機器這樣吧! 總之每次 new Date() 都會產生一個當下的時間就對了!

getHours(), getMinutes(), getSeconds() 分別為Date物件的三種方法, 用來取得當下時間的小時, 分鐘, 秒的值. 但是得到的值都是從0開始算, 例如3:12:52, 會得到2, 11, 51這三個數字.

要轉換成度數就是小學高年級課本教過的時鐘問題了, 好懷念! 鐘面旋轉一圈是360度, 秒針有60格, 1格6度, 所以當下秒數換成度數就是... 秒數 x 360/60度! 以此類推...

但因為我們未transform前, 指針的起點是在9點鐘方向, 因此完成計算後, 個別要再加上90度, 才會顯示正確的時間!

最後用DOM物件的style屬性存取transform特性, 把旋轉度數更新成計算出來的值就好.

因為這種存取transform特性的方式是重新指定一個值給它, 因此舊的值會被洗掉, 記得我們之前有針對指針置中的問題做微調, 這裡要重複打一次上去. 這樣才能保證每次更新位置時, 指針都有置中.

指針雖然有在動, 但好像沒有tick tock tick tock的感覺, 身為一個古董發條機械鐘領域的權威, 各位肯定覺得這個鐘不合格! 沒關係, 讓我們在每個指針的CSS引入transition轉場. 第一篇有提到轉場的作用就是讓特性變化的過程有連續性. 轉場的其中一個參數是控制轉場速率跟時間的相依性, 但預設的選擇其實不多. 想要更客製化的變化, 我們可以用transition-timing-function特性來自訂這個時間曲線, 希望指針有跳動的感覺, 可以像下面這樣設定.

.hand {      
  transition: all 0.05s;
  transition-timing-function: cubic-bezier(0.1, 2.7, 0.58, 1);
}

設置完轉場後, 看起來更像時鐘了!鐘錶界的權威們普天同慶. 但如果各位像我一樣會陶醉在成品中盯著看好幾個小時, 一定會發現一個BUG!

沒錯, 為什麼指針轉一圈後會自動倒退一圈呢?
問題出在這裡...

const secDeg = sec * 360/60 + 90;
secHand.style.transform = `translateY(-1px) rotate(${secDeg}deg)`;

當指針從 59 到 0 的瞬間, 依照公式, 度數會從444度瞬間變回90度, 視覺上看來雖然是從84度到90度的差距而已, 但數值上來看是整整後退了快360度.
因為我們用了CSS轉場, 所以旋轉的會在444度到90度的過程中建立一個連續動畫, 所以才會有倒轉一圈的動畫!

該怎麼修正這個問題呢?

魔術數字就是444度! 沒錯, 即使超過360度, 時鐘仍是不停運轉著, 我們只要不歸零, 讓數字持續跑下去, 就不會倒轉一圈了!
程式碼如下:

// 在函數外設定一個變數
/* 前略 */
let round = 0;

function updateTime() {
  /* 前略 */
  // 在 const sec = now.getSeconds() 後面加上
  if(sec === 0) {
    round++;
  }   
  /* secDeg稍微改一下 */
  let secDeg = (sec + 60*round) * 360/60 + 90;
}

在函數外增加一個全域變數 round, 負責記錄現在轉幾圈了.
在函數內偵測只要秒數歸零, 也就是轉了一圈, 就讓round圈數增加.
在實際計算度數時, 看轉了幾圈, 就加上幾個360度, 這麼一來, 度數就會持續前進. 也就不會看到迴轉的問題.

其實這只是一個勉勉強強的解法, 因為如果放著不管讓網頁一直跑下去, 總有一天會因為數字過大而溢位. 嗯... 如果之後有發現什麼更好的解法, 再寫一篇補充上來吧!

以上就是JS30 第二篇心得!

Reference

Date物件
transform的原理
程式碼練習


上一篇
Day 1 - JS Drum Kit
下一篇
Day 3 - CSS Variables
系列文
JS30 錄30

1 則留言

0
JasonYang
iT邦新手 5 級 ‧ 2018-01-06 23:29:06

這個時鐘,我想研究一下。
看看能不能倒數:
一個填滿顏色的圓,
隨時間,慢慢的減少填滿的面積

Arel iT邦新手 5 級 ‧ 2018-01-07 14:25:41 檢舉

做出來時分享一下啊~

我要留言

立即登入留言