Pārlūkot izejas kodu

svg动画完成

robert 2 dienas atpakaļ
vecāks
revīzija
a567925f03
6 mainītis faili ar 885 papildinājumiem un 28 dzēšanām
  1. 93 11
      animal.js
  2. 23 5
      doc/index.html
  3. 24 0
      doc/svg/list.html
  4. 369 0
      doc/svg/test_morph_path.html
  5. 254 0
      doc/svg/test_morph_points.html
  6. 122 12
      xjs.js

+ 93 - 11
animal.js

@@ -10,7 +10,7 @@
   const isStr = (s) => typeof s === 'string';
   const isFunc = (f) => typeof f === 'function';
   const isNil = (v) => v === undefined || v === null;
-  const isSVG = (el) => (typeof SVGElement !== 'undefined') && (el instanceof SVGElement);
+  const isSVG = (el) => (typeof SVGElement !== 'undefined' && el instanceof SVGElement) || ((typeof Element !== 'undefined') && (el instanceof Element) && el.namespaceURI === 'http://www.w3.org/2000/svg');
   const isEl = (v) => (typeof Element !== 'undefined') && (v instanceof Element);
   const clamp01 = (n) => Math.max(0, Math.min(1, n));
   const toKebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
@@ -442,6 +442,54 @@
     if (prop && (prop.startsWith('rotate') || prop.startsWith('skew'))) return v + 'deg';
     return v + 'px';
   }
+
+  // String number template: interpolate numeric tokens inside a string.
+  // Useful for SVG path `d`, `points`, and other attributes that contain many numbers.
+  const _PURE_NUMBER_RE = /^[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?(?:%|px|pt|em|rem|in|cm|mm|ex|ch|pc|vw|vh|vmin|vmax|deg|rad|turn)?$/;
+  function isPureNumberLike(str) {
+    return _PURE_NUMBER_RE.test(String(str || '').trim());
+  }
+  function countDecimals(numStr) {
+    const s = String(numStr || '');
+    const dot = s.indexOf('.');
+    if (dot < 0) return 0;
+    // Strip exponent part for decimal count
+    const e = s.search(/[eE]/);
+    const end = e >= 0 ? e : s.length;
+    const frac = s.slice(dot + 1, end);
+    // Ignore trailing zeros for display
+    const trimmed = frac.replace(/0+$/, '');
+    return Math.min(6, trimmed.length);
+  }
+  function parseNumberTemplate(str) {
+    const s = String(str ?? '');
+    const nums = [];
+    const parts = [];
+    let last = 0;
+    const re = /[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g;
+    let m;
+    while ((m = re.exec(s))) {
+      const start = m.index;
+      const end = start + m[0].length;
+      parts.push(s.slice(last, start));
+      nums.push(parseFloat(m[0]));
+      last = end;
+    }
+    parts.push(s.slice(last));
+    return { nums, parts };
+  }
+  function extractNumberStrings(str) {
+    // Use a fresh regex to avoid any `lastIndex` surprises.
+    return String(str ?? '').match(/[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g) || [];
+  }
+  function formatInterpolatedNumber(n, decimals) {
+    let v = n;
+    if (!Number.isFinite(v)) v = 0;
+    if (Math.abs(v) < 1e-12) v = 0;
+    if (!decimals) return String(Math.round(v));
+    // Use fixed, then strip trailing zeros/dot to keep strings compact.
+    return v.toFixed(decimals).replace(/\.?0+$/, '');
+  }
   
   // --- JS Interpolator (anime.js-ish, simplified) ---
   class JsAnimation {
@@ -547,6 +595,12 @@
         return;
       }
       
+      // Fallback for path/d/points even if isSVG check somehow failed
+      if ((k === 'd' || k === 'points') && isEl(t)) {
+         t.setAttribute(k, v);
+         return;
+      }
+      
       // Generic property
       t[k] = v;
     }
@@ -561,19 +615,37 @@
         const fromStr = isNil(fromRaw) ? '0' : ('' + fromRaw).trim();
         const toStr = isNil(toRaw) ? '0' : ('' + toRaw).trim();
         
-        const fromNum = parseFloat(fromStr);
-        const toNum = parseFloat(toStr);
-        const fromNumOk = !Number.isNaN(fromNum);
-        const toNumOk = !Number.isNaN(toNum);
-        
-        // numeric tween (with unit preservation)
-        if (fromNumOk && toNumOk) {
+        const fromScalar = isPureNumberLike(fromStr);
+        const toScalar = isPureNumberLike(toStr);
+
+        // numeric tween (with unit preservation) — only when BOTH sides are pure scalars.
+        if (fromScalar && toScalar) {
+          const fromNum = parseFloat(fromStr);
+          const toNum = parseFloat(toStr);
           const unit = getUnit(toStr, k) ?? getUnit(fromStr, k) ?? '';
           tween[k] = { type: 'number', from: fromNum, to: toNum, unit };
-        } else {
-          // Non-numeric: fall back to "switch" (still useful for seek endpoints)
-          tween[k] = { type: 'discrete', from: fromStr, to: toStr };
+          return;
+        }
+
+        // string number-template tween (SVG path `d`, `points`, etc.)
+        // Requires both strings to have the same count of numeric tokens (>= 2).
+        const a = parseNumberTemplate(fromStr);
+        const b = parseNumberTemplate(toStr);
+        
+        if (a.nums.length >= 2 && a.nums.length === b.nums.length && a.parts.length === b.parts.length) {
+          const aStrs = extractNumberStrings(fromStr);
+          const bStrs = extractNumberStrings(toStr);
+          const decs = a.nums.map((_, i) => Math.max(countDecimals(aStrs[i]), countDecimals(bStrs[i])));
+          tween[k] = { type: 'number-template', fromNums: a.nums, toNums: b.nums, parts: b.parts, decimals: decs };
+          return;
+        } else if ((k === 'd' || k === 'points') && (a.nums.length > 0 || b.nums.length > 0)) {
+           console.warn(`[Animal.js] Morph mismatch for property "${k}".\nValues must have matching number count.`,
+             { from: a.nums.length, to: b.nums.length, fromVal: fromStr, toVal: toStr }
+           );
         }
+
+        // Non-numeric: fall back to "switch" (still useful for seek endpoints)
+        tween[k] = { type: 'discrete', from: fromStr, to: toStr };
       });
       return tween;
     }
@@ -590,6 +662,16 @@
           } else {
             this._writeValue(k, (val + t.unit));
           }
+        } else if (t.type === 'number-template') {
+          const eased = this._ease ? this._ease(this._progress) : this._progress;
+          const { fromNums, toNums, parts, decimals } = t;
+          let out = parts[0] || '';
+          for (let i = 0; i < fromNums.length; i++) {
+            const v = fromNums[i] + (toNums[i] - fromNums[i]) * eased;
+            out += formatInterpolatedNumber(v, decimals ? decimals[i] : 0);
+            out += parts[i + 1] || '';
+          }
+          this._writeValue(k, out);
         } else {
           const val = this._progress >= 1 ? t.to : t.from;
           this._writeValue(k, val);

+ 23 - 5
doc/index.html

@@ -263,6 +263,9 @@
     </svg>
 
     <script>
+        // Bump this when you want to force-refresh iframe pages
+        const DOC_CACHE_VERSION = '2';
+
         function selectCategory(listUrl, defaultContentUrl, el) {
             // 1. Highlight Nav Item
             document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
@@ -270,18 +273,28 @@
 
             // 2. Load Middle Column (List)
             const middleFrame = document.getElementById('middle-frame');
-            if (middleFrame.getAttribute('src') !== listUrl) {
-                middleFrame.src = listUrl;
+            const listUrlV = withDocVersion(listUrl);
+            if (middleFrame.getAttribute('src') !== listUrlV) {
+                middleFrame.src = listUrlV;
             }
 
             // 3. Load Right Column (Default Content)
             loadContent(defaultContentUrl);
         }
 
+        function withDocVersion(url) {
+            const u = String(url || '');
+            if (!u) return u;
+            // keep explicit query params if present
+            if (u.includes('?')) return u;
+            return u + '?v=' + encodeURIComponent(DOC_CACHE_VERSION);
+        }
+
         function loadContent(url) {
             const contentFrame = document.getElementById('content-frame');
-            if (contentFrame.getAttribute('src') !== url) {
-                contentFrame.src = url;
+            const urlV = withDocVersion(url);
+            if (contentFrame.getAttribute('src') !== urlV) {
+                contentFrame.src = urlV;
             }
 
             // Keep middle list selection in sync when possible
@@ -339,6 +352,8 @@
             { group: 'SVG', title: 'Motion Path', url: 'svg/test_motion_path.html' },
             { group: 'SVG', title: 'SVG 属性动画', url: 'svg/test_svg_attributes.html' },
             { group: 'SVG', title: 'SVG transform', url: 'svg/test_svg_transforms.html' },
+            { group: 'SVG', title: 'Morph(points)', url: 'svg/test_morph_points.html' },
+            { group: 'SVG', title: 'Morph(path d)', url: 'svg/test_morph_path.html' },
 
             // Callbacks
             { group: 'Callbacks', title: 'onUpdate', url: 'test_on_update.html' },
@@ -352,7 +367,10 @@
         ];
 
         function normalizeDocUrl(url) {
-            return String(url || '')
+            const raw = String(url || '');
+            const noHash = raw.split('#')[0];
+            const noQuery = noHash.split('?')[0];
+            return String(noQuery || '')
                 .replace(/^[#/]+/, '')
                 .replace(/^\.\//, '')
                 .replace(/^\/+/, '');

+ 24 - 0
doc/svg/list.html

@@ -127,6 +127,30 @@
         </div>
         <div class="card-footer">SVG transform(x/y/rotate/scale)</div>
       </div>
+
+      <div class="card" data-content="svg/test_morph_points.html" onclick="selectItem('test_morph_points.html', this)">
+        <div class="card-preview">
+          <div class="mini" aria-hidden="true">
+            <svg viewBox="0 0 120 60">
+              <polygon class="stroke orange" fill="rgba(255,159,67,0.10)"
+                points="60,10 72,26 92,28 77,40 82,54 60,46 38,54 43,40 28,28 48,26"></polygon>
+            </svg>
+          </div>
+        </div>
+        <div class="card-footer">变形(points 多边形)</div>
+      </div>
+
+      <div class="card" data-content="svg/test_morph_path.html" onclick="selectItem('test_morph_path.html', this)">
+        <div class="card-preview">
+          <div class="mini" aria-hidden="true">
+            <svg viewBox="0 0 120 60">
+              <path class="stroke orange" fill="rgba(255,159,67,0.10)"
+                d="M60 8 C84 8 100 22 100 34 C100 46 84 54 60 54 C36 54 20 46 20 34 C20 22 36 8 60 8 Z"></path>
+            </svg>
+          </div>
+        </div>
+        <div class="card-footer">变形(path d)</div>
+      </div>
     </div>
   </div>
 

+ 369 - 0
doc/svg/test_morph_path.html

@@ -0,0 +1,369 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>SVG Morph (path d) - Animal.js</title>
+  <link rel="stylesheet" href="../demo.css">
+  <style>
+    .hint {
+      color: #9a9a9a;
+      font-size: 12px;
+      line-height: 1.65;
+      margin-top: -8px;
+      margin-bottom: 16px;
+    }
+    .demo-visual {
+      padding: 26px 30px;
+      background: #151515;
+      display: grid;
+      grid-template-columns: 1fr 280px;
+      gap: 16px;
+      align-items: stretch;
+    }
+    .stage {
+      background: rgba(0,0,0,0.25);
+      border: 1px solid #262626;
+      border-radius: 12px;
+      padding: 14px;
+      overflow: hidden;
+      position: relative;
+      min-height: 300px;
+      display: grid;
+      place-items: center;
+    }
+    .panel {
+      background: rgba(0,0,0,0.18);
+      border: 1px solid #262626;
+      border-radius: 12px;
+      padding: 14px;
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      justify-content: space-between;
+    }
+    .control {
+      display: grid;
+      grid-template-columns: 90px 1fr 64px;
+      gap: 10px;
+      align-items: center;
+    }
+    .control label { color: #bdbdbd; font-size: 12px; font-weight: 800; }
+    .control output { color: #e6e6e6; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; }
+    input[type="range"] { width: 100%; }
+
+    svg { width: 100%; height: 100%; max-height: 340px; overflow: visible; }
+    .ref {
+      fill: none;
+      stroke: rgba(255,255,255,0.10);
+      stroke-width: 2;
+      stroke-dasharray: 6 6;
+      opacity: 0.75;
+    }
+    .ref.to { stroke: rgba(255,159,67,0.32); }
+    .ref.from { stroke: rgba(255,75,75,0.28); }
+    .path {
+      fill: rgba(255,159,67,0.12);
+      stroke: rgba(255,159,67,0.62);
+      stroke-width: 2.2;
+      filter: drop-shadow(0 0 22px rgba(255,159,67,0.10));
+    }
+    .label {
+      fill: rgba(255,255,255,0.22);
+      font-weight: 800;
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+      font-size: 12px;
+      letter-spacing: 0.8px;
+    }
+    .meter {
+      display: inline-flex;
+      gap: 10px;
+      align-items: center;
+      padding: 8px 10px;
+      border-radius: 12px;
+      border: 1px solid #2a2a2a;
+      background: rgba(255,255,255,0.02);
+      color: #cfcfcf;
+      font-size: 12px;
+      font-weight: 800;
+    }
+    .dot {
+      width: 10px;
+      height: 10px;
+      border-radius: 50%;
+      background: #444;
+    }
+    .dot.on {
+      background: var(--accent-color);
+      box-shadow: 0 0 0 5px rgba(255,159,67,0.14);
+    }
+    .tools {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+      margin-top: 10px;
+    }
+    .mini-btn {
+      background: rgba(255,255,255,0.02);
+      border: 1px solid #2a2a2a;
+      color: #cfcfcf;
+      padding: 6px 10px;
+      border-radius: 999px;
+      font-size: 12px;
+      font-weight: 800;
+      cursor: pointer;
+    }
+    .mini-btn:hover {
+      border-color: var(--orange);
+      color: var(--orange);
+    }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">ANIMATION › SVG</div>
+      <div class="since">SINCE 1.0.0</div>
+    </div>
+
+    <h1>变形动画:path 的 <code class="inline">d</code> (DEBUG MODE)</h1>
+    <div style="background:#330; padding:10px; border-radius:4px; font-family:monospace; margin-bottom:10px;" id="debug-panel">Waiting for debug info...</div>
+    <p class="description">
+      这就是你想要的“形状变形”。在本库里,只要两段 <code class="inline">d</code> 字符串<strong>结构一致</strong>(命令序列相同,数字数量相同),
+      就能直接插值,做出平滑 morph。
+    </p>
+    <p class="hint">
+      <strong>关键点:</strong>不要试图让“完全不相干”的两条 path 直接互变。先让它们有相同的命令结构(比如都用 4 段 cubic Bézier),再改数值。
+      <br>下面 demo 会显示两条参考虚线(From/To),中间那条橙色实体才是正在 morph 的路径。
+    </p>
+
+    <div class="box-container">
+      <div class="box-header">
+        <div class="box-title">Morph (path d) code example</div>
+        <div class="box-right">
+          <button class="icon-btn" type="button" title="Copy" onclick="DocPage.copyActiveCode()" aria-label="Copy code">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <rect x="9" y="9" width="13" height="13" rx="2"></rect>
+              <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+            </svg>
+          </button>
+          <div class="tabs" role="tablist" aria-label="Code tabs">
+            <div class="tab active" role="tab" aria-selected="true" tabindex="0" onclick="DocPage.switchTab('js')">JavaScript</div>
+            <div class="tab" role="tab" aria-selected="false" tabindex="-1" onclick="DocPage.switchTab('html')">HTML</div>
+            <div class="tab" role="tab" aria-selected="false" tabindex="-1" onclick="DocPage.switchTab('css')">CSS</div>
+          </div>
+        </div>
+      </div>
+
+      <div id="js-code" class="code-view active">
+<span class="kwd">const</span> FROM <span class="punc">=</span> <span class="str">'M160 60 C204 60 240 96 240 140 C240 184 204 220 160 220 C116 220 80 184 80 140 C80 96 116 60 160 60 Z'</span><span class="punc">;</span>
+<span class="kwd">const</span> TO <span class="punc">=</span> <span class="str">'M160 34 C252 62 260 112 224 140 C188 168 196 238 160 224 C124 210 132 168 96 140 C60 112 68 62 160 34 Z'</span><span class="punc">;</span>
+
+<span class="fun">xjs</span><span class="punc">(</span><span class="str">'#m'</span><span class="punc">)</span>.<span class="fun">animate</span><span class="punc">({</span>
+  d<span class="punc">:</span> <span class="punc">[</span>FROM<span class="punc">,</span> TO<span class="punc">]</span><span class="punc">,</span>
+  duration<span class="punc">:</span> <span class="num">1200</span><span class="punc">,</span>
+  easing<span class="punc">:</span> <span class="str">'ease-in-out'</span><span class="punc">,</span>
+  update<span class="punc">:</span> <span class="punc">({</span>progress<span class="punc">})</span> <span class="punc">=&gt;</span> console.<span class="fun">log</span>(progress)
+<span class="punc">}</span><span class="punc">)</span><span class="punc">;</span>
+      </div>
+
+      <div id="html-code" class="html-view">
+<span class="tag">&lt;path</span> <span class="attr">id</span>=<span class="val">"m"</span> <span class="attr">d</span>=<span class="val">"M160 40 C..."</span> <span class="tag">/&gt;</span>
+      </div>
+
+      <pre id="css-code" class="css-view">.path { fill: rgba(255,159,67,0.12); stroke: rgba(255,159,67,0.62); stroke-width: 2.2; }</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>
+        <br>- 本库会把字符串里的数字提取出来逐个插值,然后把字符串“拼回去”写回属性。
+        <br>- 适合:logo 变形、icon morph、液体/呼吸形状、SVG 插画过渡。
+      </div>
+
+      <div class="demo-visual">
+        <div class="stage">
+          <svg viewBox="0 0 320 260" aria-label="Morph path demo">
+            <path id="refFrom" class="ref from"
+              d="M160 60 C204 60 240 96 240 140 C240 184 204 220 160 220 C116 220 80 184 80 140 C80 96 116 60 160 60 Z"></path>
+            <path id="refTo" class="ref to"
+              d="M160 34 C252 62 260 112 224 140 C188 168 196 238 160 224 C124 210 132 168 96 140 C60 112 68 62 160 34 Z"></path>
+            <path id="m" class="path"
+              d="M160 60 C204 60 240 96 240 140 C240 184 204 220 160 220 C116 220 80 184 80 140 C80 96 116 60 160 60 Z"></path>
+            <text class="label" x="160" y="252" text-anchor="middle">path d morph (watch orange shape)</text>
+          </svg>
+        </div>
+        <div class="panel">
+          <div>
+            <div class="meter">
+              <span id="runDot" class="dot"></span>
+              <span id="pText">progress: 0%</span>
+            </div>
+            <div class="tools">
+              <button class="mini-btn" type="button" onclick="setFrom()">SET FROM</button>
+              <button class="mini-btn" type="button" onclick="setTo()">SET TO</button>
+              <button class="mini-btn" type="button" onclick="logNow()">LOG d</button>
+            </div>
+            <div class="control">
+              <label for="dur">duration</label>
+              <input id="dur" type="range" min="200" max="3200" step="50" value="1200" />
+              <output id="durOut">1200ms</output>
+            </div>
+            <div class="control">
+              <label for="loop">loop</label>
+              <input id="loop" type="range" min="1" max="6" step="1" value="2" />
+              <output id="loopOut">2</output>
+            </div>
+          </div>
+          <div style="display:flex; gap:10px; justify-content:flex-end;">
+            <button class="play-btn secondary" onclick="pauseDemo()">PAUSE</button>
+            <button class="play-btn secondary" onclick="resumeDemo()">RESUME</button>
+            <button class="play-btn" onclick="runDemo()">REPLAY</button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="doc-nav" aria-label="Previous and next navigation">
+      <a href="#" onclick="goPrev(); return false;">
+        <span>
+          <span class="nav-label">Previous</span><br>
+          <span id="prev-title" class="nav-title">—</span>
+        </span>
+        <span aria-hidden="true">←</span>
+      </a>
+      <div id="nav-center" class="nav-center">SVG</div>
+      <a href="#" onclick="goNext(); return false;">
+        <span>
+          <span class="nav-label">Next</span><br>
+          <span id="next-title" class="nav-title">—</span>
+        </span>
+        <span aria-hidden="true">→</span>
+      </a>
+    </div>
+  </div>
+
+  <div id="toast" class="toast" role="status" aria-live="polite"></div>
+
+  <script src="../page_utils.js?v=1"></script>
+  <script src="../../xjs.js?v=debug_fix_2"></script>
+  <script>
+    // Debug helper
+    window.addEventListener('load', () => {
+        const m = document.getElementById('m');
+        const isSVGInstance = typeof SVGElement !== 'undefined' && m instanceof SVGElement;
+        const ns = m.namespaceURI;
+        const info = `[Debug] isSVGElement:${isSVGInstance}, ns:${ns}`;
+        console.log(info);
+        const pText = document.getElementById('pText');
+        const debugPanel = document.getElementById('debug-panel');
+        if (debugPanel) debugPanel.textContent = info;
+        if (pText) pText.setAttribute('title', info); 
+    });
+  </script>
+  <script>
+    const CURRENT = 'svg/test_morph_path.html';
+    let __ctls = [];
+    let __lastD = '';
+
+    const m = document.getElementById('m');
+    const dur = document.getElementById('dur');
+    const loop = document.getElementById('loop');
+    const durOut = document.getElementById('durOut');
+    const loopOut = document.getElementById('loopOut');
+    const pText = document.getElementById('pText');
+    const runDot = document.getElementById('runDot');
+
+    const FROM = 'M160 60 C204 60 240 96 240 140 C240 184 204 220 160 220 C116 220 80 184 80 140 C80 96 116 60 160 60 Z';
+    const TO = 'M160 34 C252 62 260 112 224 140 C188 168 196 238 160 224 C124 210 132 168 96 140 C60 112 68 62 160 34 Z';
+
+    function read() {
+      const d = Number(dur.value);
+      const l = Number(loop.value);
+      durOut.textContent = d + 'ms';
+      loopOut.textContent = String(l);
+      return { d, l };
+    }
+
+    function reset() {
+      (__ctls || []).forEach(c => { try { c?.cancel?.(); } catch {} });
+      __ctls = [];
+      m.setAttribute('d', FROM);
+      __lastD = m.getAttribute('d') || '';
+      if (pText) pText.textContent = 'progress: 0%';
+      if (runDot) runDot.classList.remove('on');
+    }
+
+    function bboxText() {
+      try {
+        const bb = m.getBBox();
+        const w = Math.round(bb.width);
+        const h = Math.round(bb.height);
+        return `bbox:${w}×${h}`;
+      } catch {
+        return 'bbox:n/a';
+      }
+    }
+
+    function setFrom() {
+      reset();
+      m.setAttribute('d', FROM);
+      __lastD = m.getAttribute('d') || '';
+      if (pText) pText.textContent = 'manual: FROM  |  ' + bboxText();
+    }
+
+    function setTo() {
+      reset();
+      m.setAttribute('d', TO);
+      __lastD = m.getAttribute('d') || '';
+      if (pText) pText.textContent = 'manual: TO  |  ' + bboxText();
+    }
+
+    function logNow() {
+      const cur = m.getAttribute('d') || '';
+      console.log('[morph:d]', { len: cur.length, head: cur.slice(0, 60), bbox: bboxText() });
+      if (pText) pText.textContent = 'logged  |  ' + bboxText();
+    }
+
+    function runDemo() {
+      reset();
+      const { d, l } = read();
+      if (runDot) runDot.classList.add('on');
+      __ctls.push(xjs(m).animate({
+        d: [FROM, TO],
+        duration: d,
+        easing: 'ease-in-out',
+        direction: 'alternate',
+        loop: l,
+        update: ({ progress }) => {
+          const pct = Math.round(progress * 100);
+          const cur = m.getAttribute('d') || '';
+          const changed = cur && cur !== __lastD;
+          if (changed) __lastD = cur;
+          if (pText) {
+            const head = cur ? cur.slice(0, 18).replace(/\s+/g, ' ') : '';
+            pText.textContent = 'progress: ' + pct + '%  |  d: ' + (changed ? 'changing' : 'stuck') + '  |  ' + bboxText() + '  |  ' + head + (cur ? '…' : '');
+          }
+        },
+        complete: () => {
+          if (runDot) runDot.classList.remove('on');
+        }
+      }));
+    }
+
+    function pauseDemo() { (__ctls || []).forEach(c => { try { c?.pause?.(); } catch {} }); }
+    function resumeDemo() { (__ctls || []).forEach(c => { try { c?.play?.(); } catch {} }); }
+
+    dur.addEventListener('input', read);
+    loop.addEventListener('input', read);
+
+    function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
+    function goNext() { window.parent?.docNavigateNext?.(CURRENT); }
+
+    DocPage.enableTabKeyboardNav();
+    DocPage.syncMiddleActive(CURRENT);
+    DocPage.syncPrevNext(CURRENT);
+    read();
+    setTimeout(runDemo, 320);
+  </script>
+</body>
+</html>
+

+ 254 - 0
doc/svg/test_morph_points.html

@@ -0,0 +1,254 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>SVG Morph (points) - Animal.js</title>
+  <link rel="stylesheet" href="../demo.css">
+  <style>
+    .hint {
+      color: #9a9a9a;
+      font-size: 12px;
+      line-height: 1.65;
+      margin-top: -8px;
+      margin-bottom: 16px;
+    }
+    .demo-visual {
+      padding: 26px 30px;
+      background: #151515;
+      display: grid;
+      grid-template-columns: 1fr 280px;
+      gap: 16px;
+      align-items: stretch;
+    }
+    .stage {
+      background: rgba(0,0,0,0.25);
+      border: 1px solid #262626;
+      border-radius: 12px;
+      padding: 14px;
+      overflow: hidden;
+      position: relative;
+      min-height: 280px;
+      display: grid;
+      place-items: center;
+    }
+    .panel {
+      background: rgba(0,0,0,0.18);
+      border: 1px solid #262626;
+      border-radius: 12px;
+      padding: 14px;
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      justify-content: space-between;
+    }
+    .control {
+      display: grid;
+      grid-template-columns: 90px 1fr 64px;
+      gap: 10px;
+      align-items: center;
+    }
+    .control label { color: #bdbdbd; font-size: 12px; font-weight: 800; }
+    .control output { color: #e6e6e6; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; }
+    input[type="range"] { width: 100%; }
+
+    svg { width: 100%; height: 100%; max-height: 320px; }
+    .shape {
+      fill: rgba(255,159,67,0.14);
+      stroke: rgba(255,159,67,0.55);
+      stroke-width: 2;
+      filter: drop-shadow(0 0 18px rgba(255,159,67,0.10));
+    }
+    .shape.red {
+      fill: rgba(255,75,75,0.12);
+      stroke: rgba(255,75,75,0.50);
+      filter: drop-shadow(0 0 18px rgba(255,75,75,0.10));
+    }
+    .label {
+      fill: rgba(255,255,255,0.22);
+      font-weight: 800;
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+      font-size: 12px;
+      letter-spacing: 0.8px;
+    }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">ANIMATION › SVG</div>
+      <div class="since">SINCE 1.0.0</div>
+    </div>
+
+    <h1>变形动画:points(多边形)</h1>
+    <p class="description">
+      这是最“稳”的 morph:只要两边的 <code class="inline">points</code> 有<strong>相同数量的数字</strong>,
+      就能直接做平滑过渡(本库会把字符串里的数字逐个插值)。
+    </p>
+    <p class="hint">
+      <strong>规则:</strong>points 其实就是一串数字:<code class="inline">x1 y1 x2 y2 ...</code>。
+      你想从 A 变到 B,就确保 A/B 的点数量一致(比如都 12 个点)。
+    </p>
+
+    <div class="box-container">
+      <div class="box-header">
+        <div class="box-title">Morph (points) code example</div>
+        <div class="box-right">
+          <button class="icon-btn" type="button" title="Copy" onclick="DocPage.copyActiveCode()" aria-label="Copy code">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <rect x="9" y="9" width="13" height="13" rx="2"></rect>
+              <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+            </svg>
+          </button>
+          <div class="tabs" role="tablist" aria-label="Code tabs">
+            <div class="tab active" role="tab" aria-selected="true" tabindex="0" onclick="DocPage.switchTab('js')">JavaScript</div>
+            <div class="tab" role="tab" aria-selected="false" tabindex="-1" onclick="DocPage.switchTab('html')">HTML</div>
+            <div class="tab" role="tab" aria-selected="false" tabindex="-1" onclick="DocPage.switchTab('css')">CSS</div>
+          </div>
+        </div>
+      </div>
+
+      <div id="js-code" class="code-view active">
+<span class="kwd">const</span> star <span class="punc">=</span> <span class="str">'160 35 185 75 230 80 195 110 206 155 160 132 114 155 125 110 90 80 135 75'</span><span class="punc">;</span>
+<span class="kwd">const</span> blob <span class="punc">=</span> <span class="str">'160 50 205 60 235 95 220 130 190 165 160 175 130 165 100 130 85 95 115 60'</span><span class="punc">;</span>
+
+<span class="fun">xjs</span><span class="punc">(</span><span class="str">'#poly'</span><span class="punc">)</span>.<span class="fun">animate</span><span class="punc">({</span>
+  points<span class="punc">:</span> <span class="punc">[</span>star<span class="punc">,</span> blob<span class="punc">]</span><span class="punc">,</span>
+  duration<span class="punc">:</span> <span class="num">1200</span><span class="punc">,</span>
+  easing<span class="punc">:</span> <span class="str">'ease-in-out'</span>
+<span class="punc">}</span><span class="punc">)</span><span class="punc">;</span>
+      </div>
+
+      <div id="html-code" class="html-view">
+<span class="tag">&lt;polygon</span> <span class="attr">id</span>=<span class="val">"poly"</span> <span class="attr">points</span>=<span class="val">"160 35 ..."</span> <span class="tag">/&gt;</span>
+      </div>
+
+      <pre id="css-code" class="css-view">.shape { fill: rgba(255,159,67,0.14); stroke: rgba(255,159,67,0.55); stroke-width: 2; }</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>
+        <br>- 这里的 <code class="inline">points</code> 是“数字模板字符串”。本库会识别其中的数字并逐个插值。
+        <br>- 适合:图标变形、加载态、装饰动效(尤其是多边形/折线)。
+      </div>
+
+      <div class="demo-visual">
+        <div class="stage">
+          <svg viewBox="0 0 320 220" aria-label="Morph points demo">
+            <polygon id="poly" class="shape"
+              points="160 35 185 75 230 80 195 110 206 155 160 132 114 155 125 110 90 80 135 75"></polygon>
+            <polygon id="poly2" class="shape red" opacity="0.65"
+              points="160 50 205 60 235 95 220 130 190 165 160 175 130 165 100 130 85 95 115 60"></polygon>
+            <text class="label" x="160" y="210" text-anchor="middle">points morph (same number tokens)</text>
+          </svg>
+        </div>
+        <div class="panel">
+          <div>
+            <div class="control">
+              <label for="dur">duration</label>
+              <input id="dur" type="range" min="200" max="3200" step="50" value="1200" />
+              <output id="durOut">1200ms</output>
+            </div>
+            <div class="control">
+              <label for="loop">loop</label>
+              <input id="loop" type="range" min="1" max="6" step="1" value="2" />
+              <output id="loopOut">2</output>
+            </div>
+          </div>
+          <div style="display:flex; gap:10px; justify-content:flex-end;">
+            <button class="play-btn secondary" onclick="pauseDemo()">PAUSE</button>
+            <button class="play-btn secondary" onclick="resumeDemo()">RESUME</button>
+            <button class="play-btn" onclick="runDemo()">REPLAY</button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="doc-nav" aria-label="Previous and next navigation">
+      <a href="#" onclick="goPrev(); return false;">
+        <span>
+          <span class="nav-label">Previous</span><br>
+          <span id="prev-title" class="nav-title">—</span>
+        </span>
+        <span aria-hidden="true">←</span>
+      </a>
+      <div id="nav-center" class="nav-center">SVG</div>
+      <a href="#" onclick="goNext(); return false;">
+        <span>
+          <span class="nav-label">Next</span><br>
+          <span id="next-title" class="nav-title">—</span>
+        </span>
+        <span aria-hidden="true">→</span>
+      </a>
+    </div>
+  </div>
+
+  <div id="toast" class="toast" role="status" aria-live="polite"></div>
+
+  <script src="../page_utils.js?v=1"></script>
+  <script src="../../xjs.js?v=1"></script>
+  <script>
+    const CURRENT = 'svg/test_morph_points.html';
+    let __ctls = [];
+
+    const poly = document.getElementById('poly');
+    const poly2 = document.getElementById('poly2');
+    const dur = document.getElementById('dur');
+    const loop = document.getElementById('loop');
+    const durOut = document.getElementById('durOut');
+    const loopOut = document.getElementById('loopOut');
+
+    const STAR = '160 35 185 75 230 80 195 110 206 155 160 132 114 155 125 110 90 80 135 75';
+    const BLOB = '160 50 205 60 235 95 220 130 190 165 160 175 130 165 100 130 85 95 115 60';
+
+    function read() {
+      const d = Number(dur.value);
+      const l = Number(loop.value);
+      durOut.textContent = d + 'ms';
+      loopOut.textContent = String(l);
+      return { d, l };
+    }
+
+    function reset() {
+      (__ctls || []).forEach(c => { try { c?.cancel?.(); } catch {} });
+      __ctls = [];
+      poly.setAttribute('points', STAR);
+      poly2.setAttribute('points', BLOB);
+    }
+
+    function runDemo() {
+      reset();
+      const { d, l } = read();
+      __ctls.push(xjs(poly).animate({
+        points: [STAR, BLOB],
+        duration: d,
+        easing: 'ease-in-out',
+        direction: 'alternate',
+        loop: l
+      }));
+      __ctls.push(xjs(poly2).animate({
+        points: [BLOB, STAR],
+        duration: d,
+        easing: 'ease-in-out',
+        direction: 'alternate',
+        loop: l
+      }));
+    }
+
+    function pauseDemo() { (__ctls || []).forEach(c => { try { c?.pause?.(); } catch {} }); }
+    function resumeDemo() { (__ctls || []).forEach(c => { try { c?.play?.(); } catch {} }); }
+
+    dur.addEventListener('input', read);
+    loop.addEventListener('input', read);
+
+    function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
+    function goNext() { window.parent?.docNavigateNext?.(CURRENT); }
+
+    DocPage.enableTabKeyboardNav();
+    DocPage.syncMiddleActive(CURRENT);
+    DocPage.syncPrevNext(CURRENT);
+    read();
+    setTimeout(runDemo, 320);
+  </script>
+</body>
+</html>
+

+ 122 - 12
xjs.js

@@ -456,7 +456,7 @@
   const isStr = (s) => typeof s === 'string';
   const isFunc = (f) => typeof f === 'function';
   const isNil = (v) => v === undefined || v === null;
-  const isSVG = (el) => (typeof SVGElement !== 'undefined') && (el instanceof SVGElement);
+  const isSVG = (el) => (typeof SVGElement !== 'undefined' && el instanceof SVGElement) || ((typeof Element !== 'undefined') && (el instanceof Element) && el.namespaceURI === 'http://www.w3.org/2000/svg');
   const isEl = (v) => (typeof Element !== 'undefined') && (v instanceof Element);
   const clamp01 = (n) => Math.max(0, Math.min(1, n));
   const toKebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
@@ -888,6 +888,50 @@
     if (prop && (prop.startsWith('rotate') || prop.startsWith('skew'))) return v + 'deg';
     return v + 'px';
   }
+
+  // String number template: interpolate numeric tokens inside a string.
+  // Useful for SVG path `d`, `points`, and other attributes that contain many numbers.
+  const _PURE_NUMBER_RE = /^[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?(?:%|px|pt|em|rem|in|cm|mm|ex|ch|pc|vw|vh|vmin|vmax|deg|rad|turn)?$/;
+  function isPureNumberLike(str) {
+    return _PURE_NUMBER_RE.test(String(str || '').trim());
+  }
+  function countDecimals(numStr) {
+    const s = String(numStr || '');
+    const dot = s.indexOf('.');
+    if (dot < 0) return 0;
+    const e = s.search(/[eE]/);
+    const end = e >= 0 ? e : s.length;
+    const frac = s.slice(dot + 1, end);
+    const trimmed = frac.replace(/0+$/, '');
+    return Math.min(6, trimmed.length);
+  }
+  function parseNumberTemplate(str) {
+    const s = String(str ?? '');
+    const nums = [];
+    const parts = [];
+    let last = 0;
+    const re = /[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g;
+    let m;
+    while ((m = re.exec(s))) {
+      const start = m.index;
+      const end = start + m[0].length;
+      parts.push(s.slice(last, start));
+      nums.push(parseFloat(m[0]));
+      last = end;
+    }
+    parts.push(s.slice(last));
+    return { nums, parts };
+  }
+  function extractNumberStrings(str) {
+    return String(str ?? '').match(/[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g) || [];
+  }
+  function formatInterpolatedNumber(n, decimals) {
+    let v = n;
+    if (!Number.isFinite(v)) v = 0;
+    if (Math.abs(v) < 1e-12) v = 0;
+    if (!decimals) return String(Math.round(v));
+    return v.toFixed(decimals).replace(/\.?0+$/, '');
+  }
   
   // --- JS Interpolator (anime.js-ish, simplified) ---
   class JsAnimation {
@@ -962,12 +1006,43 @@
     
     _writeValue(k, v) {
       const t = this.target;
+      
+      // Debug for 'd'
+      if (k === 'd') {
+          const isSvg = isSVG(t);
+          const isElType = isEl(t);
+          if (Math.random() < 0.05) {
+             console.log('[Animal.js] DEBUG: _writeValue d', { 
+                 isSvg, 
+                 isEl: isElType, 
+                 attr: toKebab(k), 
+                 valLen: v.length,
+                 hasSetAttribute: typeof t.setAttribute === 'function'
+             });
+          }
+      }
+
       // JS object
       if (!isEl(t) && !isSVG(t)) {
         t[k] = v;
         return;
       }
       
+      // SVG attribute
+      if (isSVG(t)) {
+        const attr = toKebab(k);
+        t.setAttribute(attr, v);
+        // Verify write
+        // if (k === 'd' && Math.random() < 0.01) console.log('Readback:', t.getAttribute('d')?.slice(0, 10));
+        return;
+      }
+      
+      // Fallback
+      if ((k === 'd' || k === 'points') && isEl(t)) {
+         t.setAttribute(k, v);
+         return;
+      }
+
       // scroll
       if (k === 'scrollTop' || k === 'scrollLeft') {
         t[k] = v;
@@ -993,6 +1068,12 @@
         return;
       }
       
+      // Fallback for path/d/points even if isSVG check somehow failed
+      if ((k === 'd' || k === 'points') && isEl(t)) {
+         t.setAttribute(k, v);
+         return;
+      }
+      
       // Generic property
       t[k] = v;
     }
@@ -1006,20 +1087,38 @@
         
         const fromStr = isNil(fromRaw) ? '0' : ('' + fromRaw).trim();
         const toStr = isNil(toRaw) ? '0' : ('' + toRaw).trim();
-        
-        const fromNum = parseFloat(fromStr);
-        const toNum = parseFloat(toStr);
-        const fromNumOk = !Number.isNaN(fromNum);
-        const toNumOk = !Number.isNaN(toNum);
-        
-        // numeric tween (with unit preservation)
-        if (fromNumOk && toNumOk) {
+
+        const fromScalar = isPureNumberLike(fromStr);
+        const toScalar = isPureNumberLike(toStr);
+
+        // numeric tween (with unit preservation) — only when BOTH sides are pure scalars.
+        if (fromScalar && toScalar) {
+          const fromNum = parseFloat(fromStr);
+          const toNum = parseFloat(toStr);
           const unit = getUnit(toStr, k) ?? getUnit(fromStr, k) ?? '';
           tween[k] = { type: 'number', from: fromNum, to: toNum, unit };
-        } else {
-          // Non-numeric: fall back to "switch" (still useful for seek endpoints)
-          tween[k] = { type: 'discrete', from: fromStr, to: toStr };
+          return;
+        }
+
+        // string number-template tween (SVG path `d`, `points`, etc.)
+        const a = parseNumberTemplate(fromStr);
+        const b = parseNumberTemplate(toStr);
+        if (a.nums.length >= 2 && a.nums.length === b.nums.length && a.parts.length === b.parts.length) {
+          if (k === 'd') console.log('[Animal.js] DEBUG: Building d tween. Num count:', a.nums.length);
+          const aStrs = extractNumberStrings(fromStr);
+          const bStrs = extractNumberStrings(toStr);
+          const decs = a.nums.map((_, i) => Math.max(countDecimals(aStrs[i]), countDecimals(bStrs[i])));
+          tween[k] = { type: 'number-template', fromNums: a.nums, toNums: b.nums, parts: b.parts, decimals: decs };
+          return;
+        } else if ((k === 'd' || k === 'points') && (a.nums.length > 0 || b.nums.length > 0)) {
+           console.warn(`[Animal.js] Morph mismatch for property "${k}".\nValues must have matching number count.`,
+             { from: a.nums.length, to: b.nums.length, fromVal: fromStr, toVal: toStr }
+           );
         }
+
+        if (k === 'd') console.log('[Animal.js] DEBUG: Fallback to discrete for d (mismatch or too short). a.nums:', a.nums.length, 'b.nums:', b.nums.length);
+        // Non-numeric: fall back to "switch" (still useful for seek endpoints)
+        tween[k] = { type: 'discrete', from: fromStr, to: toStr };
       });
       return tween;
     }
@@ -1036,6 +1135,17 @@
           } else {
             this._writeValue(k, (val + t.unit));
           }
+        } else if (t.type === 'number-template') {
+          const eased = this._ease ? this._ease(this._progress) : this._progress;
+          const { fromNums, toNums, parts, decimals } = t;
+          let out = parts[0] || '';
+          for (let i = 0; i < fromNums.length; i++) {
+            const v = fromNums[i] + (toNums[i] - fromNums[i]) * eased;
+            out += formatInterpolatedNumber(v, decimals ? decimals[i] : 0);
+            out += parts[i + 1] || '';
+          }
+          this._writeValue(k, out);
+          if (k === 'd' && Math.random() < 0.05) console.log('[Animal.js] DEBUG: writing d. Len:', out.length, 'Sample:', out.slice(0, 30));
         } else {
           const val = this._progress >= 1 ? t.to : t.from;
           this._writeValue(k, val);