|
@@ -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
|
|
|
|
|
+ }
|
|
|
|
|
+}
|