caoziwu пре 1 час
родитељ
комит
f02ebb654c

+ 11 - 2
src/renderer/src/assets/simulator.less

@@ -195,7 +195,7 @@
   display: flex;
   flex-direction: column;
   gap: 10px;
-  margin-top: 2.2rem;
+  margin-top: 2.6rem;
 }
 .result-list {
   display: flex;
@@ -255,11 +255,20 @@
   width: 100%;
   display: block;
 }
-
+.diagram-panel {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
 .chart-box--diagram {
   height: 360px;
 }
 
+.chart-box--top {
+  height: 26rem;
+  width: 30rem;
+}
+
 .chart-box--spl {
   height: 320px;
 }

+ 10 - 0
src/renderer/src/components/simulator/SpeakerSimulator.vue

@@ -15,6 +15,7 @@ import ParamsPanel from './ParamsPanel.vue'
 import ParamTuner from './ParamTuner.vue'
 import ThemeToggle from './ThemeToggle.vue'
 import DiagramPanel from './DiagramPanel.vue'
+import TopDiagramPanel from './TopDiagramPanel.vue'
 import ResultPanel from './ResultPanel.vue'
 import SPLChartPanel from './SPLChartPanel.vue'
 import logo from '../../assets/logo.png'
@@ -35,6 +36,7 @@ const defaultParams: SpeakerParams = {
   d3: 10.39
 }
 
+const covAngle = ref(180)
 const params = reactive<SpeakerParams>({ ...defaultParams })
 const linked = ref(true)
 const result = ref<CalcResult | null>(null)
@@ -198,6 +200,14 @@ runCalc()
             <NTabPane name="diagram" :tab="$t('diagram.panel')">
               <DiagramPanel :params="params" :result="result" />
             </NTabPane>
+            <NTabPane name="top" :tab="$t('diagram.topPanel')">
+              <TopDiagramPanel
+                :params="params"
+                :result="result"
+                :covAngleParams="covAngle"
+                @update-cov-angle="(e) => (covAngle = e)"
+              />
+            </NTabPane>
             <NTabPane name="chart" :tab="$t('chart.panel')">
               <SPLChartPanel :params="params" :result="result" />
             </NTabPane>

+ 100 - 0
src/renderer/src/components/simulator/TopDiagramPanel.vue

@@ -0,0 +1,100 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { echarts } from '../../lib/speaker/echarts/install'
+import type { EChartsCoreOption } from 'echarts/core'
+import type { CalcResult, SpeakerParams } from '../../lib/speaker/types'
+import { buildChartEmptyOption, buildTopDiagramOption } from '../../lib/speaker/echarts'
+import { useChartPalette } from '../../theme/useChartPalette'
+
+const props = defineProps<{
+  params: SpeakerParams
+  covAngleParams: number
+  result: CalcResult | null
+}>()
+
+const chartDom = ref<HTMLDivElement | null>(null)
+const { t, locale } = useI18n()
+const palette = useChartPalette()
+const covAngle = ref(180)
+const emit = defineEmits<{
+  (e: 'update-cov-angle', value: number): void
+}>()
+
+let chart: ReturnType<typeof echarts.init> | null = null
+let resizeObserver: ResizeObserver | null = null
+
+const render = (): void => {
+  if (!chart) return
+  const labels = {
+    empty: t('diagram.empty'),
+    forwardDistance: t('diagram.forwardDistance'),
+    lateralDistance: t('diagram.lateralDistance'),
+    leftBound: t('diagram.leftBound'),
+    rightBound: t('diagram.rightBound'),
+    acousticAxis: t('diagram.acousticAxis'),
+    coverage: t('diagram.coverage'),
+    horizontal: t('diagram.coverage')
+  }
+  const pal = palette.value
+  const option: EChartsCoreOption = props.result
+    ? buildTopDiagramOption(snapshot.value, labels, pal)
+    : buildChartEmptyOption(labels.empty, pal)
+  chart.setOption(option, true)
+}
+const updateCovAngle = (value: number): void => {
+  covAngle.value = value
+  emit('update-cov-angle', value)
+}
+// 显式解构全部参数 + 结果:任何字段变化都会生成新快照 → 触发重绘
+const snapshot = computed(() => {
+  const { splMax, z, vCov, dir, d1, d2, d3, hCov } = props.params
+  return {
+    splMax,
+    z,
+    vCov,
+    dir,
+    covAngle: covAngle.value,
+    d1,
+    hCov,
+    d2,
+    d3,
+    result: props.result
+  } as SpeakerParams & { result?: CalcResult }
+})
+watch(snapshot, render)
+watch(locale, render)
+// 主题切换后重绘(palette 来自 naive 主题变量,随 dark/light 变化)
+watch(palette, render)
+const formatTooltip = (value: number): string => `${t('diagram.coverage')}:${value}°`
+onMounted(() => {
+  covAngle.value = props.covAngleParams
+  if (!chartDom.value) return
+  chart = echarts.init(chartDom.value)
+  render()
+  resizeObserver = new ResizeObserver(() => chart?.resize())
+  resizeObserver.observe(chartDom.value)
+})
+
+onBeforeUnmount(() => {
+  resizeObserver?.disconnect()
+  chart?.dispose()
+  chart = null
+})
+</script>
+
+<template>
+  <div class="diagram-panel">
+    <div ref="chartDom" class="chart-box chart-box--top"></div>
+
+    <n-space style="height: 360px; justify-content: center">
+      <n-slider
+        v-model:value="covAngle"
+        :max="360"
+        vertical
+        :format-tooltip="formatTooltip"
+        @update:value="updateCovAngle"
+      />
+    </n-space>
+  </div>
+</template>

+ 1 - 0
src/renderer/src/lib/speaker/echarts/index.ts

@@ -5,6 +5,7 @@ import type { ChartPalette } from './palette'
 export * from './palette'
 export * from './diagramOption'
 export * from './splChartOption'
+export * from './topDiagramOption'
 
 /** 空状态图表(居中提示文案) */
 export function buildChartEmptyOption(text: string, pal: ChartPalette): EChartsOption {

+ 278 - 0
src/renderer/src/lib/speaker/echarts/topDiagramOption.ts

@@ -0,0 +1,278 @@
+/**
+ * 覆盖示意图(俯视图)ECharts option 工厂
+ *
+ * 说明:俯视图是水平平面图,覆盖扇形使用水平指向角 H。
+ * - X/Y 轴采用相同数据范围([-maxD, maxD]),确保等比例,圆弧不变形
+ * - 支持 0~360° 任意覆盖角(hCov > 180° 时为大扇形,绕过设备后方)
+ * - 覆盖区域按声压级(SPL)分层渐变填充,SPL 越高颜色越深,越低越浅
+ *   颜色从深橙色(高SPL)渐变到浅橙色(低SPL),色相和明度双重变化
+ * - D1/D2/D3 以设备为圆心,画同心圆弧参考线
+ */
+import type { DefaultLabelFormatterCallbackParams, EChartsOption, SeriesOption } from 'echarts'
+import type { SpeakerParams } from '../types'
+import { DEG, splAt } from '../calc'
+import type { ChartPalette } from './palette'
+import { fmtN } from './utils'
+
+export interface TopDiagramChartLabels {
+  empty: string
+  forwardDistance: string
+  lateralDistance: string
+  leftBound: string
+  rightBound: string
+  acousticAxis: string
+  coverage: string
+  horizontal: string
+}
+
+function ringPolygon(
+  cx: number,
+  cy: number,
+  rInner: number,
+  rOuter: number,
+  startAngle: number,
+  endAngle: number,
+  steps = 40
+): [number, number][] {
+  const pts: [number, number][] = []
+  for (let i = 0; i <= steps; i++) {
+    const a = startAngle + ((endAngle - startAngle) * i) / steps
+    pts.push([cx + Math.cos(a) * rOuter, cy + Math.sin(a) * rOuter])
+  }
+  for (let i = steps; i >= 0; i--) {
+    const a = startAngle + ((endAngle - startAngle) * i) / steps
+    pts.push([cx + Math.cos(a) * rInner, cy + Math.sin(a) * rInner])
+  }
+  pts.push(pts[0])
+
+  return pts
+}
+
+function arcPoints(
+  cx: number,
+  cy: number,
+  r: number,
+  startAngle: number,
+  endAngle: number,
+  steps = 40
+): [number, number][] {
+  const pts: [number, number][] = []
+  for (let i = 0; i <= steps; i++) {
+    const a = startAngle + ((endAngle - startAngle) * i) / steps
+    pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r])
+  }
+  return pts
+}
+
+function parseHex(color: string): [number, number, number] {
+  const c = color.startsWith('#') ? color.slice(1) : color
+  if (c.length === 3) {
+    return [parseInt(c[0] + c[0], 16), parseInt(c[1] + c[1], 16), parseInt(c[2] + c[2], 16)]
+  }
+  return [
+    parseInt(c.substring(0, 2), 16),
+    parseInt(c.substring(2, 4), 16),
+    parseInt(c.substring(4, 6), 16)
+  ]
+}
+
+export function buildTopDiagramOption(
+  p: SpeakerParams,
+  labels: TopDiagramChartLabels,
+  pal: ChartPalette
+): EChartsOption {
+  const { splMax, d1, d2, d3, covAngle } = p
+
+  const cov = covAngle
+  const t6 = cov / 2
+  const t6Rad = t6 * DEG
+
+  const maxD = Math.max(d1, d2, d3, 5) * 1.13
+
+  const axisMin = -maxD
+  const axisMax = maxD
+
+  const series: SeriesOption[] = []
+
+  // ------------------------------------------------------------------
+  // 1. 覆盖区域按声压级(SPL)分层渐变填充
+  //    SPL 越高颜色越深;SPL 越低颜色越浅
+  //    颜色从深橙色渐变到浅橙色,色相和明度双重变化
+  // ------------------------------------------------------------------
+  const fullCircle = cov >= 360
+  const startAngle = fullCircle ? 0 : -t6Rad
+  const endAngle = fullCircle ? 2 * Math.PI : t6Rad
+
+  const layers = 32
+  const maxAlpha = 0.9
+  const minAlpha = 0.08
+
+  // 解析主题强调色的 RGB(palette 已按浅/深主题给好颜色,这里只取色相)
+  const [br, bg, bb] = parseHex(pal.accent)
+
+  const splAtMaxD = splAt(maxD, p)
+  const splRange = Math.max(splMax - splAtMaxD, 1)
+
+  for (let i = 0; i < layers; i++) {
+    const rInner = (maxD * i) / layers
+    const rOuter = (maxD * (i + 1)) / layers
+    const rMid = (rInner + rOuter) / 2
+
+    if (rOuter <= 0.01) continue
+
+    const spl = splAt(rMid, p)
+    const splRatio = Math.max(0, Math.min(1, (spl - splAtMaxD) / splRange))
+
+    const alpha = minAlpha + splRatio * (maxAlpha - minAlpha)
+    const color = `rgba(${br}, ${bg}, ${bb}, ${alpha.toFixed(3)})`
+
+    series.push({
+      type: 'line',
+      name: labels.coverage,
+      data: ringPolygon(0, 0, rInner, rOuter, startAngle, endAngle, 48),
+      showSymbol: false,
+      lineStyle: { opacity: 0 },
+      areaStyle: { color }, // ← 只给 color,不要再设 opacity
+      silent: true,
+      z: 2
+    } as SeriesOption)
+  }
+
+  // ------------------------------------------------------------------
+  // 2. 左右 -6dB 边界虚线(从圆心出发,用 sin/cos 计算端点,支持大角度)
+  // ------------------------------------------------------------------
+  if (!fullCircle) {
+    const farRight = [maxD * Math.cos(t6Rad), maxD * Math.sin(t6Rad)]
+    const farLeft = [maxD * Math.cos(-t6Rad), maxD * Math.sin(-t6Rad)]
+
+    series.push({
+      type: 'line',
+      name: labels.rightBound,
+      data: [[0, 0], farRight],
+      showSymbol: false,
+      lineStyle: { color: pal.accent, type: 'dashed', opacity: 0.6, width: 1.2 },
+      silent: true,
+      z: 3
+    } as SeriesOption)
+
+    series.push({
+      type: 'line',
+      name: labels.leftBound,
+      data: [[0, 0], farLeft],
+      showSymbol: false,
+      lineStyle: { color: pal.accent, type: 'dashed', opacity: 0.6, width: 1.2 },
+      silent: true,
+      z: 3
+    } as SeriesOption)
+  }
+
+  // ------------------------------------------------------------------
+  // 3. D1/D2/D3 同心圆弧参考线(以设备为圆心,仅在覆盖角范围内绘制)
+  // ------------------------------------------------------------------
+  const ringDefs = [
+    { r: d1, lbl: 'D1' },
+    { r: d2, lbl: 'D2' },
+    { r: d3, lbl: 'D3' }
+  ]
+  ringDefs.forEach((def) => {
+    if (def.r > 0 && def.r <= maxD) {
+      series.push({
+        type: 'line',
+        name: def.lbl,
+        data: arcPoints(0, 0, def.r, startAngle, endAngle, 36),
+        showSymbol: false,
+        lineStyle: { color: pal.text, type: 'dashed', width: 1, opacity: 0.35 },
+        silent: true,
+        z: 1,
+        tooltip: { show: false }
+      } as SeriesOption)
+    }
+  })
+
+  // ------------------------------------------------------------------
+  // 4. 声学中轴线(X 轴正方向,从圆心到 maxD)
+  // ------------------------------------------------------------------
+  series.push({
+    type: 'line',
+    name: labels.acousticAxis,
+    data: [
+      [0, 0],
+      [maxD, 0]
+    ],
+    showSymbol: false,
+    lineStyle: { color: pal.accent, type: 'solid', width: 2 },
+    silent: true,
+    z: 3
+  } as SeriesOption)
+
+  // ------------------------------------------------------------------
+  // 5. D1/D2/D3 点标记(在声轴上,即各圆弧与 X 正轴的交点)
+  // ------------------------------------------------------------------
+  const keyPoints = [
+    { d: d1, lbl: 'D1' },
+    { d: d2, lbl: 'D2' },
+    { d: d3, lbl: 'D3' }
+  ]
+  keyPoints
+    .sort((a, b) => a.d - b.d)
+    .forEach((pt, index) => {
+      if (pt.d >= 0 && pt.d <= maxD) {
+        series.push({
+          type: 'scatter',
+          name: pt.lbl,
+          // data: [{ value: [pt.d, 0], name: pt.lbl }],
+          data: [{ value: [pt.d, 0, splAt(pt.d, p).toFixed(1)], name: pt.lbl }],
+          symbolSize: 8,
+          itemStyle: { color: pal.accent, borderColor: pal.bg, borderWidth: 2 },
+          label: {
+            show: true,
+            position: (index + 2) % 2 === 0 ? 'top' : 'bottom',
+            color: pal.text,
+            formatter: (params: DefaultLabelFormatterCallbackParams) => {
+              const v = (params.data as { value: [number, number, string] }).value
+              return `${v[2]} dB\n${(params.data as { name: string }).name} = ${v[0].toFixed(1)}m`
+            },
+            fontSize: 10
+          },
+          z: 4
+        } as SeriesOption)
+      }
+    })
+  return {
+    animation: false,
+    backgroundColor: pal.bg,
+    grid: { left: 60, right: 10, top: 10, bottom: 20 },
+    title: {
+      right: 6,
+      top: 0,
+      text: `${labels.horizontal} H = ${cov.toFixed(0)}° · θ₋₆ = ${t6.toFixed(0)}°`,
+      textStyle: { color: pal.dim, fontSize: 11, fontWeight: 'normal' }
+    },
+    xAxis: {
+      type: 'value',
+      min: axisMin,
+      max: axisMax,
+      name: labels.forwardDistance,
+      nameLocation: 'end',
+      nameGap: 16,
+      nameRotate: 90,
+      nameTextStyle: { color: pal.dim, fontSize: 11 },
+      axisLine: { lineStyle: { color: pal.dim } },
+      axisLabel: { color: pal.dim, fontSize: 10, formatter: (v: number) => fmtN(Math.abs(v)) },
+      splitLine: { lineStyle: { color: pal.border, type: 'dashed' } }
+    },
+    yAxis: {
+      type: 'value',
+      min: axisMin,
+      max: axisMax,
+      name: labels.lateralDistance,
+      nameLocation: 'middle',
+      nameGap: 0,
+      nameTextStyle: { color: pal.dim, fontSize: 11 },
+      axisLine: { lineStyle: { color: pal.dim } },
+      axisLabel: { color: pal.dim, fontSize: 10, formatter: (v: number) => fmtN(Math.abs(v)) },
+      splitLine: { lineStyle: { color: pal.border, type: 'dashed' } }
+    },
+    series
+  }
+}

+ 6 - 2
src/renderer/src/lib/speaker/types.ts

@@ -6,8 +6,8 @@
 /** 覆盖计算采用的方向 */
 export type Direction = 'H' | 'V'
 
-/** 扬声器参数中的数值字段键(排除方向 dir) */
-export type NumberParamKey = Exclude<keyof SpeakerParams, 'dir'>
+/** 扬声器参数中的数值字段键(排除方向 dir 和 计算结果 result) */
+export type NumberParamKey = Exclude<keyof SpeakerParams, 'dir' | 'result'>
 export type DeviceParamKey = Exclude<keyof DeviceParams, 'dir'>
 /** 设备型号参数(与原始 HTML 输入一一对应) */
 export interface DeviceParams {
@@ -39,6 +39,10 @@ export interface SpeakerParams {
   d2: number
   /** 声学轴心参考点 m */
   d3: number
+  /** 覆盖角度 ° */
+  covAngle?: number
+  /** 计算结果 */
+  result?: CalcResult
 }
 
 /** 计算输出 */

+ 8 - 2
src/renderer/src/locales/lang/en_US.json

@@ -16,7 +16,7 @@
     "featuresTitle": "Key features",
     "features": [
       "Calculate SPL at near-field, far-field and acoustic-axis points",
-      "Visualize the speaker coverage area (side view)",
+      "Visualize the speaker coverage area (side view + top view)",
       "Generate SPL vs. distance curve",
       "Evaluate coverage uniformity with optimization advice",
       "Switch between horizontal / vertical coverage calculation"
@@ -62,14 +62,20 @@
   },
   "diagram": {
     "panel": "Coverage Diagram (Side View)",
+    "topPanel": "Coverage Diagram (Top View)",
     "empty": "Enter parameters and the coverage diagram will be generated",
     "horizontalDistance": "Horizontal distance (m)",
     "height": "Height (m)",
+    "forwardDistance": "Fore-aft distance (m)",
+    "lateralDistance": "Lateral distance (m)",
     "upperBound": "-6dB upper bound",
     "lowerBound": "-6dB lower bound",
+    "leftBound": "-6dB left bound",
+    "rightBound": "-6dB right bound",
     "acousticAxis": "Acoustic axis",
     "coverage": "Coverage",
-    "vertical": "Vertical"
+    "vertical": "Vertical",
+    "horizontal": "Horizontal"
   },
   "result": {
     "panel": "Results",

+ 8 - 2
src/renderer/src/locales/lang/zh_CN.json

@@ -16,7 +16,7 @@
     "featuresTitle": "主要功能",
     "features": [
       "计算近场、远场和轴心处的声压级",
-      "可视化扬声器覆盖区域(侧视图)",
+      "可视化扬声器覆盖区域(侧视图 + 俯视图)",
       "生成声压级随距离变化的曲线图",
       "评估覆盖均匀性并提供优化建议",
       "支持水平/垂直指向角切换计算"
@@ -62,14 +62,20 @@
   },
   "diagram": {
     "panel": "覆盖示意图(侧视图)",
+    "topPanel": "覆盖示意图(俯视图)",
     "empty": "请输入参数后自动生成覆盖示意图",
     "horizontalDistance": "水平距离 (m)",
     "height": "高度 (m)",
+    "forwardDistance": "前后距离 (m)",
+    "lateralDistance": "左右距离 (m)",
     "upperBound": "-6dB 上边界",
     "lowerBound": "-6dB 下边界",
+    "leftBound": "-6dB 左边界",
+    "rightBound": "-6dB 右边界",
     "acousticAxis": "声学中轴",
     "coverage": "覆盖角",
-    "vertical": "垂直"
+    "vertical": "垂直",
+    "horizontal": "水平"
   },
   "result": {
     "panel": "计算结果",

+ 4 - 2
src/renderer/src/main.ts

@@ -26,7 +26,8 @@ import {
   NTabs,
   NCheckbox,
   NFlex,
-  NTabPane
+  NTabPane,
+  NSlider
 } from 'naive-ui'
 import App from './App.vue'
 import { setupI18n } from './locales'
@@ -63,7 +64,8 @@ async function bootstrap(): Promise<void> {
         NSelect,
         NFlex,
         NCheckbox,
-        NTabPane
+        NTabPane,
+        NSlider
       ]
     })
   )