From d34f3cfa0e9aae0659a9e896949b26ad8d62d6e5 Mon Sep 17 00:00:00 2001 From: ztimson Date: Sat, 27 Jun 2026 15:20:21 -0400 Subject: [PATCH] better seismic drawings, and adsb enrichment --- client/src/components/GraphModal.vue | 13 +-- client/src/components/Seismic.vue | 141 +++++++++++++++++++----- client/src/services/adsb.ts | 10 +- client/src/services/ais.ts | 10 +- client/src/services/range.ts | 10 +- server/src/adsb.mjs | 158 +++++++++++++++++++++++---- 6 files changed, 276 insertions(+), 66 deletions(-) diff --git a/client/src/components/GraphModal.vue b/client/src/components/GraphModal.vue index 162b4c7..8e34fc8 100644 --- a/client/src/components/GraphModal.vue +++ b/client/src/components/GraphModal.vue @@ -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; } -
+
@@ -211,10 +209,9 @@ svg { width: 100%; overflow: visible; cursor: crosshair; } to -
- -
- {{ (chart.dot.v as number).toFixed(2) }} +
+ {{ (chart.dot.v as number).toFixed(2) }} +
diff --git a/client/src/components/Seismic.vue b/client/src/components/Seismic.vue index 85be28c..49edd7d 100644 --- a/client/src/components/Seismic.vue +++ b/client/src/components/Seismic.vue @@ -1,52 +1,140 @@ diff --git a/client/src/services/adsb.ts b/client/src/services/adsb.ts index fd08a54..07dfa8e 100644 --- a/client/src/services/adsb.ts +++ b/client/src/services/adsb.ts @@ -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) } diff --git a/client/src/services/ais.ts b/client/src/services/ais.ts index e23c566..f6c01b4 100644 --- a/client/src/services/ais.ts +++ b/client/src/services/ais.ts @@ -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) } diff --git a/client/src/services/range.ts b/client/src/services/range.ts index ccb6fa3..6089f94 100644 --- a/client/src/services/range.ts +++ b/client/src/services/range.ts @@ -11,11 +11,12 @@ export class RangeLayer { private map: Map private layer!: VectorLayer 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; + } } } diff --git a/server/src/adsb.mjs b/server/src/adsb.mjs index ddea59d..b2ec14c 100644 --- a/server/src/adsb.mjs +++ b/server/src/adsb.mjs @@ -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(`]*>.*?]*>.*?${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>/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(`]*>.*?]*>.*?${icao}.*?<\/span>.*?<\/tr>`, 's')); + if (!rowMatch) return null; + const cells = rowMatch.match(/]*>.*?<\/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() {