提起日历,第一感就会让人想到一页显示一个月的那种常规日历,那么你是否会想起小时候见到的挂在墙上的手撕日历呢?一页纸只显示当天一天,但现在提倡环保,再使用那种手撕日历便不太好了。不过我们可以用D3制作一个这样撕日历的效果,有趣的是制作这样的效果格式化后整好只需要100行代码。
前置准备
引入依赖
除了如文章用D3制作矩形式树状结构图(Treemapping)并设计动画效果所提到的引入D3外,还需要再引入一个日历的中文语言json,因为d3默认是不带中文的:
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
import dateZhCN from "https://cdn.jsdelivr.net/npm/d3-time-format@3/locale/zh-CN.json" assert {
type: "json" };
第一步就是把引入的这个dateZhCN
作为D3默认语言:
d3.timeFormatDefaultLocale(dateZhCN);
然后假定一个需求,想通过这个日历翻到6天之后看看是哪一天,可以先写在html那边的body
中,也顺带调一下body
的样式将未来生成的canvas调到中心的位置:
<body style="text-align: center;font-size: 18px;">
<h2>6天后是哪一天?</h2>
</body>
基本流程制定
首先可以假定这个日历要翻到6天后,那么实际上就要准备7页日历纸,因为要加上当天那一页纸嘛,一页纸就是一张svg
,那先准备好一个数组,就专门供生成循环用就好了,所以这个数组也不需要具体日期,都是空的然后每次循环的时候+1天即可,这个很简单:
const current = new Date();
const dayData = Array(7).fill(null);
dayData.map(() => {
...
current.setDate(current.getDate() + 1);
console.log(current);
})
就是这样,先定义今天的日期,然后填充一个7个空值的数组,再循环数组,每次循环+1天,这个current
就是从今天到6天之后的共7个Date
对象了,所有的生成操作都会在这个循环中,循环结束之后就开始动画,那么下一步就开始生成具体的svg。
但请注意接下来的代码都是写在current.setDate(current.getDate() + 1);
之前的,因为+1天之后就是从第二天开始了。
内容制作
生成结构
首先就是生成出这7张svg
,以及它们基本的设置:
const _svg = d3
.select("body")
.append("svg")
.style("position", "absolute")
.style("left", "calc(50% - 150px)")
.style(
"z-index",
-Number(d3.timeFormat("%Y")(current) + d3.timeFormat("%j")(current))
)
.attr("width", "300")
.attr("height", "460");
为什么要做成absolute
定位呢,因为所有的日历页应该折叠在一起,然后一张一张掉下来。left
定位自不必说,我喜欢定在中间的位置上,z-index
是因为小的日期要显示在前面,这个怎么排都可以的,这里是用年份拼接一年中的第几日,这样才有撕下的效果,然后就是操作这个_svg
去构筑基本的几个元素了。
首先需要一张覆盖整张页面的rect
作为底色:
_svg
.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("stroke", "#964d22")
.attr("stroke-width", 5)
.attr("fill", "#f9e9cd");
然后还需要一个圆,来包住日期:
_svg
.append("circle")
.attr("fill", "#f8c387")
.attr("stroke", "#e5a256")
.attr("stroke-width", 7)
.attr("cx", "50%")
.attr("cy", "45%")
.attr("r", "120");
效果如下:
这样大致结构就完成了,然后就是填充上日期的具体数值文本了
生成文本
因为要写好几个文本,所以先准备一段通用的文本生成方法,很简单就是让文字居中,还有一个统一的边
function genText() {
return _svg
.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("stroke", "#513c20")
.attr("stroke-width", 1)
.attr("x", "50%");
}
生成年份,年份就定位在左上角好了:
genText()
.attr("fill", "#584717")
.style("font-size", "26px")
.attr("x", "40px")
.attr("y", "25px")
.text(current.getFullYear());
然后生成月份,定位在最上方:
genText()
.attr("fill", "#132c33")
.style("font-size", "42px")
.attr("y", "12%")
.text(d3.timeFormat("%B")(current));
%B
是D3的格式化文本,可以格式化为对应语言的月份,比喻说现在是5月,他会显示为五月
,比较有意思。
接下来生成一个巨大的日期,这个当然是放在正中心了:
genText()
.attr("fill", "#533c1b")
.style("font-size", "160px")
.style("font-weight", "bold")
.attr("y", "50%")
.attr("stroke-width", 5)
.text(current.getDate());
最后面就放一下周吧:
genText()
.attr("fill", "#66462a")
.style("font-size", "64px")
.attr("y", "85%")
.text(d3.timeFormat("%A")(current));
%A
就是周了,今天是周五,d3.timeFormat("%A")(new Date())
就会显示星期五
。这样就都完成了,最后别忘了,前面提到的current.setDate(current.getDate() + 1);
是要放在这个循环的最后面的,这部分做好之后效果如下:
动画效果
总共分3段:
- 第一段:往左下方向扯开纸张
- 第二段:往右下方向撕掉
- 第三段:纸张向下旋转滑落
dayData.pop();
d3.selectAll("svg")
.sort(d3.ascending())
.data(dayData)
.transition()
.delay(function (d, i) {
return i * 300;
})
.style(
"transform",
"rotateY(30deg) translate(-100px, 50px) skew(-10deg, 10deg) scale(1.2)"
)
.duration(800)
.transition()
.style(
"transform",
"rotateY(210deg) translateX(-650px) skew(310deg, 55deg) scale(0.2)"
)
.style("opacity", "0.6")
.duration(2400)
.transition()
.style(
"transform",
"rotateY(145deg) translate(-650px, 180px) skew(180deg, 60deg) scale(0.1)"
)
.style("opacity", "0.1")
.duration(800)
.remove();
最开头的dayData.pop();
为什么要删掉一页呢,是因为最后一条的是要显示的日子,就撕掉6页纸就好了,然后就是每一段的数值调试,是略有些麻烦的,需要耐心。最后调低opacity
淡化掉日历页之后就彻底删除掉。
下面就是播放的动画效果了:
效果展示
最后贴一下整体的源码,其实非常的轻量,去掉3个空行,刚好就100行代码:
综合源代码
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
import dateZhCN from "https://cdn.jsdelivr.net/npm/d3-time-format@3/locale/zh-CN.json" assert {
type: "json" };
d3.timeFormatDefaultLocale(dateZhCN);
const current = new Date();
const dayData = Array(7).fill(null);
dayData.map(() => {
const _svg = d3
.select("body")
.append("svg")
.style("position", "absolute")
.style("left", "calc(50% - 150px)")
.style(
"z-index",
-Number(d3.timeFormat("%Y")(current) + d3.timeFormat("%j")(current))
)
.attr("width", "300")
.attr("height", "460");
_svg
.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("stroke", "#964d22")
.attr("stroke-width", 5)
.attr("fill", "#f9e9cd");
_svg
.append("circle")
.attr("fill", "#f8c387")
.attr("stroke", "#e5a256")
.attr("stroke-width", 7)
.attr("cx", "50%")
.attr("cy", "45%")
.attr("r", "120");
function genText() {
return _svg
.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("stroke", "#513c20")
.attr("stroke-width", 1)
.attr("x", "50%");
}
genText()
.attr("fill", "#584717")
.style("font-size", "26px")
.attr("x", "40px")
.attr("y", "25px")
.text(current.getFullYear());
genText()
.attr("fill", "#132c33")
.style("font-size", "42px")
.attr("y", "12%")
.text(d3.timeFormat("%B")(current));
genText()
.attr("fill", "#533c1b")
.style("font-size", "160px")
.style("font-weight", "bold")
.attr("y", "50%")
.attr("stroke-width", 5)
.text(current.getDate());
genText()
.attr("fill", "#66462a")
.style("font-size", "64px")
.attr("y", "85%")
.text(d3.timeFormat("%A")(current));
current.setDate(current.getDate() + 1);
});
dayData.pop();
d3.selectAll("svg")
.sort(d3.ascending())
.data(dayData)
.transition()
.delay(function (d, i) {
return i * 300;
})
.style(
"transform",
"rotateY(30deg) translate(-100px, 50px) skew(-10deg, 10deg) scale(1.2)"
)
.duration(800)
.transition()
.style(
"transform",
"rotateY(210deg) translateX(-650px) skew(310deg, 55deg) scale(0.2)"
)
.style("opacity", "0.6")
.duration(2400)
.transition()
.style(
"transform",
"rotateY(145deg) translate(-650px, 180px) skew(180deg, 60deg) scale(0.1)"
)
.style("opacity", "0.1")
.duration(800)
.remove();
其实还有些优化,例如svg
本身和下面的text
的css可以统一设置,但我不想另外再写css
了,本文只做一个很简单的效果展示。
本文写作于2023年5月26日并发布于lyrieek的掘金,于2023年7月17日进行修订发布于lyrieek的阿里云开发者社区。