@motionstudies/core 0.1.0-alpha.24 → 0.1.0-alpha.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/air-enrichment.d.ts +63 -0
- package/air-enrichment.js +76 -0
- package/map-labels.d.ts +24 -0
- package/map-labels.js +48 -0
- package/package.json +11 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type AirContinent } from './air-continents.ts';
|
|
2
|
+
import type { AirSearchTrack } from './air-search.ts';
|
|
3
|
+
export type EndpointSide = 'origin' | 'destination';
|
|
4
|
+
export type EndpointEvidence = 'observed' | 'corroborated' | 'candidate' | 'conflicting' | 'unknown';
|
|
5
|
+
export interface EnrichmentAirport {
|
|
6
|
+
icao: string;
|
|
7
|
+
iata: string;
|
|
8
|
+
name: string;
|
|
9
|
+
continent: AirContinent | null;
|
|
10
|
+
}
|
|
11
|
+
export interface EnrichmentSource {
|
|
12
|
+
id: string;
|
|
13
|
+
kind: 'airport-reference' | 'route-reference' | 'association-audit';
|
|
14
|
+
url: string;
|
|
15
|
+
sha256: string;
|
|
16
|
+
license: string;
|
|
17
|
+
}
|
|
18
|
+
export interface EndpointProposal {
|
|
19
|
+
trackId: string;
|
|
20
|
+
callsign: string;
|
|
21
|
+
aircraft: string;
|
|
22
|
+
side: EndpointSide;
|
|
23
|
+
airport: string;
|
|
24
|
+
anchor: string;
|
|
25
|
+
status: 'corroborated' | 'candidate' | 'conflicting';
|
|
26
|
+
sourceIds: string[];
|
|
27
|
+
reasons: string[];
|
|
28
|
+
}
|
|
29
|
+
/** Sidecar metadata only. Proposed endpoints deliberately have no playback or event time. */
|
|
30
|
+
export interface AirEnrichment {
|
|
31
|
+
kind: 'air-endpoint-enrichment';
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
date: string;
|
|
34
|
+
sourceManifestSha256: string;
|
|
35
|
+
airports: EnrichmentAirport[];
|
|
36
|
+
sources: EnrichmentSource[];
|
|
37
|
+
proposals: EndpointProposal[];
|
|
38
|
+
}
|
|
39
|
+
export interface EndpointLabel {
|
|
40
|
+
status: EndpointEvidence;
|
|
41
|
+
airport?: EnrichmentAirport;
|
|
42
|
+
sourceIds: readonly string[];
|
|
43
|
+
reasons: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
/** Reject wrong-day, foreign-track, duplicate, stale-anchor and observed-overwrite metadata. */
|
|
46
|
+
export declare function readAirEnrichment(value: unknown, binding: {
|
|
47
|
+
date: string;
|
|
48
|
+
sourceManifestSha256: string;
|
|
49
|
+
tracks: readonly AirSearchTrack[];
|
|
50
|
+
}): AirEnrichment;
|
|
51
|
+
export declare function createEndpointResolver(enrichment: AirEnrichment): (track: AirSearchTrack, side: EndpointSide) => EndpointLabel;
|
|
52
|
+
export declare function usableEndpoint(label: EndpointLabel, includeCandidates?: boolean): boolean;
|
|
53
|
+
export declare function endpointCoverage(tracks: readonly AirSearchTrack[], resolve: ReturnType<typeof createEndpointResolver>, side: EndpointSide, includeCandidates?: boolean): {
|
|
54
|
+
observed: number;
|
|
55
|
+
corroborated: number;
|
|
56
|
+
candidate: number;
|
|
57
|
+
conflicting: number;
|
|
58
|
+
unknown: number;
|
|
59
|
+
segments: number;
|
|
60
|
+
usable: number;
|
|
61
|
+
unresolved: number;
|
|
62
|
+
continentKnown: number;
|
|
63
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isAirContinent } from './air-continents.js';
|
|
2
|
+
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
3
|
+
const text = (v) => typeof v === 'string' && v.length > 0;
|
|
4
|
+
const hash = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
5
|
+
const side = (v) => v === 'origin' || v === 'destination';
|
|
6
|
+
/** Reject wrong-day, foreign-track, duplicate, stale-anchor and observed-overwrite metadata. */
|
|
7
|
+
export function readAirEnrichment(value, binding) {
|
|
8
|
+
const fail = () => { throw Error('Invalid or mismatched endpoint enrichment'); };
|
|
9
|
+
if (!object(value) || value.kind !== 'air-endpoint-enrichment' || value.schemaVersion !== 1 || value.date !== binding.date
|
|
10
|
+
|| !hash(value.sourceManifestSha256) || value.sourceManifestSha256 !== binding.sourceManifestSha256
|
|
11
|
+
|| !Array.isArray(value.airports) || !Array.isArray(value.sources) || !Array.isArray(value.proposals))
|
|
12
|
+
return fail();
|
|
13
|
+
const airports = new Set(), sources = new Map(), proposals = new Set();
|
|
14
|
+
for (const a of value.airports) {
|
|
15
|
+
if (!object(a) || !text(a.icao) || typeof a.iata !== 'string' || !text(a.name) || (a.continent !== null && !isAirContinent(a.continent)) || airports.has(a.icao))
|
|
16
|
+
return fail();
|
|
17
|
+
airports.add(a.icao);
|
|
18
|
+
}
|
|
19
|
+
for (const s of value.sources) {
|
|
20
|
+
if (!object(s) || !text(s.id) || sources.has(s.id) || !text(s.url) || !hash(s.sha256) || !text(s.license)
|
|
21
|
+
|| !['airport-reference', 'route-reference', 'association-audit'].includes(String(s.kind)))
|
|
22
|
+
return fail();
|
|
23
|
+
// Links are either public HTTPS sources or simple sibling report files.
|
|
24
|
+
if (!/^https:\/\/[^\s]+$/.test(s.url) && !/^reports\/[a-zA-Z0-9._-]+\.json$/.test(s.url))
|
|
25
|
+
return fail();
|
|
26
|
+
sources.set(s.id, String(s.kind));
|
|
27
|
+
}
|
|
28
|
+
const tracks = new Map(binding.tracks.map(t => [t.id, t]));
|
|
29
|
+
for (const p of value.proposals) {
|
|
30
|
+
if (!object(p) || !text(p.trackId) || !side(p.side) || !text(p.airport) || !airports.has(p.airport)
|
|
31
|
+
|| !['corroborated', 'candidate', 'conflicting'].includes(String(p.status)) || !Array.isArray(p.sourceIds) || !p.sourceIds.length
|
|
32
|
+
|| !p.sourceIds.every(s => typeof s === 'string' && sources.has(s)) || !Array.isArray(p.reasons) || !p.reasons.every(text))
|
|
33
|
+
return fail();
|
|
34
|
+
const t = tracks.get(p.trackId), key = `${p.trackId}:${p.side}`, opposite = p.side === 'origin' ? 'destination' : 'origin';
|
|
35
|
+
if (!t || t[p.side] || t[opposite]?.icao !== p.anchor || t.callsign !== p.callsign || t.icaoAddress !== p.aircraft || proposals.has(key))
|
|
36
|
+
return fail();
|
|
37
|
+
if (!p.sourceIds.some(s => sources.get(s) === 'route-reference')
|
|
38
|
+
|| (p.status !== 'candidate' && !p.sourceIds.some(s => sources.get(s) === 'association-audit'))
|
|
39
|
+
|| (p.status === 'corroborated' && p.reasons.length))
|
|
40
|
+
return fail();
|
|
41
|
+
proposals.add(key);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
export function createEndpointResolver(enrichment) {
|
|
46
|
+
const airports = new Map(enrichment.airports.map(a => [a.icao, a]));
|
|
47
|
+
const proposals = new Map(enrichment.proposals.map(p => [`${p.trackId}:${p.side}`, p]));
|
|
48
|
+
const referenceIds = enrichment.sources.filter(s => s.kind === 'airport-reference').map(s => s.id);
|
|
49
|
+
return (track, side) => {
|
|
50
|
+
const observed = track[side];
|
|
51
|
+
if (observed)
|
|
52
|
+
return { status: 'observed', airport: { icao: observed.icao, iata: observed.iata, name: observed.name,
|
|
53
|
+
continent: isAirContinent(observed.continent) ? observed.continent : airports.get(observed.icao)?.continent ?? null }, sourceIds: referenceIds, reasons: [] };
|
|
54
|
+
const p = proposals.get(`${track.id}:${side}`);
|
|
55
|
+
if (!p)
|
|
56
|
+
return { status: 'unknown', sourceIds: [], reasons: [] };
|
|
57
|
+
return { status: p.status, airport: airports.get(p.airport), sourceIds: p.sourceIds, reasons: p.reasons };
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function usableEndpoint(label, includeCandidates = false) {
|
|
61
|
+
return label.status === 'observed' || label.status === 'corroborated' || (includeCandidates && label.status === 'candidate');
|
|
62
|
+
}
|
|
63
|
+
export function endpointCoverage(tracks, resolve, side, includeCandidates = false) {
|
|
64
|
+
const counts = { observed: 0, corroborated: 0, candidate: 0, conflicting: 0, unknown: 0 };
|
|
65
|
+
let usable = 0, continentKnown = 0;
|
|
66
|
+
for (const t of tracks) {
|
|
67
|
+
const label = resolve(t, side);
|
|
68
|
+
counts[label.status]++;
|
|
69
|
+
if (usableEndpoint(label, includeCandidates)) {
|
|
70
|
+
usable++;
|
|
71
|
+
if (label.airport?.continent)
|
|
72
|
+
continentKnown++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { segments: tracks.length, ...counts, usable, unresolved: tracks.length - usable, continentKnown };
|
|
76
|
+
}
|
package/map-labels.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Renderer-independent hierarchy shared by station and airport map labels. */
|
|
2
|
+
export interface MapLabelPriority {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly rank: number;
|
|
5
|
+
readonly priority: number;
|
|
6
|
+
readonly retained: boolean;
|
|
7
|
+
readonly distance: number;
|
|
8
|
+
}
|
|
9
|
+
export interface MapLabelBox {
|
|
10
|
+
readonly left: number;
|
|
11
|
+
readonly right: number;
|
|
12
|
+
readonly top: number;
|
|
13
|
+
readonly bottom: number;
|
|
14
|
+
}
|
|
15
|
+
export interface MapLabelCandidate extends MapLabelPriority {
|
|
16
|
+
readonly box: MapLabelBox;
|
|
17
|
+
}
|
|
18
|
+
export declare function compareMapLabelCandidates(first: MapLabelPriority, second: MapLabelPriority): number;
|
|
19
|
+
export declare function mapLabelBudget(cameraHeight: number): number;
|
|
20
|
+
export declare function mapLabelRankLimit(cameraHeight: number): number;
|
|
21
|
+
export declare function mapLabelBoxesOverlap(box: MapLabelBox, other: MapLabelBox, horizontalGap?: number, verticalGap?: number): boolean;
|
|
22
|
+
/** Candidate boxes include the edition's full hit area. Selected candidates must
|
|
23
|
+
* already have their higher priority; all labels respect viewport and obstacles. */
|
|
24
|
+
export declare function selectMapLabels<T extends MapLabelCandidate>(candidates: readonly T[], budget: number, viewport: MapLabelBox, obstacles?: readonly MapLabelBox[]): T[];
|
package/map-labels.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function compareMapLabelCandidates(first, second) {
|
|
2
|
+
return first.priority - second.priority || Number(second.retained) - Number(first.retained)
|
|
3
|
+
|| first.rank - second.rank || first.distance - second.distance || first.name.localeCompare(second.name, 'de-CH');
|
|
4
|
+
}
|
|
5
|
+
export function mapLabelBudget(cameraHeight) {
|
|
6
|
+
if (cameraHeight >= 30)
|
|
7
|
+
return 8;
|
|
8
|
+
if (cameraHeight >= 22)
|
|
9
|
+
return 20;
|
|
10
|
+
if (cameraHeight >= 15)
|
|
11
|
+
return 48;
|
|
12
|
+
return 96;
|
|
13
|
+
}
|
|
14
|
+
export function mapLabelRankLimit(cameraHeight) {
|
|
15
|
+
if (cameraHeight >= 30)
|
|
16
|
+
return 8;
|
|
17
|
+
if (cameraHeight >= 22)
|
|
18
|
+
return 20;
|
|
19
|
+
if (cameraHeight >= 15)
|
|
20
|
+
return 48;
|
|
21
|
+
if (cameraHeight >= 14)
|
|
22
|
+
return 96;
|
|
23
|
+
return Number.POSITIVE_INFINITY;
|
|
24
|
+
}
|
|
25
|
+
export function mapLabelBoxesOverlap(box, other, horizontalGap = 5, verticalGap = 4) {
|
|
26
|
+
return box.left < other.right + horizontalGap && box.right > other.left - horizontalGap
|
|
27
|
+
&& box.top < other.bottom + verticalGap && box.bottom > other.top - verticalGap;
|
|
28
|
+
}
|
|
29
|
+
/** Candidate boxes include the edition's full hit area. Selected candidates must
|
|
30
|
+
* already have their higher priority; all labels respect viewport and obstacles. */
|
|
31
|
+
export function selectMapLabels(candidates, budget, viewport, obstacles = []) {
|
|
32
|
+
const selected = [], occupied = [...obstacles];
|
|
33
|
+
const limit = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
|
34
|
+
for (const candidate of [...candidates].sort(compareMapLabelCandidates)) {
|
|
35
|
+
if (selected.length >= limit)
|
|
36
|
+
break;
|
|
37
|
+
const box = candidate.box;
|
|
38
|
+
if (![box.left, box.right, box.top, box.bottom].every(Number.isFinite) || box.right <= box.left || box.bottom <= box.top)
|
|
39
|
+
continue;
|
|
40
|
+
if (box.left < viewport.left || box.right > viewport.right || box.top < viewport.top || box.bottom > viewport.bottom)
|
|
41
|
+
continue;
|
|
42
|
+
if (occupied.some(other => mapLabelBoxesOverlap(box, other)))
|
|
43
|
+
continue;
|
|
44
|
+
selected.push(candidate);
|
|
45
|
+
occupied.push(box);
|
|
46
|
+
}
|
|
47
|
+
return selected;
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionstudies/core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.26",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Transport contracts and motion primitives for Motion Studies.",
|
|
@@ -224,6 +224,16 @@
|
|
|
224
224
|
"types": "./render-frame.d.ts",
|
|
225
225
|
"import": "./render-frame.js",
|
|
226
226
|
"default": "./render-frame.js"
|
|
227
|
+
},
|
|
228
|
+
"./map-labels": {
|
|
229
|
+
"types": "./map-labels.d.ts",
|
|
230
|
+
"import": "./map-labels.js",
|
|
231
|
+
"default": "./map-labels.js"
|
|
232
|
+
},
|
|
233
|
+
"./air-enrichment": {
|
|
234
|
+
"types": "./air-enrichment.d.ts",
|
|
235
|
+
"import": "./air-enrichment.js",
|
|
236
|
+
"default": "./air-enrichment.js"
|
|
227
237
|
}
|
|
228
238
|
},
|
|
229
239
|
"files": [
|