- {!meshSessionActive && (
+ {meshView === 'settings' && (
+
+
+
+
+
MESHTASTIC MQTT
+
+ Public Mesh is separate from Wormhole. Turning MQTT on disables the private Wormhole lane for MeshChat.
+
+
+
+ {meshMqttConnectionLabel}
+
+
+ {meshMqttSettings?.runtime?.last_error && (
+
+ LAST ERROR: {meshMqttSettings.runtime.last_error}
+
+ )}
+ {meshMqttRunning && !meshMqttConnected && !meshMqttSettings?.runtime?.last_error && (
+
+ MQTT bridge is starting. Live messages appear after broker connect.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {meshMqttStatusText && (
+
{meshMqttStatusText}
+ )}
+
+ )}
+ {!meshSessionActive && meshView !== 'settings' && (
MeshChat is off. Turn it on to connect the public mesh lane.
diff --git a/frontend/src/components/MeshChat/useMeshChatController.ts b/frontend/src/components/MeshChat/useMeshChatController.ts
index bac9b23..10f436b 100644
--- a/frontend/src/components/MeshChat/useMeshChatController.ts
+++ b/frontend/src/components/MeshChat/useMeshChatController.ts
@@ -15,9 +15,6 @@ import {
import type { DesktopControlAuditReport } from '@/lib/desktopControlContract';
import { fetchPrivacyProfileSnapshot } from '@/mesh/controlPlaneStatusClient';
import {
- clearBrowserIdentityState,
- derivePublicMeshAddress,
- generateNodeKeys,
getNodeIdentity,
getStoredNodeDescriptor,
getWormholeIdentityDescriptor,
@@ -31,9 +28,7 @@ import {
updateContact,
blockContact,
getDMNotify,
- getPublicKeyAlgo,
nextSequence,
- signEvent,
verifyEventSignature,
verifyRawSignature,
purgeBrowserContactGraph,
@@ -130,7 +125,6 @@ import {
preferredDmPeerId,
} from '@/mesh/meshDmConsent';
import { deriveSasPhrase } from '@/mesh/meshSas';
-import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
import { validateEventPayload } from '@/mesh/meshSchema';
import {
buildDmTrustHint,
@@ -223,6 +217,94 @@ interface GateCompatConsentPromptState {
reason: string;
}
+interface MeshMqttRuntime {
+ enabled?: boolean;
+ running?: boolean;
+ connected?: boolean;
+ broker?: string;
+ port?: number;
+ username?: string;
+ client_id?: string;
+ message_log_size?: number;
+ signal_log_size?: number;
+ last_error?: string;
+ last_connected_at?: number;
+ last_disconnected_at?: number;
+}
+
+interface MeshMqttSettings {
+ enabled: boolean;
+ broker: string;
+ port: number;
+ username: string;
+ uses_default_credentials?: boolean;
+ has_password: boolean;
+ has_psk: boolean;
+ include_default_roots: boolean;
+ extra_roots: string;
+ extra_topics: string;
+ runtime?: MeshMqttRuntime;
+}
+
+interface MeshMqttForm {
+ broker: string;
+ port: string;
+ username: string;
+ password: string;
+ psk: string;
+ include_default_roots: boolean;
+ extra_roots: string;
+ extra_topics: string;
+}
+
+const PUBLIC_MESH_ADDRESS_KEY = 'sb_public_meshtastic_address';
+
+function normalizePublicMeshAddress(value: string): string {
+ const raw = String(value || '').trim().toLowerCase();
+ const body = raw.startsWith('!') ? raw.slice(1) : raw;
+ if (!/^[0-9a-f]{8}$/.test(body)) return '';
+ return `!${body}`;
+}
+
+function readStoredPublicMeshAddress(): string {
+ if (typeof window === 'undefined') return '';
+ try {
+ return normalizePublicMeshAddress(window.localStorage.getItem(PUBLIC_MESH_ADDRESS_KEY) || '');
+ } catch {
+ return '';
+ }
+}
+
+function writeStoredPublicMeshAddress(address: string): void {
+ if (typeof window === 'undefined') return;
+ const normalized = normalizePublicMeshAddress(address);
+ if (!normalized) return;
+ try {
+ window.localStorage.setItem(PUBLIC_MESH_ADDRESS_KEY, normalized);
+ } catch {
+ /* ignore */
+ }
+}
+
+function clearStoredPublicMeshAddress(): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.removeItem(PUBLIC_MESH_ADDRESS_KEY);
+ } catch {
+ /* ignore */
+ }
+}
+
+function createPublicMeshAddress(): string {
+ if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
+ const value = new Uint32Array(1);
+ window.crypto.getRandomValues(value);
+ if (value[0]) return `!${value[0].toString(16).padStart(8, '0')}`;
+ }
+ const fallback = Math.floor((Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0);
+ return `!${fallback.toString(16).padStart(8, '0')}`;
+}
+
function describeGateCompatConsentRequired(): string {
return 'Local gate runtime is unavailable for this room.';
}
@@ -315,8 +397,21 @@ export function useMeshChatController({
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [meshSessionActive, setMeshSessionActive] = useState(false);
const [publicMeshAddress, setPublicMeshAddress] = useState('');
- const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
+ const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings'>('channel');
const [meshDirectTarget, setMeshDirectTarget] = useState('');
+ const [meshMqttSettings, setMeshMqttSettings] = useState
(null);
+ const [meshMqttForm, setMeshMqttForm] = useState({
+ broker: 'mqtt.meshtastic.org',
+ port: '1883',
+ username: '',
+ password: '',
+ psk: '',
+ include_default_roots: true,
+ extra_roots: '',
+ extra_topics: '',
+ });
+ const [meshMqttBusy, setMeshMqttBusy] = useState(false);
+ const [meshMqttStatusText, setMeshMqttStatusText] = useState('');
// Identity
const [identity, setIdentity] = useState(null);
@@ -329,15 +424,123 @@ export function useMeshChatController({
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
- const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null;
- const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty();
- const publicIdentity = meshSessionActive ? storedPublicIdentity : null;
- const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity;
+ const storedPublicMeshAddress = clientHydrated ? readStoredPublicMeshAddress() : '';
+ const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicMeshAddress);
+ const publicIdentity = null;
+ const hasPublicLaneIdentity = meshSessionActive && Boolean(publicMeshAddress);
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
const dmSendQueue = useRef<(() => Promise)[]>([]);
+ const meshMqttRuntime = meshMqttSettings?.runtime;
+ const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled);
+ const meshMqttRunning = Boolean(meshMqttRuntime?.running);
+ const meshMqttConnected = Boolean(meshMqttRuntime?.connected);
+ const meshMqttConnectionLabel = !meshMqttEnabled
+ ? 'MQTT OFF'
+ : meshMqttConnected
+ ? 'MQTT LIVE'
+ : meshMqttRunning
+ ? 'MQTT CONNECTING'
+ : 'MQTT STARTING';
+
+ const applyMeshMqttSettings = useCallback((data: MeshMqttSettings) => {
+ setMeshMqttSettings(data);
+ setMeshMqttForm((prev) => ({
+ broker: data.broker || prev.broker || 'mqtt.meshtastic.org',
+ port: String(data.port || prev.port || '1883'),
+ username: data.uses_default_credentials ? '' : data.username || prev.username || '',
+ password: '',
+ psk: '',
+ include_default_roots: Boolean(data.include_default_roots),
+ extra_roots: data.extra_roots || '',
+ extra_topics: data.extra_topics || '',
+ }));
+ }, []);
+
+ const refreshMeshMqttSettings = useCallback(async () => {
+ try {
+ const res = await fetch(`${API_BASE}/api/settings/meshtastic-mqtt`, { cache: 'no-store' });
+ if (!res.ok) return null;
+ const data = (await res.json()) as MeshMqttSettings;
+ applyMeshMqttSettings(data);
+ return data;
+ } catch {
+ return null;
+ }
+ }, [applyMeshMqttSettings]);
+
+ const saveMeshMqttSettings = useCallback(
+ async (updates: Partial & { enabled?: boolean } = {}) => {
+ setMeshMqttBusy(true);
+ setMeshMqttStatusText('');
+ try {
+ const nextForm = { ...meshMqttForm, ...updates };
+ const body: Record = {
+ broker: nextForm.broker.trim() || 'mqtt.meshtastic.org',
+ port: Number.parseInt(nextForm.port, 10) || 1883,
+ username: nextForm.username.trim(),
+ include_default_roots: Boolean(nextForm.include_default_roots),
+ extra_roots: nextForm.extra_roots.trim(),
+ extra_topics: nextForm.extra_topics.trim(),
+ };
+ if (!nextForm.username.trim() && !nextForm.password.trim()) {
+ body.password = '';
+ }
+ if (typeof updates.enabled === 'boolean') {
+ body.enabled = updates.enabled;
+ }
+ if (nextForm.password.trim()) {
+ body.password = nextForm.password;
+ }
+ if (nextForm.psk.trim()) {
+ body.psk = nextForm.psk.trim();
+ }
+ const res = await fetch(`${API_BASE}/api/settings/meshtastic-mqtt`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ throw new Error(detail || `HTTP ${res.status}`);
+ }
+ const data = (await res.json()) as MeshMqttSettings;
+ applyMeshMqttSettings(data);
+ if (data.enabled) {
+ setWormholeEnabled(false);
+ setWormholeReadyState(false);
+ setWormholeRnsReady(false);
+ setWormholeRnsDirectReady(false);
+ setWormholeRnsPeers({ active: 0, configured: 0 });
+ setSecureModeCached(false);
+ }
+ const status = data.runtime?.connected
+ ? 'MQTT bridge connected.'
+ : data.enabled
+ ? 'MQTT bridge enabled. Connection may take a few seconds.'
+ : 'MQTT bridge disabled.';
+ setMeshMqttStatusText(status);
+ return { ok: true as const, text: status, data };
+ } catch (err) {
+ const text = err instanceof Error ? err.message : 'MQTT settings update failed';
+ setMeshMqttStatusText(text);
+ return { ok: false as const, text };
+ } finally {
+ setMeshMqttBusy(false);
+ }
+ },
+ [applyMeshMqttSettings, meshMqttForm],
+ );
+
+ const enableMeshMqttBridge = useCallback(async () => {
+ const result = await saveMeshMqttSettings({ enabled: true });
+ if (!result.ok) {
+ throw new Error(result.text);
+ }
+ return result;
+ }, [saveMeshMqttSettings]);
const dmSendTimer = useRef | null>(null);
const streamEnabledForSelectedGateRef = useRef(false);
const displayPublicMeshSender = useCallback(
@@ -345,15 +548,14 @@ export function useMeshChatController({
if (!sender) return '???';
if (
hasPublicLaneIdentity &&
- publicIdentity?.nodeId &&
publicMeshAddress &&
- sender.toLowerCase() === publicIdentity.nodeId.toLowerCase()
+ sender.toLowerCase() === publicMeshAddress.toLowerCase()
) {
return publicMeshAddress.toUpperCase();
}
return sender;
},
- [hasPublicLaneIdentity, publicIdentity?.nodeId, publicMeshAddress],
+ [hasPublicLaneIdentity, publicMeshAddress],
);
const openIdentityWizard = useCallback(
@@ -370,6 +572,7 @@ export function useMeshChatController({
useEffect(() => {
if (!clientHydrated) return;
+ setPublicMeshAddress(readStoredPublicMeshAddress());
setMeshSessionActive(false);
setMeshMessages([]);
setMeshQuickStatus(null);
@@ -525,25 +728,6 @@ export function useMeshChatController({
};
}, []);
- useEffect(() => {
- let alive = true;
- const senderId = storedPublicIdentity?.nodeId || '';
- if (!senderId || !globalThis.crypto?.subtle) {
- setPublicMeshAddress('');
- return;
- }
- derivePublicMeshAddress(senderId)
- .then((addr) => {
- if (alive) setPublicMeshAddress(addr);
- })
- .catch(() => {
- if (alive) setPublicMeshAddress('');
- });
- return () => {
- alive = false;
- };
- }, [storedPublicIdentity?.nodeId]);
-
const flushDmQueue = useCallback(async () => {
const queue = dmSendQueue.current.splice(0);
if (dmSendTimer.current) {
@@ -1155,6 +1339,36 @@ export function useMeshChatController({
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
+ useEffect(() => {
+ if (!expanded || activeTab !== 'meshtastic') return;
+ let alive = true;
+ const tick = async () => {
+ const data = await refreshMeshMqttSettings();
+ if (!alive || !data) return;
+ if (!data.enabled && meshSessionActive) {
+ setMeshQuickStatus({
+ type: 'err',
+ text: 'Public Mesh key is ready, but MQTT is off. Enable MQTT in Settings to join the live public lane.',
+ });
+ }
+ };
+ void tick();
+ const timer = window.setInterval(() => {
+ void tick();
+ }, meshMqttEnabled && !meshMqttConnected ? 5_000 : 15_000);
+ return () => {
+ alive = false;
+ window.clearInterval(timer);
+ };
+ }, [
+ activeTab,
+ expanded,
+ meshMqttConnected,
+ meshMqttEnabled,
+ meshSessionActive,
+ refreshMeshMqttSettings,
+ ]);
+
// ─── InfoNet Polling ─────────────────────────────────────────────────────
useEffect(() => {
@@ -2411,7 +2625,7 @@ export function useMeshChatController({
]);
setGateReplyContext(null);
} else if (activeTab === 'meshtastic') {
- if (!meshSessionActive || !publicIdentity || !hasSovereignty()) {
+ if (!meshSessionActive || !publicMeshAddress) {
setInputValue(msg);
setLastSendTime(0);
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
@@ -2425,8 +2639,20 @@ export function useMeshChatController({
setBusy(false);
return;
}
+ if (!meshMqttEnabled) {
+ setInputValue(msg);
+ setLastSendTime(0);
+ setSendError('mqtt is off');
+ setMeshQuickStatus({
+ type: 'err',
+ text: 'Public Mesh key is ready, but MQTT is off. Open Settings and enable the public broker.',
+ });
+ setMeshView('settings');
+ setTimeout(() => setSendError(''), 4000);
+ setBusy(false);
+ return;
+ }
const meshDestination = meshDirectTarget.trim() || 'broadcast';
- const sequence = nextSequence();
const payload = {
message: msg,
destination: meshDestination,
@@ -2444,8 +2670,7 @@ export function useMeshChatController({
setBusy(false);
return;
}
- const signature = await signEvent('message', publicIdentity.nodeId, sequence, payload);
- const sendRes = await fetch(`${API_BASE}/api/mesh/send`, {
+ const sendRes = await fetch(`${API_BASE}/api/mesh/meshtastic/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -2455,14 +2680,8 @@ export function useMeshChatController({
priority: 'normal',
ephemeral: false,
transport_lock: 'meshtastic',
- sender_id: publicIdentity.nodeId,
- node_id: publicIdentity.nodeId,
- public_key: publicIdentity.publicKey,
- public_key_algo: getPublicKeyAlgo(),
- signature,
- sequence,
- protocol_version: PROTOCOL_VERSION,
- credentials: { mesh_region: meshRegion },
+ sender_id: publicMeshAddress,
+ mesh_region: meshRegion,
}),
});
if (!sendRes.ok) {
@@ -2476,15 +2695,7 @@ export function useMeshChatController({
if (!sendData.ok) {
setInputValue(msg);
setLastSendTime(0);
- if (sendData.detail === 'Invalid signature') {
- setSendError('public mesh signature failed');
- openIdentityWizard({
- type: 'err',
- text: 'This public mesh identity did not verify. Reset it, recreate it, then retry.',
- });
- } else {
- setSendError(sendData.detail || 'send failed');
- }
+ setSendError(sendData.detail || 'send failed');
setTimeout(() => setSendError(''), 4000);
return;
}
@@ -3937,6 +4148,7 @@ export function useMeshChatController({
wormholeReadyState &&
!selectedGateAccessReady) ||
(activeTab === 'infonet' && anonymousPublicBlocked) ||
+ (activeTab === 'meshtastic' && (!hasPublicLaneIdentity || !meshMqttEnabled)) ||
(activeTab === 'dms' &&
(dmView !== 'chat' ||
!selectedContact ||
@@ -4003,11 +4215,11 @@ export function useMeshChatController({
setIdentityWizardStatus(null);
try {
await disableWormholeForPublicMesh();
- const nextIdentity = await generateNodeKeys();
- const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
- const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
- setIdentity(nextIdentity);
- setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
+ const nextAddress = createPublicMeshAddress();
+ await enableMeshMqttBridge();
+ writeStoredPublicMeshAddress(nextAddress);
+ const readyAddress = nextAddress.toUpperCase();
+ setPublicMeshAddress(nextAddress);
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
@@ -4038,7 +4250,7 @@ export function useMeshChatController({
setIdentityWizardBusy(false);
}
},
- [disableWormholeForPublicMesh],
+ [disableWormholeForPublicMesh, enableMeshMqttBridge],
);
const handleCreatePublicIdentity = useCallback(async () => {
@@ -4059,8 +4271,8 @@ export function useMeshChatController({
setIdentityWizardStatus(null);
setMeshQuickStatus(null);
try {
- const savedIdentity = getNodeIdentity();
- if (!savedIdentity || !hasSovereignty()) {
+ const savedAddress = readStoredPublicMeshAddress();
+ if (!savedAddress) {
const text = 'No saved public mesh key is available. Create a mesh key first.';
setMeshSessionActive(false);
setIdentityWizardStatus({ type: 'err', text });
@@ -4068,10 +4280,9 @@ export function useMeshChatController({
return { ok: false as const, text };
}
await disableWormholeForPublicMesh();
- const nextAddress = await derivePublicMeshAddress(savedIdentity.nodeId).catch(() => '');
- const readyAddress = (nextAddress || savedIdentity.nodeId).toUpperCase();
- setIdentity(savedIdentity);
- setPublicMeshAddress(nextAddress || savedIdentity.nodeId);
+ await enableMeshMqttBridge();
+ const readyAddress = savedAddress.toUpperCase();
+ setPublicMeshAddress(savedAddress);
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
@@ -4091,7 +4302,7 @@ export function useMeshChatController({
} finally {
setIdentityWizardBusy(false);
}
- }, [disableWormholeForPublicMesh]);
+ }, [disableWormholeForPublicMesh, enableMeshMqttBridge]);
const handleReplyToMeshAddress = useCallback((address: string) => {
const target = String(address || '').trim();
@@ -4127,14 +4338,8 @@ export function useMeshChatController({
try {
setMeshSessionActive(false);
setMeshMessages([]);
- await clearBrowserIdentityState();
- setIdentity(null);
+ clearStoredPublicMeshAddress();
setPublicMeshAddress('');
- setContacts({});
- setSelectedContact('');
- setDmMessages([]);
- setAccessRequestsState([]);
- setPendingSentState([]);
setIdentityWizardStatus({
type: 'ok',
text: 'Public mesh identity cleared. Start a fresh one when you are ready.',
@@ -4246,6 +4451,17 @@ export function useMeshChatController({
setMeshView,
meshDirectTarget,
setMeshDirectTarget,
+ meshMqttSettings,
+ meshMqttForm,
+ setMeshMqttForm,
+ meshMqttBusy,
+ meshMqttStatusText,
+ meshMqttEnabled,
+ meshMqttRunning,
+ meshMqttConnected,
+ meshMqttConnectionLabel,
+ saveMeshMqttSettings,
+ refreshMeshMqttSettings,
// Identity
identity,
publicIdentity,
diff --git a/frontend/src/components/NewsFeed.tsx b/frontend/src/components/NewsFeed.tsx
index 298fb41..495fcc3 100644
--- a/frontend/src/components/NewsFeed.tsx
+++ b/frontend/src/components/NewsFeed.tsx
@@ -100,6 +100,7 @@ const AIRCRAFT_WIKI: Record = {
PA46: 'Piper PA-46 Malibu', BE36: 'Beechcraft Bonanza', BE9L: 'Beechcraft King Air',
BE20: 'Beechcraft Super King Air', B350: 'Beechcraft King Air 350', PC12: 'Pilatus PC-12',
PC24: 'Pilatus PC-24', TBM7: 'Daher TBM', TBM8: 'Daher TBM', TBM9: 'Daher TBM',
+ PIVI: 'Pipistrel Virus',
// Helicopters
R44: 'Robinson R44', R22: 'Robinson R22', R66: 'Robinson R66',
B06: 'Bell 206', B407: 'Bell 407', B412: 'Bell 412',
@@ -196,12 +197,17 @@ function resolveAcTypeWiki(acType: string): string | null {
return null;
}
+function resolveAircraftWikiTitle(model: string | undefined): string | null {
+ if (!model) return null;
+ return AIRCRAFT_WIKI[model] || resolveAcTypeWiki(model);
+}
+
// Module-level cache for Wikipedia thumbnails (persists across re-renders)
const _wikiThumbCache: Record = {};
function useAircraftImage(model: string | undefined): { imgUrl: string | null; wikiUrl: string | null; loading: boolean } {
const [, forceUpdate] = useState(0);
- const wikiTitle = model ? AIRCRAFT_WIKI[model] : undefined;
+ const wikiTitle = resolveAircraftWikiTitle(model) || undefined;
const wikiUrl = wikiTitle ? `https://en.wikipedia.org/wiki/${wikiTitle.replace(/ /g, '_')}` : null;
useEffect(() => {
@@ -236,6 +242,162 @@ const VESSEL_TYPE_WIKI: Record = {
'military_vessel': 'https://en.wikipedia.org/wiki/Warship',
};
+type FlightTrailPoint = { lat?: number; lng?: number; alt?: number; ts?: number } | number[];
+
+function readTrailTimestamp(point: FlightTrailPoint): number | null {
+ if (Array.isArray(point)) {
+ const ts = Number(point[3]);
+ return Number.isFinite(ts) && ts > 0 ? ts : null;
+ }
+ const ts = Number(point?.ts);
+ return Number.isFinite(ts) && ts > 0 ? ts : null;
+}
+
+function readTrailLatLng(point: FlightTrailPoint): { lat: number; lng: number } | null {
+ const lat = Number(Array.isArray(point) ? point[0] : point?.lat);
+ const lng = Number(Array.isArray(point) ? point[1] : point?.lng);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
+ return { lat, lng };
+}
+
+function distanceNm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
+ const toRad = (deg: number) => (deg * Math.PI) / 180;
+ const earthRadiusNm = 3440.065;
+ const dLat = toRad(b.lat - a.lat);
+ const dLng = toRad(b.lng - a.lng);
+ const lat1 = toRad(a.lat);
+ const lat2 = toRad(b.lat);
+ const h =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
+ return 2 * earthRadiusNm * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
+}
+
+function formatObservedDuration(hours: number): string {
+ const minutes = Math.max(1, Math.round(hours * 60));
+ if (minutes < 60) return `${minutes} min`;
+ const wholeHours = Math.floor(minutes / 60);
+ const remainder = minutes % 60;
+ return remainder ? `${wholeHours}h ${remainder}m` : `${wholeHours}h`;
+}
+
+function estimateObservedEmissions(flight: any): {
+ fuelGallons: number;
+ co2Kg: number;
+ durationLabel: string;
+ distanceLabel: string | null;
+ basisLabel: string;
+} | null {
+ const fuelGph = Number(flight?.emissions?.fuel_gph);
+ const co2KgPerHour = Number(flight?.emissions?.co2_kg_per_hour);
+ const trail = Array.isArray(flight?.trail) ? (flight.trail as FlightTrailPoint[]) : [];
+ if (!Number.isFinite(fuelGph) || !Number.isFinite(co2KgPerHour)) {
+ return null;
+ }
+
+ const timestamps = trail
+ .map(readTrailTimestamp)
+ .filter((ts): ts is number => ts !== null)
+ .sort((a, b) => a - b);
+ if (timestamps.length >= 2) {
+ const elapsedHours = (timestamps[timestamps.length - 1] - timestamps[0]) / 3600;
+ if (Number.isFinite(elapsedHours) && elapsedHours >= 5 / 60) {
+ let distance = 0;
+ let previous: { lat: number; lng: number } | null = null;
+ for (const point of trail) {
+ const current = readTrailLatLng(point);
+ if (previous && current) distance += distanceNm(previous, current);
+ if (current) previous = current;
+ }
+
+ return {
+ fuelGallons: Math.round(fuelGph * elapsedHours),
+ co2Kg: Math.round(co2KgPerHour * elapsedHours),
+ durationLabel: formatObservedDuration(elapsedHours),
+ distanceLabel: distance > 1 ? `${Math.round(distance).toLocaleString()} nm` : null,
+ basisLabel: 'trail history',
+ };
+ }
+ }
+
+ const origin = Array.isArray(flight?.origin_loc)
+ ? { lng: Number(flight.origin_loc[0]), lat: Number(flight.origin_loc[1]) }
+ : null;
+ const current = { lat: Number(flight?.lat), lng: Number(flight?.lng) };
+ const speedKnots = Number(flight?.speed_knots);
+ if (
+ origin &&
+ Number.isFinite(origin.lat) &&
+ Number.isFinite(origin.lng) &&
+ Number.isFinite(current.lat) &&
+ Number.isFinite(current.lng) &&
+ Number.isFinite(speedKnots) &&
+ speedKnots > 50
+ ) {
+ const flownNm = distanceNm(origin, current);
+ const elapsedHours = flownNm / speedKnots;
+ if (Number.isFinite(elapsedHours) && elapsedHours >= 5 / 60 && elapsedHours <= 18) {
+ return {
+ fuelGallons: Math.round(fuelGph * elapsedHours),
+ co2Kg: Math.round(co2KgPerHour * elapsedHours),
+ durationLabel: formatObservedDuration(elapsedHours),
+ distanceLabel: `${Math.round(flownNm).toLocaleString()} nm`,
+ basisLabel: 'route progress',
+ };
+ }
+ }
+
+ return null;
+}
+
+function EmissionsEstimateBlock({ flight }: { flight: any }) {
+ const observed = estimateObservedEmissions(flight);
+ const emissions = flight?.emissions;
+ const context = observed
+ ? `${observed.durationLabel} ${observed.basisLabel}${observed.distanceLabel ? ` / ${observed.distanceLabel}` : ''}`
+ : emissions
+ ? 'Rate only until enough trail history accumulates'
+ : null;
+
+ return (
+
+
EMISSIONS ESTIMATE
+
+
+
+ {observed ? 'FUEL BURNED' : 'FUEL RATE'}
+
+
+ {observed ? (
+ <>{observed.fuelGallons.toLocaleString()} GAL>
+ ) : emissions ? (
+ <>{emissions.fuel_gph} GPH>
+ ) : 'UNKNOWN'}
+
+
+
+
+ {observed ? 'CO2 PRODUCED' : 'CO2 RATE'}
+
+
+ {observed ? (
+ <>{observed.co2Kg.toLocaleString()} KG>
+ ) : emissions ? (
+ <>{emissions.co2_kg_per_hour.toLocaleString()} KG/HR>
+ ) : 'UNKNOWN'}
+
+
+
+ {context && (
+
+ {context}
+ {observed && emissions ? ` - estimated from ${emissions.fuel_gph} GPH model rate.` : ''}
+
+ )}
+
+ );
+}
+
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void }) {
const data = useDataKeys([
'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets',
@@ -277,12 +439,17 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
const selectedFlightModel = (() => {
if (!selectedEntity) return undefined;
const { type, id } = selectedEntity;
- let flight: any = null;
- if (type === 'flight') flight = data?.commercial_flights?.[id as number];
- else if (type === 'private_flight') flight = data?.private_flights?.[id as number];
- else if (type === 'private_jet') flight = data?.private_jets?.[id as number];
- else if (type === 'military_flight') flight = data?.military_flights?.[id as number];
- else if (type === 'tracked_flight') flight = data?.tracked_flights?.[id as number];
+ const findByIdOrIndex = (flights?: Array<{ icao24?: string; model?: string }>) => {
+ if (!flights) return null;
+ if (typeof id === 'number') return flights[id] || null;
+ return flights.find((flight) => flight.icao24 === id) || null;
+ };
+ let flight: { model?: string } | null = null;
+ if (type === 'flight') flight = findByIdOrIndex(data?.commercial_flights);
+ else if (type === 'private_flight') flight = findByIdOrIndex(data?.private_flights);
+ else if (type === 'private_jet') flight = findByIdOrIndex(data?.private_jets);
+ else if (type === 'military_flight') flight = findByIdOrIndex(data?.military_flights);
+ else if (type === 'tracked_flight') flight = findByIdOrIndex(data?.tracked_flights);
return flight?.model;
})();
const { imgUrl: aircraftImgUrl, wikiUrl: aircraftWikiUrl, loading: aircraftImgLoading } = useAircraftImage(selectedFlightModel);
@@ -684,19 +851,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}
)}
-