分享和讨论Javascript相关的话题
回复
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

canvas製作數學動畫

帖子 ejsoon »

做一個js,在div#ejtricc內增加一個canvas,大小是360*360,暗底色,在canvas中,有一個直徑為348的圓,圓心位於畫布中心。其內部存在一個三角形ABC,對應的邊為a,b,c,延長每條邊使其接觸到圓,保持「點A外的兩條延長線段長度等於a,點B外的兩條延長線段長度等於b,點C外的兩條延長線段長度等於c」。隨機緩慢移動A,B,C,但不能移至三點共線。不用標字母,三種長度相同的線段用相同顏色。加進css使canvas寬度等於div。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

在上面代碼的基礎上,增加「導出svg動畫」功能。svg中用circle和path替代canvas中的圓、點和線。三個點移動五次重回原位,用時5s,形成循環動畫。說明是否animate的values只需要5個幀即可實現,如不行,則做成每秒12幀更改點坐標和path的d值。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

代码: 全选

<?xml version="1.0" encoding="UTF-8"?>
<svg
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
  width="360"
  height="360"
  viewBox="0 0 360 360"
  role="img"
  aria-label="循環移動的幾何三角形"
  data-position-count="4"
  data-duration="7"
  data-fps="12"
>
  <defs>
    <radialGradient
      id="ejtricc-circle-background"
      cx="50%"
      cy="50%"
      r="50%"
    >
      <stop
        offset="0%"
        stop-color="#121b2f"
      />

      <stop
        offset="72%"
        stop-color="#0d1424"
      />

      <stop
        offset="100%"
        stop-color="#090e19"
      />
    </radialGradient>

    <clipPath id="ejtricc-circle-clip">
      <circle
        cx="180"
        cy="180"
        r="174"
      />
    </clipPath>

    <filter
      id="ejtricc-soft-glow"
      x="-20%"
      y="-20%"
      width="140%"
      height="140%"
      color-interpolation-filters="sRGB"
    >
      <feGaussianBlur
        stdDeviation="2"
        result="blur"
      />

      <feMerge>
        <feMergeNode in="blur"/>
        <feMergeNode in="SourceGraphic"/>
      </feMerge>
    </filter>
  </defs>

  <!-- 暗色背景 -->
  <path
    d="M 0 0 H 360 V 360 H 0 Z"
    fill="#070a12"
  />

  <!-- 直徑 348 的圓形背景 -->
  <circle
    cx="180"
    cy="180"
    r="174"
    fill="url(#ejtricc-circle-background)"
  />

  <!-- 九條動畫線段 -->
  <g
    clip-path="url(#ejtricc-circle-clip)"
    filter="url(#ejtricc-soft-glow)"
  >
    
        <path
          d="M 99.742 334.385 L 128.398 241.16"
          fill="none"
          stroke="#36e2ff"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 99.742 334.385 L 128.398 241.16;M 100.192 334.618 L 128.607 241.161;M 101.472 335.272 L 129.199 241.159;M 103.481 336.272 L 130.117 241.151;M 106.124 337.538 L 131.301 241.128;M 109.302 338.99 L 132.695 241.083;M 112.916 340.548 L 134.241 241.011;M 116.859 342.14 L 135.882 240.907;M 121.021 343.699 L 137.567 240.771;M 125.284 345.173 L 139.245 240.605;M 129.523 346.518 L 140.869 240.413;M 133.611 347.702 L 142.395 240.204;M 137.412 348.708 L 143.78 239.99;M 140.791 349.525 L 144.985 239.784;M 143.608 350.152 L 145.972 239.6;M 145.727 350.591 L 146.704 239.456;M 147.009 350.844 L 147.142 239.366;M 147.379 350.915 L 147.269 239.344;M 148.638 351.15 L 147.698 239.372;M 151.525 351.654 L 148.677 239.426;M 155.832 352.313 L 150.123 239.479;M 161.349 352.998 L 151.95 239.497;M 167.864 353.576 L 154.072 239.449;M 175.151 353.932 L 156.401 239.309;M 182.979 353.974 L 158.852 239.06;M 191.105 353.645 L 161.343 238.694;M 199.279 352.929 L 163.795 238.218;M 207.254 351.852 L 166.136 237.649;M 214.783 350.488 L 168.299 237.017;M 221.629 348.947 L 170.227 236.363;M 227.565 347.373 L 171.868 235.732;M 232.373 345.931 L 173.177 235.178;M 235.846 344.795 L 174.11 234.753;M 237.776 344.128 L 174.625 234.508;M 237.961 344.063 L 174.68 234.496;M 236.386 344.61 L 174.329 234.826;M 233.252 345.651 L 173.62 235.479;M 228.762 347.028 L 172.582 236.404;M 223.126 348.571 L 171.238 237.55;M 216.557 350.116 L 169.613 238.863;M 209.28 351.519 L 167.738 240.293;M 201.532 352.663 L 165.646 241.789;M 193.555 353.471 L 163.386 243.302;M 185.594 353.91 L 161.013 244.786;M 177.893 353.987 L 158.598 246.197;M 170.689 353.751 L 156.224 247.495;M 164.207 353.282 L 153.985 248.646;M 158.661 352.687 L 151.986 249.618;M 154.254 352.085 L 150.339 250.381;M 151.182 351.597 L 149.158 250.908;M 149.64 351.331 L 148.555 251.171;M 149.294 351.269 L 148.406 251.014;M 148.116 351.054 L 147.873 249.959;M 146.022 350.65 L 146.961 248.081;M 143.173 350.058 L 145.794 245.521;M 139.73 349.276 L 144.498 242.423;M 135.853 348.306 L 143.188 238.931;M 131.695 347.16 L 141.963 235.188;M 127.399 345.859 L 140.892 231.331;M 123.098 344.433 L 140.02 227.487;M 118.916 342.925 L 139.367 223.774;M 114.963 341.388 L 138.926 220.295;M 111.344 339.882 L 138.674 217.139;M 108.152 338.474 L 138.572 214.387;M 105.478 337.234 L 138.575 212.105;M 103.407 336.235 L 138.631 210.354;M 102.026 335.551 L 138.696 209.196;M 101.426 335.249 L 138.731 208.695;M 101.365 335.218 L 138.642 208.849;M 101.201 335.135 L 138.302 209.501;M 100.945 335.004 L 137.748 210.6;M 100.631 334.843 L 137.028 212.1;M 100.293 334.67 L 136.189 213.961;M 99.964 334.5 L 135.275 216.14;M 99.673 334.349 L 134.327 218.594;M 99.439 334.227 L 133.379 221.271;M 99.278 334.143 L 132.461 224.111;M 99.195 334.099 L 131.599 227.043;M 99.188 334.096 L 130.812 229.981;M 99.247 334.127 L 130.116 232.824;M 99.355 334.183 L 129.525 235.456;M 99.486 334.251 L 129.048 237.746;M 99.613 334.318 L 128.695 239.555;M 99.706 334.366 L 128.474 240.738;M 99.742 334.385 L 128.398 241.16"
          />
        </path>
      
        <path
          d="M 167.183 114.982 L 229.075 190.357"
          fill="none"
          stroke="#36e2ff"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 167.183 114.982 L 229.075 190.357;M 166.97 114.986 L 229.205 190.275;M 166.367 115.003 L 229.573 190.043;M 165.427 115.048 L 230.147 189.672;M 164.203 115.138 L 230.893 189.175;M 162.75 115.294 L 231.777 188.563;M 161.124 115.53 L 232.766 187.847;M 159.382 115.855 L 233.827 187.044;M 157.581 116.273 L 234.927 186.171;M 155.779 116.776 L 236.034 185.25;M 154.029 117.348 L 237.116 184.308;M 152.383 117.964 L 238.142 183.374;M 150.89 118.591 L 239.081 182.485;M 149.594 119.19 L 239.905 181.676;M 148.535 119.719 L 240.585 180.989;M 147.753 120.134 L 241.091 180.465;M 147.285 120.392 L 241.396 180.145;M 147.151 120.469 L 241.482 180.049;M 146.701 120.77 L 241.712 179.662;M 145.684 121.475 L 242.231 178.763;M 144.208 122.563 L 242.985 177.402;M 142.387 124.018 L 243.916 175.62;M 140.34 125.82 L 244.962 173.459;M 138.182 127.939 L 246.065 170.968;M 136.024 130.33 L 247.169 168.202;M 133.96 132.929 L 248.224 165.229;M 132.07 135.658 L 249.19 162.128;M 130.41 138.423 L 250.035 158.99;M 129.015 141.121 L 250.741 155.919;M 127.895 143.644 L 251.302 153.031;M 127.043 145.882 L 251.722 150.449;M 126.435 147.728 L 252.014 148.301;M 126.044 149.078 L 252.198 146.717;M 125.844 149.834 L 252.29 145.824;M 125.833 149.921 L 252.291 145.716;M 126.074 149.459 L 252.142 146.219;M 126.57 148.552 L 251.837 147.209;M 127.322 147.285 L 251.387 148.602;M 128.332 145.747 L 250.796 150.312;M 129.6 144.032 L 250.071 152.254;M 131.111 142.231 L 249.222 154.345;M 132.84 140.432 L 248.262 156.509;M 134.744 138.713 L 247.212 158.673;M 136.765 137.138 L 246.098 160.772;M 138.829 135.754 L 244.954 162.749;M 140.854 134.592 L 243.82 164.552;M 142.752 133.662 L 242.744 166.138;M 144.431 132.957 L 241.779 167.467;M 145.799 132.46 L 240.982 168.506;M 146.772 132.147 L 240.409 169.22;M 147.265 132.001 L 240.116 169.576;M 147.35 131.89 L 240.189 169.744;M 147.587 131.318 L 240.765 170.537;M 148.039 130.307 L 241.759 171.995;M 148.717 128.947 L 243.047 174.077;M 149.633 127.329 L 244.494 176.743;M 150.793 125.546 L 245.968 179.939;M 152.187 123.689 L 247.345 183.59;M 153.791 121.838 L 248.524 187.598;M 155.564 120.066 L 249.431 191.842;M 157.448 118.432 L 250.023 196.182;M 159.371 116.977 L 250.299 200.465;M 161.254 115.732 L 250.289 204.532;M 163.009 114.708 L 250.057 208.226;M 164.548 113.91 L 249.693 211.393;M 165.781 113.331 L 249.3 213.887;M 166.623 112.966 L 248.982 215.568;M 166.994 112.812 L 248.829 216.303;M 166.992 112.746 L 248.702 216.1;M 166.941 112.518 L 248.232 215.242;M 166.864 112.176 L 247.451 213.846;M 166.774 111.784 L 246.405 212.032;M 166.684 111.411 L 245.138 209.921;M 166.607 111.116 L 243.693 207.623;M 166.553 110.948 L 242.111 205.241;M 166.529 110.942 L 240.433 202.862;M 166.54 111.113 L 238.7 200.56;M 166.585 111.458 L 236.959 198.398;M 166.661 111.957 L 235.255 196.424;M 166.761 112.568 L 233.641 194.68;M 166.875 113.237 L 232.174 193.195;M 166.989 113.892 L 230.918 191.996;M 167.088 114.453 L 229.937 191.107;M 167.157 114.84 L 229.301 190.55;M 167.183 114.982 L 229.075 190.357"
          />
        </path>
      
        <path
          d="M 128.398 241.16 L 41.326 285.098"
          fill="none"
          stroke="#36e2ff"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 128.398 241.16 L 41.326 285.098;M 128.607 241.161 L 41.442 285.251;M 129.199 241.159 L 41.771 285.683;M 130.117 241.151 L 42.285 286.351;M 131.301 241.128 L 42.956 287.214;M 132.695 241.083 L 43.755 288.228;M 134.241 241.011 L 44.655 289.351;M 135.882 240.907 L 45.626 290.543;M 137.567 240.771 L 46.64 291.764;M 139.245 240.605 L 47.668 292.979;M 140.869 240.413 L 48.679 294.153;M 142.395 240.204 L 49.645 295.255;M 143.78 239.99 L 50.535 296.254;M 144.985 239.784 L 51.32 297.122;M 145.972 239.6 L 51.971 297.833;M 146.704 239.456 L 52.457 298.359;M 147.142 239.366 L 52.751 298.675;M 147.269 239.344 L 52.842 298.772;M 147.698 239.372 L 53.339 299.302;M 148.677 239.426 L 54.483 300.505;M 150.123 239.479 L 56.198 302.266;M 151.95 239.497 L 58.411 304.467;M 154.072 239.449 L 61.047 306.989;M 156.401 239.309 L 64.027 309.716;M 158.852 239.06 L 67.266 312.541;M 161.343 238.694 L 70.672 315.364;M 163.795 238.218 L 74.147 318.098;M 166.136 237.649 L 77.586 320.668;M 168.299 237.017 L 80.882 323.009;M 170.227 236.363 L 83.923 325.069;M 171.868 235.732 L 86.595 326.804;M 173.177 235.178 L 88.786 328.176;M 174.11 234.753 L 90.384 329.147;M 174.625 234.508 L 91.278 329.681;M 174.68 234.496 L 91.404 329.756;M 174.329 234.826 L 91.115 329.584;M 173.62 235.479 L 90.538 329.24;M 172.582 236.404 L 89.709 328.74;M 171.238 237.55 L 88.661 328.098;M 169.613 238.863 L 87.429 327.332;M 167.738 240.293 L 86.05 326.456;M 165.646 241.789 L 84.561 325.49;M 163.386 243.302 L 83.002 324.456;M 161.013 244.786 L 81.418 323.379;M 158.598 246.197 L 79.853 322.291;M 156.224 247.495 L 78.357 321.226;M 153.985 248.646 L 76.982 320.226;M 151.986 249.618 L 75.781 319.336;M 150.339 250.381 L 74.81 318.604;M 149.158 250.908 L 74.123 318.08;M 148.555 251.171 L 73.775 317.812;M 148.406 251.014 L 73.343 317.478;M 147.873 249.959 L 71.034 315.656;M 146.961 248.081 L 66.967 312.286;M 145.794 245.521 L 61.52 307.431;M 144.498 242.423 L 55.098 301.143;M 143.188 238.931 L 48.115 293.5;M 141.963 235.188 L 40.976 284.634;M 140.892 231.331 L 34.054 274.741;M 140.02 227.487 L 27.669 264.091;M 139.367 223.774 L 22.065 253.024;M 138.926 220.295 L 17.395 241.933;M 138.674 217.139 L 13.72 231.253;M 138.572 214.387 L 11.006 221.436;M 138.575 212.105 L 9.145 212.933;M 138.631 210.354 L 7.981 206.184;M 138.696 209.196 L 7.347 201.607;M 138.731 208.695 L 7.107 199.599;M 138.642 208.849 L 7.175 200.186;M 138.302 209.501 L 7.483 202.671;M 137.748 210.6 L 8.071 206.763;M 137.028 212.1 L 9.001 212.179;M 136.189 213.961 L 10.342 218.627;M 135.275 216.14 L 12.14 225.814;M 134.327 218.594 L 14.413 233.45;M 133.379 221.271 L 17.141 241.26;M 132.461 224.111 L 20.259 248.985;M 131.599 227.043 L 23.664 256.389;M 130.812 229.981 L 27.216 263.265;M 130.116 232.824 L 30.746 269.438;M 129.525 235.456 L 34.065 274.757;M 129.048 237.746 L 36.976 279.096;M 128.695 239.555 L 39.281 282.343;M 128.474 240.738 L 40.789 284.385;M 128.398 241.16 L 41.326 285.098"
          />
        </path>
      
        <path
          d="M 167.183 114.982 L 200.317 7.19"
          fill="none"
          stroke="#ff4fc8"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 167.183 114.982 L 200.317 7.19;M 166.97 114.986 L 199.765 7.126;M 166.367 115.003 L 198.201 6.955;M 165.427 115.048 L 195.762 6.715;M 164.203 115.138 L 192.585 6.456;M 162.75 115.294 L 188.81 6.223;M 161.124 115.53 L 184.576 6.06;M 159.382 115.855 L 180.026 6;M 157.581 116.273 L 175.298 6.064;M 155.779 116.776 L 170.535 6.258;M 154.029 117.348 L 165.874 6.574;M 152.383 117.964 L 161.451 6.992;M 150.89 118.591 L 157.398 7.474;M 149.594 119.19 L 153.844 7.977;M 148.535 119.719 L 150.914 8.448;M 147.753 120.134 L 148.731 8.833;M 147.285 120.392 L 147.418 9.078;M 147.151 120.469 L 147.04 9.15;M 146.701 120.77 L 145.765 9.401;M 145.684 121.475 L 142.856 10.011;M 144.208 122.563 L 138.564 11.006;M 142.387 124.018 L 133.146 12.427;M 140.34 125.82 L 126.864 14.312;M 138.182 127.939 L 119.982 16.679;M 136.024 130.33 L 112.758 19.518;M 133.96 132.929 L 105.443 22.783;M 132.07 135.658 L 98.27 26.39;M 130.41 138.423 L 91.451 30.217;M 129.015 141.121 L 85.176 34.108;M 127.895 143.644 L 79.608 37.883;M 127.043 145.882 L 74.886 41.338;M 126.435 147.728 L 71.135 44.264;M 126.044 149.078 L 68.466 46.448;M 125.844 149.834 L 66.998 47.688;M 125.833 149.921 L 66.857 47.808;M 126.074 149.459 L 68.044 46.801;M 126.57 148.552 L 70.43 44.832;M 127.322 147.285 L 73.9 42.092;M 128.332 145.747 L 78.342 38.785;M 129.6 144.032 L 83.643 35.116;M 131.111 142.231 L 89.672 31.283;M 132.84 140.432 L 96.277 27.466;M 134.744 138.713 L 103.283 23.825;M 136.765 137.138 L 110.489 20.488;M 138.829 135.754 L 117.67 17.547;M 140.854 134.592 L 124.582 15.061;M 142.752 133.662 L 130.97 13.051;M 144.431 132.957 L 136.565 11.508;M 145.799 132.46 L 141.101 10.404;M 146.772 132.147 L 144.311 9.699;M 147.265 132.001 L 145.937 9.367;M 147.35 131.89 L 146.264 9.302;M 147.587 131.318 L 147.293 9.102;M 148.039 130.307 L 149.152 8.756;M 148.717 128.947 L 151.742 8.31;M 149.633 127.329 L 154.967 7.81;M 150.793 125.546 L 158.723 7.306;M 152.187 123.689 L 162.902 6.842;M 153.791 121.838 L 167.385 6.458;M 155.564 120.066 L 172.043 6.182;M 157.448 118.432 L 176.74 6.031;M 159.371 116.977 L 181.331 6.005;M 161.254 115.732 L 185.666 6.092;M 163.009 114.708 L 189.594 6.265;M 164.548 113.91 L 192.962 6.483;M 165.781 113.331 L 195.618 6.702;M 166.623 112.966 L 197.413 6.873;M 166.994 112.812 L 198.198 6.954;M 166.992 112.746 L 198.199 6.954;M 166.941 112.518 L 198.118 6.946;M 166.864 112.176 L 197.998 6.933;M 166.774 111.784 L 197.87 6.92;M 166.684 111.411 L 197.761 6.909;M 166.607 111.116 L 197.698 6.902;M 166.553 110.948 L 197.702 6.903;M 166.529 110.942 L 197.787 6.912;M 166.54 111.113 L 197.959 6.929;M 166.585 111.458 L 198.215 6.956;M 166.661 111.957 L 198.543 6.991;M 166.761 112.568 L 198.92 7.032;M 166.875 113.237 L 199.317 7.076;M 166.989 113.892 L 199.697 7.118;M 167.088 114.453 L 200.017 7.155;M 167.157 114.84 L 200.237 7.181;M 167.183 114.982 L 200.317 7.19"
          />
        </path>
      
        <path
          d="M 95.619 27.829 L 167.183 114.982"
          fill="none"
          stroke="#ff4fc8"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 95.619 27.829 L 167.183 114.982;M 95.144 28.094 L 166.97 114.986;M 93.802 28.852 L 166.367 115.003;M 91.718 30.059 L 165.427 115.048;M 89.025 31.678 L 164.203 115.138;M 85.853 33.671 L 162.75 115.294;M 82.333 35.996 L 161.124 115.53;M 78.595 38.603 L 159.382 115.855;M 74.764 41.431 L 157.581 116.273;M 70.957 44.406 L 155.779 116.776;M 67.286 47.442 L 154.029 117.348;M 63.853 50.44 L 152.383 117.964;M 60.753 53.287 L 150.89 118.591;M 58.07 55.866 L 149.594 119.19;M 55.886 58.051 L 148.535 119.719;M 54.274 59.713 L 147.753 120.134;M 53.311 60.727 L 147.285 120.392;M 53.033 61.023 L 147.151 120.469;M 52.038 62.094 L 146.701 120.77;M 49.795 64.577 L 145.684 121.475;M 46.549 68.345 L 144.208 122.563;M 42.567 73.284 L 142.387 124.018;M 38.119 79.274 L 140.34 125.82;M 33.465 86.173 L 138.182 127.939;M 28.847 93.809 L 136.024 130.33;M 24.473 101.979 L 133.96 132.929;M 20.507 110.444 L 132.07 135.658;M 17.067 118.936 L 130.41 138.423;M 14.216 127.165 L 129.015 141.121;M 11.966 134.826 L 127.895 143.644;M 10.289 141.606 L 127.043 145.882;M 9.121 147.192 L 126.435 147.728;M 8.387 151.28 L 126.044 149.078;M 8.019 153.572 L 125.844 149.834;M 7.978 153.84 L 125.833 149.921;M 8.189 152.489 L 126.074 149.459;M 8.638 149.816 L 126.57 148.552;M 9.348 146.032 L 127.322 147.285;M 10.347 141.349 L 128.332 145.747;M 11.66 135.983 L 129.6 144.032;M 13.295 130.147 L 131.111 142.231;M 15.24 124.052 L 132.84 140.432;M 17.46 117.898 L 134.744 138.713;M 19.892 111.873 L 136.765 137.138;M 22.449 106.151 L 138.829 135.754;M 25.024 100.889 L 140.854 134.592;M 27.494 96.228 L 142.752 133.662;M 29.722 92.293 L 144.431 132.957;M 31.57 89.201 L 145.799 132.46;M 32.899 87.063 L 146.772 132.147;M 33.579 85.995 L 147.265 132.001;M 33.832 85.603 L 147.35 131.89;M 34.943 83.904 L 147.587 131.318;M 36.975 80.905 L 148.039 130.307;M 39.86 76.866 L 148.717 128.947;M 43.529 72.058 L 149.633 127.329;M 47.904 66.746 L 150.793 125.546;M 52.886 61.18 L 152.187 123.689;M 58.353 55.589 L 153.791 121.838;M 64.155 50.17 L 155.564 120.066;M 70.117 45.086 L 157.448 118.432;M 76.044 40.468 L 159.371 116.977;M 81.724 36.411 L 161.254 115.732;M 86.934 32.98 L 163.009 114.708;M 91.448 30.219 L 164.548 113.91;M 95.035 28.155 L 165.781 113.331;M 97.472 26.817 L 166.623 112.966;M 98.542 26.245 L 166.994 112.812;M 98.587 26.221 L 166.992 112.746;M 98.631 26.198 L 166.941 112.518;M 98.691 26.166 L 166.864 112.176;M 98.743 26.139 L 166.774 111.784;M 98.764 26.127 L 166.684 111.411;M 98.735 26.143 L 166.607 111.116;M 98.638 26.194 L 166.553 110.948;M 98.466 26.286 L 166.529 110.942;M 98.214 26.419 L 166.54 111.113;M 97.89 26.592 L 166.585 111.458;M 97.506 26.798 L 166.661 111.957;M 97.086 27.025 L 166.761 112.568;M 96.658 27.258 L 166.875 113.237;M 96.258 27.477 L 166.989 113.892;M 95.926 27.66 L 167.088 114.453;M 95.701 27.784 L 167.157 114.84;M 95.619 27.829 L 167.183 114.982"
          />
        </path>
      
        <path
          d="M 229.075 190.357 L 128.398 241.16"
          fill="none"
          stroke="#ff4fc8"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 229.075 190.357 L 128.398 241.16;M 229.205 190.275 L 128.607 241.161;M 229.573 190.043 L 129.199 241.159;M 230.147 189.672 L 130.117 241.151;M 230.893 189.175 L 131.301 241.128;M 231.777 188.563 L 132.695 241.083;M 232.766 187.847 L 134.241 241.011;M 233.827 187.044 L 135.882 240.907;M 234.927 186.171 L 137.567 240.771;M 236.034 185.25 L 139.245 240.605;M 237.116 184.308 L 140.869 240.413;M 238.142 183.374 L 142.395 240.204;M 239.081 182.485 L 143.78 239.99;M 239.905 181.676 L 144.985 239.784;M 240.585 180.989 L 145.972 239.6;M 241.091 180.465 L 146.704 239.456;M 241.396 180.145 L 147.142 239.366;M 241.482 180.049 L 147.269 239.344;M 241.712 179.662 L 147.698 239.372;M 242.231 178.763 L 148.677 239.426;M 242.985 177.402 L 150.123 239.479;M 243.916 175.62 L 151.95 239.497;M 244.962 173.459 L 154.072 239.449;M 246.065 170.968 L 156.401 239.309;M 247.169 168.202 L 158.852 239.06;M 248.224 165.229 L 161.343 238.694;M 249.19 162.128 L 163.795 238.218;M 250.035 158.99 L 166.136 237.649;M 250.741 155.919 L 168.299 237.017;M 251.302 153.031 L 170.227 236.363;M 251.722 150.449 L 171.868 235.732;M 252.014 148.301 L 173.177 235.178;M 252.198 146.717 L 174.11 234.753;M 252.29 145.824 L 174.625 234.508;M 252.291 145.716 L 174.68 234.496;M 252.142 146.219 L 174.329 234.826;M 251.837 147.209 L 173.62 235.479;M 251.387 148.602 L 172.582 236.404;M 250.796 150.312 L 171.238 237.55;M 250.071 152.254 L 169.613 238.863;M 249.222 154.345 L 167.738 240.293;M 248.262 156.509 L 165.646 241.789;M 247.212 158.673 L 163.386 243.302;M 246.098 160.772 L 161.013 244.786;M 244.954 162.749 L 158.598 246.197;M 243.82 164.552 L 156.224 247.495;M 242.744 166.138 L 153.985 248.646;M 241.779 167.467 L 151.986 249.618;M 240.982 168.506 L 150.339 250.381;M 240.409 169.22 L 149.158 250.908;M 240.116 169.576 L 148.555 251.171;M 240.189 169.744 L 148.406 251.014;M 240.765 170.537 L 147.873 249.959;M 241.759 171.995 L 146.961 248.081;M 243.047 174.077 L 145.794 245.521;M 244.494 176.743 L 144.498 242.423;M 245.968 179.939 L 143.188 238.931;M 247.345 183.59 L 141.963 235.188;M 248.524 187.598 L 140.892 231.331;M 249.431 191.842 L 140.02 227.487;M 250.023 196.182 L 139.367 223.774;M 250.299 200.465 L 138.926 220.295;M 250.289 204.532 L 138.674 217.139;M 250.057 208.226 L 138.572 214.387;M 249.693 211.393 L 138.575 212.105;M 249.3 213.887 L 138.631 210.354;M 248.982 215.568 L 138.696 209.196;M 248.829 216.303 L 138.731 208.695;M 248.702 216.1 L 138.642 208.849;M 248.232 215.242 L 138.302 209.501;M 247.451 213.846 L 137.748 210.6;M 246.405 212.032 L 137.028 212.1;M 245.138 209.921 L 136.189 213.961;M 243.693 207.623 L 135.275 216.14;M 242.111 205.241 L 134.327 218.594;M 240.433 202.862 L 133.379 221.271;M 238.7 200.56 L 132.461 224.111;M 236.959 198.398 L 131.599 227.043;M 235.255 196.424 L 130.812 229.981;M 233.641 194.68 L 130.116 232.824;M 232.174 193.195 L 129.525 235.456;M 230.918 191.996 L 129.048 237.746;M 229.937 191.107 L 128.695 239.555;M 229.301 190.55 L 128.474 240.738;M 229.075 190.357 L 128.398 241.16"
          />
        </path>
      
        <path
          d="M 128.398 241.16 L 167.183 114.982"
          fill="none"
          stroke="#ffd447"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 128.398 241.16 L 167.183 114.982;M 128.607 241.161 L 166.97 114.986;M 129.199 241.159 L 166.367 115.003;M 130.117 241.151 L 165.427 115.048;M 131.301 241.128 L 164.203 115.138;M 132.695 241.083 L 162.75 115.294;M 134.241 241.011 L 161.124 115.53;M 135.882 240.907 L 159.382 115.855;M 137.567 240.771 L 157.581 116.273;M 139.245 240.605 L 155.779 116.776;M 140.869 240.413 L 154.029 117.348;M 142.395 240.204 L 152.383 117.964;M 143.78 239.99 L 150.89 118.591;M 144.985 239.784 L 149.594 119.19;M 145.972 239.6 L 148.535 119.719;M 146.704 239.456 L 147.753 120.134;M 147.142 239.366 L 147.285 120.392;M 147.269 239.344 L 147.151 120.469;M 147.698 239.372 L 146.701 120.77;M 148.677 239.426 L 145.684 121.475;M 150.123 239.479 L 144.208 122.563;M 151.95 239.497 L 142.387 124.018;M 154.072 239.449 L 140.34 125.82;M 156.401 239.309 L 138.182 127.939;M 158.852 239.06 L 136.024 130.33;M 161.343 238.694 L 133.96 132.929;M 163.795 238.218 L 132.07 135.658;M 166.136 237.649 L 130.41 138.423;M 168.299 237.017 L 129.015 141.121;M 170.227 236.363 L 127.895 143.644;M 171.868 235.732 L 127.043 145.882;M 173.177 235.178 L 126.435 147.728;M 174.11 234.753 L 126.044 149.078;M 174.625 234.508 L 125.844 149.834;M 174.68 234.496 L 125.833 149.921;M 174.329 234.826 L 126.074 149.459;M 173.62 235.479 L 126.57 148.552;M 172.582 236.404 L 127.322 147.285;M 171.238 237.55 L 128.332 145.747;M 169.613 238.863 L 129.6 144.032;M 167.738 240.293 L 131.111 142.231;M 165.646 241.789 L 132.84 140.432;M 163.386 243.302 L 134.744 138.713;M 161.013 244.786 L 136.765 137.138;M 158.598 246.197 L 138.829 135.754;M 156.224 247.495 L 140.854 134.592;M 153.985 248.646 L 142.752 133.662;M 151.986 249.618 L 144.431 132.957;M 150.339 250.381 L 145.799 132.46;M 149.158 250.908 L 146.772 132.147;M 148.555 251.171 L 147.265 132.001;M 148.406 251.014 L 147.35 131.89;M 147.873 249.959 L 147.587 131.318;M 146.961 248.081 L 148.039 130.307;M 145.794 245.521 L 148.717 128.947;M 144.498 242.423 L 149.633 127.329;M 143.188 238.931 L 150.793 125.546;M 141.963 235.188 L 152.187 123.689;M 140.892 231.331 L 153.791 121.838;M 140.02 227.487 L 155.564 120.066;M 139.367 223.774 L 157.448 118.432;M 138.926 220.295 L 159.371 116.977;M 138.674 217.139 L 161.254 115.732;M 138.572 214.387 L 163.009 114.708;M 138.575 212.105 L 164.548 113.91;M 138.631 210.354 L 165.781 113.331;M 138.696 209.196 L 166.623 112.966;M 138.731 208.695 L 166.994 112.812;M 138.642 208.849 L 166.992 112.746;M 138.302 209.501 L 166.941 112.518;M 137.748 210.6 L 166.864 112.176;M 137.028 212.1 L 166.774 111.784;M 136.189 213.961 L 166.684 111.411;M 135.275 216.14 L 166.607 111.116;M 134.327 218.594 L 166.553 110.948;M 133.379 221.271 L 166.529 110.942;M 132.461 224.111 L 166.54 111.113;M 131.599 227.043 L 166.585 111.458;M 130.812 229.981 L 166.661 111.957;M 130.116 232.824 L 166.761 112.568;M 129.525 235.456 L 166.875 113.237;M 129.048 237.746 L 166.989 113.892;M 128.695 239.555 L 167.088 114.453;M 128.474 240.738 L 167.157 114.84;M 128.398 241.16 L 167.183 114.982"
          />
        </path>
      
        <path
          d="M 229.075 190.357 L 312.845 292.375"
          fill="none"
          stroke="#ffd447"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 229.075 190.357 L 312.845 292.375;M 229.205 190.275 L 313.227 291.922;M 229.573 190.043 L 314.3 290.632;M 230.147 189.672 L 315.947 288.603;M 230.893 189.175 L 318.042 285.926;M 231.777 188.563 L 320.461 282.697;M 232.766 187.847 L 323.081 279.014;M 233.827 187.044 L 325.788 274.983;M 234.927 186.171 L 328.481 270.716;M 236.034 185.25 L 331.07 266.335;M 237.116 184.308 L 333.483 261.97;M 238.142 183.374 L 335.66 257.755;M 239.081 182.485 L 337.559 253.831;M 239.905 181.676 L 339.148 250.341;M 240.585 180.989 L 340.403 247.43;M 241.091 180.465 L 341.306 245.24;M 241.396 180.145 L 341.836 243.915;M 241.482 180.049 L 341.988 243.53;M 241.712 179.662 L 342.523 242.148;M 242.231 178.763 L 343.701 238.973;M 242.985 177.402 L 345.335 234.225;M 243.916 175.62 L 347.214 228.121;M 244.962 173.459 L 349.127 220.89;M 246.065 170.968 L 350.885 212.775;M 247.169 168.202 L 352.332 204.036;M 248.224 165.229 L 353.357 194.948;M 249.19 162.128 L 353.904 185.794;M 250.035 158.99 L 353.972 176.859;M 250.741 155.919 L 353.615 168.425;M 251.302 153.031 L 352.933 160.762;M 251.722 150.449 L 352.065 154.125;M 252.014 148.301 L 351.171 148.753;M 252.198 146.717 L 350.418 144.878;M 252.29 145.824 L 349.961 142.725;M 252.291 145.716 L 349.905 142.471;M 252.142 146.219 L 350.171 143.7;M 251.837 147.209 L 350.675 146.149;M 251.387 148.602 L 351.335 149.663;M 250.796 150.312 L 352.059 154.086;M 250.071 152.254 L 352.76 159.262;M 249.222 154.345 L 353.354 165.026;M 248.262 156.509 L 353.778 171.206;M 247.212 158.673 L 353.984 177.622;M 246.098 160.772 L 353.952 184.087;M 244.954 162.749 L 353.688 190.408;M 243.82 164.552 L 353.227 196.386;M 242.744 166.138 L 352.626 201.825;M 241.779 167.467 L 351.966 206.528;M 240.982 168.506 L 351.342 210.299;M 240.409 169.22 L 350.852 212.947;M 240.116 169.576 L 350.589 214.282;M 240.189 169.744 L 350.5 214.723;M 240.765 170.537 L 350.115 216.563;M 241.759 171.995 L 349.372 219.862;M 243.047 174.077 L 348.239 224.404;M 244.494 176.743 L 346.671 229.968;M 245.968 179.939 L 344.632 236.324;M 247.345 183.59 L 342.102 243.237;M 248.524 187.598 L 339.093 250.466;M 249.431 191.842 L 335.652 257.772;M 250.023 196.182 L 331.87 264.921;M 250.299 200.465 L 327.878 271.696;M 250.289 204.532 L 323.847 277.897;M 250.057 208.226 L 319.983 283.348;M 249.693 211.393 L 316.511 287.892;M 249.3 213.887 L 313.673 291.39;M 248.982 215.568 L 311.705 293.709;M 248.829 216.303 L 310.831 294.714;M 248.702 216.1 L 310.842 294.701;M 248.232 215.242 L 310.984 294.539;M 247.451 213.846 L 311.208 294.283;M 246.405 212.032 L 311.486 293.963;M 245.138 209.921 L 311.79 293.611;M 243.693 207.623 L 312.094 293.257;M 242.111 205.241 L 312.376 292.928;M 240.433 202.862 L 312.617 292.644;M 238.7 200.56 L 312.807 292.42;M 236.959 198.398 L 312.939 292.264;M 235.255 196.424 L 313.012 292.177;M 233.641 194.68 L 313.033 292.153;M 232.174 193.195 L 313.011 292.178;M 230.918 191.996 L 312.963 292.235;M 229.937 191.107 L 312.908 292.301;M 229.301 190.55 L 312.863 292.354;M 229.075 190.357 L 312.845 292.375"
          />
        </path>
      
        <path
          d="M 346.925 130.887 L 229.075 190.357"
          fill="none"
          stroke="#ffd447"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="M 346.925 130.887 L 229.075 190.357;M 346.885 130.75 L 229.205 190.275;M 346.769 130.36 L 229.573 190.043;M 346.586 129.75 L 230.147 189.672;M 346.343 128.95 L 230.893 189.175;M 346.046 127.992 L 231.777 188.563;M 345.702 126.907 L 232.766 187.847;M 345.32 125.729 L 233.827 187.044;M 344.909 124.492 L 234.927 186.171;M 344.478 123.229 L 236.034 185.25;M 344.041 121.977 L 237.116 184.308;M 343.61 120.774 L 238.142 183.374;M 343.202 119.658 L 239.081 182.485;M 342.832 118.668 L 239.905 181.676;M 342.519 117.843 L 240.585 180.989;M 342.281 117.223 L 241.091 180.465;M 342.135 116.848 L 241.396 180.145;M 342.09 116.731 L 241.482 180.049;M 341.831 116.073 L 241.712 179.662;M 341.229 114.57 L 242.231 178.763;M 340.308 112.343 L 242.985 177.402;M 339.085 109.517 L 243.916 175.62;M 337.581 106.214 L 244.962 173.459;M 335.817 102.559 L 246.065 170.968;M 333.826 98.676 L 247.169 168.202;M 331.649 94.686 L 248.224 165.229;M 329.342 90.709 L 249.19 162.128;M 326.972 86.858 L 250.035 158.99;M 324.619 83.246 L 250.741 155.919;M 322.378 79.977 L 251.302 153.031;M 320.351 77.153 L 251.722 150.449;M 318.65 74.87 L 252.014 148.301;M 317.386 73.224 L 252.198 146.717;M 316.67 72.309 L 252.29 145.824;M 316.572 72.185 L 252.291 145.716;M 316.848 72.536 L 252.142 146.219;M 317.391 73.23 L 251.837 147.209;M 318.151 74.215 L 251.387 148.602;M 319.078 75.438 L 250.796 150.312;M 320.124 76.844 L 250.071 152.254;M 321.242 78.38 L 249.222 154.345;M 322.388 79.992 L 248.262 156.509;M 323.525 81.63 L 247.212 158.673;M 324.617 83.242 L 246.098 160.772;M 325.636 84.783 L 244.954 162.749;M 326.558 86.209 L 243.82 164.552;M 327.363 87.478 L 242.744 166.138;M 328.033 88.555 L 241.779 167.467;M 328.554 89.404 L 240.982 168.506;M 328.912 89.993 L 240.409 169.22;M 329.089 90.287 L 240.116 169.576;M 329.379 90.77 L 240.189 169.744;M 330.94 93.437 L 240.765 170.537;M 333.612 98.272 L 241.759 171.995;M 337.025 105.038 L 243.047 174.077;M 340.789 113.494 L 244.494 176.743;M 344.526 123.369 L 245.968 179.939;M 347.906 134.352 L 247.345 183.59;M 350.665 146.096 L 248.524 187.598;M 352.631 158.219 L 249.431 191.842;M 353.731 170.322 L 250.023 196.182;M 353.988 182.003 L 250.299 200.465;M 353.523 192.871 L 250.289 204.532;M 352.531 202.563 L 250.057 208.226;M 351.263 210.742 L 249.693 211.393;M 349.999 217.101 L 249.3 213.887;M 349.016 221.348 L 248.982 215.568;M 348.553 223.195 L 248.829 216.303;M 348.682 222.688 L 248.702 216.1;M 349.217 220.515 L 248.232 215.242;M 350.046 216.881 L 247.451 213.846;M 351.038 211.967 L 246.405 212.032;M 352.053 205.956 L 245.138 209.921;M 352.955 199.04 L 243.693 207.623;M 353.624 191.425 L 242.111 205.241;M 353.968 183.337 L 240.433 202.862;M 353.929 175.016 L 238.7 200.56;M 353.492 166.715 L 236.959 198.398;M 352.691 158.693 L 235.255 196.424;M 351.603 151.216 L 233.641 194.68;M 350.349 144.542 L 232.174 193.195;M 349.083 138.928 L 230.918 191.996;M 347.978 134.62 L 229.937 191.107;M 347.208 131.86 L 229.301 190.55;M 346.925 130.887 L 229.075 190.357"
          />
        </path>
      
  </g>

  <!-- 三個動畫頂點 -->
  <g>
    
        <circle
          cx="128.398"
          cy="241.16"
          r="4.2"
          fill="#080c16"
          stroke="#ffffff"
          stroke-opacity="0.9"
          stroke-width="1.5"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="cx"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="128.398;128.607;129.199;130.117;131.301;132.695;134.241;135.882;137.567;139.245;140.869;142.395;143.78;144.985;145.972;146.704;147.142;147.269;147.698;148.677;150.123;151.95;154.072;156.401;158.852;161.343;163.795;166.136;168.299;170.227;171.868;173.177;174.11;174.625;174.68;174.329;173.62;172.582;171.238;169.613;167.738;165.646;163.386;161.013;158.598;156.224;153.985;151.986;150.339;149.158;148.555;148.406;147.873;146.961;145.794;144.498;143.188;141.963;140.892;140.02;139.367;138.926;138.674;138.572;138.575;138.631;138.696;138.731;138.642;138.302;137.748;137.028;136.189;135.275;134.327;133.379;132.461;131.599;130.812;130.116;129.525;129.048;128.695;128.474;128.398"
          />

          <animate
            attributeName="cy"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="241.16;241.161;241.159;241.151;241.128;241.083;241.011;240.907;240.771;240.605;240.413;240.204;239.99;239.784;239.6;239.456;239.366;239.344;239.372;239.426;239.479;239.497;239.449;239.309;239.06;238.694;238.218;237.649;237.017;236.363;235.732;235.178;234.753;234.508;234.496;234.826;235.479;236.404;237.55;238.863;240.293;241.789;243.302;244.786;246.197;247.495;248.646;249.618;250.381;250.908;251.171;251.014;249.959;248.081;245.521;242.423;238.931;235.188;231.331;227.487;223.774;220.295;217.139;214.387;212.105;210.354;209.196;208.695;208.849;209.501;210.6;212.1;213.961;216.14;218.594;221.271;224.111;227.043;229.981;232.824;235.456;237.746;239.555;240.738;241.16"
          />
        </circle>
      
        <circle
          cx="167.183"
          cy="114.982"
          r="4.2"
          fill="#080c16"
          stroke="#ffffff"
          stroke-opacity="0.9"
          stroke-width="1.5"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="cx"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="167.183;166.97;166.367;165.427;164.203;162.75;161.124;159.382;157.581;155.779;154.029;152.383;150.89;149.594;148.535;147.753;147.285;147.151;146.701;145.684;144.208;142.387;140.34;138.182;136.024;133.96;132.07;130.41;129.015;127.895;127.043;126.435;126.044;125.844;125.833;126.074;126.57;127.322;128.332;129.6;131.111;132.84;134.744;136.765;138.829;140.854;142.752;144.431;145.799;146.772;147.265;147.35;147.587;148.039;148.717;149.633;150.793;152.187;153.791;155.564;157.448;159.371;161.254;163.009;164.548;165.781;166.623;166.994;166.992;166.941;166.864;166.774;166.684;166.607;166.553;166.529;166.54;166.585;166.661;166.761;166.875;166.989;167.088;167.157;167.183"
          />

          <animate
            attributeName="cy"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="114.982;114.986;115.003;115.048;115.138;115.294;115.53;115.855;116.273;116.776;117.348;117.964;118.591;119.19;119.719;120.134;120.392;120.469;120.77;121.475;122.563;124.018;125.82;127.939;130.33;132.929;135.658;138.423;141.121;143.644;145.882;147.728;149.078;149.834;149.921;149.459;148.552;147.285;145.747;144.032;142.231;140.432;138.713;137.138;135.754;134.592;133.662;132.957;132.46;132.147;132.001;131.89;131.318;130.307;128.947;127.329;125.546;123.689;121.838;120.066;118.432;116.977;115.732;114.708;113.91;113.331;112.966;112.812;112.746;112.518;112.176;111.784;111.411;111.116;110.948;110.942;111.113;111.458;111.957;112.568;113.237;113.892;114.453;114.84;114.982"
          />
        </circle>
      
        <circle
          cx="229.075"
          cy="190.357"
          r="4.2"
          fill="#080c16"
          stroke="#ffffff"
          stroke-opacity="0.9"
          stroke-width="1.5"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="cx"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="229.075;229.205;229.573;230.147;230.893;231.777;232.766;233.827;234.927;236.034;237.116;238.142;239.081;239.905;240.585;241.091;241.396;241.482;241.712;242.231;242.985;243.916;244.962;246.065;247.169;248.224;249.19;250.035;250.741;251.302;251.722;252.014;252.198;252.29;252.291;252.142;251.837;251.387;250.796;250.071;249.222;248.262;247.212;246.098;244.954;243.82;242.744;241.779;240.982;240.409;240.116;240.189;240.765;241.759;243.047;244.494;245.968;247.345;248.524;249.431;250.023;250.299;250.289;250.057;249.693;249.3;248.982;248.829;248.702;248.232;247.451;246.405;245.138;243.693;242.111;240.433;238.7;236.959;235.255;233.641;232.174;230.918;229.937;229.301;229.075"
          />

          <animate
            attributeName="cy"
            dur="7s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="0;0.01190476;0.02380952;0.03571429;0.04761905;0.05952381;0.07142857;0.08333333;0.0952381;0.10714286;0.11904762;0.13095238;0.14285714;0.1547619;0.16666667;0.17857143;0.19047619;0.20238095;0.21428571;0.22619048;0.23809524;0.25;0.26190476;0.27380952;0.28571429;0.29761905;0.30952381;0.32142857;0.33333333;0.3452381;0.35714286;0.36904762;0.38095238;0.39285714;0.4047619;0.41666667;0.42857143;0.44047619;0.45238095;0.46428571;0.47619048;0.48809524;0.5;0.51190476;0.52380952;0.53571429;0.54761905;0.55952381;0.57142857;0.58333333;0.5952381;0.60714286;0.61904762;0.63095238;0.64285714;0.6547619;0.66666667;0.67857143;0.69047619;0.70238095;0.71428571;0.72619048;0.73809524;0.75;0.76190476;0.77380952;0.78571429;0.79761905;0.80952381;0.82142857;0.83333333;0.8452381;0.85714286;0.86904762;0.88095238;0.89285714;0.9047619;0.91666667;0.92857143;0.94047619;0.95238095;0.96428571;0.97619048;0.98809524;1"
            values="190.357;190.275;190.043;189.672;189.175;188.563;187.847;187.044;186.171;185.25;184.308;183.374;182.485;181.676;180.989;180.465;180.145;180.049;179.662;178.763;177.402;175.62;173.459;170.968;168.202;165.229;162.128;158.99;155.919;153.031;150.449;148.301;146.717;145.824;145.716;146.219;147.209;148.602;150.312;152.254;154.345;156.509;158.673;160.772;162.749;164.552;166.138;167.467;168.506;169.22;169.576;169.744;170.537;171.995;174.077;176.743;179.939;183.59;187.598;191.842;196.182;200.465;204.532;208.226;211.393;213.887;215.568;216.303;216.1;215.242;213.846;212.032;209.921;207.623;205.241;202.862;200.56;198.398;196.424;194.68;193.195;191.996;191.107;190.55;190.357"
          />
        </circle>
      
  </g>

  <!-- 最上層圓框 -->
  <circle
    cx="180"
    cy="180"
    r="174"
    fill="none"
    stroke="#cde1ff"
    stroke-opacity="0.7"
    stroke-width="1.4"
    vector-effect="non-scaling-stroke"
  />
</svg>
SVG preview
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

代码: 全选

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
#ejtricc {
  box-sizing: border-box;
  width: 100%;
  color: #eaf6ff;
  background: #070a12;
  font-family:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    sans-serif;
}

/* SVG 預覽區域 */
#ejtricc .ejtricc-preview {
  display: block;
  width: 100%;
  overflow: hidden;
  background: #070a12;
}

#ejtricc .ejtricc-preview svg {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  background: #070a12;
}

/* 設定面板 */
#ejtricc .ejtricc-panel {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
  box-sizing: border-box;
  width: 100%;
  padding: 12px;
  background: #0d1424;
  border-top: 1px solid #243550;
}

#ejtricc .ejtricc-field {
  display: flex;
  flex-direction: column;
  min-width: 0;
  gap: 6px;
}

#ejtricc .ejtricc-field label {
  color: #b9cae2;
  font-size: 13px;
  line-height: 1.4;
}

#ejtricc .ejtricc-field input {
  box-sizing: border-box;
  width: 100%;
  min-width: 0;
  padding: 9px 10px;

  color: #f2f8ff;
  background: #121d31;
  border: 1px solid #385071;
  border-radius: 6px;

  font: inherit;
  outline: none;
}

#ejtricc .ejtricc-field input:focus {
  border-color: #36e2ff;
  box-shadow: 0 0 0 2px rgb(54 226 255 / 15%);
}

#ejtricc .ejtricc-actions {
  display: flex;
  grid-column: 1 / -1;
  gap: 10px;
}

#ejtricc .ejtricc-button {
  flex: 1;
  box-sizing: border-box;
  padding: 10px 16px;

  color: #eaf6ff;
  background: #17233a;
  border: 1px solid #385071;
  border-radius: 6px;

  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

#ejtricc .ejtricc-button:hover {
  background: #213352;
}

#ejtricc .ejtricc-button:active {
  background: #101a2c;
}

#ejtricc .ejtricc-generate {
  color: #061019;
  background: #36e2ff;
  border-color: #36e2ff;
}

#ejtricc .ejtricc-generate:hover {
  background: #79edff;
}

#ejtricc .ejtricc-info {
  grid-column: 1 / -1;
  margin: 0;
  color: #8195b2;
  font-size: 12px;
  line-height: 1.5;
}

@media (max-width: 520px) {
  #ejtricc .ejtricc-panel {
    grid-template-columns: 1fr;
  }

  #ejtricc .ejtricc-actions,
  #ejtricc .ejtricc-info {
    grid-column: 1;
  }

  #ejtricc .ejtricc-actions {
    flex-direction: column;
  }
}
  </style>
</head>
<body>
<div id="ejtricc"></div>
  <script>
(() => {
  "use strict";

  const CANVAS_SIZE = 360;
  const CENTER = CANVAS_SIZE / 2;
  const CIRCLE_RADIUS = 348 / 2;

  const DEFAULT_OPTIONS = {
    positionCount: 3,
    duration: 5,
    fps: 12
  };

  const COLORS = {
    a: "#36e2ff",
    b: "#ff4fc8",
    c: "#ffd447"
  };

  function initialize() {
    const container = document.querySelector("#ejtricc");

    if (!container) {
      console.warn("找不到 div#ejtricc");
      return;
    }

    /*
     * 清除原來的 Canvas 和其他內容,
     * 改成 SVG 預覽及設定面板。
     */
    container.replaceChildren();

    const preview = document.createElement("div");
    preview.className = "ejtricc-preview";
    preview.setAttribute("aria-label", "SVG 動畫預覽");

    const panel = document.createElement("div");
    panel.className = "ejtricc-panel";

    panel.innerHTML = `
      <div class="ejtricc-field">
        <label for="ejtricc-position-count">
          不同位置數量(不含起點)
        </label>

        <input
          id="ejtricc-position-count"
          class="ejtricc-position-count"
          type="number"
          min="1"
          max="30"
          step="1"
          value="${DEFAULT_OPTIONS.positionCount}"
        >
      </div>

      <div class="ejtricc-field">
        <label for="ejtricc-duration">
          動畫用時(秒)
        </label>

        <input
          id="ejtricc-duration"
          class="ejtricc-duration"
          type="number"
          min="1"
          max="60"
          step="0.1"
          value="${DEFAULT_OPTIONS.duration}"
        >
      </div>

      <div class="ejtricc-field">
        <label for="ejtricc-fps">
          每秒幀數
        </label>

        <input
          id="ejtricc-fps"
          class="ejtricc-fps"
          type="number"
          min="1"
          max="60"
          step="1"
          value="${DEFAULT_OPTIONS.fps}"
        >
      </div>

      <div class="ejtricc-actions">
        <button
          type="button"
          class="ejtricc-button ejtricc-generate"
        >
          生成
        </button>

        <button
          type="button"
          class="ejtricc-button ejtricc-download"
        >
          導出 SVG
        </button>
      </div>

      <p class="ejtricc-info"></p>
    `;

    /*
     * 預覽放在原 Canvas 的位置,
     * 控制面板放在預覽下方。
     */
    container.append(preview, panel);

    const positionInput = panel.querySelector(
      ".ejtricc-position-count"
    );

    const durationInput = panel.querySelector(
      ".ejtricc-duration"
    );

    const fpsInput = panel.querySelector(
      ".ejtricc-fps"
    );

    const generateButton = panel.querySelector(
      ".ejtricc-generate"
    );

    const downloadButton = panel.querySelector(
      ".ejtricc-download"
    );

    const infoElement = panel.querySelector(
      ".ejtricc-info"
    );

    let currentSVG = "";

    function clamp(value, minimum, maximum) {
      return Math.min(
        maximum,
        Math.max(minimum, value)
      );
    }

    function readOptions() {
      let positionCount = Number.parseInt(
        positionInput.value,
        10
      );

      let duration = Number.parseFloat(
        durationInput.value
      );

      let fps = Number.parseInt(
        fpsInput.value,
        10
      );

      if (!Number.isFinite(positionCount)) {
        positionCount =
          DEFAULT_OPTIONS.positionCount;
      }

      if (!Number.isFinite(duration)) {
        duration = DEFAULT_OPTIONS.duration;
      }

      if (!Number.isFinite(fps)) {
        fps = DEFAULT_OPTIONS.fps;
      }

      positionCount = clamp(
        Math.round(positionCount),
        1,
        30
      );

      duration = clamp(
        duration,
        1,
        60
      );

      fps = clamp(
        Math.round(fps),
        1,
        60
      );

      /*
       * 將清理後的值寫回輸入框。
       */
      positionInput.value = positionCount;
      durationInput.value = duration;
      fpsInput.value = fps;

      return {
        positionCount,
        duration,
        fps
      };
    }

    function mix(a, b, t) {
      return a + (b - a) * t;
    }

    function smoothstep(t) {
      return t * t * (3 - 2 * t);
    }

    function distance(p1, p2) {
      return Math.hypot(
        p2.x - p1.x,
        p2.y - p1.y
      );
    }

    function rotatePoint(point, angle) {
      const cos = Math.cos(angle);
      const sin = Math.sin(angle);

      return {
        x: point.x * cos - point.y * sin,
        y: point.x * sin + point.y * cos
      };
    }

    /*
     * 每個角至少為 34 度,避免三點共線,
     * 也避免生成過於扁平的三角形。
     */
    function createRandomState(
      rotationBase = Math.random() * Math.PI * 2
    ) {
      const minimumAngle =
        34 * Math.PI / 180;

      const remainder =
        Math.PI - minimumAngle * 3;

      const weights = [
        0.25 + Math.random(),
        0.25 + Math.random(),
        0.25 + Math.random()
      ];

      const total =
        weights[0] +
        weights[1] +
        weights[2];

      return {
        alpha:
          minimumAngle +
          remainder * weights[0] / total,

        beta:
          minimumAngle +
          remainder * weights[1] / total,

        rotation:
          rotationBase +
          (Math.random() - 0.5) * 1.15
      };
    }

    /*
     * 將 angle 轉換為距離 reference 最近的等效角度,
     * 避免返回起點時突然旋轉一整圈。
     */
    function nearestEquivalentAngle(
      angle,
      reference
    ) {
      let result = angle;

      while (result - reference > Math.PI) {
        result -= Math.PI * 2;
      }

      while (result - reference < -Math.PI) {
        result += Math.PI * 2;
      }

      return result;
    }

    /*
     * 依照三個角和旋轉角度建立三角形。
     */
    function buildTriangle(
      alpha,
      beta,
      rotation
    ) {
      const gamma =
        Math.PI - alpha - beta;

      /*
       * 依正弦定理建立相對邊長。
       */
      const rawA = Math.sin(alpha);
      const rawB = Math.sin(beta);
      const rawC = Math.sin(gamma);

      const source = {
        A: {
          x: 0,
          y: 0
        },

        B: {
          x: rawC,
          y: 0
        },

        C: {
          x: rawB * Math.cos(alpha),
          y: rawB * Math.sin(alpha)
        }
      };

      const a = distance(
        source.B,
        source.C
      );

      const b = distance(
        source.C,
        source.A
      );

      const c = distance(
        source.A,
        source.B
      );

      const perimeter = a + b + c;
      const semiperimeter = perimeter / 2;

      const area = Math.abs(
        (source.B.x - source.A.x) *
        (source.C.y - source.A.y) -
        (source.B.y - source.A.y) *
        (source.C.x - source.A.x)
      ) / 2;

      const inradius =
        area / semiperimeter;

      /*
       * 三角形內心。
       */
      const incenter = {
        x: (
          a * source.A.x +
          b * source.B.x +
          c * source.C.x
        ) / perimeter,

        y: (
          a * source.A.y +
          b * source.B.y +
          c * source.C.y
        ) / perimeter
      };

      /*
       * 縮放後,三條邊的延長線都能按照指定長度
       * 接觸半徑為 174 的圓。
       */
      const scale =
        CIRCLE_RADIUS /
        Math.sqrt(
          semiperimeter * semiperimeter +
          inradius * inradius
        );

      function transform(point) {
        const centered = {
          x:
            (point.x - incenter.x) *
            scale,

          y:
            (point.y - incenter.y) *
            scale
        };

        const rotated = rotatePoint(
          centered,
          rotation
        );

        return {
          x: CENTER + rotated.x,
          y: CENTER + rotated.y
        };
      }

      const A = transform(source.A);
      const B = transform(source.B);
      const C = transform(source.C);

      return {
        A,
        B,
        C,
        a: distance(B, C),
        b: distance(C, A),
        c: distance(A, B)
      };
    }

    /*
     * 從 point 沿著背離 awayFrom 的方向延長。
     */
    function extendFrom(
      point,
      awayFrom,
      length
    ) {
      const dx = point.x - awayFrom.x;
      const dy = point.y - awayFrom.y;
      const d = Math.hypot(dx, dy);

      return {
        x: point.x + dx / d * length,
        y: point.y + dy / d * length
      };
    }

    /*
     * 將三角形轉換成三個點和九條線段。
     */
    function getGeometry(triangle) {
      const {
        A,
        B,
        C,
        a,
        b,
        c
      } = triangle;

      /*
       * AB 所在直線:
       * 圓端點 --a-- A --c-- B --b-- 圓端點
       */
      const ABAtA = extendFrom(A, B, a);
      const ABAtB = extendFrom(B, A, b);

      /*
       * BC 所在直線:
       * 圓端點 --b-- B --a-- C --c-- 圓端點
       */
      const BCAtB = extendFrom(B, C, b);
      const BCAtC = extendFrom(C, B, c);

      /*
       * CA 所在直線:
       * 圓端點 --c-- C --b-- A --a-- 圓端點
       */
      const CAAtC = extendFrom(C, A, c);
      const CAAtA = extendFrom(A, C, a);

      return {
        points: {
          A,
          B,
          C
        },

        segments: [
          /*
           * 長度 a。
           */
          {
            from: ABAtA,
            to: A,
            color: COLORS.a
          },
          {
            from: B,
            to: C,
            color: COLORS.a
          },
          {
            from: A,
            to: CAAtA,
            color: COLORS.a
          },

          /*
           * 長度 b。
           */
          {
            from: B,
            to: ABAtB,
            color: COLORS.b
          },
          {
            from: BCAtB,
            to: B,
            color: COLORS.b
          },
          {
            from: C,
            to: A,
            color: COLORS.b
          },

          /*
           * 長度 c。
           */
          {
            from: A,
            to: B,
            color: COLORS.c
          },
          {
            from: C,
            to: BCAtC,
            color: COLORS.c
          },
          {
            from: CAAtC,
            to: C,
            color: COLORS.c
          }
        ]
      };
    }

    /*
     * 建立:
     *
     * 起始位置
     * -> 隨機位置 1
     * -> 隨機位置 2
     * -> ...
     * -> 隨機位置 N
     * -> 起始位置
     */
    function createPoses(positionCount) {
      const initial = createRandomState();
      const poses = [initial];

      let previous = initial;

      for (
        let index = 0;
        index < positionCount;
        index++
      ) {
        const next = createRandomState(
          previous.rotation
        );

        poses.push(next);
        previous = next;
      }

      poses.push({
        alpha: initial.alpha,
        beta: initial.beta,

        rotation: nearestEquivalentAngle(
          initial.rotation,
          previous.rotation
        )
      });

      return poses;
    }

    /*
     * 取得某個標準化時間 0~1 對應的姿態。
     */
    function getPoseAtTime(poses, time) {
      const segmentCount =
        poses.length - 1;

      /*
       * time === 1 時直接返回最後一個姿態,
       * 也就是起始姿態。
       */
      if (time >= 1) {
        return {
          ...poses[poses.length - 1]
        };
      }

      const position =
        time * segmentCount;

      const index = Math.min(
        Math.floor(position),
        segmentCount - 1
      );

      const localProgress =
        position - index;

      const eased =
        smoothstep(localProgress);

      const from = poses[index];
      const to = poses[index + 1];

      return {
        alpha: mix(
          from.alpha,
          to.alpha,
          eased
        ),

        beta: mix(
          from.beta,
          to.beta,
          eased
        ),

        rotation: mix(
          from.rotation,
          to.rotation,
          eased
        )
      };
    }

    /*
     * 建立逐幀幾何資料。
     *
     * 例如:
     * duration = 5
     * fps = 12
     *
     * 會得到 60 個時間區間和 61 個取樣點。
     * 第 0 個與第 60 個取樣點相同。
     */
    function createFrames(options) {
      const {
        positionCount,
        duration,
        fps
      } = options;

      const intervalCount = Math.max(
        1,
        Math.round(duration * fps)
      );

      const poses =
        createPoses(positionCount);

      const frames = [];

      for (
        let index = 0;
        index <= intervalCount;
        index++
      ) {
        const time =
          index / intervalCount;

        const pose = getPoseAtTime(
          poses,
          time
        );

        const triangle = buildTriangle(
          pose.alpha,
          pose.beta,
          pose.rotation
        );

        frames.push(
          getGeometry(triangle)
        );
      }

      return {
        frames,
        intervalCount
      };
    }

    function svgNumber(value) {
      return Number(value.toFixed(3));
    }

    function createPathD(from, to) {
      return [
        "M",
        svgNumber(from.x),
        svgNumber(from.y),
        "L",
        svgNumber(to.x),
        svgNumber(to.y)
      ].join(" ");
    }

    /*
     * keyTimes 包含首尾:
     *
     * 0;1/60;2/60;...;59/60;1
     */
    function createKeyTimes(intervalCount) {
      const values = [];

      for (
        let index = 0;
        index <= intervalCount;
        index++
      ) {
        values.push(
          Number(
            (index / intervalCount).toFixed(8)
          )
        );
      }

      return values.join(";");
    }

    function createPathSVG(
      frames,
      segmentIndex,
      duration,
      keyTimes
    ) {
      const first =
        frames[0].segments[segmentIndex];

      const values = frames
        .map(frame => {
          const segment =
            frame.segments[segmentIndex];

          return createPathD(
            segment.from,
            segment.to
          );
        })
        .join(";");

      return `
        <path
          d="${createPathD(first.from, first.to)}"
          fill="none"
          stroke="${first.color}"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="d"
            dur="${duration}s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="${keyTimes}"
            values="${values}"
          />
        </path>
      `;
    }

    function createPointSVG(
      frames,
      pointName,
      duration,
      keyTimes
    ) {
      const first =
        frames[0].points[pointName];

      const xValues = frames
        .map(frame => {
          return svgNumber(
            frame.points[pointName].x
          );
        })
        .join(";");

      const yValues = frames
        .map(frame => {
          return svgNumber(
            frame.points[pointName].y
          );
        })
        .join(";");

      return `
        <circle
          cx="${svgNumber(first.x)}"
          cy="${svgNumber(first.y)}"
          r="4.2"
          fill="#080c16"
          stroke="#ffffff"
          stroke-opacity="0.9"
          stroke-width="1.5"
          vector-effect="non-scaling-stroke"
        >
          <animate
            attributeName="cx"
            dur="${duration}s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="${keyTimes}"
            values="${xValues}"
          />

          <animate
            attributeName="cy"
            dur="${duration}s"
            repeatCount="indefinite"
            calcMode="discrete"
            keyTimes="${keyTimes}"
            values="${yValues}"
          />
        </circle>
      `;
    }

    function createAnimatedSVG(options) {
      const {
        duration,
        fps,
        positionCount
      } = options;

      const {
        frames,
        intervalCount
      } = createFrames(options);

      const keyTimes =
        createKeyTimes(intervalCount);

      const paths = frames[0].segments
        .map((segment, index) => {
          return createPathSVG(
            frames,
            index,
            duration,
            keyTimes
          );
        })
        .join("");

      const points = ["A", "B", "C"]
        .map(pointName => {
          return createPointSVG(
            frames,
            pointName,
            duration,
            keyTimes
          );
        })
        .join("");

      return `
<svg
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
  width="360"
  height="360"
  viewBox="0 0 360 360"
  role="img"
  aria-label="循環移動的幾何三角形"
  data-position-count="${positionCount}"
  data-duration="${duration}"
  data-fps="${fps}"
>
  <defs>
    <radialGradient
      id="ejtricc-circle-background"
      cx="50%"
      cy="50%"
      r="50%"
    >
      <stop
        offset="0%"
        stop-color="#121b2f"
      />

      <stop
        offset="72%"
        stop-color="#0d1424"
      />

      <stop
        offset="100%"
        stop-color="#090e19"
      />
    </radialGradient>

    <clipPath id="ejtricc-circle-clip">
      <circle
        cx="${CENTER}"
        cy="${CENTER}"
        r="${CIRCLE_RADIUS}"
      />
    </clipPath>

    <filter
      id="ejtricc-soft-glow"
      x="-20%"
      y="-20%"
      width="140%"
      height="140%"
      color-interpolation-filters="sRGB"
    >
      <feGaussianBlur
        stdDeviation="2"
        result="blur"
      />

      <feMerge>
        <feMergeNode in="blur"/>
        <feMergeNode in="SourceGraphic"/>
      </feMerge>
    </filter>
  </defs>

  <!-- 暗色背景 -->
  <path
    d="M 0 0 H 360 V 360 H 0 Z"
    fill="#070a12"
  />

  <!-- 直徑 348 的圓形背景 -->
  <circle
    cx="${CENTER}"
    cy="${CENTER}"
    r="${CIRCLE_RADIUS}"
    fill="url(#ejtricc-circle-background)"
  />

  <!-- 九條動畫線段 -->
  <g
    clip-path="url(#ejtricc-circle-clip)"
    filter="url(#ejtricc-soft-glow)"
  >
    ${paths}
  </g>

  <!-- 三個動畫頂點 -->
  <g>
    ${points}
  </g>

  <!-- 最上層圓框 -->
  <circle
    cx="${CENTER}"
    cy="${CENTER}"
    r="${CIRCLE_RADIUS}"
    fill="none"
    stroke="#cde1ff"
    stroke-opacity="0.7"
    stroke-width="1.4"
    vector-effect="non-scaling-stroke"
  />
</svg>`.trim();
    }

    function generate() {
      const options = readOptions();

      currentSVG =
        createAnimatedSVG(options);

      /*
       * replaceChildren/innerHTML 會建立新的 SVG 節點,
       * 因此每次按下生成後,動畫會從頭播放。
       */
      preview.innerHTML = currentSVG;

      const intervalCount = Math.max(
        1,
        Math.round(
          options.duration * options.fps
        )
      );

      infoElement.textContent =
        `已生成 ${options.positionCount} 個不同位置,` +
        `由起點移動後再返回起點;` +
        `動畫 ${options.duration} 秒,` +
        `約 ${options.fps} 幀/秒,` +
        `共 ${intervalCount} 個時間區間、` +
        `${intervalCount + 1} 組 SVG 座標值。`;
    }

    function downloadSVG() {
      if (!currentSVG) {
        generate();
      }

      const documentSource =
        `<?xml version="1.0" encoding="UTF-8"?>\n` +
        currentSVG;

      const blob = new Blob(
        [documentSource],
        {
          type: "image/svg+xml;charset=utf-8"
        }
      );

      const url =
        URL.createObjectURL(blob);

      const link =
        document.createElement("a");

      link.href = url;
      link.download =
        `triangle-animation-${Date.now()}.svg`;

      document.body.appendChild(link);
      link.click();
      link.remove();

      setTimeout(() => {
        URL.revokeObjectURL(url);
      }, 1000);
    }

    generateButton.addEventListener(
      "click",
      generate
    );

    downloadButton.addEventListener(
      "click",
      downloadSVG
    );

    /*
     * 按 Enter 也可以重新生成。
     */
    panel.addEventListener(
      "keydown",
      event => {
        if (
          event.key === "Enter" &&
          event.target.matches("input")
        ) {
          event.preventDefault();
          generate();
        }
      }
    );

    /*
     * 頁面初始化時先生成一次預設動畫。
     */
    generate();
  }

  if (document.readyState === "loading") {
    document.addEventListener(
      "DOMContentLoaded",
      initialize,
      {
        once: true
      }
    );
  } else {
    initialize();
  }
})();
  </script>
</body>
</html>
加上文件大小:

代码: 全选

可以使用 `Blob.size` 計算實際 UTF-8 編碼後的文件大小。為確保顯示的 byte 數與下載文件完全一致,預覽生成時就建立包含 XML 宣告的完整導出內容,下載時直接使用同一份字串。

在上一版 JavaScript 中作以下修改。

### 1. 修改目前 SVG 內容變數

找到:

```js
let currentSVG = "";
```

替換為:

```js
let currentSVG = "";
let currentSVGDocument = "";
let currentSVGByteSize = 0;
```

### 2. 替換 `generate()` 函數

```js
function generate() {
  const options = readOptions();

  currentSVG = createAnimatedSVG(options);

  /*
   * 預覽使用不帶 XML 宣告的 SVG。
   */
  preview.innerHTML = currentSVG;

  /*
   * 導出的實際文件包含 XML 宣告。
   * Blob.size 是 UTF-8 編碼後的實際 byte 數。
   */
  currentSVGDocument =
    `<?xml version="1.0" encoding="UTF-8"?>\n` +
    currentSVG;

  const svgBlob = new Blob(
    [currentSVGDocument],
    {
      type: "image/svg+xml;charset=utf-8"
    }
  );

  currentSVGByteSize = svgBlob.size;

  const intervalCount = Math.max(
    1,
    Math.round(
      options.duration * options.fps
    )
  );

  infoElement.textContent =
    `已生成 ${options.positionCount} 個不同位置,` +
    `由起點移動後再返回起點;` +
    `動畫 ${options.duration} 秒,` +
    `約 ${options.fps} 幀/秒,` +
    `共 ${intervalCount} 個時間區間、` +
    `${intervalCount + 1} 組 SVG 座標值;` +
    `導出的 SVG 文件大小:${currentSVGByteSize} byte。`;
}
```

### 3. 替換 `downloadSVG()` 函數

```js
function downloadSVG() {
  if (!currentSVGDocument) {
    generate();
  }

  /*
   * 直接使用生成預覽時計算大小的同一份文件內容,
   * 確保下載文件大小與文字顯示的 byte 數一致。
   */
  const blob = new Blob(
    [currentSVGDocument],
    {
      type: "image/svg+xml;charset=utf-8"
    }
  );

  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");

  link.href = url;
  link.download =
    `triangle-animation-${Date.now()}.svg`;

  document.body.appendChild(link);
  link.click();
  link.remove();

  setTimeout(() => {
    URL.revokeObjectURL(url);
  }, 1000);
}
```

下方說明將顯示類似:

```text
已生成 3 個不同位置,由起點移動後再返回起點;動畫 5 秒,約 12 幀/秒,共 60 個時間區間、61 組 SVG 座標值;導出的 SVG 文件大小:43628 byte。
```

其中 byte 數會依隨機生成的座標、位置數量、動畫時長和幀率即時重新計算;每次按下「生成」後都會更新。顯示的是實際下載內容經 UTF-8 編碼後的大小,而不是 JavaScript 字串的字元數。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

寫一個html+css+js
在div#ejpolygonarea內,添加一個canvas,寬480,高540,其中上面480*480部分是暗色,下面高60部分是另一種暗色。上面部分是點格區域,下面部分是選項區域。

在選項中,有10*10,12*12,16*16三個選項。默認為10*10,則在點格區域就會把480劃分成10*10,然後在每個交點的位置上顯示小圓點,排除邊界上的點。其它兩個選項同理。

當點擊點格時,最靠近點擊坐標的點會被選中高亮,再次點擊,則會在這次所點擊的點和上次的點連接一條線段,最後點擊的點始終高亮,起始的點也始終以另一種顏色高亮。

要求所有的線段不能交叉,否則會顯示一條虛線並在一秒後消失。

當點擊一個前面已經選中過的點,則移除這個點以及往後的點和線。當只有一個起始點,再次點擊起始點則移除起始點。當場上有多個點,但都在同一條直線,這時點擊起始點,則移除所有點線。

當場上有多個點,點擊起始點,排除以上的特殊情況,則會形成一個多邊形。這時這個多邊形所有經過的點(之前不一定被選中)都高亮,其內部包含的所有點也用另一種顏色高亮。選項區將變成英文文字說明:「多邊形面積=內部的點的數量+邊上的點的數量÷2-1」,右邊有一個打勾確認按鈕,以及一個下載按鈕。

當點擊下載按鈕,把當前canvas下載為一個png檔,文件名為ejpolygonarea_yyyymmdd_hhiiss.png。當點擊確認按鈕,場上全部清空,選項恢復顯示。

canvas的寬度應始終等同於父元素的寬度。

代码: 全选

寫一個html+css+js
在div#ejpolygonarea內,添加一個canvas,寬480,高540,其中上面480*480部分是暗色,下面高60部分是另一種暗色。上面部分是點格區域,下面部分是選項區域。

在選項中,有10*10,12*12,16*16三個選項。默認為10*10,則在點格區域就會把480劃分成10*10,然後在每個交點的位置上顯示小圓點,排除邊界上的點。其它兩個選項同理。

當點擊點格時,最靠近點擊坐標的點會被選中高亮,再次點擊,則會在這次所點擊的點和上次的點連接一條線段,最後點擊的點始終高亮,起始的點也始終以另一種顏色高亮。

要求所有的線段不能交叉,否則會顯示一條虛線並在一秒後消失。

當點擊一個前面已經選中過的點,則移除這個點以及往後的點和線。當只有一個起始點,再次點擊起始點則移除起始點。當場上有多個點,但都在同一條直線,這時點擊起始點,則移除所有點線。

當場上有多個點,點擊起始點,排除以上的特殊情況,則會形成一個多邊形。這時這個多邊形所有經過的點(之前不一定被選中)都高亮,其內部包含的所有點也用另一種顏色高亮。選項區將變成英文文字說明:「多邊形面積=內部的點的數量+邊上的點的數量÷2-1」,右邊有一個打勾確認按鈕,以及一個下載按鈕。

當點擊下載按鈕,把當前canvas下載為一個png檔,文件名為ejpolygonarea_yyyymmdd_hhiiss.png。當點擊確認按鈕,場上全部清空,選項恢復顯示。

canvas的寬度應始終等同於父元素的寬度。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

代码: 全选

以下是可直接執行的完整 HTML。Canvas 使用 $480 \times 540$ 的邏輯尺寸,並以 CSS 自動縮放至父元素寬度。

```html
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>點格多邊形</title>

  <style>
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      min-height: 100vh;
      display: grid;
      place-items: center;
      padding: 24px;
      background: #11151c;
      font-family: Arial, "Microsoft JhengHei", sans-serif;
    }

    #ejpolygonarea {
      width: min(100%, 720px);
    }

    #ejpolygonarea canvas {
      display: block;
      width: 100%;
      height: auto;
      aspect-ratio: 480 / 540;
      border: 1px solid #3b4658;
      border-radius: 8px;
      background: #161b24;
      box-shadow: 0 12px 32px rgb(0 0 0 / 35%);
      touch-action: none;
      cursor: crosshair;
    }
  </style>
</head>

<body>
  <div id="ejpolygonarea"></div>

  <script>
    (() => {
      "use strict";

      const LOGICAL_WIDTH = 480;
      const LOGICAL_HEIGHT = 540;
      const GRID_HEIGHT = 480;
      const OPTION_HEIGHT = 60;

      const container = document.querySelector("#ejpolygonarea");
      const canvas = document.createElement("canvas");

      canvas.width = LOGICAL_WIDTH;
      canvas.height = LOGICAL_HEIGHT;
      canvas.setAttribute("aria-label", "點格多邊形繪圖區");

      container.appendChild(canvas);

      const ctx = canvas.getContext("2d");

      const colors = {
        gridBackground: "#151b25",
        optionBackground: "#222a37",
        optionBorder: "#3d485c",

        gridLine: "rgba(255,255,255,0.045)",
        point: "#8290a8",
        pointShadow: "rgba(155,177,210,0.15)",

        line: "#61dafb",
        lineGlow: "rgba(97,218,251,0.22)",

        startPoint: "#ffb74d",
        currentPoint: "#ff5370",
        selectedPoint: "#61dafb",

        boundaryPoint: "#f7c65c",
        interiorPoint: "#70e1a1",
        polygonFill: "rgba(70,160,255,0.16)",

        invalidLine: "#ff5370",

        option: "#344055",
        optionHover: "#42516b",
        optionActive: "#2f94c7",
        optionText: "#f4f7fb",

        confirm: "#2eaf72",
        download: "#397fd5"
      };

      let divisions = 10;
      let gridPoints = [];

      // 使用者按順序選取的控制點
      let selectedPoints = [];

      // 多邊形完成後的所有邊界格點與內部格點
      let boundaryPoints = [];
      let interiorPoints = [];

      let polygonClosed = false;
      let invalidSegment = null;
      let invalidTimer = null;
      let hoveredControl = null;

      const optionControls = [];
      const actionControls = [];

      function pointKey(point) {
        return `${point.col},${point.row}`;
      }

      function samePoint(a, b) {
        return a && b && a.col === b.col && a.row === b.row;
      }

      function createGrid() {
        gridPoints = [];

        const step = GRID_HEIGHT / divisions;

        // 排除四周邊界,因此使用 1 到 divisions - 1
        for (let row = 1; row < divisions; row++) {
          for (let col = 1; col < divisions; col++) {
            gridPoints.push({
              x: col * step,
              y: row * step,
              col,
              row
            });
          }
        }
      }

      function resetDrawing() {
        selectedPoints = [];
        boundaryPoints = [];
        interiorPoints = [];
        polygonClosed = false;
        invalidSegment = null;

        if (invalidTimer) {
          clearTimeout(invalidTimer);
          invalidTimer = null;
        }

        draw();
      }

      function setDivisions(value) {
        divisions = value;
        resetDrawing();
        createGrid();
        draw();
      }

      function roundedRectPath(x, y, width, height, radius) {
        const r = Math.min(radius, width / 2, height / 2);

        ctx.beginPath();
        ctx.moveTo(x + r, y);
        ctx.lineTo(x + width - r, y);
        ctx.quadraticCurveTo(x + width, y, x + width, y + r);
        ctx.lineTo(x + width, y + height - r);
        ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
        ctx.lineTo(x + r, y + height);
        ctx.quadraticCurveTo(x, y + height, x, y + height - r);
        ctx.lineTo(x, y + r);
        ctx.quadraticCurveTo(x, y, x + r, y);
        ctx.closePath();
      }

      function drawBackground() {
        ctx.clearRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);

        ctx.fillStyle = colors.gridBackground;
        ctx.fillRect(0, 0, LOGICAL_WIDTH, GRID_HEIGHT);

        ctx.fillStyle = colors.optionBackground;
        ctx.fillRect(
          0,
          GRID_HEIGHT,
          LOGICAL_WIDTH,
          OPTION_HEIGHT
        );

        ctx.strokeStyle = colors.optionBorder;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(0, GRID_HEIGHT + 0.5);
        ctx.lineTo(LOGICAL_WIDTH, GRID_HEIGHT + 0.5);
        ctx.stroke();
      }

      function drawGridGuides() {
        const step = GRID_HEIGHT / divisions;

        ctx.save();
        ctx.strokeStyle = colors.gridLine;
        ctx.lineWidth = 1;

        for (let i = 1; i < divisions; i++) {
          const position = Math.round(i * step) + 0.5;

          ctx.beginPath();
          ctx.moveTo(position, 0);
          ctx.lineTo(position, GRID_HEIGHT);
          ctx.stroke();

          ctx.beginPath();
          ctx.moveTo(0, position);
          ctx.lineTo(LOGICAL_WIDTH, position);
          ctx.stroke();
        }

        ctx.restore();
      }

      function drawCircle(point, radius, fill, stroke = null, lineWidth = 1) {
        ctx.beginPath();
        ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
        ctx.fillStyle = fill;
        ctx.fill();

        if (stroke) {
          ctx.strokeStyle = stroke;
          ctx.lineWidth = lineWidth;
          ctx.stroke();
        }
      }

      function drawBasePoints() {
        ctx.save();

        for (const point of gridPoints) {
          drawCircle(point, 2.3, colors.pointShadow);
          drawCircle(point, 1.45, colors.point);
        }

        ctx.restore();
      }

      function drawPolygonFill() {
        if (!polygonClosed || selectedPoints.length < 3) {
          return;
        }

        ctx.save();
        ctx.beginPath();
        ctx.moveTo(selectedPoints[0].x, selectedPoints[0].y);

        for (let i = 1; i < selectedPoints.length; i++) {
          ctx.lineTo(selectedPoints[i].x, selectedPoints[i].y);
        }

        ctx.closePath();
        ctx.fillStyle = colors.polygonFill;
        ctx.fill();
        ctx.restore();
      }

      function drawSegments() {
        if (selectedPoints.length < 2) {
          return;
        }

        ctx.save();
        ctx.lineCap = "round";
        ctx.lineJoin = "round";

        ctx.shadowColor = colors.lineGlow;
        ctx.shadowBlur = 8;
        ctx.strokeStyle = colors.line;
        ctx.lineWidth = 3;

        ctx.beginPath();
        ctx.moveTo(selectedPoints[0].x, selectedPoints[0].y);

        for (let i = 1; i < selectedPoints.length; i++) {
          ctx.lineTo(selectedPoints[i].x, selectedPoints[i].y);
        }

        if (polygonClosed) {
          ctx.closePath();
        }

        ctx.stroke();
        ctx.restore();
      }

      function drawHighlightedPoints() {
        if (polygonClosed) {
          // 內部點
          for (const point of interiorPoints) {
            drawCircle(
              point,
              5,
              colors.interiorPoint,
              "#d9ffea",
              1.2
            );
          }

          // 邊界上所有格點,包括之前沒有手動點選的點
          for (const point of boundaryPoints) {
            drawCircle(
              point,
              5,
              colors.boundaryPoint,
              "#fff0bb",
              1.2
            );
          }
        } else {
          for (let i = 0; i < selectedPoints.length; i++) {
            const point = selectedPoints[i];

            if (i === 0) {
              drawCircle(
                point,
                6.5,
                colors.startPoint,
                "#fff2d8",
                1.5
              );
            } else if (i === selectedPoints.length - 1) {
              drawCircle(
                point,
                6.5,
                colors.currentPoint,
                "#ffe1e7",
                1.5
              );
            } else {
              drawCircle(
                point,
                5,
                colors.selectedPoint,
                "#dff8ff",
                1.2
              );
            }
          }
        }
      }

      function drawInvalidSegment() {
        if (!invalidSegment) {
          return;
        }

        ctx.save();
        ctx.beginPath();
        ctx.setLineDash([8, 6]);
        ctx.lineWidth = 3;
        ctx.lineCap = "round";
        ctx.strokeStyle = colors.invalidLine;
        ctx.moveTo(invalidSegment.from.x, invalidSegment.from.y);
        ctx.lineTo(invalidSegment.to.x, invalidSegment.to.y);
        ctx.stroke();
        ctx.restore();
      }

      function drawButton(control, active = false) {
        const hovered = hoveredControl === control;

        if (active) {
          ctx.fillStyle = colors.optionActive;
        } else if (hovered) {
          ctx.fillStyle = colors.optionHover;
        } else {
          ctx.fillStyle = control.color || colors.option;
        }

        roundedRectPath(
          control.x,
          control.y,
          control.width,
          control.height,
          7
        );
        ctx.fill();

        ctx.strokeStyle = active
          ? "rgba(255,255,255,0.55)"
          : "rgba(255,255,255,0.13)";
        ctx.lineWidth = 1;
        ctx.stroke();

        ctx.fillStyle = colors.optionText;
        ctx.font = control.font || "bold 15px Arial";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(
          control.label,
          control.x + control.width / 2,
          control.y + control.height / 2 + 0.5
        );
      }

      function drawOptions() {
        optionControls.length = 0;
        actionControls.length = 0;

        if (!polygonClosed) {
          const values = [10, 12, 16];
          const buttonWidth = 108;
          const buttonHeight = 38;
          const gap = 18;
          const totalWidth =
            buttonWidth * values.length + gap * (values.length - 1);
          const startX = (LOGICAL_WIDTH - totalWidth) / 2;
          const y = GRID_HEIGHT + 11;

          values.forEach((value, index) => {
            const control = {
              type: "division",
              value,
              label: `${value} × ${value}`,
              x: startX + index * (buttonWidth + gap),
              y,
              width: buttonWidth,
              height: buttonHeight
            };

            optionControls.push(control);
            drawButton(control, divisions === value);
          });

          return;
        }

        const confirmControl = {
          type: "confirm",
          label: "✓",
          x: 360,
          y: GRID_HEIGHT + 11,
          width: 45,
          height: 38,
          color: colors.confirm,
          font: "bold 22px Arial"
        };

        const downloadControl = {
          type: "download",
          label: "↓ PNG",
          x: 413,
          y: GRID_HEIGHT + 11,
          width: 58,
          height: 38,
          color: colors.download,
          font: "bold 12px Arial"
        };

        actionControls.push(confirmControl, downloadControl);

        ctx.fillStyle = "#f0f4fa";
        ctx.textAlign = "left";
        ctx.textBaseline = "middle";
        ctx.font = "12px Arial, Microsoft JhengHei";

        ctx.fillText(
          "Polygon area = interior points",
          12,
          GRID_HEIGHT + 20
        );
        ctx.fillText(
          "+ boundary points ÷ 2 − 1",
          12,
          GRID_HEIGHT + 39
        );

        drawButton(confirmControl);
        drawButton(downloadControl);
      }

      function draw() {
        drawBackground();
        drawGridGuides();
        drawBasePoints();
        drawPolygonFill();
        drawSegments();
        drawHighlightedPoints();
        drawInvalidSegment();
        drawOptions();
      }

      function getCanvasPosition(event) {
        const rect = canvas.getBoundingClientRect();

        return {
          x: (event.clientX - rect.left) *
            (LOGICAL_WIDTH / rect.width),
          y: (event.clientY - rect.top) *
            (LOGICAL_HEIGHT / rect.height)
        };
      }

      function isInsideControl(position, control) {
        return (
          position.x >= control.x &&
          position.x <= control.x + control.width &&
          position.y >= control.y &&
          position.y <= control.y + control.height
        );
      }

      function getControlAt(position) {
        const controls = polygonClosed
          ? actionControls
          : optionControls;

        return controls.find(control =>
          isInsideControl(position, control)
        ) || null;
      }

      function nearestGridPoint(position) {
        let nearest = null;
        let nearestDistanceSquared = Infinity;

        for (const point of gridPoints) {
          const dx = point.x - position.x;
          const dy = point.y - position.y;
          const distanceSquared = dx * dx + dy * dy;

          if (distanceSquared < nearestDistanceSquared) {
            nearestDistanceSquared = distanceSquared;
            nearest = point;
          }
        }

        return nearest;
      }

      function orientation(a, b, c) {
        const value =
          (b.x - a.x) * (c.y - a.y) -
          (b.y - a.y) * (c.x - a.x);

        if (Math.abs(value) < 1e-9) {
          return 0;
        }

        return value > 0 ? 1 : -1;
      }

      function onSegment(a, b, p) {
        return (
          orientation(a, b, p) === 0 &&
          p.x >= Math.min(a.x, b.x) - 1e-9 &&
          p.x <= Math.max(a.x, b.x) + 1e-9 &&
          p.y >= Math.min(a.y, b.y) - 1e-9 &&
          p.y <= Math.max(a.y, b.y) + 1e-9
        );
      }

      function segmentsIntersect(a, b, c, d) {
        const o1 = orientation(a, b, c);
        const o2 = orientation(a, b, d);
        const o3 = orientation(c, d, a);
        const o4 = orientation(c, d, b);

        if (o1 !== o2 && o3 !== o4) {
          return true;
        }

        if (o1 === 0 && onSegment(a, b, c)) return true;
        if (o2 === 0 && onSegment(a, b, d)) return true;
        if (o3 === 0 && onSegment(c, d, a)) return true;
        if (o4 === 0 && onSegment(c, d, b)) return true;

        return false;
      }

      /*
       * 檢查新線段是否和既有線段相交。
       * 與相鄰線段共享端點不視為交叉。
       */
      function wouldCrossExistingSegments(from, to, closing = false) {
        const segmentCount = selectedPoints.length - 1;

        for (let i = 0; i < segmentCount; i++) {
          const a = selectedPoints[i];
          const b = selectedPoints[i + 1];

          // 新線段一定與最後一條線共用 from
          if (i === segmentCount - 1) {
            continue;
          }

          // 封閉線段會與第一條線共用起始點
          if (closing && i === 0) {
            continue;
          }

          if (segmentsIntersect(from, to, a, b)) {
            return true;
          }
        }

        return false;
      }

      function showInvalidSegment(from, to) {
        invalidSegment = { from, to };
        draw();

        if (invalidTimer) {
          clearTimeout(invalidTimer);
        }

        invalidTimer = setTimeout(() => {
          invalidSegment = null;
          invalidTimer = null;
          draw();
        }, 1000);
      }

      function allSelectedPointsCollinear() {
        if (selectedPoints.length < 3) {
          return true;
        }

        const a = selectedPoints[0];
        const b = selectedPoints[1];

        for (let i = 2; i < selectedPoints.length; i++) {
          if (orientation(a, b, selectedPoints[i]) !== 0) {
            return false;
          }
        }

        return true;
      }

      function polygonSignedArea(points) {
        let sum = 0;

        for (let i = 0; i < points.length; i++) {
          const next = (i + 1) % points.length;
          sum +=
            points[i].x * points[next].y -
            points[next].x * points[i].y;
        }

        return sum / 2;
      }

      function pointInPolygon(point, polygon) {
        let inside = false;

        for (
          let i = 0, j = polygon.length - 1;
          i < polygon.length;
          j = i++
        ) {
          const a = polygon[i];
          const b = polygon[j];

          if (onSegment(a, b, point)) {
            return false;
          }

          const intersects =
            ((a.y > point.y) !== (b.y > point.y)) &&
            (
              point.x <
              ((b.x - a.x) * (point.y - a.y)) /
              (b.y - a.y) +
              a.x
            );

          if (intersects) {
            inside = !inside;
          }
        }

        return inside;
      }

      function classifyPolygonPoints() {
        boundaryPoints = [];
        interiorPoints = [];

        const boundaryKeys = new Set();

        for (const point of gridPoints) {
          for (let i = 0; i < selectedPoints.length; i++) {
            const a = selectedPoints[i];
            const b =
              selectedPoints[(i + 1) % selectedPoints.length];

            if (onSegment(a, b, point)) {
              boundaryKeys.add(pointKey(point));
              boundaryPoints.push(point);
              break;
            }
          }
        }

        for (const point of gridPoints) {
          if (boundaryKeys.has(pointKey(point))) {
            continue;
          }

          if (pointInPolygon(point, selectedPoints)) {
            interiorPoints.push(point);
          }
        }
      }

      function closePolygon() {
        const first = selectedPoints[0];
        const last = selectedPoints[selectedPoints.length - 1];

        if (wouldCrossExistingSegments(last, first, true)) {
          showInvalidSegment(last, first);
          return;
        }

        // 防止零面積或所有點共線的圖形
        if (Math.abs(polygonSignedArea(selectedPoints)) < 1e-9) {
          resetDrawing();
          return;
        }

        polygonClosed = true;
        classifyPolygonPoints();
        draw();
      }

      function handleGridClick(position) {
        if (polygonClosed) {
          return;
        }

        const point = nearestGridPoint(position);

        if (!point) {
          return;
        }

        if (selectedPoints.length === 0) {
          selectedPoints.push(point);
          draw();
          return;
        }

        const existingIndex = selectedPoints.findIndex(
          selected => samePoint(selected, point)
        );

        if (existingIndex !== -1) {
          // 只有起始點,再次點擊起始點:移除起始點
          if (
            selectedPoints.length === 1 &&
            existingIndex === 0
          ) {
            resetDrawing();
            return;
          }

          /*
           * 多個點都在同一直線,點擊起始點:
           * 移除所有點線。
           */
          if (
            existingIndex === 0 &&
            allSelectedPointsCollinear()
          ) {
            resetDrawing();
            return;
          }

          /*
           * 點擊起始點並且不是特殊情況:
           * 嘗試封閉多邊形。
           */
          if (
            existingIndex === 0 &&
            selectedPoints.length >= 3
          ) {
            closePolygon();
            return;
          }

          /*
           * 點擊已經選取過的非起始點:
           * 該點及其後面的點與線全部移除。
           */
          selectedPoints = selectedPoints.slice(0, existingIndex);
          draw();
          return;
        }

        const last = selectedPoints[selectedPoints.length - 1];

        if (wouldCrossExistingSegments(last, point, false)) {
          showInvalidSegment(last, point);
          return;
        }

        selectedPoints.push(point);
        draw();
      }

      function pad2(number) {
        return String(number).padStart(2, "0");
      }

      function createDownloadFilename() {
        const date = new Date();

        const yyyymmdd =
          date.getFullYear() +
          pad2(date.getMonth() + 1) +
          pad2(date.getDate());

        const hhiiss =
          pad2(date.getHours()) +
          pad2(date.getMinutes()) +
          pad2(date.getSeconds());

        return `ejpolygonarea_${yyyymmdd}_${hhiiss}.png`;
      }

      function downloadCanvas() {
        /*
         * 暫時取消按鈕 hover,避免把 hover 狀態下載進圖片。
         */
        const previousHover = hoveredControl;
        hoveredControl = null;
        draw();

        const filename = createDownloadFilename();

        canvas.toBlob(blob => {
          if (!blob) {
            hoveredControl = previousHover;
            draw();
            return;
          }

          const url = URL.createObjectURL(blob);
          const link = document.createElement("a");

          link.href = url;
          link.download = filename;
          document.body.appendChild(link);
          link.click();
          link.remove();

          setTimeout(() => URL.revokeObjectURL(url), 1000);

          hoveredControl = previousHover;
          draw();
        }, "image/png");
      }

      function handleOptionClick(position) {
        const control = getControlAt(position);

        if (!control) {
          return;
        }

        if (control.type === "division") {
          setDivisions(control.value);
          return;
        }

        if (control.type === "confirm") {
          resetDrawing();
          return;
        }

        if (control.type === "download") {
          downloadCanvas();
        }
      }

      canvas.addEventListener("pointerdown", event => {
        if (event.button !== 0 && event.pointerType === "mouse") {
          return;
        }

        event.preventDefault();

        const position = getCanvasPosition(event);

        if (position.y < GRID_HEIGHT) {
          handleGridClick(position);
        } else {
          handleOptionClick(position);
        }
      });

      canvas.addEventListener("pointermove", event => {
        const position = getCanvasPosition(event);
        const previous = hoveredControl;

        hoveredControl =
          position.y >= GRID_HEIGHT
            ? getControlAt(position)
            : null;

        canvas.style.cursor = hoveredControl
          ? "pointer"
          : position.y < GRID_HEIGHT && !polygonClosed
            ? "crosshair"
            : "default";

        if (previous !== hoveredControl) {
          draw();
        }
      });

      canvas.addEventListener("pointerleave", () => {
        if (hoveredControl) {
          hoveredControl = null;
          draw();
        }

        canvas.style.cursor = "default";
      });

      createGrid();
      draw();
    })();
  </script>
</body>
</html>
```

功能包括:

- `10 × 10`、`12 × 12`、`16 × 16` 三種點格。
- 排除 Canvas 外圍邊界上的點。
- 自動選取最接近點擊位置的格點。
- 起始點、最後點、一般選取點分色顯示。
- 阻止線段自相交,違規線段以虛線顯示一秒。
- 點擊已選取點可移除該點及其後續路徑。
- 點擊起始點封閉多邊形。
- 自動標記多邊形邊界上的所有格點及內部格點。
- 完成後顯示 Pick 定理英文說明。
- 確認按鈕清空並恢復選項。
- 下載按鈕輸出帶日期時間檔名的 PNG。
- Canvas 透過 `width: 100%` 始終與父元素同寬。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

改進:
把點格的白點變大一些。

把計算面積的文字寫到一行,除了Polygon area之外用實際數字代替,並算出結果,保留最多三位小數。

chatgpt

代码: 全选

請在原程式中修改以下兩個部分。

### 1. 放大點格中的白點

找到 `drawBasePoints()`,替換為:

```js
function drawBasePoints() {
  ctx.save();

  for (const point of gridPoints) {
    // 加大陰影及白色格點
    drawCircle(point, 4, colors.pointShadow);
    drawCircle(point, 2.6, colors.point);
  }

  ctx.restore();
}
```

如需讓白點更接近純白,也可以把 `colors` 中的:

```js
point: "#8290a8",
```

改成:

```js
point: "#d8e0ec",
```

---

### 2. 將面積算式改成單行,顯示實際數字及結果

在 `drawOptions()` 中,找到以下內容:

```js
ctx.fillStyle = "#f0f4fa";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = "12px Arial, Microsoft JhengHei";

ctx.fillText(
  "Polygon area = interior points",
  12,
  GRID_HEIGHT + 20
);
ctx.fillText(
  "+ boundary points ÷ 2 − 1",
  12,
  GRID_HEIGHT + 39
);
```

替換為:

```js
const interiorCount = interiorPoints.length;
const boundaryCount = boundaryPoints.length;
const polygonArea =
  interiorCount + boundaryCount / 2 - 1;

// 最多保留三位小數,並移除末尾多餘的 0
const formattedArea = Number(
  polygonArea.toFixed(3)
).toString();

const areaText =
  `Polygon area = ${interiorCount} + ` +
  `${boundaryCount} ÷ 2 − 1 = ${formattedArea}`;

ctx.fillStyle = "#f0f4fa";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = "11px Arial, Microsoft JhengHei";

ctx.fillText(
  areaText,
  10,
  GRID_HEIGHT + OPTION_HEIGHT / 2,
  342
);
```

這樣完成多邊形後會顯示成一行,例如:

```text
Polygon area = 12 + 8 ÷ 2 − 1 = 15
```

或:

```text
Polygon area = 9 + 7 ÷ 2 − 1 = 11.5
```

其中 `fillText()` 的第四個參數 `342` 是文字最大寬度,避免文字覆蓋右側的確認及下載按鈕;當內容較長時,Canvas 會自動壓縮文字寬度以維持單行顯示。
在下載的png中,不應出現兩個按鈕。

代码: 全选

可以在下載前暫時隱藏兩個按鈕,取得 PNG 資料後立即重新繪製,這樣畫面上的按鈕仍然存在,但下載的圖片不會包含按鈕。

### 1. 新增匯出狀態變數

在這段變數附近:

```js
let hoveredControl = null;
```

後面加入:

```js
// 下載圖片時不繪製確認及下載按鈕
let isExporting = false;
```

---

### 2. 修改 `drawOptions()`

在 `drawOptions()` 中,找到:

```js
actionControls.push(confirmControl, downloadControl);

ctx.fillStyle = "#f0f4fa";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = "11px Arial, Microsoft JhengHei";

ctx.fillText(
  areaText,
  10,
  GRID_HEIGHT + OPTION_HEIGHT / 2,
  342
);

drawButton(confirmControl);
drawButton(downloadControl);
```

替換成:

```js
// 匯出 PNG 時不加入也不繪製按鈕
if (!isExporting) {
  actionControls.push(confirmControl, downloadControl);
}

ctx.fillStyle = "#f0f4fa";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = "11px Arial, Microsoft JhengHei";

ctx.fillText(
  areaText,
  10,
  GRID_HEIGHT + OPTION_HEIGHT / 2,
  isExporting ? 460 : 342
);

if (!isExporting) {
  drawButton(confirmControl);
  drawButton(downloadControl);
}
```

匯出時文字可使用幾乎完整的選項區寬度,不會再為兩個按鈕預留空間。

---

### 3. 替換 `downloadCanvas()`

將原本的 `downloadCanvas()` 完整替換成:

```js
function downloadCanvas() {
  const previousHover = hoveredControl;

  // 暫時移除 hover 狀態及兩個操作按鈕
  hoveredControl = null;
  isExporting = true;
  draw();

  /*
   * toDataURL() 會同步取得目前 Canvas 的圖像,
   * 此時確認按鈕及下載按鈕尚未被繪製。
   */
  const imageURL = canvas.toDataURL("image/png");
  const filename = createDownloadFilename();

  // 立即恢復正常畫面
  isExporting = false;
  hoveredControl = previousHover;
  draw();

  const link = document.createElement("a");
  link.href = imageURL;
  link.download = filename;

  document.body.appendChild(link);
  link.click();
  link.remove();
}
```

這樣下載的 PNG 會保留:

- 點格背景
- 多邊形及線段
- 邊界點和內部點
- `Polygon area = ...` 面積算式

但不會包含:

- 打勾確認按鈕
- PNG 下載按鈕
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

在「下載png」的右邊,再增加一個「下載svg」的按鈕,用不同的按鈕顏色,也同樣在下載時去掉所有按鈕,canvas的元素都用svg元素替代。

chatgpt sol (low)

代码: 全选

可以新增 SVG 匯出功能。下載的 SVG 會以真正的 `<rect>`、`<line>`、`<polyline>`、`<polygon>`、`<circle>` 和 `<text>` 元素重新建立畫面,不會把 Canvas 轉成點陣圖片,也不會包含三個操作按鈕。

## 1. 增加 SVG 按鈕顏色

在 `colors` 中找到:

```js
confirm: "#2eaf72",
download: "#397fd5"
```

改成:

```js
confirm: "#2eaf72",
download: "#397fd5",
downloadSvg: "#9b59d0"
```

---

## 2. 建立統一的面積文字函式

加入以下函式,之後 Canvas 和 SVG 都使用相同的面積文字:

```js
function getAreaText() {
  const interiorCount = interiorPoints.length;
  const boundaryCount = boundaryPoints.length;

  const polygonArea =
    interiorCount + boundaryCount / 2 - 1;

  // 最多保留三位小數,移除末尾多餘的 0
  const formattedArea = Number(
    polygonArea.toFixed(3)
  ).toString();

  return (
    `Polygon area = ${interiorCount} + ` +
    `${boundaryCount} ÷ 2 − 1 = ${formattedArea}`
  );
}
```

---

## 3. 修改完成多邊形後的按鈕區域

在 `drawOptions()` 中,保留未完成多邊形時顯示點格選項的部分,將形成多邊形之後的部分改成以下內容:

```js
const confirmControl = {
  type: "confirm",
  label: "✓",
  x: 315,
  y: GRID_HEIGHT + 11,
  width: 38,
  height: 38,
  color: colors.confirm,
  font: "bold 20px Arial"
};

const downloadControl = {
  type: "download",
  label: "↓ PNG",
  x: 360,
  y: GRID_HEIGHT + 11,
  width: 53,
  height: 38,
  color: colors.download,
  font: "bold 11px Arial"
};

const downloadSvgControl = {
  type: "download-svg",
  label: "↓ SVG",
  x: 420,
  y: GRID_HEIGHT + 11,
  width: 51,
  height: 38,
  color: colors.downloadSvg,
  font: "bold 11px Arial"
};

// 匯出圖片時不加入操作按鈕,也不繪製按鈕
if (!isExporting) {
  actionControls.push(
    confirmControl,
    downloadControl,
    downloadSvgControl
  );
}

const areaText = getAreaText();

ctx.fillStyle = "#f0f4fa";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = "11px Arial, Microsoft JhengHei";

ctx.fillText(
  areaText,
  10,
  GRID_HEIGHT + OPTION_HEIGHT / 2,
  isExporting ? 460 : 295
);

if (!isExporting) {
  drawButton(confirmControl);
  drawButton(downloadControl);
  drawButton(downloadSvgControl);
}
```

完成後會有三個不同顏色的按鈕:

- 綠色:確認
- 藍色:下載 PNG
- 紫色:下載 SVG

---

## 4. 修改按鈕點擊處理

在 `handleOptionClick()` 中加入 SVG 下載處理。

完整函式可以改成:

```js
function handleOptionClick(position) {
  const control = getControlAt(position);

  if (!control) {
    return;
  }

  if (control.type === "division") {
    setDivisions(control.value);
    return;
  }

  if (control.type === "confirm") {
    resetDrawing();
    return;
  }

  if (control.type === "download") {
    downloadCanvas();
    return;
  }

  if (control.type === "download-svg") {
    downloadSvg();
  }
}
```

---

## 5. 建立 SVG 檔名

在 `createDownloadFilename()` 後面加入:

```js
function createSvgDownloadFilename() {
  const date = new Date();

  const yyyymmdd =
    date.getFullYear() +
    pad2(date.getMonth() + 1) +
    pad2(date.getDate());

  const hhiiss =
    pad2(date.getHours()) +
    pad2(date.getMinutes()) +
    pad2(date.getSeconds());

  return `ejpolygonarea_${yyyymmdd}_${hhiiss}.svg`;
}
```

---

## 6. 加入 SVG 產生與下載功能

將以下所有函式加入 JavaScript。可以放在 `downloadCanvas()` 後面。

```js
function escapeSvgText(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

function svgNumber(value) {
  return Number(value.toFixed(3));
}

function svgCircle(
  point,
  radius,
  fill,
  stroke = "none",
  strokeWidth = 0
) {
  return `
    <circle
      cx="${svgNumber(point.x)}"
      cy="${svgNumber(point.y)}"
      r="${radius}"
      fill="${fill}"
      stroke="${stroke}"
      stroke-width="${strokeWidth}"
    />
  `;
}

function createSvgContent() {
  const svg = [];

  svg.push(
    `<?xml version="1.0" encoding="UTF-8"?>`
  );

  svg.push(`
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="${LOGICAL_WIDTH}"
      height="${LOGICAL_HEIGHT}"
      viewBox="0 0 ${LOGICAL_WIDTH} ${LOGICAL_HEIGHT}"
      role="img"
      aria-label="Point grid polygon"
    >
  `);

  /*
   * Canvas 陰影由 SVG filter 代替。
   * 每個可見物件都使用 SVG 向量元素建立。
   */
  svg.push(`
    <defs>
      <filter
        id="lineGlow"
        x="-30%"
        y="-30%"
        width="160%"
        height="160%"
      >
        <feGaussianBlur
          stdDeviation="3"
          result="blur"
        />
        <feMerge>
          <feMergeNode in="blur" />
          <feMergeNode in="SourceGraphic" />
        </feMerge>
      </filter>

      <clipPath id="gridClip">
        <rect
          x="0"
          y="0"
          width="${LOGICAL_WIDTH}"
          height="${GRID_HEIGHT}"
        />
      </clipPath>
    </defs>
  `);

  // 上方點格背景
  svg.push(`
    <rect
      x="0"
      y="0"
      width="${LOGICAL_WIDTH}"
      height="${GRID_HEIGHT}"
      fill="${colors.gridBackground}"
    />
  `);

  // 下方資訊區背景
  svg.push(`
    <rect
      x="0"
      y="${GRID_HEIGHT}"
      width="${LOGICAL_WIDTH}"
      height="${OPTION_HEIGHT}"
      fill="${colors.optionBackground}"
    />
  `);

  // 上下區域分隔線
  svg.push(`
    <line
      x1="0"
      y1="${GRID_HEIGHT + 0.5}"
      x2="${LOGICAL_WIDTH}"
      y2="${GRID_HEIGHT + 0.5}"
      stroke="${colors.optionBorder}"
      stroke-width="1"
    />
  `);

  // 格線
  const step = GRID_HEIGHT / divisions;

  svg.push(`<g clip-path="url(#gridClip)">`);

  for (let i = 1; i < divisions; i++) {
    const position = svgNumber(i * step);

    svg.push(`
      <line
        x1="${position}"
        y1="0"
        x2="${position}"
        y2="${GRID_HEIGHT}"
        stroke="rgba(255,255,255,0.045)"
        stroke-width="1"
      />
    `);

    svg.push(`
      <line
        x1="0"
        y1="${position}"
        x2="${LOGICAL_WIDTH}"
        y2="${position}"
        stroke="rgba(255,255,255,0.045)"
        stroke-width="1"
      />
    `);
  }

  svg.push(`</g>`);

  // 所有基礎格點
  svg.push(`<g id="grid-points">`);

  for (const point of gridPoints) {
    svg.push(
      svgCircle(
        point,
        4,
        colors.pointShadow
      )
    );

    svg.push(
      svgCircle(
        point,
        2.6,
        colors.point
      )
    );
  }

  svg.push(`</g>`);

  /*
   * 多邊形填色。
   * SVG polygon 的 points 使用所有手動選中的頂點。
   */
  if (polygonClosed && selectedPoints.length >= 3) {
    const polygonPoints = selectedPoints
      .map(point =>
        `${svgNumber(point.x)},${svgNumber(point.y)}`
      )
      .join(" ");

    svg.push(`
      <polygon
        points="${polygonPoints}"
        fill="${colors.polygonFill}"
        stroke="none"
      />
    `);
  }

  // 已建立的線段或封閉多邊形邊線
  if (selectedPoints.length >= 2) {
    const linePoints = selectedPoints
      .map(point =>
        `${svgNumber(point.x)},${svgNumber(point.y)}`
      )
      .join(" ");

    if (polygonClosed) {
      svg.push(`
        <polygon
          points="${linePoints}"
          fill="none"
          stroke="${colors.line}"
          stroke-width="3"
          stroke-linecap="round"
          stroke-linejoin="round"
          filter="url(#lineGlow)"
        />
      `);
    } else {
      svg.push(`
        <polyline
          points="${linePoints}"
          fill="none"
          stroke="${colors.line}"
          stroke-width="3"
          stroke-linecap="round"
          stroke-linejoin="round"
          filter="url(#lineGlow)"
        />
      `);
    }
  }

  // 高亮點
  if (polygonClosed) {
    /*
     * 內部點使用與封閉前最後選中點相同的紅色。
     */
    svg.push(`<g id="interior-points">`);

    for (const point of interiorPoints) {
      svg.push(
        svgCircle(
          point,
          6.5,
          colors.currentPoint,
          "#ffe1e7",
          1.5
        )
      );
    }

    svg.push(`</g>`);

    // 邊界上的所有格點
    svg.push(`<g id="boundary-points">`);

    for (const point of boundaryPoints) {
      svg.push(
        svgCircle(
          point,
          5,
          colors.boundaryPoint,
          "#fff0bb",
          1.2
        )
      );
    }

    svg.push(`</g>`);
  } else {
    // 尚未形成多邊形時的選取點
    svg.push(`<g id="selected-points">`);

    for (let i = 0; i < selectedPoints.length; i++) {
      const point = selectedPoints[i];

      if (i === 0) {
        svg.push(
          svgCircle(
            point,
            6.5,
            colors.startPoint,
            "#fff2d8",
            1.5
          )
        );
      } else if (i === selectedPoints.length - 1) {
        svg.push(
          svgCircle(
            point,
            6.5,
            colors.currentPoint,
            "#ffe1e7",
            1.5
          )
        );
      } else {
        svg.push(
          svgCircle(
            point,
            5,
            colors.selectedPoint,
            "#dff8ff",
            1.2
          )
        );
      }
    }

    svg.push(`</g>`);
  }

  // 如果下載時剛好有無效線段,使用 SVG 虛線表示
  if (invalidSegment) {
    svg.push(`
      <line
        x1="${svgNumber(invalidSegment.from.x)}"
        y1="${svgNumber(invalidSegment.from.y)}"
        x2="${svgNumber(invalidSegment.to.x)}"
        y2="${svgNumber(invalidSegment.to.y)}"
        fill="none"
        stroke="${colors.invalidLine}"
        stroke-width="3"
        stroke-linecap="round"
        stroke-dasharray="8 6"
      />
    `);
  }

  /*
   * 下方只輸出面積文字。
   * 確認、PNG、SVG 三個按鈕均不加入 SVG。
   */
  if (polygonClosed) {
    const areaText = escapeSvgText(getAreaText());

    svg.push(`
      <text
        x="10"
        y="${GRID_HEIGHT + OPTION_HEIGHT / 2}"
        fill="#f0f4fa"
        font-family="Arial, Microsoft JhengHei, sans-serif"
        font-size="11"
        text-anchor="start"
        dominant-baseline="middle"
      >${areaText}</text>
    `);
  }

  svg.push(`</svg>`);

  return svg.join("");
}

function downloadSvg() {
  const svgContent = createSvgContent();

  const blob = new Blob(
    [svgContent],
    {
      type: "image/svg+xml;charset=utf-8"
    }
  );

  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");

  link.href = url;
  link.download = createSvgDownloadFilename();

  document.body.appendChild(link);
  link.click();
  link.remove();

  setTimeout(() => {
    URL.revokeObjectURL(url);
  }, 1000);
}
```

---

## 7. 保持 PNG 下載時不出現任何按鈕

原本修改過的 `downloadCanvas()` 可以繼續使用:

```js
function downloadCanvas() {
  const previousHover = hoveredControl;

  hoveredControl = null;
  isExporting = true;
  draw();

  const imageURL = canvas.toDataURL("image/png");
  const filename = createDownloadFilename();

  isExporting = false;
  hoveredControl = previousHover;
  draw();

  const link = document.createElement("a");

  link.href = imageURL;
  link.download = filename;

  document.body.appendChild(link);
  link.click();
  link.remove();
}
```

這樣:

- PNG 下載時,三個按鈕都不會出現在圖片中。
- SVG 下載內容不會包含三個按鈕。
- SVG 並非嵌入 PNG,而是使用真正的 SVG 向量元素重建畫面。
- SVG 內的格線、格點、多邊形、線段、高亮點、背景及面積文字在放大後仍保持清晰。
- SVG 檔名格式為 `ejpolygonarea_yyyymmdd_hhiiss.svg`。
附件
ejpolyarea1.html.7z
(5.49 KiB) 已下载 1 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6595
注册时间: 2022年 11月 18日 17:36
联系:

Re: canvas製作數學動畫

帖子 ejsoon »

在形成多邊形後,下方面積的式子中,數字的顏色要對應點的顏色。比如有17個邊上的點,則「17」應跟邊上的點的顏色一致(黄色),有8個內部的點,則「8」應跟內部的點的顏色一致。把17÷2放到8的前面。注意svg下載也要作相應更改。

代码: 全选

已按你的要求整理修改方式:

- 面積公式改為:`Polygon area = 17 ÷ 2 + 8 − 1 = ...`
- 邊界點數字(如 `17`)使用邊界點黃色 `colors.boundaryPoint`
- 內部點數字(如 `8`)使用目前內部點的紅色 `colors.currentPoint`
- PNG 下載會沿用 Canvas 的彩色公式
- SVG 下載則使用 `<tspan>` 分段設定相同顏色

請在 `ejpolyarea2.html` 中作以下三處替換。

---

### 1. 在 `drawOptions()` 前加入這個函式

```js
function drawAreaFormula(maxWidth) {
  const segments = getAreaSegments();
  const baseFontSize = 16;
  const fontFamily = 'Arial, Microsoft JhengHei';

  ctx.save();
  ctx.textAlign = 'left';
  ctx.textBaseline = 'middle';
  ctx.font = `${baseFontSize}px ${fontFamily}`;

  const naturalWidth = segments.reduce(
    (total, segment) => total + ctx.measureText(segment.text).width,
    0
  );

  const fontSize =
    naturalWidth > maxWidth
      ? Math.max(12, (baseFontSize * maxWidth) / naturalWidth)
      : baseFontSize;

  ctx.font = `${fontSize}px ${fontFamily}`;

  let x = 10;
  const y = GRID_HEIGHT + OPTION_HEIGHT / 2;

  for (const segment of segments) {
    ctx.fillStyle = segment.color;
    ctx.fillText(segment.text, x, y);
    x += ctx.measureText(segment.text).width;
  }

  ctx.restore();
}
```

---

### 2. 在 `drawOptions()` 裡找到以下程式碼

```js
const areaText = getAreaText();

ctx.fillStyle = '#f0f4fa';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '16px Arial, Microsoft JhengHei';

ctx.fillText(areaText, 10, GRID_HEIGHT + OPTION_HEIGHT / 2, isExporting ? 460 : 295);
```

替換成:

```js
drawAreaFormula(isExporting ? 460 : 295);
```

---

### 3. 替換原本的 `getAreaText()`

把:

```js
function getAreaText() {
  const interiorCount = interiorPoints.length;
  const boundaryCount = boundaryPoints.length;

  const polygonArea = interiorCount + boundaryCount / 2 - 1;

  // 最多保留三位小數,移除末尾多餘的 0
  const formattedArea = Number(polygonArea.toFixed(3)).toString();

  return `Polygon area = ${interiorCount} + ` + `${boundaryCount} ÷ 2 − 1 = ${formattedArea}`;
}
```

替換成:

```js
function getAreaSegments() {
  const interiorCount = interiorPoints.length;
  const boundaryCount = boundaryPoints.length;

  const polygonArea = interiorCount + boundaryCount / 2 - 1;

  // 最多保留三位小數,移除末尾多餘的 0
  const formattedArea = Number(polygonArea.toFixed(3)).toString();

  return [
    {
      text: 'Polygon area = ',
      color: '#f0f4fa'
    },
    {
      text: String(boundaryCount),
      color: colors.boundaryPoint
    },
    {
      text: ' ÷ 2 + ',
      color: '#f0f4fa'
    },
    {
      text: String(interiorCount),
      color: colors.currentPoint
    },
    {
      text: ` − 1 = ${formattedArea}`,
      color: '#f0f4fa'
    }
  ];
}

function getAreaText() {
  return getAreaSegments()
    .map(segment => segment.text)
    .join('');
}
```

---

### 4. 修改 SVG 下載中的面積文字

在 `createSvgContent()` 中找到:

```js
if (polygonClosed) {
  const areaText = escapeSvgText(getAreaText());

  svg.push(`
      <text
        x="10"
        y="${GRID_HEIGHT + OPTION_HEIGHT / 2}"
        fill="#f0f4fa"
        font-family="Arial, Microsoft JhengHei, sans-serif"
        font-size="16"
        text-anchor="start"
        dominant-baseline="middle"
      >${areaText}</text>
    `);
}
```

替換成:

```js
if (polygonClosed) {
  const areaSegments = getAreaSegments();

  const areaTspans = areaSegments
    .map(
      segment =>
        `<tspan fill="${segment.color}">${escapeSvgText(segment.text)}</tspan>`
    )
    .join('');

  svg.push(`
      <text
        x="10"
        y="${GRID_HEIGHT + OPTION_HEIGHT / 2}"
        fill="#f0f4fa"
        font-family="Arial, Microsoft JhengHei, sans-serif"
        font-size="16"
        text-anchor="start"
        dominant-baseline="middle"
        xml:space="preserve"
      >${areaTspans}</text>
    `);
}
```

修改後,例如有 17 個邊界點、8 個內部點,會顯示:

**Polygon area = `17` ÷ 2 + `8` − 1 = 15.5**

其中 `17` 為黃色,`8` 為內部點所使用的紅色;畫面、PNG 與 SVG 下載內容會保持一致。
附件
ejpolyarea2.html.7z
(6.97 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: 没有注册用户 和 2 访客