用D3制作一张有翻页特效的手撕日历(只需100行代码)

简介: 在D3中用十分简单的代码就可以实现丰富的动画,下面来看一下手撕日历的动画效果吧

提起日历,第一感就会让人想到一页显示一个月的那种常规日历,那么你是否会想起小时候见到的挂在墙上的手撕日历呢?一页纸只显示当天一天,但现在提倡环保,再使用那种手撕日历便不太好了。不过我们可以用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");

效果如下:
image.png

这样大致结构就完成了,然后就是填充上日期的具体数值文本了

生成文本

因为要写好几个文本,所以先准备一段通用的文本生成方法,很简单就是让文字居中,还有一个统一的边

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);是要放在这个循环的最后面的,这部分做好之后效果如下:
image.png

动画效果

总共分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淡化掉日历页之后就彻底删除掉。

下面就是播放的动画效果了:

效果展示

Animation-min.gif

最后贴一下整体的源码,其实非常的轻量,去掉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的阿里云开发者社区。

目录
相关文章
|
API
免费节假日api接口使用教程-聚合数据
免费节假日api接口使用教程-聚合数据
6015 0
免费节假日api接口使用教程-聚合数据
|
10月前
|
移动开发 监控 Devops
阿里云移动研发平台EMAS全解析——费用价格、版本对比、功能及使用场景整理
阿里云EMAS是云原生应用研发平台,支持App、H5、小程序等多端开发,集成DevOps、Serverless、低代码等技术,提供开发、测试、运维、运营全周期服务。支持HTTPDNS、热修复、移动推送等功能,助力提效降本、保障质量、提升用户体验,适用于个人开发者与企业。
|
7月前
|
SQL 缓存 Java
【MyBatis】MyBatis框架知识(全体系总结)
MyBatis 是一款轻量级持久层框架,支持SQL与代码分离、动态SQL、灵活映射及一二级缓存。本文系统讲解其核心组件、配置文件、Mapper映射、CRUD、缓存机制、插件扩展(如PageHelper、MyBatis-Plus)及最佳实践,助你高效掌握ORM开发。
1529 3
|
人工智能 IDE 开发工具
寻找Cursor的替代品:10款AI编程工具深度评测与推荐·优雅草卓伊凡
寻找Cursor的替代品:10款AI编程工具深度评测与推荐·优雅草卓伊凡
10914 18
寻找Cursor的替代品:10款AI编程工具深度评测与推荐·优雅草卓伊凡
|
机器学习/深度学习 存储 算法
《匿名化技术:数据隐私与价值挖掘的平衡探索》
在数据驱动的时代,数据成为企业和组织的核心资产,匿名化技术作为保护数据隐私的重要手段备受关注。它通过去除或混淆个人身份信息,如数据脱敏、泛化和加密等方法,有效保护隐私。然而,匿名化可能影响数据的完整性和准确性,进而影响价值挖掘。为平衡隐私保护与数据利用,需明确使用目的、加强数据治理、创新技术应用,确保数据安全合规,推动数字经济健康发展。
1002 30
|
缓存 算法 大数据
倚天710规模化应用 - 性能优化 - 软件预取分析与优化实践
软件预取技术是编程者结合数据结构和算法知识,将访问内存的指令提前插入到程序,以此获得内存访取的最佳性能。然而,为了获取性能收益,预取数据与load加载数据,比依据指令时延调用减小cachemiss的收益更大。
|
移动开发 开发工具 数据库
App内“邀请好友“功能:如何准确追踪邀请关系并自动发放奖励
探讨如何准确追踪APP“邀请好友”功能中的邀请关系并自动发放奖励
1098 2
|
PHP
WordPress微信公众号同步助手插件
该内容介绍了网站与微信公众号之间的文章同步功能,支持自动和手动两种方式。功能包括设置作者、封面、评论等,可将多篇文章合并同步或批量操作。特别提示:需确保微信公众号已认证以使用群发接口,且注意接口限制和资源文件格式要求。同时说明了从公众号同步至网站的限制及注意事项,如无法同步已群发文章等。更新记录显示新增了封面图片获取顺序设置。
1940 0
|
JavaScript 前端开发 Java
解决跨域问题大集合:vue-cli项目 和 java/springboot(6种方式) 两端解决(完美解决)
这篇文章详细介绍了如何在前端Vue项目和后端Spring Boot项目中通过多种方式解决跨域问题。
1639 1
解决跨域问题大集合:vue-cli项目 和 java/springboot(6种方式) 两端解决(完美解决)
|
存储 消息中间件 NoSQL
Redis从入门到精通之底层数据类型Stream详解和使用示例
Redis Stream的底层数据结构还涉及到基数树和listpacks,这些结构使得Redis Stream具有高效的空间和时间复杂度,同时允许通过ID进行随机访问。 基数树是一种高效的数据结构,用于存储有序数据集合。在Redis Stream中,消息ID有序集合就是基于基数树实现的。基数树可以快速定位一个元素在有序集合中的位置,以及在有序集合中查找一段范围内的元素。 listpack是一种紧凑的、可扩展的、有序的、二进制数据结构,用于存储多个元素。在Redis Stream中,消息哈希表就是基于listpack实现的。listpack可以高效地存储多个元素,同时支持快速的元素访问插入。
2483 133
Redis从入门到精通之底层数据类型Stream详解和使用示例