@worca/app 1.0.0 → 1.1.1
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/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/shared/graph/template.mjs
|
|
2
|
+
// The editable v2 template: normalize/serialize, the node/wire factory and the
|
|
3
|
+
// drop-legality check the composer runs on the POINTER PATH (so it must stay a
|
|
4
|
+
// Map lookup plus a type check — never a Tarjan walk).
|
|
5
|
+
import { TEMPLATE_VERSION } from './constants.mjs';
|
|
6
|
+
import { portsOf, findPort, resolveOrOutType, inboundWires } from './ports.mjs';
|
|
7
|
+
|
|
8
|
+
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
9
|
+
const ARITY_SET = new Set(['and', 'or', 'combine']);
|
|
10
|
+
|
|
11
|
+
/** Canonicalize an authored or fetched template: numeric coordinates, a
|
|
12
|
+
* materialized `config` on every node, non-object nodes/wires dropped, `key`
|
|
13
|
+
* kept only where V3 allows it. Unknown config keys are PRESERVED (V17 warns
|
|
14
|
+
* and ignores them; it never strips them). */
|
|
15
|
+
export function normalizeTemplate(raw) {
|
|
16
|
+
const t = isObject(raw) ? raw : {};
|
|
17
|
+
const template = {
|
|
18
|
+
id: String(t.id ?? ''),
|
|
19
|
+
name: String(t.name ?? ''),
|
|
20
|
+
version: TEMPLATE_VERSION,
|
|
21
|
+
domain: String(t.domain ?? ''),
|
|
22
|
+
nodes: (Array.isArray(t.nodes) ? t.nodes : []).filter(isObject).map(normalizeNode),
|
|
23
|
+
wires: (Array.isArray(t.wires) ? t.wires : []).filter(isObject).map(normalizeWire),
|
|
24
|
+
};
|
|
25
|
+
const canvas = normalizeCanvas(t.canvas);
|
|
26
|
+
if (canvas) template.canvas = canvas; // view state, engine-ignored — but it round-trips
|
|
27
|
+
return template;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The POST body: normalized, stable key order, stripped of anything JSON cannot carry. */
|
|
31
|
+
export function serializeTemplate(template) {
|
|
32
|
+
return JSON.parse(JSON.stringify(normalizeTemplate(template)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeNode(node) {
|
|
36
|
+
const out = { id: String(node.id ?? ''), kind: String(node.kind ?? '') };
|
|
37
|
+
if (out.kind === 'agent' && node.key !== undefined) out.key = String(node.key);
|
|
38
|
+
out.x = Number(node.x);
|
|
39
|
+
out.y = Number(node.y);
|
|
40
|
+
out.config = isObject(node.config) ? { ...node.config } : {};
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeWire(wire) {
|
|
45
|
+
const out = {
|
|
46
|
+
id: String(wire.id ?? ''),
|
|
47
|
+
from: { node: String(wire.from?.node ?? ''), port: String(wire.from?.port ?? '') },
|
|
48
|
+
to: { node: String(wire.to?.node ?? ''), port: String(wire.to?.port ?? '') },
|
|
49
|
+
};
|
|
50
|
+
if (isObject(wire.config) && Object.keys(wire.config).length) out.config = { ...wire.config };
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** All three fields or nothing — a half-written canvas is worse than none. */
|
|
55
|
+
function normalizeCanvas(canvas) {
|
|
56
|
+
if (!isObject(canvas)) return null;
|
|
57
|
+
const { x, y, zoom } = canvas;
|
|
58
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(zoom)) return null;
|
|
59
|
+
return { x, y, zoom };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `n_`/`w_` + 8 base36 chars, re-drawn until it misses `taken` (a Set of ids). */
|
|
63
|
+
export function mintId(prefix, taken) {
|
|
64
|
+
const used = taken instanceof Set ? taken : new Set(Array.isArray(taken) ? taken : []);
|
|
65
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
66
|
+
let body = '';
|
|
67
|
+
while (body.length < 8) body += Math.random().toString(36).slice(2);
|
|
68
|
+
const id = `${prefix}${body.slice(0, 8)}`;
|
|
69
|
+
if (!used.has(id)) return id;
|
|
70
|
+
}
|
|
71
|
+
return `${prefix}${Date.now().toString(36).slice(-8)}`; // unreachable in practice
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {'agent'|'task'|'end'|'and'|'or'|'combine'} kind @param {string|null} key */
|
|
75
|
+
export function newNode(kind, key, x = 0, y = 0, taken) {
|
|
76
|
+
const node = { id: mintId('n_', taken), kind };
|
|
77
|
+
if (kind === 'agent' && key) node.key = String(key); // V3: only agent nodes carry a key
|
|
78
|
+
node.x = Number(x) || 0;
|
|
79
|
+
node.y = Number(y) || 0;
|
|
80
|
+
node.config = ARITY_SET.has(kind) ? { arity: 2 } : {};
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function newWire(from, to, config, taken) {
|
|
85
|
+
const wire = {
|
|
86
|
+
id: mintId('w_', taken),
|
|
87
|
+
from: { node: from.node, port: from.port },
|
|
88
|
+
to: { node: to.node, port: to.port },
|
|
89
|
+
};
|
|
90
|
+
if (isObject(config) && Object.keys(config).length) wire.config = { ...config };
|
|
91
|
+
return wire;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Drop legality, derived entirely from the template — which is what lets it see
|
|
96
|
+
* or-homogeneity and existing wires at all. Reasons, in check order:
|
|
97
|
+
* unknown port — the endpoint does not resolve (V5)
|
|
98
|
+
* same node — an output and an input of the SAME card, UNLESS
|
|
99
|
+
* the pair is the self-loop the engine runs: a
|
|
100
|
+
* `when:'blocking'` output into a `loop:true`
|
|
101
|
+
* input (the seeded refiner `revise → revise`).
|
|
102
|
+
* Any other same-card pair is refused here rather
|
|
103
|
+
* than left to V10/V11 after the drop. Checked
|
|
104
|
+
* after the ports resolve because it reads their
|
|
105
|
+
* meta; `V0` is the chip's code, not a validator
|
|
106
|
+
* rule.
|
|
107
|
+
* already connected — UNIFORM single-wire (V7): ANY wired input rejects,
|
|
108
|
+
* agent ports, or `inK`, `end.result` and `await`
|
|
109
|
+
* alike. A duplicate (from,to) pair is necessarily a
|
|
110
|
+
* second wire into a wired input, so it lands here
|
|
111
|
+
* too. Rewiring is remove-then-drop.
|
|
112
|
+
* <out> → <in> type mismatch — per-wire legality (V8); `any` accepts everything
|
|
113
|
+
* or inputs must match: <t> — or homogeneity (V12), mirrored from the resolved
|
|
114
|
+
* payload type; an or accepts ANY type until one
|
|
115
|
+
* in-wire exists.
|
|
116
|
+
* @returns {{ok:true} | {ok:false, code:string, reason:string}}
|
|
117
|
+
*/
|
|
118
|
+
export function canWire({ tpl, portsFn, from, to }) {
|
|
119
|
+
const nodes = Array.isArray(tpl?.nodes) ? tpl.nodes : [];
|
|
120
|
+
const byId = new Map(nodes.filter(isObject).map((n) => [n.id, n]));
|
|
121
|
+
const source = byId.get(from?.node);
|
|
122
|
+
const target = byId.get(to?.node);
|
|
123
|
+
const outPort = source ? findPort(portsOf(portsFn, source), from.port, 'out') : null;
|
|
124
|
+
const inPort = target ? findPort(portsOf(portsFn, target), to.port, 'in') : null;
|
|
125
|
+
if (!outPort || !inPort) return deny('V5', 'unknown port');
|
|
126
|
+
const selfLoop = outPort.when === 'blocking' && inPort.loop === true;
|
|
127
|
+
if (source.id === target.id && !selfLoop) return deny('V0', 'same node');
|
|
128
|
+
if (inboundWires(tpl, to.node, to.port).length) return deny('V7', 'already connected');
|
|
129
|
+
|
|
130
|
+
const sourceType = outPort.type && outPort.type !== 'any'
|
|
131
|
+
? outPort.type
|
|
132
|
+
: (source.kind === 'or' ? resolveOrOutType(tpl, portsFn, source.id) : outPort.type ?? null);
|
|
133
|
+
if (inPort.type !== 'any' && sourceType !== null && sourceType !== inPort.type) {
|
|
134
|
+
return deny('V8', `${sourceType} → ${inPort.type} type mismatch`);
|
|
135
|
+
}
|
|
136
|
+
if (target.kind === 'or') {
|
|
137
|
+
const resolved = resolveOrOutType(tpl, portsFn, target.id);
|
|
138
|
+
if (resolved !== null && sourceType !== null && sourceType !== resolved) {
|
|
139
|
+
return deny('V12', `or inputs must match: ${resolved}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const deny = (code, reason) => ({ ok: false, code, reason });
|
|
146
|
+
|
|
147
|
+
/** Structural edits — always a NEW template; the input is never mutated. */
|
|
148
|
+
export function removeNode(tpl, nodeId) {
|
|
149
|
+
const t = normalizeTemplate(tpl);
|
|
150
|
+
return { ...t, nodes: t.nodes.filter((n) => n.id !== nodeId),
|
|
151
|
+
wires: t.wires.filter((w) => w.from.node !== nodeId && w.to.node !== nodeId) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function removeWire(tpl, wireId) {
|
|
155
|
+
const t = normalizeTemplate(tpl);
|
|
156
|
+
return { ...t, wires: t.wires.filter((w) => w.id !== wireId) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function nodeById(tpl, id) {
|
|
160
|
+
return (Array.isArray(tpl?.nodes) ? tpl.nodes : []).find((n) => n?.id === id) || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function wireById(tpl, id) {
|
|
164
|
+
return (Array.isArray(tpl?.wires) ? tpl.wires : []).find((w) => w?.id === id) || null;
|
|
165
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/shared/graph/thumbnail.mjs
|
|
2
|
+
// A v2 template -> a mini-SVG string for the saved-pipeline rows. Pure and
|
|
3
|
+
// deterministic, and the markup carries NUMBERS ONLY (no ids, no names, no
|
|
4
|
+
// author text), so the result is safe to hand to innerHTML without escaping.
|
|
5
|
+
// The whole scene is drawn in WORLD space inside one <g transform>, which is
|
|
6
|
+
// what lets it reuse the real router instead of a second wire geometry.
|
|
7
|
+
import { graphBounds, fitBounds, nodeSize, portAnchor } from './geometry.mjs';
|
|
8
|
+
import { routeAll, routePathD } from './route.mjs';
|
|
9
|
+
import { portsOf, findPort } from './ports.mjs';
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = { width: 120, height: 64, pad: 8, radius: 3 };
|
|
12
|
+
const round = (v) => Math.round(v * 100) / 100;
|
|
13
|
+
/** A drawable node: an object with a string id. A truthy non-object survived
|
|
14
|
+
* `filter(Boolean)` and indexed under `undefined`, which is what let a
|
|
15
|
+
* non-object wire find a `from` and throw on `w.from.port` below. */
|
|
16
|
+
const isNode = (n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n) && typeof n.id === 'string';
|
|
17
|
+
|
|
18
|
+
export function thumbnailSvg(tpl, portsFn, opts = {}) {
|
|
19
|
+
const { width, height, pad, radius } = { ...DEFAULTS, ...opts };
|
|
20
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
|
|
21
|
+
const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" `
|
|
22
|
+
+ `viewBox="0 0 ${width} ${height}" role="img" aria-hidden="true">`;
|
|
23
|
+
if (!nodes.length) return `${open}</svg>`;
|
|
24
|
+
|
|
25
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
26
|
+
// The cards are the router's obstacles, so the tile shows the same shapes the
|
|
27
|
+
// live canvas does — one wire, one <path>, no arrow markers, no split legs.
|
|
28
|
+
const obstacles = nodes.map((n) => ({ x: Number(n.x) || 0, y: Number(n.y) || 0, ...nodeSize(n, portsOf(portsFn, n)) }));
|
|
29
|
+
const wireList = [];
|
|
30
|
+
for (const w of (Array.isArray(tpl?.wires) ? tpl.wires : [])) {
|
|
31
|
+
const from = byId.get(w?.from?.node);
|
|
32
|
+
const to = byId.get(w?.to?.node);
|
|
33
|
+
if (!from || !to) continue; // dangling (V5) — never draw NaN
|
|
34
|
+
const fromPorts = portsOf(portsFn, from);
|
|
35
|
+
const toPorts = portsOf(portsFn, to);
|
|
36
|
+
if (!findPort(fromPorts, w.from.port, 'out') || !findPort(toPorts, w.to.port, 'in')) continue;
|
|
37
|
+
const a = portAnchor(from, fromPorts, w.from.port, 'out');
|
|
38
|
+
const b = portAnchor(to, toPorts, w.to.port, 'in');
|
|
39
|
+
if (a && b) wireList.push({ id: String(w.id), a, b }); // ids stay INTERNAL: route keys only
|
|
40
|
+
}
|
|
41
|
+
const { routes } = routeAll(wireList, obstacles);
|
|
42
|
+
|
|
43
|
+
// Measured over the DRAWN set, never the raw one: a junk entry the loop above
|
|
44
|
+
// skips must not stretch the fit that positions the cards it does draw. The
|
|
45
|
+
// routed vertices join the union so a detour is never clipped out of the tile.
|
|
46
|
+
const base = graphBounds({ ...tpl, nodes }, portsFn, { pad: 0 });
|
|
47
|
+
let x0 = base.x; let y0 = base.y; let x1 = base.x + base.w; let y1 = base.y + base.h;
|
|
48
|
+
for (const pts of routes.values()) {
|
|
49
|
+
for (const p of pts) { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y); }
|
|
50
|
+
}
|
|
51
|
+
const bounds = { x: x0 - pad, y: y0 - pad, w: x1 - x0 + 2 * pad, h: y1 - y0 + 2 * pad };
|
|
52
|
+
const { z, tx, ty } = fitBounds(bounds, { width, height }, { zoomMin: 0, zoomMax: 1 });
|
|
53
|
+
const stroke = round(1 / (z || 1));
|
|
54
|
+
|
|
55
|
+
// Wires first so the cards sit on top, exactly like the live canvas.
|
|
56
|
+
const paths = [...routes.values()].map((pts) =>
|
|
57
|
+
`<path d="${routePathD(pts)}" fill="none" stroke="#B7B7BC" stroke-width="${stroke}" stroke-linejoin="round"/>`).join('');
|
|
58
|
+
|
|
59
|
+
const rects = nodes.map((node) => {
|
|
60
|
+
const size = nodeSize(node, portsOf(portsFn, node));
|
|
61
|
+
return `<rect x="${round(Number(node.x) || 0)}" y="${round(Number(node.y) || 0)}" `
|
|
62
|
+
+ `width="${round(size.w)}" height="${round(size.h)}" rx="${radius}" `
|
|
63
|
+
+ `fill="#FFFFFF" stroke="#C9C9CE" stroke-width="${stroke}"/>`;
|
|
64
|
+
}).join('');
|
|
65
|
+
|
|
66
|
+
return `${open}<g transform="translate(${round(tx)} ${round(ty)}) scale(${round(z)})">${paths}${rects}</g></svg>`;
|
|
67
|
+
}
|