Files
gods-eye-view/src/tools/results.js
T
quanticsoul4772andClaude Opus 5.5 aec4f67ca9 feat(tools): count aircraft in an area by airline
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]>
2026-10-17 14:32:46 -07:00

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);
}