v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors

Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them
through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation
system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery,
killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption
keys and chain state during updates.

New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers,
CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets,
desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing).

Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami,
@chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const,
@Elhard1, @ttulttul
This commit is contained in:
anoracleofra-code
2026-03-26 05:58:04 -06:00
parent d363013742
commit 668ce16dc7
363 changed files with 170456 additions and 23229 deletions
+288 -9
View File
@@ -1,12 +1,291 @@
// ICAO type code -> aircraft shape classification
export const HELI_TYPES = new Set(['R22', 'R44', 'R66', 'B06', 'B05', 'B47G', 'B105', 'B212', 'B222', 'B230', 'B407', 'B412', 'B429', 'B430', 'B505', 'BK17', 'S55', 'S58', 'S61', 'S64', 'S70', 'S76', 'S92', 'A109', 'A119', 'A139', 'A169', 'A189', 'AW09', 'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'EC75', 'H125', 'H130', 'H135', 'H145', 'H155', 'H160', 'H175', 'H215', 'H225', 'AS32', 'AS35', 'AS50', 'AS55', 'AS65', 'MD52', 'MD60', 'MDHI', 'MD90', 'NOTR', 'HUEY', 'GAMA', 'CABR', 'EXE', 'R300', 'R480', 'LAMA', 'ALLI', 'PUMA', 'NH90', 'CH47', 'UH1', 'UH60', 'AH64', 'MI8', 'MI24', 'MI26', 'MI28', 'KA52', 'K32', 'LYNX', 'WILD', 'MRLX', 'A149', 'A119']);
export const TURBOPROP_TYPES = new Set(['AT43', 'AT45', 'AT72', 'AT73', 'AT75', 'AT76', 'B190', 'B350', 'BE20', 'BE30', 'BE40', 'BE9L', 'BE99', 'C130', 'C160', 'C208', 'C212', 'C295', 'CN35', 'D228', 'D328', 'DHC2', 'DHC3', 'DHC4', 'DHC5', 'DHC6', 'DHC7', 'DHC8', 'DO28', 'DH8A', 'DH8B', 'DH8C', 'DH8D', 'E110', 'E120', 'F27', 'F406', 'F50', 'G159', 'G73T', 'J328', 'JS31', 'JS32', 'JS41', 'L188', 'MA60', 'M28', 'N262', 'P68', 'P180', 'PA31', 'PA42', 'PC12', 'PC21', 'PC24', 'S2', 'S340', 'SF34', 'SF50', 'SW4', 'TRIS', 'TBM7', 'TBM8', 'TBM9', 'C30J', 'C5M', 'AN12', 'AN24', 'AN26', 'AN30', 'AN32', 'IL18', 'L410', 'Y12', 'BALL', 'AEST', 'AC68', 'AC80', 'AC90', 'AC95', 'AC11', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337', 'C402', 'C414', 'C421', 'C425', 'C441', 'M20P', 'M20T', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'PA60', 'P28A', 'P28B', 'P28R', 'P32R', 'P46T', 'SR20', 'SR22', 'DA40', 'DA42', 'DA62', 'RV10', 'BE33', 'BE35', 'BE36', 'BE55', 'BE58', 'DR40', 'TB20', 'AA5']);
export const BIZJET_TYPES = new Set(['ASTR', 'C25A', 'C25B', 'C25C', 'C25M', 'C500', 'C501', 'C510', 'C525', 'C526', 'C550', 'C551', 'C560', 'C56X', 'C650', 'C680', 'C700', 'C750', 'CL30', 'CL35', 'CL60', 'CONI', 'CRJX', 'E35L', 'E45X', 'E50P', 'E55P', 'F2TH', 'F900', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'G100', 'G150', 'G200', 'G280', 'GA5C', 'GA6C', 'GALX', 'GL5T', 'GL7T', 'GLEX', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'H25A', 'H25B', 'H25C', 'HA4T', 'HDJT', 'LJ23', 'LJ24', 'LJ25', 'LJ28', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60', 'LJ70', 'LJ75', 'MU30', 'PC24', 'PRM1', 'SBR1', 'SBR2', 'WW24', 'BE40', 'BLCF']);
export const HELI_TYPES = new Set([
'R22',
'R44',
'R66',
'B06',
'B05',
'B47G',
'B105',
'B212',
'B222',
'B230',
'B407',
'B412',
'B429',
'B430',
'B505',
'BK17',
'S55',
'S58',
'S61',
'S64',
'S70',
'S76',
'S92',
'A109',
'A119',
'A139',
'A169',
'A189',
'AW09',
'EC20',
'EC25',
'EC30',
'EC35',
'EC45',
'EC55',
'EC75',
'H125',
'H130',
'H135',
'H145',
'H155',
'H160',
'H175',
'H215',
'H225',
'AS32',
'AS35',
'AS50',
'AS55',
'AS65',
'MD52',
'MD60',
'MDHI',
'MD90',
'NOTR',
'HUEY',
'GAMA',
'CABR',
'EXE',
'R300',
'R480',
'LAMA',
'ALLI',
'PUMA',
'NH90',
'CH47',
'UH1',
'UH60',
'AH64',
'MI8',
'MI24',
'MI26',
'MI28',
'KA52',
'K32',
'LYNX',
'WILD',
'MRLX',
'A149',
'A119',
]);
export const TURBOPROP_TYPES = new Set([
'AT43',
'AT45',
'AT72',
'AT73',
'AT75',
'AT76',
'B190',
'B350',
'BE20',
'BE30',
'BE40',
'BE9L',
'BE99',
'C130',
'C160',
'C208',
'C212',
'C295',
'CN35',
'D228',
'D328',
'DHC2',
'DHC3',
'DHC4',
'DHC5',
'DHC6',
'DHC7',
'DHC8',
'DO28',
'DH8A',
'DH8B',
'DH8C',
'DH8D',
'E110',
'E120',
'F27',
'F406',
'F50',
'G159',
'G73T',
'J328',
'JS31',
'JS32',
'JS41',
'L188',
'MA60',
'M28',
'N262',
'P68',
'P180',
'PA31',
'PA42',
'PC12',
'PC21',
'PC24',
'S2',
'S340',
'SF34',
'SF50',
'SW4',
'TRIS',
'TBM7',
'TBM8',
'TBM9',
'C30J',
'C5M',
'AN12',
'AN24',
'AN26',
'AN30',
'AN32',
'IL18',
'L410',
'Y12',
'BALL',
'AEST',
'AC68',
'AC80',
'AC90',
'AC95',
'AC11',
'C172',
'C182',
'C206',
'C210',
'C310',
'C337',
'C402',
'C414',
'C421',
'C425',
'C441',
'M20P',
'M20T',
'PA28',
'PA32',
'PA34',
'PA44',
'PA46',
'PA60',
'P28A',
'P28B',
'P28R',
'P32R',
'P46T',
'SR20',
'SR22',
'DA40',
'DA42',
'DA62',
'RV10',
'BE33',
'BE35',
'BE36',
'BE55',
'BE58',
'DR40',
'TB20',
'AA5',
]);
export const BIZJET_TYPES = new Set([
'ASTR',
'C25A',
'C25B',
'C25C',
'C25M',
'C500',
'C501',
'C510',
'C525',
'C526',
'C550',
'C551',
'C560',
'C56X',
'C650',
'C680',
'C700',
'C750',
'CL30',
'CL35',
'CL60',
'CONI',
'CRJX',
'E35L',
'E45X',
'E50P',
'E55P',
'F2TH',
'F900',
'FA10',
'FA20',
'FA50',
'FA7X',
'FA8X',
'G100',
'G150',
'G200',
'G280',
'GA5C',
'GA6C',
'GALX',
'GL5T',
'GL7T',
'GLEX',
'GLF2',
'GLF3',
'GLF4',
'GLF5',
'GLF6',
'H25A',
'H25B',
'H25C',
'HA4T',
'HDJT',
'LJ23',
'LJ24',
'LJ25',
'LJ28',
'LJ31',
'LJ35',
'LJ40',
'LJ45',
'LJ55',
'LJ60',
'LJ70',
'LJ75',
'MU30',
'PC24',
'PRM1',
'SBR1',
'SBR2',
'WW24',
'BE40',
'BLCF',
]);
export function classifyAircraft(model: string, category?: string): 'heli' | 'turboprop' | 'bizjet' | 'airliner' {
const m = (model || '').toUpperCase();
if (category === 'heli' || HELI_TYPES.has(m)) return 'heli';
if (BIZJET_TYPES.has(m)) return 'bizjet';
if (TURBOPROP_TYPES.has(m)) return 'turboprop';
return 'airliner';
export function classifyAircraft(
model: string,
category?: string,
): 'heli' | 'turboprop' | 'bizjet' | 'airliner' {
const m = (model || '').toUpperCase();
if (category === 'heli' || HELI_TYPES.has(m)) return 'heli';
if (BIZJET_TYPES.has(m)) return 'bizjet';
if (TURBOPROP_TYPES.has(m)) return 'turboprop';
return 'airliner';
}
+4 -12
View File
@@ -18,20 +18,14 @@ describe('spreadAlertItems', () => {
});
it('filters out items without coords', () => {
const items = [
{ title: 'No coords', alert_level: 1 },
makeAlert('Has coords', 40, -74),
];
const items = [{ title: 'No coords', alert_level: 1 }, makeAlert('Has coords', 40, -74)];
const result = spreadAlertItems(items, 4, new Set());
expect(result.length).toBe(1);
expect(result[0].title).toBe('Has coords');
});
it('filters dismissed alerts by alertKey', () => {
const items = [
makeAlert('Fire in NYC', 40.7, -74.0),
makeAlert('Floods in LA', 34.0, -118.2),
];
const items = [makeAlert('Fire in NYC', 40.7, -74.0), makeAlert('Floods in LA', 34.0, -118.2)];
const dismissed = new Set(['Fire in NYC|40.7,-74']);
const result = spreadAlertItems(items, 4, dismissed);
expect(result.length).toBe(1);
@@ -59,12 +53,10 @@ describe('spreadAlertItems', () => {
it('spreads overlapping alerts apart (offsets are non-zero for stacked items)', () => {
// Place 5 alerts at the exact same location — they should be spread apart
const items = Array.from({ length: 5 }, (_, i) =>
makeAlert(`Alert ${i}`, 40.0, -74.0)
);
const items = Array.from({ length: 5 }, (_, i) => makeAlert(`Alert ${i}`, 40.0, -74.0));
const result = spreadAlertItems(items, 8, new Set()); // zoom 8 = close enough to overlap
const hasNonZeroOffset = result.some(
(r: any) => Math.abs(r.offsetX) > 1 || Math.abs(r.offsetY) > 1
(r: any) => Math.abs(r.offsetX) > 1 || Math.abs(r.offsetY) > 1,
);
expect(hasNonZeroOffset).toBe(true);
});
+113 -110
View File
@@ -4,26 +4,28 @@
* so alert boxes don't stack on top of each other on the map.
*/
import type { NewsArticle } from "@/types/dashboard";
import { ALERT_BOX_WIDTH_PX, ALERT_MAX_OFFSET_PX } from "@/lib/constants";
import type { NewsArticle } from '@/types/dashboard';
import { ALERT_BOX_WIDTH_PX, ALERT_MAX_OFFSET_PX } from '@/lib/constants';
export interface SpreadAlertItem extends NewsArticle {
originalIdx: number;
x: number;
y: number;
offsetX: number;
offsetY: number;
boxH: number;
alertKey: string;
showLine: boolean;
coords: [number, number];
originalIdx: number;
x: number;
y: number;
offsetX: number;
offsetY: number;
boxH: number;
alertKey: string;
showLine: boolean;
cluster_count?: number;
}
/** Estimate rendered box height based on title length */
function estimateBoxH(n: { title?: string; cluster_count?: number }): number {
const titleLen = (n.title || "").length;
const titleLines = Math.max(1, Math.ceil(titleLen / 20)); // ~20 chars per line at 9px in 160px
const hasFooter = (n.cluster_count || 1) > 1;
return 10 + 14 + titleLines * 13 + (hasFooter ? 14 : 0) + 10; // padding + header + title + footer + padding
const titleLen = (n.title || '').length;
const titleLines = Math.max(1, Math.ceil(titleLen / 20)); // ~20 chars per line at 9px in 160px
const hasFooter = (n.cluster_count || 1) > 1;
return 10 + 14 + titleLines * 13 + (hasFooter ? 14 : 0) + 10; // padding + header + title + footer + padding
}
/**
@@ -31,111 +33,112 @@ function estimateBoxH(n: { title?: string; cluster_count?: number }): number {
* Returns positioned items with offsets and alert keys.
*/
export function spreadAlertItems(
news: NewsArticle[],
zoom: number,
dismissedAlerts: Set<string>
news: NewsArticle[],
zoom: number,
dismissedAlerts: Set<string>,
): SpreadAlertItem[] {
const pixelsPerDeg = (256 * Math.pow(2, zoom)) / 360;
const pixelsPerDeg = (256 * Math.pow(2, zoom)) / 360;
let items = news
.map((n, idx) => ({ ...n, originalIdx: idx }))
.filter((n) => n.coords)
.map((n) => ({
...n,
x: n.coords![1] * pixelsPerDeg,
y: -n.coords![0] * pixelsPerDeg,
offsetX: 0,
offsetY: 0,
boxH: estimateBoxH(n as { title?: string; cluster_count?: number }),
}));
const items = news
.map((n, idx) => ({ ...n, originalIdx: idx }))
.filter((n) => n.coords)
.map((n) => ({
...n,
x: n.coords![1] * pixelsPerDeg,
y: -n.coords![0] * pixelsPerDeg,
offsetX: 0,
offsetY: 0,
boxH: estimateBoxH(n as { title?: string; cluster_count?: number }),
}));
const BOX_W = ALERT_BOX_WIDTH_PX;
const GAP = 6;
const MAX_OFFSET = ALERT_MAX_OFFSET_PX;
const BOX_W = ALERT_BOX_WIDTH_PX;
const GAP = 6;
const MAX_OFFSET = ALERT_MAX_OFFSET_PX;
// Grid-based Collision Resolution (O(n) per iteration instead of O(n²))
const CELL_W = BOX_W + GAP;
const CELL_H = 100;
const maxIter = 30;
// Grid-based Collision Resolution (O(n) per iteration instead of O(n²))
const CELL_W = BOX_W + GAP;
const CELL_H = 100;
const maxIter = 30;
for (let iter = 0; iter < maxIter; iter++) {
let moved = false;
const grid: Record<string, number[]> = {};
for (let i = 0; i < items.length; i++) {
const cx = Math.floor((items[i].x + items[i].offsetX) / CELL_W);
const cy = Math.floor((items[i].y + items[i].offsetY) / CELL_H);
const key = `${cx},${cy}`;
(grid[key] ??= []).push(i);
}
const checked = new Set<string>();
for (const key in grid) {
const [cx, cy] = key.split(",").map(Number);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const nk = `${cx + dx},${cy + dy}`;
if (!grid[nk]) continue;
const pairKey = cx + dx < cx || (cx + dx === cx && cy + dy < cy) ? `${nk}|${key}` : `${key}|${nk}`;
if (key !== nk && checked.has(pairKey)) continue;
checked.add(pairKey);
const cellA = grid[key];
const cellB = key === nk ? cellA : grid[nk];
for (const i of cellA) {
const startJ = key === nk ? cellA.indexOf(i) + 1 : 0;
for (let jIdx = startJ; jIdx < cellB.length; jIdx++) {
const j = cellB[jIdx];
if (i === j) continue;
const a = items[i],
b = items[j];
const adx = Math.abs(a.x + a.offsetX - (b.x + b.offsetX));
const ady = Math.abs(a.y + a.offsetY - (b.y + b.offsetY));
const minDistX = BOX_W + GAP;
const minDistY = (a.boxH + b.boxH) / 2 + GAP;
if (adx < minDistX && ady < minDistY) {
moved = true;
const overlapX = minDistX - adx;
const overlapY = minDistY - ady;
if (overlapY < overlapX) {
const push = overlapY / 2 + 1;
if (a.y + a.offsetY <= b.y + b.offsetY) {
a.offsetY -= push;
b.offsetY += push;
} else {
a.offsetY += push;
b.offsetY -= push;
}
} else {
const push = overlapX / 2 + 1;
if (a.x + a.offsetX <= b.x + b.offsetX) {
a.offsetX -= push;
b.offsetX += push;
} else {
a.offsetX += push;
b.offsetX -= push;
}
}
}
}
}
for (let iter = 0; iter < maxIter; iter++) {
let moved = false;
const grid: Record<string, number[]> = {};
for (let i = 0; i < items.length; i++) {
const cx = Math.floor((items[i].x + items[i].offsetX) / CELL_W);
const cy = Math.floor((items[i].y + items[i].offsetY) / CELL_H);
const key = `${cx},${cy}`;
(grid[key] ??= []).push(i);
}
const checked = new Set<string>();
for (const key in grid) {
const [cx, cy] = key.split(',').map(Number);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const nk = `${cx + dx},${cy + dy}`;
if (!grid[nk]) continue;
const pairKey =
cx + dx < cx || (cx + dx === cx && cy + dy < cy) ? `${nk}|${key}` : `${key}|${nk}`;
if (key !== nk && checked.has(pairKey)) continue;
checked.add(pairKey);
const cellA = grid[key];
const cellB = key === nk ? cellA : grid[nk];
for (const i of cellA) {
const startJ = key === nk ? cellA.indexOf(i) + 1 : 0;
for (let jIdx = startJ; jIdx < cellB.length; jIdx++) {
const j = cellB[jIdx];
if (i === j) continue;
const a = items[i],
b = items[j];
const adx = Math.abs(a.x + a.offsetX - (b.x + b.offsetX));
const ady = Math.abs(a.y + a.offsetY - (b.y + b.offsetY));
const minDistX = BOX_W + GAP;
const minDistY = (a.boxH + b.boxH) / 2 + GAP;
if (adx < minDistX && ady < minDistY) {
moved = true;
const overlapX = minDistX - adx;
const overlapY = minDistY - ady;
if (overlapY < overlapX) {
const push = overlapY / 2 + 1;
if (a.y + a.offsetY <= b.y + b.offsetY) {
a.offsetY -= push;
b.offsetY += push;
} else {
a.offsetY += push;
b.offsetY -= push;
}
} else {
const push = overlapX / 2 + 1;
if (a.x + a.offsetX <= b.x + b.offsetX) {
a.offsetX -= push;
b.offsetX += push;
} else {
a.offsetX += push;
b.offsetX -= push;
}
}
}
}
}
}
if (!moved) break;
}
}
if (!moved) break;
}
// Clamp offsets so boxes stay near their origin
for (const item of items) {
item.offsetX = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, item.offsetX));
item.offsetY = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, item.offsetY));
}
// Clamp offsets so boxes stay near their origin
for (const item of items) {
item.offsetX = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, item.offsetX));
item.offsetY = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, item.offsetY));
}
return items
.filter((item) => {
const alertKey = `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`;
return !dismissedAlerts.has(alertKey);
})
.map((item) => ({
...item,
alertKey: `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`,
showLine: Math.abs(item.offsetX) > 5 || Math.abs(item.offsetY) > 5,
})) as SpreadAlertItem[];
return items
.filter((item) => {
const alertKey = `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`;
return !dismissedAlerts.has(alertKey);
})
.map((item) => ({
...item,
alertKey: `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`,
showLine: Math.abs(item.offsetX) > 5 || Math.abs(item.offsetY) > 5,
})) as SpreadAlertItem[];
}
+53 -19
View File
@@ -1,23 +1,57 @@
// --- Smooth position interpolation helpers ---
// Given heading (degrees) and speed (knots), compute new lat/lng after dt seconds
export function interpolatePosition(lat: number, lng: number, headingDeg: number, speedKnots: number, dtSeconds: number, maxDist = 0, maxDt = 65): [number, number] {
if (!speedKnots || speedKnots <= 0 || dtSeconds <= 0) return [lat, lng];
// Cap interpolation time to prevent runaway drift when data is stale
const clampedDt = Math.min(dtSeconds, maxDt);
// 1 knot = 1 nautical mile/hour = 1852 m/h
const speedMps = speedKnots * 0.5144; // meters per second
const dist = maxDist > 0 ? Math.min(speedMps * clampedDt, maxDist) : speedMps * clampedDt;
const R = 6371000; // Earth radius in meters
const headingRad = (headingDeg * Math.PI) / 180;
const latRad = (lat * Math.PI) / 180;
const lngRad = (lng * Math.PI) / 180;
const newLatRad = Math.asin(
Math.sin(latRad) * Math.cos(dist / R) +
Math.cos(latRad) * Math.sin(dist / R) * Math.cos(headingRad)
export function interpolatePosition(
lat: number,
lng: number,
headingDeg: number,
speedKnots: number,
dtSeconds: number,
maxDist = 0,
maxDt = 65,
): [number, number] {
if (!speedKnots || speedKnots <= 0 || dtSeconds <= 0) return [lat, lng];
// Cap interpolation time to prevent runaway drift when data is stale
const clampedDt = Math.min(dtSeconds, maxDt);
// 1 knot = 1 nautical mile/hour = 1852 m/h
const speedMps = speedKnots * 0.5144; // meters per second
const dist = maxDist > 0 ? Math.min(speedMps * clampedDt, maxDist) : speedMps * clampedDt;
const R = 6371000; // Earth radius in meters
const headingRad = (headingDeg * Math.PI) / 180;
const latRad = (lat * Math.PI) / 180;
const lngRad = (lng * Math.PI) / 180;
const newLatRad = Math.asin(
Math.sin(latRad) * Math.cos(dist / R) +
Math.cos(latRad) * Math.sin(dist / R) * Math.cos(headingRad),
);
const newLngRad =
lngRad +
Math.atan2(
Math.sin(headingRad) * Math.sin(dist / R) * Math.cos(latRad),
Math.cos(dist / R) - Math.sin(latRad) * Math.sin(newLatRad),
);
const newLngRad = lngRad + Math.atan2(
Math.sin(headingRad) * Math.sin(dist / R) * Math.cos(latRad),
Math.cos(dist / R) - Math.sin(latRad) * Math.sin(newLatRad)
);
return [(newLatRad * 180) / Math.PI, (newLngRad * 180) / Math.PI];
return [(newLatRad * 180) / Math.PI, (newLngRad * 180) / Math.PI];
}
// Project a point at a given bearing and distance (meters) using great-circle math
export function projectPoint(
lat: number,
lng: number,
bearingDeg: number,
distMeters: number,
): [number, number] {
const R = 6371000;
const bearingRad = (bearingDeg * Math.PI) / 180;
const latRad = (lat * Math.PI) / 180;
const lngRad = (lng * Math.PI) / 180;
const newLatRad = Math.asin(
Math.sin(latRad) * Math.cos(distMeters / R) +
Math.cos(latRad) * Math.sin(distMeters / R) * Math.cos(bearingRad),
);
const newLngRad =
lngRad +
Math.atan2(
Math.sin(bearingRad) * Math.sin(distMeters / R) * Math.cos(latRad),
Math.cos(distMeters / R) - Math.sin(latRad) * Math.sin(newLatRad),
);
return [(newLatRad * 180) / Math.PI, (newLngRad * 180) / Math.PI];
}
+89 -89
View File
@@ -16,35 +16,35 @@ const RAD = 180 / Math.PI;
* Returns: { declination (radians), eqTime (minutes) }
*/
function solarPosition(date: Date) {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
const dayOfYear = Math.floor(diff / oneDay);
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
const dayOfYear = Math.floor(diff / oneDay);
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
// Fractional year in radians
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (hour - 12) / 24);
// Fractional year in radians
const gamma = ((2 * Math.PI) / 365) * (dayOfYear - 1 + (hour - 12) / 24);
// Equation of time (minutes)
const eqTime = 229.18 * (
0.000075
+ 0.001868 * Math.cos(gamma)
- 0.032077 * Math.sin(gamma)
- 0.014615 * Math.cos(2 * gamma)
- 0.040849 * Math.sin(2 * gamma)
);
// Equation of time (minutes)
const eqTime =
229.18 *
(0.000075 +
0.001868 * Math.cos(gamma) -
0.032077 * Math.sin(gamma) -
0.014615 * Math.cos(2 * gamma) -
0.040849 * Math.sin(2 * gamma));
// Solar declination (radians)
const declination =
0.006918
- 0.399912 * Math.cos(gamma)
+ 0.070257 * Math.sin(gamma)
- 0.006758 * Math.cos(2 * gamma)
+ 0.000907 * Math.sin(2 * gamma)
- 0.002697 * Math.cos(3 * gamma)
+ 0.00148 * Math.sin(3 * gamma);
// Solar declination (radians)
const declination =
0.006918 -
0.399912 * Math.cos(gamma) +
0.070257 * Math.sin(gamma) -
0.006758 * Math.cos(2 * gamma) +
0.000907 * Math.sin(2 * gamma) -
0.002697 * Math.cos(3 * gamma) +
0.00148 * Math.sin(3 * gamma);
return { declination, eqTime };
return { declination, eqTime };
}
/**
@@ -52,19 +52,19 @@ function solarPosition(date: Date) {
* Returns the latitude (in degrees) where the sun angle = 0.
*/
function terminatorLatitude(lng: number, declination: number, subsolarLng: number): number {
// Hour angle at this longitude
const ha = (lng - subsolarLng) * DEG;
// Terminator: cos(zenith) = 0 => sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha) = 0
// => tan(lat) = -cos(ha) * cos(dec) / sin(dec)
// => lat = atan(-cos(ha) / tan(dec))
// Hour angle at this longitude
const ha = (lng - subsolarLng) * DEG;
// Terminator: cos(zenith) = 0 => sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha) = 0
// => tan(lat) = -cos(ha) * cos(dec) / sin(dec)
// => lat = atan(-cos(ha) / tan(dec))
const tanDec = Math.tan(declination);
if (Math.abs(tanDec) < 1e-10) {
// Near equinox, terminator is roughly at ±90° adjusted
return -Math.acos(0) * RAD; // fallback
}
const lat = Math.atan(-Math.cos(ha) / tanDec) * RAD;
return lat;
const tanDec = Math.tan(declination);
if (Math.abs(tanDec) < 1e-10) {
// Near equinox, terminator is roughly at ±90° adjusted
return -Math.acos(0) * RAD; // fallback
}
const lat = Math.atan(-Math.cos(ha) / tanDec) * RAD;
return lat;
}
/**
@@ -72,65 +72,65 @@ function terminatorLatitude(lng: number, declination: number, subsolarLng: numbe
* Updated every call with the current date.
*/
export function computeNightPolygon(date: Date = new Date()): GeoJSON.FeatureCollection {
const { declination, eqTime } = solarPosition(date);
const { declination, eqTime } = solarPosition(date);
// Subsolar longitude: where the sun is directly overhead
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
const subsolarLng = -(hour - 12) * 15 - eqTime / 4; // degrees
// Subsolar longitude: where the sun is directly overhead
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
const subsolarLng = -(hour - 12) * 15 - eqTime / 4; // degrees
// Generate terminator line points (one per degree of longitude)
const terminatorPoints: [number, number][] = [];
for (let lng = -180; lng <= 180; lng += 1) {
const lat = terminatorLatitude(lng, declination, subsolarLng);
// Clamp latitude to valid range
terminatorPoints.push([lng, Math.max(-85, Math.min(85, lat))]);
}
// Generate terminator line points (one per degree of longitude)
const terminatorPoints: [number, number][] = [];
for (let lng = -180; lng <= 180; lng += 1) {
const lat = terminatorLatitude(lng, declination, subsolarLng);
// Clamp latitude to valid range
terminatorPoints.push([lng, Math.max(-85, Math.min(85, lat))]);
}
// Determine which side is night: if declination > 0 (northern summer),
// the night polygon is on the south side of the terminator, and vice versa.
// More precisely: at lng=subsolarLng, the sun is overhead, so the opposite side is night.
// We check: is the subsolar point on the +lat or -lat side of the terminator at that lng?
// Determine which side is night: if declination > 0 (northern summer),
// the night polygon is on the south side of the terminator, and vice versa.
// More precisely: at lng=subsolarLng, the sun is overhead, so the opposite side is night.
// We check: is the subsolar point on the +lat or -lat side of the terminator at that lng?
// The subsolar latitude
const subsolarLat = declination * RAD;
// The terminator latitude at the subsolar longitude
const termLatAtSubsolar = terminatorLatitude(subsolarLng, declination, subsolarLng);
// The subsolar latitude
const subsolarLat = declination * RAD;
// The terminator latitude at the subsolar longitude
const termLatAtSubsolar = terminatorLatitude(subsolarLng, declination, subsolarLng);
// If subsolar lat > terminator lat at that point, night is on the south (below terminator)
const nightIsSouth = subsolarLat > termLatAtSubsolar;
// If subsolar lat > terminator lat at that point, night is on the south (below terminator)
const nightIsSouth = subsolarLat > termLatAtSubsolar;
// Build the night polygon
// South side: terminator -> bottom edge (-85) -> close
// North side: terminator -> top edge (85) -> close
const nightCoords: [number, number][] = [];
// Build the night polygon
// South side: terminator -> bottom edge (-85) -> close
// North side: terminator -> top edge (85) -> close
const nightCoords: [number, number][] = [];
if (nightIsSouth) {
// Night is below the terminator line
// Go left-to-right along the terminator, then close along the bottom
for (const pt of terminatorPoints) nightCoords.push(pt);
nightCoords.push([180, -85]);
nightCoords.push([-180, -85]);
} else {
// Night is above the terminator line
for (const pt of terminatorPoints) nightCoords.push(pt);
nightCoords.push([180, 85]);
nightCoords.push([-180, 85]);
}
if (nightIsSouth) {
// Night is below the terminator line
// Go left-to-right along the terminator, then close along the bottom
for (const pt of terminatorPoints) nightCoords.push(pt);
nightCoords.push([180, -85]);
nightCoords.push([-180, -85]);
} else {
// Night is above the terminator line
for (const pt of terminatorPoints) nightCoords.push(pt);
nightCoords.push([180, 85]);
nightCoords.push([-180, 85]);
}
// Close the ring
nightCoords.push(nightCoords[0]);
// Close the ring
nightCoords.push(nightCoords[0]);
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {},
geometry: {
type: "Polygon",
coordinates: [nightCoords],
},
},
],
};
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [nightCoords],
},
},
],
};
}