| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628 |
- <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-select
- v-model="axisMode"
- size="mini"
- style="width: 120px"
- @change="handleAxisModeChange"
- >
- <el-option label="频率(Hz)" value="hz" />
- <el-option label="阶次(Order)" value="order" />
- </el-select>
- <span class="label1">{{ axisModeLabel }}</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 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>
- <p class="hint-text">
- 提示:点击某一采样时刻对应的谱线可高亮该时间(红色);再点同一条谱线取消高亮。
- </p>
- </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,
- axisMode: "hz", // hz | order
- freqRange: null, // 当前频率范围
- markLines: [], // 标注线
- /** 点击高亮的采样时间(时间戳 ms),null 表示未选中 */
- highlightedTime: null,
- };
- },
- computed: {
- axisModeLabel() {
- return this.axisMode === "order" ? "阶次" : "频率";
- },
- },
- watch: {
- data: {
- deep: true,
- immediate: true,
- handler() {
- this.highlightedTime = null;
- this.$nextTick(() => {
- this.renderChart();
- });
- },
- },
- },
- beforeDestroy() {
- const el = this.$refs.chart;
- if (el && typeof el.removeAllListeners === "function") {
- el.removeAllListeners("plotly_click");
- el.removeAllListeners("plotly_relayout");
- }
- if (el && typeof Plotly.purge === "function") {
- Plotly.purge(el);
- }
- },
- methods: {
- calcOrder(freq, rpm) {
- const f = Number(freq);
- const r = Number(rpm);
- if (!Number.isFinite(f) || !Number.isFinite(r) || r <= 0) return null;
- return (60 * f) / r;
- },
- toAxisValue(freq, rpm) {
- if (this.axisMode === "order") {
- return this.calcOrder(freq, rpm);
- }
- const f = Number(freq);
- return Number.isFinite(f) ? f : null;
- },
- handleAxisModeChange() {
- // 单位切换后,旧范围/标注的单位语义失效,清空避免误读
- this.freqRange = null;
- this.freqMin = "";
- this.freqMax = "";
- this.markLines = [];
- this.manualFreq = "";
- this.multiple = 1;
- this.highlightedTime = null;
- this.renderChart();
- },
- /** ✅ 时间解析(核心修复) */
- 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())}`;
- },
- /** ✅ 轴刻度:仅显示 月/日 时:分 */
- formatTimeTick(t) {
- const d = new Date(t);
- const pad = (n) => String(n).padStart(2, "0");
- const md = `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
- const hh = pad(d.getHours());
- const mm = pad(d.getMinutes());
- // 00:00 不展示时间(避免“0点”刷屏)
- if (hh === "00" && mm === "00") return md;
- return `${md} ${hh}:${mm}`;
- },
- renderChart() {
- if (!this.data.length) return;
- /** =========================
- * 1. 分组(时间 + Hz)
- ========================== */
- const groupMap = new Map();
- this.data.forEach(([freq, amp, time, Hz, RPM]) => {
- 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, RPM]);
- });
- /** =========================
- * 2. 转数组 + 排序
- ========================== */
- const groups = [...groupMap.values()];
- // .sort((a, b) => a.time - b.time);
- if (
- this.highlightedTime != null &&
- !groups.some((g) => g.time === this.highlightedTime)
- ) {
- this.highlightedTime = null;
- }
- /** =========================
- * 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 = [];
- let maxAmp = 0;
- // ✅ 时间轴等间隔:用索引轴 + ticktext(避免时间戳间隔不均导致视觉“挤/拉”)
- const uniqueTimes = Array.from(new Set(groups.map((g) => g.time))).sort(
- (a, b) => a - b,
- );
- const timeToIndex = new Map(uniqueTimes.map((t, i) => [t, i]));
- groups.forEach((group, globalIndex) => {
- const { time, Hz, points } = group;
- const sameTimeGroups = timeGroupMap.get(time);
- const index = sameTimeGroups.indexOf(group);
- const total = sameTimeGroups.length;
- // 同一时间点若存在多组(不同采样频率 Hz),用极小偏移避免完全重叠(不影响刻度显示)
- const offsetX = total > 1 ? (index - (total - 1) / 2) * 0.08 : 0;
- const xBase = timeToIndex.get(time) ?? 0;
- const x = [];
- const y = [];
- const z = [];
- const text = [];
- const lineColor = [];
- points.sort((a, b) => a[0] - b[0]);
- points.forEach(([freq, amp, rpm]) => {
- const axisY = this.toAxisValue(freq, rpm);
- if (axisY == null) return;
- if (this.freqRange) {
- if (axisY < this.freqRange.min || axisY > this.freqRange.max)
- return;
- }
- // 轴映射:X=采样时间(等间隔),Y=频率(Hz),Z=幅值
- x.push(xBase + offsetX);
- y.push(axisY);
- z.push(amp);
- if (Number.isFinite(amp)) {
- maxAmp = Math.max(maxAmp, Number(amp));
- }
- lineColor.push(amp);
- const order = this.calcOrder(freq, rpm);
- text.push(
- `时间: ${this.formatTime(time)}<br>` +
- `采样频率: ${Hz} Hz<br>` +
- `频率: ${freq.toFixed(2)} Hz<br>` +
- `阶次: ${order == null ? "-" : order.toFixed(3)}<br>` +
- `加速度(m/s^2): ${amp.toFixed(3)}`,
- );
- });
- traces.push({
- x,
- y,
- z,
- text,
- type: "scatter3d",
- mode: "lines",
- opacity: 0.78,
- line:
- this.highlightedTime != null && time === this.highlightedTime
- ? {
- width: 3,
- color: "#ff0000",
- }
- : {
- width: 2,
- color: lineColor,
- colorscale: "Viridis",
- cauto: true,
- },
- showlegend: false,
- hovertemplate: "%{text}<extra></extra>",
- });
- });
- this._dataTraceCount = groups.length;
- this._traceIndexToTime = groups.map((g) => g.time);
- /** =========================
- * 5. 采样时间刻度(仅 月/日 时:分)
- ========================== */
- const tickvals = uniqueTimes.map((_, i) => i);
- const ticktext = uniqueTimes.map((t) => this.formatTimeTick(t));
- // 仅用于 z 轴标签:去掉 0,避免原点出现两个“0”标签
- const zTickVals =
- maxAmp > 0
- ? Array.from({ length: 5 }, (_, i) => ((i + 1) * maxAmp) / 5)
- : [];
- /** =========================
- * 6. 标注线
- ========================== */
- if (this.markLines.length) {
- const minX = 0;
- const maxX = Math.max(0, uniqueTimes.length - 1);
- this.markLines.forEach((line) => {
- traces.push({
- x: [minX, maxX],
- 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: "#ffffff",
- plot_bgcolor: "#ffffff",
- margin: { l: 0, r: 0, b: 10, t: 26 },
- scene: {
- xaxis: {
- // title: "采样时间",
- title: "",
- tickvals,
- ticktext,
- tickangle: 45,
- gridcolor: "#e5e5e5",
- zeroline: false,
- showspikes: false,
- ticks: "outside",
- tickfont: { size: 10, color: "#606266" },
- titlefont: { size: 10, color: "#606266" },
- backgroundcolor: "#ffffff",
- showbackground: true,
- showline: true,
- // linecolor: "#dcdfe6",
- linecolor: "#e5e5e5",
- linewidth: 1,
- },
- yaxis: {
- title: this.axisMode === "order" ? "阶次 (Order)" : "频率 (Hz)",
- gridcolor: "#e5e5e5",
- zeroline: false,
- showspikes: false,
- ticks: "outside",
- tickfont: { size: 9, color: "#606266" },
- titlefont: { size: 10, color: "#606266" },
- backgroundcolor: "#fafafa",
- showbackground: true,
- showline: true,
- // linecolor: "#dcdfe6",
- linecolor: "#e5e5e5",
- linewidth: 1,
- // tickangle: 90,
- },
- zaxis: {
- title: "",
- gridcolor: "#e5e5e5",
- zeroline: false,
- showspikes: false,
- ticks: "outside",
- tickangle: 90,
- tickmode: "array",
- tickvals: zTickVals,
- tickformat: ".2f",
- tickfont: { size: 10, color: "#606266" },
- titlefont: {
- size: 10,
- color: "#606266",
- },
- backgroundcolor: "#fbfbfb",
- showbackground: true,
- showline: true,
- // linecolor: "#dcdfe6",
- linecolor: "#e5e5e5",
- linewidth: 1,
- },
- aspectmode: "manual",
- // X 采用等间隔索引后,整体更均衡
- aspectratio: { x: 2.4, y: 3.2, z: 1.1 },
- // annotations: [
- // {
- // text: "加速度(m/s^2)",
- // x: 0.92, // 右上角位置可微调
- // y: 0.98,
- // xref: "paper",
- // yref: "paper",
- // showarrow: false,
- // font: { size: 10, color: "#606266" },
- // align: "right",
- // },
- // ],
- camera: {
- up: { x: 0, y: 0, z: 1 },
- center: { x: 0, y: 0, z: 0 },
- // 让时间轴视觉更贴近底边
- // eye: { x: 2.6, y: 1.2, z: 0.8 },
- eye: {
- x: 2.8547815750982184,
- y: 0.3847735823828088,
- z: 0.7363229242526919,
- },
- projection: {
- type: "orthographic",
- },
- },
- },
- };
- /** =========================
- * 8. 渲染(先卸监听再 react,避免重绘过程中 plotly_click 重入 renderChart 栈溢出)
- ========================== */
- const plotEl = this.$refs.chart;
- if (!plotEl) return;
- if (typeof plotEl.removeAllListeners === "function") {
- plotEl.removeAllListeners("plotly_relayout");
- plotEl.removeAllListeners("plotly_click");
- }
- const plotOpts = {
- responsive: true,
- displayModeBar: true,
- displaylogo: false,
- modeBarButtonsToRemove: [
- "zoom3d",
- "pan3d",
- "orbitRotation",
- "tableRotation",
- "resetCameraDefault3d",
- "resetCameraLastSave3d",
- "hoverClosest3d",
- ],
- };
- const done = Plotly.react(plotEl, traces, layout, plotOpts);
- const bind = () => this._bindPlotlyEvents();
- if (done && typeof done.then === "function") {
- done.then(bind).catch(bind);
- } else {
- this.$nextTick(bind);
- }
- },
- _bindPlotlyEvents() {
- const plotElement = this.$refs.chart;
- if (!plotElement || typeof plotElement.on !== "function") return;
- plotElement.on("plotly_relayout", (eventData) => {
- if (eventData && eventData["scene.camera"]) {
- console.log("当前相机视角:", eventData["scene.camera"]);
- }
- });
- plotElement.on("plotly_click", (ev) => {
- const pt = ev.points && ev.points[0];
- if (!pt || pt.curveNumber == null) return;
- const n = this._dataTraceCount || 0;
- if (pt.curveNumber >= n) return;
- const t =
- this._traceIndexToTime && this._traceIndexToTime[pt.curveNumber];
- if (t == null) return;
- this.highlightedTime = this.highlightedTime === t ? null : t;
- this.$nextTick(() => {
- this.renderChart();
- });
- });
- },
- 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++) {
- const val = base * i;
- newLines.push({
- freq: val,
- label:
- this.axisMode === "order"
- ? `${i}x (${val}Order)`
- : `${i}x (${val}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: 65%;
- margin: 5px 0;
- }
- }
- .hint-text {
- width: 100%;
- margin: 4px 0 0;
- font-size: 12px;
- color: #909399;
- line-height: 1.45;
- }
- .panel-block {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .label {
- font-size: 12px;
- color: #666;
- }
- .label1 {
- font-size: 12px;
- color: #666;
- /* 父级是 flex:仅写 width 会被 flex-shrink 压缩,需固定 flex 基准且不收缩 */
- flex: 0 0 40px;
- box-sizing: border-box;
- }
- .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;
- }
- /* 勿用 flex:1,否则会拉伸子项,内联 width 无法按预期生效 */
- .el-select {
- flex: 0 0 auto;
- }
- </style>
|