canvas库 konva 实现腾讯文档 [日历视图]

3,894 阅读10分钟
效果图实现展示
628554d721bf27a3ef1c1cd95bf190f6.png
为什么实现这个功能?
  • canvas大部分用于画图设计之类的功能,大厂用 canvas 用于业务实现,新颖性。
  • 大厂的这些用于商用的功能实现都不开源,自我实现具有挑战性。
功能分析
  • 主体展示 : 日历表的绘制
  • 功能增强 : 任务分配进度的绘制
  • 用户交互 : 拖动调整任务时段 | 模拟 div 的 hover 效果等等...
技术选型
  • 腾讯文档用的 konva,我们复刻。
  • 使用 konva本身进行实现,不使用 react-konva 和 vue 相关的库,可以跨框架接入。
实现设计

传递基础配置实例化对象,利用发布订阅暴露出去重要的事件,用于使用者接收。

export interface KonvaCalendarConfig {
  //  模式  可读 | 可修改 ( 影响拖动是否可修改日期 )
  mode: 'read' | 'edit';
  //  挂载节点
  container: string;
  //  初始时间
  initDate?: Date | 'string'
}
 const bootstrap = new CanvasKonvaCalendar({ mode: 'edit', container: '.smart-table-root',initDate : new Date('2024-10-20') })
    bootstrap.setData([{startTime: '2024-09-30',
      endTime: '2024-09-30',
      fill: 'rgba(49, 116, 173,0.8)',
      description: '1',id: uuid()}])  
      
    bootstrap.on('ADDTASKRANGE', (day: string) => {
      console.log('点添加日期', day);
    })
    bootstrap.on('CLICKRANGE', (day: string) => {
      console.log('选择日期', day);
    })
内部实现详情 | 初始化数据
export class CanvasKonvaCalendar {
  static readonly daysOfWeek = ['周一', '周二', '周三', '周四', '周五', '周六', '周天'];
  //  渲染日历 layer ( 节点稳定 性能损失较小 )
  private readonly layer: Konva.Layer;
  //  用户交互 layer ( 节点变更频繁 )
  private readonly featureLayer: Konva.Layer;
  //  画布
  private readonly stage: Konva.Stage;
  //  horve group 实例
  private hoverGroup!: Konva.Group;
  private readonly cellWidth: number;
  private readonly cellHeight: number;
  //  x轴绘制起点
  private readonly startX = 20;
  private readonly emitter = new CalendarEvent();

  private readonly hoverRect = { x: 0, y: 0, id: '' }
  //  渲染任务进度的数据源
  taskRanges =[];

  // 当前日期
  private date: Date = new Date();
  private stringDate: string;
  private month: number;
  private year: number;

  //  记录 拖动任务group 一些坐标信息
  private recordsDragGroupRect: DragRect = {
    //  鼠标点击开始拖动位置与 range x 差值
    differenceX: 0,
    //  鼠标点击开始拖动位置与 range y 差值
    differenceY: 0,
    //  拖动源 原始值x
    sourceX: 0,
    //  拖动源 原始值y
    sourceY: 0,
    //  拖动源
    targetGroup: null,
    //  鼠标开始拖动位置
    startX: 0,
    //  鼠标开始拖动位置
    startY: 0
  }
  //  拖动 任务group实例
  private dragGroup: Konva.Group | null = null;
  private readonly stageHeight: number;

  constructor(
    private readonly config: KonvaCalendarConfig,
  ) {
    this.stage = new Konva.Stage({
      width: innerWidth - 350,
      height: innerHeight - 170,
      x: 0,
      y: 0,
      container: this.config.container
    });
    this.layer = new Konva.Layer({});
    this.featureLayer = new Konva.Layer({});
    this.stage.add(this.layer);
    this.stage.add(this.featureLayer);

    this.date = new Date(this.config.initDate || new Date());
    this.stringDate = formatDate(this.date);
    this.month = this.date.getMonth();
    this.year = this.date.getFullYear();

    (this.stage.container().children[0] as HTMLDivElement).style.background = '#FFF';
    const { width, height } = this.stageRect;
    this.stageHeight = height;
    this.cellWidth = (width - 40) / 7;
    this.cellHeight = (height - 60 - 30) / 5;
    this.registerEvents();
    this.draw();
  }
  
    // 初始绘制
  private draw(): this {
    this.drawCalendar(this.month, this.year);
    this.drawHoverGroup();
    this.drawTaskProgress();
    return this;
  }
绘制日历
  • 采用周一至周天的顺序进行绘制,一列七天 一个月显示 5行的形式。
  • 这样大概率会出现三个月的时间交叉,所以要以本月为基础同时绘制上月和下月在这个视图中出现的日期
  // 绘制日历
  private drawCalendar(month: number, year: number): void {
    // 清空图层
    this.layer.removeChildren();
    const { firstDay, daysInMonth } = this.getDaysInMonth(month, year);
    //  绘制当前显示的年份
    const headerGroup = new Konva.Group({ name: 'header' })
    const yearRect = new Konva.Rect({
      x: 0,
      y: 0,
      width: this.stage.width(),
      height: 40,
      fill: 'white',
      strokeWidth: 1,
    });

    const yearText = new Konva.Text({
      x: 0,
      y: 10,
      text: `${year}年${month + 1}月`,
      fontSize: 20,
      fontFamily: 'Calibri',
      fontStyle: 'bold',
      width: 120,
      align: 'center',
    })

    headerGroup.add(yearRect, yearText);
    this.layer.add(headerGroup);

    // 绘制每个星期的标题
    CanvasKonvaCalendar.daysOfWeek.forEach((day, index) => {
      const backgroudRect = new Konva.Rect({
        x: index * this.cellWidth + this.startX,
        y: 40,
        width: this.cellWidth,
        height: 30,
        fill: 'white',
        strokeWidth: 1,
      })

      const text = new Konva.Text({
        x: index * this.cellWidth + this.startX,
        y: 50,
        text: day,
        fontSize: 13,
        fontFamily: 'Calibri',
        // fill: 'black',
        fill: 'rgba(0,0,0,0.9)',
        width: this.cellWidth,
        align: 'center',
      });
      this.layer.add(backgroudRect, text);
    });
    // 计算偏移量
    const startOffset = (firstDay.getDay() + 6) % 7; // 计算偏移,周一为0

    const lastMonth = month === 0 ? 11 : month - 1;
    const lastMonthYear = month === 0 ? year - 1 : year;
    const { daysInMonth: lastDaysInMonth } = this.getDaysInMonth(lastMonth, lastMonthYear);
    // 渲染上一个月的日期
    for (let i = 0; i < startOffset; i++) {
      const day = lastDaysInMonth - startOffset + 1 + i; // 计算上一个月的日期
      const x = i * this.cellWidth + this.startX;
      const id = `${year}-${month}-${(day)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1');
      const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id));

      const group = new Konva.Group({ name: 'dateCell', id: id, x: x, y: 70 });
      const rect = new Konva.Rect({
        x: 0,
        y: 0,
        width: this.cellWidth,
        height: this.cellHeight,
        fill: '#fff',
        stroke: '#E1E2E3',
        strokeWidth: 1,
      });
      const text = new Konva.Text({
        x: 10,
        y: 10,
        text: day.toString(),
        fontSize: 20,
        fontFamily: 'Calibri',
        fill: 'gray', // 用灰色标记上个月的日期
      });

      const chineseText = new Konva.Text({
        x: this.cellWidth - 40,
        y: 13,
        text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese,
        fontSize: 13,
        fontFamily: 'Calibri',
        fill: 'rgba(0,0,0,0.4)',
      });

      group.add(rect, text, chineseText);
      this.layer.add(group);
    }

    // 渲染当前月份的日期
    for (let i = 0; i < daysInMonth; i++) {
      const x = (i + startOffset) % 7 * this.cellWidth + this.startX;
      const y = Math.floor((i + startOffset) / 7) * this.cellHeight + 40 + 30; // + cellHeight 为下移一行
      const id = `${year}-${month + 1}-${(i + 1)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1');
      const group = new Konva.Group({ name: 'dateCell', id, x, y });
      const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id));

      const activeDate = this.stringDate === id;
      const rect = new Konva.Rect({
        x: 0,
        y: 0,
        width: this.cellWidth,
        height: this.cellHeight,
        fill: '#fff',
        stroke: '#EEE',
        strokeWidth: 1,
      });

      let Circlex = 20;
      let Circley = 20;
      let CircleRadius = 13;
      let fontSize = 20;
      let textContext = (i + 1).toString();
      if (textContext === '1') {
        textContext = month + 1 + '月' + (i + 1) + '日';
        fontSize = 15;
        CircleRadius = 15
      }

      //  命中当前日期
      const circle = new Konva.Circle({
        x: Circlex,
        y: Circley,
        radius: CircleRadius,
        fill: activeDate ? '#1f6df6' : '#FFF',
        stroke: activeDate ? '#1f6df6' : '#FFF',
        strokeWidth: 1,
      });

      const text = new Konva.Text({
        x: textContext?.length > 1 ? 10 : 15,
        y: textContext?.length > 1 ? 12 : 10,
        text: textContext,
        fontSize: fontSize,
        fontFamily: 'Calibri',
        fill: activeDate ? '#FFF' : 'black',
        width: this.cellWidth - 20,
        fontStyle: 'bold',
        // align: 'center',
      });
      // 添加月份名称
      const monthText = new Konva.Text({
        x: x,
        y: y + this.cellHeight / 2, // 调整位置以显示月份
        text: new Date(year, month).toLocaleString('default', { month: 'long' }),
        fontSize: 14,
        fontFamily: 'Calibri',
        fill: 'black',
        width: this.cellWidth,
        align: 'center',
      });

      const chineseText = new Konva.Text({
        x: this.cellWidth - 40,
        y: 13,
        text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese,
        fontSize: 13,
        fontFamily: 'Calibri',
        fill: 'rgba(0,0,0,0.4)',
      });
      group.add(rect, circle, text, chineseText);
      const { y: groupY, height } = group.getClientRect();
      if (groupY + height < this.stageHeight) {
        this.layer.add(group);
        group.moveToTop();
      }
    }

    // 渲染下一个月的日期
    const endOffset = (daysInMonth + startOffset) % 7;
    for (let i = 0; i < (7 - endOffset) % 7; i++) {
      const day = i + 1; // 下个月的日期
      const x = (daysInMonth + startOffset + i) % 7 * this.cellWidth + this.startX;
      const y = Math.floor((daysInMonth + startOffset) / 7) * this.cellHeight + 40 + 30;
      const id = `${year}-${month + 2}-${(day).toString().padStart(2, '0')}`;
      const group = new Konva.Group({ name: 'dateCell', id, x, y });
      const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id));

      const rect = new Konva.Rect({
        x: 0,
        y: 0,
        width: this.cellWidth,
        height: this.cellHeight,
        fill: '#fff',
        stroke: '#E1E2E3',
        strokeWidth: 1,
      });
      const text = new Konva.Text({
        x: 10,
        y: 10,
        text: day.toString(),
        fontSize: 24,
        fontFamily: 'Calibri',
        // 用灰色标记下个月的日期
        fill: 'gray',
        align: 'center',
      });
      const chineseText = new Konva.Text({
        x: this.cellWidth - 40,
        y: 13,
        text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese,
        fontSize: 13,
        fontFamily: 'Calibri',
        fill: 'rgba(0,0,0,0.4)',
      });
      group.add(rect, text, chineseText);

      const { y: groupY, height } = group.getClientRect();
      if (groupY + height < this.stageHeight) {
        this.layer.add(group);
      }

    }
  }

着重讲解一下 任务进度的渲染 (最复杂的部分)
  // 假如任务队列中数据  下面贴出任务的展示形式
   taskRanges = {
     startTime: '2024-10-01',
     endTime: '2024-10-20',
     fill: '#fff5cc',
     description: '1',
     id: '12345' || uuid()
  },
3047d3ceddd1c6dee9186536488d3ec3.png

任务进度的渲染使用的 konva.rect, 那么一个 rect 只能表示某一周中的时间范围。而视图中却显示了三个 rect。由此我们能分析出,每一个跨周的时间任务需要切割成一周一周的任务来渲染。由此上面taskRanges就需要被切割。分割成不同的小块 range 后,需要与原始 range 形成关联 , "origin": "12345"标注出父节点的 id,用于后续修改|拖动|点击 | 查找 | 删除 原始range。具体如何分割的不是重点,后面可以查看 github 源码。

 [    {        "startTime": "2024-10-01",        "endTime": "2024-10-06",        "fill": "rgba(0, 0, 255, 0.3)",        "description": "3 ",        "origin": "12345",        "id": "bb47c948-6ab0-4a47-8adf-13f8ed952643",        "day": 19    },    {        "startTime": "2024-10-07",        "endTime": "2024-10-13",        "fill": "rgba(0, 0, 255, 0.3)",        "description": "3 ",        "origin": "12345",        "id": "bb47c948-6ab0-4a47-8adf-13f8ed952643",        "day": 19    },    {        "startTime": "2024-10-14",        "endTime": "2024-10-20",        "fill": "rgba(0, 0, 255, 0.3)",        "description": "3 ",        "origin": "12345",        "id": "bb47c948-6ab0-4a47-8adf-13f8ed952643",        "day": 19    }]

接下来是循环渲染分割块任务,需要确定任务块的起始坐标和宽度。通过去日历渲染 layer 层查找 range 的起点时间为 id 去 找到对应的 group 就能拿到在画布中的起始坐标 ,宽度很好计算,只需要知道每天占用的宽度 * range 的结束时间 - 开始时间的天数 const width = dayCount * this.cellWidth;

    //  绘制日历的时候确定好层级关系 Group 包裹 (rect text)
    const id = `${year}-${month + 1}-${(i + 1)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1');
    //  用当天的时间作为 id  方便后续查找
    const group = new Konva.Group({ name: 'dateCell', id, x, y });
      
    //  查找
    const group = this.layer.find(`#${range.startTime}`)[0]; 
    if (!group) return; 
    const groupRect = group.getClientRect();
       // 遍历任务数据
    expectedResult.forEach((range) => {
      const startDate = new Date(range.startTime).toISOString().split('T')[0];
      const endDate = new Date(range.endTime).toISOString().split('T')[0];

      // 计算任务跨越的天数,用于绘制宽度
      const dayCount = Math.abs(this.calculateDaysDifference(range.endTime, range.startTime)) + 1;
      const width = dayCount * this.cellWidth;

      // 查找任务开始日期的组
      const group = this.layer.find(`#${range.startTime}`)[0];
      if (!group) return;
      const groupRect = group.getClientRect();

      // 查找合适的 yOffset
      let yOffset = 0;
      for (let [offset, dates] of sizeMap) {
        let overlap = false;

        // 检查当前 yOffset 是否有日期重叠
        for (let dateRange of dates) {
          if ((startDate >= dateRange[0] && startDate <= dateRange[1]) ||
            (endDate >= dateRange[0] && endDate <= dateRange[1]) ||
            (startDate <= dateRange[0] && endDate >= dateRange[1])) {
            overlap = true;
            break;
          }
        }

        // 如果没有重叠,使用当前的 yOffset,并将日期插入该 yOffset 的数组
        if (!overlap) {
          yOffset = offset;
          dates.push([startDate, endDate]);
          break;
        }
      }

      // 绘制任务
      const rect = new Konva.Rect({
        x: groupRect.x + 10,
        y: groupRect.y + yOffset,
        width: width - 10,
        height: 20,
        fill: range.fill || '#f3d4d4',
        stroke: range.fill || '#f3d4d4',
        opacity: 1,
        strokeWidth: 1,
        cornerRadius: [3, 3, 3, 3]
      });

      const text = new Konva.Text({
        x: groupRect.x + 15,
        y: groupRect.y + yOffset + 5,
        text: range.description || '无',
        fontSize: 12,
        fill: 'rgba(0,0,0,0.8)'
        // fill : 'white'
      });

      // 创建 Konva 组并添加任务矩形和文本
      const taskProgressGroup = new Konva.Group({
        name: `task-progress-group ${range.origin}`,
        id: range.id,
        day: range.day
      });

      taskProgressGroup.add(rect, text);
      this.featureLayer.add(taskProgressGroup);
      taskProgressGroup.moveToTop();
    });

同一周存在多个时间段的处理 ( 也是有点难度的 ) 有些任务的时间范围跨度比较长,经过的时间多,所以我决定对任务进行时间的先后顺序排序 并且优先绘制时间跨度的长的任务。但是仅仅如此的话 多个任务相交的时间点任务显示会被重叠 所以还要处理任务的 y轴 位置。仔细看下面代码中的注释解释:

 假如数据源为
  taskRanges =[      {    startTime: '2024-10-01',    endTime: '2024-10-20',    fill: 'rgba(0, 0, 255, 0.3)',    description: '3 ',    id: uuid()  },    {    startTime: '2024-10-04',    endTime: '2024-10-05',    fill: 'pink',    description: '2 ',    id: uuid()  },  {    startTime: '2024-10-10',    endTime: '2024-10-12',    fill: '#caeadb',    description: '4',    id: uuid()  },  {    startTime: '2024-10-04',    endTime: '2024-10-04',    fill: 'rgba(214,241,255,0.6)',    description: '555',    id: uuid()  },  ];
  
  //  在渲染任务进度的代码中  我定义了 
  // 初始化 sizeMap,用于管理不同 yOffset 对应的日期范围
    const sizeMap的作用 
    假如 range= new Map<number, string[][]>([      [35, []],
      [65, []],
      [95, []],
      [125, []]
    ]);
   我简单解释下这个sizeMap的作用 
   假如 range1 =  {  startTime: '2024-10-01',  endTime: '2024-10-04' }
   那么在渲染的时候 我会将经过的时间都存储进去
   结果就是 range= new Map<number, string[][]>([      [35, [ '2024-10-01''2024-10-02''2024-10-03''2024-10-04' ]],
      [65, []],
      [95, []],
      [125, []]
    ]);
   渲染第二条 range的时候  假如 range2 =  {  startTime: '2024-10-03',  endTime: '2024-10-04' }
   
   我就会先去找到 y 坐标为 35 中是否存在 startTime 如果存在那么就会赋值  y :75 并且得到新的
  range= new Map<number, string[][]>([
      [35, [ '2024-10-01''2024-10-02''2024-10-03''2024-10-04' ]],
      [65, [ '2024-10-03' ,  '2024-10-04'],
      [95, []],
      [125, []]
    ]);
    
    以此类推  多个 range 就算交叉也不会重叠   
    
    注释:  一个时间的 cell 最多展示 3 个 range 便可, 超过就不要渲染了 可在左下方渲染一个文字提示,这块我目前还没去实现 也不是很复杂。只需要 判断某个range的起点时间 在 rangeMap 中 35 65 95 都存在了 就不渲染了
  
faa10b9460607f0777d8b5769c2db0ec.png
用户交互部分 拖动调整时间范围 (借助几个事件 )
  • mousedown 确定目标 rang 的信息
  • dragMousemove 将已经渲染的 目标range 设置一个低透明度,并且 this.recordsDragGroupRect.targetGroup!.clone()克隆一个 range 对象,添加到用户交互的layer 涂层上 this.featureLayer.add(this.dragGroup); 只需要控制这个克隆后的 range 在画布中移动的 x y 距离就行。
  • mouseup 还原一些临时数据,和确定时间更变的信息 进行修改 并且发布事件 range 调用者

下面的代码中有好几处调用了这个函数 我来解释下通过鼠标的xy 坐标 | 指定 某个 xy 坐标在 哪个layer层去查找 name 的 group mouseup就需要接住这个函数 鼠标抬起 拿到 xy 去判断在当前哪个时间上。

    const sorceDate = this.findGroup(this.layer, '.dateCell', {
      x: this.recordsDragGroupRect.startX,
      y: this.recordsDragGroupRect.startY
     });
      
        //  通过鼠标坐标 查找某个图层的元素
  private findGroup(
    layer: Konva.Layer,
    findkey: string,
    pointerParam?: Vector2d | null
  ) {
    const pointer = pointerParam || this.stage.getPointerPosition()!;
    const taskGroups = layer.find(findkey) as Konva.Group[];
    if (!taskGroups.length) {
      return;
    }
    for (let i = 0; i < taskGroups.length; i++) {
      const group = taskGroups[i];
      const rect = group.getClientRect();
      if (haveIntersection(rect, pointer)) {
        return { group, rect, pointer };
      }
    }
  }
  private mousedown(): void {
    if (this.config.mode === 'read') {
      return;
    }
    const result = this.findGroup(this.featureLayer, '.task-progress-group');
    if (!result) {
      return;
    }
    const { group, pointer, rect } = result;
    this.recordsDragGroupRect = {
      differenceX: pointer.x - rect.x,
      differenceY: pointer.y - rect.y,
      sourceX: rect.x,
      sourceY: rect.y,
      targetGroup: group,
      startX: pointer.x,
      startY: pointer.y
    }
  }
  
 private dragMousemove(): void {
    if (this.config.mode === 'read') {
      return;
    }
    if (this.recordsDragGroupRect.differenceX === 0 && this.recordsDragGroupRect.differenceY === 0) {
      return;
    }
    //  拖动中
    if (!this.dragGroup) {
      this.dragGroup = this.recordsDragGroupRect.targetGroup!.clone();
      this.recordsDragGroupRect.targetGroup!.opacity(0.3);
      this.hoverGroup.children[0].setAttr('fill', 'rgba(237,244,255,0.8)');
      this.hoverGroup.moveToBottom();
      this.featureLayer.add(this.dragGroup);
    }

    const pointer = this.stage.getPointerPosition()!;
    this.dragGroup.setAttrs({
      x: pointer.x - this.recordsDragGroupRect.differenceX - this.recordsDragGroupRect.sourceX,
      y: pointer.y - this.recordsDragGroupRect.differenceY - this.recordsDragGroupRect.sourceY
    })
  }
  
  private mouseup(): void {
    if (this.config.mode === 'read') {
      return;
    }
    if (this.dragGroup) {
      // this.hoverGroup.children[0].setAttr('fill', 'rgba(0, 0, 0, 0.053)');
      //  拖动结束
      const sorceDate = this.findGroup(this.layer, '.dateCell', {
        x: this.recordsDragGroupRect.startX,
        y: this.recordsDragGroupRect.startY
      });
      const targetDate = this.findGroup(this.layer, '.dateCell');
      if (!targetDate || !sorceDate) {
        return;
      }
      const sorceDateId = sorceDate.group.attrs.id;
      const targetDateId = targetDate.group.attrs.id;
      //  选择时间相同
      if (sorceDateId === targetDateId) {
        this.recordsDragGroupRect.targetGroup!.opacity(1);
      } else {
        console.log('this.recordsDragGroupRect', this.recordsDragGroupRect);

        const { day = 0, id } = this.recordsDragGroupRect.targetGroup?.attrs
        const arratItem = this.taskRanges.findIndex((item) => item.id === id);
        if (arratItem >= 0) {
          const endTime = this.addDays(targetDateId, day);
          console.log('=====>', day, sorceDateId, targetDateId, targetDateId, endTime);

          this.taskRanges[arratItem].startTime = targetDateId;
          this.taskRanges[arratItem].endTime = endTime;
          this.drawTaskProgress();
        }
      }
      this.dragGroup.remove();
    }
    this.recordsDragGroupRect = {
      differenceX: 0,
      differenceY: 0,
      sourceX: 0,
      sourceY: 0,
      startX: 0,
      startY: 0,
      targetGroup: null
    }
    this.dragGroup = null;
  }
向外界暴露一些功能 ,还有一些用户交互的代码 比如鼠标移入某个时间可以高亮 | 还能在某个时间点击添加任务。这些不多做赘述 看一后面参考代码还有一些功能 例如拖动延长任务时间什么的 可以在一有代码的参考下 自行实现下。也算是给大家一点挑战性。
    //. 更新任务
   setData(ranges: Range[]): this {
    this.taskRanges = ranges;
    this.draw();
    return this;
  }
   // 自定义的内部事件派发 事件监听
  on(key: EventType, callback: any): this {
    this.emitter.on(key, callback);
    return this;
  }
  //  将任务表 转成图片
   downImage(config: Parameters<Konva.Stage['toImage']>[number]): void {
    this.stage.toImage({
      pixelRatio: 2,
      callback: (image) => {
        const link = document.createElement('a');
        link.href = image.src;
        link.download = 'image.png';
        link.click();
      },
      ...config
    })
  }
  
    // 下一个月
  nextMonth(): void {
    this.month++;
    if (this.month > 11) {
      this.month = 0;
      this.year++;
    }
    this.featureLayer.removeChildren();
    this.draw();
  }
  //  今天
  today(): void {
    this.month = new Date().getMonth();
    this.year = new Date().getFullYear();
    this.featureLayer.removeChildren();
    this.draw();
  }
  //  上一个月
  prevMonth(): void {
    this.month--;
    if (this.month < 0) {
      this.month = 11;
      this.year--;
    }
    this.featureLayer.removeChildren();
    this.draw()
  }
结束语

某些逻辑实现可能不是最佳实践,多多包涵。可以等开源后自行修改源码。

目前这个代码我考虑优化后 近几天 开源发布 npm 包。

更新 :

npm包 : www.npmjs.com/package/kon…

github :    github.com/ayuechuan/k…