Files
weather-station/client/src/components/Seismic.vue
T
2026-09-10 22:52:44 -04:00

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>