@liquidcars/atlas 0.1.48 → 0.1.49
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 +50 -0
- package/dist/liquidcars-atlas.js +2352 -785
- package/dist/liquidcars-atlas.js.map +1 -1
- package/package.json +1 -1
- package/src/diagram-svg.js +603 -0
- package/src/diagram-view.js +502 -0
- package/src/document-navigation.js +27 -0
- package/src/liquidcars-atlas.js +547 -118
- package/src/panel-content.js +84 -0
package/package.json
CHANGED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import * as THREE from "./three.module.js";
|
|
2
|
+
|
|
3
|
+
const round = value => Number(value.toFixed(3));
|
|
4
|
+
const escapeXml = value => String(value ?? "").replace(/[&<>"']/g, character => ({
|
|
5
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
6
|
+
})[character]);
|
|
7
|
+
const cross = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
8
|
+
const textureCache = new WeakMap();
|
|
9
|
+
const sourceTextureCache = new WeakMap();
|
|
10
|
+
|
|
11
|
+
function blendHex(background, foreground, opacity) {
|
|
12
|
+
const back = Number.parseInt(background.slice(1), 16);
|
|
13
|
+
const front = Number.parseInt(foreground.slice(1), 16);
|
|
14
|
+
const channel = shift => Math.round(((back >> shift) & 255) * (1 - opacity) + ((front >> shift) & 255) * opacity);
|
|
15
|
+
return `#${[16, 8, 0].map(shift => channel(shift).toString(16).padStart(2, "0")).join("")}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function relationMode(relation) {
|
|
19
|
+
const raw = String(relation.mode || relation.visual || relation.render || relation.type || relation.direction || "")
|
|
20
|
+
.trim().toLowerCase().replace(/[_\s]+/g, "-");
|
|
21
|
+
if (relation.bidirectional === true || ["bidirectional", "both", "two-way", "duplex"].includes(raw)) return "bidirectional";
|
|
22
|
+
if (relation.broken === true || ["broken", "interrupted", "disconnected", "failed"].includes(raw)) return "broken";
|
|
23
|
+
return "directed";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function textureImage(texture) {
|
|
27
|
+
const source = texture?.image;
|
|
28
|
+
if (!source || !(source.naturalWidth || source.width) || !(source.naturalHeight || source.height)) return null;
|
|
29
|
+
if (textureCache.get(texture)?.source === source) return textureCache.get(texture).image;
|
|
30
|
+
const shared = sourceTextureCache.get(source);
|
|
31
|
+
if (shared) {
|
|
32
|
+
textureCache.set(texture, { source, image: shared });
|
|
33
|
+
return shared;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const canvas = globalThis.document.createElement("canvas");
|
|
37
|
+
const sourceWidth = source.naturalWidth || source.width;
|
|
38
|
+
const sourceHeight = source.naturalHeight || source.height;
|
|
39
|
+
const ratio = Math.min(1, 512 / Math.max(sourceWidth, sourceHeight));
|
|
40
|
+
canvas.width = Math.max(1, Math.round(sourceWidth * ratio));
|
|
41
|
+
canvas.height = Math.max(1, Math.round(sourceHeight * ratio));
|
|
42
|
+
canvas.getContext("2d").drawImage(source, 0, 0, canvas.width, canvas.height);
|
|
43
|
+
const image = { uri: canvas.toDataURL("image/png"), width: canvas.width, height: canvas.height };
|
|
44
|
+
textureCache.set(texture, { source, image });
|
|
45
|
+
sourceTextureCache.set(source, image);
|
|
46
|
+
return image;
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function texturedFace(geometry, matrix, texture, image) {
|
|
51
|
+
const position = geometry.getAttribute("position");
|
|
52
|
+
const uv = geometry.getAttribute("uv");
|
|
53
|
+
if (!uv || !position || !image) return null;
|
|
54
|
+
const index = geometry.index;
|
|
55
|
+
const points = [];
|
|
56
|
+
const mapped = [];
|
|
57
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
58
|
+
const vertex = new THREE.Vector3().fromBufferAttribute(position, i).applyMatrix4(matrix);
|
|
59
|
+
points.push(vertex);
|
|
60
|
+
mapped.push({
|
|
61
|
+
x: (uv.getX(i) * texture.repeat.x + texture.offset.x) * image.width,
|
|
62
|
+
y: (1 - uv.getY(i) * texture.repeat.y - texture.offset.y) * image.height,
|
|
63
|
+
px: vertex.x, py: -vertex.y
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// A single affine image can represent a planar face. Curved or warped UVs
|
|
67
|
+
// need their own 2D geometry contract and continue through the mesh fallback.
|
|
68
|
+
const count = index ? index.count : position.count;
|
|
69
|
+
for (let i = 0; i + 2 < count; i += 3) {
|
|
70
|
+
const a = mapped[index ? index.getX(i) : i];
|
|
71
|
+
const b = mapped[index ? index.getX(i + 1) : i + 1];
|
|
72
|
+
const c = mapped[index ? index.getX(i + 2) : i + 2];
|
|
73
|
+
const determinant = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
|
|
74
|
+
if (Math.abs(determinant) < 1e-6) continue;
|
|
75
|
+
const A = ((b.px - a.px) * (c.y - a.y) - (c.px - a.px) * (b.y - a.y)) / determinant;
|
|
76
|
+
const C = ((c.px - a.px) * (b.x - a.x) - (b.px - a.px) * (c.x - a.x)) / determinant;
|
|
77
|
+
const B = ((b.py - a.py) * (c.y - a.y) - (c.py - a.py) * (b.y - a.y)) / determinant;
|
|
78
|
+
const D = ((c.py - a.py) * (b.x - a.x) - (b.py - a.py) * (c.x - a.x)) / determinant;
|
|
79
|
+
const E = a.px - A * a.x - C * a.y;
|
|
80
|
+
const F = a.py - B * a.x - D * a.y;
|
|
81
|
+
if (mapped.some(p => Math.hypot(A * p.x + C * p.y + E - p.px, B * p.x + D * p.y + F - p.py) > 1e-3)) return null;
|
|
82
|
+
return { image, points, transform: [A, B, C, D, E, F], depth: points.reduce((sum, p) => sum + p.z, 0) / points.length };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function convexHull(points) {
|
|
88
|
+
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
|
|
89
|
+
if (sorted.length < 3) return sorted;
|
|
90
|
+
const lower = [];
|
|
91
|
+
const upper = [];
|
|
92
|
+
for (const point of sorted) {
|
|
93
|
+
while (lower.length > 1 && cross(lower.at(-2), lower.at(-1), point) <= 0) lower.pop();
|
|
94
|
+
lower.push(point);
|
|
95
|
+
}
|
|
96
|
+
for (const point of sorted.reverse()) {
|
|
97
|
+
while (upper.length > 1 && cross(upper.at(-2), upper.at(-1), point) <= 0) upper.pop();
|
|
98
|
+
upper.push(point);
|
|
99
|
+
}
|
|
100
|
+
return lower.slice(0, -1).concat(upper.slice(0, -1));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function frontFaces(node, independent = false) {
|
|
104
|
+
const root = node.mesh;
|
|
105
|
+
if (!root) return null;
|
|
106
|
+
const opacityOf = material => independent
|
|
107
|
+
? (material === node.material ? 1 : material.userData?.atlasBaseOpacity ?? material.opacity ?? 1)
|
|
108
|
+
: material.opacity ?? 1;
|
|
109
|
+
node.group.updateWorldMatrix(true, true);
|
|
110
|
+
const inverse = node.group.matrixWorld.clone().invert();
|
|
111
|
+
const triangles = [];
|
|
112
|
+
const textured = [];
|
|
113
|
+
const points = [];
|
|
114
|
+
let unsupportedTexture = false;
|
|
115
|
+
root.traverse(mesh => {
|
|
116
|
+
if (!mesh.isMesh || !mesh.visible || mesh === node.pickMesh) return;
|
|
117
|
+
const geometry = mesh.geometry;
|
|
118
|
+
const position = geometry?.getAttribute?.("position");
|
|
119
|
+
if (!position) return;
|
|
120
|
+
const matrix = inverse.clone().multiply(mesh.matrixWorld);
|
|
121
|
+
const index = geometry.index;
|
|
122
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
123
|
+
const count = index ? index.count : position.count;
|
|
124
|
+
const v = new THREE.Vector3();
|
|
125
|
+
const p = vertex => v.fromBufferAttribute(position, vertex).applyMatrix4(matrix).clone();
|
|
126
|
+
if (materials.length === 1 && materials[0]?.map) {
|
|
127
|
+
const face = texturedFace(geometry, matrix, materials[0].map, textureImage(materials[0].map));
|
|
128
|
+
if (face) {
|
|
129
|
+
face.opacity = opacityOf(materials[0]);
|
|
130
|
+
textured.push(face);
|
|
131
|
+
points.push(...face.points);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
unsupportedTexture = true;
|
|
135
|
+
}
|
|
136
|
+
for (let i = 0; i + 2 < count; i += 3) {
|
|
137
|
+
const materialIndex = geometry.groups.find(group => i >= group.start && i < group.start + group.count)?.materialIndex || 0;
|
|
138
|
+
const material = materials[materialIndex] || materials[0];
|
|
139
|
+
if (!material?.visible || material.colorWrite === false || opacityOf(material) <= 0) continue;
|
|
140
|
+
if (material.map) unsupportedTexture = true;
|
|
141
|
+
const a = p(index ? index.getX(i) : i);
|
|
142
|
+
const b = p(index ? index.getX(i + 1) : i + 1);
|
|
143
|
+
const c = p(index ? index.getX(i + 2) : i + 2);
|
|
144
|
+
const area = cross(a, b, c);
|
|
145
|
+
if (Math.abs(area) < 1e-9 || (area < 0 && material.side !== THREE.DoubleSide && material.side !== THREE.BackSide)) continue;
|
|
146
|
+
if (area > 0 && material.side === THREE.BackSide) continue;
|
|
147
|
+
points.push(a, b, c);
|
|
148
|
+
triangles.push({ vertices: [a, b, c], depth: (a.z + b.z + c.z) / 3,
|
|
149
|
+
color: `#${material.color.getHexString()}`, opacity: opacityOf(material) });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
if (!triangles.length && !textured.length) return null;
|
|
153
|
+
triangles.sort((a, b) => a.depth - b.depth);
|
|
154
|
+
const bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
|
155
|
+
for (const point of points) {
|
|
156
|
+
bounds.minX = Math.min(bounds.minX, point.x);
|
|
157
|
+
bounds.maxX = Math.max(bounds.maxX, point.x);
|
|
158
|
+
bounds.minY = Math.min(bounds.minY, point.y);
|
|
159
|
+
bounds.maxY = Math.max(bounds.maxY, point.y);
|
|
160
|
+
}
|
|
161
|
+
const hull = convexHull(points.map(p => ({ x: p.x, y: p.y })));
|
|
162
|
+
return { triangles, textured, bounds, hull, unsupportedTexture };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function rayBoundary(item, towards) {
|
|
166
|
+
const dx = towards.x - item.x;
|
|
167
|
+
const dy = towards.y - item.y;
|
|
168
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
169
|
+
const direction = { x: dx / length, y: dy / length };
|
|
170
|
+
const polygon = item.hull.map(p => ({ x: item.x + p.x * item.scale, y: item.y - p.y * item.scale }));
|
|
171
|
+
let closest = Infinity;
|
|
172
|
+
for (let i = 0; i < polygon.length; i += 1) {
|
|
173
|
+
const a = polygon[i];
|
|
174
|
+
const b = polygon[(i + 1) % polygon.length];
|
|
175
|
+
const edge = { x: b.x - a.x, y: b.y - a.y };
|
|
176
|
+
const determinant = direction.x * edge.y - direction.y * edge.x;
|
|
177
|
+
if (Math.abs(determinant) < 1e-9) continue;
|
|
178
|
+
const offset = { x: a.x - item.x, y: a.y - item.y };
|
|
179
|
+
const distance = (offset.x * edge.y - offset.y * edge.x) / determinant;
|
|
180
|
+
const fraction = (offset.x * direction.y - offset.y * direction.x) / determinant;
|
|
181
|
+
if (distance >= 0 && fraction >= 0 && fraction <= 1) closest = Math.min(closest, distance);
|
|
182
|
+
}
|
|
183
|
+
const distance = Number.isFinite(closest) ? closest + 2 : 0;
|
|
184
|
+
return { x: round(item.x + direction.x * distance), y: round(item.y + direction.y * distance) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function separateOverlaps(items, gap) {
|
|
188
|
+
for (let iteration = 0; iteration < 96; iteration += 1) {
|
|
189
|
+
let moved = false;
|
|
190
|
+
for (let i = 0; i < items.length; i += 1) for (let j = i + 1; j < items.length; j += 1) {
|
|
191
|
+
const a = items[i];
|
|
192
|
+
const b = items[j];
|
|
193
|
+
const boundsA = itemBounds(a, a.labelLines.length > 0);
|
|
194
|
+
const boundsB = itemBounds(b, b.labelLines.length > 0);
|
|
195
|
+
const x = Math.min(boundsA.maxX, boundsB.maxX) - Math.max(boundsA.minX, boundsB.minX) + gap;
|
|
196
|
+
const y = Math.min(boundsA.maxY, boundsB.maxY) - Math.max(boundsA.minY, boundsB.minY) + gap;
|
|
197
|
+
if (x <= 0 || y <= 0) continue;
|
|
198
|
+
moved = true;
|
|
199
|
+
if (x < y) {
|
|
200
|
+
const push = x / 2 + .01;
|
|
201
|
+
const sign = a.x <= b.x ? 1 : -1;
|
|
202
|
+
a.x -= sign * push;
|
|
203
|
+
b.x += sign * push;
|
|
204
|
+
} else {
|
|
205
|
+
const push = y / 2 + .01;
|
|
206
|
+
const sign = a.y <= b.y ? 1 : -1;
|
|
207
|
+
a.y -= sign * push;
|
|
208
|
+
b.y += sign * push;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!moved) break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function labelLines(name) {
|
|
216
|
+
const lines = [];
|
|
217
|
+
for (const word of String(name).split(/\s+/).filter(Boolean)) {
|
|
218
|
+
const previous = lines.at(-1);
|
|
219
|
+
if (previous && previous.length + word.length + 1 <= 20) lines[lines.length - 1] += ` ${word}`;
|
|
220
|
+
else for (let i = 0; i < word.length; i += 20) lines.push(word.slice(i, i + 20));
|
|
221
|
+
}
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function itemBounds(item, labels) {
|
|
226
|
+
const halfLabel = labels ? Math.max(...item.labelLines.map(line => line.length), 0) * item.labelFontSize * .31 : 0;
|
|
227
|
+
return {
|
|
228
|
+
minX: item.x + Math.min(item.bounds.minX * item.scale, -halfLabel),
|
|
229
|
+
maxX: item.x + Math.max(item.bounds.maxX * item.scale, halfLabel),
|
|
230
|
+
minY: item.y - item.bounds.maxY * item.scale,
|
|
231
|
+
maxY: item.y - item.bounds.minY * item.scale + (labels ? item.labelLines.length * item.labelLineHeight + item.labelGap : 0)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function compactProjectedPositions(items, nodes, scale) {
|
|
236
|
+
if (items.length < 2) return;
|
|
237
|
+
const sizes = items.map(item => Math.max(item.width, item.height)).sort((a, b) => a - b);
|
|
238
|
+
const minimumSpan = Math.max(80, sizes[Math.floor(sizes.length / 2)]) * Math.sqrt(items.length) * 1.5;
|
|
239
|
+
// Turning a deep model can turn depth into thousands of empty diagram pixels.
|
|
240
|
+
// Bound only the center positions, leaving each face and label at full size.
|
|
241
|
+
for (const [diagramAxis, worldAxis] of [["x", "x"], ["y", "y"]]) {
|
|
242
|
+
const projected = items.map(item => item[diagramAxis]);
|
|
243
|
+
const first = Math.min(...projected), last = Math.max(...projected);
|
|
244
|
+
const span = last - first;
|
|
245
|
+
if (!span) continue;
|
|
246
|
+
const frontal = items.map(item => nodes.get(item.id).group.position[worldAxis] * scale);
|
|
247
|
+
const frontalSpan = Math.max(...frontal) - Math.min(...frontal);
|
|
248
|
+
const factor = Math.min(1, Math.max(frontalSpan, minimumSpan) / span);
|
|
249
|
+
const middle = (first + last) / 2;
|
|
250
|
+
for (const item of items) item[diagramAxis] = middle + (item[diagramAxis] - middle) * factor;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function compactWithinContainers(items, nodes, scale) {
|
|
255
|
+
const groups = new Map();
|
|
256
|
+
for (const item of items) {
|
|
257
|
+
let node = nodes.get(item.id), root = null, guard = 0;
|
|
258
|
+
while (node?.parent && guard++ < nodes.size) {
|
|
259
|
+
node = nodes.get(node.parent);
|
|
260
|
+
if (node?.type === "container" || node?.type === "shell") root = node.id;
|
|
261
|
+
}
|
|
262
|
+
if (!root) continue;
|
|
263
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
264
|
+
groups.get(root).push(item);
|
|
265
|
+
}
|
|
266
|
+
for (const group of groups.values()) compactProjectedPositions(group, nodes, scale);
|
|
267
|
+
return groups;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function compactRootBlocks(blocks, gap, aspect) {
|
|
271
|
+
if (blocks.length < 2) return null;
|
|
272
|
+
const left = Math.min(...blocks.map(block => block.box.minX));
|
|
273
|
+
const right = Math.max(...blocks.map(block => block.box.maxX));
|
|
274
|
+
const top = Math.min(...blocks.map(block => block.box.minY));
|
|
275
|
+
const bottom = Math.max(...blocks.map(block => block.box.maxY));
|
|
276
|
+
const currentWidth = right - left, currentHeight = bottom - top;
|
|
277
|
+
const score = (width, height) => Math.max(width / aspect, height);
|
|
278
|
+
const area = blocks.reduce((sum, block) => sum +
|
|
279
|
+
(block.box.maxX - block.box.minX + gap) * (block.box.maxY - block.box.minY + gap), 0);
|
|
280
|
+
const target = Math.sqrt(area * aspect);
|
|
281
|
+
const orders = [
|
|
282
|
+
[...blocks].sort((a, b) => (a.box.minY + a.box.maxY) - (b.box.minY + b.box.maxY)),
|
|
283
|
+
[...blocks].sort((a, b) => (a.box.minX + a.box.maxX) - (b.box.minX + b.box.maxX))
|
|
284
|
+
];
|
|
285
|
+
let best = null;
|
|
286
|
+
for (const order of orders) for (const ratio of [.8, 1, 1.2, 1.5, 1.9]) {
|
|
287
|
+
const limit = target * ratio;
|
|
288
|
+
let x = 0, y = 0, rowHeight = 0, width = 0;
|
|
289
|
+
const placements = new Map();
|
|
290
|
+
for (const block of order) {
|
|
291
|
+
const w = block.box.maxX - block.box.minX;
|
|
292
|
+
const h = block.box.maxY - block.box.minY;
|
|
293
|
+
if (x && x + w > limit) { x = 0; y += rowHeight + gap; rowHeight = 0; }
|
|
294
|
+
placements.set(block, { x, y });
|
|
295
|
+
width = Math.max(width, x + w);
|
|
296
|
+
rowHeight = Math.max(rowHeight, h);
|
|
297
|
+
x += w + gap;
|
|
298
|
+
}
|
|
299
|
+
const height = y + rowHeight;
|
|
300
|
+
const candidate = { placements, width, height, score: score(width, height) };
|
|
301
|
+
if (!best || candidate.score < best.score) best = candidate;
|
|
302
|
+
}
|
|
303
|
+
if (best.score >= score(currentWidth, currentHeight) * .88
|
|
304
|
+
|| best.width * best.height >= currentWidth * currentHeight * 1.15) return null;
|
|
305
|
+
return best.placements;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Vector front views of the already laid out 3D nodes, projected onto one orthographic plane. */
|
|
309
|
+
export function renderDiagramSvg(atlas, options = {}) {
|
|
310
|
+
if (!atlas?.nodes || !atlas?.camera) throw new TypeError("An initialized Atlas instance is required.");
|
|
311
|
+
const scale = Math.max(1, Number(options.scale) || 80);
|
|
312
|
+
const gap = Math.max(0, Number(options.gap) || 0);
|
|
313
|
+
const observerScale = Number(options.observerScale) > 0 ? Number(options.observerScale) : null;
|
|
314
|
+
// Fit can start from a huge rotated page. Without a ceiling, its tiny preview
|
|
315
|
+
// scale inflates labels and container margins, making the next page even larger.
|
|
316
|
+
const labelScale = observerScale ? Math.max(.2, observerScale) : null;
|
|
317
|
+
const labelFontSize = labelScale ? 12 / labelScale : 18;
|
|
318
|
+
const labelLineHeight = labelScale ? 15 / labelScale : 21;
|
|
319
|
+
const labelGap = labelScale ? 4 / labelScale : 7;
|
|
320
|
+
// The diagram starts from the model's canonical front, independently of the
|
|
321
|
+
// interactive 3D camera. A caller can supply another orientation explicitly.
|
|
322
|
+
const view = options.cameraView || { position: [0, 0, 1], target: [0, 0, 0] };
|
|
323
|
+
const camera = new THREE.PerspectiveCamera();
|
|
324
|
+
camera.position.fromArray(view.position);
|
|
325
|
+
camera.lookAt(new THREE.Vector3().fromArray(view.target));
|
|
326
|
+
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
|
|
327
|
+
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
|
|
328
|
+
const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(camera.quaternion);
|
|
329
|
+
const target = new THREE.Vector3().fromArray(view.target);
|
|
330
|
+
const include = options.includeIds ? new Set(options.includeIds) : null;
|
|
331
|
+
const groups = options.relationGroups ? new Set(options.relationGroups) : null;
|
|
332
|
+
const focused = options.focus?.ids?.length ? new Set(options.focus.ids) : null;
|
|
333
|
+
const hiddenByFocus = id => focused && options.focus.effect === "hide" && !focused.has(id);
|
|
334
|
+
const dimmed = id => focused && !focused.has(id) ? round(options.focus.opacity ?? .2) : 1;
|
|
335
|
+
const reflowFocus = options.focus?.layout === "reflow";
|
|
336
|
+
const isContainer = node => node.type === "container" || node.type === "shell";
|
|
337
|
+
const isClosed = node => isContainer(node) && atlas.open && !atlas.open.has(node.id);
|
|
338
|
+
const independent = atlas.viewMode === "2d" && typeof atlas._isVisible === "function";
|
|
339
|
+
const isVisible = node => independent ? atlas._isVisible(node) : node.group?.visible;
|
|
340
|
+
const depth = Array.isArray(options.depth) ? options.depth : [-Infinity, Infinity];
|
|
341
|
+
let items = [];
|
|
342
|
+
const warnings = [];
|
|
343
|
+
for (const node of atlas.nodes.values()) {
|
|
344
|
+
if (include && !include.has(node.id)) continue;
|
|
345
|
+
if (!isVisible(node) || (isContainer(node) && !isClosed(node))) continue;
|
|
346
|
+
if (reflowFocus && hiddenByFocus(node.id)) continue;
|
|
347
|
+
const origin = node.group.position.clone().sub(target);
|
|
348
|
+
const distance = origin.dot(forward);
|
|
349
|
+
if (distance < depth[0] || distance > depth[1]) continue;
|
|
350
|
+
const faceCache = options.faceCache;
|
|
351
|
+
let face = faceCache?.has(node) ? faceCache.get(node) : frontFaces(node, independent);
|
|
352
|
+
if (faceCache && !faceCache.has(node)) faceCache.set(node, face);
|
|
353
|
+
if (!face) { warnings.push(`${node.id}: no vector front face`); continue; }
|
|
354
|
+
// The 3D renderer can leave an opened shell's material translucent when
|
|
355
|
+
// switching to 2D. A closed container is a solid diagram entity.
|
|
356
|
+
if (isClosed(node)) face = {
|
|
357
|
+
...face,
|
|
358
|
+
triangles: face.triangles.map(triangle => ({ ...triangle, opacity: 1 })),
|
|
359
|
+
textured: face.textured.map(triangle => ({ ...triangle, opacity: 1 }))
|
|
360
|
+
};
|
|
361
|
+
if (face.unsupportedTexture) warnings.push(`${node.id}: some image texture could not be embedded`);
|
|
362
|
+
const lines = options.labels === false ? [] : labelLines(node.name || "");
|
|
363
|
+
const geometryWidth = (face.bounds.maxX - face.bounds.minX) * scale;
|
|
364
|
+
const geometryHeight = (face.bounds.maxY - face.bounds.minY) * scale;
|
|
365
|
+
items.push({ id: node.id, name: node.name || "", x: origin.dot(right) * scale,
|
|
366
|
+
y: -origin.dot(up) * scale, scale, depth: distance, ...face, labelLines: lines,
|
|
367
|
+
labelFontSize, labelLineHeight, labelGap,
|
|
368
|
+
width: geometryWidth, height: geometryHeight });
|
|
369
|
+
}
|
|
370
|
+
if (options.cameraFrame && items.length) {
|
|
371
|
+
const { zoom = 1, aspect = 1 } = options.cameraFrame;
|
|
372
|
+
const ratio = Math.max(.25, Math.min(8, Number(zoom) || 1));
|
|
373
|
+
const frameAspect = Math.max(.25, Math.min(5, Number(aspect) || 1));
|
|
374
|
+
const positions = [...atlas.nodes.values()].filter(node => isVisible(node)
|
|
375
|
+
&& (!isContainer(node) || isClosed(node))).map(node => node.group.position);
|
|
376
|
+
const sceneCenter = new THREE.Vector3(...["x", "y", "z"].map(axis => (
|
|
377
|
+
Math.min(...positions.map(point => point[axis])) + Math.max(...positions.map(point => point[axis]))
|
|
378
|
+
) / 2)).sub(target);
|
|
379
|
+
const centerX = sceneCenter.dot(right) * scale;
|
|
380
|
+
const centerY = -sceneCenter.dot(up) * scale;
|
|
381
|
+
const bounds = items.map(item => itemBounds(item, false));
|
|
382
|
+
const halfX = Math.max(80, ...bounds.flatMap(box => [Math.abs(box.minX - centerX), Math.abs(box.maxX - centerX)]));
|
|
383
|
+
const halfY = Math.max(80, ...bounds.flatMap(box => [Math.abs(box.minY - centerY), Math.abs(box.maxY - centerY)]));
|
|
384
|
+
const halfHeight = Math.max(halfY, halfX / frameAspect) * 1.1 / ratio;
|
|
385
|
+
const halfWidth = halfHeight * frameAspect;
|
|
386
|
+
items = items.filter(item => {
|
|
387
|
+
const box = itemBounds(item, false);
|
|
388
|
+
return box.minX <= halfWidth && box.maxX >= -halfWidth
|
|
389
|
+
&& box.minY <= halfHeight && box.maxY >= -halfHeight;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const itemGroups = compactWithinContainers(items, atlas.nodes, scale);
|
|
393
|
+
compactProjectedPositions(items, atlas.nodes, scale);
|
|
394
|
+
if (forward.z < .97 && itemGroups.size) {
|
|
395
|
+
const groupedItems = new Set([...itemGroups.values()].flat());
|
|
396
|
+
for (const group of itemGroups.values()) separateOverlaps(group, gap);
|
|
397
|
+
separateOverlaps(items.filter(item => !groupedItems.has(item)), gap);
|
|
398
|
+
} else separateOverlaps(items, gap);
|
|
399
|
+
const byId = new Map(items.map(item => [item.id, item]));
|
|
400
|
+
const activeRelations = options.relationIndexes == null ? null : new Set(options.relationIndexes);
|
|
401
|
+
const visible = atlas.relations.filter((relation, index) => byId.has(relation.from) && byId.has(relation.to)
|
|
402
|
+
&& (!activeRelations || activeRelations.has(index))
|
|
403
|
+
&& (!groups || groups.has(relation.group || "default"))
|
|
404
|
+
&& (options.relationFocusId == null || relation.from === options.relationFocusId || relation.to === options.relationFocusId)
|
|
405
|
+
&& !(hiddenByFocus(relation.from) || hiddenByFocus(relation.to))
|
|
406
|
+
&& options.relations !== "none"
|
|
407
|
+
&& (options.relations !== "within" || atlas.nodes.get(relation.from)?.parent === atlas.nodes.get(relation.to)?.parent));
|
|
408
|
+
const padding = Math.max(12, Number(options.padding) || 24);
|
|
409
|
+
const containers = [...atlas.nodes.values()].filter(node => isContainer(node) && isVisible(node) && !isClosed(node)
|
|
410
|
+
&& !(reflowFocus && hiddenByFocus(node.id)));
|
|
411
|
+
const containerFontSize = labelScale ? 11 / labelScale : 18;
|
|
412
|
+
const sidePadding = labelScale ? Math.max(23, 10 / labelScale) : 23;
|
|
413
|
+
const topPadding = labelScale ? Math.max(41, 22 / labelScale) : 41;
|
|
414
|
+
const bottomPadding = labelScale ? Math.max(23, 10 / labelScale) : 23;
|
|
415
|
+
const containerDepth = node => {
|
|
416
|
+
let level = 0, parent = node.parent, guard = 0;
|
|
417
|
+
while (parent && guard++ < atlas.nodes.size) { level++; parent = atlas.nodes.get(parent)?.parent; }
|
|
418
|
+
return level;
|
|
419
|
+
};
|
|
420
|
+
const buildBoxes = () => {
|
|
421
|
+
const boxes = new Map();
|
|
422
|
+
for (const node of containers.sort((a, b) => containerDepth(b) - containerDepth(a))) {
|
|
423
|
+
const members = [
|
|
424
|
+
...items.filter(item => atlas.nodes.get(item.id)?.parent === node.id).map(item => itemBounds(item, options.labels !== false)),
|
|
425
|
+
...containers.filter(child => child.parent === node.id && boxes.has(child.id)).map(child => boxes.get(child.id))
|
|
426
|
+
];
|
|
427
|
+
if (!members.length) continue;
|
|
428
|
+
let minX = Math.min(...members.map(box => box.minX)) - sidePadding;
|
|
429
|
+
let maxX = Math.max(...members.map(box => box.maxX)) + sidePadding;
|
|
430
|
+
const titleWidth = (node.name || node.id).length * containerFontSize * .72 + 2 * sidePadding;
|
|
431
|
+
if (maxX - minX < titleWidth) {
|
|
432
|
+
const extra = (titleWidth - (maxX - minX)) / 2;
|
|
433
|
+
minX -= extra;
|
|
434
|
+
maxX += extra;
|
|
435
|
+
}
|
|
436
|
+
boxes.set(node.id, { node, minX, maxX,
|
|
437
|
+
minY: Math.min(...members.map(box => box.minY)) - topPadding,
|
|
438
|
+
maxY: Math.max(...members.map(box => box.maxY)) + bottomPadding });
|
|
439
|
+
}
|
|
440
|
+
return boxes;
|
|
441
|
+
};
|
|
442
|
+
let boxes = buildBoxes();
|
|
443
|
+
const rootGroups = containers.filter(node => boxes.has(node.id) && !boxes.has(node.parent));
|
|
444
|
+
const rootMembers = new Map(rootGroups.map(root => [root.id, items.filter(item => {
|
|
445
|
+
let parent = atlas.nodes.get(item.id)?.parent, guard = 0;
|
|
446
|
+
while (parent && guard++ < atlas.nodes.size) {
|
|
447
|
+
if (parent === root.id) return true;
|
|
448
|
+
parent = atlas.nodes.get(parent)?.parent;
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
})]));
|
|
452
|
+
const groupGap = labelScale ? 8 / labelScale : gap;
|
|
453
|
+
for (let iteration = 0; iteration < 24; iteration += 1) {
|
|
454
|
+
let moved = false;
|
|
455
|
+
for (let i = 0; i < rootGroups.length; i += 1) for (let j = i + 1; j < rootGroups.length; j += 1) {
|
|
456
|
+
const a = boxes.get(rootGroups[i].id), b = boxes.get(rootGroups[j].id);
|
|
457
|
+
const overlapX = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX) + groupGap;
|
|
458
|
+
const overlapY = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY) + groupGap;
|
|
459
|
+
if (overlapX <= 0 || overlapY <= 0) continue;
|
|
460
|
+
const axis = overlapX < overlapY ? "x" : "y";
|
|
461
|
+
const centerA = (a[axis === "x" ? "minX" : "minY"] + a[axis === "x" ? "maxX" : "maxY"]) / 2;
|
|
462
|
+
const centerB = (b[axis === "x" ? "minX" : "minY"] + b[axis === "x" ? "maxX" : "maxY"]) / 2;
|
|
463
|
+
const push = (axis === "x" ? overlapX : overlapY) / 2 + .01;
|
|
464
|
+
const sign = centerA <= centerB ? 1 : -1;
|
|
465
|
+
for (const item of rootMembers.get(rootGroups[i].id)) item[axis] -= sign * push;
|
|
466
|
+
for (const item of rootMembers.get(rootGroups[j].id)) item[axis] += sign * push;
|
|
467
|
+
boxes = buildBoxes();
|
|
468
|
+
moved = true;
|
|
469
|
+
}
|
|
470
|
+
if (!moved) break;
|
|
471
|
+
}
|
|
472
|
+
const grouped = new Set([...rootMembers.values()].flat());
|
|
473
|
+
const blocks = rootGroups.map(root => ({ box: boxes.get(root.id), members: rootMembers.get(root.id) }));
|
|
474
|
+
for (const item of items) if (!grouped.has(item)) {
|
|
475
|
+
blocks.push({ box: itemBounds(item, options.labels !== false), members: [item] });
|
|
476
|
+
}
|
|
477
|
+
// Keep the canonical front's authored grouping. A turned camera can spread
|
|
478
|
+
// those same root groups across a very large page, so arrange the already
|
|
479
|
+
// resolved group rectangles into a compact reading surface.
|
|
480
|
+
const placement = forward.z < .97 ? compactRootBlocks(blocks, groupGap,
|
|
481
|
+
Math.max(.5, Math.min(3, Number(options.cameraFrame?.aspect) || 1.7))) : null;
|
|
482
|
+
if (placement) {
|
|
483
|
+
for (const block of blocks) {
|
|
484
|
+
const position = placement.get(block);
|
|
485
|
+
for (const item of block.members) {
|
|
486
|
+
item.x += position.x - block.box.minX;
|
|
487
|
+
item.y += position.y - block.box.minY;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
boxes = buildBoxes();
|
|
491
|
+
}
|
|
492
|
+
// Captured diagrams keep the user's chosen 2D arrangement independently of
|
|
493
|
+
// later changes to the packing heuristic or viewport aspect.
|
|
494
|
+
if (options.placements && typeof options.placements === "object") {
|
|
495
|
+
for (const item of items) {
|
|
496
|
+
const position = options.placements[item.id];
|
|
497
|
+
if (Array.isArray(position) && position.length === 2 && position.every(Number.isFinite)) {
|
|
498
|
+
item.x = position[0];
|
|
499
|
+
item.y = position[1];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
boxes = buildBoxes();
|
|
503
|
+
}
|
|
504
|
+
const extent = [...items.map(item => itemBounds(item, options.labels !== false)), ...boxes.values()];
|
|
505
|
+
const left = (extent.length ? Math.min(...extent.map(box => box.minX)) : 0) - padding;
|
|
506
|
+
const top = (extent.length ? Math.min(...extent.map(box => box.minY)) : 0) - padding;
|
|
507
|
+
const rightEdge = (extent.length ? Math.max(...extent.map(box => box.maxX)) : 0) + padding;
|
|
508
|
+
const bottom = (extent.length ? Math.max(...extent.map(box => box.maxY)) : 0) + padding;
|
|
509
|
+
const width = Math.max(1, Math.ceil(rightEdge - left));
|
|
510
|
+
const height = Math.max(1, Math.ceil(bottom - top));
|
|
511
|
+
if (options.measureOnly) return { width, height, warnings };
|
|
512
|
+
const fmt = p => `${round(p.x - left)} ${round(p.y - top)}`;
|
|
513
|
+
const relations = visible.map(relation => {
|
|
514
|
+
const a = byId.get(relation.from);
|
|
515
|
+
const b = byId.get(relation.to);
|
|
516
|
+
if (a === b) return "";
|
|
517
|
+
const start = rayBoundary(a, b);
|
|
518
|
+
const end = rayBoundary(b, a);
|
|
519
|
+
const mode = relationMode(relation);
|
|
520
|
+
const id = escapeXml(relation.id || `${relation.from}-${relation.to}`);
|
|
521
|
+
const opacity = Math.min(dimmed(relation.from), dimmed(relation.to));
|
|
522
|
+
const alpha = opacity < 1 ? ` opacity="${opacity}"` : "";
|
|
523
|
+
if (mode === "broken") {
|
|
524
|
+
const length = Math.hypot(end.x - start.x, end.y - start.y);
|
|
525
|
+
const gap = Math.min(16, length * .3);
|
|
526
|
+
const ratio = length ? gap / (2 * length) : 0;
|
|
527
|
+
const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
528
|
+
const before = { x: midpoint.x - (end.x - start.x) * ratio,
|
|
529
|
+
y: midpoint.y - (end.y - start.y) * ratio };
|
|
530
|
+
const after = { x: midpoint.x + (end.x - start.x) * ratio,
|
|
531
|
+
y: midpoint.y + (end.y - start.y) * ratio };
|
|
532
|
+
return `<path data-relation="${id}" data-mode="broken" d="M${fmt(start)} L${fmt(before)} M${fmt(after)} L${fmt(end)}" fill="none" stroke="#c43748" stroke-width="2.5" stroke-dasharray="6 4" stroke-linecap="round"${alpha}/>`;
|
|
533
|
+
}
|
|
534
|
+
return `<path data-relation="${id}" data-mode="${mode}" d="M${fmt(start)} L${fmt(end)}" fill="none" stroke="${escapeXml(options.connectionColor || "#58788d")}" stroke-width="2"${mode === "bidirectional" ? ' marker-start="url(#atlas-diagram-arrow)"' : ""} marker-end="url(#atlas-diagram-arrow)"${alpha}/>`;
|
|
535
|
+
}).join("");
|
|
536
|
+
const canvasColor = options.canvasColor || "#f5f5f5";
|
|
537
|
+
const boxBackdrops = new Map();
|
|
538
|
+
const containersSvg = [...boxes.values()].sort((a, b) => containerDepth(a.node) - containerDepth(b.node)).map(box => {
|
|
539
|
+
if (hiddenByFocus(box.node.id)) return "";
|
|
540
|
+
const baseColor = box.node.material?.color || new THREE.Color("#58788d");
|
|
541
|
+
const color = `#${baseColor.getHexString()}`;
|
|
542
|
+
const ink = `#${baseColor.clone().lerp(new THREE.Color("#254253"), .48).getHexString()}`;
|
|
543
|
+
let parent = box.node.parent, guard = 0;
|
|
544
|
+
while (parent && !boxBackdrops.has(parent) && guard++ < atlas.nodes.size) parent = atlas.nodes.get(parent)?.parent;
|
|
545
|
+
const parentBackdrop = boxBackdrops.get(parent) || canvasColor;
|
|
546
|
+
const backdrop = blendHex(parentBackdrop, color, .14);
|
|
547
|
+
boxBackdrops.set(box.node.id, backdrop);
|
|
548
|
+
const x = round(box.minX - left);
|
|
549
|
+
const y = round(box.minY - top);
|
|
550
|
+
const labelX = labelScale ? round(8 / labelScale) : 12;
|
|
551
|
+
const labelY = labelScale ? round(14 / labelScale) : 26;
|
|
552
|
+
const fill = options.maskGrid ? `fill="${backdrop}"` : `fill="${color}" fill-opacity="0.14"`;
|
|
553
|
+
const underlay = options.maskGrid && dimmed(box.node.id) < 1
|
|
554
|
+
? `<rect x="${x}" y="${y}" width="${round(box.maxX - box.minX)}" height="${round(box.maxY - box.minY)}" rx="12" fill="${parentBackdrop}"/>` : "";
|
|
555
|
+
return `${underlay}<g data-container="${escapeXml(box.node.id)}"${dimmed(box.node.id) < 1 ? ` opacity="${dimmed(box.node.id)}"` : ""}><title>${escapeXml(box.node.name || box.node.id)}</title><rect x="${x}" y="${y}" width="${round(box.maxX - box.minX)}" height="${round(box.maxY - box.minY)}" rx="12" ${fill} stroke="${ink}" stroke-opacity="0.72" stroke-width="2"/><g data-diagram-label="container" transform="translate(${x} ${y})"><text x="${labelX}" y="${labelY}" font-family="system-ui,sans-serif" font-size="${round(containerFontSize)}" font-weight="600" fill="${ink}">${escapeXml(box.node.name || box.node.id)}</text></g></g>`;
|
|
556
|
+
}).join("");
|
|
557
|
+
const entities = items.sort((a, b) => a.depth - b.depth).map(item => {
|
|
558
|
+
if (hiddenByFocus(item.id)) return "";
|
|
559
|
+
let parent = atlas.nodes.get(item.id)?.parent, guard = 0;
|
|
560
|
+
while (parent && !boxBackdrops.has(parent) && guard++ < atlas.nodes.size) parent = atlas.nodes.get(parent)?.parent;
|
|
561
|
+
const mask = options.maskGrid && !boxBackdrops.has(parent) && item.hull.length > 2
|
|
562
|
+
? `<polygon points="${item.hull.map(point => `${round(point.x * scale)},${round(-point.y * scale)}`).join(" ")}" fill="${escapeXml(canvasColor)}"/>` : "";
|
|
563
|
+
const paths = [...item.triangles, ...item.textured].sort((a, b) => a.depth - b.depth).map((triangle, index) => {
|
|
564
|
+
if (triangle.image) {
|
|
565
|
+
const clipId = `atlas-clip-${escapeXml(item.id).replace(/[^a-zA-Z0-9_-]/g, "-")}-${index}`;
|
|
566
|
+
const hull = convexHull(triangle.points.map(point => ({ x: point.x, y: point.y })));
|
|
567
|
+
const clipPoints = hull.map(point => `${round(point.x * scale)},${round(-point.y * scale)}`).join(" ");
|
|
568
|
+
const [a, b, c, d, e, f] = triangle.transform.map(value => round(value * scale));
|
|
569
|
+
return `<defs><clipPath id="${clipId}"><polygon points="${clipPoints}"/></clipPath></defs><g clip-path="url(#${clipId})"><image href="${triangle.image.uri}" width="${triangle.image.width}" height="${triangle.image.height}" transform="matrix(${a} ${b} ${c} ${d} ${e} ${f})" opacity="${round(triangle.opacity)}"/></g>`;
|
|
570
|
+
}
|
|
571
|
+
const p = triangle.vertices.map(point => `${round(point.x * scale)},${round(-point.y * scale)}`).join(" ");
|
|
572
|
+
const stroke = triangle.opacity < 1 ? "" : ` stroke="${triangle.color}" stroke-width="0.8" stroke-linejoin="round"`;
|
|
573
|
+
return `<polygon points="${p}" fill="${triangle.color}"${stroke}${triangle.opacity < 1 ? ` opacity="${round(triangle.opacity)}"` : ""}/>`;
|
|
574
|
+
}).join("");
|
|
575
|
+
const labelBottom = round(-item.bounds.minY * scale);
|
|
576
|
+
const label = options.labels === false || !item.labelLines.length ? "" : `<g data-diagram-label="entity" transform="translate(0 ${labelBottom})">${item.labelLines.map((line, i) => `<text x="0" y="${round(item.labelGap + item.labelFontSize + i * item.labelLineHeight)}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="${round(item.labelFontSize)}" fill="${escapeXml(options.labelColor || "#172c3a")}">${escapeXml(line)}</text>`).join("")}</g>`;
|
|
577
|
+
const transform = `translate(${round(item.x - left)} ${round(item.y - top)})`;
|
|
578
|
+
return `${mask ? `<g transform="${transform}" pointer-events="none">${mask}</g>` : ""}<g data-entity="${escapeXml(item.id)}" transform="${transform}"${dimmed(item.id) < 1 ? ` opacity="${dimmed(item.id)}"` : ""}><title>${escapeXml(item.name || item.id)}</title>${paths}${label}</g>`;
|
|
579
|
+
}).join("");
|
|
580
|
+
const background = options.background ? `<rect width="100%" height="100%" fill="${escapeXml(options.background)}"/>` : "";
|
|
581
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><defs><marker id="atlas-diagram-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10Z" fill="${escapeXml(options.connectionColor || "#58788d")}"/></marker></defs>${background}<g class="containers">${containersSvg}</g><g class="relations">${relations}</g><g class="entities">${entities}</g></svg>`;
|
|
582
|
+
return { svg, width, height, warnings, items: items.map(({ id, x, y, width: w, height: h }) => ({ id, x: round(x - left), y: round(y - top), width: round(w), height: round(h) })) };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function diagramSvgToPng(svg, ownerDocument = globalThis.document) {
|
|
586
|
+
const image = new Image();
|
|
587
|
+
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
|
588
|
+
const url = URL.createObjectURL(blob);
|
|
589
|
+
try {
|
|
590
|
+
await new Promise((resolve, reject) => {
|
|
591
|
+
image.onload = resolve;
|
|
592
|
+
image.onerror = () => reject(new Error("Could not rasterize Atlas Diagram SVG."));
|
|
593
|
+
image.src = url;
|
|
594
|
+
});
|
|
595
|
+
const canvas = ownerDocument.createElement("canvas");
|
|
596
|
+
canvas.width = image.naturalWidth;
|
|
597
|
+
canvas.height = image.naturalHeight;
|
|
598
|
+
canvas.getContext("2d").drawImage(image, 0, 0);
|
|
599
|
+
return canvas;
|
|
600
|
+
} finally {
|
|
601
|
+
URL.revokeObjectURL(url);
|
|
602
|
+
}
|
|
603
|
+
}
|