jtwang7 / Project-Note

开发小记 - 项目里的一些新奇玩意儿
1 stars 0 forks source link

数据可视化 - 环形音乐频谱 React + canvas 绘制 #14

Open jtwang7 opened 1 year ago

jtwang7 commented 1 year ago

数据可视化 - 环形音乐频谱 canvas 绘制

参考文章:

可视化效果

环形频谱

代码

🌈 index.tsx

import { useEffect, useRef } from "react";
import { SpectrumProps } from "./types";

const RAD = Math.PI / 180; // 1 弧度
export default function Spectrum(props: SpectrumProps) {
  const { data, config = {} } = props;

  // 默认配置
  const {
    width = 400,
    height = 400,
    mainCircle = {},
    subCircle = {},
    outerbar = {},
    innerbar = {},
  } = config;
  const {
    visible: mainCircleVisible = true,
    dash: mainCircleDash = [2],
    radius: mainCircleRadius = 100,
    lineWidth: mainCircleLineWidth = 1,
    strokeColor: mainCircleStrokeColor = "#111",
    fillColor: mainCircleFillColor = "#fff",
  } = mainCircle;
  const {
    visible: subCircleVisible = true,
    dash: subCircleDash = [],
    radius: subCircleRadius = 5,
    lineWidth: subCircleLineWidth = 1,
    strokeColor: subCircleStrokeColor = "#111",
    fillColor: subCircleFillColor = "transparent",
  } = subCircle;
  const {
    visible: outerBarVisible = true,
    lineWidth: outerLineWidth = 2,
    ratio: outerRatio = 10,
    color: outerColor = "#111",
  } = outerbar;
  const {
    visible: innerBarVisible = true,
    lineWidth: innerLineWidth = 1,
    ratio: innerRatio = 10,
    color: innerColor = "gray",
  } = innerbar;

  const CENTER = [width / 2, height / 2]; // 画布中心
  const outerRadius = mainCircleRadius + 10 + subCircleRadius; // 外圆半径
  const innerRadius = mainCircleRadius - 10 - subCircleRadius; // 内圆半径

  const canvasRef = useRef<HTMLCanvasElement>(null!);
  const ctx = useRef<CanvasRenderingContext2D | null>(null);

  // common function
  function toRange(num: number, min = 0, max = 360): number {
    const r = max - min;
    if (num >= min && num < max) return num;
    if (num < min) {
      num = r - num;
    } else if (num >= max) {
      num = num - r;
    }
    return toRange(num, min, max);
  }
  // 初始化画笔样式
  function initStyles(context: CanvasRenderingContext2D) {
    context.setLineDash([]);
    context.fillStyle = "#fff";
    context.strokeStyle = "#111";
    context.lineWidth = 1;
  }

  useEffect(() => {
    ctx.current = canvasRef.current?.getContext("2d") ?? null;
  }, []);

  useEffect(() => {
    const context = ctx.current;
    if (data.length && context) {
      // * 绘制外围频谱
      if (outerBarVisible) {
        const rounds = new Array(360).fill(outerRadius + 5);
        for (const item of data) {
          rounds[toRange(item.grid)] += 1 * outerRatio;
          rounds[toRange(item.grid - 1)] += 0.5 * outerRatio;
          rounds[toRange(item.grid - 2)] += 0.3 * outerRatio;
          rounds[toRange(item.grid + 1)] += 0.5 * outerRatio;
          rounds[toRange(item.grid + 2)] += 0.3 * outerRatio;
        }
        for (const [i, r] of rounds.entries()) {
          const x0 = CENTER[0] + Math.cos(i * RAD) * outerRadius;
          const y0 = CENTER[1] + Math.sin(i * RAD) * outerRadius;
          const [x1, y1] = CENTER;
          const x2 = CENTER[0] + Math.cos(i * RAD) * r;
          const y2 = CENTER[1] + Math.sin(i * RAD) * r;
          context.beginPath();
          context.lineWidth = outerLineWidth;
          if (typeof outerColor === "string") {
            context.strokeStyle = outerColor;
          } else if (
            Array.isArray(outerColor) &&
            outerColor.every((item) => typeof item === "string")
          ) {
            const lens = outerColor.length;
            const step = 1 / lens;
            const gradient = context.createLinearGradient(x0, y0, x2, y2);
            for (let i = 1; i <= lens; i++) {
              gradient.addColorStop(i * step, outerColor[i - 1]);
            }
            context.strokeStyle = gradient;
          }
          context.moveTo(x1, y1);
          context.lineTo(x2, y2);
          context.stroke();
          context.closePath();
        }
        initStyles(context);
        // * 绘制外圆
        context.beginPath();
        context.lineWidth = 3;
        context.fillStyle = "#fff";
        if (typeof outerColor === "string") {
          context.strokeStyle = outerColor;
        } else if (
          Array.isArray(outerColor) &&
          outerColor.every((item) => typeof item === "string")
        ) {
          context.strokeStyle = outerColor[0];
        }
        context.arc(CENTER[0], CENTER[1], outerRadius, 0, 360 * RAD);
        context.stroke();
        context.fill();
        context.closePath();
        initStyles(context);
      }
      if (mainCircleVisible) {
        // * 绘制主圆
        // 开启路径
        context.beginPath();
        // 设置画笔样式
        context.lineWidth = mainCircleLineWidth;
        context.strokeStyle = mainCircleStrokeColor;
        context.fillStyle = mainCircleFillColor;
        context.setLineDash(mainCircleDash);
        // 绘制
        context.arc(CENTER[0], CENTER[1], mainCircleRadius, 0, 360 * RAD);
        context.fill();
        context.stroke();
        // 结束路径
        context.closePath();
        initStyles(context);
      }
      if (subCircleVisible) {
        // * 绘制小圆
        for (const item of data) {
          const x = CENTER[0] + Math.cos(item.rad) * mainCircleRadius;
          const y = CENTER[1] + Math.sin(item.rad) * mainCircleRadius;
          context.beginPath();
          context.lineWidth = subCircleLineWidth;
          context.strokeStyle = subCircleStrokeColor;
          context.fillStyle = subCircleFillColor;
          context.setLineDash(subCircleDash);
          context.arc(x, y, subCircleRadius, 0, 360 * RAD);
          context.stroke();
          context.fill();
          context.closePath();
        }
        initStyles(context);
      }
      if (innerBarVisible) {
        // * 绘制内围频谱
        let innerRounds = new Array(360).fill(20);
        for (const item of data) {
          innerRounds[toRange(item.grid)] -= 1 * innerRatio;
          innerRounds[toRange(item.grid - 1)] -= 0.5 * innerRatio;
          innerRounds[toRange(item.grid - 2)] -= 0.3 * innerRatio;
          innerRounds[toRange(item.grid + 1)] -= 0.5 * innerRatio;
          innerRounds[toRange(item.grid + 2)] -= 0.3 * innerRatio;
        }
        innerRounds = innerRounds.map((o) => (o < 0 ? 0 : o));
        for (const [i, r] of innerRounds.entries()) {
          const x1 = CENTER[0] + Math.cos(i * RAD) * innerRadius;
          const y1 = CENTER[1] + Math.sin(i * RAD) * innerRadius;
          const x2 = CENTER[0] + Math.cos(i * RAD) * (innerRadius - r);
          const y2 = CENTER[1] + Math.sin(i * RAD) * (innerRadius - r);
          context.beginPath();
          context.lineWidth = innerLineWidth;
          if (typeof innerColor === "string") {
            context.strokeStyle = innerColor;
          } else if (
            Array.isArray(innerColor) &&
            innerColor.every((item) => typeof item === "string")
          ) {
            const lens = innerColor.length;
            const step = 1 / lens;
            const gradient = context.createLinearGradient(x1, y1, x2, y2);
            for (let i = 1; i <= lens; i++) {
              gradient.addColorStop(i * step, innerColor[i - 1]);
            }
            context.strokeStyle = gradient;
          }
          context.moveTo(x1, y1);
          context.lineTo(x2, y2);
          context.stroke();
          context.closePath();
        }
        initStyles(context);
        // * 绘制内圆
        context.beginPath();
        context.lineWidth = 3;
        if (typeof innerColor === "string") {
          context.strokeStyle = innerColor;
        } else if (
          Array.isArray(innerColor) &&
          innerColor.every((item) => typeof item === "string")
        ) {
          context.strokeStyle = innerColor[0];
        }
        context.arc(CENTER[0], CENTER[1], innerRadius, 0, 360 * RAD);
        context.stroke();
        context.closePath();
        initStyles(context);
      }
    }
    // data 发生变化时,清除 old canvas
    return () => {
      context && context.clearRect(0, 0, width, height)
    }
  }, [data]);

  return (
    <canvas width={width} height={height} ref={canvasRef}>
      当前浏览器不支持canvas元素,请升级或更换浏览器!
    </canvas>
  );
}

🌈 index.d.ts

type Bar = {
  visible?: boolean;
  // 宽度
  lineWidth?: number;
  // 相邻线条长度的影响系数
  ratio?: number;
  // 颜色
  color?: string | string[];
};

interface Circle {
  visible?: boolean;
  // 是否虚线
  dash?: number[];
  // 填充颜色
  fillColor?: string;
  // 描边颜色
  strokeColor?: string;
  // 描边粗细
  lineWidth?: number;
  // 半径
  radius?: number;
}

export interface SpectrumConfig {
  width?: number;
  height?: number;
  // 主圆样式
  mainCircle?: Circle;
  // 小圆样式
  subCircle?: Circle;
  // 频谱线样式
  outerbar?: Bar;
  innerbar?: Bar;
}

export interface SpectrumDataItem {
  rad: number;
  grid: number;
}

export interface SpectrumProps {
  data: SpectrumDataItem[];
  config?: SpectrumConfig;
}

使用

❇️ 数据集

import _ from "lodash";

const RAD = Math.PI / 180;

export const data = new Array(100).fill(null).map(() => ({
  rad: _.random(0, 360, true) * RAD,
  grid: _.random(0, 360, false),
}));

👋 调用

import React, { useEffect, useState } from "react";
import { heatmapBar1, heatmapBar2 } from "../analysis/constants/color";
import Spectrum from "./components/spectrum";
import { data } from "./components/spectrum/mocks";

export default function Test() {
  const [spectrumData, setSpectrumData] = useState<any>([]);
  useEffect(() => {
    const timer = setInterval(() => {
      setSpectrumData(data());
    }, 1000);
    return () => {
      clearInterval(timer);
    };
  }, []);

  return (
    <div>
      <Spectrum
        data={spectrumData}
        config={{
          outerbar: { color: heatmapBar1.slice(0, 4), ratio: 10 },
          innerbar: { color: heatmapBar2.slice(0, 4), ratio: 15 },
          subCircle: { strokeColor: "#42B3D5" },
        }}
      />
    </div>
  );
}
jtwang7 commented 1 year ago

Canvas2D - Konva.js 库实现环形音频

参考:

可视化效果

环形频谱

代码

🌈 index.tsx

import React, { useEffect, useRef } from "react";
import Konva from "konva";
import { Stage } from "konva/lib/Stage";
import { SpectrumKonvaProps } from "./types";
import _ from "lodash";

const DEGREE = 180 / Math.PI;
const RAD = Math.PI / 180;
export default function SpectrumKonva({
  width,
  height,
  data: odata,
  initRotate = 0, // 起始角度:默认十字坐标系y轴正向
  radius = 80,
  outerRatio = 10,
  backgroundColor = "#111",
  subCircleStyle = {},
  lineStyle = {},
  parser = {
    rad: "rad",
    grid: "grid",
  },
}: SpectrumKonvaProps) {
  const ref = useRef<HTMLDivElement>(null!);
  const stage = useRef<Stage | null>(null);

  const {
    radius: subCircleRadius = 5,
    fill: subCircleFill = "transparent",
    stroke: subCircleStroke = "#111",
  } = subCircleStyle;
  const { width: lineWidth = 1, stroke: lineStroke = "#111" } = lineStyle;
  const clockRadius = radius - 6 * subCircleRadius;

  // common function
  function toRange(num: number, min = 0, max = 360): number {
    const r = max - min;
    if (num >= min && num < max) return num;
    if (num < min) {
      num = r - num;
    } else if (num >= max) {
      num = num - r;
    }
    return toRange(num, min, max);
  }

  useEffect(() => {
    stage.current = ref.current
      ? new Konva.Stage({
          container: ref.current,
          width,
          height,
          draggable: false,
        })
      : null;
    return () => {
      stage.current && stage.current.destroy();
      stage.current = null;
    };
  }, []);

  useEffect(() => {
    const layer = new Konva.Layer();
    if (stage.current && odata.length) {
      try {
        // 数据格式转换
        const data = odata.map((o) =>
          _.mapKeys(o, (val, key) => {
            if (key === parser?.grid) {
              return "grid";
            } else if (key === parser?.rad) {
              return "rad";
            } else {
              return key;
            }
          })
        );

        const CENTER = [stage.current.width() / 2, stage.current.height() / 2];

        const group = new Konva.Group({
          x: CENTER[0],
          y: CENTER[1],
          rotation: initRotate - 90,
        });

        const rounds = new Array(360).fill(radius + 5);
        for (const item of data) {
          rounds[toRange(item.grid)] += 1 * outerRatio;
          rounds[toRange(item.grid - 1)] += 0.5 * outerRatio;
          rounds[toRange(item.grid - 2)] += 0.3 * outerRatio;
          rounds[toRange(item.grid + 1)] += 0.5 * outerRatio;
          rounds[toRange(item.grid + 2)] += 0.3 * outerRatio;
        }
        const pts: [number, number][] = [];
        const lineList = [];
        for (const [i, long] of rounds.entries()) {
          // * 绘制频谱线段
          const line = new Konva.Line({
            points: [0, 0, long, 0],
            // x: CENTER[0],
            // y: CENTER[1],
            strokeWidth: lineWidth,
            stroke: lineStroke,
          });
          pts.push([
            Math.cos(i * RAD) * (long + 4),
            Math.sin(i * RAD) * (long + 4),
          ]);
          group.add(line);
          lineList.push({ node: line, rotation: i });
        }
        const curve = new Konva.Line({
          points: pts.flat(1),
          // x: CENTER[0],
          // y: CENTER[1],
          tension: 0.5,
          strokeWidth: lineWidth + 2,
          stroke: backgroundColor,
          fill: backgroundColor,
          closed: true,
          opacity: 0,
        });
        group.add(curve);

        const ring = new Konva.Ring({
          innerRadius: clockRadius,
          outerRadius: radius,
          // x: CENTER[0],
          // y: CENTER[1],
          fill: "#fff",
          stroke: lineStroke,
          strokeWidth: 2,
        });
        group.add(ring);

        // * 绘制时钟
        const clock = new Konva.Circle({
          radius: clockRadius,
          strokeWidth: 2,
          fill: "#fff",
          stroke: lineStroke,
        });
        group.add(clock);
        for (let i = 0; i < 360; i++) {
          if (i % 3 === 0) {
            const subline = new Konva.Line({
              points: [clockRadius, 0, clockRadius - 4, 0],
              rotation: i,
              strokeWidth: 1,
              stroke: lineStroke,
            });
            group.add(subline);
          }
          if (i % 15 === 0) {
            const mainLine = new Konva.Line({
              points: [clockRadius, 0, clockRadius - 8, 0],
              rotation: i,
              strokeWidth: 2,
              stroke: lineStroke,
            });
            group.add(mainLine);
          }
        }

        const subCircleList = [];
        for (const o of data) {
          // * 绘制小圆点
          const subCircle = new Konva.Circle({
            radius: subCircleRadius,
            // 将操作中心基于默认中心(circle默认中心定位在圆心)偏移
            offsetX: -clockRadius,
            offsetY: 0,
            // 此时 x, y 相对偏移后的中心定位
            // x: CENTER[0],
            // y: CENTER[1],
            fill: subCircleFill,
            stroke: subCircleStroke,
            strokeWidth: 0.5,
          });
          group.add(subCircle);
          subCircleList.push({ node: subCircle, rotation: o.rad * DEGREE });
        }

        layer.add(group);
        // 动画必须要等待节点添加到 layer 后
        lineList.forEach(({ node, rotation }) => {
          const tween = new Konva.Tween({
            node, // node 节点必须保证已被添加到 layer
            duration: 0.5,
            rotation,
            easing: Konva.Easings.EaseInOut,
          });
          tween.play();
        });
        setTimeout(() => {
          const curveTween = new Konva.Tween({
            node: curve,
            opacity: 0.3,
            duration: 0.5,
          });
          curveTween.play();
        }, 500);
        subCircleList.forEach(({ node, rotation }) => {
          const tween = new Konva.Tween({
            node, // node 节点必须保证已被添加到 layer
            duration: 0.5,
            rotation,
            easing: Konva.Easings.EaseInOut,
          });
          tween.play();
          setTimeout(() => {
            const tween2 = new Konva.Tween({
              node, // node 节点必须保证已被添加到 layer
              duration: 0.5,
              offsetX: -radius + (radius - clockRadius) / 2,
              easing: Konva.Easings.EaseInOut,
            });
            tween2.play();
          }, 500);
        });

        stage.current.add(layer);
      } catch (err) {
        console.log(err);
      }
    }

    return () => {
      // 彻底销毁 layer 图层
      // ! 仅通过 stage.clear() 清除所有图层无效,绘制的图层会被缓存
      layer.destroy();
    };
  }, [odata]);

  return <div ref={ref}></div>;
}

🌈 index.d.ts

export interface SpectrumKonvaProps {
  width: number;
  height: number;
  data: any[]; // 'grid', 'rad' is needed (or parsed)
  initRotate?: number;
  radius?: number;
  outerRatio?: number;
  backgroundColor?: string;
  subCircleStyle?: {
    radius?: number;
    fill?: string;
    stroke?: string;
  };
  lineStyle?: {
    width?: number;
    stroke?: string;
  };
  parser?: {
    rad?: string;
    grid?: string;
  };
}

使用

✅ 数据集:同上文

import React, { useEffect, useState } from "react";
import { heatmapBar1, heatmapBar2 } from "../analysis/constants/color";
import SpectrumKonva from "./components/spectrum-konva";
import { data as konvaData } from "./components/spectrum-konva/mocks";

export default function Test() {
  const [a, setA] = useState({});
  useEffect(() => {
    const timer = setInterval(() => {
      setA({});
    }, 3000);
    return () => {
      clearInterval(timer);
    };
  }, []);

  return (
    <div>
      <SpectrumKonva
        data={konvaData()}
        width={400}
        height={400}
        backgroundColor="#E85285"
      />
    </div>
  );
}