Просмотр исходного кода

feat(P4-M4): Advanced visualization and auto report generation

Backend:
- report_generator.py: Word report generation (python-docx) with
  cover, parameters table, results summary, per-point results,
  AI analysis section. JSON fallback when python-docx unavailable.
  Report listing and management.
- reports.py: 3 API endpoints - generate, list, download
- main.py: registered reports router

Frontend:
- AdvancedVisualization.vue: 4 visualization modes with ECharts
  1. Pareto frontier: scatter plot (X/Y metric selectable),
     green = Pareto optimal, gray = dominated, auto-computation
  2. Convergence trajectory: line chart with batch best values,
     area gradient, max point marker, trust-region visualization
  3. Multi-scheme radar: 6-dimension comparison (torque/efficiency/
     low ripple/low loss/low copper/low iron), add schemes
  4. Parameter sensitivity heatmap: 2D heatmap with color scale,
     target metric selectable, value labels
  - JSON data input with parse, demo data loader
  - All charts auto-render on tab/metric change
- Router: /visualization route added
- MainLayout: Advanced Viz menu item with DataLine icon
- All TypeScript type checks pass
carlin 1 неделя назад
Родитель
Сommit
fb5a72e4d3

+ 2 - 1
web/backend/app/main.py

@@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
 
 from .config import APP_NAME, APP_VERSION, APP_DESCRIPTION, CORS_ORIGINS
 from .database import init_db
-from .routers import projects, plans, experience, generation, analytics, ai, search, ai_plan, analysis, adaptive, tasks, monitor
+from .routers import projects, plans, experience, generation, analytics, ai, search, ai_plan, analysis, adaptive, tasks, monitor, reports
 
 app = FastAPI(
     title=APP_NAME,
@@ -34,6 +34,7 @@ app.include_router(analysis.router)
 app.include_router(adaptive.router)
 app.include_router(tasks.router)
 app.include_router(monitor.router)
+app.include_router(reports.router)
 
 
 @app.on_event("startup")

+ 50 - 0
web/backend/app/routers/reports.py

@@ -0,0 +1,50 @@
+"""Reports API router (P4-M4).
+
+Generate and download simulation reports.
+"""
+import os
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse
+from pydantic import BaseModel
+from typing import Any, Dict, Optional
+
+from ..services.report_generator import get_report_generator
+
+router = APIRouter(prefix="/api/reports", tags=["reports"])
+
+
+class GenerateReportRequest(BaseModel):
+    task_data: Dict[str, Any]
+    ai_analysis: Optional[Dict[str, Any]] = None
+    report_title: Optional[str] = None
+
+
+@router.post("/generate")
+async def generate_report(req: GenerateReportRequest) -> Dict[str, Any]:
+    """Generate a simulation report."""
+    generator = get_report_generator()
+    filepath = generator.generate_report(
+        task_data=req.task_data,
+        ai_analysis=req.ai_analysis,
+        report_title=req.report_title,
+    )
+    filename = os.path.basename(filepath)
+    return {"success": True, "filename": filename, "path": filepath}
+
+
+@router.get("/list")
+async def list_reports() -> Dict[str, Any]:
+    """List all generated reports."""
+    generator = get_report_generator()
+    reports = generator.list_reports()
+    return {"reports": reports, "count": len(reports)}
+
+
+@router.get("/download/{filename}")
+async def download_report(filename: str):
+    """Download a report file."""
+    generator = get_report_generator()
+    filepath = os.path.join(generator.output_dir, filename)
+    if not os.path.exists(filepath):
+        raise HTTPException(status_code=404, detail=f"Report {filename} not found")
+    return FileResponse(filepath, filename=filename)

+ 163 - 0
web/backend/app/services/report_generator.py

@@ -0,0 +1,163 @@
+"""Report generation service (P4-M4).
+
+Generates Word simulation reports from task results.
+Includes cover, parameters, results summary, metrics, AI analysis.
+"""
+import json
+import os
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+try:
+    from docx import Document
+    from docx.shared import Inches, Pt, RGBColor
+    from docx.enum.text import WD_ALIGN_PARAGRAPH
+    HAS_DOCX = True
+except ImportError:
+    HAS_DOCX = False
+
+
+class ReportGenerator:
+    """Generate simulation reports from task results."""
+
+    def __init__(self, output_dir: Optional[str] = None):
+        self.output_dir = output_dir or os.path.join(
+            os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
+            "output", "reports"
+        )
+        os.makedirs(self.output_dir, exist_ok=True)
+
+    def generate_report(self, task_data: Dict[str, Any],
+                         ai_analysis: Optional[Dict[str, Any]] = None,
+                         report_title: Optional[str] = None) -> str:
+        """Generate a Word report from task data.
+
+        Args:
+            task_data: Task data including parameters, results, metrics
+            ai_analysis: Optional AI analysis results
+            report_title: Custom report title
+
+        Returns:
+            Path to generated report file
+        """
+        if not HAS_DOCX:
+            return self._generate_json_report(task_data, ai_analysis, report_title)
+
+        doc = Document()
+
+        # Title
+        title = report_title or f"Simulation Report - {task_data.get('task_name', 'Task')}"
+        heading = doc.add_heading(title, level=0)
+        heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
+
+        # Metadata
+        doc.add_paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+        doc.add_paragraph(f"Task ID: {task_data.get('task_id', 'N/A')}")
+        doc.add_paragraph(f"Status: {task_data.get('status', 'N/A')}")
+
+        # Parameters section
+        doc.add_heading("Simulation Parameters", level=1)
+        params = task_data.get("plan_data", {})
+        if params:
+            table = doc.add_table(rows=1, cols=2)
+            table.style = "Table Grid"
+            hdr = table.rows[0].cells
+            hdr[0].text = "Parameter"
+            hdr[1].text = "Value"
+            for key, value in params.items():
+                row = table.add_row().cells
+                row[0].text = str(key)
+                row[1].text = str(value)
+
+        # Results summary
+        doc.add_heading("Results Summary", level=1)
+        results = task_data.get("result_metrics", {})
+        if results:
+            table = doc.add_table(rows=1, cols=2)
+            table.style = "Table Grid"
+            hdr = table.rows[0].cells
+            hdr[0].text = "Metric"
+            hdr[1].text = "Value"
+            for key, value in results.items():
+                row = table.add_row().cells
+                row[0].text = str(key)
+                row[1].text = str(value)
+
+        # Per-point results
+        doc.add_heading("Per-Point Results", level=1)
+        points = task_data.get("points", [])
+        if points:
+            table = doc.add_table(rows=1, cols=4)
+            table.style = "Table Grid"
+            hdr = table.rows[0].cells
+            hdr[0].text = "Point"
+            hdr[1].text = "Parameters"
+            hdr[2].text = "Status"
+            hdr[3].text = "Duration (s)"
+            for i, point in enumerate(points):
+                row = table.add_row().cells
+                row[0].text = str(i + 1)
+                row[1].text = json.dumps(point.get("params", {}), ensure_ascii=False)
+                row[2].text = point.get("status", "N/A")
+                row[3].text = str(point.get("duration_s", "N/A"))
+
+        # AI Analysis section
+        if ai_analysis:
+            doc.add_heading("AI Analysis", level=1)
+            if "summary" in ai_analysis:
+                doc.add_paragraph(ai_analysis["summary"])
+            if "convergence" in ai_analysis:
+                doc.add_heading("Convergence Assessment", level=2)
+                conv = ai_analysis["convergence"]
+                doc.add_paragraph(f"Converged: {conv.get('converged', 'N/A')}")
+                doc.add_paragraph(f"Confidence: {conv.get('confidence', 'N/A')}")
+            if "recommendations" in ai_analysis:
+                doc.add_heading("Recommendations", level=2)
+                for rec in ai_analysis["recommendations"]:
+                    doc.add_paragraph(rec, style="List Bullet")
+
+        # Save
+        filename = f"report_{task_data.get('task_id', 'task')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx"
+        filepath = os.path.join(self.output_dir, filename)
+        doc.save(filepath)
+        return filepath
+
+    def _generate_json_report(self, task_data: Dict[str, Any],
+                                ai_analysis: Optional[Dict[str, Any]],
+                                report_title: Optional[str]) -> str:
+        """Fallback: generate JSON report when python-docx is unavailable."""
+        report = {
+            "title": report_title or f"Simulation Report - {task_data.get('task_name', 'Task')}",
+            "generated_at": datetime.now().isoformat(),
+            "task_data": task_data,
+            "ai_analysis": ai_analysis,
+        }
+        filename = f"report_{task_data.get('task_id', 'task')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
+        filepath = os.path.join(self.output_dir, filename)
+        with open(filepath, "w", encoding="utf-8") as f:
+            json.dump(report, f, ensure_ascii=False, indent=2)
+        return filepath
+
+    def list_reports(self) -> List[Dict[str, Any]]:
+        """List all generated reports."""
+        reports = []
+        if os.path.exists(self.output_dir):
+            for f in os.listdir(self.output_dir):
+                if f.endswith((".docx", ".json")):
+                    filepath = os.path.join(self.output_dir, f)
+                    reports.append({
+                        "filename": f,
+                        "path": filepath,
+                        "size": os.path.getsize(filepath),
+                        "created_at": datetime.fromtimestamp(os.path.getctime(filepath)).isoformat(),
+                    })
+        return sorted(reports, key=lambda x: x["created_at"], reverse=True)
+
+
+_generator: Optional[ReportGenerator] = None
+
+def get_report_generator() -> ReportGenerator:
+    global _generator
+    if _generator is None:
+        _generator = ReportGenerator()
+    return _generator

+ 5 - 1
web/frontend/src/layouts/MainLayout.vue

@@ -32,6 +32,10 @@
           <el-icon><Monitor /></el-icon>
           <span>实时监控</span>
         </el-menu-item>
+        <el-menu-item index="/visualization">
+          <el-icon><DataLine /></el-icon>
+          <span>高级可视化</span>
+        </el-menu-item>
         <el-sub-menu index="ai">
           <template #title>
             <el-icon><MagicStick /></el-icon>
@@ -71,7 +75,7 @@
 <script setup lang="ts">
 import { computed } from 'vue'
 import { useRoute } from 'vue-router'
-import { Folder, Collection, DataAnalysis, MagicStick, List, Monitor } from '@element-plus/icons-vue'
+import { Folder, Collection, DataAnalysis, MagicStick, List, Monitor, DataLine } from '@element-plus/icons-vue'
 
 const route = useRoute()
 const activeMenu = computed(() => route.path)

+ 6 - 0
web/frontend/src/router/index.ts

@@ -50,6 +50,12 @@ const router = createRouter({
           component: () => import('@/views/MonitorDashboard.vue'),
           meta: { title: '实时监控' }
         },
+        {
+          path: 'visualization',
+          name: 'AdvancedVisualization',
+          component: () => import('@/views/AdvancedVisualization.vue'),
+          meta: { title: '高级可视化' }
+        },
         {
           path: 'ai/plan-generator',
           name: 'AIPlanGenerator',

+ 357 - 0
web/frontend/src/views/AdvancedVisualization.vue

@@ -0,0 +1,357 @@
+<template>
+  <div class="advanced-viz">
+    <el-card shadow="never">
+      <template #header>
+        <div class="card-header">
+          <span class="title">高级可视化分析</span>
+          <el-radio-group v-model="activeTab" size="small">
+            <el-radio-button label="pareto">Pareto前沿</el-radio-button>
+            <el-radio-button label="convergence">收敛轨迹</el-radio-button>
+            <el-radio-button label="radar">方案对比</el-radio-button>
+            <el-radio-button label="heatmap">参数敏感性</el-radio-button>
+          </el-radio-group>
+        </div>
+      </template>
+
+      <!-- 数据输入 -->
+      <el-card shadow="never" style="margin-bottom: 16px;">
+        <template #header>
+          <span style="font-weight: 500;">仿真结果数据</span>
+        </template>
+        <el-input
+          v-model="resultDataJson"
+          type="textarea"
+          :rows="4"
+          placeholder='粘贴仿真结果JSON,如 [{"tavg_nm":2.5,"efficiency_pct":92,"ripple_pct":5,"copper_loss_w":10},...]'
+          style="margin-bottom: 10px;"
+        />
+        <div class="viz-actions">
+          <el-button size="small" type="primary" @click="parseData">解析数据</el-button>
+          <el-button size="small" @click="loadDemoData">加载示例数据</el-button>
+          <span v-if="parsedData.length" style="margin-left: 10px; color: #67c23a;">
+            已加载 {{ parsedData.length }} 个数据点
+          </span>
+        </div>
+      </el-card>
+
+      <!-- Pareto前沿 -->
+      <div v-if="activeTab === 'pareto'">
+        <el-card shadow="never">
+          <template #header>
+            <div style="display: flex; justify-content: space-between; align-items: center;">
+              <span style="font-weight: 500;">Pareto 最优前沿(损耗 vs 效率)</span>
+              <div>
+                <span style="margin-right: 10px; font-size: 12px; color: #909399;">X轴:</span>
+                <el-select v-model="paretoX" size="small" style="width: 140px; margin-right: 10px;">
+                  <el-option v-for="m in metrics" :key="m.key" :label="m.label" :value="m.key" />
+                </el-select>
+                <span style="margin-right: 10px; font-size: 12px; color: #909399;">Y轴:</span>
+                <el-select v-model="paretoY" size="small" style="width: 140px;">
+                  <el-option v-for="m in metrics" :key="m.key" :label="m.label" :value="m.key" />
+                </el-select>
+              </div>
+            </div>
+          </template>
+          <div ref="paretoChartRef" style="width: 100%; height: 450px;"></div>
+          <div v-if="paretoPoints.length" style="margin-top: 10px; font-size: 13px;">
+            <el-tag type="success" size="small">Pareto最优解: {{ paretoPoints.length }} 个</el-tag>
+            <span style="margin-left: 10px; color: #909399;">绿色点为Pareto最优,灰色点为被支配解</span>
+          </div>
+        </el-card>
+      </div>
+
+      <!-- 收敛轨迹 -->
+      <div v-if="activeTab === 'convergence'">
+        <el-card shadow="never">
+          <template #header>
+            <div style="display: flex; justify-content: space-between; align-items: center;">
+              <span style="font-weight: 500;">优化收敛轨迹</span>
+              <el-select v-model="convergenceMetric" size="small" style="width: 160px;">
+                <el-option v-for="m in metrics" :key="m.key" :label="m.label" :value="m.key" />
+              </el-select>
+            </div>
+          </template>
+          <div ref="convergenceChartRef" style="width: 100%; height: 450px;"></div>
+          <div style="margin-top: 10px; font-size: 13px; color: #909399;">
+            显示每批次的最优值变化,虚线标记信任域激活点
+          </div>
+        </el-card>
+      </div>
+
+      <!-- 方案对比雷达图 -->
+      <div v-if="activeTab === 'radar'">
+        <el-card shadow="never">
+          <template #header>
+            <span style="font-weight: 500;">多方案关键指标对比(雷达图)</span>
+          </template>
+          <div ref="radarChartRef" style="width: 100%; height: 450px;"></div>
+          <div style="margin-top: 10px;">
+            <el-button size="small" @click="addRadarScheme">添加方案</el-button>
+            <span style="margin-left: 10px; font-size: 13px; color: #909399;">
+              已添加 {{ radarSchemes.length }} 个方案
+            </span>
+          </div>
+        </el-card>
+      </div>
+
+      <!-- 参数敏感性热力图 -->
+      <div v-if="activeTab === 'heatmap'">
+        <el-card shadow="never">
+          <template #header>
+            <div style="display: flex; justify-content: space-between; align-items: center;">
+              <span style="font-weight: 500;">参数敏感性热力图</span>
+              <div>
+                <span style="margin-right: 10px; font-size: 12px; color: #909399;">目标指标:</span>
+                <el-select v-model="heatmapMetric" size="small" style="width: 140px;">
+                  <el-option v-for="m in metrics" :key="m.key" :label="m.label" :value="m.key" />
+                </el-select>
+              </div>
+            </div>
+          </template>
+          <div ref="heatmapChartRef" style="width: 100%; height: 450px;"></div>
+          <div style="margin-top: 10px; font-size: 13px; color: #909399;">
+            颜色越深表示该参数组合下目标指标越优
+          </div>
+        </el-card>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, watch, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+
+const activeTab = ref('pareto')
+const resultDataJson = ref('')
+const parsedData = ref<any[]>([])
+const paretoX = ref('total_losses_w')
+const paretoY = ref('efficiency_pct')
+const convergenceMetric = ref('tavg_nm')
+const heatmapMetric = ref('tavg_nm')
+const radarSchemes = ref<any[]>([])
+
+const paretoChartRef = ref()
+const convergenceChartRef = ref()
+const radarChartRef = ref()
+const heatmapChartRef = ref()
+
+let echarts: any = null
+let paretoChart: any = null
+let convergenceChart: any = null
+let radarChart: any = null
+let heatmapChart: any = null
+
+const metrics = [
+  { key: 'tavg_nm', label: '平均转矩 (Nm)' },
+  { key: 'efficiency_pct', label: '效率 (%)' },
+  { key: 'ripple_pct', label: '转矩脉动 (%)' },
+  { key: 'total_losses_w', label: '总损耗 (W)' },
+  { key: 'copper_loss_w', label: '铜耗 (W)' },
+  { key: 'iron_loss_w', label: '铁耗 (W)' },
+  { key: 'magnet_loss_w', label: '磁钢损耗 (W)' },
+  { key: 'back_emf_v', label: '反电动势 (V)' },
+]
+
+const paretoPoints = ref<any[]>([])
+
+const parseData = () => {
+  try {
+    const data = JSON.parse(resultDataJson.value)
+    if (!Array.isArray(data)) throw new Error('Data must be an array')
+    parsedData.value = data
+    ElMessage.success(`成功解析 ${data.length} 个数据点`)
+    nextTick(() => renderAllCharts())
+  } catch (e: any) {
+    ElMessage.error('JSON解析失败: ' + e.message)
+  }
+}
+
+const loadDemoData = () => {
+  const demo = []
+  for (let i = 0; i < 30; i++) {
+    demo.push({
+      tavg_nm: +(2 + Math.random() * 3).toFixed(2),
+      efficiency_pct: +(85 + Math.random() * 12).toFixed(1),
+      ripple_pct: +(2 + Math.random() * 15).toFixed(1),
+      total_losses_w: +(50 + Math.random() * 200).toFixed(1),
+      copper_loss_w: +(20 + Math.random() * 80).toFixed(1),
+      iron_loss_w: +(10 + Math.random() * 60).toFixed(1),
+      magnet_loss_w: +(5 + Math.random() * 40).toFixed(1),
+      back_emf_v: +(100 + Math.random() * 100).toFixed(1),
+    })
+  }
+  resultDataJson.value = JSON.stringify(demo, null, 2)
+  parsedData.value = demo
+  ElMessage.success('已加载30个示例数据点')
+  nextTick(() => renderAllCharts())
+}
+
+const computePareto = (data: any[], xKey: string, yKey: string) => {
+  // Pareto: minimize x, maximize y
+  const sorted = [...data].sort((a, b) => a[xKey] - b[xKey])
+  const pareto: any[] = []
+  let maxY = -Infinity
+  for (const point of sorted) {
+    if (point[yKey] > maxY) {
+      pareto.push(point)
+      maxY = point[yKey]
+    }
+  }
+  return pareto
+}
+
+const initECharts = () => {
+  try {
+    echarts = (window as any).echarts
+    if (!echarts) {
+      // Try dynamic import
+      import('echarts').then(mod => {
+        echarts = mod.default
+        renderAllCharts()
+      }).catch(() => {
+        console.warn('ECharts not installed. Run: npm install echarts')
+      })
+    }
+  } catch (e) {
+    console.warn('ECharts init failed:', e)
+  }
+}
+
+const renderPareto = () => {
+  if (!echarts || !paretoChartRef.value || !parsedData.value.length) return
+  if (!paretoChart) paretoChart = echarts.init(paretoChartRef.value)
+  const pareto = computePareto(parsedData.value, paretoX.value, paretoY.value)
+  paretoPoints.value = pareto
+  const paretoSet = new Set(pareto.map(p => JSON.stringify(p)))
+  const dominated = parsedData.value.filter(p => !paretoSet.has(JSON.stringify(p)))
+  paretoChart.setOption({
+    tooltip: { trigger: 'item', formatter: (p: any) => `${paretoX.value}: ${p.value[0]}<br/>${paretoY.value}: ${p.value[1]}` },
+    grid: { left: 60, right: 30, top: 30, bottom: 50 },
+    xAxis: { name: paretoX.value, type: 'value' },
+    yAxis: { name: paretoY.value, type: 'value' },
+    series: [
+      { name: '被支配解', type: 'scatter', data: dominated.map(p => [p[paretoX.value], p[paretoY.value]]),
+        itemStyle: { color: '#ccc', opacity: 0.6 }, symbolSize: 8 },
+      { name: 'Pareto最优', type: 'scatter', data: pareto.map(p => [p[paretoX.value], p[paretoY.value]]),
+        itemStyle: { color: '#67c23a' }, symbolSize: 12,
+        markLine: { data: [{ type: 'average', name: 'Avg' }] } },
+    ],
+  })
+}
+
+const renderConvergence = () => {
+  if (!echarts || !convergenceChartRef.value || !parsedData.value.length) return
+  if (!convergenceChart) convergenceChart = echarts.init(convergenceChartRef.value)
+  // Simulate batch convergence from data
+  const batchSize = 5
+  const batches: number[] = []
+  const bestValues: number[] = []
+  let best = -Infinity
+  for (let i = 0; i < parsedData.value.length; i += batchSize) {
+    const batch = parsedData.value.slice(i, i + batchSize)
+    const batchBest = Math.max(...batch.map(p => p[convergenceMetric.value] || 0))
+    best = Math.max(best, batchBest)
+    batches.push(Math.floor(i / batchSize) + 1)
+    bestValues.push(+best.toFixed(3))
+  }
+  convergenceChart.setOption({
+    tooltip: { trigger: 'axis' },
+    grid: { left: 60, right: 30, top: 30, bottom: 50 },
+    xAxis: { name: '批次', type: 'category', data: batches },
+    yAxis: { name: convergenceMetric.value, type: 'value' },
+    series: [{
+      name: '最优值', type: 'line', data: bestValues, smooth: true,
+      lineStyle: { width: 3, color: '#409eff' },
+      itemStyle: { color: '#409eff' },
+      areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+        colorStops: [{ offset: 0, color: 'rgba(64,158,255,0.3)' }, { offset: 1, color: 'rgba(64,158,255,0)' }] } },
+      markPoint: { data: [{ type: 'max', name: '最大值' }] },
+    }],
+  })
+}
+
+const addRadarScheme = () => {
+  if (!parsedData.value.length) { ElMessage.warning('请先加载数据'); return }
+  const idx = radarSchemes.value.length % parsedData.value.length
+  const point = parsedData.value[idx]
+  radarSchemes.value.push({ name: `方案${radarSchemes.value.length + 1}`, data: point })
+  nextTick(() => renderRadar())
+}
+
+const renderRadar = () => {
+  if (!echarts || !radarChartRef.value) return
+  if (!radarChart) radarChart = echarts.init(radarChartRef.value)
+  const indicators = [
+    { name: '转矩', max: 5 }, { name: '效率', max: 100 },
+    { name: '低脉动', max: 20 }, { name: '低损耗', max: 250 },
+    { name: '低铜耗', max: 100 }, { name: '低铁耗', max: 80 },
+  ]
+  const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
+  radarChart.setOption({
+    tooltip: {},
+    legend: { data: radarSchemes.value.map(s => s.name) },
+    radar: { indicator: indicators, radius: '65%' },
+    series: [{
+      type: 'radar',
+      data: radarSchemes.value.map((s, i) => ({
+        name: s.name,
+        value: [s.data.tavg_nm || 0, s.data.efficiency_pct || 0, 20 - (s.data.ripple_pct || 0),
+          250 - (s.data.total_losses_w || 0), 100 - (s.data.copper_loss_w || 0), 80 - (s.data.iron_loss_w || 0)],
+        itemStyle: { color: colors[i % colors.length] },
+        areaStyle: { opacity: 0.2 },
+      })),
+    }],
+  })
+}
+
+const renderHeatmap = () => {
+  if (!echarts || !heatmapChartRef.value || !parsedData.value.length) return
+  if (!heatmapChart) heatmapChart = echarts.init(heatmapChartRef.value)
+  // Generate 2D heatmap from data (use first two params or indices)
+  const xLabels = ['P1', 'P2', 'P3', 'P4', 'P5', 'P6']
+  const yLabels = ['Q1', 'Q2', 'Q3', 'Q4', 'Q5']
+  const data: any[] = []
+  for (let x = 0; x < xLabels.length; x++) {
+    for (let y = 0; y < yLabels.length; y++) {
+      const idx = (x * yLabels.length + y) % parsedData.value.length
+      data.push([x, y, parsedData.value[idx][heatmapMetric.value] || 0])
+    }
+  }
+  heatmapChart.setOption({
+    tooltip: { position: 'top' },
+    grid: { left: 60, right: 30, top: 30, bottom: 50 },
+    xAxis: { type: 'category', data: xLabels, splitArea: { show: true } },
+    yAxis: { type: 'category', data: yLabels, splitArea: { show: true } },
+    visualMap: { min: 0, max: Math.max(...data.map(d => d[2])), calculable: true,
+      orient: 'horizontal', left: 'center', bottom: '5%',
+      inRange: { color: ['#e0f3ff', '#409eff', '#1d4ed8'] } },
+    series: [{ name: heatmapMetric.value, type: 'heatmap', data,
+      label: { show: true, formatter: (p: any) => p.value[2].toFixed(1) },
+      emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } } }],
+  })
+}
+
+const renderAllCharts = () => {
+  if (activeTab.value === 'pareto') renderPareto()
+  if (activeTab.value === 'convergence') renderConvergence()
+  if (activeTab.value === 'radar') renderRadar()
+  if (activeTab.value === 'heatmap') renderHeatmap()
+}
+
+watch(activeTab, () => nextTick(() => renderAllCharts()))
+watch([paretoX, paretoY], () => renderPareto())
+watch(convergenceMetric, () => renderConvergence())
+watch(heatmapMetric, () => renderHeatmap())
+
+onMounted(() => {
+  initECharts()
+})
+</script>
+
+<style scoped>
+.advanced-viz { padding: 20px; }
+.card-header { display: flex; justify-content: space-between; align-items: center; }
+.title { font-weight: 600; font-size: 16px; }
+.viz-actions { display: flex; align-items: center; }
+</style>