codex-skill-analytics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +279 -0
- package/PRODUCT.md +54 -0
- package/README.md +128 -0
- package/npm/cli.mjs +89 -0
- package/package.json +43 -0
- package/src/codex_skill_analytics/__init__.py +4 -0
- package/src/codex_skill_analytics/cli.py +130 -0
- package/src/codex_skill_analytics/database.py +359 -0
- package/src/codex_skill_analytics/graph.py +193 -0
- package/src/codex_skill_analytics/parser.py +484 -0
- package/src/codex_skill_analytics/sync.py +141 -0
- package/src/codex_skill_analytics/web.py +151 -0
- package/src/codex_skill_analytics/web_templates.py +61 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .database import AnalyticsDB
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_graph_data(
|
|
12
|
+
db: AnalyticsDB,
|
|
13
|
+
*,
|
|
14
|
+
days: int,
|
|
15
|
+
relation_type: str,
|
|
16
|
+
min_weight: int,
|
|
17
|
+
limit: int,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
summaries = {
|
|
20
|
+
(str(row["scope"]), str(row["name"])): row for row in db.summary(days)
|
|
21
|
+
}
|
|
22
|
+
relation_rows = [
|
|
23
|
+
row for row in db.relations(days, relation_type) if int(row["weight"]) >= min_weight
|
|
24
|
+
][:limit]
|
|
25
|
+
identities: set[tuple[str, str]] = set()
|
|
26
|
+
edges: list[dict[str, object]] = []
|
|
27
|
+
for row in relation_rows:
|
|
28
|
+
source = (str(row["source_scope"]), str(row["source"]))
|
|
29
|
+
target = (str(row["target_scope"]), str(row["target"]))
|
|
30
|
+
identities.update((source, target))
|
|
31
|
+
edges.append(
|
|
32
|
+
{
|
|
33
|
+
"source": f"{source[0]}:{source[1]}",
|
|
34
|
+
"target": f"{target[0]}:{target[1]}",
|
|
35
|
+
"weight": int(row["weight"]),
|
|
36
|
+
"threads": int(row["threads"]),
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
if not identities:
|
|
40
|
+
identities.update(list(summaries)[: min(limit, 30)])
|
|
41
|
+
nodes: list[dict[str, object]] = []
|
|
42
|
+
for scope, name in sorted(identities):
|
|
43
|
+
summary = summaries.get((scope, name))
|
|
44
|
+
nodes.append(
|
|
45
|
+
{
|
|
46
|
+
"id": f"{scope}:{name}",
|
|
47
|
+
"name": name,
|
|
48
|
+
"scope": scope,
|
|
49
|
+
"invocations": int(summary["invocations"]) if summary else 0,
|
|
50
|
+
"accesses": int(summary["accesses"] or 0) if summary else 0,
|
|
51
|
+
"threads": int(summary["threads"]) if summary else 0,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
return {
|
|
55
|
+
"meta": {
|
|
56
|
+
"days": days,
|
|
57
|
+
"relation_type": relation_type,
|
|
58
|
+
"min_weight": min_weight,
|
|
59
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
60
|
+
"directed": relation_type != "same-turn",
|
|
61
|
+
},
|
|
62
|
+
"nodes": nodes,
|
|
63
|
+
"edges": edges,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_graph_html(data: dict[str, Any], output: Path) -> Path:
|
|
68
|
+
output = output.expanduser().resolve()
|
|
69
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
71
|
+
payload = payload.replace("<", "\\u003c").replace("&", "\\u0026")
|
|
72
|
+
output.write_text(HTML_TEMPLATE.replace("__GRAPH_DATA__", payload), encoding="utf-8")
|
|
73
|
+
return output
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
HTML_TEMPLATE = r'''<!doctype html>
|
|
77
|
+
<html lang="zh-CN">
|
|
78
|
+
<head>
|
|
79
|
+
<meta charset="utf-8">
|
|
80
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
81
|
+
<title>Codex Skill 依赖图谱</title>
|
|
82
|
+
<style>
|
|
83
|
+
:root {
|
|
84
|
+
--paper:#edf2f4; --panel:#f8fafb; --ink:#17272e; --muted:#62737a;
|
|
85
|
+
--line:#bdcbd0; --focus:#176d89; --warm:#ca6c2b; --shadow:#25383f22;
|
|
86
|
+
--system:#7356a8; --user:#177b76; --plugin:#d2702c; --repo:#3f659b; --unknown:#758087;
|
|
87
|
+
}
|
|
88
|
+
*{box-sizing:border-box}
|
|
89
|
+
html,body{height:100%;margin:0;background:var(--paper);color:var(--ink)}
|
|
90
|
+
body{font-family:Optima,"Avenir Next","PingFang SC",sans-serif;overflow:hidden}
|
|
91
|
+
button,input{font:inherit}
|
|
92
|
+
.shell{height:100%;display:grid;grid-template-rows:auto 1fr}
|
|
93
|
+
header{display:grid;grid-template-columns:minmax(280px,1fr) auto;gap:24px;align-items:end;padding:20px 24px 16px;border-bottom:1px solid var(--line);background:rgba(248,250,251,.92);backdrop-filter:blur(12px)}
|
|
94
|
+
.eyebrow{font:600 11px/1.2 ui-monospace,SFMono-Regular,monospace;letter-spacing:.16em;text-transform:uppercase;color:var(--focus);margin-bottom:5px}
|
|
95
|
+
h1{font:600 clamp(24px,3vw,39px)/1.04 Optima,"Songti SC",serif;letter-spacing:-.035em;margin:0}
|
|
96
|
+
.subtitle{margin:7px 0 0;color:var(--muted);font-size:13px}
|
|
97
|
+
.controls{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap;justify-content:flex-end}
|
|
98
|
+
label.control{display:grid;gap:5px;font:600 10px/1.2 ui-monospace,SFMono-Regular,monospace;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
|
|
99
|
+
input[type=search]{width:210px;border:1px solid var(--line);border-radius:4px;background:white;padding:8px 10px;color:var(--ink);outline:none}
|
|
100
|
+
input[type=search]:focus{border-color:var(--focus);box-shadow:0 0 0 3px #176d8922}
|
|
101
|
+
input[type=range]{accent-color:var(--focus);width:135px}
|
|
102
|
+
.check{display:flex!important;grid-auto-flow:column;align-items:center;gap:7px!important;padding-bottom:8px}
|
|
103
|
+
.workspace{min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 270px}
|
|
104
|
+
.stage{position:relative;min-width:0;overflow:hidden;background-image:linear-gradient(#69808716 1px,transparent 1px),linear-gradient(90deg,#69808716 1px,transparent 1px);background-size:28px 28px}
|
|
105
|
+
svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab}
|
|
106
|
+
svg.dragging{cursor:grabbing}
|
|
107
|
+
.edge{stroke:#708991;stroke-opacity:.44;transition:stroke-opacity .15s,stroke-width .15s}
|
|
108
|
+
.edge.active{stroke:var(--warm);stroke-opacity:.92}
|
|
109
|
+
.edge.dim{stroke-opacity:.05}
|
|
110
|
+
.edge-label{fill:#5a6e75;font:600 10px ui-monospace,SFMono-Regular,monospace;paint-order:stroke;stroke:var(--paper);stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
|
111
|
+
.node{cursor:pointer;outline:none;transition:opacity .15s}
|
|
112
|
+
.node circle{stroke:var(--panel);stroke-width:3;filter:drop-shadow(0 3px 3px var(--shadow));transition:stroke-width .15s,filter .15s}
|
|
113
|
+
.node:hover circle,.node:focus circle,.node.selected circle{stroke:var(--ink);stroke-width:4;filter:drop-shadow(0 5px 5px #25383f35)}
|
|
114
|
+
.node.dim{opacity:.12}
|
|
115
|
+
.node text{fill:var(--ink);font:600 12px/1.2 "Avenir Next","PingFang SC",sans-serif;paint-order:stroke;stroke:var(--paper);stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
|
116
|
+
.node .scope-tag{font:600 8px ui-monospace,SFMono-Regular,monospace;letter-spacing:.08em;text-transform:uppercase;fill:var(--muted)}
|
|
117
|
+
.empty{position:absolute;inset:0;display:none;place-items:center;color:var(--muted);text-align:center;padding:32px}
|
|
118
|
+
aside{border-left:1px solid var(--line);background:var(--panel);padding:20px;overflow:auto}
|
|
119
|
+
.aside-title{font:600 10px ui-monospace,SFMono-Regular,monospace;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin:0 0 12px}
|
|
120
|
+
.metric{display:grid;grid-template-columns:1fr auto;gap:8px;padding:8px 0;border-top:1px solid #dce4e7;font-size:13px}.metric b{font-family:ui-monospace,SFMono-Regular,monospace}
|
|
121
|
+
.detail{min-height:170px}.detail h2{font:600 20px/1.15 Optima,"PingFang SC",sans-serif;margin:0 0 5px;overflow-wrap:anywhere}.detail p{color:var(--muted);font-size:12px;margin:0 0 15px}
|
|
122
|
+
.legend{display:grid;gap:9px;margin:12px 0 24px}.legend-row{display:flex;align-items:center;gap:9px;font-size:12px}.dot{width:10px;height:10px;border-radius:50%}
|
|
123
|
+
.note{font-size:11px;line-height:1.55;color:var(--muted);border-top:1px solid var(--line);padding-top:14px}
|
|
124
|
+
.status{position:absolute;left:16px;bottom:14px;padding:7px 9px;border:1px solid var(--line);border-radius:4px;background:#f8fafbea;color:var(--muted);font:11px ui-monospace,SFMono-Regular,monospace;pointer-events:none}
|
|
125
|
+
@media(max-width:760px){body{overflow:auto}.shell{height:auto;min-height:100%}header{grid-template-columns:1fr}.controls{justify-content:flex-start}.workspace{grid-template-columns:1fr;grid-template-rows:65vh auto}.stage{min-height:480px}aside{border-left:0;border-top:1px solid var(--line)}}
|
|
126
|
+
@media(prefers-reduced-motion:reduce){*{transition:none!important}.edge-flow{display:none}}
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<div class="shell">
|
|
131
|
+
<header>
|
|
132
|
+
<div><div class="eyebrow">Observed dependency trace</div><h1>Codex Skill 依赖图谱</h1><p class="subtitle" id="subtitle"></p></div>
|
|
133
|
+
<div class="controls">
|
|
134
|
+
<label class="control">搜索 Skill<input id="search" type="search" placeholder="输入名称或作用域…"></label>
|
|
135
|
+
<label class="control">最小边权重 <span id="weightValue"></span><input id="weight" type="range" min="1" value="1"></label>
|
|
136
|
+
<label class="control check"><input id="labels" type="checkbox" checked>显示标签</label>
|
|
137
|
+
</div>
|
|
138
|
+
</header>
|
|
139
|
+
<main class="workspace">
|
|
140
|
+
<section class="stage" aria-label="Skill 关系图">
|
|
141
|
+
<svg id="graph" viewBox="0 0 1100 760" role="img" aria-labelledby="graphTitle graphDesc">
|
|
142
|
+
<title id="graphTitle">Skill 调用依赖图谱</title><desc id="graphDesc">节点代表 Skill,连线代表历史调用中的观察关系。</desc>
|
|
143
|
+
<defs><marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#708991"></path></marker></defs>
|
|
144
|
+
<g id="viewport"><g id="edges"></g><g id="edgeLabels"></g><g id="nodes"></g></g>
|
|
145
|
+
</svg>
|
|
146
|
+
<div class="empty" id="empty">当前筛选条件下没有关系。<br>降低最小边权重后重试。</div>
|
|
147
|
+
<div class="status" id="status"></div>
|
|
148
|
+
</section>
|
|
149
|
+
<aside>
|
|
150
|
+
<p class="aside-title">选中节点</p><div class="detail" id="detail"><h2>点击一个 Skill</h2><p>查看调用量、访问量及相邻依赖。</p></div>
|
|
151
|
+
<p class="aside-title">作用域</p><div class="legend" id="legend"></div>
|
|
152
|
+
<p class="note" id="note"></p>
|
|
153
|
+
</aside>
|
|
154
|
+
</main>
|
|
155
|
+
</div>
|
|
156
|
+
<script type="application/json" id="graph-data">__GRAPH_DATA__</script>
|
|
157
|
+
<script>
|
|
158
|
+
(() => {
|
|
159
|
+
'use strict';
|
|
160
|
+
const data=JSON.parse(document.getElementById('graph-data').textContent);
|
|
161
|
+
const colors={system:'#7356a8',user:'#177b76',plugin:'#d2702c',repo:'#3f659b',unknown:'#758087'};
|
|
162
|
+
const typeNames={'same-turn':'同轮共现','sequence':'同轮相邻调用','next-in-thread':'线程内相邻调用'};
|
|
163
|
+
const directed=data.meta.directed;
|
|
164
|
+
document.getElementById('subtitle').textContent=`${typeNames[data.meta.relation_type]||data.meta.relation_type} · 最近 ${data.meta.days} 天 · ${data.nodes.length} 个节点 / ${data.edges.length} 条边`;
|
|
165
|
+
document.getElementById('note').textContent=directed?'箭头表示观察到的调用方向,不代表 Skill 源码声明了硬依赖。':'连线表示两个 Skill 在同一轮共同出现,不代表源码硬依赖。';
|
|
166
|
+
const legend=document.getElementById('legend');
|
|
167
|
+
Object.entries(colors).forEach(([scope,color])=>legend.insertAdjacentHTML('beforeend',`<div class="legend-row"><i class="dot" style="background:${color}"></i>${scope}</div>`));
|
|
168
|
+
const svg=document.getElementById('graph'), viewport=document.getElementById('viewport');
|
|
169
|
+
const edgeLayer=document.getElementById('edges'), edgeLabelLayer=document.getElementById('edgeLabels'), nodeLayer=document.getElementById('nodes');
|
|
170
|
+
const search=document.getElementById('search'), weight=document.getElementById('weight'), weightValue=document.getElementById('weightValue'), labels=document.getElementById('labels');
|
|
171
|
+
const maxWeight=Math.max(1,...data.edges.map(e=>e.weight)); weight.max=String(maxWeight); weight.value=String(data.meta.min_weight); weightValue.textContent=weight.value;
|
|
172
|
+
const hash=s=>{let h=2166136261;for(const c of s){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return h>>>0};
|
|
173
|
+
const nodes=data.nodes.map((n,i)=>({...n,x:140+(hash(n.id)%820),y:90+((hash(n.id+'y')+i*97)%570),vx:0,vy:0,r:10+Math.sqrt(Math.max(1,n.invocations))*2.4}));
|
|
174
|
+
const byId=new Map(nodes.map(n=>[n.id,n]));
|
|
175
|
+
const edges=data.edges.map((e,i)=>({...e,i,sourceNode:byId.get(e.source),targetNode:byId.get(e.target)})).filter(e=>e.sourceNode&&e.targetNode);
|
|
176
|
+
const edgeEls=new Map(), edgeLabelEls=new Map(), nodeEls=new Map();
|
|
177
|
+
edges.forEach(e=>{const line=document.createElementNS('http://www.w3.org/2000/svg','line');line.classList.add('edge');line.style.strokeWidth=String(1.2+Math.sqrt(e.weight)*1.15);if(directed)line.setAttribute('marker-end','url(#arrow)');edgeLayer.appendChild(line);edgeEls.set(e,line);const t=document.createElementNS('http://www.w3.org/2000/svg','text');t.classList.add('edge-label');t.textContent=String(e.weight);edgeLabelLayer.appendChild(t);edgeLabelEls.set(e,t)});
|
|
178
|
+
nodes.forEach(n=>{const g=document.createElementNS('http://www.w3.org/2000/svg','g');g.classList.add('node');g.setAttribute('tabindex','0');g.setAttribute('role','button');g.setAttribute('aria-label',`${n.name},调用 ${n.invocations} 次`);const c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.setAttribute('r',n.r);c.setAttribute('fill',colors[n.scope]||colors.unknown);const t=document.createElementNS('http://www.w3.org/2000/svg','text');t.setAttribute('text-anchor','middle');t.setAttribute('y',n.r+17);t.textContent=n.name.split(':').at(-1);const s=document.createElementNS('http://www.w3.org/2000/svg','text');s.classList.add('scope-tag');s.setAttribute('text-anchor','middle');s.setAttribute('y',n.r+30);s.textContent=n.scope;g.append(c,t,s);nodeLayer.appendChild(g);nodeEls.set(n,g);g.addEventListener('click',()=>selectNode(n));g.addEventListener('keydown',ev=>{if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();selectNode(n)}});bindDrag(g,n)});
|
|
179
|
+
function updatePositions(){edges.forEach(e=>{const a=e.sourceNode,b=e.targetNode,dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1,ux=dx/d,uy=dy/d;const line=edgeEls.get(e),pad=directed?b.r+8:b.r;line.setAttribute('x1',a.x+ux*(a.r+3));line.setAttribute('y1',a.y+uy*(a.r+3));line.setAttribute('x2',b.x-ux*pad);line.setAttribute('y2',b.y-uy*pad);const t=edgeLabelEls.get(e);t.setAttribute('x',(a.x+b.x)/2+uy*9);t.setAttribute('y',(a.y+b.y)/2-ux*9)});nodes.forEach(n=>nodeEls.get(n).setAttribute('transform',`translate(${n.x},${n.y})`))}
|
|
180
|
+
let tick=0, running=true;
|
|
181
|
+
function simulate(){if(!running)return;nodes.forEach(n=>{n.vx+=(550-n.x)*.00065;n.vy+=(370-n.y)*.00065});for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){const a=nodes[i],b=nodes[j],dx=b.x-a.x,dy=b.y-a.y,d=Math.max(1,Math.hypot(dx,dy)),min=a.r+b.r+72,repel=6500/(d*d)+(d<min?(min-d)*.018:0);a.vx-=dx/d*repel;a.vy-=dy/d*repel;b.vx+=dx/d*repel;b.vy+=dy/d*repel}edges.forEach(e=>{const a=e.sourceNode,b=e.targetNode,dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1,f=(d-(135+a.r+b.r))*.00145*(1+Math.log1p(e.weight));a.vx+=dx/d*f;a.vy+=dy/d*f;b.vx-=dx/d*f;b.vy-=dy/d*f});nodes.forEach(n=>{n.vx*=.85;n.vy*=.85;n.x=Math.max(55,Math.min(1045,n.x+n.vx));n.y=Math.max(55,Math.min(690,n.y+n.vy))});updatePositions();tick++;if(tick<300)requestAnimationFrame(simulate);else running=false}
|
|
182
|
+
if(matchMedia('(prefers-reduced-motion: reduce)').matches){for(let i=0;i<260;i++)simulate();running=false}else requestAnimationFrame(simulate);
|
|
183
|
+
function bindDrag(el,n){let active=false;el.addEventListener('pointerdown',ev=>{active=true;el.setPointerCapture(ev.pointerId);svg.classList.add('dragging');ev.stopPropagation()});el.addEventListener('pointermove',ev=>{if(!active)return;const p=svg.createSVGPoint();p.x=ev.clientX;p.y=ev.clientY;const q=p.matrixTransform(viewport.getScreenCTM().inverse());n.x=q.x;n.y=q.y;n.vx=n.vy=0;updatePositions()});el.addEventListener('pointerup',()=>{active=false;svg.classList.remove('dragging')})}
|
|
184
|
+
function selectNode(n){nodeEls.forEach((el,node)=>el.classList.toggle('selected',node===n));edges.forEach(e=>edgeEls.get(e).classList.toggle('active',e.sourceNode===n||e.targetNode===n));const outgoing=edges.filter(e=>e.sourceNode===n).reduce((s,e)=>s+e.weight,0),incoming=edges.filter(e=>e.targetNode===n).reduce((s,e)=>s+e.weight,0);document.getElementById('detail').innerHTML=`<h2>${escapeHtml(n.name)}</h2><p>${escapeHtml(n.scope)} · ${escapeHtml(n.id)}</p><div class="metric"><span>调用次数</span><b>${n.invocations}</b></div><div class="metric"><span>访问证据</span><b>${n.accesses}</b></div><div class="metric"><span>涉及线程</span><b>${n.threads}</b></div><div class="metric"><span>流入 / 流出</span><b>${incoming} / ${outgoing}</b></div>`}
|
|
185
|
+
function escapeHtml(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
186
|
+
function applyFilters(){const min=Number(weight.value),q=search.value.trim().toLowerCase();weightValue.textContent=String(min);let visibleEdges=0;edges.forEach(e=>{const matches=!q||e.source.toLowerCase().includes(q)||e.target.toLowerCase().includes(q),show=e.weight>=min&&matches;edgeEls.get(e).style.display=show?'':'none';edgeLabelEls.get(e).style.display=show&&labels.checked?'':'none';if(show)visibleEdges++});nodes.forEach(n=>{const match=!q||n.id.toLowerCase().includes(q);nodeEls.get(n).classList.toggle('dim',!match)});nodeEls.forEach(el=>el.querySelectorAll('text').forEach(t=>t.style.display=labels.checked?'':'none'));document.getElementById('status').textContent=`${nodes.length} nodes · ${visibleEdges} visible edges`;document.getElementById('empty').style.display=visibleEdges===0&&edges.length?'grid':'none'}
|
|
187
|
+
search.addEventListener('input',applyFilters);weight.addEventListener('input',applyFilters);labels.addEventListener('change',applyFilters);applyFilters();updatePositions();
|
|
188
|
+
let transform={x:0,y:0,k:1},panning=false,last={x:0,y:0};function setTransform(){viewport.setAttribute('transform',`translate(${transform.x} ${transform.y}) scale(${transform.k})`)}svg.addEventListener('wheel',ev=>{ev.preventDefault();transform.k=Math.max(.35,Math.min(2.8,transform.k*(ev.deltaY>0?.9:1.1)));setTransform()},{passive:false});svg.addEventListener('pointerdown',ev=>{if(ev.target===svg){panning=true;last={x:ev.clientX,y:ev.clientY};svg.setPointerCapture(ev.pointerId)}});svg.addEventListener('pointermove',ev=>{if(!panning)return;transform.x+=(ev.clientX-last.x)/transform.k;transform.y+=(ev.clientY-last.y)/transform.k;last={x:ev.clientX,y:ev.clientY};setTransform()});svg.addEventListener('pointerup',()=>panning=false);
|
|
189
|
+
})();
|
|
190
|
+
</script>
|
|
191
|
+
</body>
|
|
192
|
+
</html>
|
|
193
|
+
'''
|