better seismic drawings, and adsb enrichment
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user