| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- <template>
- <div>
- <div ref="chart" class="waterfall-chart" id="waterfall-chart"></div>
- <div class="control-panel">
- <!-- 频率范围 -->
- <div class="box full-row">
- <div class="panel-block">
- <span class="label1">频率</span>
- <el-input v-model="freqMin" size="mini" placeholder="下限" />
- <span>~</span>
- <el-input v-model="freqMax" size="mini" placeholder="上限" />
- <el-button size="mini" type="primary" @click="handleFreqRange">
- 应用
- </el-button>
- </div>
- </div>
- </div>
- <div class="control-panel">
- <!-- 手动标注 -->
- <div class="panel-block">
- <span class="label1">标注</span>
- <el-input v-model="manualFreq" size="mini" placeholder="频率" />
- <el-input v-model="multiple" size="mini" placeholder="倍频" />
- <el-button size="mini" type="success" @click="handleMark"
- >标注</el-button
- >
- <el-button size="mini" type="info" @click="removeMark">清除</el-button>
- </div>
- </div>
- </div>
- </template>
- <script>
- import Plotly from "plotly.js-dist-min";
- export default {
- name: "Waterfall3D",
- props: {
- // 数据格式:[freq, amp, time]
- data: {
- type: Array,
- default: () => [],
- },
- },
- data() {
- return {
- freqMin: "",
- freqMax: "",
- manualFreq: "",
- multiple: 1,
- freqRange: null, // 当前频率范围
- markLines: [], // 标注线
- };
- },
- watch: {
- data: {
- deep: true,
- immediate: true,
- handler() {
- this.$nextTick(() => {
- this.renderChart();
- });
- },
- },
- },
- methods: {
- /** ✅ 时间解析(核心修复) */
- parseTime(time) {
- if (typeof time === "number") {
- return time < 1e12 ? time * 1000 : time;
- }
- if (typeof time === "string") {
- return new Date(time.replace(/-/g, "/")).getTime();
- }
- return new Date(time).getTime();
- },
- /** ✅ 时间格式化 */
- formatTime(t) {
- const d = new Date(t);
- const pad = (n) => String(n).padStart(2, "0");
- return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(
- d.getDate(),
- )} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
- },
- renderChart() {
- if (!this.data.length) return;
- /** =========================
- * 1. 分组(时间 + Hz)
- ========================== */
- const groupMap = new Map();
- this.data.forEach(([freq, amp, time, Hz]) => {
- const t = this.parseTime(time);
- const key = `${t}_${Hz}`;
- if (!groupMap.has(key)) {
- groupMap.set(key, {
- time: t,
- Hz,
- points: [],
- });
- }
- groupMap.get(key).points.push([freq, amp]);
- });
- /** =========================
- * 2. 转数组 + 排序
- ========================== */
- const groups = [...groupMap.values()];
- // .sort((a, b) => a.time - b.time);
- /** =========================
- * 3. 同时间分组(关键!!!)
- ========================== */
- const timeGroupMap = new Map();
- groups.forEach((g) => {
- if (!timeGroupMap.has(g.time)) {
- timeGroupMap.set(g.time, []);
- }
- timeGroupMap.get(g.time).push(g);
- });
- /** =========================
- * 4. traces
- ========================== */
- const traces = [];
- groups.forEach((group, globalIndex) => {
- const { time, Hz, points } = group;
- const sameTimeGroups = timeGroupMap.get(time);
- const index = sameTimeGroups.indexOf(group);
- const total = sameTimeGroups.length;
- const spread = 200; // 👈 控制展开宽度(可调)
- // 👉 同时间“对称展开”
- const offsetX = total > 1 ? (index - (total - 1) / 2) * spread : 0;
- const x = [];
- const y = [];
- const z = [];
- const text = [];
- points.sort((a, b) => a[0] - b[0]);
- points.forEach(([freq, amp]) => {
- if (this.freqRange) {
- if (freq < this.freqRange.min || freq > this.freqRange.max) return;
- }
- x.push(time + offsetX); // 👈 关键
- y.push(freq);
- z.push(amp + globalIndex * 0.02); // 防Z重叠
- text.push(
- `时间: ${this.formatTime(time)}<br>` +
- `采样频率: ${Hz} Hz<br>` +
- `频率: ${freq.toFixed(2)} Hz<br>` +
- `幅值: ${amp.toFixed(3)}`,
- );
- });
- traces.push({
- x,
- y,
- z,
- text,
- type: "scatter3d",
- mode: "lines",
- opacity: 0.7,
- line: {
- width: 2,
- color: "#162961",
- },
- showlegend: false,
- hovertemplate: "%{text}<extra></extra>",
- });
- });
- /** =========================
- * 5. X轴刻度(时间 + Hz)
- ========================== */
- const tickvals = groups.map((g) => g.time);
- const ticktext = groups.map((g) => {
- return `${this.formatTime(g.time)}-${g.Hz}Hz`;
- });
- /** =========================
- * 6. 标注线
- ========================== */
- if (this.markLines.length) {
- const minTime = Math.min(...groups.map((g) => g.time));
- const maxTime = Math.max(...groups.map((g) => g.time));
- this.markLines.forEach((line) => {
- traces.push({
- x: [minTime, maxTime],
- y: [line.freq, line.freq],
- z: [0, 0],
- type: "scatter3d",
- mode: "lines+text",
- line: {
- color: "#ff0000",
- width: 4,
- dash: "dash",
- },
- text: ["", line.label],
- textposition: "top right",
- showlegend: false,
- hovertemplate: `${line.label}<extra></extra>`,
- });
- });
- }
- /** =========================
- * 7. layout
- ========================== */
- const layout = {
- // title: "3D 瀑布频谱图",
- paper_bgcolor: "#f5f7fa",
- margin: { l: 0, r: 0, b: 0, t: 40 },
- scene: {
- xaxis: {
- autorange: "reversed",
- title: "时间",
- tickvals,
- ticktext,
- tickangle: 30,
- gridcolor: "#fff",
- backgroundcolor: "#e0e7f1",
- showbackground: true,
- },
- yaxis: {
- title: "频率 (Hz)",
- gridcolor: "#fff",
- backgroundcolor: "#e0e7f1",
- showbackground: true,
- },
- zaxis: {
- title: "幅值",
- gridcolor: "#fff",
- backgroundcolor: "#e0e7f1",
- showbackground: true,
- },
- aspectmode: "manual",
- aspectratio: { x: 2.0, y: 2.8, z: 1.2 },
- camera: {
- up: {
- x: -0.1644947035315824,
- y: -0.07969781808287146,
- z: 0.9831529638377166,
- },
- center: {
- x: -0.052807476121180814,
- y: 0.02451796399554085,
- z: -0.022911006648570736,
- },
- eye: {
- x: 2.980700714870927,
- y: 1.6273671421077383,
- z: 0.6145682420564063,
- },
- projection: {
- type: "orthographic",
- },
- },
- },
- };
- /** =========================
- * 8. 渲染
- ========================== */
- Plotly.react(this.$refs.chart, traces, layout, {
- responsive: true,
- displayModeBar: false,
- });
- },
- // renderChart() {
- // if (!this.data.length) return;
- // /** =========================
- // * 1. 分组
- // ========================== */
- // // console.log(this.data, "groupMap");
- // const groupMap = new Map();
- // this.data.forEach(([freq, amp, time, Hz]) => {
- // const t = this.parseTime(time);
- // const key = `${t}_${Hz}`; // 👈 用key区分
- // if (!groupMap.has(key)) {
- // groupMap.set(key, {
- // time: t,
- // Hz,
- // points: [],
- // });
- // }
- // groupMap.get(key).points.push([freq, amp]);
- // });
- // /** =========================
- // * 2. 排序
- // ========================== */
- // const times = [...groupMap.keys()].sort((a, b) => a - b);
- // /** =========================
- // * 3. traces
- // ========================== */
- // const traces = [];
- // times.forEach((time, index) => {
- // const points = groupMap.get(time);
- // points.sort((a, b) => a[0] - b[0]);
- // const x = [];
- // const y = [];
- // const z = [];
- // const text = [];
- // // const offset = index * 0.02;
- // points.forEach(([freq, amp]) => {
- // if (this.freqRange) {
- // if (freq < this.freqRange.min || freq > this.freqRange.max) return;
- // }
- // const offsetX = index * 50; // 👈 新增
- // const offsetZ = index * 0.02; // 👈 你已有
- // x.push(time + offsetX);
- // y.push(freq);
- // z.push(amp + offsetZ);
- // text.push(
- // `时间: ${this.formatTime(time)}<br>` +
- // `频率: ${freq.toFixed(2)} Hz<br>` +
- // `幅值: ${amp.toFixed(3)}`,
- // );
- // });
- // traces.push({
- // x,
- // y,
- // z,
- // text,
- // type: "scatter3d",
- // mode: "lines",
- // opacity: 0.7,
- // line: {
- // width: 2,
- // color: "#162961",
- // },
- // showlegend: false,
- // hovertemplate: "%{text}<extra></extra>",
- // });
- // });
- // /** =========================
- // * ✅ 标注线(修复重复问题)
- // ========================== */
- // if (this.markLines.length) {
- // const minTime = Math.min(...times);
- // const maxTime = Math.max(...times);
- // this.markLines.forEach((line) => {
- // traces.push({
- // x: [minTime, maxTime],
- // y: [line.freq, line.freq],
- // z: [0, 0],
- // type: "scatter3d",
- // mode: "lines+text",
- // line: {
- // color: "#ff0000",
- // width: 4,
- // dash: "dash",
- // },
- // text: ["", line.label],
- // textposition: "top right",
- // showlegend: false,
- // hovertemplate: `${line.label}<extra></extra>`,
- // });
- // });
- // }
- // // 👉 抽稀刻度(防止重叠)
- // const step = Math.ceil(times.length / 8);
- // const tickvals = times.filter((_, i) => i % step === 0);
- // const ticktext = tickvals.map(this.formatTime);
- // /** =========================
- // * 5. 布局(工业风)
- // ========================== */
- // const layout = {
- // title: "3D 瀑布频谱图",
- // paper_bgcolor: "#f5f7fa",
- // margin: { l: 0, r: 0, b: 0, t: 40 },
- // scene: {
- // xaxis: {
- // autorange: "reversed", //轴方向
- // title: "时间",
- // tickvals,
- // ticktext,
- // dtick: "D3",
- // // showbackground: true,
- // // backgroundcolor: "#ffffff",
- // // gridcolor: "#e0e7f1",
- // gridcolor: "#fff",
- // backgroundcolor: "#e0e7f1",
- // showbackground: true,
- // linecolor: "black",
- // ticks: "outside",
- // ticklen: 10,
- // tickcolor: "black",
- // zeroline: false,
- // tickangle: -10,
- // tickangle: 30, // 👈 倾斜
- // margin: {
- // l: 20,
- // r: 120, // 👉 关键!!给时间留空间
- // t: 40,
- // b: 40,
- // },
- // },
- // yaxis: {
- // title: "频率 (Hz)",
- // gridcolor: "#fff",
- // backgroundcolor: "#e0e7f1",
- // showbackground: true,
- // linecolor: "black",
- // ticks: "outside",
- // ticklen: 10,
- // tickcolor: "black",
- // zeroline: false,
- // tickangle: -10,
- // tickangle: 0,
- // },
- // zaxis: {
- // title: "幅值",
- // gridcolor: "#fff",
- // backgroundcolor: "#e0e7f1",
- // showbackground: true,
- // linecolor: "black",
- // ticks: "outside",
- // ticklen: 10,
- // tickcolor: "black",
- // zeroline: false,
- // tickangle: -10,
- // tickangle: 0,
- // },
- // /** ✅ 核心1:比例(决定“扁不扁”) */
- // // bgcolor: "#e5ecf6",
- // // gridcolor: "#fff",
- // aspectmode: "manual",
- // aspectratio: { x: 2.0, y: 2.8, z: 1.2 },
- // /** ✅ 核心2:相机(决定轴在哪边) */
- // camera: {
- // up: {
- // x: -0.1644947035315824,
- // y: -0.07969781808287146,
- // z: 0.9831529638377166,
- // },
- // center: {
- // x: -0.052807476121180814,
- // y: 0.02451796399554085,
- // z: -0.022911006648570736,
- // },
- // eye: {
- // x: 2.980700714870927,
- // y: 1.6273671421077383,
- // z: 0.6145682420564063,
- // },
- // projection: {
- // type: "orthographic",
- // },
- // },
- // },
- // };
- // /** =========================
- // * 6. 渲染
- // ========================== */
- // Plotly.react(this.$refs.chart, traces, layout, {
- // responsive: true,
- // displayModeBar: false,
- // });
- // // 监听图表的 relayout 事件,获取并输出相机视角
- // const plotElement = document.getElementById(`waterfall-chart`);
- // plotElement.on("plotly_relayout", function (eventData) {
- // // 在每次布局变更时,打印当前相机视角
- // if (eventData["scene.camera"]) {
- // console.log(
- // "当前相机视角:",
- // eventData["scene.camera"],
- // eventData["scene.aspectratio"],
- // );
- // }
- // });
- // },
- removeMark() {
- this.markLines = [];
- this.manualFreq = "";
- this.multiple = 1;
- this.renderChart();
- },
- handleFreqRange() {
- const min = Number(this.freqMin);
- const max = Number(this.freqMax);
- if (isNaN(min) || isNaN(max)) return;
- this.freqRange = { min, max };
- this.renderChart();
- },
- handleMark() {
- const base = Number(this.manualFreq);
- const multi = Number(this.multiple) || 1;
- if (isNaN(base)) return;
- const newLines = [];
- for (let i = 1; i <= multi; i++) {
- newLines.push({
- freq: base * i,
- label: `${i}x (${base * i}Hz)`,
- });
- }
- this.markLines = [...this.markLines, ...newLines];
- this.renderChart();
- },
- },
- };
- </script>
- <style scoped>
- .waterfall-chart {
- width: 100%;
- height: 520px;
- }
- .control-panel {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 12px;
- padding: 8px 12px;
- background: #f5f7fa;
- border: 1px solid #ddd;
- border-radius: 6px;
- margin-bottom: 10px;
- }
- /* 🌟 关键:独占一行 */
- .full-row {
- width: 100%;
- .panel-block {
- width: 45%;
- }
- }
- .panel-block {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .label {
- font-size: 12px;
- color: #666;
- }
- .label1 {
- font-size: 12px;
- color: #666;
- display: inline-block;
- width: 75px;
- }
- .btn-group {
- display: flex;
- gap: 6px;
- }
- .btn {
- padding: 2px 8px;
- font-size: 12px;
- border: 1px solid #ccc;
- border-radius: 3px;
- cursor: pointer;
- }
- .btn.active {
- background: #409eff;
- color: #fff;
- }
- .full-width {
- width: 100%;
- }
- .el-cascader {
- font-size: 12px;
- }
- .el-cascader__tags {
- max-width: 240px;
- overflow: hidden;
- }
- .feature-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 10px;
- width: 100%;
- }
- .feature-item {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .el-select {
- flex: 1;
- }
- </style>
|