mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
OpenSky, the default civil feed, reports no operator, so group_by: operator said "no operator reported". aircraft_in_area now also takes group_by: 'airline', read from the ICAO designator in each callsign: three letters followed by a flight number that starts with a digit (SWA1234, UAL12A). Registrations used as callsigns (N123AB) count as unknown. Rows are unchanged; the designator is derived for the breakdown only. Live, Texas: "785 aircraft ...; most by airline: AAL (89), SWA (65), UAL (52); 283 unknown." Co-Authored-By: Claude Opus 5.5 <[email protected]>
132 lines
4.0 KiB
JavaScript
132 lines
4.0 KiB
JavaScript
/** Result sizing shared by tools that return lists. */
|
|
|
|
export const DEFAULT_LIMIT = 25;
|
|
export const MAX_LIMIT = 200;
|
|
|
|
/** JSON Schema for the `limit` argument. */
|
|
export const LIMIT_SCHEMA = Object.freeze({
|
|
type: 'integer',
|
|
minimum: 1,
|
|
maximum: MAX_LIMIT,
|
|
description: `Most rows to return (default ${DEFAULT_LIMIT}, at most ${MAX_LIMIT}).`,
|
|
});
|
|
|
|
/** Keep the first `limit` rows and report how many matched in total. */
|
|
export function capRows(rows, limit = DEFAULT_LIMIT) {
|
|
const kept = rows.slice(0, Math.min(limit, MAX_LIMIT));
|
|
return {
|
|
total: rows.length,
|
|
returned: kept.length,
|
|
truncated: kept.length < rows.length,
|
|
rows: kept,
|
|
};
|
|
}
|
|
|
|
/** Most groups a breakdown lists before counting the rest as other. */
|
|
export const MAX_GROUPS = 10;
|
|
|
|
/**
|
|
* Count rows by one text field over every row, not just those returned.
|
|
* Values group case-insensitively and keep their first spelling; rows
|
|
* without a value are counted as unknown. `valueOf` derives the value when
|
|
* it is not a row field as it stands.
|
|
*/
|
|
export function breakdown(rows, field, valueOf = (row) => row[field]) {
|
|
const groups = new Map();
|
|
let unknown = 0;
|
|
for (const row of rows) {
|
|
const raw = valueOf(row);
|
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
if (!value) {
|
|
unknown += 1;
|
|
continue;
|
|
}
|
|
const key = value.toLowerCase();
|
|
const group = groups.get(key);
|
|
if (group) group.count += 1;
|
|
else groups.set(key, { value, count: 1 });
|
|
}
|
|
const sorted = [...groups.values()].sort(
|
|
(a, b) => b.count - a.count || a.value.localeCompare(b.value),
|
|
);
|
|
const listed = sorted.slice(0, MAX_GROUPS);
|
|
return {
|
|
field,
|
|
groups: listed,
|
|
other_count: rows.length - unknown - sum(listed.map((g) => g.count)),
|
|
unknown_count: unknown,
|
|
};
|
|
}
|
|
|
|
/** Minimum, median, mean and maximum of one numeric field, or null. */
|
|
export function numericSummary(rows, field) {
|
|
const values = rows
|
|
.map((row) => row[field])
|
|
.filter(Number.isFinite)
|
|
.sort((a, b) => a - b);
|
|
if (!values.length) return null;
|
|
const middle = values.length >> 1;
|
|
const median =
|
|
values.length % 2
|
|
? values[middle]
|
|
: (values[middle - 1] + values[middle]) / 2;
|
|
const round1 = (value) => Number(value.toFixed(1));
|
|
return {
|
|
count: values.length,
|
|
min: values[0],
|
|
median: round1(median),
|
|
mean: round1(sum(values) / values.length),
|
|
max: values.at(-1),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* "; most by operator: Delta (12), United (9), American (7); 4 unknown", or
|
|
* "; no operator reported" when no row carries the field.
|
|
*/
|
|
export function breakdownPhrase(result, label) {
|
|
if (!result.groups.length)
|
|
return result.unknown_count ? `; no ${label} reported` : '';
|
|
const top = result.groups
|
|
.slice(0, 3)
|
|
.map((group) => `${group.value} (${group.count})`);
|
|
const unknown = result.unknown_count
|
|
? `; ${result.unknown_count} unknown`
|
|
: '';
|
|
return `; most by ${label}: ${top.join(', ')}${unknown}`;
|
|
}
|
|
|
|
function sum(values) {
|
|
return values.reduce((total, value) => total + value, 0);
|
|
}
|
|
|
|
/** Format an epoch millisecond time as ISO 8601, or null. */
|
|
export function isoTime(ms) {
|
|
if (!Number.isFinite(ms)) return null;
|
|
const date = new Date(ms);
|
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
}
|
|
|
|
/** "1 earthquake", "3 earthquakes". */
|
|
export function countNoun(count, singular, plural = `${singular}s`) {
|
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
}
|
|
|
|
/** Keep the first and last items and evenly spaced items between them. */
|
|
export function thinEvenly(items, max) {
|
|
if (items.length <= max) return items;
|
|
const step = (items.length - 1) / (max - 1);
|
|
return Array.from(
|
|
{ length: max },
|
|
(_, index) => items[Math.round(index * step)],
|
|
);
|
|
}
|
|
|
|
/** Base64-encode bytes with the platform's btoa, in chunks. */
|
|
export function toBase64(bytes) {
|
|
let binary = '';
|
|
for (let offset = 0; offset < bytes.length; offset += 0x8000)
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
return btoa(binary);
|
|
}
|