Compare commits

..
Author SHA1 Message Date
German Khokhlov 3822ee9f00 i18n: keep 並 before 道路, 道歉 and 道理 in zh-Hant
The 並道 → 併道 correction replaced the substring anywhere, so a zh-CN
string where 并 means "and" and 道 starts the next word would turn into
併道路 or 併道歉. It now skips 道路, 道歉 and 道理. The three current
entries (lane merging) convert as before; zh-Hant.json does not change.
2026-11-13 20:07:44 +04:00
German Khokhlov 4c5fbd3223 Changelog fragment for #1699 2026-11-13 18:47:10 +04:00
German Khokhlov 5d28d614e0 i18n: generate zh-Hant from zh-CN
zh-Hant is the zh-CN text in Traditional characters, but contributors
still translated it by hand from `--missing zh-Hant`, and a model asked
for Traditional Chinese writes Taiwan vocabulary (影片, 伺服器, 載入).

`npm run i18n -- --zh-hant` now rewrites zh-Hant.json from zh-CN.json
with OpenCC s2tw (opencc-js 1.4.2, cn -> tw, characters only), then the
fixed list of characters it picks wrongly. The check that `npm test`
runs fails when zh-Hant differs from that conversion, naming the key,
and `--missing zh-Hant` points to the command instead of printing a
template.

The conversion reproduces every one of the 3346 entries on main. 25
keys were out of zh-CN order and move; no value changes.

The converter loads on first use, so a copy of the script without
node_modules (stale-index.test.mjs) still extracts messages.
2026-11-13 18:46:27 +04:00
13 changed files with 247 additions and 80 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- Generate the Traditional Chinese catalog instead of translating it:
`npm run i18n -- --zh-hant` writes `zh-Hant` from `zh-CN` with OpenCC `s2tw`
and its character corrections, and `npm test` fails, naming the key, when
`zh-Hant` is edited by hand (German, @gekh, #1699).
@@ -1,4 +0,0 @@
- Recent Imagery: when the automatic day probes empty, the next ranked day now
drapes once its probe says present. Focus stayed on the empty day, or moved
to the nearest card rather than the next ranked day, so the successor never
reached the map (gekh, #1701).
+4 -2
View File
@@ -5096,7 +5096,9 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, *
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, empty, unused,
placeholder-mismatched, still-English (plural forms included) or
wrongly-shaped plural entries.
wrongly-shaped plural entries. `zh-Hant` is generated from `zh-CN`
(`npm run i18n -- --zh-hant`: OpenCC `s2tw` through `opencc-js`, then a
fixed list of character corrections), and the gate fails when it differs.
- **Not translated by design:** layer ids, share-link and hash parameters, API
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
station names) and attribution.
@@ -7274,7 +7276,7 @@ pins and share links already supply complete boxes.
- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room.
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. When that day probes empty the next ranked day takes over, and focus still on the empty day moves with it, so the successor drapes once its probe says present. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
+2
View File
@@ -104,6 +104,8 @@ npm run i18n -- --prune # drop entries whose English no longer exists
Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/<code>.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete.
Do not translate `zh-Hant`: it is generated. After `zh-CN`, run `npm run i18n -- --zh-hant` (see [zh-Hant.md](./translations/zh-Hant.md)).
To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner).
An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`.
+2
View File
@@ -36,6 +36,8 @@ npm run i18n -- --prune # drop entries the code no longer uses
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
Do not translate `zh-Hant`: it is generated. Translate `zh-CN`, then run `npm run i18n -- --zh-hant` (see [zh-Hant.md](./translations/zh-Hant.md)).
## Fixing a translation
Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`.
+12 -4
View File
@@ -2,16 +2,24 @@
Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs.
**Do not translate or edit this catalog: it is generated.** `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters. Wording and terminology are the same as in `zh-CN`; only the script differs. To add or change an entry, edit [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog, then run:
After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布, 控制檯 → 控制台, and 周 → 週 where it means a week (1 週/秒).
```sh
npm run i18n -- --zh-hant # rewrite zh-Hant.json from zh-CN.json
```
`npm test` fails when `zh-Hant.json` differs from the conversion of `zh-CN.json`, naming the entry, and `npm run i18n -- --missing zh-Hant` points to this command instead of printing a template.
The command converts with OpenCC `s2tw` ([opencc-js](https://github.com/nk2028/opencc-js), `cn` → `tw`), which changes characters only. Never `s2twp`: it also swaps in Taiwan vocabulary (影片, 伺服器, 載入, 圖示), which this catalog does not use. After the conversion it fixes the characters OpenCC picks wrongly here: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道 (not in 並道路, 並道歉, 並道理), 發佈 → 發布, 公佈 → 公布, 控制檯 → 控制台, and 周 → 週 where it means a week (1 週/秒). The list is `ZH_HANT_CORRECTIONS` in `scripts/i18n.mjs`; when the conversion picks another wrong character, add the pair there and run the command.
An entry `zh-CN` keeps in English on purpose needs the same line under `zh-Hant` in `src/i18n/same-as-english.json`.
## Style
- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters.
- Wording, punctuation, spacing, units and the glossary are those of [zh-CN.md](./zh-CN.md), written in Traditional characters.
- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前.
- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese.
## Plurals
`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」.
`Intl.PluralRules('zh-Hant')` uses only `other`. The measure words come from the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」.
+8
View File
@@ -32,6 +32,7 @@
"@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0",
"happy-dom": "^20.14.6",
"opencc-js": "1.4.2",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
@@ -2976,6 +2977,13 @@
"node": ">= 0.8"
}
},
"node_modules/opencc-js": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
"dev": true,
"license": "MIT AND Apache-2.0"
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+1
View File
@@ -85,6 +85,7 @@
"@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0",
"happy-dom": "^20.14.6",
"opencc-js": "1.4.2",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
+122 -8
View File
@@ -3,17 +3,21 @@
// npm run i18n check every catalog and find unmarked UI text
// npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
// npm run i18n -- --zh-hant rewrite zh-Hant from zh-CN (see toZhHant)
//
// A translation left as the English fails unless src/i18n/same-as-english.json
// lists it.
// lists it. zh-Hant is not translated: it is generated from zh-CN, and the
// check fails when it differs.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in index.html and the Vue views.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
@@ -618,16 +622,124 @@ export function missingTemplate(locale, catalog, messages) {
return out;
}
// zh-Hant is zh-CN in Traditional characters (docs/translations/zh-Hant.md):
// the same wording, only the script differs. OpenCC s2tw (opencc-js, cn → tw)
// converts characters only; s2twp would also swap in Taiwan vocabulary
// (影片, 伺服器, 載入), which the catalog does not use.
const ZH_HANT = 'zh-Hant';
const ZH_HANT_SOURCE = 'zh-CN';
const ZH_HANT_COMMAND = 'npm run i18n -- --zh-hant';
// Characters s2tw picks wrongly in this text, replaced after the conversion.
const ZH_HANT_CORRECTIONS = [
['年曆史', '年歷史'],
['下遊', '下游'],
// 併道 is merging lanes; 並 stays when 道 starts 道路, 道歉 or 道理.
[/並道(?![路歉理])/g, '併道'],
['發佈', '發布'],
['公佈', '公布'],
['控制檯', '控制台'],
// 周 as a week: the time-lapse speed in weeks per second.
[' 周/秒', ' 週/秒'],
];
let s2tw;
/** zh-CN text in Traditional characters, as zh-Hant shows it. */
export function toZhHant(text) {
// Loaded on first use: extracting messages needs no converter, and a copy
// of this script without node_modules (stale-index.test.mjs) still runs.
s2tw ??= createRequire(import.meta.url)('opencc-js/cn2t').Converter({
from: 'cn',
to: 'tw',
});
return ZH_HANT_CORRECTIONS.reduce(
(out, [wrong, right]) => out.replaceAll(wrong, right),
s2tw(text),
);
}
/** The zh-Hant catalog for a zh-CN catalog: the same keys in the same order. */
export function zhHantCatalog(zhCN) {
const convert = (entry) => {
if (typeof entry === 'string') return toZhHant(entry);
if (entry && typeof entry === 'object')
return Object.fromEntries(
Object.entries(entry).map(([category, form]) => [
category,
convert(form),
]),
);
return entry;
};
return Object.fromEntries(
Object.entries(zhCN).map(([text, entry]) => [text, convert(entry)]),
);
}
/**
* Where a zh-Hant catalog is not the conversion of the zh-CN one.
* @returns {string[]} One error per entry, naming its key.
*/
export function checkZhHant(zhCN, zhHant) {
const expected = zhHantCatalog(zhCN);
const fix = `zh-Hant is generated: edit ${ZH_HANT_SOURCE}, then run ${ZH_HANT_COMMAND}`;
const errors = [];
for (const [text, entry] of Object.entries(expected)) {
if (!Object.hasOwn(zhHant, text))
errors.push(`"${text}" is in ${ZH_HANT_SOURCE} but not here. ${fix}`);
else if (!isDeepStrictEqual(zhHant[text], entry))
errors.push(
`"${text}" is ${JSON.stringify(zhHant[text])}, not ${ZH_HANT_SOURCE} converted (${JSON.stringify(entry)}). ${fix}`,
);
}
for (const text of Object.keys(zhHant)) {
if (!Object.hasOwn(expected, text))
errors.push(`"${text}" is not in ${ZH_HANT_SOURCE}. ${fix}`);
}
if (!errors.length) {
const order = Object.keys(expected);
const keys = Object.keys(zhHant);
const at = order.findIndex((text, i) => keys[i] !== text);
if (at >= 0)
errors.push(
`entries leave the ${ZH_HANT_SOURCE} order at "${order[at]}": run ${ZH_HANT_COMMAND}`,
);
}
return errors;
}
const serialize = (catalog) => `${JSON.stringify(catalog, null, 2)}\n`;
/** Rewrite zh-Hant.json as the conversion of zh-CN.json. */
export function writeZhHant() {
const catalog = zhHantCatalog(readCatalog(ZH_HANT_SOURCE));
writeFileSync(catalogPath(ZH_HANT), serialize(catalog));
return Object.keys(catalog).length;
}
function main(args) {
const messages = extractMessages();
const missingFor = args.includes('--missing')
? args[args.indexOf('--missing') + 1]
: null;
if (missingFor?.toLowerCase() === ZH_HANT.toLowerCase()) {
console.error(
`${ZH_HANT} is generated, not translated: add the entries to ${ZH_HANT_SOURCE} (npm run i18n -- --missing ${ZH_HANT_SOURCE}), then run ${ZH_HANT_COMMAND}`,
);
return 1;
}
if (missingFor) {
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
console.log(JSON.stringify(out, null, 2));
return 0;
}
if (args.includes('--zh-hant')) {
const count = writeZhHant();
console.log(
`${ZH_HANT}: wrote ${count} entries converted from ${ZH_HANT_SOURCE}`,
);
}
let failed = false;
for (const locale of shippedLocales()) {
let catalog = readCatalog(locale);
@@ -636,10 +748,7 @@ function main(args) {
catalog = Object.fromEntries(
Object.entries(catalog).filter(([text]) => messages.has(text)),
);
writeFileSync(
catalogPath(locale),
`${JSON.stringify(catalog, null, 2)}\n`,
);
writeFileSync(catalogPath(locale), serialize(catalog));
console.log(`${locale}: pruned ${stale.length} unused entries`);
} else if (stale.length) {
failed = true;
@@ -648,11 +757,16 @@ function main(args) {
);
for (const text of stale) console.log(` ${JSON.stringify(text)}`);
}
// shippedLocales() is sorted, so --prune has already pruned zh-CN.
if (locale === ZH_HANT)
errors.push(...checkZhHant(readCatalog(ZH_HANT_SOURCE), catalog));
if (missing.length) {
failed = true;
console.log(
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`,
);
const fill =
locale === ZH_HANT
? `add them to ${ZH_HANT_SOURCE}, then ${ZH_HANT_COMMAND}`
: `npm run i18n -- --missing ${locale}`;
console.log(`${locale}: ${missing.length} missing (${fill}):`);
for (const text of missing)
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
}
+62
View File
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict';
import {
checkCatalog,
checkZhHant,
extractMessages,
findUnmarked,
markedTemplate,
readCatalog,
shippedLocales,
toZhHant,
unmarkedMarkup,
unmarkedScript,
zhHantCatalog,
} from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js';
@@ -36,6 +39,65 @@ for (const locale of shippedLocales()) {
});
}
test('zh-Hant is zh-CN converted to Traditional characters (npm run i18n -- --zh-hant)', () => {
assert.deepEqual(
checkZhHant(readCatalog('zh-CN'), readCatalog('zh-Hant')),
[],
);
});
test('toZhHant converts characters, not vocabulary, and fixes what s2tw picks wrongly', () => {
// s2twp would write 影片, 伺服器 and 載入; zh-Hant keeps the zh-CN words.
assert.equal(toZhHant('视频'), '視頻');
assert.equal(toZhHant('服务器'), '服務器');
assert.equal(toZhHant('加载'), '加載');
assert.equal(toZhHant('50 年历史'), '50 年歷史');
assert.equal(toZhHant('发布于 {date}'), '發布於 {date}');
assert.equal(toZhHant('1 周/秒'), '1 週/秒');
assert.equal(
toZhHant('{direction}并道,进入 {road}'),
'{direction}併道,進入 {road}',
);
assert.equal(toZhHant('事故并道路封闭'), '事故並道路封閉');
assert.equal(toZhHant('已关闭并道歉'), '已關閉並道歉');
});
test('checkZhHant names each entry that is not the conversion of zh-CN', () => {
const zhCN = {
Video: '视频',
Server: '服务器',
'{count} ships': { other: '{count} 艘船' },
};
const generated = zhHantCatalog(zhCN);
assert.deepEqual(generated, {
Video: '視頻',
Server: '服務器',
'{count} ships': { other: '{count} 艘船' },
});
assert.deepEqual(checkZhHant(zhCN, generated), []);
const fix =
'zh-Hant is generated: edit zh-CN, then run npm run i18n -- --zh-hant';
const { Server, ...withoutServer } = generated;
assert.deepEqual(
checkZhHant(zhCN, {
...withoutServer,
Video: '影片',
'{count} ships': { other: '{count} 艘船隻' },
Extra: '多餘',
}),
[
`"Video" is "影片", not zh-CN converted ("視頻"). ${fix}`,
`"Server" is in zh-CN but not here. ${fix}`,
`"{count} ships" is {"other":"{count} 艘船隻"}, not zh-CN converted ({"other":"{count} 艘船"}). ${fix}`,
`"Extra" is not in zh-CN. ${fix}`,
],
);
assert.deepEqual(checkZhHant(zhCN, { Server, ...withoutServer }), [
'entries leave the zh-CN order at "Video": run npm run i18n -- --zh-hant',
]);
});
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
assert.deepEqual(
findUnmarked().map(({ where, text }) => `${where} ${text}`),
+25 -25
View File
@@ -612,6 +612,7 @@
"Cyclone advisories unavailable": "氣旋公告不可用",
"Cyclone data unavailable": "氣旋數據不可用",
"Potential tropical cyclone": "潛在熱帶氣旋",
"Post-tropical cyclone": "後熱帶氣旋",
"Hurricane": "颶風",
"Tropical storm": "熱帶風暴",
"Tropical depression": "熱帶低壓",
@@ -707,11 +708,15 @@
"Merge onto {road}": "併入 {road}",
"maneuver|Merge": "併道",
"Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp on the {direction}": "{direction}駛入匝道",
"Take the ramp {direction}": "{direction}駛入匝道",
"Take the ramp onto {road}": "駛入匝道,進入 {road}",
"Take the ramp": "駛入匝道",
"Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit on the {direction}": "從出口{direction}駛出",
"Take the exit {direction}": "從出口{direction}駛出",
"Take the exit onto {road}": "從出口駛出,進入 {road}",
"Take the exit": "從出口駛出",
"At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}",
@@ -1599,6 +1604,9 @@
"LIVE SIGNALS": "實時信號",
"CCTV layer unavailable": "CCTV 圖層不可用",
"Link copied!": "鏈接已複製!",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"Shared vessel is unavailable": "分享的船舶不可用",
"Copy failed": "複製失敗",
"Return map to straight-down view": "將地圖恢復為垂直俯視",
"Tilt map to oblique view": "將地圖傾斜至斜視角",
@@ -1952,6 +1960,8 @@
"ORBIT": "環繞",
"Location not found": "未找到位置",
"Search failed": "搜索失敗",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
"Detailed outline unavailable": "無法獲取詳細輪廓",
"Fly to a POI first": "請先飛往一個 POI",
"Reset to full globe view": "重置為完整地球視圖",
@@ -2977,16 +2987,6 @@
"Search": "搜索",
"More": "更多",
"Panels": "面板",
"Post-tropical cyclone": "後熱帶氣旋",
"Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp {direction}": "{direction}駛入匝道",
"Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit {direction}": "從出口{direction}駛出",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"Shared vessel is unavailable": "分享的船舶不可用",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
"Recent speech": "最近語音",
"Voice Settings": "語音設置",
"Preview": "預覽",
@@ -3025,8 +3025,19 @@
"other": "{count} 個地標"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
"Nominal and high confidence detections": "中、高置信度的火點探測",
"High confidence detections only": "僅高置信度的火點探測",
"Fire intensity (FRP)": "火勢強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "探測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}",
"From {lower}": "最低 {lower}",
@@ -3039,6 +3050,8 @@
"Prohibited": "禁區",
"Restricted": "限制區",
"Prohibited & Restricted Airspace": "禁區與限制區空域",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景",
"SCENE": "場景",
@@ -3064,19 +3077,6 @@
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板",
"Right panels": "右側面板",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
"Nominal and high confidence detections": "中、高置信度的火點探測",
"High confidence detections only": "僅高置信度的火點探測",
"Fire intensity (FRP)": "火勢強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "探測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
+5 -10
View File
@@ -285,17 +285,12 @@ export function createRecentImageryLayer({
const rank = (list) =>
rankLatest(list.map(withAvailability), { truncated: _truncated });
/**
* Focus the START HERE day and preview it when the mode's slot is empty.
* @param {string | null} [emptyKey] The empty automatic day handed over;
* focus still on it moves too, since a followed day previews only while
* it is focused.
*/
function autoPreview(emptyKey = null) {
/** Focus the START HERE day and preview it when the mode's slot is empty. */
function autoPreview() {
const ranked = rank(sourcedCandidates());
if (!ranked.candidate) return;
const key = ranked.candidate.key;
if (!_focusKey || _focusKey === emptyKey) _focusKey = key;
if (!_focusKey) _focusKey = key;
if (!previewSlot() || activeSlotOf(key)) return;
_auto = { key, reason: ranked.reason, certain: ranked.certain };
if (drapable(candidateFor(key))) _previewKey = key;
@@ -322,7 +317,7 @@ export function createRecentImageryLayer({
_previewKey = null;
_auto = null;
changed = true;
if (wasAuto) autoPreview(preview.key);
if (wasAuto) autoPreview();
}
// A followed day that turns out empty is dropped; an automatic one hands
// over to the next ranked day.
@@ -332,7 +327,7 @@ export function createRecentImageryLayer({
_followKey = null;
if (wasAuto) {
_auto = null;
autoPreview(followed.key);
autoPreview();
}
changed = true;
}
-27
View File
@@ -1128,33 +1128,6 @@ test('an automatic overview that probes empty hands over to the next ranked day'
assert.deepEqual(f.drapes(), { a: `${V15}|none`, b: null });
});
test('with empty days shown, focus leaves an empty automatic overview with the hand-over and its successor drapes', async () => {
const f = fixture();
f.layer.setSources({ hls: false });
f.layer.setShowUnavailable(true);
await f.ready();
assert.equal(f.diag().following, V21, 'today is followed');
f.thumbnails.probe(V21, 'empty');
assert.equal(f.diag().following, V15, 'yesterday takes over');
assert.equal(f.snap().focus.key, V15, 'focus goes with it');
f.thumbnails.probe(V15, 'present');
assert.equal(f.snap().preview.key, V15);
assert.equal(f.snap().preview.pending, null);
assert.deepEqual(f.drapes(), { a: `${V15}|none`, b: null });
});
test('focus on an empty automatic day moves to the next ranked day, not the nearest card, and it drapes once present', async () => {
const f = fixture();
await f.ready([CANDIDATES[0], CANDIDATES[1], CANDIDATES[3]]);
assert.equal(f.snap().preview.key, S18);
f.thumbnails.probe(S18, 'empty');
assert.equal(f.diag().following, V21, 'the newest overview takes over');
assert.equal(f.snap().focus.key, V21, 'not the older neighbour V15');
f.thumbnails.probe(V21, 'present');
assert.equal(f.snap().preview.key, V21);
assert.deepEqual(f.drapes(), { a: `${V21}|none`, b: null });
});
test('a pinned day still being probed drapes once the probe says present', async () => {
const f = fixture();
f.layer.setParams({ ...LINK_BOX, a: V21, mode: 0 });