Compare commits

..
Author SHA1 Message Date
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
10 changed files with 234 additions and 38 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).
+3 -1
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`) - **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, empty, unused, extracts every message and fails on missing, empty, unused,
placeholder-mismatched, still-English (plural forms included) or 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 - **Not translated by design:** layer ids, share-link and hash parameters, API
fields, voice tool schemas, provider names, feed data (callsigns, vessel and fields, voice tool schemas, provider names, feed data (callsigns, vessel and
station names) and attribution. station names) and attribution.
+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. 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). 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`. 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. 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 ## Fixing a translation
Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`. 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). 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: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布, 控制檯 → 控制台, 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 ## 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 前. - 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. - The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese.
## Plurals ## 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", "@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0", "flag-icons": "7.5.0",
"happy-dom": "^20.14.6", "happy-dom": "^20.14.6",
"opencc-js": "1.4.2",
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.16.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
@@ -2976,6 +2977,13 @@
"node": ">= 0.8" "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": { "node_modules/pako": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+1
View File
@@ -85,6 +85,7 @@
"@vue/test-utils": "^2.5.1", "@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0", "flag-icons": "7.5.0",
"happy-dom": "^20.14.6", "happy-dom": "^20.14.6",
"opencc-js": "1.4.2",
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.16.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
+121 -8
View File
@@ -3,17 +3,21 @@
// npm run i18n check every catalog and find unmarked UI text // 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 -- --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 -- --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 // 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/, // 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. // 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). // It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales'); const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
@@ -618,16 +622,123 @@ export function missingTemplate(locale, catalog, messages) {
return out; 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 = [
['年曆史', '年歷史'],
['下遊', '下游'],
['並道', '併道'],
['發佈', '發布'],
['公佈', '公布'],
['控制檯', '控制台'],
// 周 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) { function main(args) {
const messages = extractMessages(); const messages = extractMessages();
const missingFor = args.includes('--missing') const missingFor = args.includes('--missing')
? args[args.indexOf('--missing') + 1] ? args[args.indexOf('--missing') + 1]
: null; : 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) { if (missingFor) {
const out = missingTemplate(missingFor, readCatalog(missingFor), messages); const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
console.log(JSON.stringify(out, null, 2)); console.log(JSON.stringify(out, null, 2));
return 0; 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; let failed = false;
for (const locale of shippedLocales()) { for (const locale of shippedLocales()) {
let catalog = readCatalog(locale); let catalog = readCatalog(locale);
@@ -636,10 +747,7 @@ function main(args) {
catalog = Object.fromEntries( catalog = Object.fromEntries(
Object.entries(catalog).filter(([text]) => messages.has(text)), Object.entries(catalog).filter(([text]) => messages.has(text)),
); );
writeFileSync( writeFileSync(catalogPath(locale), serialize(catalog));
catalogPath(locale),
`${JSON.stringify(catalog, null, 2)}\n`,
);
console.log(`${locale}: pruned ${stale.length} unused entries`); console.log(`${locale}: pruned ${stale.length} unused entries`);
} else if (stale.length) { } else if (stale.length) {
failed = true; failed = true;
@@ -648,11 +756,16 @@ function main(args) {
); );
for (const text of stale) console.log(` ${JSON.stringify(text)}`); 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) { if (missing.length) {
failed = true; failed = true;
console.log( const fill =
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`, 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) for (const text of missing)
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`); console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
} }
+56
View File
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict';
import { import {
checkCatalog, checkCatalog,
checkZhHant,
extractMessages, extractMessages,
findUnmarked, findUnmarked,
markedTemplate, markedTemplate,
readCatalog, readCatalog,
shippedLocales, shippedLocales,
toZhHant,
unmarkedMarkup, unmarkedMarkup,
unmarkedScript, unmarkedScript,
zhHantCatalog,
} from '../../scripts/i18n.mjs'; } from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js'; import { LOCALES } from './locale.js';
@@ -36,6 +39,59 @@ 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 週/秒');
});
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)', () => { test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
assert.deepEqual( assert.deepEqual(
findUnmarked().map(({ where, text }) => `${where} ${text}`), findUnmarked().map(({ where, text }) => `${where} ${text}`),
+25 -25
View File
@@ -612,6 +612,7 @@
"Cyclone advisories unavailable": "氣旋公告不可用", "Cyclone advisories unavailable": "氣旋公告不可用",
"Cyclone data unavailable": "氣旋數據不可用", "Cyclone data unavailable": "氣旋數據不可用",
"Potential tropical cyclone": "潛在熱帶氣旋", "Potential tropical cyclone": "潛在熱帶氣旋",
"Post-tropical cyclone": "後熱帶氣旋",
"Hurricane": "颶風", "Hurricane": "颶風",
"Tropical storm": "熱帶風暴", "Tropical storm": "熱帶風暴",
"Tropical depression": "熱帶低壓", "Tropical depression": "熱帶低壓",
@@ -707,11 +708,15 @@
"Merge onto {road}": "併入 {road}", "Merge onto {road}": "併入 {road}",
"maneuver|Merge": "併道", "maneuver|Merge": "併道",
"Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}", "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 on the {direction}": "{direction}駛入匝道",
"Take the ramp {direction}": "{direction}駛入匝道",
"Take the ramp onto {road}": "駛入匝道,進入 {road}", "Take the ramp onto {road}": "駛入匝道,進入 {road}",
"Take the ramp": "駛入匝道", "Take the ramp": "駛入匝道",
"Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}", "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 on the {direction}": "從出口{direction}駛出",
"Take the exit {direction}": "從出口{direction}駛出",
"Take the exit onto {road}": "從出口駛出,進入 {road}", "Take the exit onto {road}": "從出口駛出,進入 {road}",
"Take the exit": "從出口駛出", "Take the exit": "從出口駛出",
"At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}", "At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}",
@@ -1599,6 +1604,9 @@
"LIVE SIGNALS": "實時信號", "LIVE SIGNALS": "實時信號",
"CCTV layer unavailable": "CCTV 圖層不可用", "CCTV layer unavailable": "CCTV 圖層不可用",
"Link copied!": "鏈接已複製!", "Link copied!": "鏈接已複製!",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"Shared vessel is unavailable": "分享的船舶不可用",
"Copy failed": "複製失敗", "Copy failed": "複製失敗",
"Return map to straight-down view": "將地圖恢復為垂直俯視", "Return map to straight-down view": "將地圖恢復為垂直俯視",
"Tilt map to oblique view": "將地圖傾斜至斜視角", "Tilt map to oblique view": "將地圖傾斜至斜視角",
@@ -1952,6 +1960,8 @@
"ORBIT": "環繞", "ORBIT": "環繞",
"Location not found": "未找到位置", "Location not found": "未找到位置",
"Search failed": "搜索失敗", "Search failed": "搜索失敗",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
"Detailed outline unavailable": "無法獲取詳細輪廓", "Detailed outline unavailable": "無法獲取詳細輪廓",
"Fly to a POI first": "請先飛往一個 POI", "Fly to a POI first": "請先飛往一個 POI",
"Reset to full globe view": "重置為完整地球視圖", "Reset to full globe view": "重置為完整地球視圖",
@@ -2977,16 +2987,6 @@
"Search": "搜索", "Search": "搜索",
"More": "更多", "More": "更多",
"Panels": "面板", "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": "最近語音", "Recent speech": "最近語音",
"Voice Settings": "語音設置", "Voice Settings": "語音設置",
"Preview": "預覽", "Preview": "預覽",
@@ -3025,8 +3025,19 @@
"other": "{count} 個地標" "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 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", "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": "路線服務繁忙:同時請求的路線過多", "fire confidence filter|All": "全部",
"route proxy error": "路線代理出錯", "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}", "{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}", "Up to {upper}": "最高 {upper}",
"From {lower}": "最低 {lower}", "From {lower}": "最低 {lower}",
@@ -3039,6 +3050,8 @@
"Prohibited": "禁區", "Prohibited": "禁區",
"Restricted": "限制區", "Restricted": "限制區",
"Prohibited & Restricted Airspace": "禁區與限制區空域", "Prohibited & Restricted Airspace": "禁區與限制區空域",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"AUTHORING": "創作", "AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景", "Create and edit scenes": "創建和編輯場景",
"SCENE": "場景", "SCENE": "場景",
@@ -3064,19 +3077,6 @@
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板", "Left panels": "左側面板",
"Right 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": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件", "Directions, drawing and your own files": "路線導航、繪製和你自己的文件",