查看: 176|回复: 3

Canvas多时区动态时钟实现:时区偏移与刻度绘制

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
在酒店大堂常见的多城市时钟墙,本质是把多个时区的时间同时可视化。实体钟需要采购与维护成本,而电子屏普及后,用一块屏加一个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区分,便于后续按城市逐个控制:
  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>Canvas 多时区时钟可视化</title>
  6. <style>
  7. body { background-color: #F5F5F5; }
  8. canvas { border: 1px dashed #444; }
  9. </style>
  10. </head>
  11. <body>
  12. <table style="width:99%;margin: auto;align:center;">
  13. <tbody>
  14. <tr>
  15. <td><canvas id="clock0" width="300" height="300"></canvas></td>
  16. <td><canvas id="clock1" width="300" height="300"></canvas></td>
  17. <td><canvas id="clock2" width="300" height="300"></canvas></td>
  18. </tr>
  19. <tr>
  20. <td><canvas id="clock3" width="300" height="300"></canvas></td>
  21. <td><canvas id="clock4" width="300" height="300"></canvas></td>
  22. <td><canvas id="clock5" width="300" height="300"></canvas></td>
  23. </tr>
  24. </tbody>
  25. </table>
  26. </body>
  27. </html>
复制代码

原文示例只保留了功能验证所需的最小样式,实际项目应在此基础上重写边框、背景与响应式尺寸。

三、初始化:数组管理画布并把原点移到中心

创建画布后,把六个canvas对象统一放进数组,同时用另一个数组保存城市名。顺序必须与id一一对应:clock0到clock5依次为纽约、伦敦、巴黎、莫斯科、北京、东京。初始化时调用getContext("2d")取得绘图上下文,并执行一次ctx.translate(centerX, centerY),把坐标原点从左上角搬到画布中心,后续所有绘制都以圆心为基准:
  1. //创建时钟数组
  2. var clockArray = new Array();
  3. var timeZoneArray = ["纽约","伦敦","巴黎","莫斯科","北京","东京"];
  4. for(var i = 0;i < 6; i++){
  5.   const canvas = document.getElementById("clock" + i);
  6.   const ctx = canvas.getContext("2d");
  7.   const centerX = canvas.width / 2;
  8.   const centerY = canvas.height / 2;
  9.   // 将坐标系移动到中心点
  10.   ctx.translate(centerX, centerY);
  11.   clockArray.push(canvas);
  12. }
复制代码

这里的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"画出中心圆点。
  1. function drawClock() {
  2.   const now = new Date();
  3.   for(var j = 0;j < 6;j++){
  4.     var _canvas = clockArray[j];
  5.     var _ctx = _canvas.getContext("2d");
  6.     const radius = _canvas.width / 2 - 5;
  7.     const centerX = _canvas.width / 2;
  8.     const centerY = _canvas.height / 2;
  9.     _ctx.clearRect(-centerX, -centerY, _canvas.width, _canvas.height); // 每次绘制前清空整个画布
  10.     _ctx.beginPath();
  11.     _ctx.arc(0, 0, radius, 0, 2 * Math.PI);
  12.     _ctx.stroke();
  13.     // 绘制钟表数字
  14.     _ctx.textAlign = "center";
  15.     _ctx.textBaseline = "middle";
  16.     _ctx.font = "20px sans-serif";
  17.     var text = timeZoneArray[j];
  18.     _ctx.strokeText(text + "时间", 2, -80);
  19.     _ctx.font = "bold 14px Arial";
  20.     drawClockNumber(_ctx, radius); //绘制数字
  21.     // 绘制时针
  22.     var bjHour = now.getHours();
  23.     var hour = getHour(j, bjHour);
  24.     var minute = now.getMinutes();
  25.     var second = now.getSeconds();
  26.     drawShowTime(_ctx, hour, minute, second); //绘制当前时间
  27.     var hourAngle = (hour % 12 + minute / 60 + second / 3600) * Math.PI / 6;
  28.     var hourLength = 0.6 * radius;
  29.     var hourX = Math.sin(hourAngle) * hourLength;
  30.     var hourY = -Math.cos(hourAngle) * hourLength;
  31.     _ctx.beginPath();
  32.     _ctx.moveTo(0, 0);
  33.     _ctx.lineTo(hourX, hourY);
  34.     _ctx.lineWidth = 4;
  35.     _ctx.lineCap = "round";
  36.     _ctx.stroke();
  37.     // 绘制分针
  38.     const minuteAngle = (minute + second / 60) * Math.PI / 30;
  39.     const minuteLength = 0.8 * radius;
  40.     const minuteX = Math.sin(minuteAngle) * minuteLength;
  41.     const minuteY = -Math.cos(minuteAngle) * minuteLength;
  42.     _ctx.beginPath();
  43.     _ctx.moveTo(0, 0);
  44.     _ctx.lineTo(minuteX, minuteY);
  45.     _ctx.lineWidth = 2;
  46.     _ctx.stroke();
  47.     // 绘制秒针
  48.     const secondAngle = second * Math.PI / 30;
  49.     const secondLength = 0.9 * radius;
  50.     const secondX = Math.sin(secondAngle) * secondLength;
  51.     const secondY = -Math.cos(secondAngle) * secondLength;
  52.     drawScale(_ctx, radius); //绘制刻度
  53.     _ctx.beginPath();
  54.     _ctx.moveTo(0, 0);
  55.     _ctx.lineTo(secondX, secondY);
  56.     _ctx.lineWidth = 1;
  57.     _ctx.strokeStyle = "red";
  58.     _ctx.stroke();
  59.     // 绘制中央圆点
  60.     _ctx.beginPath();
  61.     _ctx.arc(0, 0, 5, 0, 2 * Math.PI);
  62.     _ctx.fillStyle = "#333";
  63.     _ctx.fill();
  64.   }
  65.   // 循环绘制
  66.   setTimeout(drawClock, 1000);
  67. }
复制代码

五、数字与刻度:rotate/translate组合与moveTo踩坑点

1到12的数字不能直接按极坐标硬算,惯用做法是先把画布旋转到对应角度,再把坐标平移到圆边,绘制文字后把旋转和平移反向还原,保证下一次循环不受影响。drawClockNumber正是这样实现的:循环i从1到12,角度为i × π / 6,先rotate(angle),再translate(0, -radius + 15)把位置推到圆边缘内侧15像素处,然后rotate(-angle)把文字方向摆正,fillText后依次反向还原。
  1. function drawClockNumber(_ctx, radius){
  2.   for (let i = 1; i <= 12; i++) {
  3.     var angle = i * Math.PI / 6;
  4.     _ctx.rotate(angle);
  5.     _ctx.translate(0, -radius + 15);
  6.     _ctx.rotate(-angle);
  7.     _ctx.fillText(i.toString(), 0, 0);
  8.     _ctx.rotate(angle);
  9.     _ctx.translate(0, radius - 15);
  10.     _ctx.rotate(-angle);
  11.   }
  12. }
复制代码

60条刻度线则用循环加角度计算实现。每5分钟一条长刻度,长度取半径的5%,其余取2.5%:
  1. function drawScale(_ctx, radius){
  2.   const maxMajorTickLength = radius * 0.05; // 较大刻度线长度
  3.   const maxMinorTickLength = radius * 0.025; // 较小刻度线长度
  4.   for (let m = 0; m < 60; m++) {
  5.     const minuteAngle = m * Math.PI / 30;
  6.     let tickLength = (m % 5 === 0) ? maxMajorTickLength : maxMinorTickLength;
  7.     var x1 = Math.sin(minuteAngle) * radius; // 圆边缘的x坐标
  8.     var y1 = -Math.cos(minuteAngle) * radius; // 圆边缘的y坐标
  9.     var x2 = Math.sin(minuteAngle) * (radius - tickLength);
  10.     var y2 = -Math.cos(minuteAngle) * (radius - tickLength);
  11.     _ctx.beginPath();
  12.     _ctx.moveTo(x1, y1); // 从圆边缘开始
  13.     _ctx.lineTo(x2, y2); // 向内延伸到刻度线端点
  14.     _ctx.stroke();
  15.   }
  16. }
复制代码

这里有一个原文作者亲历的坑:刻度线应当从圆周边缘开始向内画,起点是(x1, y1),终点是(x2, y2)。如果习惯性写成moveTo(0, 0),就会从圆心向外画,刻度效果完全错误。刻度不准基本都出在起点和终点坐标算错,而不是角度公式有问题。

六、时区处理:以北京时间为基准的偏移换算

六个城市时间不同,原文没有引入时区库,而是统一以本机小时数为基准,按城市索引叠加固定偏移量,再用(hour + 24) % 24保证结果落在0到23之间,避免出现负数小时:
  1. function getHour(index, bjHour){
  2.   var hour = bjHour; // bjHour是北京时间的小时数
  3.   var offset;
  4.   switch(index) {
  5.     case 0: // 纽约 (北京时间比纽约快13小时)
  6.       offset = -13;
  7.       break;
  8.     case 1: // 伦敦 (北京时间比伦敦快8小时)
  9.       offset = -8;
  10.       break;
  11.     case 2: // 巴黎 (北京时间比巴黎快7小时)
  12.       offset = -7;
  13.       break;
  14.     case 3: // 莫斯科 (北京时间比莫斯科快5小时)
  15.       offset = -5;
  16.       break;
  17.     case 4: // 北京 (默认为北京时间)
  18.       offset = 0;
  19.       break;
  20.     case 5: // 东京
  21.       offset = 1;
  22.       break;
  23.     default:
  24.       return hour;
  25.   }
  26.   hour += offset;
  27.   hour = (hour + 24) % 24;
  28.   return hour;
  29. }
复制代码

需要说明的是,这个方案是固定偏移常量,原文并未处理夏令时切换,也没有做跨天日期的显示。如果部署在需要精确时间的正式环境,应改为按IANA时区规则计算,或直接用Intl.DateTimeFormat带timeZone参数取各城市的小时、分钟、秒,再传入绘制函数;getHour只需保留为兜底。原文也提到,最初未做偏移取模时结果会越界,加上(hour + 24) % 24后才稳定在24小时制范围内。

七、性能与工程化建议

当前实现每秒对六个画布完整重绘一次,画布为300×300,开销尚可接受。若要放到大屏长时间运行,可从三个方向优化:一是把静态表盘与刻度预渲染到离屏画布,主循环只贴图加画指针;二是用requestAnimationFrame替代setTimeout,避免后台标签页计时漂移和资源浪费;三是把城市、偏移量、画布id抽成配置对象,绘制函数只接收配置与时间参数,便于增减城市数量而不改动循环逻辑。

整体来看,这段代码把Canvas的坐标系平移、路径绘制、文本绘制、旋转还原、三角函数换算和定时重绘串在一个完整场景里,是理解2D绘图上下文状态管理的合适案例。原文的时区换算属于简化实现,理解了偏移表与取模的意义,替换成标准时区API只是改一个函数的事。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Canvas多时区动态时钟实现:时区偏移与刻度绘制

楼主这篇写得挺清楚的,尤其是把 Canvas 和 DOM 渲染的取舍先讲明白,再落到具体实现,读起来顺。几个点我比较有共鸣: 一是分层渲染那块。表盘圆环、刻度、数字这些每秒都不变,确实没必要跟着指针一起重绘。我自己的做法是搞一个离屏 canvas 先把静态部分画好,主循环里只 drawImage 贴上去,再画三根指针,开销会小很多;六个钟同时跑的时候差别还挺明显的。 二是 translate 那个坑,楼主提醒得对。不过我觉得更稳妥的写法是每个钟绘制前用 ctx.save() / ctx.restore() 包一层,或者干脆 setTransform(1,0,0,1,0,0) 复位后再重新平移,这样就不用老惦记清屏要传负坐标了,后面加别的绘制内容也不容易出错。 三是时区偏移。原文暂时没看到这部分细节,不过按纽约、伦敦、巴黎、莫斯科、北京、东京这六个城市,纽约和伦敦是带夏令时的,如果只是简单加减固定小时数,换季那阵子会差一小时。我一般用 Date 的 getTime 加上目标时区相对 UTC 的偏移量来算,或者直接用 Intl.DateTimeFormat 指定 timeZone 拿时分秒,省心也准确。莫斯科现在不实行夏令时,北京东京也没有,但代码里最好别写死。 另外实现上两个小建议:定时器用 setTimeout 递归的话,每秒重算一次 new Date() 是对的,但最好每次按当前
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Canvas多时区动态时钟实现:时区偏移与刻度绘制

看完首帖,整体思路很清楚:用一块页面加六个 canvas 做多城市时钟墙,比实体钟更省维护成本,也比 DOM 高频更新更合适。两行三列的 table 布局和 clock0 到 clock5 的 id 对应方式比较直观,初始化时把坐标原点平移到画布中心,后面画表盘和指针会方便很多。特别提醒 translate 是累积生效、清屏要用负坐标这一点很实用,这是实际写 Canvas 时钟容易踩的坑。 另外,首帖提到可以把静态表盘和动态指针分层绘制,这个优化方向很好,六个时钟每秒重绘时能省不少重复绘制。后面如果补全 drawClock 里指针角度换算和时区偏移处理,建议重点说明各城市本地时间如何取到小时、分钟、秒,以及跨日期和夏令时怎么处理;城市顺序与 id 一一对应也值得在代码里加个注释,避免后续调整顺序时出错。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Canvas多时区动态时钟实现:时区偏移与刻度绘制

这个多时区时钟墙的选题挺实用的,酒店大堂、跨时区团队办公区都能用上。思路也清楚,Canvas 和 DOM 的对比、坐标平移到中心、清屏要用负坐标、静态表盘和动态指针分层绘制,这些关键点都讲到了,对刚接触 Canvas 的人比较友好。 有几个小建议可以一起考虑:主循环用 setTimeout 每秒重绘会有累积漂移,后面可以换成 requestAnimationFrame 或定时校准;时区偏移最好别写死,纽约、伦敦这些城市有夏令时,用浏览器按城市时区取时间会更稳。另外,六个画布每秒重绘压力不大,但把表盘、刻度这些静态内容提前画好,再单独重绘指针,后面扩展成更多城市时会更从容。正文末尾似乎还没贴完,期待后续指针角度换算和时区偏移的完整实现。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-30 04:45 , Processed in 0.028778 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部