mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/b-awareness-marker-km
This commit is contained in:
commit
2124f56f92
40 files changed
+546
-48
No files matched your search
@@ -1573,8 +1573,11 @@ function greatCircleM(a, b) {
|
||||
|
||||
function formatDistance(m) {
|
||||
if (!Number.isFinite(m)) return null;
|
||||
if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
|
||||
return `${Math.round(m / 10) * 10} m`;
|
||||
// Round before each unit cut: 996 m was printed as "1000 m" and
|
||||
// 9,960 m as "10.0 km".
|
||||
const tens = Math.round(m / 10) * 10;
|
||||
if (tens < 1000) return `${tens} m`;
|
||||
return `${(m / 1000).toFixed(Math.round(m / 100) >= 100 ? 0 : 1)} km`;
|
||||
}
|
||||
|
||||
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
|
||||
|
||||
@@ -730,3 +730,18 @@ test('only an explicit navigation request permits resolving distant annotation t
|
||||
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const labels = [];
|
||||
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
for (const metres of [994, 996, 9940, 9960]) {
|
||||
await engine.annotate([
|
||||
{ type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] },
|
||||
]);
|
||||
}
|
||||
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
|
||||
engine.destroy();
|
||||
});
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
import {
|
||||
normalizeHeading,
|
||||
roundHeadingDegrees,
|
||||
} from './data/headingDegrees.js';
|
||||
|
||||
/** Normalize a heading into the [0, 360) range. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return ((value % 360) + 360) % 360;
|
||||
}
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
export { normalizeHeading };
|
||||
|
||||
/**
|
||||
* Three-digit heading readout, 000–359.
|
||||
@@ -12,7 +12,7 @@ export function normalizeHeading(value) {
|
||||
*/
|
||||
export function formatHeadingReadout(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '---';
|
||||
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
|
||||
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
|
||||
}
|
||||
|
||||
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
||||
|
||||
@@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => {
|
||||
assert.equal(formatHeadingReadout(359.4), '359');
|
||||
assert.equal(formatHeadingReadout(0), '000');
|
||||
assert.equal(formatHeadingReadout(10.2), '010');
|
||||
assert.equal(formatHeadingReadout(-0.6), '359');
|
||||
assert.equal(formatHeadingReadout(Number.NaN), '---');
|
||||
});
|
||||
|
||||
|
||||
@@ -1321,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
|
||||
assert.deepEqual(bare.details, []);
|
||||
});
|
||||
|
||||
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
|
||||
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
|
||||
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
|
||||
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
|
||||
'CONTAINER SHIP · 14.5KT · 0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||
try {
|
||||
Object.assign(harness.record, { heading: null, course: 359.7 });
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||||
|
||||
@@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
assert.equal(routeDistanceText(994), '990\u00a0m');
|
||||
assert.equal(routeDistanceText(996), '1.0\u00a0km');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDistanceText(9940), '9.9\u00a0km');
|
||||
assert.equal(routeDistanceText(9960), '10\u00a0km');
|
||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||
assert.equal(routeDurationText(59.4), '59\u00a0s');
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
// Only a negative remainder takes the extra turn, so a positive bearing
|
||||
// keeps its precision (0.49999999999999 must not become 360.5).
|
||||
const remainder = value % 360;
|
||||
const wrapped = remainder < 0 ? remainder + 360 : remainder;
|
||||
return wrapped === 360 ? 0 : wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
|
||||
* and negative or multi-turn values wrap. Null when the value is not finite,
|
||||
* so each caller keeps its own placeholder.
|
||||
*/
|
||||
export function roundHeadingDegrees(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Math.round(normalizeHeading(value)) % 360;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
|
||||
|
||||
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
|
||||
assert.equal(normalizeHeading(370), 10);
|
||||
assert.equal(normalizeHeading(-10), 350);
|
||||
assert.equal(normalizeHeading(720), 0);
|
||||
assert.equal(normalizeHeading(Number.NaN), 0);
|
||||
assert.equal(normalizeHeading(Infinity), 0);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
|
||||
assert.equal(roundHeadingDegrees(359.4), 359);
|
||||
assert.equal(roundHeadingDegrees(359.5), 0);
|
||||
assert.equal(roundHeadingDegrees(359.6), 0);
|
||||
assert.equal(roundHeadingDegrees(360), 0);
|
||||
assert.equal(roundHeadingDegrees(0), 0);
|
||||
assert.equal(roundHeadingDegrees(194.2), 194);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
|
||||
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
|
||||
assert.equal(roundHeadingDegrees(-0.6), 359);
|
||||
assert.equal(roundHeadingDegrees(-1), 359);
|
||||
assert.equal(roundHeadingDegrees(719.6), 0);
|
||||
assert.equal(roundHeadingDegrees(-360.6), 359);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees returns null for a value that is not finite', () => {
|
||||
for (const value of [
|
||||
Number.NaN,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
null,
|
||||
undefined,
|
||||
'12',
|
||||
]) {
|
||||
assert.equal(roundHeadingDegrees(value), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
|
||||
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
|
||||
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
|
||||
assert.equal(normalizeHeading(-1e-15) < 360, true);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
import {
|
||||
directionWedgePositions,
|
||||
alprLabelDetails,
|
||||
@@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => {
|
||||
const direction = (tag) =>
|
||||
alprLabelDetails(
|
||||
normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag },
|
||||
}),
|
||||
{ label: 'Custom directory' },
|
||||
).find((line) => line.startsWith('DIRECTION'));
|
||||
assert.equal(direction('359.6'), 'DIRECTION 0°');
|
||||
assert.equal(direction('359.4'), 'DIRECTION 359°');
|
||||
assert.equal(direction('0.4'), 'DIRECTION 0°');
|
||||
});
|
||||
|
||||
test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.equal(markerScale(100), 1.15);
|
||||
assert.equal(markerScale(40_000), 0.45);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DIRECTION_CONE_HALF_ANGLE_DEG,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
/**
|
||||
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
|
||||
@@ -50,7 +51,7 @@ export function alprLabelDetails(record, source) {
|
||||
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
|
||||
];
|
||||
if (Number.isFinite(record.directionDeg))
|
||||
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
|
||||
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
|
||||
const equipment = [
|
||||
...new Set(
|
||||
[record.manufacturer, record.operator, record.cameraType]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
/**
|
||||
* Format a compass direction for the awareness overlay.
|
||||
* @param {number} degrees
|
||||
@@ -5,6 +7,5 @@
|
||||
*/
|
||||
export function formatAwarenessDirection(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '—';
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
return String(Math.round(normalized) % 360).padStart(3, '0');
|
||||
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
/**
|
||||
* @module headingConfidence
|
||||
@@ -44,7 +45,7 @@ export function isHeadingEstimated(camera) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function headingHudToken(camera) {
|
||||
const hdg = Math.round(Number(camera?.headingDeg) || 0);
|
||||
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
|
||||
return isHeadingEstimated(camera)
|
||||
? t('HDG {value}° (ESTIMATED)', { value: hdg })
|
||||
: t('HDG {value}°', { value: hdg });
|
||||
|
||||
@@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => {
|
||||
assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°');
|
||||
assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°');
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }),
|
||||
'HDG 0° (ESTIMATED)',
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
|
||||
|
||||
@@ -113,7 +113,8 @@ export function routeDistanceText(meters) {
|
||||
const tens = Math.round(meters / 10) * 10;
|
||||
if (tens < 1000) return formatQuantity(tens, 'm');
|
||||
const km = meters / 1000;
|
||||
return km < 10
|
||||
// Pick the unit from the tenth it prints: 9.96 km read "10.0 km".
|
||||
return Math.round(meters / 100) < 100
|
||||
? formatQuantity(km, 'km', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createCivilFlightLayer } from './index.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createEnrichment } from './enrichment.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
|
||||
function services() {
|
||||
const names = [
|
||||
@@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.equal(flightState._enrichActive, 0);
|
||||
});
|
||||
|
||||
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
|
||||
const tracking = createTracking({
|
||||
flightState: {
|
||||
feed: { _lastSource: 'Fixture aircraft' },
|
||||
records: { data: new Map() },
|
||||
},
|
||||
services: services(),
|
||||
parts: {
|
||||
queries: {
|
||||
_describeFlight: () => ({ track: 359.6, altitudeM: 9000 }),
|
||||
_contactLabel: () => 'AAL1',
|
||||
},
|
||||
},
|
||||
layer: {},
|
||||
});
|
||||
assert.equal(
|
||||
tracking._contextSubjectMetadata('abc123').properties.heading,
|
||||
'0°',
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TRAIL_COLOR,
|
||||
CYAN_TRANSPARENT,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -126,7 +127,7 @@ export function createTracking({
|
||||
? `${Math.round(described.velocityMps * 1.944)} kt`
|
||||
: '',
|
||||
heading: Number.isFinite(described.track)
|
||||
? `${Math.round(described.track)}°`
|
||||
? `${roundHeadingDegrees(described.track)}°`
|
||||
: '',
|
||||
route: route || '',
|
||||
icao24,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
@@ -152,7 +153,7 @@ export function localAdsbCardModel(
|
||||
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
|
||||
altitude: grouped(altitude),
|
||||
speed: speed === null ? DASH : speed,
|
||||
track: track === null ? DASH : `${track % 360}°`,
|
||||
track: track === null ? DASH : `${roundHeadingDegrees(track)}°`,
|
||||
}),
|
||||
vertical === null
|
||||
? t('V/S {rate}', { rate: DASH })
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMilitaryFlightLayer } from './index.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
|
||||
function services() {
|
||||
const names = [
|
||||
@@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
|
||||
);
|
||||
});
|
||||
|
||||
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
|
||||
const stubs = services();
|
||||
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
|
||||
const tracking = createTracking({
|
||||
flightState: {
|
||||
feed: { _lastSource: 'Fixture aircraft' },
|
||||
records: { data: new Map() },
|
||||
},
|
||||
services: stubs,
|
||||
parts: {
|
||||
queries: {
|
||||
_describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }),
|
||||
_toCleanText: (value) => value || '',
|
||||
_formatAltitude: () => '',
|
||||
},
|
||||
},
|
||||
layer: {},
|
||||
});
|
||||
assert.equal(
|
||||
tracking._contextSubjectMetadata('ae1234').properties.heading,
|
||||
'0°',
|
||||
);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AMBER_TRANSPARENT,
|
||||
TRACKED_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -120,7 +121,7 @@ export function createTracking({
|
||||
? `${Math.round(described.velocityMps * 1.944)} kt`
|
||||
: '',
|
||||
heading: Number.isFinite(described.track)
|
||||
? `${Math.round(described.track)}°`
|
||||
? `${roundHeadingDegrees(described.track)}°`
|
||||
: '',
|
||||
icao24,
|
||||
// Honesty cue: the contact is coasting on dead reckoning, so the
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
transitRouteLabel,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { displayMotion, playbackPosition } from './movement.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -405,11 +406,10 @@ export function buildTransitSelectionCopy(
|
||||
reportedMotion.push(
|
||||
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
|
||||
);
|
||||
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
|
||||
if (Number.isFinite(record.bearing))
|
||||
reportedMotion.push(
|
||||
t('reported hdg {heading}°', {
|
||||
heading: Math.round(record.bearing) % 360,
|
||||
heading: roundHeadingDegrees(record.bearing),
|
||||
}),
|
||||
);
|
||||
// The operator's own claim, kept as a REPORT rather than as the live state.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeVesselType,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createCards({
|
||||
vesselState,
|
||||
@@ -55,7 +56,8 @@ export function createCards({
|
||||
if (record.speed !== null && record.speed !== undefined)
|
||||
parts.push(formatSpeed(record.speed));
|
||||
const direction = record.heading ?? record.course;
|
||||
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
|
||||
if (Number.isFinite(direction))
|
||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
actionable: Boolean(record?.mmsi),
|
||||
@@ -86,7 +88,9 @@ export function createCards({
|
||||
[
|
||||
vesselTypeShort(record) || t('VESSEL'),
|
||||
formatSpeed(record.speed),
|
||||
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
|
||||
Number.isFinite(direction)
|
||||
? `${roundHeadingDegrees(direction)}°`
|
||||
: '--°',
|
||||
].join(' · '),
|
||||
];
|
||||
const destination = String(record.destination || '').trim();
|
||||
@@ -176,7 +180,7 @@ export function createCards({
|
||||
|
||||
function formatHeading(heading) {
|
||||
return t('{heading}DEG', {
|
||||
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
|
||||
heading: roundHeadingDegrees(heading) ?? '--',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
cachedVueModule,
|
||||
compileVueModule,
|
||||
ownCacheDirectory,
|
||||
vueCacheDirectory,
|
||||
vueCacheKey,
|
||||
} from '../../build/vueLoader.js';
|
||||
|
||||
const SOURCE =
|
||||
'<script setup>\nconst label = "Hi";\n</script>\n<template><b>{{ label }}</b></template>\n';
|
||||
const FILE = '/virtual/Greeting.vue';
|
||||
|
||||
test('a component compiles once and later processes read the cached module', (t) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const compiled = cachedVueModule(SOURCE, FILE, directory);
|
||||
assert.equal(compiled, compileVueModule(SOURCE, FILE));
|
||||
const entries = readdirSync(directory);
|
||||
assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]);
|
||||
// A cached entry is returned as stored, without compiling again.
|
||||
writeFileSync(path.join(directory, entries[0]), 'export default "cached";');
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, directory),
|
||||
'export default "cached";',
|
||||
);
|
||||
});
|
||||
|
||||
test('an edited component or another path is a new cache entry', () => {
|
||||
const key = vueCacheKey(SOURCE, FILE);
|
||||
assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key);
|
||||
assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key);
|
||||
assert.equal(vueCacheKey(SOURCE, FILE), key);
|
||||
});
|
||||
|
||||
test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => {
|
||||
assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null);
|
||||
if (process.platform !== 'win32') {
|
||||
const directory = vueCacheDirectory({});
|
||||
assert.equal(path.dirname(directory), tmpdir());
|
||||
assert.match(path.basename(directory), /^gev-vue-sfc-/);
|
||||
}
|
||||
// Windows temp directories may be shared, so the cache lives in the
|
||||
// user's profile, or not at all.
|
||||
assert.equal(
|
||||
vueCacheDirectory(
|
||||
{ LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' },
|
||||
'win32',
|
||||
),
|
||||
path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'),
|
||||
);
|
||||
assert.equal(vueCacheDirectory({}, 'win32'), null);
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, null),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
});
|
||||
|
||||
test('a cache directory that cannot be written still returns the compiled module', (t) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
// A file where the cache directory should be makes every write fail.
|
||||
const blocked = path.join(directory, 'blocked');
|
||||
writeFileSync(blocked, '');
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, blocked),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'a cache directory others can write to, or a link to one, is never read',
|
||||
{
|
||||
skip: process.platform === 'win32',
|
||||
},
|
||||
(t) => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const shared = path.join(root, 'shared');
|
||||
mkdirSync(shared);
|
||||
chmodSync(shared, 0o777);
|
||||
// A module planted where the cache would look must not run.
|
||||
writeFileSync(
|
||||
path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`),
|
||||
'export default "planted";',
|
||||
);
|
||||
assert.equal(ownCacheDirectory(shared), false);
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, shared),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
const own = path.join(root, 'own');
|
||||
mkdirSync(own, { mode: 0o700 });
|
||||
const link = path.join(root, 'link');
|
||||
symlinkSync(own, link);
|
||||
assert.equal(ownCacheDirectory(link), false);
|
||||
assert.equal(ownCacheDirectory(own), true);
|
||||
},
|
||||
);
|
||||
@@ -490,3 +490,26 @@ test('the scene summary types in, then holds the whole text', async (t) => {
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-summary').textContent, text);
|
||||
});
|
||||
|
||||
test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => {
|
||||
const meta = (headingDeg) => {
|
||||
const camera = {
|
||||
id: 'a',
|
||||
city: 'Austin',
|
||||
headingDeg,
|
||||
fovDeg: 74,
|
||||
rangeM: 700,
|
||||
};
|
||||
return presentCctvPanel(
|
||||
{
|
||||
enabled: true,
|
||||
activeCameraId: camera.id,
|
||||
activeCamera: camera,
|
||||
cameras: [camera],
|
||||
},
|
||||
true,
|
||||
).meta;
|
||||
};
|
||||
assert.match(meta(359.4), /· HDG 359° ·/);
|
||||
assert.match(meta(359.6), /· HDG 0° ·/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||
|
||||
import { createCctvVideoSurface } from './cctvVideo.js';
|
||||
import { presentCctvCalibration } from './cctvCalibration.js';
|
||||
import { roundHeadingDegrees } from '../data/headingDegrees.js';
|
||||
export function _calBadgeLabel(badge) {
|
||||
switch (badge) {
|
||||
case 'calibrated':
|
||||
@@ -107,7 +108,7 @@ function metaText(state, enabled, activeCamera, catalogCount) {
|
||||
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
|
||||
{
|
||||
city: activeCamera.city,
|
||||
heading: `${Math.round(activeCamera.headingDeg)}°`,
|
||||
heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`,
|
||||
fov: `${Math.round(activeCamera.fovDeg)}°`,
|
||||
range: Math.round(activeCamera.rangeM),
|
||||
projection: projLabel,
|
||||
|
||||
@@ -120,7 +120,7 @@ export function updateHud(
|
||||
(tick) => formatAltitudeRulerTick(tick.valueFt),
|
||||
);
|
||||
view.heading = {
|
||||
text: String(Math.round(heading) % 360).padStart(3, '0'),
|
||||
text: formatHeadingReadout(heading),
|
||||
value: heading,
|
||||
immediate: forceContext,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
import {
|
||||
normalizeHeading,
|
||||
roundHeadingDegrees,
|
||||
} from '../data/headingDegrees.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
|
||||
@@ -135,8 +139,7 @@ export function cockpitUnitCase(text) {
|
||||
export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
|
||||
const labels = Object.keys(COMPASS_POINT_LABELS);
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
// 359.5° and above is north. Modulo keeps that from printing 360°.
|
||||
const degrees = Math.round(normalized) % 360;
|
||||
const normalized = normalizeHeading(value);
|
||||
const degrees = roundHeadingDegrees(value);
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
formatHeadingReadout,
|
||||
normalizeHeading,
|
||||
resolveTrackedAircraftInfo,
|
||||
} from '../cockpitMath.js';
|
||||
@@ -186,9 +187,7 @@ export function enter() {
|
||||
this.startBriefRotation();
|
||||
const trackLabel =
|
||||
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
|
||||
const trackHeading = String(
|
||||
Math.round(normalizeHeading(info.track ?? 0)),
|
||||
).padStart(3, '0');
|
||||
const trackHeading = formatHeadingReadout(info.track ?? 0);
|
||||
this.pushCockpitSignal(
|
||||
'track',
|
||||
'track',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
|
||||
import { roundHeadingDegrees } from '../data/headingDegrees.js';
|
||||
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
@@ -175,9 +176,8 @@ function presentViewer(state) {
|
||||
const { street } = state;
|
||||
const right = [];
|
||||
if (street.isPano) right.push('360°');
|
||||
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
|
||||
if (Number.isFinite(street.bearing))
|
||||
right.push(`${Math.round(street.bearing) % 360}°`);
|
||||
right.push(`${roundHeadingDegrees(street.bearing)}°`);
|
||||
if (street.capturedAt)
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
|
||||
Reference in new issue
Block a user