mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-08 15:23:54 +02:00
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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user