mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-13 03:54:45 +02:00
v0.9.5: The Voltron Update — modular architecture, stable IDs, parallelized boot
- Parallelized startup (60s → 15s) via ThreadPoolExecutor - Adaptive polling engine with ETag caching (no more bbox interrupts) - useCallback optimization for interpolation functions - Sliding LAYERS/INTEL edge panels replace bulky Record Panel - Modular fetcher architecture (flights, geo, infrastructure, financial, earth_observation) - Stable entity IDs for GDELT & News popups (PR #63, credit @csysp) - Admin auth (X-Admin-Key), rate limiting (slowapi), auto-updater - Docker Swarm secrets support, env_check.py validation - 85+ vitest tests, CI pipeline, geoJSON builder extraction - Server-side viewport bbox filtering reduces payloads 80%+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Former-commit-id: f2883150b5bc78ebc139d89cc966a76f7d7c0408
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spreadAlertItems } from '@/utils/alertSpread';
|
||||
|
||||
describe('spreadAlertItems', () => {
|
||||
const makeAlert = (title: string, lat: number, lng: number, cluster_count = 1) => ({
|
||||
title,
|
||||
coords: [lat, lng],
|
||||
cluster_count,
|
||||
alert_level: 3,
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(spreadAlertItems([], 4, new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on null input (caller must null-check)', () => {
|
||||
expect(() => spreadAlertItems(null as any, 4, new Set())).toThrow();
|
||||
});
|
||||
|
||||
it('filters out items without coords', () => {
|
||||
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 dismissed = new Set(['Fire in NYC|40.7,-74']);
|
||||
const result = spreadAlertItems(items, 4, dismissed);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].title).toBe('Floods in LA');
|
||||
});
|
||||
|
||||
it('preserves originalIdx for popup selection', () => {
|
||||
const items = [
|
||||
{ title: 'Skip me', alert_level: 1 }, // no coords
|
||||
makeAlert('Alert A', 10, 20),
|
||||
makeAlert('Alert B', 30, 40),
|
||||
];
|
||||
const result = spreadAlertItems(items, 4, new Set());
|
||||
expect(result[0].originalIdx).toBe(1);
|
||||
expect(result[1].originalIdx).toBe(2);
|
||||
});
|
||||
|
||||
it('adds alertKey and showLine properties', () => {
|
||||
const items = [makeAlert('Test Alert', 51.5, -0.1)];
|
||||
const result = spreadAlertItems(items, 4, new Set());
|
||||
expect(result[0]).toHaveProperty('alertKey');
|
||||
expect(result[0]).toHaveProperty('showLine');
|
||||
expect(result[0].alertKey).toContain('Test Alert');
|
||||
});
|
||||
|
||||
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 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
|
||||
);
|
||||
expect(hasNonZeroOffset).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Alert spread collision resolution algorithm.
|
||||
* Takes news items with coordinates and resolves visual overlaps
|
||||
* 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";
|
||||
|
||||
export interface SpreadAlertItem extends NewsArticle {
|
||||
originalIdx: number;
|
||||
x: number;
|
||||
y: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
boxH: number;
|
||||
alertKey: string;
|
||||
showLine: boolean;
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves alert box collisions using a grid-based spatial algorithm (O(n) per iteration).
|
||||
* Returns positioned items with offsets and alert keys.
|
||||
*/
|
||||
export function spreadAlertItems(
|
||||
news: NewsArticle[],
|
||||
zoom: number,
|
||||
dismissedAlerts: Set<string>
|
||||
): SpreadAlertItem[] {
|
||||
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 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user