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;
}
}
}

View File

@@ -170,31 +170,145 @@ export function classifyAircraft(row) {
return 'unknown';
}
async function scrapeHexDatabase(icao) {
const url = `https://hexdatabase.com/h/${icao}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const html = await resp.text();
const rowMatch = html.match(new RegExp(`<tr[^>]*>.*?<span[^>]*>.*?${icao}.*?<\/span>.*?<\/tr>`, 's'));
if (!rowMatch) return null;
// 0: Hex (ICAO), 1: Co (Country), 2: Reg (Registration), 3: Atype (Aircraft Type), 4: Operator, 5: Source, 6: MSN (Serial number), 7: Remarks (Description)
const cells = rowMatch.match(/<td[^>]*>.*?<\/td>/g) || [] || null;
const country = cells[1]?.replace(/<[^>]*>/g, '').trim() || null;
const registration = cells[2]?.replace(/<[^>]*>/g, '').trim() || null;
const aircraft = cells[3]?.replace(/<[^>]*>/g, '').trim() || null;
const operator = cells[4]?.replace(/<[^>]*>/g, '').trim() || null;
const serialNumber = cells[6]?.replace(/<[^>]*>/g, '').trim() || null;
return {
country,
registration,
aircraft,
operator,
serialNumber
};
} catch (e) {
return null;
}
}
async function scrapeHexDatabase(icao) {
const url = `https://hexdatabase.com/h/${icao}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const html = await resp.text();
const rowMatch = html.match(new RegExp(`<tr[^>]*>.*?<span[^>]*>.*?${icao}.*?<\/span>.*?<\/tr>`, 's'));
if (!rowMatch) return null;
const cells = rowMatch.match(/<td[^>]*>.*?<\/td>/g) || [];
return {
country: cells[1]?.replace(/<[^>]*>/g, '').trim() || null,
registration: cells[2]?.replace(/<[^>]*>/g, '').trim() || null,
aircraft: cells[3]?.replace(/<[^>]*>/g, '').trim() || null,
operator: cells[4]?.replace(/<[^>]*>/g, '').trim() || null,
serialNumber: cells[6]?.replace(/<[^>]*>/g, '').trim() || null,
};
} catch (e) {
return null;
}
}
async function fetchHexDb(icao) {
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
if (!resp.ok) return null;
const found = await resp.json();
return {
registration: found.Registration || null,
manufacturer: found.Manufacturer || null,
aircraft: found.ICAOTypeCode || null,
model: found.Type || null,
operator: found.RegisteredOwners || null,
};
}
function backfillFromSimilar(aircraft) {
if (!aircraft) return {};
const similar = db.prepare(`
SELECT manufacturer, model, engines, categoryDescription, class
FROM aircraft
WHERE aircraft = ?
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
LIMIT 1
`).get(aircraft);
if (!similar) return {};
return {
manufacturer: similar.manufacturer || null,
model: similar.model || null,
engines: similar.engines || null,
categoryDescription: similar.categoryDescription || null,
class: similar.class || null,
};
}
export async function enrichAircraft(a) {
if (!a.hex) return a;
const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(a.hex.toUpperCase());
if (!row) {
const resp = await fetch(`https://hexdb.io/api/v1/aircraft/${a.hex.toUpperCase()}`);
if (resp.ok) {
const found = await resp.json();
const insert = db.prepare(`
INSERT OR IGNORE INTO aircraft (icao24, registration, manufacturer, aircraft, model, operator)
VALUES (@icao24, @registration, @manufacturer, @aircraft, @model, @operator)
`);
const mapped = {
icao24: a.hex.toUpperCase(),
registration: found.Registration || null,
manufacturer: found.Manufacturer || null,
aircraft: found.ICAOTypeCode || null,
model: found.Type || null,
operator: found.RegisteredOwners || null,
};
insert.run(mapped);
return { ...a, ...mapped, type: classifyAircraft(mapped) };
}
return { ...a, type: isIcaoInMilitaryRange(a.hex) ? 'military' : 'unknown' };
const icao = a.hex.toUpperCase();
// 1. Check own DB first
const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(icao);
if (row?.manufacturer && row?.model) {
return { ...a, ...row, type: classifyAircraft(row) };
}
return { ...a, ...row, type: classifyAircraft(row) };
// 2. Race the two external sources
let found = await Promise.any([
fetchHexDb(icao),
scrapeHexDatabase(icao),
]).catch(() => null);
// Merge with any partial DB row we already have
const merged = { ...row, ...found };
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
const similar = backfillFromSimilar(merged.aircraft);
Object.assign(merged, { ...similar, ...merged }); // don't overwrite fetched data
}
// 4. Save back to DB
if (found) {
db.prepare(`
INSERT INTO aircraft (icao24, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
VALUES (@icao24, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
ON CONFLICT(icao24) DO UPDATE SET
registration = COALESCE(excluded.registration, registration),
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
aircraft = COALESCE(excluded.aircraft, aircraft),
model = COALESCE(excluded.model, model),
operator = COALESCE(excluded.operator, operator),
country = COALESCE(excluded.country, country),
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
engines = COALESCE(excluded.engines, engines),
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
class = COALESCE(excluded.class, class)
`).run({
icao24: icao,
registration: merged.registration || null,
manufacturer: merged.manufacturer || null,
aircraft: merged.aircraft || null,
model: merged.model || null,
operator: merged.operator || null,
country: merged.country || null,
serialNumber: merged.serialNumber || null,
engines: merged.engines || null,
categoryDescription: merged.categoryDescription || null,
class: merged.class || null,
});
}
const result = { icao24: icao, ...merged };
return { ...a, ...result, type: classifyAircraft(result) };
}
export async function getADSB() {