Compare commits

...
Author SHA1 Message Date
German Khokhlov 25c33893f6 Add the changelog entry for #1576 2026-11-05 12:53:09 +04:00
German Khokhlov b6ad32009f Say why Transit and Bike Share rows are empty outside coverage
Over a city with no feed or system (London, Tokyo) both rows read
"ON · — · never", the same as a feed that never answered. Transit already
computed a coverage note but put it only in `coverage`, which the row does
not show; Bike Share reported nothing at all.

Both layers now report a `zoom-in` guidance status with a `statusMessage`:
"Fly below 3,000 km to a covered region" / "No feed here yet · N regions
available" for Transit, and "Fly below 48 km to a covered city" / "No bike
share here yet · N systems available" for Bike Share. A loading or failed
Bike Share city keeps its own text. Leaving coverage clears the layer's
last-update time, so the row no longer dates data it no longer draws.

The coverage-note translations move from the voice card into
`translateStatusMessage` (ui/statusText.js), which the Layers row and the
voice card now share.
2026-11-05 12:52:28 +04:00
German Khokhlov 7d8b80db0a Merge pull request #1561 from bilawalsidhu/german/chat-autocomplete-arabic-marks
Ignore Arabic short vowels and shadda in chat autocomplete
2026-11-04 23:58:19 +04:00
German Khokhlov e84cae6baa Add the changelog entry for #1561 2026-11-04 23:35:46 +04:00
German Khokhlov 06c84f92d3 Ignore Arabic short vowels and shadda in chat autocomplete
Arabic catalog strings carry optional marks ("تتبّع"), but people type
without them, so "تتبع" found no suggestion. Fold the marks out of both
the draft and the candidates before comparing.
2026-11-04 23:35:25 +04:00
27 changed files with 493 additions and 43 deletions

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).
+13
View File
@@ -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,
+9
View File
@@ -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": "جزئي",
+5
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+4
View File
@@ -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": "部分的",
+6
View File
@@ -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",
+7
View File
@@ -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 + потоки декодера",
+7
View File
@@ -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": "частково",
+4
View File
@@ -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": "部分可用",
+4
View File
@@ -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": "部分可用",
+20 -2
View File
@@ -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`);
}
});
+3
View File
@@ -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;
}
+39
View File
@@ -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',
);
});
+8 -5
View File
@@ -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
+3
View File
@@ -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);
+6 -3
View File
@@ -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) {
+54
View File
@@ -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');
}
});
+63
View File
@@ -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);
}
+57 -1
View File
@@ -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'),
);
});
});
}
+6 -6
View File
@@ -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 {
+19
View File
@@ -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('طُوكيو'), ['انتقل إلى طوكيو']);
});
+5 -26
View File
@@ -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. */