在酒店大堂常见的多城市时钟墙,本质是把多个时区的时间同时可视化。实体钟需要采购与维护成本,而电子屏普及后,用一块屏加一个Web页面来展示同样内容更划算。本文以HTML5 Canvas为基础,还原一个六城市多时钟动态展示的实现:两行三列共六个画布,每个画布分别标注城市名、显示24小时制时间,并让时针、分针、秒针每秒转动。涉及的代码包括画布初始化、坐标系平移、表盘与刻度绘制、指针角度换算以及时区偏移处理。
一、Canvas在这类场景中的定位
Canvas是HTML5新增的标签,用于在网页中实时生成并操作位图,本质上是一块可由JavaScript操控的位图画布。它与DOM渲染的差别在于:DOM驻留模式依靠DOM树管理布局和样式,更新时浏览器要重新计算布局再渲染,灵活但开销大;Canvas快速模式直接在画布上通过2D绘图API作画,不关心DOM布局,只重绘受影响的部分,更适合高频刷新的图形场景。对于每秒都要重绘指针的时钟,Canvas显然是更合适的选择。
如果进一步优化,可以把变化少的内容(表盘圆环、1到12的数字、60条刻度)与变化多的内容(三根指针、中心圆点)分层绘制,避免每秒重复绘制静态部分,这是图形领域常用的分层渲染思路。
二、页面骨架:六个画布与两行三列布局
页面上用table做两行三列布局,每个单元格放一个300×300的canvas,并用id从clock0到clock5区分,便于后续按城市逐个控制:
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <title>Canvas 多时区时钟可视化</title>
- <style>
- body { background-color: #F5F5F5; }
- canvas { border: 1px dashed #444; }
- </style>
- </head>
- <body>
- <table style="width:99%;margin: auto;align:center;">
- <tbody>
- <tr>
- <td><canvas id="clock0" width="300" height="300"></canvas></td>
- <td><canvas id="clock1" width="300" height="300"></canvas></td>
- <td><canvas id="clock2" width="300" height="300"></canvas></td>
- </tr>
- <tr>
- <td><canvas id="clock3" width="300" height="300"></canvas></td>
- <td><canvas id="clock4" width="300" height="300"></canvas></td>
- <td><canvas id="clock5" width="300" height="300"></canvas></td>
- </tr>
- </tbody>
- </table>
- </body>
- </html>
复制代码
原文示例只保留了功能验证所需的最小样式,实际项目应在此基础上重写边框、背景与响应式尺寸。
三、初始化:数组管理画布并把原点移到中心
创建画布后,把六个canvas对象统一放进数组,同时用另一个数组保存城市名。顺序必须与id一一对应:clock0到clock5依次为纽约、伦敦、巴黎、莫斯科、北京、东京。初始化时调用getContext("2d")取得绘图上下文,并执行一次ctx.translate(centerX, centerY),把坐标原点从左上角搬到画布中心,后续所有绘制都以圆心为基准:
- //创建时钟数组
- var clockArray = new Array();
- var timeZoneArray = ["纽约","伦敦","巴黎","莫斯科","北京","东京"];
- for(var i = 0;i < 6; i++){
- const canvas = document.getElementById("clock" + i);
- const ctx = canvas.getContext("2d");
- const centerX = canvas.width / 2;
- const centerY = canvas.height / 2;
- // 将坐标系移动到中心点
- ctx.translate(centerX, centerY);
- clockArray.push(canvas);
- }
复制代码
这里的translate是累积生效的,只需在初始化阶段执行一次;但要注意此后清屏也必须使用负坐标,否则会清不干净。
四、主绘制循环:清屏、表盘、指针与定时器
drawClock是整个时钟的核心函数。它先取一次new Date()作为基准时间,再遍历六个画布分别绘制,最后用setTimeout(drawClock, 1000)形成每秒一次的循环。每个画布内部的流程如下:
1. 取canvas与ctx,计算radius为宽度的一半减5,留出边距;
2. 用clearRect(-centerX, -centerY, width, height)清空整块画布,因为坐标原点已经平移,清屏矩形必须从负坐标开始;
3. 用arc(0, 0, radius, 0, 2 * Math.PI)画出表盘外圈;
4. 设置textAlign为center、textBaseline为middle、字体20px,用strokeText绘制"城市名+时间",位置在(2, -80),即圆心上方;
5. 调用drawClockNumber绘制1到12的数字,调用drawShowTime绘制当前时间文本;
6. 依次绘制时针、分针、秒针与中心圆点。
指针角度的关键是三角函数换算:以12点方向为0度,角度用Math.sin取x、-Math.cos取y,这样计算出的端点自然落在正确的象限。时针角度为(hour % 12 + minute / 60 + second / 3600) × π / 6,长度取0.6倍半径、线宽4;分针角度为(minute + second / 60) × π / 30,长度取0.8倍半径、线宽2;秒针角度为second × π / 30,长度取0.9倍半径、线宽1,并把strokeStyle设为red。三根指针都用lineCap = "round"让笔端更圆润。最后以arc(0, 0, 5, 0, 2 * Math.PI)加fillStyle = "#333"画出中心圆点。
- function drawClock() {
- const now = new Date();
- for(var j = 0;j < 6;j++){
- var _canvas = clockArray[j];
- var _ctx = _canvas.getContext("2d");
- const radius = _canvas.width / 2 - 5;
- const centerX = _canvas.width / 2;
- const centerY = _canvas.height / 2;
- _ctx.clearRect(-centerX, -centerY, _canvas.width, _canvas.height); // 每次绘制前清空整个画布
- _ctx.beginPath();
- _ctx.arc(0, 0, radius, 0, 2 * Math.PI);
- _ctx.stroke();
- // 绘制钟表数字
- _ctx.textAlign = "center";
- _ctx.textBaseline = "middle";
- _ctx.font = "20px sans-serif";
- var text = timeZoneArray[j];
- _ctx.strokeText(text + "时间", 2, -80);
- _ctx.font = "bold 14px Arial";
- drawClockNumber(_ctx, radius); //绘制数字
- // 绘制时针
- var bjHour = now.getHours();
- var hour = getHour(j, bjHour);
- var minute = now.getMinutes();
- var second = now.getSeconds();
- drawShowTime(_ctx, hour, minute, second); //绘制当前时间
- var hourAngle = (hour % 12 + minute / 60 + second / 3600) * Math.PI / 6;
- var hourLength = 0.6 * radius;
- var hourX = Math.sin(hourAngle) * hourLength;
- var hourY = -Math.cos(hourAngle) * hourLength;
- _ctx.beginPath();
- _ctx.moveTo(0, 0);
- _ctx.lineTo(hourX, hourY);
- _ctx.lineWidth = 4;
- _ctx.lineCap = "round";
- _ctx.stroke();
- // 绘制分针
- const minuteAngle = (minute + second / 60) * Math.PI / 30;
- const minuteLength = 0.8 * radius;
- const minuteX = Math.sin(minuteAngle) * minuteLength;
- const minuteY = -Math.cos(minuteAngle) * minuteLength;
- _ctx.beginPath();
- _ctx.moveTo(0, 0);
- _ctx.lineTo(minuteX, minuteY);
- _ctx.lineWidth = 2;
- _ctx.stroke();
- // 绘制秒针
- const secondAngle = second * Math.PI / 30;
- const secondLength = 0.9 * radius;
- const secondX = Math.sin(secondAngle) * secondLength;
- const secondY = -Math.cos(secondAngle) * secondLength;
- drawScale(_ctx, radius); //绘制刻度
- _ctx.beginPath();
- _ctx.moveTo(0, 0);
- _ctx.lineTo(secondX, secondY);
- _ctx.lineWidth = 1;
- _ctx.strokeStyle = "red";
- _ctx.stroke();
- // 绘制中央圆点
- _ctx.beginPath();
- _ctx.arc(0, 0, 5, 0, 2 * Math.PI);
- _ctx.fillStyle = "#333";
- _ctx.fill();
- }
- // 循环绘制
- setTimeout(drawClock, 1000);
- }
复制代码
五、数字与刻度:rotate/translate组合与moveTo踩坑点
1到12的数字不能直接按极坐标硬算,惯用做法是先把画布旋转到对应角度,再把坐标平移到圆边,绘制文字后把旋转和平移反向还原,保证下一次循环不受影响。drawClockNumber正是这样实现的:循环i从1到12,角度为i × π / 6,先rotate(angle),再translate(0, -radius + 15)把位置推到圆边缘内侧15像素处,然后rotate(-angle)把文字方向摆正,fillText后依次反向还原。
- function drawClockNumber(_ctx, radius){
- for (let i = 1; i <= 12; i++) {
- var angle = i * Math.PI / 6;
- _ctx.rotate(angle);
- _ctx.translate(0, -radius + 15);
- _ctx.rotate(-angle);
- _ctx.fillText(i.toString(), 0, 0);
- _ctx.rotate(angle);
- _ctx.translate(0, radius - 15);
- _ctx.rotate(-angle);
- }
- }
复制代码
60条刻度线则用循环加角度计算实现。每5分钟一条长刻度,长度取半径的5%,其余取2.5%:
- function drawScale(_ctx, radius){
- const maxMajorTickLength = radius * 0.05; // 较大刻度线长度
- const maxMinorTickLength = radius * 0.025; // 较小刻度线长度
- for (let m = 0; m < 60; m++) {
- const minuteAngle = m * Math.PI / 30;
- let tickLength = (m % 5 === 0) ? maxMajorTickLength : maxMinorTickLength;
- var x1 = Math.sin(minuteAngle) * radius; // 圆边缘的x坐标
- var y1 = -Math.cos(minuteAngle) * radius; // 圆边缘的y坐标
- var x2 = Math.sin(minuteAngle) * (radius - tickLength);
- var y2 = -Math.cos(minuteAngle) * (radius - tickLength);
- _ctx.beginPath();
- _ctx.moveTo(x1, y1); // 从圆边缘开始
- _ctx.lineTo(x2, y2); // 向内延伸到刻度线端点
- _ctx.stroke();
- }
- }
复制代码
这里有一个原文作者亲历的坑:刻度线应当从圆周边缘开始向内画,起点是(x1, y1),终点是(x2, y2)。如果习惯性写成moveTo(0, 0),就会从圆心向外画,刻度效果完全错误。刻度不准基本都出在起点和终点坐标算错,而不是角度公式有问题。
六、时区处理:以北京时间为基准的偏移换算
六个城市时间不同,原文没有引入时区库,而是统一以本机小时数为基准,按城市索引叠加固定偏移量,再用(hour + 24) % 24保证结果落在0到23之间,避免出现负数小时:
- function getHour(index, bjHour){
- var hour = bjHour; // bjHour是北京时间的小时数
- var offset;
- switch(index) {
- case 0: // 纽约 (北京时间比纽约快13小时)
- offset = -13;
- break;
- case 1: // 伦敦 (北京时间比伦敦快8小时)
- offset = -8;
- break;
- case 2: // 巴黎 (北京时间比巴黎快7小时)
- offset = -7;
- break;
- case 3: // 莫斯科 (北京时间比莫斯科快5小时)
- offset = -5;
- break;
- case 4: // 北京 (默认为北京时间)
- offset = 0;
- break;
- case 5: // 东京
- offset = 1;
- break;
- default:
- return hour;
- }
- hour += offset;
- hour = (hour + 24) % 24;
- return hour;
- }
复制代码
需要说明的是,这个方案是固定偏移常量,原文并未处理夏令时切换,也没有做跨天日期的显示。如果部署在需要精确时间的正式环境,应改为按IANA时区规则计算,或直接用Intl.DateTimeFormat带timeZone参数取各城市的小时、分钟、秒,再传入绘制函数;getHour只需保留为兜底。原文也提到,最初未做偏移取模时结果会越界,加上(hour + 24) % 24后才稳定在24小时制范围内。
七、性能与工程化建议
当前实现每秒对六个画布完整重绘一次,画布为300×300,开销尚可接受。若要放到大屏长时间运行,可从三个方向优化:一是把静态表盘与刻度预渲染到离屏画布,主循环只贴图加画指针;二是用requestAnimationFrame替代setTimeout,避免后台标签页计时漂移和资源浪费;三是把城市、偏移量、画布id抽成配置对象,绘制函数只接收配置与时间参数,便于增减城市数量而不改动循环逻辑。
整体来看,这段代码把Canvas的坐标系平移、路径绘制、文本绘制、旋转还原、三角函数换算和定时重绘串在一个完整场景里,是理解2D绘图上下文状态管理的合适案例。原文的时区换算属于简化实现,理解了偏移表与取模的意义,替换成标准时区API只是改一个函数的事。 |