better seismic drawings, and adsb enrichment

This commit is contained in:
2026-06-27 15:20:21 -04:00
parent f3356ff05c
commit d34f3cfa0e
6 changed files with 276 additions and 66 deletions

View File

@@ -156,7 +156,6 @@ function onSvgClick(e: MouseEvent) {
}
.controls {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 16px;
button {
font-size: 11px; padding: 4px 12px; border-radius: 6px;
border: 1px solid var(--border); background: none; color: var(--text-muted); cursor: pointer;
@@ -172,9 +171,8 @@ function onSvgClick(e: MouseEvent) {
}
}
.current-val {
font-size: 13px; color: var(--text-muted); margin-bottom: 12px;
font-size: 13px; color: var(--text-muted);
span { color: var(--accent); font-weight: 700; }
.val-time { font-size: 11px; margin-left: 6px; opacity: 0.6; }
}
.chart-wrap { position: relative; }
.loading-overlay {
@@ -202,7 +200,7 @@ svg { width: 100%; overflow: visible; cursor: crosshair; }
<button class="close-btn" @click="emit('close')"></button>
</div>
<div class="controls gap-0">
<div class="controls gap-0 mb-2">
<button :class="{active: mode === 'hourly'}" style="border-radius: 6px 0 0 6px" @click="mode = 'hourly'">Hourly</button>
<button :class="{active: mode === 'daily'}" style="border-radius: 0 6px 6px 0" @click="mode = 'daily'">Daily</button>
</div>
@@ -211,10 +209,9 @@ svg { width: 100%; overflow: visible; cursor: crosshair; }
<span style="font-size:11px;color:var(--text-muted)">to</span>
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customEnd"/>
<button class="apply-btn" @click="fetchData">Apply</button>
</div>
<div v-if="chart.dot" class="current-val">
<span>{{ (chart.dot.v as number).toFixed(2) }}</span>
<div v-if="chart.dot" class="flex-fill flex-r justify-end align-items-center current-val">
<span>{{ (chart.dot.v as number).toFixed(2) }}</span>
</div>
</div>
<div class="chart-wrap">

View File

@@ -1,52 +1,140 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import {ref, onMounted, onUnmounted} from 'vue';
import {api} from '../services/api';
import { ref, onMounted, onUnmounted } from 'vue';
import { api } from '../services/api';
import MetricRow from './MetricRow.vue';
const d = ref<any>(null);
const history = ref<number[]>([]);
const W = 320, H = 60, MAX_PTS = 120;
const canvas = ref<HTMLCanvasElement | null>(null);
const W = 640, H = 120, MAX_PTS = 120;
const SEG_W = W / (MAX_PTS - 1);
const SUB = 4;
const PAD_SEGS = 3; // segments of breathing room at the right edge
let interval: ReturnType<typeof setInterval>;
let rafId: number;
let drawProgress = 0;
let lastTimestamp = 0;
const ANIM_DURATION = 950;
function toPath(pts: number[]) {
if (!pts.length) return '';
const mid = H / 2, amp = H / 2 - 4;
const max = Math.max(Math.max(...pts), 1.0); // 1.0 = min scale floor
let pointBuffer: { x: number; y: number }[] = [];
let prevMax = 1.0;
// Fixed px per point — path grows left→right, never stretches
const segW = W / (MAX_PTS - 1);
const subPts: { x: number; y: number }[] = [];
function buildBuffer(pts: number[]) {
if (pts.length < 2) return;
const mid = H / 2, amp = H / 2 - 6;
const newMax = Math.max(...pts, 1.0);
prevMax = prevMax + (newMax - prevMax) * 0.15;
const max = prevMax;
const buf: { x: number; y: number }[] = [];
pts.forEach((v, i) => {
const prev = pts[i - 1] ?? v;
const prevNorm = (prev / max) * amp;
const norm = (v / max) * amp;
const baseX = i * segW;
const s = segW / 4;
const baseX = i * SEG_W;
const s = SEG_W / SUB;
subPts.push({ x: baseX, y: mid - norm * 0.5 });
subPts.push({ x: baseX + s, y: mid - norm });
subPts.push({ x: baseX + s * 2, y: mid + norm });
subPts.push({ x: baseX + s * 3, y: mid });
const a0 = prevNorm * 0.75 + norm * 0.25;
const a1 = prevNorm * 0.25 + norm * 0.75;
const a2 = norm;
buf.push({ x: baseX, y: mid - a0 }); // up
buf.push({ x: baseX + s, y: mid - a1 }); // up peak
buf.push({ x: baseX + s * 2, y: mid + a2 }); // down
buf.push({ x: baseX + s * 3, y: mid }); // back to exact center ✓
});
return subPts.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`
).join(' ');
pointBuffer = buf;
}
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);
// Center line — full width
ctx.beginPath();
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
ctx.moveTo(0, H / 2);
ctx.lineTo(W, H / 2);
ctx.stroke();
if (pointBuffer.length < 2) { rafId = requestAnimationFrame(draw); return; }
// Shift the whole path left by PAD_SEGS segments so tip never hits the edge
const xOffset = PAD_SEGS * SEG_W;
const settledCount = pointBuffer.length - SUB;
const anchor = pointBuffer[settledCount - 1];
const totalSteps = SUB + 1;
const animFrac = drawProgress * totalSteps;
const animFloor = Math.floor(animFrac);
const animRemainder = animFrac - animFloor;
ctx.beginPath();
ctx.strokeStyle = '#4d9fff';
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
for (let i = 0; i < settledCount; i++) {
const p = pointBuffer[i];
const x = p.x - xOffset;
i === 0 ? ctx.moveTo(x, p.y) : ctx.lineTo(x, p.y);
}
const animPoints = [
anchor,
...Array.from({ length: SUB }, (_, i) => pointBuffer[settledCount + i]),
];
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
ctx.lineTo(animPoints[i].x - xOffset, 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 - xOffset,
curr.y + (next.y - curr.y) * animRemainder,
);
}
ctx.stroke();
rafId = requestAnimationFrame(draw);
}
async function poll() {
d.value = await api.current('seismic_magnitude');
history.value.push(d.value.seismic_magnitude ?? 0);
if(history.value.length > MAX_PTS) history.value.shift();
if (history.value.length > MAX_PTS) history.value.shift();
buildBuffer(history.value);
drawProgress = 0;
}
onMounted(async () => { await poll(); interval = setInterval(poll, 1000); });
onUnmounted(() => clearInterval(interval));
onMounted(async () => {
await poll();
lastTimestamp = performance.now();
rafId = requestAnimationFrame(draw);
interval = setInterval(poll, 1000);
});
onUnmounted(() => {
clearInterval(interval);
cancelAnimationFrame(rafId);
});
</script>
<style scoped lang="scss">
svg { width: 100%; height: 60px; overflow: visible; }
canvas { width: 100%; height: 60px; }
</style>
<template>
@@ -59,10 +147,7 @@ svg { width: 100%; height: 60px; overflow: visible; }
</div>
<template v-else>
<MetricRow label="Magnitude" :value="d.seismic_magnitude?.toFixed(1)" metric-key="seismic_magnitude" :data="d" />
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
<line :x1="0" :y1="H/2" :x2="W" :y2="H/2" stroke="var(--border)" stroke-width="1" />
<path :d="toPath(history)" fill="none" stroke="var(--accent)" stroke-width="1.5" />
</svg>
<canvas ref="canvas" :width="W" :height="H" />
</template>
</div>
</template>

View File

@@ -62,9 +62,13 @@ export class AirTrafficLayer {
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
await this._fetch()
this._draw()
this._refreshPopups()
try {
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err)
}
}, 1_000)
}

View File

@@ -69,9 +69,13 @@ export class AISLayer {
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
await this._fetch()
this._draw()
this._refreshPopups()
try {
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err);
}
}, 5_000)
}

View File

@@ -11,11 +11,12 @@ export class RangeLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
visible = false
interval: any;
constructor(map: Map) { this.map = map }
async show() {
if (this.visible) return
if(this.visible) this.hide(false);
this.visible = true
const points: [number, number][] = await fetch(`${BASE}/api/range`).then(r => r.json())
@@ -30,11 +31,16 @@ export class RangeLayer {
const source = new VectorSource({ features: [feature] })
this.layer = new VectorLayer({ source, zIndex: 50 })
this.map.addLayer(this.layer)
if(!this.interval) this.interval = setInterval(() => this.show, 60_000);
}
hide() {
hide(stop = true) {
if (!this.visible) return
this.visible = false
this.map.removeLayer(this.layer)
if(stop && this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}