183 lines
5.0 KiB
Vue
183 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import Loading from '@/components/Loading.vue';
|
|
import { ref, onMounted, onUnmounted } from 'vue';
|
|
import { api } from '../services/api';
|
|
import MetricRow from './MetricRow.vue';
|
|
|
|
const d = ref<any>(null);
|
|
const canvas = ref<HTMLCanvasElement | null>(null);
|
|
|
|
const AXES = [
|
|
{ key: 'seismic_x', label: 'X', color: '#ccc' },
|
|
{ key: 'seismic_y', label: 'Y', color: '#ccc' },
|
|
{ key: 'seismic_z', label: 'Z', color: '#ccc' },
|
|
];
|
|
|
|
const W = 640, BAND = 80, H = BAND * AXES.length, MAX_PTS = 120;
|
|
const SEG_W = W / (MAX_PTS - 1);
|
|
const SUB = 4;
|
|
const RIGHT_PAD = 2 * SEG_W;
|
|
|
|
let interval: ReturnType<typeof setInterval>;
|
|
let rafId: number;
|
|
let drawProgress = 0;
|
|
let lastTimestamp = 0;
|
|
const ANIM_DURATION = 1200;
|
|
|
|
type Pt = { x: number; y: number };
|
|
const traces = AXES.map(() => ({
|
|
history: [] as number[],
|
|
prevMax: 1.0,
|
|
buffer: [] as Pt[],
|
|
}));
|
|
|
|
function buildBuffer(t: (typeof traces)[number], band: number) {
|
|
const pts = t.history;
|
|
if (pts.length < 2) return;
|
|
const mid = band * BAND + BAND / 2, amp = BAND / 2 - 6;
|
|
const newMax = Math.max(...pts, 1.0);
|
|
t.prevMax = t.prevMax + (newMax - t.prevMax) * 0.15; // eased amplitude scale
|
|
const tipX = W - RIGHT_PAD;
|
|
const buf: Pt[] = [];
|
|
|
|
pts.forEach((v, i) => {
|
|
const prev = pts[i - 1] ?? v;
|
|
const prevNorm = prev === 0 ? 0 : (prev / t.prevMax) * amp;
|
|
const norm = v === 0 ? 0 : (v / t.prevMax) * amp;
|
|
const baseX = tipX - (pts.length - i) * SEG_W;
|
|
const s = SEG_W / SUB;
|
|
|
|
// one oscillation per value, amplitude fading prevNorm → norm
|
|
const a0 = prevNorm * 0.75 + norm * 0.25;
|
|
const a1 = prevNorm * 0.25 + norm * 0.75;
|
|
|
|
buf.push({ x: baseX + s, y: mid - a0 }); // up
|
|
buf.push({ x: baseX + s * 2, y: mid - a1 }); // up peak
|
|
buf.push({ x: baseX + s * 3, y: mid + norm }); // down
|
|
buf.push({ x: baseX + s * 4, y: mid }); // back to exact center ✓
|
|
});
|
|
|
|
t.buffer = buf;
|
|
}
|
|
|
|
function drawTrace(ctx: CanvasRenderingContext2D, t: (typeof traces)[number], color: string) {
|
|
const buf = t.buffer;
|
|
if (buf.length <= SUB) return;
|
|
|
|
// slide everything left as the new segment draws in
|
|
const slide = (1 - drawProgress) * SEG_W;
|
|
|
|
const settledCount = buf.length - SUB;
|
|
const anchor = buf[settledCount - 1];
|
|
const animFrac = drawProgress * (SUB + 1);
|
|
const animFloor = Math.floor(animFrac);
|
|
const animRemainder = animFrac - animFloor;
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1;
|
|
ctx.lineJoin = 'round';
|
|
|
|
for (let i = 0; i < settledCount; i++) {
|
|
const p = <any>buf[i];
|
|
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
|
}
|
|
|
|
const animPoints = <any>[anchor, ...buf.slice(settledCount)];
|
|
|
|
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
|
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
|
|
}
|
|
|
|
if (animFloor < animPoints.length - 1 && animRemainder > 0) {
|
|
const curr = animPoints[animFloor];
|
|
const next = animPoints[animFloor + 1];
|
|
ctx.lineTo(
|
|
curr.x + (next.x - curr.x) * animRemainder + slide,
|
|
curr.y + (next.y - curr.y) * animRemainder,
|
|
);
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
function draw(timestamp: number) {
|
|
const ctx = canvas.value?.getContext('2d');
|
|
if (!ctx) { rafId = requestAnimationFrame(draw); return; }
|
|
|
|
const dt = Math.min(timestamp - lastTimestamp, 100);
|
|
lastTimestamp = timestamp;
|
|
drawProgress = Math.min(1, drawProgress + dt / ANIM_DURATION);
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
AXES.forEach((_, i) => {
|
|
const mid = i * BAND + BAND / 2;
|
|
|
|
// Axis label — top-left of each band
|
|
ctx.font = '16px monospace';
|
|
ctx.fillStyle = (<any>AXES)[i].color;
|
|
ctx.globalAlpha = 0.6;
|
|
ctx.fillText((<any>AXES)[i].label, W - 20, i * BAND + 14);
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Center line per band — full width
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
|
ctx.lineWidth = 1;
|
|
ctx.moveTo(0, mid);
|
|
ctx.lineTo(W, mid);
|
|
ctx.stroke();
|
|
|
|
drawTrace(ctx, <any>traces[i], (<any>AXES)[i].color);
|
|
});
|
|
|
|
rafId = requestAnimationFrame(draw);
|
|
}
|
|
|
|
async function poll() {
|
|
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
|
AXES.forEach((axis, i) => {
|
|
const t = <any>traces[i];
|
|
t.history.push(d.value[axis.key] ?? 0);
|
|
if (t.history.length > MAX_PTS) t.history.shift();
|
|
buildBuffer(t, i);
|
|
});
|
|
drawProgress = 0;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await poll();
|
|
lastTimestamp = performance.now();
|
|
rafId = requestAnimationFrame(draw);
|
|
interval = setInterval(poll, 1000);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(interval);
|
|
cancelAnimationFrame(rafId);
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
canvas { width: 100%; height: 180px; }
|
|
</style>
|
|
|
|
<template>
|
|
<div class="card">
|
|
<div class="card-title">🫨 Seismic</div>
|
|
<div v-if="!d" class="p-2">
|
|
<div class="w-100 pos-rel br-2 overflow-hidden mb-2" style="height: 30px">
|
|
<Loading />
|
|
</div>
|
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 150px">
|
|
<Loading />
|
|
</div>
|
|
</div>
|
|
<template v-else>
|
|
<MetricRow label="Magnitude" :value="d.seismic_magnitude?.toFixed(1)" metric-key="seismic_magnitude" :data="d" />
|
|
<canvas ref="canvas" :width="W" :height="H" />
|
|
</template>
|
|
</div>
|
|
</template>
|