Enhance internationalization support across UI components; integrate i18n for dynamic text rendering in menus, forms, and HUD elements. Update climate and weather modules to utilize localized labels and descriptions, ensuring a consistent user experience in multiple languages. Refactor related tests to validate translations and locale settings.
This commit is contained in:
@@ -80,6 +80,9 @@ ids on first use without a lock, and two threads racing there hand out the same
|
|||||||
|
|
||||||
Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`.
|
Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`.
|
||||||
|
|
||||||
|
UI copy lives in `src/TheLivingWorld.Web/src/i18n/locales/`. `en.ts` is the schema; add a language by
|
||||||
|
copying it and registering the file in `locales.ts`. The language switcher is built from that registry.
|
||||||
|
|
||||||
## Working conventions
|
## Working conventions
|
||||||
|
|
||||||
- Prefer small, focused diffs. Do not rewrite unrelated code or add docs the user did not ask for.
|
- Prefer small, focused diffs. Do not rewrite unrelated code or add docs the user did not ask for.
|
||||||
|
|||||||
@@ -11,16 +11,19 @@
|
|||||||
<div class="menu">
|
<div class="menu">
|
||||||
<header class="menu__header">
|
<header class="menu__header">
|
||||||
<div class="menu__titles">
|
<div class="menu__titles">
|
||||||
<h1>The Living World</h1>
|
<h1 data-i18n="app.title">The Living World</h1>
|
||||||
<p class="menu__subtitle">Generate a world from OpenStreetMap</p>
|
<p class="menu__subtitle" data-i18n="app.subtitle">Generate a world from OpenStreetMap</p>
|
||||||
|
</div>
|
||||||
|
<div class="menu__actions">
|
||||||
|
<div id="locale-switch" class="locale-switch"></div>
|
||||||
|
<button id="menu-theme-toggle" type="button" class="theme-button" title="Switch theme">
|
||||||
|
Night
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="menu-theme-toggle" type="button" class="theme-button" title="Switch theme">
|
|
||||||
Night
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<button id="continue-button" type="button" class="continue" hidden>
|
<button id="continue-button" type="button" class="continue" hidden>
|
||||||
<span class="continue__kicker">Continue</span>
|
<span class="continue__kicker" data-i18n="menu.continue">Continue</span>
|
||||||
<span id="continue-name" class="continue__name"></span>
|
<span id="continue-name" class="continue__name"></span>
|
||||||
<span class="continue__arrow" aria-hidden="true">→</span>
|
<span class="continue__arrow" aria-hidden="true">→</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -28,27 +31,27 @@
|
|||||||
<div class="menu__body">
|
<div class="menu__body">
|
||||||
<section class="worlds">
|
<section class="worlds">
|
||||||
<div class="worlds__header">
|
<div class="worlds__header">
|
||||||
<h2>Worlds</h2>
|
<h2 data-i18n="menu.worlds">Worlds</h2>
|
||||||
<span id="slot-count" class="slot-count"></span>
|
<span id="slot-count" class="slot-count"></span>
|
||||||
</div>
|
</div>
|
||||||
<p id="worlds-empty" class="worlds__empty" hidden>
|
<p id="worlds-empty" class="worlds__empty" hidden data-i18n="menu.worldsEmpty">
|
||||||
Worlds you generate will show up here. A small town takes a few seconds.
|
Worlds you generate will show up here. A small town takes a few seconds.
|
||||||
</p>
|
</p>
|
||||||
<ul id="world-list" class="world-list"></ul>
|
<ul id="world-list" class="world-list"></ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form id="generate-form" class="form">
|
<form id="generate-form" class="form">
|
||||||
<h2 class="form__title">New world</h2>
|
<h2 class="form__title" data-i18n="form.newWorld">New world</h2>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Name</span>
|
<span data-i18n="form.name">Name</span>
|
||||||
<input id="field-name" type="text" placeholder="Optional — defaults to the coordinates" autocomplete="off" />
|
<input id="field-name" type="text" data-i18n-placeholder="form.namePlaceholder" placeholder="Optional — defaults to the coordinates" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">
|
<span class="field__label">
|
||||||
Location
|
<span data-i18n="form.location">Location</span>
|
||||||
<button id="use-location" type="button" class="link-button">Use my location</button>
|
<button id="use-location" type="button" class="link-button" data-i18n="form.useMyLocation">Use my location</button>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
id="field-coords"
|
id="field-coords"
|
||||||
@@ -58,18 +61,19 @@
|
|||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
value="31.8966010, -100.4858591"
|
value="31.8966010, -100.4858591"
|
||||||
|
data-i18n-placeholder="form.coordsPlaceholder"
|
||||||
placeholder="Latitude, longitude"
|
placeholder="Latitude, longitude"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Size <output id="field-size-value">10 km</output></span>
|
<span><span data-i18n="form.size">Size</span> <output id="field-size-value">10 km</output></span>
|
||||||
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Start date & time</span>
|
<span data-i18n="form.start">Start date & time</span>
|
||||||
<input
|
<input
|
||||||
id="field-start"
|
id="field-start"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -79,15 +83,15 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Climate</span>
|
<span data-i18n="form.climate">Climate</span>
|
||||||
<select id="field-climate">
|
<select id="field-climate">
|
||||||
<option value="">From the location</option>
|
<option value="" data-i18n="form.climateFromLocation">From the location</option>
|
||||||
</select>
|
</select>
|
||||||
<small id="climate-hint" class="field__hint"></small>
|
<small id="climate-hint" class="field__hint"></small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p id="form-hint" class="form__hint" hidden></p>
|
<p id="form-hint" class="form__hint" hidden></p>
|
||||||
<button id="generate-button" type="submit" class="button">Generate world</button>
|
<button id="generate-button" type="submit" class="button" data-i18n="form.generate">Generate world</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,7 +103,7 @@
|
|||||||
<div id="stage"></div>
|
<div id="stage"></div>
|
||||||
|
|
||||||
<header class="game-bar">
|
<header class="game-bar">
|
||||||
<button id="back-button" type="button" class="icon-button" title="Back to menu" aria-label="Back to menu">
|
<button id="back-button" type="button" class="icon-button" data-i18n-title="game.back" data-i18n-aria="game.back" title="Back to menu" aria-label="Back to menu">
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<span id="world-title" class="game-bar__title"></span>
|
<span id="world-title" class="game-bar__title"></span>
|
||||||
@@ -113,7 +117,7 @@
|
|||||||
title="Pause"
|
title="Pause"
|
||||||
aria-label="Pause"
|
aria-label="Pause"
|
||||||
>⏸</button>
|
>⏸</button>
|
||||||
<div class="sim-controls__speeds" role="group" aria-label="Simulation speed">
|
<div class="sim-controls__speeds" role="group" data-i18n-aria="game.speed" aria-label="Simulation speed">
|
||||||
<button type="button" class="speed-button" data-scale="1">x1</button>
|
<button type="button" class="speed-button" data-scale="1">x1</button>
|
||||||
<button type="button" class="speed-button" data-scale="2">x2</button>
|
<button type="button" class="speed-button" data-scale="2">x2</button>
|
||||||
<button type="button" class="speed-button" data-scale="3">x3</button>
|
<button type="button" class="speed-button" data-scale="3">x3</button>
|
||||||
@@ -125,6 +129,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="icon-button"
|
class="icon-button"
|
||||||
title="Hide weather effects"
|
title="Hide weather effects"
|
||||||
|
data-i18n-aria="game.weatherAria"
|
||||||
aria-label="Weather effects"
|
aria-label="Weather effects"
|
||||||
aria-pressed="true"
|
aria-pressed="true"
|
||||||
>🌦</button>
|
>🌦</button>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { getLocale, LOCALES, localeList, setLocale, t } from './index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills `[data-i18n]`, `[data-i18n-placeholder]`, `[data-i18n-title]` and `[data-i18n-aria]`
|
||||||
|
* from the active catalog. Put those attributes on leaf nodes only — `textContent`
|
||||||
|
* would wipe nested markup.
|
||||||
|
*/
|
||||||
|
export function applyDomTranslations(): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
document.title = t('app.title');
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n]')) {
|
||||||
|
const key = element.dataset.i18n;
|
||||||
|
if (key) element.textContent = t(key as Parameters<typeof t>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n-placeholder]')) {
|
||||||
|
const key = element.dataset.i18nPlaceholder;
|
||||||
|
if (key) element.setAttribute('placeholder', t(key as Parameters<typeof t>[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n-title]')) {
|
||||||
|
const key = element.dataset.i18nTitle;
|
||||||
|
if (key) element.title = t(key as Parameters<typeof t>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n-aria]')) {
|
||||||
|
const key = element.dataset.i18nAria;
|
||||||
|
if (key) element.setAttribute('aria-label', t(key as Parameters<typeof t>[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds EN/RU (and any later locale) buttons from the registry. */
|
||||||
|
export function mountLocaleSwitch(container: HTMLElement): void {
|
||||||
|
container.replaceChildren();
|
||||||
|
container.setAttribute('role', 'group');
|
||||||
|
|
||||||
|
for (const locale of localeList()) {
|
||||||
|
const meta = LOCALES[locale];
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'locale-button';
|
||||||
|
button.dataset.locale = locale;
|
||||||
|
button.textContent = meta.code;
|
||||||
|
button.title = meta.name;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
setLocale(locale);
|
||||||
|
});
|
||||||
|
container.append(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
paintLocaleSwitch(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paintLocaleSwitch(container: HTMLElement): void {
|
||||||
|
container.setAttribute('aria-label', t('locale.switch'));
|
||||||
|
const active = getLocale();
|
||||||
|
for (const button of container.querySelectorAll<HTMLButtonElement>('.locale-button')) {
|
||||||
|
button.setAttribute('aria-pressed', button.dataset.locale === active ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { catalog, detectLocale, getLocale, setLocale, t } from './index';
|
||||||
|
import { translateStage } from './stage';
|
||||||
|
import { localeList } from './locales';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLocale('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectLocale', () => {
|
||||||
|
it('picks the first registered language the browser offers', () => {
|
||||||
|
expect(detectLocale(['ru-RU', 'en-US'])).toBe('ru');
|
||||||
|
expect(detectLocale(['en-GB'])).toBe('en');
|
||||||
|
expect(detectLocale(['de-DE', 'ru'])).toBe('ru');
|
||||||
|
expect(detectLocale(['de-DE', 'fr'])).toBe('en');
|
||||||
|
expect(detectLocale([])).toBe('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('t', () => {
|
||||||
|
it('returns English by default', () => {
|
||||||
|
expect(getLocale()).toBe('en');
|
||||||
|
expect(t('menu.continue')).toBe('Continue');
|
||||||
|
expect(t('menu.openWorld', { name: 'Austin' })).toBe('Open Austin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the whole catalog', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
expect(t('menu.continue')).toBe('Продолжить');
|
||||||
|
expect(t('menu.openWorld', { name: 'Austin' })).toBe('Открыть Austin');
|
||||||
|
expect(t('weather.condition.cloudy')).toBe('Облачно');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects English and Russian plural forms', () => {
|
||||||
|
expect(t('world.buildings', { n: 1 })).toBe('1 building');
|
||||||
|
expect(t('world.buildings', { n: 2 })).toBe('2 buildings');
|
||||||
|
|
||||||
|
setLocale('ru');
|
||||||
|
expect(t('world.buildings', { n: 1 })).toBe('1 здание');
|
||||||
|
expect(t('world.buildings', { n: 2 })).toBe('2 здания');
|
||||||
|
expect(t('world.buildings', { n: 5 })).toBe('5 зданий');
|
||||||
|
expect(t('world.buildings', { n: 21 })).toBe('21 здание');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unknown keys as the key', () => {
|
||||||
|
expect(t('menu.continue'.replace('continue', 'missing') as 'menu.continue')).toBe('menu.missing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('catalogs', () => {
|
||||||
|
it('registers en and ru', () => {
|
||||||
|
expect(localeList()).toEqual(['en', 'ru']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps climate kinds aligned with the English labels the API still sends', () => {
|
||||||
|
expect(catalog().climate.kind.centralEuropean).toBe('Central European');
|
||||||
|
setLocale('ru');
|
||||||
|
expect(catalog().climate.kind.centralEuropean).toBe('Центральноевропейский');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('translateStage', () => {
|
||||||
|
it('translates the fixed progress lines', () => {
|
||||||
|
expect(translateStage('Queued')).toBe('Queued');
|
||||||
|
expect(translateStage('Slicing into chunks')).toBe('Slicing into chunks');
|
||||||
|
setLocale('ru');
|
||||||
|
expect(translateStage('Queued')).toBe('В очереди');
|
||||||
|
expect(translateStage('Starting')).toBe('Запуск');
|
||||||
|
expect(translateStage('Using cached OpenStreetMap data')).toBe('Кэш OpenStreetMap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites the counted lines in the active locale', () => {
|
||||||
|
expect(translateStage('Writing 12 chunks')).toBe('Writing 12 chunks');
|
||||||
|
setLocale('ru');
|
||||||
|
expect(translateStage('Writing 12 chunks')).toBe('Запись 12 чанков');
|
||||||
|
expect(translateStage('Querying OpenStreetMap (overpass.kumi.systems, attempt 2)')).toBe(
|
||||||
|
'Запрос OpenStreetMap (overpass.kumi.systems, попытка 2)',
|
||||||
|
);
|
||||||
|
const imported = translateStage('Importing 1,234 elements');
|
||||||
|
expect(imported).toContain('Импорт');
|
||||||
|
expect(imported).toContain('объектов');
|
||||||
|
expect(imported).toMatch(/1\s?234/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through stages it does not know', () => {
|
||||||
|
expect(translateStage('Reticulating splines')).toBe('Reticulating splines');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { MessageKey, Messages, Plural } from './types';
|
||||||
|
import { DEFAULT_LOCALE, isLocale, LOCALES, localeList, type Locale } from './locales';
|
||||||
|
|
||||||
|
export type { Locale, MessageKey, Messages, Plural };
|
||||||
|
export { isLocale, LOCALES, localeList };
|
||||||
|
|
||||||
|
export const LOCALE_STORAGE_KEY = 'the-living-world:locale';
|
||||||
|
|
||||||
|
let current: Locale = DEFAULT_LOCALE;
|
||||||
|
const listeners = new Set<(locale: Locale) => void>();
|
||||||
|
|
||||||
|
export function getLocale(): Locale {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localeTag(): string {
|
||||||
|
return LOCALES[current].tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalog(): Messages {
|
||||||
|
return LOCALES[current].messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: (locale: Locale) => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the first browser language that matches a registered locale, otherwise English. */
|
||||||
|
export function detectLocale(languages: readonly string[]): Locale {
|
||||||
|
const available = localeList();
|
||||||
|
for (const raw of languages) {
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
for (const locale of available) {
|
||||||
|
if (lower === locale || lower.startsWith(`${locale}-`)) return locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigatorLanguages(): string[] {
|
||||||
|
if (typeof navigator === 'undefined') return [];
|
||||||
|
if (navigator.languages?.length) return [...navigator.languages];
|
||||||
|
if (navigator.language) return [navigator.language];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredLocale(): Locale | null {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
return isLocale(stored) ? stored : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(locale: Locale): void {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||||
|
} catch {
|
||||||
|
// Private mode / node tests: keep the in-memory locale.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHtmlLang(locale: Locale): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
document.documentElement.lang = LOCALES[locale].tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the locale from storage, then the browser, then English.
|
||||||
|
* Call once at startup, before painting the UI. Does not notify subscribers.
|
||||||
|
*/
|
||||||
|
export function initI18n(): Locale {
|
||||||
|
current = readStoredLocale() ?? detectLocale(navigatorLanguages());
|
||||||
|
persist(current);
|
||||||
|
applyHtmlLang(current);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocale(locale: Locale): void {
|
||||||
|
if (locale === current) return;
|
||||||
|
current = locale;
|
||||||
|
persist(locale);
|
||||||
|
applyHtmlLang(locale);
|
||||||
|
for (const listener of listeners) listener(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlural(value: unknown): value is Plural {
|
||||||
|
return typeof value === 'object' && value !== null && 'other' in value && 'one' in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(key: string): unknown {
|
||||||
|
let currentValue: unknown = catalog();
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
if (typeof currentValue !== 'object' || currentValue === null || !(part in currentValue)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
currentValue = (currentValue as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||||
|
if (!params) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
|
||||||
|
const value = params[name];
|
||||||
|
if (value === undefined) return `{${name}}`;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return name === 'n' ? value.toLocaleString(localeTag()) : String(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a catalog string. Pass `{ n }` for plural leaves; the count `n` is
|
||||||
|
* formatted with the active locale, other numbers stringify as-is.
|
||||||
|
*/
|
||||||
|
export function t(key: MessageKey, params?: Record<string, string | number>): string {
|
||||||
|
const value = lookup(key);
|
||||||
|
if (typeof value === 'string') return interpolate(value, params);
|
||||||
|
if (isPlural(value)) {
|
||||||
|
const count = typeof params?.n === 'number' ? params.n : 0;
|
||||||
|
const category = new Intl.PluralRules(localeTag()).select(count);
|
||||||
|
const template = value[category as keyof Plural] ?? value.other;
|
||||||
|
return interpolate(template, params);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { en } from './locales/en';
|
||||||
|
import { ru } from './locales/ru';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered UI languages. To add one: copy `locales/en.ts`, translate, and add an entry here.
|
||||||
|
* The language switcher is built from this map, so no HTML change is required.
|
||||||
|
*/
|
||||||
|
export const LOCALES = {
|
||||||
|
en: { messages: en, code: 'EN', name: 'English', tag: 'en' },
|
||||||
|
ru: { messages: ru, code: 'RU', name: 'Русский', tag: 'ru' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Locale = keyof typeof LOCALES;
|
||||||
|
|
||||||
|
export const DEFAULT_LOCALE: Locale = 'en';
|
||||||
|
|
||||||
|
export function isLocale(value: string | null | undefined): value is Locale {
|
||||||
|
return value !== null && value !== undefined && value in LOCALES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localeList(): Locale[] {
|
||||||
|
return Object.keys(LOCALES) as Locale[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
|
export const en: Messages = {
|
||||||
|
app: {
|
||||||
|
title: 'The Living World',
|
||||||
|
subtitle: 'Generate a world from OpenStreetMap',
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
switch: 'Language',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
day: 'Day',
|
||||||
|
night: 'Night',
|
||||||
|
switchTo: 'Switch to {theme} theme',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
continue: 'Continue',
|
||||||
|
worlds: 'Worlds',
|
||||||
|
worldsEmpty: 'Worlds you generate will show up here. A small town takes a few seconds.',
|
||||||
|
lastPlayed: 'Last played',
|
||||||
|
openWorld: 'Open {name}',
|
||||||
|
deleteWorld: 'Delete {name}',
|
||||||
|
deleteConfirm: 'Delete "{name}"?',
|
||||||
|
slotsFull: 'All {n} slots are in use. Delete a world to create another.',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
newWorld: 'New world',
|
||||||
|
name: 'Name',
|
||||||
|
namePlaceholder: 'Optional — defaults to the coordinates',
|
||||||
|
location: 'Location',
|
||||||
|
useMyLocation: 'Use my location',
|
||||||
|
coordsPlaceholder: 'Latitude, longitude',
|
||||||
|
size: 'Size',
|
||||||
|
sizeValue: '{n} km',
|
||||||
|
start: 'Start date & time',
|
||||||
|
climate: 'Climate',
|
||||||
|
climateFromLocation: 'From the location',
|
||||||
|
generate: 'Generate world',
|
||||||
|
},
|
||||||
|
climate: {
|
||||||
|
kind: {
|
||||||
|
equatorial: 'Equatorial',
|
||||||
|
tropicalMonsoon: 'Tropical monsoon',
|
||||||
|
savanna: 'Savanna',
|
||||||
|
hotDesert: 'Hot desert',
|
||||||
|
coldSteppe: 'Cold steppe',
|
||||||
|
mediterranean: 'Mediterranean',
|
||||||
|
humidSubtropical: 'Humid subtropical',
|
||||||
|
oceanic: 'Oceanic',
|
||||||
|
centralEuropean: 'Central European',
|
||||||
|
siberian: 'Siberian',
|
||||||
|
tundra: 'Tundra',
|
||||||
|
highland: 'Highland',
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
equatorial: 'Singapore',
|
||||||
|
tropicalMonsoon: 'Mumbai',
|
||||||
|
savanna: 'Nairobi',
|
||||||
|
hotDesert: 'Cairo',
|
||||||
|
coldSteppe: 'Astana',
|
||||||
|
mediterranean: 'Barcelona',
|
||||||
|
humidSubtropical: 'Tokyo',
|
||||||
|
oceanic: 'London',
|
||||||
|
centralEuropean: 'Warsaw',
|
||||||
|
siberian: 'Yakutsk',
|
||||||
|
tundra: 'Murmansk',
|
||||||
|
highland: 'La Paz',
|
||||||
|
},
|
||||||
|
describe: '{label} ({code}) · like {example}',
|
||||||
|
hintInferred: 'This location suggests {label} ({code}).',
|
||||||
|
hintNeedLocation: 'Enter a location to see what the latitude suggests.',
|
||||||
|
hintMismatch: '{label} — the latitude would have suggested {inferred}.',
|
||||||
|
hintChosen: '{label}, like {example}.',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
pickPlace: 'Pick a place and generate a world to get started.',
|
||||||
|
continueHint: 'Continue where you left off, or open another world.',
|
||||||
|
chooseWorld: 'Choose a world or create a new one.',
|
||||||
|
loadingMap: 'Loading map…',
|
||||||
|
requestingWorld: 'Requesting world…',
|
||||||
|
findingLocation: 'Finding your location…',
|
||||||
|
locationFilled: 'Location filled in — adjust the size and generate.',
|
||||||
|
geolocationUnavailable: 'Geolocation is not available in this browser.',
|
||||||
|
invalidCoords: 'Enter a location as latitude, longitude — for example 31.90, -100.49.',
|
||||||
|
invalidStart: 'Pick a start date and time for the in-world calendar.',
|
||||||
|
generationFailed: 'Generation failed: {error}',
|
||||||
|
generationFailedUnknown: 'unknown error',
|
||||||
|
deleted: 'Deleted {name}',
|
||||||
|
couldNotOpen: 'Could not open world: {error}',
|
||||||
|
couldNotDelete: 'Could not delete world: {error}',
|
||||||
|
couldNotReachApi: 'Could not reach the API: {error}',
|
||||||
|
couldNotUpdateClock: 'Could not update clock: {error}',
|
||||||
|
couldNotGetLocation: 'Could not get location: {error}',
|
||||||
|
worldProgress: '{name}: {stage}',
|
||||||
|
generating: 'Generating',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
worldStatus: {
|
||||||
|
pending: 'Pending',
|
||||||
|
generating: 'Generating',
|
||||||
|
ready: 'Ready',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
world: {
|
||||||
|
buildings: { one: '{n} building', other: '{n} buildings' },
|
||||||
|
roads: { one: '{n} road', other: '{n} roads' },
|
||||||
|
sizeKm: '{n} km',
|
||||||
|
},
|
||||||
|
game: {
|
||||||
|
back: 'Back to menu',
|
||||||
|
play: 'Play',
|
||||||
|
pause: 'Pause',
|
||||||
|
speed: 'Simulation speed',
|
||||||
|
weatherOn: 'Hide weather effects',
|
||||||
|
weatherOff: 'Show weather effects',
|
||||||
|
weatherAria: 'Weather effects',
|
||||||
|
},
|
||||||
|
hud: {
|
||||||
|
metersPerPixel: '{n} m/px',
|
||||||
|
chunks: '{loaded}/{total} chunks',
|
||||||
|
position: '{x}, {y} m',
|
||||||
|
loading: 'loading…',
|
||||||
|
},
|
||||||
|
weather: {
|
||||||
|
condition: {
|
||||||
|
clear: 'Clear',
|
||||||
|
fewClouds: 'Few clouds',
|
||||||
|
cloudy: 'Cloudy',
|
||||||
|
overcast: 'Overcast',
|
||||||
|
fog: 'Fog',
|
||||||
|
drizzle: 'Drizzle',
|
||||||
|
rain: 'Rain',
|
||||||
|
heavyRain: 'Heavy rain',
|
||||||
|
thunderstorm: 'Thunderstorm',
|
||||||
|
sleet: 'Sleet',
|
||||||
|
snow: 'Snow',
|
||||||
|
heavySnow: 'Heavy snow',
|
||||||
|
blizzard: 'Blizzard',
|
||||||
|
sandstorm: 'Sandstorm',
|
||||||
|
},
|
||||||
|
compass: [
|
||||||
|
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||||
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW',
|
||||||
|
],
|
||||||
|
feels: 'feels {temp}',
|
||||||
|
feelsLike: 'Feels like {temp}',
|
||||||
|
wind: 'Wind {wind}',
|
||||||
|
humidity: 'Humidity {n}%',
|
||||||
|
cloud: 'Cloud {n}%',
|
||||||
|
pressure: 'Pressure {n} hPa',
|
||||||
|
precipitation: 'Precipitation {n} mm/h',
|
||||||
|
windSpeed: '{dir} {n} m/s',
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
months: [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
],
|
||||||
|
weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||||
|
shortWeekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||||
|
format: '{day} {month} {year} · {time}',
|
||||||
|
formatWithWeekday: '{weekday} {day} {month} {year} · {time}',
|
||||||
|
},
|
||||||
|
stage: {
|
||||||
|
queued: 'Queued',
|
||||||
|
starting: 'Starting',
|
||||||
|
cachedOsm: 'Using cached OpenStreetMap data',
|
||||||
|
queryingOsm: 'Querying OpenStreetMap ({host}, attempt {n})',
|
||||||
|
parsingOsm: 'Parsing OpenStreetMap response',
|
||||||
|
importing: 'Importing {n} elements',
|
||||||
|
computingBounds: 'Computing bounds and chunks',
|
||||||
|
slicing: 'Slicing into chunks',
|
||||||
|
writing: 'Writing {n} chunks',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
|
export const ru: Messages = {
|
||||||
|
app: {
|
||||||
|
title: 'The Living World',
|
||||||
|
subtitle: 'Мир по данным OpenStreetMap',
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
switch: 'Язык',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
day: 'День',
|
||||||
|
night: 'Ночь',
|
||||||
|
switchTo: 'Переключить на тему «{theme}»',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
continue: 'Продолжить',
|
||||||
|
worlds: 'Миры',
|
||||||
|
worldsEmpty: 'Созданные миры появятся здесь. Небольшой город занимает несколько секунд.',
|
||||||
|
lastPlayed: 'Недавний',
|
||||||
|
openWorld: 'Открыть {name}',
|
||||||
|
deleteWorld: 'Удалить {name}',
|
||||||
|
deleteConfirm: 'Удалить «{name}»?',
|
||||||
|
slotsFull: 'Все {n} слотов заняты. Удалите мир, чтобы создать новый.',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
newWorld: 'Новый мир',
|
||||||
|
name: 'Название',
|
||||||
|
namePlaceholder: 'Необязательно — по умолчанию координаты',
|
||||||
|
location: 'Место',
|
||||||
|
useMyLocation: 'Моё местоположение',
|
||||||
|
coordsPlaceholder: 'Широта, долгота',
|
||||||
|
size: 'Размер',
|
||||||
|
sizeValue: '{n} км',
|
||||||
|
start: 'Дата и время начала',
|
||||||
|
climate: 'Климат',
|
||||||
|
climateFromLocation: 'По месту',
|
||||||
|
generate: 'Создать мир',
|
||||||
|
},
|
||||||
|
climate: {
|
||||||
|
kind: {
|
||||||
|
equatorial: 'Экваториальный',
|
||||||
|
tropicalMonsoon: 'Тропический муссон',
|
||||||
|
savanna: 'Саванна',
|
||||||
|
hotDesert: 'Жаркая пустыня',
|
||||||
|
coldSteppe: 'Холодная степь',
|
||||||
|
mediterranean: 'Средиземноморский',
|
||||||
|
humidSubtropical: 'Влажный субтропический',
|
||||||
|
oceanic: 'Океанический',
|
||||||
|
centralEuropean: 'Центральноевропейский',
|
||||||
|
siberian: 'Сибирский',
|
||||||
|
tundra: 'Тундра',
|
||||||
|
highland: 'Высокогорный',
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
equatorial: 'Сингапур',
|
||||||
|
tropicalMonsoon: 'Мумбаи',
|
||||||
|
savanna: 'Найроби',
|
||||||
|
hotDesert: 'Каир',
|
||||||
|
coldSteppe: 'Астана',
|
||||||
|
mediterranean: 'Барселона',
|
||||||
|
humidSubtropical: 'Токио',
|
||||||
|
oceanic: 'Лондон',
|
||||||
|
centralEuropean: 'Варшава',
|
||||||
|
siberian: 'Якутск',
|
||||||
|
tundra: 'Мурманск',
|
||||||
|
highland: 'Ла-Пас',
|
||||||
|
},
|
||||||
|
describe: '{label} ({code}) · как {example}',
|
||||||
|
hintInferred: 'Это место предполагает {label} ({code}).',
|
||||||
|
hintNeedLocation: 'Введите место, чтобы увидеть, что подсказывает широта.',
|
||||||
|
hintMismatch: '{label} — по широте ближе {inferred}.',
|
||||||
|
hintChosen: '{label}, как {example}.',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
pickPlace: 'Выберите место и создайте мир.',
|
||||||
|
continueHint: 'Продолжите с того места, где остановились, или откройте другой мир.',
|
||||||
|
chooseWorld: 'Выберите мир или создайте новый.',
|
||||||
|
loadingMap: 'Загрузка карты…',
|
||||||
|
requestingWorld: 'Запрос мира…',
|
||||||
|
findingLocation: 'Определение местоположения…',
|
||||||
|
locationFilled: 'Место подставлено — выберите размер и создайте мир.',
|
||||||
|
geolocationUnavailable: 'Геолокация недоступна в этом браузере.',
|
||||||
|
invalidCoords: 'Введите широту и долготу — например 31.90, -100.49.',
|
||||||
|
invalidStart: 'Выберите дату и время начала игрового календаря.',
|
||||||
|
generationFailed: 'Не удалось создать мир: {error}',
|
||||||
|
generationFailedUnknown: 'неизвестная ошибка',
|
||||||
|
deleted: 'Удалён {name}',
|
||||||
|
couldNotOpen: 'Не удалось открыть мир: {error}',
|
||||||
|
couldNotDelete: 'Не удалось удалить мир: {error}',
|
||||||
|
couldNotReachApi: 'Нет связи с API: {error}',
|
||||||
|
couldNotUpdateClock: 'Не удалось изменить часы: {error}',
|
||||||
|
couldNotGetLocation: 'Не удалось определить место: {error}',
|
||||||
|
worldProgress: '{name}: {stage}',
|
||||||
|
generating: 'Создание',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
},
|
||||||
|
worldStatus: {
|
||||||
|
pending: 'Ожидание',
|
||||||
|
generating: 'Создание',
|
||||||
|
ready: 'Готов',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
},
|
||||||
|
world: {
|
||||||
|
buildings: { one: '{n} здание', few: '{n} здания', many: '{n} зданий', other: '{n} зданий' },
|
||||||
|
roads: { one: '{n} дорога', few: '{n} дороги', many: '{n} дорог', other: '{n} дорог' },
|
||||||
|
sizeKm: '{n} км',
|
||||||
|
},
|
||||||
|
game: {
|
||||||
|
back: 'Назад в меню',
|
||||||
|
play: 'Играть',
|
||||||
|
pause: 'Пауза',
|
||||||
|
speed: 'Скорость симуляции',
|
||||||
|
weatherOn: 'Скрыть погоду',
|
||||||
|
weatherOff: 'Показать погоду',
|
||||||
|
weatherAria: 'Погодные эффекты',
|
||||||
|
},
|
||||||
|
hud: {
|
||||||
|
metersPerPixel: '{n} м/пикс',
|
||||||
|
chunks: '{loaded}/{total} чанков',
|
||||||
|
position: '{x}, {y} м',
|
||||||
|
loading: 'загрузка…',
|
||||||
|
},
|
||||||
|
weather: {
|
||||||
|
condition: {
|
||||||
|
clear: 'Ясно',
|
||||||
|
fewClouds: 'Малооблачно',
|
||||||
|
cloudy: 'Облачно',
|
||||||
|
overcast: 'Пасмурно',
|
||||||
|
fog: 'Туман',
|
||||||
|
drizzle: 'Морось',
|
||||||
|
rain: 'Дождь',
|
||||||
|
heavyRain: 'Ливень',
|
||||||
|
thunderstorm: 'Гроза',
|
||||||
|
sleet: 'Мокрый снег',
|
||||||
|
snow: 'Снег',
|
||||||
|
heavySnow: 'Сильный снег',
|
||||||
|
blizzard: 'Метель',
|
||||||
|
sandstorm: 'Песчаная буря',
|
||||||
|
},
|
||||||
|
compass: [
|
||||||
|
'С', 'ССВ', 'СВ', 'ВСВ', 'В', 'ВЮВ', 'ЮВ', 'ЮЮВ',
|
||||||
|
'Ю', 'ЮЮЗ', 'ЮЗ', 'ЗЮЗ', 'З', 'ЗСЗ', 'СЗ', 'ССЗ',
|
||||||
|
],
|
||||||
|
feels: 'ощущается {temp}',
|
||||||
|
feelsLike: 'Ощущается как {temp}',
|
||||||
|
wind: 'Ветер {wind}',
|
||||||
|
humidity: 'Влажность {n}%',
|
||||||
|
cloud: 'Облачность {n}%',
|
||||||
|
pressure: 'Давление {n} гПа',
|
||||||
|
precipitation: 'Осадки {n} мм/ч',
|
||||||
|
windSpeed: '{dir} {n} м/с',
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
months: [
|
||||||
|
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
|
||||||
|
],
|
||||||
|
weekdays: ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота'],
|
||||||
|
shortWeekdays: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'],
|
||||||
|
format: '{day} {month} {year} · {time}',
|
||||||
|
formatWithWeekday: '{weekday} {day} {month} {year} · {time}',
|
||||||
|
},
|
||||||
|
stage: {
|
||||||
|
queued: 'В очереди',
|
||||||
|
starting: 'Запуск',
|
||||||
|
cachedOsm: 'Кэш OpenStreetMap',
|
||||||
|
queryingOsm: 'Запрос OpenStreetMap ({host}, попытка {n})',
|
||||||
|
parsingOsm: 'Разбор ответа OpenStreetMap',
|
||||||
|
importing: 'Импорт {n} объектов',
|
||||||
|
computingBounds: 'Расчёт границ и чанков',
|
||||||
|
slicing: 'Нарезка на чанки',
|
||||||
|
writing: 'Запись {n} чанков',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { t } from './index';
|
||||||
|
|
||||||
|
const EXACT: Record<string, Parameters<typeof t>[0]> = {
|
||||||
|
Queued: 'stage.queued',
|
||||||
|
Starting: 'stage.starting',
|
||||||
|
'Using cached OpenStreetMap data': 'stage.cachedOsm',
|
||||||
|
'Parsing OpenStreetMap response': 'stage.parsingOsm',
|
||||||
|
'Computing bounds and chunks': 'stage.computingBounds',
|
||||||
|
'Slicing into chunks': 'stage.slicing',
|
||||||
|
};
|
||||||
|
|
||||||
|
function countFrom(raw: string): number | string {
|
||||||
|
const n = Number(raw.replace(/[^\d]/g, ''));
|
||||||
|
return Number.isFinite(n) ? n : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server generation stages are English codes-as-sentences; map the known ones, leave unknowns as-is. */
|
||||||
|
export function translateStage(stage: string): string {
|
||||||
|
const exact = EXACT[stage];
|
||||||
|
if (exact) return t(exact);
|
||||||
|
|
||||||
|
const importing = /^Importing (.+) elements$/.exec(stage);
|
||||||
|
if (importing) return t('stage.importing', { n: countFrom(importing[1]!) });
|
||||||
|
|
||||||
|
const writing = /^Writing (.+) chunks$/.exec(stage);
|
||||||
|
if (writing) return t('stage.writing', { n: countFrom(writing[1]!) });
|
||||||
|
|
||||||
|
const querying = /^Querying OpenStreetMap \((.+), attempt (\d+)\)$/.exec(stage);
|
||||||
|
if (querying) return t('stage.queryingOsm', { host: querying[1]!, n: Number(querying[2]) });
|
||||||
|
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { ClimateKind, WeatherCondition, WorldStatus } from '../api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICU-lite plural forms. `t(key, { n })` picks a category via `Intl.PluralRules`.
|
||||||
|
* English needs `one`/`other`; Russian also uses `few`/`many`.
|
||||||
|
*/
|
||||||
|
export interface Plural {
|
||||||
|
zero?: string;
|
||||||
|
one: string;
|
||||||
|
two?: string;
|
||||||
|
few?: string;
|
||||||
|
many?: string;
|
||||||
|
other: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthNames = [string, string, string, string, string, string, string, string, string, string, string, string];
|
||||||
|
export type WeekdayNames = [string, string, string, string, string, string, string];
|
||||||
|
export type CompassNames = [
|
||||||
|
string, string, string, string, string, string, string, string,
|
||||||
|
string, string, string, string, string, string, string, string,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every user-facing string in the client. Add a key here first — TypeScript will then
|
||||||
|
* refuse to build until every registered locale fills it in.
|
||||||
|
*/
|
||||||
|
export interface Messages {
|
||||||
|
app: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
locale: {
|
||||||
|
switch: string;
|
||||||
|
};
|
||||||
|
theme: {
|
||||||
|
day: string;
|
||||||
|
night: string;
|
||||||
|
switchTo: string;
|
||||||
|
};
|
||||||
|
menu: {
|
||||||
|
continue: string;
|
||||||
|
worlds: string;
|
||||||
|
worldsEmpty: string;
|
||||||
|
lastPlayed: string;
|
||||||
|
openWorld: string;
|
||||||
|
deleteWorld: string;
|
||||||
|
deleteConfirm: string;
|
||||||
|
slotsFull: string;
|
||||||
|
};
|
||||||
|
form: {
|
||||||
|
newWorld: string;
|
||||||
|
name: string;
|
||||||
|
namePlaceholder: string;
|
||||||
|
location: string;
|
||||||
|
useMyLocation: string;
|
||||||
|
coordsPlaceholder: string;
|
||||||
|
size: string;
|
||||||
|
sizeValue: string;
|
||||||
|
start: string;
|
||||||
|
climate: string;
|
||||||
|
climateFromLocation: string;
|
||||||
|
generate: string;
|
||||||
|
};
|
||||||
|
climate: {
|
||||||
|
kind: Record<ClimateKind, string>;
|
||||||
|
example: Record<ClimateKind, string>;
|
||||||
|
describe: string;
|
||||||
|
hintInferred: string;
|
||||||
|
hintNeedLocation: string;
|
||||||
|
hintMismatch: string;
|
||||||
|
hintChosen: string;
|
||||||
|
};
|
||||||
|
status: {
|
||||||
|
pickPlace: string;
|
||||||
|
continueHint: string;
|
||||||
|
chooseWorld: string;
|
||||||
|
loadingMap: string;
|
||||||
|
requestingWorld: string;
|
||||||
|
findingLocation: string;
|
||||||
|
locationFilled: string;
|
||||||
|
geolocationUnavailable: string;
|
||||||
|
invalidCoords: string;
|
||||||
|
invalidStart: string;
|
||||||
|
generationFailed: string;
|
||||||
|
generationFailedUnknown: string;
|
||||||
|
deleted: string;
|
||||||
|
couldNotOpen: string;
|
||||||
|
couldNotDelete: string;
|
||||||
|
couldNotReachApi: string;
|
||||||
|
couldNotUpdateClock: string;
|
||||||
|
couldNotGetLocation: string;
|
||||||
|
worldProgress: string;
|
||||||
|
generating: string;
|
||||||
|
failed: string;
|
||||||
|
};
|
||||||
|
worldStatus: Record<WorldStatus, string>;
|
||||||
|
world: {
|
||||||
|
buildings: Plural;
|
||||||
|
roads: Plural;
|
||||||
|
sizeKm: string;
|
||||||
|
};
|
||||||
|
game: {
|
||||||
|
back: string;
|
||||||
|
play: string;
|
||||||
|
pause: string;
|
||||||
|
speed: string;
|
||||||
|
weatherOn: string;
|
||||||
|
weatherOff: string;
|
||||||
|
weatherAria: string;
|
||||||
|
};
|
||||||
|
hud: {
|
||||||
|
metersPerPixel: string;
|
||||||
|
chunks: string;
|
||||||
|
position: string;
|
||||||
|
loading: string;
|
||||||
|
};
|
||||||
|
weather: {
|
||||||
|
condition: Record<WeatherCondition, string>;
|
||||||
|
compass: CompassNames;
|
||||||
|
feels: string;
|
||||||
|
feelsLike: string;
|
||||||
|
wind: string;
|
||||||
|
humidity: string;
|
||||||
|
cloud: string;
|
||||||
|
pressure: string;
|
||||||
|
precipitation: string;
|
||||||
|
windSpeed: string;
|
||||||
|
};
|
||||||
|
time: {
|
||||||
|
months: MonthNames;
|
||||||
|
weekdays: WeekdayNames;
|
||||||
|
shortWeekdays: WeekdayNames;
|
||||||
|
format: string;
|
||||||
|
formatWithWeekday: string;
|
||||||
|
};
|
||||||
|
stage: {
|
||||||
|
queued: string;
|
||||||
|
starting: string;
|
||||||
|
cachedOsm: string;
|
||||||
|
queryingOsm: string;
|
||||||
|
parsingOsm: string;
|
||||||
|
importing: string;
|
||||||
|
computingBounds: string;
|
||||||
|
slicing: string;
|
||||||
|
writing: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paths to string or plural leaves, e.g. `menu.continue` or `weather.condition.clear`. */
|
||||||
|
export type MessageKey = {
|
||||||
|
[K in keyof Messages & string]: LeafKey<K, Messages[K]>;
|
||||||
|
}[keyof Messages & string];
|
||||||
|
|
||||||
|
type LeafKey<Prefix extends string, T> = T extends string
|
||||||
|
? Prefix
|
||||||
|
: T extends Plural
|
||||||
|
? Prefix
|
||||||
|
: T extends readonly string[]
|
||||||
|
? Prefix
|
||||||
|
: {
|
||||||
|
[K in keyof T & string]: LeafKey<`${Prefix}.${K}`, T[K]>;
|
||||||
|
}[keyof T & string];
|
||||||
@@ -11,8 +11,11 @@ import {
|
|||||||
interpolateGameTime,
|
interpolateGameTime,
|
||||||
startGameTimeFromInput,
|
startGameTimeFromInput,
|
||||||
} from './ui/gameTime';
|
} from './ui/gameTime';
|
||||||
import { climateFromLatitude, describeClimate, findClimate } from './ui/climate';
|
import { climateFromLatitude, climateExample, climateLabel, describeClimate, findClimate } from './ui/climate';
|
||||||
import { conditionIcon, describeWeather, formatTemperature, formatWeather } from './ui/weather';
|
import { conditionIcon, describeWeather, formatTemperature, formatWeather } from './ui/weather';
|
||||||
|
import { catalog, initI18n, subscribe, t } from './i18n';
|
||||||
|
import { applyDomTranslations, mountLocaleSwitch, paintLocaleSwitch } from './i18n/dom';
|
||||||
|
import { translateStage } from './i18n/stage';
|
||||||
|
|
||||||
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||||
const THEME_KEY = 'the-living-world:theme';
|
const THEME_KEY = 'the-living-world:theme';
|
||||||
@@ -45,6 +48,7 @@ const elements = {
|
|||||||
hud: required<HTMLDivElement>('hud'),
|
hud: required<HTMLDivElement>('hud'),
|
||||||
worldTitle: required<HTMLElement>('world-title'),
|
worldTitle: required<HTMLElement>('world-title'),
|
||||||
back: required<HTMLButtonElement>('back-button'),
|
back: required<HTMLButtonElement>('back-button'),
|
||||||
|
localeSwitch: required<HTMLDivElement>('locale-switch'),
|
||||||
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
|
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
|
||||||
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
|
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
|
||||||
weatherToggle: required<HTMLButtonElement>('weather-toggle'),
|
weatherToggle: required<HTMLButtonElement>('weather-toggle'),
|
||||||
@@ -76,6 +80,7 @@ let gameSnapshotAt = 0;
|
|||||||
let gamePollTimer: number | null = null;
|
let gamePollTimer: number | null = null;
|
||||||
let gamePaintTimer: number | null = null;
|
let gamePaintTimer: number | null = null;
|
||||||
let clockUpdating = false;
|
let clockUpdating = false;
|
||||||
|
let lastMapStatus: MapStatus | null = null;
|
||||||
|
|
||||||
function required<T extends HTMLElement>(id: string): T {
|
function required<T extends HTMLElement>(id: string): T {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
@@ -102,14 +107,20 @@ function showGame(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderHud(status: MapStatus): void {
|
function renderHud(status: MapStatus): void {
|
||||||
const scale = status.metersPerPixel >= 10
|
lastMapStatus = status;
|
||||||
? `${Math.round(status.metersPerPixel)} m/px`
|
const scale = t('hud.metersPerPixel', {
|
||||||
: `${status.metersPerPixel.toFixed(1)} m/px`;
|
n: status.metersPerPixel >= 10
|
||||||
|
? String(Math.round(status.metersPerPixel))
|
||||||
|
: status.metersPerPixel.toFixed(1),
|
||||||
|
});
|
||||||
|
|
||||||
const chunks = `${status.loadedChunks}/${status.totalChunks} chunks`;
|
const chunks = t('hud.chunks', { loaded: status.loadedChunks, total: status.totalChunks });
|
||||||
const position = `${Math.round(status.center.x)}, ${Math.round(status.center.y)} m`;
|
const position = t('hud.position', {
|
||||||
|
x: Math.round(status.center.x),
|
||||||
|
y: Math.round(status.center.y),
|
||||||
|
});
|
||||||
|
|
||||||
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
|
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ` · ${t('hud.loading')}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGenerateEnabled(): void {
|
function updateGenerateEnabled(): void {
|
||||||
@@ -118,7 +129,7 @@ function updateGenerateEnabled(): void {
|
|||||||
|
|
||||||
if (full) {
|
if (full) {
|
||||||
elements.formHint.hidden = false;
|
elements.formHint.hidden = false;
|
||||||
elements.formHint.textContent = `All ${maxConcurrentWorlds} slots are in use. Delete a world to create another.`;
|
elements.formHint.textContent = t('menu.slotsFull', { n: maxConcurrentWorlds });
|
||||||
} else {
|
} else {
|
||||||
elements.formHint.hidden = true;
|
elements.formHint.hidden = true;
|
||||||
elements.formHint.textContent = '';
|
elements.formHint.textContent = '';
|
||||||
@@ -222,9 +233,11 @@ function stopMenuClockLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function worldBadge(world: WorldSummary): string | null {
|
function worldBadge(world: WorldSummary): string | null {
|
||||||
if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating';
|
if (world.status === 'pending' || world.status === 'generating') {
|
||||||
if (world.status === 'failed') return 'Failed';
|
return world.stage ? translateStage(world.stage) : t('status.generating');
|
||||||
if (world.id === lastWorldId()) return 'Last played';
|
}
|
||||||
|
if (world.status === 'failed') return t('status.failed');
|
||||||
|
if (world.id === lastWorldId()) return t('menu.lastPlayed');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +255,8 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
|||||||
open.className = 'world__open';
|
open.className = 'world__open';
|
||||||
open.disabled = world.status !== 'ready';
|
open.disabled = world.status !== 'ready';
|
||||||
open.title = world.status === 'ready'
|
open.title = world.status === 'ready'
|
||||||
? `Open ${world.name}`
|
? t('menu.openWorld', { name: world.name })
|
||||||
: (world.error ?? world.stage ?? world.status);
|
: (world.error ?? (world.stage ? translateStage(world.stage) : catalog().worldStatus[world.status]));
|
||||||
|
|
||||||
const nameRow = document.createElement('span');
|
const nameRow = document.createElement('span');
|
||||||
nameRow.className = 'world__name-row';
|
nameRow.className = 'world__name-row';
|
||||||
@@ -274,8 +287,8 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
|||||||
const remove = document.createElement('button');
|
const remove = document.createElement('button');
|
||||||
remove.type = 'button';
|
remove.type = 'button';
|
||||||
remove.className = 'world__delete';
|
remove.className = 'world__delete';
|
||||||
remove.title = `Delete ${world.name}`;
|
remove.title = t('menu.deleteWorld', { name: world.name });
|
||||||
remove.setAttribute('aria-label', `Delete ${world.name}`);
|
remove.setAttribute('aria-label', t('menu.deleteWorld', { name: world.name }));
|
||||||
remove.textContent = '×';
|
remove.textContent = '×';
|
||||||
remove.addEventListener('click', () => {
|
remove.addEventListener('click', () => {
|
||||||
void deleteWorld(world);
|
void deleteWorld(world);
|
||||||
@@ -286,7 +299,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string {
|
function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string {
|
||||||
if (world.status === 'failed') return world.error ?? 'Generation failed';
|
if (world.status === 'failed') return world.error ?? t('status.failed');
|
||||||
if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4);
|
if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4);
|
||||||
|
|
||||||
// Enough to tell a town under snow from one in the tropics without opening either.
|
// Enough to tell a town under snow from one in the tropics without opening either.
|
||||||
@@ -295,9 +308,9 @@ function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string
|
|||||||
world.weather
|
world.weather
|
||||||
? `${conditionIcon(world.weather.condition)} ${formatTemperature(world.weather.temperatureC)}`
|
? `${conditionIcon(world.weather.condition)} ${formatTemperature(world.weather.temperatureC)}`
|
||||||
: null,
|
: null,
|
||||||
`${(world.sizeMeters / 1000).toFixed(0)} km`,
|
t('world.sizeKm', { n: Number((world.sizeMeters / 1000).toFixed(0)) }),
|
||||||
world.stats
|
world.stats
|
||||||
? `${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`
|
? `${t('world.buildings', { n: world.stats.buildings })} · ${t('world.roads', { n: world.stats.roads })}`
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
.filter((part): part is string => part !== null)
|
.filter((part): part is string => part !== null)
|
||||||
@@ -325,8 +338,8 @@ function applyClockToControls(clock: WorldClock): void {
|
|||||||
paintGameClock();
|
paintGameClock();
|
||||||
|
|
||||||
elements.playPause.textContent = clock.paused ? '▶' : '⏸';
|
elements.playPause.textContent = clock.paused ? '▶' : '⏸';
|
||||||
elements.playPause.title = clock.paused ? 'Play' : 'Pause';
|
elements.playPause.title = clock.paused ? t('game.play') : t('game.pause');
|
||||||
elements.playPause.setAttribute('aria-label', clock.paused ? 'Play' : 'Pause');
|
elements.playPause.setAttribute('aria-label', clock.paused ? t('game.play') : t('game.pause'));
|
||||||
|
|
||||||
for (const button of elements.speedButtons) {
|
for (const button of elements.speedButtons) {
|
||||||
const scale = Number(button.dataset.scale);
|
const scale = Number(button.dataset.scale);
|
||||||
@@ -415,7 +428,7 @@ async function patchClock(body: { paused?: boolean; timeScale?: number }): Promi
|
|||||||
const clock = await api.updateClock(activeWorldId, body);
|
const clock = await api.updateClock(activeWorldId, body);
|
||||||
applyClockToControls(clock);
|
applyClockToControls(clock);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Could not update clock: ${message(error)}`, 'error');
|
setStatus(t('status.couldNotUpdateClock', { error: message(error) }), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
clockUpdating = false;
|
clockUpdating = false;
|
||||||
}
|
}
|
||||||
@@ -429,6 +442,18 @@ async function loadClimates(): Promise<void> {
|
|||||||
climateOptions = [];
|
climateOptions = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
paintClimateOptions();
|
||||||
|
paintClimateHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintClimateOptions(): void {
|
||||||
|
const selected = elements.climate.value;
|
||||||
|
const fallback = elements.climate.options[0];
|
||||||
|
if (fallback) fallback.textContent = t('form.climateFromLocation');
|
||||||
|
while (elements.climate.options.length > 1) {
|
||||||
|
elements.climate.options[1]!.remove();
|
||||||
|
}
|
||||||
|
|
||||||
for (const option of climateOptions) {
|
for (const option of climateOptions) {
|
||||||
const element = document.createElement('option');
|
const element = document.createElement('option');
|
||||||
element.value = option.kind;
|
element.value = option.kind;
|
||||||
@@ -436,8 +461,8 @@ async function loadClimates(): Promise<void> {
|
|||||||
elements.climate.append(element);
|
elements.climate.append(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elements.climate.value = selected;
|
||||||
elements.climate.disabled = climateOptions.length === 0;
|
elements.climate.disabled = climateOptions.length === 0;
|
||||||
paintClimateHint();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Explains what "From the location" will actually pick, and warns when a manual choice fights the latitude. */
|
/** Explains what "From the location" will actually pick, and warns when a manual choice fights the latitude. */
|
||||||
@@ -452,8 +477,8 @@ function paintClimateHint(): void {
|
|||||||
|
|
||||||
if (!elements.climate.value) {
|
if (!elements.climate.value) {
|
||||||
elements.climateHint.textContent = inferred
|
elements.climateHint.textContent = inferred
|
||||||
? `This location suggests ${inferred.label} (${inferred.koppenCode}).`
|
? t('climate.hintInferred', { label: climateLabel(inferred), code: inferred.koppenCode })
|
||||||
: 'Enter a location to see what the latitude suggests.';
|
: t('climate.hintNeedLocation');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,8 +489,8 @@ function paintClimateHint(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elements.climateHint.textContent = inferred && inferred.kind !== chosen.kind
|
elements.climateHint.textContent = inferred && inferred.kind !== chosen.kind
|
||||||
? `${chosen.label} — the latitude would have suggested ${inferred.label}.`
|
? t('climate.hintMismatch', { label: climateLabel(chosen), inferred: climateLabel(inferred) })
|
||||||
: `${chosen.label}, like ${chosen.example}.`;
|
: t('climate.hintChosen', { label: climateLabel(chosen), example: climateExample(chosen) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureMap(): Promise<void> {
|
async function ensureMap(): Promise<void> {
|
||||||
@@ -478,7 +503,7 @@ async function ensureMap(): Promise<void> {
|
|||||||
|
|
||||||
async function openWorld(id: string): Promise<void> {
|
async function openWorld(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setStatus('Loading map…', 'busy');
|
setStatus(t('status.loadingMap'), 'busy');
|
||||||
// Stage must be visible before Pixi init / camera.fit — a hidden host measures as 0×0 and
|
// Stage must be visible before Pixi init / camera.fit — a hidden host measures as 0×0 and
|
||||||
// leaves the canvas stuck as a thin strip.
|
// leaves the canvas stuck as a thin strip.
|
||||||
showGame();
|
showGame();
|
||||||
@@ -500,7 +525,7 @@ async function openWorld(id: string): Promise<void> {
|
|||||||
await refreshWorldList();
|
await refreshWorldList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMenu();
|
showMenu();
|
||||||
setStatus(`Could not open world: ${message(error)}`, 'error');
|
setStatus(t('status.couldNotOpen', { error: message(error) }), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,12 +540,12 @@ async function returnToMenu(): Promise<void> {
|
|||||||
await refreshWorldList();
|
await refreshWorldList();
|
||||||
setStatus('');
|
setStatus('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Could not reach the API: ${message(error)}`, 'error');
|
setStatus(t('status.couldNotReachApi', { error: message(error) }), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteWorld(world: WorldSummary): Promise<void> {
|
async function deleteWorld(world: WorldSummary): Promise<void> {
|
||||||
if (!confirm(`Delete "${world.name}"?`)) return;
|
if (!confirm(t('menu.deleteConfirm', { name: world.name }))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteWorld(world.id);
|
await api.deleteWorld(world.id);
|
||||||
@@ -538,9 +563,9 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await refreshWorldList();
|
await refreshWorldList();
|
||||||
setStatus(`Deleted ${world.name}`);
|
setStatus(t('status.deleted', { name: world.name }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Could not delete world: ${message(error)}`, 'error');
|
setStatus(t('status.couldNotDelete', { error: message(error) }), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,14 +574,14 @@ async function generate(event: SubmitEvent): Promise<void> {
|
|||||||
|
|
||||||
const location = parseCoordinates(elements.coords.value);
|
const location = parseCoordinates(elements.coords.value);
|
||||||
if (!location) {
|
if (!location) {
|
||||||
setStatus('Enter a location as latitude, longitude — for example 31.90, -100.49.', 'error');
|
setStatus(t('status.invalidCoords'), 'error');
|
||||||
elements.coords.focus();
|
elements.coords.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startGameTime = startGameTimeFromInput(elements.start.value);
|
const startGameTime = startGameTimeFromInput(elements.start.value);
|
||||||
if (!startGameTime) {
|
if (!startGameTime) {
|
||||||
setStatus('Pick a start date and time for the in-world calendar.', 'error');
|
setStatus(t('status.invalidStart'), 'error');
|
||||||
elements.start.focus();
|
elements.start.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -564,7 +589,7 @@ async function generate(event: SubmitEvent): Promise<void> {
|
|||||||
const sizeKm = Number(elements.size.value);
|
const sizeKm = Number(elements.size.value);
|
||||||
generating = true;
|
generating = true;
|
||||||
updateGenerateEnabled();
|
updateGenerateEnabled();
|
||||||
setStatus('Requesting world…', 'busy');
|
setStatus(t('status.requestingWorld'), 'busy');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await api.createWorld({
|
const created = await api.createWorld({
|
||||||
@@ -580,18 +605,21 @@ async function generate(event: SubmitEvent): Promise<void> {
|
|||||||
await refreshWorldList();
|
await refreshWorldList();
|
||||||
|
|
||||||
const finished = await waitForWorld(created.id, (summary) => {
|
const finished = await waitForWorld(created.id, (summary) => {
|
||||||
setStatus(summary.stage ? `${summary.name}: ${summary.stage}` : `${summary.name}: ${summary.status}`, 'busy');
|
setStatus(t('status.worldProgress', {
|
||||||
|
name: summary.name,
|
||||||
|
stage: summary.stage ? translateStage(summary.stage) : catalog().worldStatus[summary.status],
|
||||||
|
}), 'busy');
|
||||||
void refreshWorldList();
|
void refreshWorldList();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (finished.status === 'failed') {
|
if (finished.status === 'failed') {
|
||||||
setStatus(`Generation failed: ${finished.error ?? 'unknown error'}`, 'error');
|
setStatus(t('status.generationFailed', { error: finished.error ?? t('status.generationFailedUnknown') }), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await openWorld(finished.id);
|
await openWorld(finished.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Generation failed: ${message(error)}`, 'error');
|
setStatus(t('status.generationFailed', { error: message(error) }), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
generating = false;
|
generating = false;
|
||||||
void refreshWorldList();
|
void refreshWorldList();
|
||||||
@@ -600,18 +628,18 @@ async function generate(event: SubmitEvent): Promise<void> {
|
|||||||
|
|
||||||
function useMyLocation(): void {
|
function useMyLocation(): void {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
setStatus('Geolocation is not available in this browser.', 'error');
|
setStatus(t('status.geolocationUnavailable'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('Finding your location…', 'busy');
|
setStatus(t('status.findingLocation'), 'busy');
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
elements.coords.value = formatCoordinates(position.coords.latitude, position.coords.longitude);
|
elements.coords.value = formatCoordinates(position.coords.latitude, position.coords.longitude);
|
||||||
setStatus('Location filled in — adjust the size and generate.');
|
setStatus(t('status.locationFilled'));
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
setStatus(`Could not get location: ${error.message}`, 'error');
|
setStatus(t('status.couldNotGetLocation', { error: error.message }), 'error');
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 10_000 },
|
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 10_000 },
|
||||||
);
|
);
|
||||||
@@ -625,12 +653,12 @@ function message(error: unknown): string {
|
|||||||
function applyTheme(name: ThemeName): void {
|
function applyTheme(name: ThemeName): void {
|
||||||
if (mapReady) view.setTheme(name);
|
if (mapReady) view.setTheme(name);
|
||||||
document.documentElement.dataset.theme = name;
|
document.documentElement.dataset.theme = name;
|
||||||
const next = name === 'day' ? 'Night' : 'Day';
|
const next = name === 'day' ? t('theme.night') : t('theme.day');
|
||||||
elements.menuThemeToggle.textContent = next;
|
elements.menuThemeToggle.textContent = next;
|
||||||
elements.menuThemeToggle.title = `Switch to ${next.toLowerCase()} theme`;
|
elements.menuThemeToggle.title = t('theme.switchTo', { theme: next });
|
||||||
elements.gameThemeToggle.textContent = THEMES[name].dark ? '☀' : '☾';
|
elements.gameThemeToggle.textContent = THEMES[name].dark ? '☀' : '☾';
|
||||||
elements.gameThemeToggle.title = `Switch to ${next.toLowerCase()} theme`;
|
elements.gameThemeToggle.title = t('theme.switchTo', { theme: next });
|
||||||
elements.gameThemeToggle.setAttribute('aria-label', `Switch to ${next.toLowerCase()} theme`);
|
elements.gameThemeToggle.setAttribute('aria-label', t('theme.switchTo', { theme: next }));
|
||||||
localStorage.setItem(THEME_KEY, name);
|
localStorage.setItem(THEME_KEY, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +679,7 @@ function applyWeatherEffects(enabled: boolean): void {
|
|||||||
view.setWeatherEffectsEnabled(enabled);
|
view.setWeatherEffectsEnabled(enabled);
|
||||||
|
|
||||||
elements.weatherToggle.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
elements.weatherToggle.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
||||||
elements.weatherToggle.title = enabled ? 'Hide weather effects' : 'Show weather effects';
|
elements.weatherToggle.title = enabled ? t('game.weatherOn') : t('game.weatherOff');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme(): void {
|
function toggleTheme(): void {
|
||||||
@@ -659,10 +687,44 @@ function toggleTheme(): void {
|
|||||||
applyTheme(current === 'day' ? 'night' : 'day');
|
applyTheme(current === 'day' ? 'night' : 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paintSizeValue(): void {
|
||||||
|
elements.sizeValue.textContent = t('form.sizeValue', { n: Number(elements.size.value) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMenuStatus(): void {
|
||||||
|
if (elements.menu.hidden) return;
|
||||||
|
if (elements.status.dataset.tone === 'error' || elements.status.dataset.tone === 'busy') return;
|
||||||
|
if (worldCount === 0) setStatus(t('status.pickPlace'));
|
||||||
|
else if (continueWorldId) setStatus(t('status.continueHint'));
|
||||||
|
else setStatus(t('status.chooseWorld'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocaleChanged(): void {
|
||||||
|
applyDomTranslations();
|
||||||
|
paintLocaleSwitch(elements.localeSwitch);
|
||||||
|
paintSizeValue();
|
||||||
|
paintClimateOptions();
|
||||||
|
paintClimateHint();
|
||||||
|
updateGenerateEnabled();
|
||||||
|
elements.worldList.replaceChildren(...sortWorlds(listedWorlds).map(renderWorldItem));
|
||||||
|
updateContinue(sortWorlds(listedWorlds));
|
||||||
|
paintMenuStatus();
|
||||||
|
|
||||||
|
applyTheme((document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme());
|
||||||
|
applyWeatherEffects(readWeatherEffects());
|
||||||
|
if (gameClock) applyClockToControls(gameClock);
|
||||||
|
applyWeatherToControls(gameWeather ?? undefined);
|
||||||
|
if (lastMapStatus) renderHud(lastMapStatus);
|
||||||
|
}
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
elements.size.addEventListener('input', () => {
|
initI18n();
|
||||||
elements.sizeValue.textContent = `${elements.size.value} km`;
|
mountLocaleSwitch(elements.localeSwitch);
|
||||||
});
|
applyDomTranslations();
|
||||||
|
subscribe(onLocaleChanged);
|
||||||
|
|
||||||
|
elements.size.addEventListener('input', paintSizeValue);
|
||||||
|
paintSizeValue();
|
||||||
elements.form.addEventListener('submit', (event) => {
|
elements.form.addEventListener('submit', (event) => {
|
||||||
void generate(event);
|
void generate(event);
|
||||||
});
|
});
|
||||||
@@ -699,11 +761,11 @@ async function start(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const worlds = await refreshWorldList();
|
const worlds = await refreshWorldList();
|
||||||
if (worlds.length === 0) setStatus('Pick a place and generate a world to get started.');
|
if (worlds.length === 0) setStatus(t('status.pickPlace'));
|
||||||
else if (continueWorldId) setStatus('Continue where you left off, or open another world.');
|
else if (continueWorldId) setStatus(t('status.continueHint'));
|
||||||
else setStatus('Choose a world or create a new one.');
|
else setStatus(t('status.chooseWorld'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Could not reach the API: ${message(error)}`, 'error');
|
setStatus(t('status.couldNotReachApi', { error: message(error) }), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,44 @@ body {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-button[aria-pressed='true'] {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.menu__header h1 {
|
.menu__header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import type { ClimateOption } from '../api/types';
|
import type { ClimateOption } from '../api/types';
|
||||||
|
import { setLocale } from '../i18n';
|
||||||
import { climateFromLatitude, describeClimate, findClimate } from './climate';
|
import { climateFromLatitude, describeClimate, findClimate } from './climate';
|
||||||
|
|
||||||
/** Shaped like the real GET /api/climates payload: equator first, unbanded presets mixed in. */
|
/** Shaped like the real GET /api/climates payload: equator first, unbanded presets mixed in. */
|
||||||
@@ -18,6 +19,10 @@ const OPTIONS: ClimateOption[] = [
|
|||||||
{ kind: 'highland', label: 'Highland', koppenCode: 'H', example: 'La Paz' },
|
{ kind: 'highland', label: 'Highland', koppenCode: 'H', example: 'La Paz' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLocale('en');
|
||||||
|
});
|
||||||
|
|
||||||
describe('climateFromLatitude', () => {
|
describe('climateFromLatitude', () => {
|
||||||
it('reproduces the server bands', () => {
|
it('reproduces the server bands', () => {
|
||||||
expect(climateFromLatitude(OPTIONS, 1.35)).toBe('equatorial');
|
expect(climateFromLatitude(OPTIONS, 1.35)).toBe('equatorial');
|
||||||
@@ -62,6 +67,11 @@ describe('describeClimate', () => {
|
|||||||
it('names the preset, its code and a place it feels like', () => {
|
it('names the preset, its code and a place it feels like', () => {
|
||||||
expect(describeClimate(OPTIONS[8]!)).toBe('Central European (Dfb) · like Warsaw');
|
expect(describeClimate(OPTIONS[8]!)).toBe('Central European (Dfb) · like Warsaw');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates the preset and the example city', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
expect(describeClimate(OPTIONS[8]!)).toBe('Центральноевропейский (Dfb) · как Варшава');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findClimate', () => {
|
describe('findClimate', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ClimateKind, ClimateOption } from '../api/types';
|
import type { ClimateKind, ClimateOption } from '../api/types';
|
||||||
|
import { catalog, t } from '../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reproduces the server's latitude guess from the band limits it sent us, so the create form can preview the
|
* Reproduces the server's latitude guess from the band limits it sent us, so the create form can preview the
|
||||||
@@ -23,9 +24,21 @@ export function climateFromLatitude(
|
|||||||
return banded[banded.length - 1]!.kind;
|
return banded[banded.length - 1]!.kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function climateLabel(option: ClimateOption): string {
|
||||||
|
return catalog().climate.kind[option.kind] ?? option.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function climateExample(option: ClimateOption): string {
|
||||||
|
return catalog().climate.example[option.kind] ?? option.example;
|
||||||
|
}
|
||||||
|
|
||||||
/** `Central European (Dfb) · like Warsaw` — enough to pick from without reading a table of numbers. */
|
/** `Central European (Dfb) · like Warsaw` — enough to pick from without reading a table of numbers. */
|
||||||
export function describeClimate(option: ClimateOption): string {
|
export function describeClimate(option: ClimateOption): string {
|
||||||
return `${option.label} (${option.koppenCode}) · like ${option.example}`;
|
return t('climate.describe', {
|
||||||
|
label: climateLabel(option),
|
||||||
|
code: option.koppenCode,
|
||||||
|
example: climateExample(option),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findClimate(
|
export function findClimate(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { setLocale } from '../i18n';
|
||||||
import {
|
import {
|
||||||
formatGameTime,
|
formatGameTime,
|
||||||
formatGameTimeRaw,
|
formatGameTimeRaw,
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
DEFAULT_START_INPUT,
|
DEFAULT_START_INPUT,
|
||||||
} from './gameTime';
|
} from './gameTime';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLocale('en');
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseGameTime', () => {
|
describe('parseGameTime', () => {
|
||||||
it('reads a naive ISO local datetime', () => {
|
it('reads a naive ISO local datetime', () => {
|
||||||
const date = parseGameTime('2012-04-12T06:00:00');
|
const date = parseGameTime('2012-04-12T06:00:00');
|
||||||
@@ -58,6 +63,14 @@ describe('formatGameTime', () => {
|
|||||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
|
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats in Russian with genitive months', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
const date = new Date(2012, 3, 12, 6, 0, 0);
|
||||||
|
expect(formatGameTime(date)).toBe('12 апреля 2012 · 06:00');
|
||||||
|
expect(formatGameTime(date, { weekday: true })).toBe('чт 12 апреля 2012 · 06:00');
|
||||||
|
expect(formatWeekday(date)).toBe('четверг');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interpolateGameTime', () => {
|
describe('interpolateGameTime', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WorldClock } from '../api/types';
|
import type { WorldClock } from '../api/types';
|
||||||
|
import { catalog, t } from '../i18n';
|
||||||
|
|
||||||
/** Matches server GameTime: five game minutes per real second at x1. */
|
/** Matches server GameTime: five game minutes per real second at x1. */
|
||||||
export const GAME_MINUTES_PER_REAL_SECOND = 5;
|
export const GAME_MINUTES_PER_REAL_SECOND = 5;
|
||||||
@@ -7,18 +8,6 @@ export const GAME_MINUTES_PER_REAL_SECOND = 5;
|
|||||||
export const MIN_TIME_SCALE = 1;
|
export const MIN_TIME_SCALE = 1;
|
||||||
export const MAX_TIME_SCALE = 4;
|
export const MAX_TIME_SCALE = 4;
|
||||||
|
|
||||||
const MONTHS = [
|
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
|
||||||
'July', 'August', 'September', 'October', 'November', 'December',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Indexed by `Date.getDay()`, which counts from Sunday. */
|
|
||||||
const WEEKDAYS = [
|
|
||||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const SHORT_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a naive game datetime from the API (`2012-04-12T06:00:00` or with fractional seconds).
|
* Parses a naive game datetime from the API (`2012-04-12T06:00:00` or with fractional seconds).
|
||||||
* Treats the value as a local calendar instant, not UTC.
|
* Treats the value as a local calendar instant, not UTC.
|
||||||
@@ -55,18 +44,22 @@ export function parseGameTime(raw: string): Date | null {
|
|||||||
* the world list, so it is opt-in rather than always on.
|
* the world list, so it is opt-in rather than always on.
|
||||||
*/
|
*/
|
||||||
export function formatGameTime(date: Date, options?: { weekday?: boolean }): string {
|
export function formatGameTime(date: Date, options?: { weekday?: boolean }): string {
|
||||||
const day = date.getDate();
|
const time = catalog().time;
|
||||||
const month = MONTHS[date.getMonth()]!;
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
const prefix = options?.weekday ? `${SHORT_WEEKDAYS[date.getDay()]!} ` : '';
|
const params = {
|
||||||
return `${prefix}${day} ${month} ${year} · ${hours}:${minutes}`;
|
day: date.getDate(),
|
||||||
|
month: time.months[date.getMonth()]!,
|
||||||
|
year: String(date.getFullYear()),
|
||||||
|
time: `${hours}:${minutes}`,
|
||||||
|
weekday: time.shortWeekdays[date.getDay()]!,
|
||||||
|
};
|
||||||
|
return options?.weekday ? t('time.formatWithWeekday', params) : t('time.format', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full weekday name, for the tooltip where there is room for it. */
|
/** Full weekday name, for the tooltip where there is room for it. */
|
||||||
export function formatWeekday(date: Date): string {
|
export function formatWeekday(date: Date): string {
|
||||||
return WEEKDAYS[date.getDay()]!;
|
return catalog().time.weekdays[date.getDay()]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatGameTimeRaw(raw: string, options?: { weekday?: boolean }): string {
|
export function formatGameTimeRaw(raw: string, options?: { weekday?: boolean }): string {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { setLocale } from '../i18n';
|
||||||
import type { Weather } from '../api/types';
|
import type { Weather } from '../api/types';
|
||||||
import {
|
import {
|
||||||
conditionIcon,
|
conditionIcon,
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
windCompass,
|
windCompass,
|
||||||
} from './weather';
|
} from './weather';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLocale('en');
|
||||||
|
});
|
||||||
|
|
||||||
function weather(overrides: Partial<Weather> = {}): Weather {
|
function weather(overrides: Partial<Weather> = {}): Weather {
|
||||||
return {
|
return {
|
||||||
condition: 'cloudy',
|
condition: 'cloudy',
|
||||||
@@ -89,6 +94,13 @@ describe('formatWeather', () => {
|
|||||||
it('reports the wind as a compass bearing and a speed', () => {
|
it('reports the wind as a compass bearing and a speed', () => {
|
||||||
expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('W 5 m/s');
|
expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('W 5 m/s');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads condition and compass names from the active locale', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
expect(conditionLabel('cloudy')).toBe('Облачно');
|
||||||
|
expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('З 5 м/с');
|
||||||
|
expect(formatWeather(weather({ temperatureC: -6, feelsLikeC: -14 }))).toContain('ощущается -14 °C');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('describeWeather', () => {
|
describe('describeWeather', () => {
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import type { Weather, WeatherCondition } from '../api/types';
|
import type { Weather, WeatherCondition } from '../api/types';
|
||||||
|
import { catalog, t } from '../i18n';
|
||||||
const CONDITION_LABELS: Record<WeatherCondition, string> = {
|
|
||||||
clear: 'Clear',
|
|
||||||
fewClouds: 'Few clouds',
|
|
||||||
cloudy: 'Cloudy',
|
|
||||||
overcast: 'Overcast',
|
|
||||||
fog: 'Fog',
|
|
||||||
drizzle: 'Drizzle',
|
|
||||||
rain: 'Rain',
|
|
||||||
heavyRain: 'Heavy rain',
|
|
||||||
thunderstorm: 'Thunderstorm',
|
|
||||||
sleet: 'Sleet',
|
|
||||||
snow: 'Snow',
|
|
||||||
heavySnow: 'Heavy snow',
|
|
||||||
blizzard: 'Blizzard',
|
|
||||||
sandstorm: 'Sandstorm',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONDITION_ICONS: Record<WeatherCondition, string> = {
|
const CONDITION_ICONS: Record<WeatherCondition, string> = {
|
||||||
clear: '☀',
|
clear: '☀',
|
||||||
@@ -34,13 +18,8 @@ const CONDITION_ICONS: Record<WeatherCondition, string> = {
|
|||||||
sandstorm: '🌪',
|
sandstorm: '🌪',
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPASS = [
|
|
||||||
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
|
||||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function conditionLabel(condition: WeatherCondition): string {
|
export function conditionLabel(condition: WeatherCondition): string {
|
||||||
return CONDITION_LABELS[condition] ?? condition;
|
return catalog().weather.condition[condition] ?? condition;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function conditionIcon(condition: WeatherCondition): string {
|
export function conditionIcon(condition: WeatherCondition): string {
|
||||||
@@ -58,11 +37,14 @@ export function formatTemperature(celsius: number): string {
|
|||||||
export function windCompass(degrees: number): string {
|
export function windCompass(degrees: number): string {
|
||||||
if (!Number.isFinite(degrees)) return '—';
|
if (!Number.isFinite(degrees)) return '—';
|
||||||
const normalised = ((degrees % 360) + 360) % 360;
|
const normalised = ((degrees % 360) + 360) % 360;
|
||||||
return COMPASS[Math.round(normalised / 22.5) % 16]!;
|
return catalog().weather.compass[Math.round(normalised / 22.5) % 16]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatWind(weather: Weather): string {
|
export function formatWind(weather: Weather): string {
|
||||||
return `${windCompass(weather.windDirectionDeg)} ${Math.round(weather.windSpeedMs)} m/s`;
|
return t('weather.windSpeed', {
|
||||||
|
dir: windCompass(weather.windDirectionDeg),
|
||||||
|
n: Math.round(weather.windSpeedMs),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +56,7 @@ export function formatWeather(weather: Weather): string {
|
|||||||
const parts = [`${icon} ${conditionLabel(weather.condition)}`, formatTemperature(weather.temperatureC)];
|
const parts = [`${icon} ${conditionLabel(weather.condition)}`, formatTemperature(weather.temperatureC)];
|
||||||
|
|
||||||
if (Math.abs(weather.feelsLikeC - weather.temperatureC) >= 1.5) {
|
if (Math.abs(weather.feelsLikeC - weather.temperatureC) >= 1.5) {
|
||||||
parts.push(`feels ${formatTemperature(weather.feelsLikeC)}`);
|
parts.push(t('weather.feels', { temp: formatTemperature(weather.feelsLikeC) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(formatWind(weather));
|
parts.push(formatWind(weather));
|
||||||
@@ -85,12 +67,14 @@ export function formatWeather(weather: Weather): string {
|
|||||||
export function describeWeather(weather: Weather): string {
|
export function describeWeather(weather: Weather): string {
|
||||||
return [
|
return [
|
||||||
`${conditionLabel(weather.condition)} ${formatTemperature(weather.temperatureC)}`,
|
`${conditionLabel(weather.condition)} ${formatTemperature(weather.temperatureC)}`,
|
||||||
`Feels like ${formatTemperature(weather.feelsLikeC)}`,
|
t('weather.feelsLike', { temp: formatTemperature(weather.feelsLikeC) }),
|
||||||
`Wind ${formatWind(weather)}`,
|
t('weather.wind', { wind: formatWind(weather) }),
|
||||||
`Humidity ${Math.round(weather.humidity * 100)}%`,
|
t('weather.humidity', { n: Math.round(weather.humidity * 100) }),
|
||||||
`Cloud ${Math.round(weather.cloudCover * 100)}%`,
|
t('weather.cloud', { n: Math.round(weather.cloudCover * 100) }),
|
||||||
`Pressure ${Math.round(weather.pressureHpa)} hPa`,
|
t('weather.pressure', { n: Math.round(weather.pressureHpa) }),
|
||||||
weather.precipitationMmH > 0 ? `Precipitation ${weather.precipitationMmH.toFixed(1)} mm/h` : null,
|
weather.precipitationMmH > 0
|
||||||
|
? t('weather.precipitation', { n: weather.precipitationMmH.toFixed(1) })
|
||||||
|
: null,
|
||||||
]
|
]
|
||||||
.filter((line): line is string => line !== null)
|
.filter((line): line is string => line !== null)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|||||||
Reference in New Issue
Block a user