mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c33893f6 | ||
|
|
b6ad32009f | ||
|
|
7d8b80db0a | ||
|
|
e84cae6baa | ||
|
|
06c84f92d3 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
- Chat autocomplete finds Arabic suggestions typed without short vowels or
|
||||
shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر»
|
||||
(German, @gekh, #1561).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Say why Transit and Bike Share are empty outside coverage. Over a city with
|
||||
no feed or system (London, Tokyo) their Layers rows read
|
||||
`No feed here yet · 16 regions available` and
|
||||
`No bike share here yet · 61 systems available`, or how low to fly, instead
|
||||
of `never`; leaving coverage clears the last-update time (German, @gekh,
|
||||
#1576).
|
||||
@@ -1817,6 +1817,19 @@ Mapped-installation zoom guidance appears in the layer row without counting as
|
||||
a global loading failure. Guidance statuses do not suppress independent refresh
|
||||
errors.
|
||||
|
||||
Transit and Bike Share report the same kind of guidance when no feed or system
|
||||
covers the view, instead of an undated `never`. Above their altitude gates the
|
||||
rows read `GTFS-RT · Fly below 3,000 km to a covered region` and
|
||||
`GBFS · Fly below 48 km to a covered city`; lower down over a city neither
|
||||
covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions
|
||||
available` and `GBFS · No bike share here yet · N systems available`, N being
|
||||
the enabled feeds and the catalogued systems. The
|
||||
chip stays ON. A Bike Share city that is loading or failed keeps its own
|
||||
loading or error text. Leaving every covered feed or system clears the layer's
|
||||
last-update time, so the row never dates data it no longer draws. The same
|
||||
English notes are the voice analyst's empty-result notes; both surfaces show
|
||||
them translated through `translateStatusMessage` (`src/ui/statusText.js`).
|
||||
|
||||
## Places and CCTV request bounds
|
||||
|
||||
With a Google key configured, nearby and text search reject missing, blank,
|
||||
|
||||
@@ -2912,6 +2912,15 @@
|
||||
"many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة",
|
||||
"other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح",
|
||||
"one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})",
|
||||
"two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})",
|
||||
"few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة",
|
||||
"many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا",
|
||||
"other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح"
|
||||
},
|
||||
"degraded": "متدهور",
|
||||
"stale": "متقادم",
|
||||
"partial": "جزئي",
|
||||
|
||||
@@ -2662,6 +2662,11 @@
|
||||
"one": "Hier noch kein Feed · {n} Region verfügbar",
|
||||
"other": "Hier noch kein Feed · {n} Regionen verfügbar"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Hier noch kein Bikesharing · {n} System verfügbar",
|
||||
"other": "Hier noch kein Bikesharing · {n} Systeme verfügbar"
|
||||
},
|
||||
"degraded": "eingeschränkt",
|
||||
"stale": "veraltet",
|
||||
"partial": "teilweise",
|
||||
|
||||
@@ -2821,6 +2821,12 @@
|
||||
"many": "Aún no hay fuente aquí · {n} de regiones disponibles",
|
||||
"other": "Aún no hay fuente aquí · {n} regiones disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Aún no hay bicis compartidas aquí · {n} sistema disponible",
|
||||
"many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles",
|
||||
"other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles"
|
||||
},
|
||||
"Wind speed field unavailable": "Campo de velocidad del viento no disponible",
|
||||
"Decoder feeds": "Fuentes del decodificador",
|
||||
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
|
||||
|
||||
@@ -2531,6 +2531,12 @@
|
||||
"many": "Pas encore de flux ici · {n} de régions disponibles",
|
||||
"other": "Pas encore de flux ici · {n} régions disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Pas encore de vélos partagés ici · {n} système disponible",
|
||||
"many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles",
|
||||
"other": "Pas encore de vélos partagés ici · {n} systèmes disponibles"
|
||||
},
|
||||
"degraded": "dégradé",
|
||||
"stale": "obsolète",
|
||||
"partial": "partiel",
|
||||
|
||||
@@ -2531,6 +2531,12 @@
|
||||
"many": "Ancora nessun feed qui · {n} di regioni disponibili",
|
||||
"other": "Ancora nessun feed qui · {n} regioni disponibili"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ancora nessun bike sharing qui · {n} sistema disponibile",
|
||||
"many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili",
|
||||
"other": "Ancora nessun bike sharing qui · {n} sistemi disponibili"
|
||||
},
|
||||
"degraded": "degradato",
|
||||
"stale": "obsoleto",
|
||||
"partial": "parziale",
|
||||
|
||||
@@ -2401,6 +2401,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件"
|
||||
},
|
||||
"degraded": "性能低下",
|
||||
"stale": "更新遅延",
|
||||
"partial": "部分的",
|
||||
|
||||
@@ -2533,6 +2533,12 @@
|
||||
"many": "Ainda não há feed aqui · {n} de regiões disponíveis",
|
||||
"other": "Ainda não há feed aqui · {n} regiões disponíveis"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível",
|
||||
"many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis",
|
||||
"other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis"
|
||||
},
|
||||
"degraded": "degradado",
|
||||
"stale": "desatualizado",
|
||||
"partial": "parcial",
|
||||
|
||||
@@ -2884,6 +2884,13 @@
|
||||
"many": "Здесь пока нет потока данных · доступно {n} регионов",
|
||||
"other": "Здесь пока нет потока данных · доступно {n} региона"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Здесь пока нет проката велосипедов · доступна {n} система",
|
||||
"few": "Здесь пока нет проката велосипедов · доступны {n} системы",
|
||||
"many": "Здесь пока нет проката велосипедов · доступно {n} систем",
|
||||
"other": "Здесь пока нет проката велосипедов · доступно {n} системы"
|
||||
},
|
||||
"Wind speed field unavailable": "Поле скорости ветра недоступно",
|
||||
"Decoder feeds": "Потоки декодера",
|
||||
"WebUSB + decoder feeds": "WebUSB + потоки декодера",
|
||||
|
||||
@@ -2595,6 +2595,13 @@
|
||||
"many": "Тут поки немає потоку даних · доступно {n} регіонів",
|
||||
"other": "Тут поки немає потоку даних · доступно {n} регіону"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Тут поки немає прокату велосипедів · доступна {n} система",
|
||||
"few": "Тут поки немає прокату велосипедів · доступні {n} системи",
|
||||
"many": "Тут поки немає прокату велосипедів · доступно {n} систем",
|
||||
"other": "Тут поки немає прокату велосипедів · доступно {n} системи"
|
||||
},
|
||||
"degraded": "з перебоями",
|
||||
"stale": "застарів",
|
||||
"partial": "частково",
|
||||
|
||||
@@ -2302,6 +2302,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此处暂无数据源 · 有 {n} 个覆盖区域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此处暂无共享单车 · 有 {n} 个可用系统"
|
||||
},
|
||||
"degraded": "降级",
|
||||
"stale": "过期",
|
||||
"partial": "部分可用",
|
||||
|
||||
@@ -2290,6 +2290,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此處暫無數據源 · 有 {n} 個覆蓋區域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此處暫無共享單車 · 有 {n} 個可用系統"
|
||||
},
|
||||
"degraded": "降級",
|
||||
"stale": "過期",
|
||||
"partial": "部分可用",
|
||||
|
||||
Vendored
+20
-2
@@ -1,5 +1,6 @@
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { STATUS_POLL_MS } from './policy.js';
|
||||
import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
@@ -57,7 +58,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Return current layer statistics for the UI status display.
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }}
|
||||
*/
|
||||
getStats() {
|
||||
const stats = {
|
||||
@@ -65,6 +66,23 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
};
|
||||
// No system in range is guidance, not an empty feed: the camera is too
|
||||
// high, or no catalogued system is near the view. A city that failed
|
||||
// leaves range empty too, and reports its failure below instead.
|
||||
if (
|
||||
layerState._enabled &&
|
||||
!layerState._loading &&
|
||||
layerState._activeCityIds.size === 0 &&
|
||||
layerState._failedCityIds.size === 0
|
||||
) {
|
||||
// English data (the analyst's empty note to the model, and the Layers
|
||||
// row's prompt); both show it through translateStatusMessage
|
||||
// (ui/statusText.js).
|
||||
const note = layerState._altitudeGateEnabled
|
||||
? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`;
|
||||
return { ...stats, status: 'zoom-in', statusMessage: note };
|
||||
}
|
||||
if (layerState._loading) {
|
||||
stats.loadingLabel =
|
||||
layerState._activeCityIds.size > 0
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// BIKESHARE COVERAGE STATUS — a view with no system says so.
|
||||
//
|
||||
// Over a city with no catalogued system (London, Tokyo) the layer drew nothing
|
||||
// and reported nothing, so its row read "ON · — · GBFS · never": the same as a
|
||||
// feed that had never answered. These cases pin the rule that replaced it:
|
||||
// with no system in range the layer reports a guidance status that says why,
|
||||
// and a view it left keeps no date for data it no longer draws.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
|
||||
const LONDON = { lat: 51.5072, lon: -0.1276 };
|
||||
|
||||
function makeLayer() {
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries() {},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const parts = {
|
||||
queries: { collectDetectableStations: () => [] },
|
||||
model: createModel({ state, services, parts: {}, source: null }),
|
||||
ingestion: { abortInFlight() {} },
|
||||
rendering: { removeCityPoints() {} },
|
||||
};
|
||||
parts.viewport = createViewport({ state, services, parts, source: null });
|
||||
const { methods } = createControls({ state, services, parts, source: {} });
|
||||
state._enabled = true;
|
||||
return { state, methods, viewport: parts.viewport };
|
||||
}
|
||||
|
||||
/** A viewer looking straight down at `at` from `height` metres. */
|
||||
function viewerAt(at, height) {
|
||||
const rad = Math.PI / 180;
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
height,
|
||||
latitude: at.lat * rad,
|
||||
longitude: at.lon * rad,
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
},
|
||||
scene: { globe: {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('above the altitude gate the row says how low to fly', async () => {
|
||||
const { state, methods, viewport } = makeLayer();
|
||||
state._viewer = viewerAt(LONDON, 2_000_000);
|
||||
await viewport.runProximityCheck();
|
||||
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.status, 'zoom-in');
|
||||
assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city');
|
||||
assert.equal(stats.error, undefined);
|
||||
});
|
||||
|
||||
test('a city with no system says so and counts the ones there are', async () => {
|
||||
const { state, methods, viewport } = makeLayer();
|
||||
state._viewer = viewerAt(LONDON, 8_000);
|
||||
await viewport.runProximityCheck();
|
||||
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.status, 'zoom-in');
|
||||
assert.equal(
|
||||
stats.statusMessage,
|
||||
`No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`,
|
||||
);
|
||||
assert.equal(stats.count, 0);
|
||||
});
|
||||
|
||||
test('a city with a system in range is not given the prompt', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
state._altitudeGateEnabled = true;
|
||||
state._activeCityIds.add('boston-bluebikes');
|
||||
|
||||
assert.equal(methods.getStats().status, undefined);
|
||||
});
|
||||
|
||||
test('a loading or failed city reports itself, not the prompt', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
state._altitudeGateEnabled = true;
|
||||
state._loading = true;
|
||||
assert.equal(methods.getStats().status, undefined);
|
||||
|
||||
// A city that failed is dropped from range, and still owns the row.
|
||||
state._loading = false;
|
||||
state._failedCityIds.add('boston-bluebikes');
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.status, undefined);
|
||||
assert.equal(stats.error, 'GBFS fetch error');
|
||||
});
|
||||
|
||||
test('a disabled layer reports no prompt', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
state._enabled = false;
|
||||
assert.equal(methods.getStats().status, undefined);
|
||||
});
|
||||
|
||||
test('leaving every system behind drops the date of the data it drew', async () => {
|
||||
for (const height of [8_000, 2_000_000]) {
|
||||
const { state, methods, viewport } = makeLayer();
|
||||
state._lastUpdate = Date.now();
|
||||
// Boston's stations were drawn and dated; the camera moves to London.
|
||||
state._activeCityIds.add('boston-bluebikes');
|
||||
state._cityRuntime.set('boston-bluebikes', {});
|
||||
state._viewer = viewerAt(LONDON, height);
|
||||
await viewport.runProximityCheck();
|
||||
|
||||
assert.equal(state._activeCityIds.size, 0);
|
||||
assert.equal(methods.getStats().lastUpdate, null, `at ${height} m`);
|
||||
}
|
||||
});
|
||||
@@ -125,6 +125,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
// A failure belongs to a city in range; none are once everything is off.
|
||||
layerState._failedCityIds.clear();
|
||||
layerState._count = 0;
|
||||
// Nothing is left on the map to date.
|
||||
layerState._lastUpdate = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,6 +229,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
layerState._activeCityIds = nextActive;
|
||||
if (layerState._activeCityIds.size === 0) {
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3982,3 +3982,42 @@ test('parked transit reprojects held and reported courses at the bounded camera
|
||||
'selected unknown course also stays screen-up',
|
||||
);
|
||||
});
|
||||
|
||||
test('a view with no feed reads its coverage prompt, not an undated feed', async (t) => {
|
||||
const app = harness(t);
|
||||
app.serve('mbta', () => ({
|
||||
status: 200,
|
||||
body: snapshot(
|
||||
'mbta',
|
||||
'MBTA',
|
||||
[vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)],
|
||||
{ fetchedAt: Date.now() },
|
||||
),
|
||||
}));
|
||||
app.layer.enable(app.viewer);
|
||||
await app.layer.update();
|
||||
assert.ok(app.layer.getStats().lastUpdate, 'Boston has a dated snapshot');
|
||||
|
||||
// London has no registered feed: the row prompts instead of dating Boston's
|
||||
// last snapshot over an empty map.
|
||||
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
|
||||
app.advance(1_000);
|
||||
const away = app.layer.getStats();
|
||||
assert.equal(away.status, 'zoom-in');
|
||||
assert.equal(away.count, 0);
|
||||
assert.equal(away.lastUpdate, null);
|
||||
assert.match(
|
||||
away.statusMessage,
|
||||
/^No feed here yet · \d+ regions available$/,
|
||||
);
|
||||
assert.equal(away.coverage, away.statusMessage);
|
||||
|
||||
// Above the altitude gate the prompt says how low to fly.
|
||||
app.viewer.camera.positionCartographic.height = 10_000_000;
|
||||
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
|
||||
app.advance(1_000);
|
||||
assert.equal(
|
||||
app.layer.getStats().statusMessage,
|
||||
'Fly below 3,000 km to a covered region',
|
||||
);
|
||||
});
|
||||
@@ -240,17 +240,20 @@ export function createQueries({ state, parts }) {
|
||||
const health = aggregateTransitFeedHealth(statuses, now);
|
||||
const count = state._vehicles.size;
|
||||
if (state._enabled && active.length === 0) {
|
||||
// English data (the analyst's empty note to the model, and the Layers
|
||||
// row's prompt); both show it through translateStatusMessage
|
||||
// (ui/statusText.js).
|
||||
const note = state._altitudeGateOpen
|
||||
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`;
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate: state._lastUpdate,
|
||||
error: null,
|
||||
source: 'GTFS-RT',
|
||||
status: 'zoom-in',
|
||||
// English data (the analyst's empty note to the model); the voice
|
||||
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
|
||||
coverage: state._altitudeGateOpen
|
||||
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
|
||||
statusMessage: note,
|
||||
coverage: note,
|
||||
};
|
||||
}
|
||||
// Age is shown by the same rule the row state uses, so the text and the
|
||||
|
||||
@@ -108,6 +108,9 @@ export function createViewport({ state, services, parts }) {
|
||||
parts.ingestion.removeFeedVehicles(feedId);
|
||||
}
|
||||
}
|
||||
// Leaving coverage leaves nothing on the map to date: a row that read
|
||||
// "26s ago" over an empty view would claim a feed that is no longer here.
|
||||
if (desired.size === 0) state._lastUpdate = null;
|
||||
for (const [feedId, feed] of desired) {
|
||||
if (!state._activeFeeds.has(feedId)) {
|
||||
state._activeFeeds.set(feedId, feed);
|
||||
|
||||
@@ -11,14 +11,17 @@ import {
|
||||
} from './layerNames.js';
|
||||
export { localizeLayerName } from './layerNames.js';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { translateFeedError, translateStatusLine } from './statusText.js';
|
||||
import {
|
||||
translateFeedError,
|
||||
translateStatusLine,
|
||||
translateStatusMessage,
|
||||
} from './statusText.js';
|
||||
import {
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
onLocaleChange,
|
||||
t,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
|
||||
// Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the
|
||||
@@ -691,7 +694,7 @@ export class LayerPanel {
|
||||
typeof stats.statusMessage === 'string' &&
|
||||
stats.statusMessage.trim()
|
||||
) {
|
||||
return `${source} · ${translateKnown(stats.statusMessage.trim())}`;
|
||||
return `${source} · ${translateStatusMessage(stats.statusMessage.trim())}`;
|
||||
}
|
||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
|
||||
if (stats.loading) {
|
||||
|
||||
@@ -431,3 +431,57 @@ test('without the Utilities list, Directions stays in Data Layers under Utilitie
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer with no feed in view says why instead of "never"', async () => {
|
||||
const { LayerPanel, presentLayerCount, presentLayerToggle } =
|
||||
await import('./layerPanel.js');
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
const panel = LayerPanel.prototype;
|
||||
const rows = [
|
||||
{
|
||||
id: 'transit',
|
||||
name: 'Transit',
|
||||
source: 'GTFS-RT',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
source: 'GTFS-RT',
|
||||
status: 'zoom-in',
|
||||
statusMessage: 'No feed here yet · 16 regions available',
|
||||
},
|
||||
meta: 'GTFS-RT · No feed here yet · 16 regions available',
|
||||
},
|
||||
{
|
||||
id: 'bikeshare',
|
||||
name: 'Bikeshare',
|
||||
source: 'GBFS',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
loading: false,
|
||||
status: 'zoom-in',
|
||||
statusMessage: 'Fly below 48 km to a covered city',
|
||||
},
|
||||
meta: 'GBFS · Fly below 48 km to a covered city',
|
||||
},
|
||||
];
|
||||
for (const layer of rows) {
|
||||
// Guidance is normal operation: the layer is on and nothing has failed.
|
||||
assert.equal(presentLayerToggle(layer).text, 'ON');
|
||||
assert.equal(presentLayerCount(layer.stats).empty, true);
|
||||
assert.equal(panel._buildMetaText(layer), layer.meta);
|
||||
}
|
||||
|
||||
setLocale('ru');
|
||||
try {
|
||||
for (const layer of rows) {
|
||||
const meta = panel._buildMetaText(layer);
|
||||
assert.doesNotMatch(meta, /never|Fly below|No feed|covered/, meta);
|
||||
assert.match(meta, /^(GTFS-RT|GBFS) · /, meta);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -265,3 +265,66 @@ export function translateStatusLine(text) {
|
||||
.map(translateStatusPiece)
|
||||
.join(STATUS_SEPARATOR);
|
||||
}
|
||||
|
||||
/** Thousands separators out of an English number ("3,000" → 3000). */
|
||||
const englishNumber = (text) => Number(text.replace(/,/g, ''));
|
||||
|
||||
// Coverage notes of a layer whose view has no feed, whole lines with a number
|
||||
// in them: transit (transit/queries.js) and bikeshare (bikeshare/controls.js).
|
||||
// Their " · " is part of one sentence, so they are matched before the line is
|
||||
// split into pieces. Keep these in step with the producers.
|
||||
const COVERAGE_NOTE_PATTERNS = [
|
||||
[
|
||||
/^Fly below ([\d,]+) km to a covered region$/,
|
||||
([, km]) =>
|
||||
t('Fly below {km} km to a covered region', {
|
||||
km: formatNumber(englishNumber(km)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^No feed here yet · ([\d,]+) regions available$/,
|
||||
([, regions]) => {
|
||||
const count = englishNumber(regions);
|
||||
return tn(
|
||||
count,
|
||||
'No feed here yet · {n} region available',
|
||||
'No feed here yet · {n} regions available',
|
||||
{ n: formatNumber(count) },
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
/^Fly below ([\d,]+) km to a covered city$/,
|
||||
([, km]) =>
|
||||
t('Fly below {km} km to a covered city', {
|
||||
km: formatNumber(englishNumber(km)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^No bike share here yet · ([\d,]+) systems available$/,
|
||||
([, systems]) => {
|
||||
const count = englishNumber(systems);
|
||||
return tn(
|
||||
count,
|
||||
'No bike share here yet · {n} system available',
|
||||
'No bike share here yet · {n} systems available',
|
||||
{ n: formatNumber(count) },
|
||||
);
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* A layer's status message in the UI language: its catalog entry, a coverage
|
||||
* note by its shape, or a status line translated piece by piece.
|
||||
*/
|
||||
export function translateStatusMessage(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of COVERAGE_NOTE_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return translateStatusLine(text);
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
import { translateFeedError, translateStatusLine } from './statusText.js';
|
||||
import {
|
||||
translateFeedError,
|
||||
translateStatusLine,
|
||||
translateStatusMessage,
|
||||
} from './statusText.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
@@ -75,3 +79,55 @@ for (const locale of ['es', 'ru']) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A layer's coverage note when no feed covers the view: one sentence with a
|
||||
// " · " in it (transit/queries.js, bikeshare/controls.js).
|
||||
const COVERAGE_NOTES = [
|
||||
'Fly below 3,000 km to a covered region',
|
||||
'No feed here yet · 16 regions available',
|
||||
'Fly below 48 km to a covered city',
|
||||
'No bike share here yet · 61 systems available',
|
||||
];
|
||||
|
||||
test('en: coverage notes show as they came', () => {
|
||||
setLocale('en');
|
||||
for (const text of COVERAGE_NOTES)
|
||||
assert.equal(translateStatusMessage(text), text);
|
||||
assert.equal(
|
||||
translateStatusMessage('No bike share here yet · 1 systems available'),
|
||||
'No bike share here yet · 1 system available',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru', 'ja']) {
|
||||
test(`${locale}: a coverage note translates whole, numbers kept`, () => {
|
||||
inLocale(locale, () => {
|
||||
for (const text of COVERAGE_NOTES) {
|
||||
const shown = translateStatusMessage(text);
|
||||
assert.doesNotMatch(
|
||||
shown,
|
||||
/\b(Fly|below|covered|feed|bike share|available)\b/,
|
||||
shown,
|
||||
);
|
||||
const number = /[\d,]+(?= km| regions| systems)/.exec(text)[0];
|
||||
assert.ok(
|
||||
shown.replace(/[^\d]/g, '').includes(number.replace(/,/g, '')),
|
||||
shown,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: a status message without a coverage note keeps its other forms`, () => {
|
||||
inLocale(locale, () => {
|
||||
assert.equal(
|
||||
translateStatusMessage('Upstream response too large'),
|
||||
t('Upstream response too large'),
|
||||
);
|
||||
assert.equal(
|
||||
translateStatusMessage('2 feeds live · 14 heard'),
|
||||
translateStatusLine('2 feeds live · 14 heard'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) {
|
||||
msg('Reset to globe view'),
|
||||
msg('Show earthquakes'),
|
||||
].map((text) => t(text));
|
||||
// Arabic short vowels and shadda are optional in writing: typing "تتبع"
|
||||
// still finds "تتبّع".
|
||||
const fold = (text) =>
|
||||
text.toLowerCase().replace(/[\u064B-\u065F\u0670]/g, '');
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(key));
|
||||
if (typeof stored?.quick === 'boolean') quick = stored.quick;
|
||||
@@ -31,18 +35,14 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) {
|
||||
function render() {
|
||||
const draft = getDraft();
|
||||
if (historyIndex >= 0 && draft !== recent[historyIndex]) resetHistory();
|
||||
const query = draft.trim().toLowerCase();
|
||||
const query = fold(draft.trim());
|
||||
const groups = [];
|
||||
if (query) {
|
||||
const candidates = [...recent, ...(quick ? quickCommands() : [])];
|
||||
groups.push({
|
||||
label: t('Suggestions'),
|
||||
items: [...new Set(candidates)]
|
||||
.filter(
|
||||
(text) =>
|
||||
text.toLowerCase().includes(query) &&
|
||||
text.toLowerCase() !== query,
|
||||
)
|
||||
.filter((text) => fold(text).includes(query) && fold(text) !== query)
|
||||
.slice(0, 4),
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { bindChatPreferences } from './chatPreferences.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
@@ -99,3 +100,21 @@ test('editing a recalled message ends recall, and sending resets it', (t) => {
|
||||
press('ArrowUp');
|
||||
assert.equal(field.value, 'Show live flights now');
|
||||
});
|
||||
|
||||
test('autocomplete ignores Arabic short vowels and shadda', (t) => {
|
||||
setLocale('ar');
|
||||
t.after(() => setLocale('en'));
|
||||
const ui = createTestVoiceUi();
|
||||
let draft = '';
|
||||
const prefs = bindChatPreferences({ ui, getDraft: () => draft });
|
||||
t.after(() => prefs.destroy());
|
||||
const suggested = (text) => {
|
||||
draft = text;
|
||||
prefs.render();
|
||||
return ui.state.chat.suggestions.flatMap((group) => group.items);
|
||||
};
|
||||
// The quick command is written "تتبّع", with a shadda.
|
||||
assert.deepEqual(suggested('تتبع'), ['اعرض تتبّع الرحلات المباشر']);
|
||||
prefs.remember('انتقل إلى طوكيو');
|
||||
assert.deepEqual(suggested('طُوكيو'), ['انتقل إلى طوكيو']);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '../i18n/index.js';
|
||||
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
|
||||
import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { translateStatusLine } from '../ui/statusText.js';
|
||||
import { translateStatusMessage } from '../ui/statusText.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.js';
|
||||
|
||||
/** Split "a; b" caveat text into separate notes. */
|
||||
@@ -397,27 +397,6 @@ const NOTE_PATTERNS = [
|
||||
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
|
||||
)) + period,
|
||||
],
|
||||
// Transit's empty note when no feed covers the view (transit/queries.js);
|
||||
// the model gets the English.
|
||||
[
|
||||
/^Fly below ([\d,]+) km to a covered region$/,
|
||||
([, km]) =>
|
||||
t('Fly below {km} km to a covered region', {
|
||||
km: formatNumber(Number(km.replace(/,/g, ''))),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^No feed here yet · ([\d,]+) regions available$/,
|
||||
([, regions]) => {
|
||||
const count = Number(regions.replace(/,/g, ''));
|
||||
return tn(
|
||||
count,
|
||||
'No feed here yet · {n} region available',
|
||||
'No feed here yet · {n} regions available',
|
||||
{ n: formatNumber(count) },
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
/^([a-z0-9-]+) (off|loading|error)$/,
|
||||
([, key, status]) =>
|
||||
@@ -444,10 +423,10 @@ function shownNote(text) {
|
||||
const shown = match ? show(match) : null;
|
||||
if (shown) return shown;
|
||||
}
|
||||
// A layer's empty note can be its status line, English pieces joined by
|
||||
// " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
|
||||
const known = translateKnown(text);
|
||||
return known !== text ? known : translateStatusLine(text);
|
||||
// A layer's empty note can be its coverage note ("Fly below 3,000 km to a
|
||||
// covered region") or its status line, English pieces joined by " · "
|
||||
// ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
|
||||
return translateStatusMessage(text);
|
||||
}
|
||||
|
||||
/** Feed states as the card's chips show them; the values stay English. */
|
||||
|
||||
Reference in new issue
Block a user