在 HTML5 Canvas 上實現可縮放的時間軸
這篇文章將介紹如何使用 HTML5 Canvas 實現一個可縮放、可互動的時間軸。這個時間軸可以顯示當前時間,並允許使用者通過滑鼠操作來查看不同的時間點。
頁面結構與 CSS 樣式 我們將建立一個包含 <canvas>
元素的頁面,用於繪製時間軸,並使用一個 <input>
元素來控制時間軸的縮放比例。
HTML 結構
html
<!DOCTYPE html>
<html lang="zh-tw">
<head>
<meta charset="UTF-8">
<title>時間軸</title>
</head>
<body>
<main>
<canvas id="SeekBar" width="1024" height="90"></canvas>
<div class="controls">
<label for="zoomControl">縮放比例:</label>
<input type="range" id="zoomControl" min="1" max="100" value="100">
</div>
</main>
</body>
</html>
CSS 樣式
css
* {
padding: 0;
margin: 0;
color: #ffffff;
}
body {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #2E2E2E;
}
main {
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
}
canvas {
cursor: pointer;
border: 1px solid #2b2f33;
background-color: #2b2f33;
}
.controls {
margin-top: 10px;
}
JavaScript 功能實現
核心功能由 JavaScript 實現,我們將代碼組織為多個類別,以提高可讀性和可維護性。
BaseEventHandle 類別 這個類別用於優化事件處理。我們可以在子類中定義 _handle_eventType 方法,來處理特定的事件。
javascript
class BaseEventHandle {
handleEvent(event) {
const type = `_handle_${event.type}`;
if (this[type]) {
this[type](event);
}
}
}
CanvasUtils 工具類別 CanvasUtils 提供了一些通用的工具方法,例如格式化時間、獲取滑鼠位置和清除畫布。
javascript
class CanvasUtils {
static formatTime(timestamp) {
const date = new Date(timestamp);
const pad = num => num.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
static getCursorPosition(canvas, event) {
return event.clientX - canvas.getBoundingClientRect().left;
}
static clearCanvas(context, width, height) {
context.clearRect(0, 0, width, height);
}
}
CanvasDrawer 繪圖類別 CanvasDrawer 專注於在畫布上繪製各種形狀和文字。
javascript
class CanvasDrawer {
static drawLine(context, xStart, yStart, xEnd, yEnd, color, width) {
context.beginPath();
context.moveTo(xStart, yStart);
context.lineTo(xEnd, yEnd);
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
}
static drawCircle(context, x, y, radius, color) {
context.beginPath();
context.arc(x, y, radius, 0, 2 * Math.PI);
context.fillStyle = color;
context.fill();
}
static drawRect(context, x, y, width, height, color) {
context.fillStyle = color;
context.fillRect(x, y, width, height);
}
static drawText(context, text, x, y, fillStyle, font, textAlign = 'center') {
context.fillStyle = fillStyle;
context.font = font;
context.textAlign = textAlign;
context.fillText(text, x, y);
}
}
SeekBar 時間軸類別 SeekBar 是核心類別,負責時間軸的繪製和互動。
javascript
class SeekBar extends BaseEventHandle {
constructor() {
super();
this.canvas = document.getElementById('SeekBar');
this.context = this.canvas.getContext('2d');
this.canvasWidth = this.canvas.width;
this.canvasHeight = this.canvas.height;
this.totalHours = 24;
this.startTimestamp = Date.now() - 12 * 60 * 60 * 1000;
this.currentTimestamp = Date.now();
this.render();
this.canvas.addEventListener('click', this);
this.canvas.addEventListener('mousemove', this);
}
render() {
this.drawBackground();
this.drawTimeMarkers();
this.drawCenterLine();
this.drawMiddleTime();
}
drawMiddleTime() {
CanvasDrawer.drawText(
this.context,
CanvasUtils.formatTime(this.currentTimestamp),
this.canvasWidth / 2,
55,
"#fff",
"12px Arial"
);
}
drawTimeMarkers() {
const pixelsPerHour = this.canvasWidth / this.totalHours;
const middleTime = new Date(this.currentTimestamp);
const middleHour = middleTime.getHours();
const middleMinute = middleTime.getMinutes();
const offsetX = (middleMinute / 60) * pixelsPerHour;
for (let i = -Math.floor(this.totalHours / 2); i <= Math.floor(this.totalHours / 2); i++) {
const x = this.canvasWidth / 2 + i * pixelsPerHour - offsetX;
const hour = (middleHour + i + 24) % 24;
CanvasDrawer.drawLine(this.context, x, 0, x, 20, "#fff", 1);
CanvasDrawer.drawText(this.context, `${hour}:00`, x, 35, "#fff", "12px Arial");
}
}
drawBackground() {
CanvasDrawer.drawRect(this.context, 0, 65, this.canvasWidth, 20, '#2a86ff');
}
drawCenterLine() {
CanvasDrawer.drawLine(
this.context,
this.canvasWidth / 2,
0,
this.canvasWidth / 2,
this.canvasHeight,
"#2a86ff",
2
);
const originRadius = 5;
CanvasDrawer.drawCircle(this.context, this.canvasWidth / 2, originRadius, originRadius, '#2a86ff');
const centerCircleRadius = 3;
CanvasDrawer.drawCircle(this.context, this.canvasWidth / 2, originRadius, centerCircleRadius, '#000');
}
_handle_click(event) {
const cursorX = CanvasUtils.getCursorPosition(this.canvas, event);
const msPerPx = (this.totalHours * 3600 * 1000) / this.canvasWidth;
const clickTime = this.currentTimestamp + (cursorX - this.canvasWidth / 2) * msPerPx;
this.setTimeToMiddle(clickTime);
}
setTimeToMiddle(time) {
this.currentTimestamp = time;
this.startTimestamp = time - (this.totalHours * 60 * 60 * 1000) / 2;
CanvasUtils.clearCanvas(this.context, this.canvasWidth, this.canvasHeight);
this.render();
}
_handle_mousemove(event) {
const cursorX = CanvasUtils.getCursorPosition(this.canvas, event);
CanvasUtils.clearCanvas(this.context, this.canvasWidth, this.canvasHeight);
CanvasDrawer.drawLine(this.context, cursorX, 0, cursorX, this.canvasHeight, "#fff", 1);
this.render();
}
handleZoom(event) {
const zoomValue = event.target.value;
this.totalHours = 24 * (zoomValue / 100);
CanvasUtils.clearCanvas(this.context, this.canvasWidth, this.canvasHeight);
this.render();
}
}
const SeekBarInstance = new SeekBar();
const zoomControl = document.getElementById('zoomControl');
zoomControl.addEventListener('input', SeekBarInstance.handleZoom.bind(SeekBarInstance));
完整程式碼
以下是完整的 HTML、CSS 和 JavaScript 程式碼。
html
<!DOCTYPE html>
<html lang="zh-tw">
<head>
<meta charset="UTF-8">
<title>時間軸</title>
<style>
* {
padding: 0;
margin: 0;
color: #ffffff;
}
body {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #2E2E2E;
}
main {
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
}
canvas {
cursor: pointer;
border: 1px solid #2b2f33;
background-color: #2b2f33;
}
.controls {
margin-top: 10px;
}
</style>
</head>
<body>
<main>
<canvas id="SeekBar" width="1024" height="90"></canvas>
<div class="controls">
<label for="zoomControl">縮放比例:</label>
<input type="range" id="zoomControl" min="1" max="100" value="100">
</div>
</main>
<script>
// 優化代理 event 事件使用
class BaseEventHandle {
handleEvent(event) {
const type = `_handle_${event.type}`;
if (this[type]) {
this[type](event);
}
}
}
// 工具類別,包含通用的工具方法
class CanvasUtils {
/**
* 格式化時間戳記為 YYYY-MM-DD HH:MM:SS 格式
* @param {number} timestamp - 時間戳記
* @returns {string} 格式化後的時間字串
*/
static formatTime(timestamp) {
const date = new Date(timestamp);
const pad = num => num.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/**
* 獲取滑鼠在畫布上的位置
* @param {HTMLCanvasElement} canvas - 畫布元素
* @param {MouseEvent} event - 滑鼠事件
* @returns {number} 滑鼠在畫布上的 x 座標
*/
static getCursorPosition(canvas, event) {
return event.clientX - canvas.getBoundingClientRect().left;
}
/**
* 清除畫布
* @param {CanvasRenderingContext2D} context - 畫布上下文
* @param {number} width - 畫布寬度
* @param {number} height - 畫布高度
*/
static clearCanvas(context, width, height) {
context.clearRect(0, 0, width, height);
}
}
// 畫布繪製類別,負責在畫布上繪製圖形
class CanvasDrawer {
/**
* 在畫布上畫一條線
* @param {CanvasRenderingContext2D} context - 畫布上下文
* @param {number} xStart - 起點 x 座標
* @param {number} yStart - 起點 y 座標
* @param {number} xEnd - 終點 x 座標
* @param {number} yEnd - 終點 y 座標
* @param {string} color - 線條顏色
* @param {number} width - 線條寬度
*/
static drawLine(context, xStart, yStart, xEnd, yEnd, color, width) {
context.beginPath();
context.moveTo(xStart, yStart);
context.lineTo(xEnd, yEnd);
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
}
/**
* 在畫布上畫一個圓
* @param {CanvasRenderingContext2D} context - 畫布上下文
* @param {number} x - 圓心 x 座標
* @param {number} y - 圓心 y 座標
* @param {number} radius - 圓半徑
* @param {string} color - 圓顏色
*/
static drawCircle(context, x, y, radius, color) {
context.beginPath();
context.arc(x, y, radius, 0, 2 * Math.PI);
context.fillStyle = color;
context.fill();
}
/**
* 在畫布上畫一個矩形
* @param {CanvasRenderingContext2D} context - 畫布上下文
* @param {number} x - 矩形的 x 座標
* @param {number} y - 矩形的 y 座標
* @param {number} width - 矩形的寬度
* @param {number} height - 矩形的高度
* @param {string} color - 矩形的顏色
*/
static drawRect(context, x, y, width, height, color) {
context.fillStyle = color;
context.fillRect(x, y, width, height);
}
/**
* 在畫布上畫文字
* @param {CanvasRenderingContext2D} context - 畫布上下文
* @param {string} text - 要繪製的文字
* @param {number} x - 文字的 x 座標
* @param {number} y - 文字的 y 座標
* @param {string} fillStyle - 填充樣式
* @param {string} font - 字體樣式
* @param {string} [textAlign='center'] - 文字對齊方式
*/
static drawText(context, text, x, y, fillStyle, font, textAlign = 'center') {
context.fillStyle = fillStyle;
context.font = font;
context.textAlign = textAlign;
context.fillText(text, x, y);
}
}
// 負責時間軸的邏輯和互動
class SeekBar extends BaseEventHandle {
constructor() {
super();
this.canvas = document.getElementById('SeekBar');
this.context = this.canvas.getContext('2d');
this.canvasWidth = this.canvas.width;
this.canvasHeight = this.canvas.height;
this.totalHours = 24;
this.startTimestamp = Date.now() - 12 * 60 * 60 * 1000;
this.currentTimestamp = Date.now();
this.render();
this.canvas.addEventListener('click', this);
this.canvas.addEventListener('mousemove', this);
}
/**
* 初始化畫布
*/
render() {
this.drawBackground();
this.drawTimeMarkers();
this.drawCenterLine();
this.drawMiddleTime();
}
/**
* 在畫布中間顯示當前時間
*/
drawMiddleTime() {
CanvasDrawer.drawText(
this.context,
CanvasUtils.formatTime(this.currentTimestamp),
this.canvasWidth / 2,
55,
"#fff",
"12px Arial"
);
}
/**
* 繪製時間標記,每小時一條線
*/
drawTimeMarkers() {
const pixelsPerHour = this.canvasWidth / this.totalHours;
const middleTime = new Date(this.currentTimestamp);
const middleHour = middleTime.getHours();
const middleMinute = middleTime.getMinutes();
const offsetX = (middleMinute / 60) * pixelsPerHour;
for (let i = -Math.floor(this.totalHours / 2); i <= Math.floor(this.totalHours / 2); i++) {
const x = this.canvasWidth / 2 + i * pixelsPerHour - offsetX;
const hour = (middleHour + i + 24) % 24;
CanvasDrawer.drawLine(this.context, x, 0, x, 20, "#fff", 1);
CanvasDrawer.drawText(this.context, `${hour}:00`, x, 35, "#fff", "12px Arial");
}
}
/**
* 繪製背景
*/
drawBackground() {
CanvasDrawer.drawRect(this.context, 0, 65, this.canvasWidth, 20, '#2a86ff');
}
/**
* 繪製中心線和中心點
*/
drawCenterLine() {
CanvasDrawer.drawLine(
this.context,
this.canvasWidth / 2,
0,
this.canvasWidth / 2,
this.canvasHeight,
"#2a86ff",
2
);
const originRadius = 5;
CanvasDrawer.drawCircle(this.context, this.canvasWidth / 2, originRadius, originRadius, '#2a86ff');
const centerCircleRadius = 3;
CanvasDrawer.drawCircle(this.context, this.canvasWidth / 2, originRadius, centerCircleRadius, '#000');
}
/**
* 處理點擊事件,將點擊位置的時間設置為中間時間
* @param {MouseEvent} event - 滑鼠事件
*/
_handle_click(event) {
const cursorX = CanvasUtils.getCursorPosition(this.canvas, event);
const msPerPx = (this.totalHours * 3600 * 1000) / this.canvasWidth;
const clickTime = this.currentTimestamp + (cursorX - this.canvasWidth / 2) * msPerPx;
this.setTimeToMiddle(clickTime);
}
/**
* 設置中間時間並重新初始化畫布
* @param {number} time - 中間時間的時間戳記
*/
setTimeToMiddle(time) {
this.currentTimestamp = time;
this.startTimestamp = time - (this.totalHours * 60 * 60 * 1000) / 2;
CanvasUtils.clearCanvas(this.context, this.canvasWidth, this.canvasHeight);
this.render();
}
/**
* 處理滑鼠移動事件,顯示滑鼠位置的垂直線
* @param {MouseEvent} event - 滑鼠事件
*/
_handle_mousemove(event) {
const cursorX = CanvasUtils.getCursorPosition(this.canvas, event);
CanvasUtils.clearCanvas(this.context, this.canvasWidth, this.canvasHeight);
CanvasDrawer.drawLine(this.context, cursorX, 0, cursorX, this.canvasHeight, "#fff", 1);
this.render();
}
/**
* 處理縮放事件,根據縮放比例調整總小時數並重新初始化畫布
* @param {Event} event - 縮放事件
*/
handleZoom(event) {
const zoomValue = event.target.value;
this.totalHours = 24 * (zoomValue / 100);
CanvasUtils.clearCanvas(this.context, this.canvasWidth, this.canvasHeight);
this.render();
}
}
const SeekBarInstance = new SeekBar();
const zoomControl = document.getElementById('zoomControl');
zoomControl.addEventListener('input', SeekBarInstance.handleZoom.bind(SeekBarInstance));
</script>
</body>
</html>
結論
透過上述的步驟,我們成功地在 HTML5 Canvas 上實現了一個可縮放的時間軸。這個時間軸具有互動性,使用者可以點擊任意位置來查看不同的時間,並通過縮放控制來調整時間範圍。
希望這篇文章對您有幫助!