@playcraft/build 0.0.8 → 0.0.10

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.
Files changed (73) hide show
  1. package/README.md +122 -6
  2. package/dist/analyzers/__tests__/optimization-analyzer.test.d.ts +1 -0
  3. package/dist/analyzers/__tests__/optimization-analyzer.test.js +169 -0
  4. package/dist/analyzers/build-analyzer.d.ts +98 -0
  5. package/dist/analyzers/build-analyzer.js +1160 -0
  6. package/dist/analyzers/enhanced-report-template.d.ts +13 -0
  7. package/dist/analyzers/enhanced-report-template.js +957 -0
  8. package/dist/analyzers/index.d.ts +6 -0
  9. package/dist/analyzers/index.js +9 -0
  10. package/dist/analyzers/optimization-analyzer.d.ts +88 -0
  11. package/dist/analyzers/optimization-analyzer.js +278 -0
  12. package/dist/analyzers/playable-analyzer.d.ts +91 -0
  13. package/dist/analyzers/playable-analyzer.js +977 -0
  14. package/dist/analyzers/report-template.d.ts +50 -0
  15. package/dist/analyzers/report-template.js +591 -0
  16. package/dist/analyzers/scene-asset-collector.js +8 -0
  17. package/dist/base-builder.d.ts +9 -0
  18. package/dist/base-builder.js +156 -2
  19. package/dist/build-state-manager.d.ts +110 -0
  20. package/dist/build-state-manager.js +169 -0
  21. package/dist/generators/config-generator.d.ts +2 -0
  22. package/dist/generators/config-generator.js +179 -10
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.js +6 -0
  25. package/dist/loaders/playcanvas-loader.d.ts +7 -0
  26. package/dist/loaders/playcanvas-loader.js +17 -0
  27. package/dist/platforms/adikteev.js +4 -2
  28. package/dist/platforms/applovin.js +9 -3
  29. package/dist/platforms/inmobi.js +4 -2
  30. package/dist/platforms/ironsource.js +4 -1
  31. package/dist/platforms/liftoff.js +8 -3
  32. package/dist/platforms/snapchat.js +8 -2
  33. package/dist/platforms/unity.js +8 -2
  34. package/dist/playable-builder.js +3 -1
  35. package/dist/state/build-state-manager.d.ts +174 -0
  36. package/dist/state/build-state-manager.js +235 -0
  37. package/dist/state/index.d.ts +4 -0
  38. package/dist/state/index.js +2 -0
  39. package/dist/state/state-to-report-converter.d.ts +141 -0
  40. package/dist/state/state-to-report-converter.js +177 -0
  41. package/dist/types.d.ts +1 -0
  42. package/dist/utils.d.ts +4 -0
  43. package/dist/utils.js +11 -0
  44. package/dist/vite/config-builder.js +11 -1
  45. package/dist/vite/platform-configs.d.ts +1 -0
  46. package/dist/vite/platform-configs.js +1 -0
  47. package/dist/vite/plugin-build-state.d.ts +13 -0
  48. package/dist/vite/plugin-build-state.js +147 -0
  49. package/dist/vite/plugin-esm-html-generator.js +11 -2
  50. package/dist/vite/plugin-platform.js +3 -1
  51. package/dist/vite/plugin-playcanvas.d.ts +1 -0
  52. package/dist/vite/plugin-playcanvas.js +160 -20
  53. package/dist/vite/plugin-source-builder.js +1 -0
  54. package/dist/vite/plugin-template-minifier.d.ts +20 -0
  55. package/dist/vite/plugin-template-minifier.js +392 -0
  56. package/package.json +12 -12
  57. package/templates/patches/one-page-mraid-resize-canvas.js +18 -4
  58. package/dist/templates/__loading__.js +0 -100
  59. package/dist/templates/__modules__.js +0 -47
  60. package/dist/templates/__settings__.template.js +0 -20
  61. package/dist/templates/__start__.js +0 -332
  62. package/dist/templates/index.html +0 -18
  63. package/dist/templates/logo.png +0 -0
  64. package/dist/templates/manifest.json +0 -1
  65. package/dist/templates/patches/cannon.min.js +0 -28
  66. package/dist/templates/patches/lz4.js +0 -10
  67. package/dist/templates/patches/one-page-http-get.js +0 -20
  68. package/dist/templates/patches/one-page-inline-game-scripts.js +0 -52
  69. package/dist/templates/patches/one-page-mraid-resize-canvas.js +0 -46
  70. package/dist/templates/patches/p2.min.js +0 -27
  71. package/dist/templates/patches/playcraft-no-xhr.js +0 -76
  72. package/dist/templates/playcanvas-stable.min.js +0 -16363
  73. package/dist/templates/styles.css +0 -43
@@ -0,0 +1,1160 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { generateUnifiedReportHTML } from './report-template.js';
4
+ import { generateEnhancedReportHTML } from './enhanced-report-template.js';
5
+ import { OptimizationAnalyzer } from './optimization-analyzer.js';
6
+ import { BuildStateManager, StateToReportConverter } from '../state/index.js';
7
+ /**
8
+ * 构建分析器 - 分析构建产物并生成详细报告
9
+ */
10
+ export class BuildAnalyzer {
11
+ constructor(buildDir, outputDir) {
12
+ this.buildDir = buildDir;
13
+ this.outputDir = outputDir;
14
+ }
15
+ /**
16
+ * 分析构建产物
17
+ */
18
+ async analyze() {
19
+ console.log('[BuildAnalyzer] 开始分析构建产物...');
20
+ // 尝试从 build-state.json 读取数据
21
+ const stateFilePath = path.join(this.buildDir, 'build-state.json');
22
+ try {
23
+ await fs.access(stateFilePath);
24
+ console.log('[BuildAnalyzer] 发现 build-state.json,使用状态数据分析');
25
+ return await this.analyzeFromState();
26
+ }
27
+ catch (error) {
28
+ console.log('[BuildAnalyzer] 未找到 build-state.json,使用文件系统扫描');
29
+ return await this.analyzeFromFileSystem();
30
+ }
31
+ }
32
+ /**
33
+ * 从 build-state.json 分析
34
+ */
35
+ async analyzeFromState() {
36
+ const stateManager = new BuildStateManager(this.buildDir, this.buildDir);
37
+ const loaded = await stateManager.loadState();
38
+ if (!loaded) {
39
+ console.warn('[BuildAnalyzer] 加载状态失败,回退到文件系统扫描');
40
+ return await this.analyzeFromFileSystem();
41
+ }
42
+ const state = stateManager.getState();
43
+ // 执行优化分析
44
+ console.log('[BuildAnalyzer] 执行优化分析...');
45
+ const optimizationAnalyzer = new OptimizationAnalyzer(state);
46
+ const optimizationAnalysis = optimizationAnalyzer.analyze();
47
+ console.log(`[BuildAnalyzer] 发现 ${optimizationAnalysis.totalSuggestions} 条优化建议`);
48
+ const unifiedData = StateToReportConverter.convertToUnifiedReport(state);
49
+ // 转换为 BuildAnalysisReport 格式
50
+ const files = unifiedData.assets.map(asset => {
51
+ // 计算 data URL 大小
52
+ const dataUrlSize = this.estimateDataUrlSize(asset.finalSize, asset.type);
53
+ return {
54
+ path: asset.id,
55
+ relativePath: asset.finalName,
56
+ size: asset.finalSize,
57
+ sizeFormatted: StateToReportConverter.formatBytes(asset.finalSize),
58
+ dataUrlSize,
59
+ dataUrlSizeFormatted: StateToReportConverter.formatBytes(dataUrlSize),
60
+ type: this.mapAssetTypeToFileType(asset.type),
61
+ category: this.mapAssetTypeToCategory(asset.type, asset.id),
62
+ };
63
+ });
64
+ // 按大小排序
65
+ files.sort((a, b) => b.size - a.size);
66
+ const totalSize = unifiedData.totalFinalSize;
67
+ const estimatedHtmlSize = files.reduce((sum, f) => sum + f.dataUrlSize, 0);
68
+ // 按分类统计
69
+ const byCategory = {};
70
+ const byType = {};
71
+ for (const file of files) {
72
+ // 按分类
73
+ if (!byCategory[file.category]) {
74
+ byCategory[file.category] = { count: 0, size: 0, dataUrlSize: 0 };
75
+ }
76
+ byCategory[file.category].count++;
77
+ byCategory[file.category].size += file.size;
78
+ byCategory[file.category].dataUrlSize += file.dataUrlSize;
79
+ // 按类型
80
+ if (!byType[file.type]) {
81
+ byType[file.type] = { count: 0, size: 0 };
82
+ }
83
+ byType[file.type].count++;
84
+ byType[file.type].size += file.size;
85
+ }
86
+ // 格式化大小
87
+ for (const category of Object.keys(byCategory)) {
88
+ byCategory[category].sizeFormatted = this.formatSize(byCategory[category].size);
89
+ byCategory[category].dataUrlSizeFormatted = this.formatSize(byCategory[category].dataUrlSize);
90
+ }
91
+ for (const type of Object.keys(byType)) {
92
+ byType[type].sizeFormatted = this.formatSize(byType[type].size);
93
+ }
94
+ const report = {
95
+ totalFiles: files.length,
96
+ totalSize,
97
+ totalSizeFormatted: this.formatSize(totalSize),
98
+ estimatedHtmlSize,
99
+ estimatedHtmlSizeFormatted: this.formatSize(estimatedHtmlSize),
100
+ files,
101
+ byCategory,
102
+ byType,
103
+ };
104
+ console.log(`[BuildAnalyzer] 分析完成: ${report.totalFiles} 个文件, 总大小 ${report.totalSizeFormatted}`);
105
+ console.log(`[BuildAnalyzer] 预估单 HTML 大小: ${report.estimatedHtmlSizeFormatted}`);
106
+ return report;
107
+ }
108
+ /**
109
+ * 从文件系统分析(回退方案)
110
+ */
111
+ async analyzeFromFileSystem() {
112
+ const files = await this.scanDirectory(this.buildDir);
113
+ const analyses = [];
114
+ for (const file of files) {
115
+ const analysis = await this.analyzeFile(file);
116
+ analyses.push(analysis);
117
+ }
118
+ // 按大小排序
119
+ analyses.sort((a, b) => b.size - a.size);
120
+ // 统计
121
+ const totalSize = analyses.reduce((sum, f) => sum + f.size, 0);
122
+ const estimatedHtmlSize = analyses.reduce((sum, f) => sum + f.dataUrlSize, 0);
123
+ // 按分类统计
124
+ const byCategory = {};
125
+ const byType = {};
126
+ for (const file of analyses) {
127
+ // 按分类
128
+ if (!byCategory[file.category]) {
129
+ byCategory[file.category] = { count: 0, size: 0, dataUrlSize: 0 };
130
+ }
131
+ byCategory[file.category].count++;
132
+ byCategory[file.category].size += file.size;
133
+ byCategory[file.category].dataUrlSize += file.dataUrlSize;
134
+ // 按类型
135
+ if (!byType[file.type]) {
136
+ byType[file.type] = { count: 0, size: 0 };
137
+ }
138
+ byType[file.type].count++;
139
+ byType[file.type].size += file.size;
140
+ }
141
+ // 格式化大小
142
+ for (const category of Object.keys(byCategory)) {
143
+ byCategory[category].sizeFormatted = this.formatSize(byCategory[category].size);
144
+ byCategory[category].dataUrlSizeFormatted = this.formatSize(byCategory[category].dataUrlSize);
145
+ }
146
+ for (const type of Object.keys(byType)) {
147
+ byType[type].sizeFormatted = this.formatSize(byType[type].size);
148
+ }
149
+ const report = {
150
+ totalFiles: analyses.length,
151
+ totalSize,
152
+ totalSizeFormatted: this.formatSize(totalSize),
153
+ estimatedHtmlSize,
154
+ estimatedHtmlSizeFormatted: this.formatSize(estimatedHtmlSize),
155
+ files: analyses,
156
+ byCategory,
157
+ byType,
158
+ };
159
+ console.log(`[BuildAnalyzer] 分析完成: ${report.totalFiles} 个文件, 总大小 ${report.totalSizeFormatted}`);
160
+ console.log(`[BuildAnalyzer] 预估单 HTML 大小: ${report.estimatedHtmlSizeFormatted}`);
161
+ return report;
162
+ }
163
+ /**
164
+ * 估算 data URL 大小
165
+ */
166
+ estimateDataUrlSize(size, type) {
167
+ const dataUrlPrefix = this.getDataUrlPrefix(type);
168
+ const base64Size = Math.ceil(size * 4 / 3);
169
+ return dataUrlPrefix.length + base64Size;
170
+ }
171
+ /**
172
+ * 映射资产类型到文件类型
173
+ */
174
+ mapAssetTypeToFileType(assetType) {
175
+ const typeMap = {
176
+ 'texture': 'image',
177
+ 'audio': 'audio',
178
+ 'model': 'other',
179
+ 'script': 'js',
180
+ 'json': 'json',
181
+ 'css': 'css',
182
+ 'html': 'html',
183
+ 'font': 'font',
184
+ 'binary': 'other',
185
+ 'other': 'other',
186
+ };
187
+ return typeMap[assetType] || 'other';
188
+ }
189
+ /**
190
+ * 映射资产类型到分类
191
+ */
192
+ mapAssetTypeToCategory(assetType, assetId) {
193
+ if (assetId.includes('engine') || assetId.includes('playcanvas')) {
194
+ return 'engine';
195
+ }
196
+ if (assetId.includes('config')) {
197
+ return 'config';
198
+ }
199
+ if (assetId.includes('scene')) {
200
+ return 'scene';
201
+ }
202
+ if (assetType === 'script') {
203
+ return 'script';
204
+ }
205
+ if (assetType === 'html') {
206
+ return 'html';
207
+ }
208
+ return 'asset';
209
+ }
210
+ /**
211
+ * 生成 HTML 报告
212
+ */
213
+ async generateHTMLReport(report) {
214
+ const reportPath = path.join(this.outputDir, 'build-analysis-report.html');
215
+ // 尝试加载 build state 并生成增强版报告
216
+ const stateManager = new BuildStateManager(this.buildDir, this.buildDir);
217
+ const loaded = await stateManager.loadState();
218
+ if (loaded) {
219
+ const state = stateManager.getState();
220
+ const unifiedData = StateToReportConverter.convertToUnifiedReport(state);
221
+ // 执行优化分析
222
+ const optimizationAnalyzer = new OptimizationAnalyzer(state);
223
+ const optimizationAnalysis = optimizationAnalyzer.analyze();
224
+ // 生成增强版报告
225
+ const enhancedData = {
226
+ ...unifiedData,
227
+ optimizationAnalysis,
228
+ };
229
+ const html = generateEnhancedReportHTML(enhancedData);
230
+ await fs.writeFile(reportPath, html, 'utf-8');
231
+ console.log(`[BuildAnalyzer] 增强版分析报告已生成: ${reportPath}`);
232
+ console.log(`[BuildAnalyzer] 发现 ${optimizationAnalysis.totalSuggestions} 条优化建议`);
233
+ return reportPath;
234
+ }
235
+ // 回退到旧版报告
236
+ const unifiedData = {
237
+ reportType: 'base-build',
238
+ title: '📊 Base Build 分析报告',
239
+ buildPath: path.basename(this.buildDir),
240
+ totalSize: report.totalSize,
241
+ totalSizeFormatted: report.totalSizeFormatted,
242
+ estimatedHtmlSize: report.estimatedHtmlSize,
243
+ estimatedHtmlSizeFormatted: report.estimatedHtmlSizeFormatted,
244
+ items: report.files.map(file => ({
245
+ name: file.relativePath,
246
+ path: file.path,
247
+ type: file.type,
248
+ category: file.category,
249
+ size: file.size,
250
+ sizeFormatted: file.sizeFormatted,
251
+ percentage: (file.size / report.totalSize) * 100,
252
+ dataUrlSize: file.dataUrlSize,
253
+ dataUrlSizeFormatted: file.dataUrlSizeFormatted,
254
+ })),
255
+ byCategory: Object.fromEntries(Object.entries(report.byCategory).map(([key, value]) => [
256
+ key,
257
+ {
258
+ ...value,
259
+ percentage: (value.size / report.totalSize) * 100,
260
+ },
261
+ ])),
262
+ byType: Object.fromEntries(Object.entries(report.byType).map(([key, value]) => [
263
+ key,
264
+ {
265
+ ...value,
266
+ percentage: (value.size / report.totalSize) * 100,
267
+ },
268
+ ])),
269
+ };
270
+ const html = generateUnifiedReportHTML(unifiedData);
271
+ await fs.writeFile(reportPath, html, 'utf-8');
272
+ console.log(`[BuildAnalyzer] 分析报告已生成: ${reportPath}`);
273
+ return reportPath;
274
+ }
275
+ /**
276
+ * 扫描目录获取所有文件
277
+ */
278
+ async scanDirectory(dir, baseDir = dir) {
279
+ const files = [];
280
+ const entries = await fs.readdir(dir, { withFileTypes: true });
281
+ for (const entry of entries) {
282
+ const fullPath = path.join(dir, entry.name);
283
+ const relativePath = path.relative(baseDir, fullPath);
284
+ // 跳过隐藏文件和临时目录
285
+ if (entry.name.startsWith('.') || entry.name.startsWith('__')) {
286
+ continue;
287
+ }
288
+ // 跳过分析报告文件
289
+ if (entry.name === 'base-bundle-report.html' || entry.name === 'build-analysis-report.html') {
290
+ continue;
291
+ }
292
+ if (entry.isDirectory()) {
293
+ const subFiles = await this.scanDirectory(fullPath, baseDir);
294
+ files.push(...subFiles);
295
+ }
296
+ else {
297
+ files.push(fullPath);
298
+ }
299
+ }
300
+ return files;
301
+ }
302
+ /**
303
+ * 分析单个文件
304
+ */
305
+ async analyzeFile(filePath) {
306
+ const stats = await fs.stat(filePath);
307
+ const size = stats.size;
308
+ const relativePath = path.relative(this.buildDir, filePath);
309
+ const ext = path.extname(filePath).toLowerCase();
310
+ // 确定文件类型
311
+ const type = this.getFileType(ext);
312
+ // 确定分类
313
+ const category = this.getFileCategory(relativePath, type);
314
+ // 计算 data URL 大小
315
+ // 注意:config.json 和 scenes.json 中已经包含了资源的 data URL,
316
+ // 所以这些文件本身已经是 Base64 编码后的大小,不需要再次计算增长
317
+ let dataUrlSize;
318
+ if (relativePath.includes('config.json') || relativePath.includes('scenes.json') || relativePath.includes('__settings__.js')) {
319
+ // 这些文件中已经包含了 data URL,只需要再次 Base64 编码文件本身
320
+ const dataUrlPrefix = this.getDataUrlPrefix(type);
321
+ const base64Size = Math.ceil(size * 4 / 3);
322
+ dataUrlSize = dataUrlPrefix.length + base64Size;
323
+ }
324
+ else if (type === 'js' || type === 'css' || type === 'html') {
325
+ // 纯文本文件,需要 Base64 编码
326
+ const dataUrlPrefix = this.getDataUrlPrefix(type);
327
+ const base64Size = Math.ceil(size * 4 / 3);
328
+ dataUrlSize = dataUrlPrefix.length + base64Size;
329
+ }
330
+ else {
331
+ // 其他资源文件(图片、音频等)在 base 构建时已经被转换为 data URL
332
+ // 并存储在 config.json 中,所以这里不应该出现这些文件
333
+ // 如果出现了,说明它们没有被正确处理,按原始大小计算
334
+ dataUrlSize = size;
335
+ }
336
+ return {
337
+ path: filePath,
338
+ relativePath,
339
+ size,
340
+ sizeFormatted: this.formatSize(size),
341
+ dataUrlSize,
342
+ dataUrlSizeFormatted: this.formatSize(dataUrlSize),
343
+ type,
344
+ category,
345
+ };
346
+ }
347
+ /**
348
+ * 获取文件类型
349
+ */
350
+ getFileType(ext) {
351
+ const typeMap = {
352
+ '.js': 'js',
353
+ '.mjs': 'js',
354
+ '.css': 'css',
355
+ '.json': 'json',
356
+ '.png': 'image',
357
+ '.jpg': 'image',
358
+ '.jpeg': 'image',
359
+ '.gif': 'image',
360
+ '.webp': 'image',
361
+ '.svg': 'image',
362
+ '.mp3': 'audio',
363
+ '.wav': 'audio',
364
+ '.ogg': 'audio',
365
+ '.m4a': 'audio',
366
+ '.woff': 'font',
367
+ '.woff2': 'font',
368
+ '.ttf': 'font',
369
+ '.otf': 'font',
370
+ '.wasm': 'wasm',
371
+ '.html': 'html',
372
+ };
373
+ return typeMap[ext] || 'other';
374
+ }
375
+ /**
376
+ * 获取文件分类
377
+ */
378
+ getFileCategory(relativePath, type) {
379
+ const lowerPath = relativePath.toLowerCase();
380
+ if (lowerPath.includes('playcanvas') || lowerPath.includes('engine')) {
381
+ return 'engine';
382
+ }
383
+ if (lowerPath === 'config.json') {
384
+ return 'config';
385
+ }
386
+ if (lowerPath.endsWith('.json') && /^\d+\.json$/.test(path.basename(lowerPath))) {
387
+ return 'scene';
388
+ }
389
+ if (type === 'js' || type === 'mjs') {
390
+ return 'script';
391
+ }
392
+ if (type === 'html') {
393
+ return 'html';
394
+ }
395
+ return 'asset';
396
+ }
397
+ /**
398
+ * 获取 data URL 前缀
399
+ */
400
+ getDataUrlPrefix(type) {
401
+ const prefixMap = {
402
+ 'js': 'data:text/javascript;base64,',
403
+ 'css': 'data:text/css;base64,',
404
+ 'json': 'data:application/json;base64,',
405
+ 'image': 'data:image/png;base64,', // 平均值
406
+ 'audio': 'data:audio/mpeg;base64,',
407
+ 'font': 'data:font/woff2;base64,',
408
+ 'wasm': 'data:application/wasm;base64,',
409
+ 'html': 'data:text/html;base64,',
410
+ };
411
+ return prefixMap[type] || 'data:application/octet-stream;base64,';
412
+ }
413
+ /**
414
+ * 格式化文件大小
415
+ */
416
+ formatSize(bytes) {
417
+ if (bytes === 0)
418
+ return '0 B';
419
+ const k = 1024;
420
+ const sizes = ['B', 'KB', 'MB', 'GB'];
421
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
422
+ return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
423
+ }
424
+ /**
425
+ * 生成树状图数据结构
426
+ */
427
+ generateTreemapData(report) {
428
+ const root = {
429
+ name: 'root',
430
+ children: []
431
+ };
432
+ // 按分类分组
433
+ const categories = new Map();
434
+ for (const file of report.files) {
435
+ if (!categories.has(file.category)) {
436
+ categories.set(file.category, {
437
+ name: file.category,
438
+ children: []
439
+ });
440
+ }
441
+ categories.get(file.category).children.push({
442
+ name: file.relativePath,
443
+ value: file.size,
444
+ type: file.type,
445
+ category: file.category,
446
+ sizeFormatted: file.sizeFormatted,
447
+ dataUrlSize: file.dataUrlSize,
448
+ dataUrlSizeFormatted: file.dataUrlSizeFormatted
449
+ });
450
+ }
451
+ root.children = Array.from(categories.values());
452
+ return root;
453
+ }
454
+ /**
455
+ * 生成 HTML 报告内容
456
+ */
457
+ generateReportHTML(report) {
458
+ // 生成树状图数据
459
+ const treemapData = this.generateTreemapData(report);
460
+ const categoryRows = Object.entries(report.byCategory)
461
+ .sort((a, b) => b[1].size - a[1].size)
462
+ .map(([category, data]) => `
463
+ <tr>
464
+ <td>${category}</td>
465
+ <td>${data.count}</td>
466
+ <td>${data.sizeFormatted}</td>
467
+ <td>${data.dataUrlSizeFormatted}</td>
468
+ <td>
469
+ <div class="bar" style="width: ${(data.size / report.totalSize * 100).toFixed(1)}%"></div>
470
+ </td>
471
+ </tr>
472
+ `).join('');
473
+ return `<!DOCTYPE html>
474
+ <html lang="zh-CN">
475
+ <head>
476
+ <meta charset="UTF-8">
477
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
478
+ <title>构建分析报告 - Treemap 可视化</title>
479
+ <style>
480
+ * {
481
+ margin: 0;
482
+ padding: 0;
483
+ box-sizing: border-box;
484
+ }
485
+
486
+ body {
487
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
488
+ background: #1e1e1e;
489
+ color: #e0e0e0;
490
+ overflow-x: hidden;
491
+ }
492
+
493
+ .header {
494
+ background: #252526;
495
+ padding: 20px 30px;
496
+ border-bottom: 1px solid #3e3e42;
497
+ display: flex;
498
+ justify-content: space-between;
499
+ align-items: center;
500
+ }
501
+
502
+ .header h1 {
503
+ font-size: 20px;
504
+ font-weight: 600;
505
+ color: #e0e0e0;
506
+ }
507
+
508
+ .header-stats {
509
+ display: flex;
510
+ gap: 30px;
511
+ font-size: 13px;
512
+ }
513
+
514
+ .stat-item {
515
+ display: flex;
516
+ flex-direction: column;
517
+ align-items: flex-end;
518
+ }
519
+
520
+ .stat-label {
521
+ color: #888;
522
+ font-size: 11px;
523
+ text-transform: uppercase;
524
+ letter-spacing: 0.5px;
525
+ }
526
+
527
+ .stat-value {
528
+ color: #4ec9b0;
529
+ font-weight: 600;
530
+ font-size: 16px;
531
+ margin-top: 2px;
532
+ }
533
+
534
+ .controls {
535
+ background: #252526;
536
+ padding: 15px 30px;
537
+ border-bottom: 1px solid #3e3e42;
538
+ display: flex;
539
+ gap: 20px;
540
+ align-items: center;
541
+ flex-wrap: wrap;
542
+ }
543
+
544
+ .control-group {
545
+ display: flex;
546
+ gap: 10px;
547
+ align-items: center;
548
+ flex-wrap: wrap;
549
+ }
550
+
551
+ .control-label {
552
+ color: #888;
553
+ font-size: 13px;
554
+ font-weight: 600;
555
+ }
556
+
557
+ .filter-divider {
558
+ width: 1px;
559
+ height: 24px;
560
+ background: #3e3e42;
561
+ }
562
+
563
+ .control-button {
564
+ background: #3e3e42;
565
+ border: 1px solid #555;
566
+ color: #e0e0e0;
567
+ padding: 6px 12px;
568
+ border-radius: 4px;
569
+ cursor: pointer;
570
+ font-size: 12px;
571
+ transition: all 0.2s;
572
+ white-space: nowrap;
573
+ }
574
+
575
+ .control-button:hover {
576
+ background: #4e4e52;
577
+ border-color: #666;
578
+ }
579
+
580
+ .control-button.active {
581
+ background: #0e639c;
582
+ border-color: #1177bb;
583
+ color: white;
584
+ }
585
+
586
+ .filter-divider {
587
+ width: 1px;
588
+ height: 24px;
589
+ background: #3e3e42;
590
+ }
591
+
592
+ .main-container {
593
+ display: flex;
594
+ height: calc(100vh - 120px);
595
+ }
596
+
597
+ .treemap-container {
598
+ flex: 1;
599
+ padding: 20px;
600
+ overflow: auto;
601
+ }
602
+
603
+ .treemap {
604
+ width: 100%;
605
+ min-height: 600px;
606
+ position: relative;
607
+ background: #1e1e1e;
608
+ }
609
+
610
+ .treemap-block {
611
+ position: absolute;
612
+ border: 2px solid #1e1e1e;
613
+ cursor: pointer;
614
+ transition: all 0.2s;
615
+ overflow: hidden;
616
+ display: flex;
617
+ flex-direction: column;
618
+ justify-content: center;
619
+ align-items: center;
620
+ padding: 8px;
621
+ }
622
+
623
+ .treemap-block:hover {
624
+ border-color: #fff;
625
+ z-index: 10;
626
+ transform: scale(1.02);
627
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
628
+ }
629
+
630
+ .treemap-block-label {
631
+ font-size: 11px;
632
+ font-weight: 600;
633
+ color: white;
634
+ text-align: center;
635
+ text-shadow: 0 1px 2px rgba(0,0,0,0.8);
636
+ word-break: break-word;
637
+ line-height: 1.3;
638
+ max-width: 100%;
639
+ overflow: hidden;
640
+ text-overflow: ellipsis;
641
+ }
642
+
643
+ .treemap-block-size {
644
+ font-size: 10px;
645
+ color: rgba(255,255,255,0.8);
646
+ text-shadow: 0 1px 2px rgba(0,0,0,0.8);
647
+ margin-top: 4px;
648
+ }
649
+
650
+ .sidebar {
651
+ width: 350px;
652
+ background: #252526;
653
+ border-left: 1px solid #3e3e42;
654
+ overflow-y: auto;
655
+ padding: 20px;
656
+ }
657
+
658
+ .sidebar h2 {
659
+ font-size: 14px;
660
+ color: #e0e0e0;
661
+ margin-bottom: 15px;
662
+ text-transform: uppercase;
663
+ letter-spacing: 0.5px;
664
+ }
665
+
666
+ .info-item {
667
+ background: #1e1e1e;
668
+ padding: 12px;
669
+ border-radius: 4px;
670
+ margin-bottom: 10px;
671
+ border: 1px solid #3e3e42;
672
+ }
673
+
674
+ .info-label {
675
+ font-size: 11px;
676
+ color: #888;
677
+ text-transform: uppercase;
678
+ letter-spacing: 0.5px;
679
+ margin-bottom: 4px;
680
+ }
681
+
682
+ .info-value {
683
+ font-size: 13px;
684
+ color: #e0e0e0;
685
+ font-weight: 500;
686
+ word-break: break-all;
687
+ }
688
+
689
+ .legend {
690
+ margin-top: 20px;
691
+ }
692
+
693
+ .legend-item {
694
+ display: flex;
695
+ align-items: center;
696
+ margin-bottom: 8px;
697
+ font-size: 12px;
698
+ }
699
+
700
+ .legend-color {
701
+ width: 20px;
702
+ height: 20px;
703
+ border-radius: 3px;
704
+ margin-right: 10px;
705
+ border: 1px solid #3e3e42;
706
+ }
707
+
708
+ .warning {
709
+ background: #3a2a1a;
710
+ border-left: 3px solid #d4a72c;
711
+ padding: 12px;
712
+ margin: 20px;
713
+ border-radius: 4px;
714
+ font-size: 13px;
715
+ color: #d4a72c;
716
+ }
717
+
718
+ .tooltip {
719
+ position: fixed;
720
+ background: rgba(0, 0, 0, 0.95);
721
+ color: white;
722
+ padding: 12px;
723
+ border-radius: 6px;
724
+ font-size: 12px;
725
+ pointer-events: none;
726
+ z-index: 1000;
727
+ display: none;
728
+ border: 1px solid #555;
729
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
730
+ max-width: 300px;
731
+ }
732
+
733
+ .tooltip-title {
734
+ font-weight: 600;
735
+ margin-bottom: 6px;
736
+ color: #4ec9b0;
737
+ }
738
+
739
+ .tooltip-row {
740
+ display: flex;
741
+ justify-content: space-between;
742
+ margin: 4px 0;
743
+ gap: 20px;
744
+ }
745
+
746
+ .tooltip-label {
747
+ color: #888;
748
+ }
749
+
750
+ .tooltip-value {
751
+ color: #e0e0e0;
752
+ font-weight: 500;
753
+ }
754
+ </style>
755
+ </head>
756
+ <body>
757
+ <div class="header">
758
+ <h1>📊 构建分析报告 - Treemap 可视化</h1>
759
+ <div class="header-stats">
760
+ <div class="stat-item">
761
+ <div class="stat-label">文件总数</div>
762
+ <div class="stat-value">${report.totalFiles}</div>
763
+ </div>
764
+ <div class="stat-item">
765
+ <div class="stat-label">当前大小</div>
766
+ <div class="stat-value">${report.totalSizeFormatted}</div>
767
+ </div>
768
+ <div class="stat-item">
769
+ <div class="stat-label">预估 HTML 大小</div>
770
+ <div class="stat-value">${report.estimatedHtmlSizeFormatted}</div>
771
+ </div>
772
+ </div>
773
+ </div>
774
+
775
+ <div class="controls">
776
+ <div class="control-group">
777
+ <span class="control-label">显示大小:</span>
778
+ <button class="control-button active" data-size-type="current">当前大小</button>
779
+ <button class="control-button" data-size-type="dataurl">Data URL 大小</button>
780
+ </div>
781
+
782
+ <div class="filter-divider"></div>
783
+
784
+ <div class="control-group">
785
+ <span class="control-label">文件类型:</span>
786
+ <button class="control-button active" data-type-filter="all">全部</button>
787
+ <button class="control-button" data-type-filter="js">JS</button>
788
+ <button class="control-button" data-type-filter="image">图片</button>
789
+ <button class="control-button" data-type-filter="json">JSON</button>
790
+ <button class="control-button" data-type-filter="css">CSS</button>
791
+ <button class="control-button" data-type-filter="audio">音频</button>
792
+ <button class="control-button" data-type-filter="other">其他</button>
793
+ </div>
794
+ </div>
795
+
796
+ ${report.estimatedHtmlSize > 5 * 1024 * 1024 ? `
797
+ <div class="warning">
798
+ ⚠️ 预估的单 HTML 大小超过 5 MB,可能会影响加载性能。建议优化资源大小。
799
+ </div>
800
+ ` : ''}
801
+
802
+ <div class="main-container">
803
+ <div class="treemap-container">
804
+ <div class="treemap" id="treemap"></div>
805
+ </div>
806
+
807
+ <div class="sidebar">
808
+ <h2>📦 选中文件信息</h2>
809
+ <div id="file-info">
810
+ <div class="info-item">
811
+ <div class="info-label">提示</div>
812
+ <div class="info-value">点击或悬停在方块上查看详细信息</div>
813
+ </div>
814
+ </div>
815
+
816
+ <div class="legend">
817
+ <h2>🎨 分类图例</h2>
818
+ ${Object.keys(report.byCategory).map((category, index) => {
819
+ const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c'];
820
+ const color = colors[index % colors.length];
821
+ return `
822
+ <div class="legend-item">
823
+ <div class="legend-color" style="background: ${color}"></div>
824
+ <span>${category}</span>
825
+ </div>
826
+ `;
827
+ }).join('')}
828
+ </div>
829
+ </div>
830
+ </div>
831
+
832
+ <div class="tooltip" id="tooltip"></div>
833
+
834
+ <script>
835
+ const data = ${JSON.stringify(treemapData)};
836
+ const totalSize = ${report.totalSize};
837
+ let currentSizeType = 'current';
838
+ let currentTypeFilter = 'all';
839
+
840
+ // 颜色映射
841
+ const categoryColors = {
842
+ 'engine': '#e74c3c',
843
+ 'config': '#3498db',
844
+ 'scene': '#2ecc71',
845
+ 'script': '#f39c12',
846
+ 'asset': '#9b59b6',
847
+ 'html': '#1abc9c'
848
+ };
849
+
850
+ // 格式化大小
851
+ const formatSize = (bytes) => {
852
+ if (bytes === 0) return '0 B';
853
+ const k = 1024;
854
+ const sizes = ['B', 'KB', 'MB', 'GB'];
855
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
856
+ return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
857
+ };
858
+
859
+ // 获取节点的大小
860
+ const getNodeSize = (node) => {
861
+ if (node.value) {
862
+ return currentSizeType === 'current' ? node.value : node.dataUrlSize;
863
+ }
864
+ if (node.children) {
865
+ return node.children.reduce((sum, child) => sum + getNodeSize(child), 0);
866
+ }
867
+ return 0;
868
+ };
869
+
870
+ // 扁平化所有文件节点(应用类型过滤)
871
+ const flattenFiles = (node) => {
872
+ const files = [];
873
+ if (node.children) {
874
+ node.children.forEach(category => {
875
+ if (category.children) {
876
+ category.children.forEach(file => {
877
+ // 应用类型过滤
878
+ if (currentTypeFilter === 'all' || file.type === currentTypeFilter) {
879
+ files.push(file);
880
+ }
881
+ });
882
+ }
883
+ });
884
+ }
885
+ return files;
886
+ };
887
+
888
+ // 简单的 treemap 布局算法
889
+ const layoutTreemap = (node, x, y, width, height) => {
890
+ // 获取所有文件
891
+ const allFiles = flattenFiles(node);
892
+
893
+ // 按大小排序
894
+ allFiles.sort((a, b) => getNodeSize(b) - getNodeSize(a));
895
+
896
+ // 计算总大小
897
+ const total = allFiles.reduce((sum, file) => sum + getNodeSize(file), 0);
898
+
899
+ if (total === 0) return [];
900
+
901
+ const blocks = [];
902
+ let currentX = x;
903
+ let currentY = y;
904
+ let remainingWidth = width;
905
+ let remainingHeight = height;
906
+ let remainingArea = width * height;
907
+ let remainingSize = total;
908
+
909
+ // 简单的行布局算法
910
+ let rowFiles = [];
911
+ let rowSize = 0;
912
+
913
+ allFiles.forEach((file, index) => {
914
+ const fileSize = getNodeSize(file);
915
+ const fileRatio = fileSize / remainingSize;
916
+ const fileArea = remainingArea * fileRatio;
917
+
918
+ // 添加到当前行
919
+ rowFiles.push(file);
920
+ rowSize += fileSize;
921
+
922
+ // 判断是否需要换行(每行最多10个文件,或者是最后一个文件)
923
+ const shouldBreak = rowFiles.length >= 10 || index === allFiles.length - 1;
924
+
925
+ if (shouldBreak) {
926
+ // 计算行高度
927
+ const rowRatio = rowSize / total;
928
+ const rowHeight = height * rowRatio;
929
+
930
+ // 布局当前行的文件
931
+ let offsetX = currentX;
932
+ rowFiles.forEach(f => {
933
+ const fSize = getNodeSize(f);
934
+ const fWidth = (fSize / rowSize) * width;
935
+
936
+ blocks.push({
937
+ ...f,
938
+ x: offsetX,
939
+ y: currentY,
940
+ width: Math.max(fWidth, 1),
941
+ height: Math.max(rowHeight, 1)
942
+ });
943
+
944
+ offsetX += fWidth;
945
+ });
946
+
947
+ // 更新位置
948
+ currentY += rowHeight;
949
+ remainingHeight -= rowHeight;
950
+ remainingArea = width * remainingHeight;
951
+ remainingSize -= rowSize;
952
+
953
+ // 重置行
954
+ rowFiles = [];
955
+ rowSize = 0;
956
+ }
957
+ });
958
+
959
+ return blocks;
960
+ };
961
+
962
+ // 渲染 treemap
963
+ const renderTreemap = () => {
964
+ const container = document.getElementById('treemap');
965
+ const width = container.clientWidth;
966
+ const height = Math.max(600, container.clientHeight);
967
+
968
+ container.innerHTML = '';
969
+ container.style.height = height + 'px';
970
+
971
+ const blocks = layoutTreemap(data, 0, 0, width, height);
972
+
973
+ blocks.forEach(block => {
974
+ if (!block.value) return;
975
+
976
+ const div = document.createElement('div');
977
+ div.className = 'treemap-block';
978
+ div.style.left = block.x + 'px';
979
+ div.style.top = block.y + 'px';
980
+ div.style.width = block.width + 'px';
981
+ div.style.height = block.height + 'px';
982
+ div.style.background = categoryColors[block.category] || '#95a5a6';
983
+
984
+ const fileName = block.name.split('/').pop();
985
+ const showLabel = block.width > 60 && block.height > 40;
986
+
987
+ if (showLabel) {
988
+ const label = document.createElement('div');
989
+ label.className = 'treemap-block-label';
990
+ label.textContent = fileName;
991
+ div.appendChild(label);
992
+
993
+ const size = document.createElement('div');
994
+ size.className = 'treemap-block-size';
995
+ size.textContent = block.sizeFormatted;
996
+ div.appendChild(size);
997
+ }
998
+
999
+ // 鼠标事件
1000
+ div.addEventListener('mouseenter', (e) => {
1001
+ showTooltip(e, block);
1002
+ updateFileInfo(block);
1003
+ });
1004
+
1005
+ div.addEventListener('mousemove', (e) => {
1006
+ updateTooltipPosition(e);
1007
+ });
1008
+
1009
+ div.addEventListener('mouseleave', () => {
1010
+ hideTooltip();
1011
+ });
1012
+
1013
+ div.addEventListener('click', () => {
1014
+ updateFileInfo(block);
1015
+ });
1016
+
1017
+ container.appendChild(div);
1018
+ });
1019
+ };
1020
+
1021
+ // 显示 tooltip
1022
+ const showTooltip = (e, block) => {
1023
+ const tooltip = document.getElementById('tooltip');
1024
+ tooltip.innerHTML = \`
1025
+ <div class="tooltip-title">\${block.name}</div>
1026
+ <div class="tooltip-row">
1027
+ <span class="tooltip-label">类型:</span>
1028
+ <span class="tooltip-value">\${block.type}</span>
1029
+ </div>
1030
+ <div class="tooltip-row">
1031
+ <span class="tooltip-label">分类:</span>
1032
+ <span class="tooltip-value">\${block.category}</span>
1033
+ </div>
1034
+ <div class="tooltip-row">
1035
+ <span class="tooltip-label">当前大小:</span>
1036
+ <span class="tooltip-value">\${block.sizeFormatted}</span>
1037
+ </div>
1038
+ <div class="tooltip-row">
1039
+ <span class="tooltip-label">Data URL 大小:</span>
1040
+ <span class="tooltip-value">\${block.dataUrlSizeFormatted}</span>
1041
+ </div>
1042
+ <div class="tooltip-row">
1043
+ <span class="tooltip-label">占比:</span>
1044
+ <span class="tooltip-value">\${((block.value / totalSize) * 100).toFixed(2)}%</span>
1045
+ </div>
1046
+ \`;
1047
+ tooltip.style.display = 'block';
1048
+ updateTooltipPosition(e);
1049
+ };
1050
+
1051
+ const updateTooltipPosition = (e) => {
1052
+ const tooltip = document.getElementById('tooltip');
1053
+ tooltip.style.left = (e.clientX + 15) + 'px';
1054
+ tooltip.style.top = (e.clientY + 15) + 'px';
1055
+ };
1056
+
1057
+ const hideTooltip = () => {
1058
+ const tooltip = document.getElementById('tooltip');
1059
+ tooltip.style.display = 'none';
1060
+ };
1061
+
1062
+ // 更新文件信息
1063
+ const updateFileInfo = (block) => {
1064
+ const infoContainer = document.getElementById('file-info');
1065
+ infoContainer.innerHTML = \`
1066
+ <div class="info-item">
1067
+ <div class="info-label">文件路径</div>
1068
+ <div class="info-value">\${block.name}</div>
1069
+ </div>
1070
+ <div class="info-item">
1071
+ <div class="info-label">文件类型</div>
1072
+ <div class="info-value">\${block.type}</div>
1073
+ </div>
1074
+ <div class="info-item">
1075
+ <div class="info-label">分类</div>
1076
+ <div class="info-value">\${block.category}</div>
1077
+ </div>
1078
+ <div class="info-item">
1079
+ <div class="info-label">当前大小</div>
1080
+ <div class="info-value">\${block.sizeFormatted}</div>
1081
+ </div>
1082
+ <div class="info-item">
1083
+ <div class="info-label">Data URL 大小</div>
1084
+ <div class="info-value">\${block.dataUrlSizeFormatted}</div>
1085
+ </div>
1086
+ <div class="info-item">
1087
+ <div class="info-label">占总大小比例</div>
1088
+ <div class="info-value">\${((block.value / totalSize) * 100).toFixed(2)}%</div>
1089
+ </div>
1090
+ \`;
1091
+ };
1092
+
1093
+ // 切换大小类型
1094
+ document.querySelectorAll('[data-size-type]').forEach(button => {
1095
+ button.addEventListener('click', () => {
1096
+ document.querySelectorAll('[data-size-type]').forEach(b => b.classList.remove('active'));
1097
+ button.classList.add('active');
1098
+ currentSizeType = button.dataset.sizeType;
1099
+ renderTreemap();
1100
+ });
1101
+ });
1102
+
1103
+ // 切换类型过滤
1104
+ document.querySelectorAll('[data-type-filter]').forEach(button => {
1105
+ button.addEventListener('click', () => {
1106
+ document.querySelectorAll('[data-type-filter]').forEach(b => b.classList.remove('active'));
1107
+ button.classList.add('active');
1108
+ currentTypeFilter = button.dataset.typeFilter;
1109
+ updateFilteredStats();
1110
+ renderTreemap();
1111
+ });
1112
+ });
1113
+
1114
+ // 更新过滤后的统计信息
1115
+ const updateFilteredStats = () => {
1116
+ const allFiles = flattenFiles(data);
1117
+ const filteredSize = allFiles.reduce((sum, file) => sum + getNodeSize(file), 0);
1118
+ const filteredCount = allFiles.length;
1119
+
1120
+ // 更新顶部统计
1121
+ const statItems = document.querySelectorAll('.stat-item');
1122
+ if (statItems[0]) {
1123
+ const label = statItems[0].querySelector('.stat-label');
1124
+ const value = statItems[0].querySelector('.stat-value');
1125
+ if (currentTypeFilter === 'all') {
1126
+ label.textContent = '文件总数';
1127
+ value.textContent = ${report.totalFiles};
1128
+ } else {
1129
+ label.textContent = '过滤后文件数';
1130
+ value.textContent = filteredCount;
1131
+ }
1132
+ }
1133
+
1134
+ if (statItems[1]) {
1135
+ const label = statItems[1].querySelector('.stat-label');
1136
+ const value = statItems[1].querySelector('.stat-value');
1137
+ if (currentTypeFilter === 'all') {
1138
+ label.textContent = '当前大小';
1139
+ value.textContent = '${report.totalSizeFormatted}';
1140
+ } else {
1141
+ label.textContent = '过滤后大小';
1142
+ value.textContent = formatSize(filteredSize);
1143
+ }
1144
+ }
1145
+ };
1146
+
1147
+ // 初始渲染
1148
+ renderTreemap();
1149
+
1150
+ // 窗口大小改变时重新渲染
1151
+ let resizeTimeout;
1152
+ window.addEventListener('resize', () => {
1153
+ clearTimeout(resizeTimeout);
1154
+ resizeTimeout = setTimeout(renderTreemap, 300);
1155
+ });
1156
+ </script>
1157
+ </body>
1158
+ </html>`;
1159
+ }
1160
+ }