-
+
{snapshot.stations.map((item) => (
onSelect(item.id)}
>
- {item.name}
+ {t(item.name)}
))}
- {station.name}
+ {t(station.name)}
- {definition &&
{definition.description}
}
+ {definition &&
{t(definition.description)}
}
-
{meta.hint}
+
{t(meta.hint)}
-
Активные инциденты
+
{t("Активные инциденты")}
{openIncidents.length === 0 ? (
-
Активных инцидентов по станции нет.
+
{t("Активных инцидентов по станции нет.")}
) : (
{openIncidents.map((item) => (
- {SEVERITY_LABEL[item.severity]}
+ {t(SEVERITY_LABEL[item.severity])}
- {item.title}
- {item.description}
+ {t(item.title)}
+ {t(item.description)}
))}
@@ -100,36 +101,36 @@ export function StationDetails({ snapshot, selected, onSelect }: StationDetailsP
-
-
+
+
0 ? 'warn' : 'default'}
- note='Только остановки оборудования'
+ note={t("Только остановки оборудования")}
/>
diff --git a/apps/web/src/components/StatusBadge.tsx b/apps/web/src/components/StatusBadge.tsx
index 16c7e9d..1ba6dfd 100644
--- a/apps/web/src/components/StatusBadge.tsx
+++ b/apps/web/src/components/StatusBadge.tsx
@@ -1,3 +1,4 @@
+import { t } from '../i18n';
import { STATUS_META } from '../status';
import type { StationStatus } from '../types';
import { StatusGlyph } from './StatusGlyph';
@@ -6,7 +7,7 @@ export function StatusBadge({ status }: { status: StationStatus }) {
return (
- {STATUS_META[status].label}
+ {t(STATUS_META[status].label)}
);
}
diff --git a/apps/web/src/components/TopBar.tsx b/apps/web/src/components/TopBar.tsx
index 17cfb8e..3d15847 100644
--- a/apps/web/src/components/TopBar.tsx
+++ b/apps/web/src/components/TopBar.tsx
@@ -1,6 +1,8 @@
-import { Monitor, Wifi, WifiOff } from 'lucide-react';
+import { t } from '../i18n';
+import { Monitor, Moon, Sun, Wifi, WifiOff } from 'lucide-react';
import type { ConnectionState } from '../useSession';
import { BROWSER_MODE } from '../runtimeMode';
+import { setPreferences, usePreferences } from '../preferences';
const CONNECTION_LABEL: Record
= {
connecting: 'Подключение…',
@@ -14,18 +16,26 @@ interface TopBarProps {
}
export function TopBar({ connection, sessionId }: TopBarProps) {
+ const { language, theme } = usePreferences();
const Icon = BROWSER_MODE ? Monitor : connection === 'lost' ? WifiOff : Wifi;
return (
- Allur / Октябрь 2026
+ {t("Allur / Октябрь 2026")}
+
+ {([{ id: 'ru', label: 'RU', name: 'Русский' }, { id: 'kk', label: 'KZ', name: 'Қазақша' }, { id: 'en', label: 'ENG', name: 'English' }] as const).map(item =>
+ setPreferences({ language: item.id })}>{t(item.label)} )}
+
+
setPreferences({ theme: theme === 'light' ? 'dark' : 'light' })}>
+ {theme === 'light' ? : }
+
- {BROWSER_MODE && connection === 'online' ? 'Локальный режим' : CONNECTION_LABEL[connection]}
- {sessionId && Сессия {sessionId.slice(0, 8)} }
+ {t(BROWSER_MODE && connection === 'online' ? 'Локальный режим' : CONNECTION_LABEL[connection])}
+ {sessionId && {t("Сессия ")}{t(sessionId.slice(0, 8))} }
diff --git a/apps/web/src/components/interface.test.ts b/apps/web/src/components/interface.test.ts
new file mode 100644
index 0000000..10ba9f1
--- /dev/null
+++ b/apps/web/src/components/interface.test.ts
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, createElement as h, Fragment, useState } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { TopBar } from './TopBar';
+import { ControlBar } from './ControlBar';
+import { CasePanel } from './CasePanel';
+import { AiWorkspace } from './AiWorkspace';
+import { getPreferences, PREFERENCES_KEY, setPreferences, usePreferences } from '../preferences';
+import { createEngine, getSnapshot } from '@driveindui/simulation';
+import { createCaseConfig, type SessionSnapshot } from '@driveindui/shared';
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+let host: HTMLDivElement, root: Root;
+const snapshot = (): SessionSnapshot => ({ ...getSnapshot(createEngine({ config: createCaseConfig() })), sessionId: 'ui-test', revision: 0, running: false, speed: 1, updatedAt: '2026-10-07T00:00:00.000Z' });
+const click = async (element: Element | null) => { expect(element).not.toBeNull(); await act(async () => { (element as HTMLElement).click(); }); };
+const byText = (text: string) => [...document.querySelectorAll('button')].find(button => button.textContent === text) ?? null;
+beforeEach(() => { localStorage.clear(); setPreferences({ language: 'ru', theme: 'light' }); host = document.createElement('div'); document.body.append(host); root = createRoot(host); });
+afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); });
+
+describe('interface interactions', () => {
+ it('switches and persists language/theme without resetting mounted shift state', async () => {
+ function Harness() {
+ usePreferences(); const [count, setCount] = useState(0);
+ return h(Fragment, null, h(TopBar, { connection: 'online', sessionId: 'same-shift' }), h('button', { onClick: () => setCount(count + 1), 'data-count': count }, 'Count'), h(ControlBar, { snapshot: snapshot(), pending: false, offline: false, error: null, onCommand: vi.fn(), onDismissError: vi.fn() }));
+ }
+ await act(async () => root.render(h(Harness)));
+ await click(byText('Count')); await click(byText('ENG'));
+ expect(document.documentElement.lang).toBe('en'); expect(host.querySelector('.session-play')?.textContent).toBe('Start');
+ expect(host.querySelector('[data-count]')?.getAttribute('data-count')).toBe('1');
+ await click(host.querySelector('[aria-label="Use dark theme"]'));
+ expect(document.documentElement.dataset.theme).toBe('dark');
+ expect(JSON.parse(localStorage.getItem(PREFERENCES_KEY)!)).toEqual({ language: 'en', theme: 'dark' });
+ await click(byText('KZ')); expect(document.documentElement.lang).toBe('kk'); expect(host.querySelector('.session-play')?.textContent).toBe('Бастау');
+ await click(byText('RU')); expect(host.textContent).toContain('Сценарий');
+ expect(host.querySelector('[data-count]')?.getAttribute('data-count')).toBe('1');
+ });
+ it('opens timer help above the page, closes on Escape/outside and preserves playback controls', async () => {
+ const onCommand = vi.fn();
+ await act(async () => root.render(h(ControlBar, { snapshot: snapshot(), pending: false, offline: false, error: null, onCommand, onDismissError: vi.fn() })));
+ const info = host.querySelector('[aria-label="Информация о смене"]')!;
+ await click(info); const dialog = document.querySelector('[role="dialog"]')!;
+ expect(host.contains(dialog)).toBe(false); expect(dialog.textContent).toContain('План и скорость сохраняются.');
+ expect(info.getAttribute('aria-expanded')).toBe('true'); expect(dialog.contains(document.activeElement)).toBe(true);
+ await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
+ expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(document.activeElement).toBe(info);
+ await click(info); await act(async () => document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })));
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
+ await click(host.querySelector('[aria-label="Запустить смену"]')); expect(onCommand).toHaveBeenCalledWith({ action: 'play' });
+ });
+ it.each(['ru', 'kk', 'en'] as const)('renders the original-data note in %s and keeps plan editing usable', async language => {
+ setPreferences({ language }); const onCommand = vi.fn();
+ await act(async () => root.render(h(CasePanel, { snapshot: snapshot(), disabled: false, onCommand, view: 'plan' })));
+ expect(host.querySelector('.case-source-note')?.textContent).toContain('700');
+ expect(host.textContent).not.toContain('План действует в симуляции');
+ expect(host.querySelectorAll('input[type="number"]')).toHaveLength(5);
+ await click(host.querySelector('.case-actions .btn--primary'));
+ expect(onCommand).toHaveBeenCalledWith(expect.objectContaining({ action: 'setConfiguration' }));
+ });
+ it('prompts for the AI code on an enabled analysis button, without sending a paid request', async () => {
+ setPreferences({ language: 'en' }); const fetch = vi.spyOn(globalThis, 'fetch');
+ await act(async () => root.render(h(AiWorkspace, { snapshot: snapshot(), active: false, disabled: false, onStation: vi.fn() })));
+ const analyse = byText('Analyse risks') as HTMLButtonElement; expect(analyse.disabled).toBe(false); await click(analyse);
+ expect(host.textContent).toContain('Enter the access code');
+ expect(host.querySelector('input[type="password"]')).toBe(document.activeElement);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+ it('keeps controls usable if preference storage is unavailable', async () => {
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Private mode'); });
+ await act(async () => root.render(h(TopBar, { connection: 'online', sessionId: null })));
+ await click(byText('ENG')); await click(host.querySelector('[aria-label="Use dark theme"]'));
+ expect(getPreferences()).toEqual({ language: 'en', theme: 'dark' });
+ });
+});
diff --git a/apps/web/src/data-workspace.css b/apps/web/src/data-workspace.css
index f1ea068..c5f0876 100644
--- a/apps/web/src/data-workspace.css
+++ b/apps/web/src/data-workspace.css
@@ -3,23 +3,23 @@
.data-workspace__tabs { display: flex; width: fit-content; margin: 14px 0; max-width: 100%; }
.data-workspace__tabs button { white-space: normal; }
.data-workspace__source { font-size: 14px; line-height: 1.7; margin: 16px 0; overflow-wrap: anywhere; }
-.data-workspace__details { border-top: 1px solid #e8e4d9; padding-top: 12px; }
+.data-workspace__details { border-top: 1px solid light-dark(#e8e4d9, #3c5142); padding-top: 12px; }
.data-workspace__details > summary { cursor: pointer; font-weight: 650; padding: 7px 0; }
.data-workspace__actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
.data-workspace__actions .btn { height: auto; min-height: 38px; white-space: normal; text-align: left; }
-.data-workspace__hint { color: #6b7372; font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
+.data-workspace__hint { color: light-dark(#6b7372, #b6c8b5); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
.data-workspace__summary h3 { margin: 18px 0 6px; font-size: 15px; overflow-wrap: anywhere; }
-.data-workspace__summary > p { font-size: 13px; color: #6b7372; overflow-wrap: anywhere; }
+.data-workspace__summary > p { font-size: 13px; color: light-dark(#6b7372, #b6c8b5); overflow-wrap: anywhere; }
.data-workspace__metrics { display: flex; flex-wrap: wrap; gap: 16px 28px; margin: 16px 0; }
-.data-workspace__metrics dt { font-size: 12px; color: #6b7372; margin-bottom: 6px; }
+.data-workspace__metrics dt { font-size: 12px; color: light-dark(#6b7372, #b6c8b5); margin-bottom: 6px; }
.data-workspace__metrics dd { margin: 0; font-size: 16px; font-weight: 650; }
.data-workspace__table { overflow-x: auto; }
.data-workspace__table table { border-collapse: collapse; width: 100%; font-size: 13px; }
-.data-workspace__table td, .data-workspace__table th { padding: 8px 12px; border-bottom: 1px solid #e8e4d9; text-align: left; }
-.data-workspace__table th { color: #6b7372; font-weight: 500; }
-.data-workspace__preview { padding: 4px 18px 8px; margin-top: 16px; border: 1px solid #b9cfc4; background: #f1f6f2; border-radius: 10px; }
+.data-workspace__table td, .data-workspace__table th { padding: 8px 12px; border-bottom: 1px solid light-dark(#e8e4d9, #3c5142); text-align: left; }
+.data-workspace__table th { color: light-dark(#6b7372, #b6c8b5); font-weight: 500; }
+.data-workspace__preview { padding: 4px 18px 8px; margin-top: 16px; border: 1px solid light-dark(#b9cfc4, #3c5142); background: light-dark(#f1f6f2, #1b2b25); border-radius: 10px; }
.data-workspace__preview > p { font-size: 13px; line-height: 1.6; }
-.data-workspace__message { color: #256352; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
+.data-workspace__message { color: light-dark(#256352, #a0d8b1); font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
.data-workspace__errors { display: block; margin-top: 16px; font-size: 13px; overflow-wrap: anywhere; }
.data-workspace__errors ul { max-height: 220px; overflow-y: auto; padding-left: 20px; }
@media (max-width: 600px) {
diff --git a/apps/web/src/factory3d.css b/apps/web/src/factory3d.css
index b1de0ed..56ccf33 100644
--- a/apps/web/src/factory3d.css
+++ b/apps/web/src/factory3d.css
@@ -1,72 +1,72 @@
-.plant-view { --plant-ink: #24483f; border-top: 1px solid #e2e7df; }
+.plant-view { --plant-ink: #24483f; border-top: 1px solid light-dark(#e2e7df, #3c5142); }
.plant-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 20px; }
.plant-switch, .plant-camera { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
-.plant-switch { padding: 3px; background: #edf0e9; border-radius: 10px; }
-.plant-view button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; font: inherit; font-size: 12px; color: var(--plant-ink); border: 1px solid #d4ded5; background: #fffef9; border-radius: 7px; padding: 8px 11px; cursor: pointer; }
-.plant-view button:focus-visible, .plant-view select:focus-visible, .plant-layout summary:focus-visible { outline: 3px solid #d68a32; outline-offset: 3px; }
-.plant-view button:hover:not(:disabled) { background: #e4eee6; }
+.plant-switch { padding: 3px; background: light-dark(#edf0e9, #21352c); border-radius: 10px; }
+.plant-view button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; font: inherit; font-size: 12px; color: var(--plant-ink); border: 1px solid light-dark(#d4ded5, #3c5142); background: light-dark(#fffef9, #1b2b25); border-radius: 7px; padding: 8px 11px; cursor: pointer; }
+.plant-view button:focus-visible, .plant-view select:focus-visible, .plant-layout summary:focus-visible { outline: 3px solid light-dark(#d68a32, #8d594a); outline-offset: 3px; }
+.plant-view button:hover:not(:disabled) { background: light-dark(#e4eee6, #21352c); }
.plant-view button:disabled { cursor: not-allowed; opacity: .45; }
.plant-switch button { background: transparent; border-color: transparent; font-weight: 650; }
-.plant-switch button[aria-pressed=true] { color: #fff; background: #245f52; box-shadow: 0 2px 5px #1b49341c; }
-.plant-viewport { height: 650px; position: relative; overflow: hidden; isolation: isolate; background: #d8e3e2; }
+.plant-switch button[aria-pressed=true] { color: light-dark(#fff, #fff); background: light-dark(#245f52, #14392e); box-shadow: 0 2px 5px #1b49341c; }
+.plant-viewport { height: 650px; position: relative; overflow: hidden; isolation: isolate; background: light-dark(#d8e3e2, #21352c); }
.plant-viewport canvas { touch-action: none; }
.plant-label-layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.plant-label-anchor { position: absolute; left: 0; top: 0; visibility: hidden; }
.plant-label-anchor button { pointer-events: auto; }
-.plant-overlay { pointer-events: none; position: absolute; top: 20px; left: 24px; display: grid; gap: 4px; color: #35584d; padding: 12px 15px; border-radius: 10px; background: #f9fcf2d9; border: 1px solid #d6e1d5; z-index: 10; backdrop-filter: blur(6px); }
+.plant-overlay { pointer-events: none; position: absolute; top: 20px; left: 24px; display: grid; gap: 4px; color: light-dark(#35584d, #e0eade); padding: 12px 15px; border-radius: 10px; background: light-dark(#f9fcf2d9, #1b2b25d9); border: 1px solid light-dark(#d6e1d5, #3c5142); z-index: 10; backdrop-filter: blur(6px); }
.plant-overlay span { font-size: 9px; letter-spacing: .12em; font-weight: 700; }
.plant-overlay strong { font-size: 19px; font-weight: 650; }
-.plant-overlay small { font-size: 10px; color: #6c7c73; }
-.plant-loading, .plant-error { height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; color: #42695b; font-size: 13px; padding: 30px; text-align: center; }
+.plant-overlay small { font-size: 10px; color: light-dark(#6c7c73, #b6c8b5); }
+.plant-loading, .plant-error { height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; color: light-dark(#42695b, #a0d8b1); font-size: 13px; padding: 30px; text-align: center; }
.plant-loading strong { font-size: 18px; }
-.plant-view .plant-label { display: grid; gap: 3px; white-space: nowrap; box-shadow: 0 3px 14px #233c3420; padding: 5px 8px; font-size: 11px; background: #fffef5ed; border-color: #aebfb8; }
+.plant-view .plant-label { display: grid; gap: 3px; white-space: nowrap; box-shadow: 0 3px 14px #233c3420; padding: 5px 8px; font-size: 11px; background: light-dark(#fffef5ed, #1b2b25ed); border-color: light-dark(#aebfb8, #3c5142); }
.plant-label small:last-child { display: none; }
.plant-label.is-selected small:last-child { display: block; }
-.plant-model-key { position: absolute; bottom: 14px; left: 20px; display: flex; gap: 12px; flex-wrap: wrap; padding: 9px 12px; border: 1px solid #b6cac2; background: #f8fcf1e8; border-radius: 8px; font-size: 10px; color: #35544a; pointer-events: none; }
+.plant-model-key { position: absolute; bottom: 14px; left: 20px; display: flex; gap: 12px; flex-wrap: wrap; padding: 9px 12px; border: 1px solid light-dark(#b6cac2, #3c5142); background: light-dark(#f8fcf1e8, #1b2b25e8); border-radius: 8px; font-size: 10px; color: light-dark(#35544a, #e0eade); pointer-events: none; }
.plant-model-key span { display: inline-flex; align-items: center; gap: 5px; }
.plant-model-key i { width: 8px; height: 8px; border-radius: 2px; }
-.plant-view .plant-label.is-selected { border: 2px solid #266c5b; background: #f4fff7; }
+.plant-view .plant-label.is-selected { border: 2px solid light-dark(#266c5b, #567e60); background: light-dark(#f4fff7, #1b2b25); }
.plant-label span { display: flex; align-items: center; gap: 5px; font-weight: 700; }
.plant-label i, .plant-station-dot { flex-shrink: 0; display: inline-block; width: 7px; height: 7px; border-radius: 50%; }
-.plant-label small { font-size: 9px; color: #5d7369; }
-.plant-terminal { background: #f8faeeed; padding: 6px 9px; border: 1px solid #bfcdbd; border-radius: 7px; white-space: nowrap; font-size: 11px; font-weight: 700; color: #40624e; text-align: center; pointer-events: none; }
+.plant-label small { font-size: 9px; color: light-dark(#5d7369, #b6c8b5); }
+.plant-terminal { background: light-dark(#f8faeeed, #1b2b25ed); padding: 6px 9px; border: 1px solid light-dark(#bfcdbd, #3c5142); border-radius: 7px; white-space: nowrap; font-size: 11px; font-weight: 700; color: light-dark(#40624e, #b6c8b5); text-align: center; pointer-events: none; }
.plant-terminal small { display: block; font-size: 9px; margin-top: 3px; font-weight: 400; }
-.plant-overflow { font-size: 12px; border-radius: 4px; padding: 3px; background: #fff0c8; color: #754710; }
-.plant-bottom { display: flex; gap: 10px; flex-wrap: wrap; justify-content: space-between; align-items: center; padding: 10px 20px; background: #f1f4ec; border-top: 1px solid #dbe4d9; }
-.plant-bottom p { margin: 0; font-size: 11px; color: #617268; }
-.plant-bottom label { display: flex; align-items: center; gap: 8px; font-size: 11px; color: #526c5d; }
-.plant-bottom select { padding: 5px 8px; border-radius: 6px; border: 1px solid #cbd7c9; color: #355544; background: #fffff9; font: inherit; }
+.plant-overflow { font-size: 12px; border-radius: 4px; padding: 3px; background: light-dark(#fff0c8, #284133); color: light-dark(#754710, #edaa95); }
+.plant-bottom { display: flex; gap: 10px; flex-wrap: wrap; justify-content: space-between; align-items: center; padding: 10px 20px; background: light-dark(#f1f4ec, #1b2b25); border-top: 1px solid light-dark(#dbe4d9, #3c5142); }
+.plant-bottom p { margin: 0; font-size: 11px; color: light-dark(#617268, #b6c8b5); }
+.plant-bottom label { display: flex; align-items: center; gap: 8px; font-size: 11px; color: light-dark(#526c5d, #b6c8b5); }
+.plant-bottom select { padding: 5px 8px; border-radius: 6px; border: 1px solid light-dark(#cbd7c9, #3c5142); color: light-dark(#355544, #e0eade); background: light-dark(#fffff9, #1b2b25); font: inherit; }
.plant-stations { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 8px; padding: 12px 20px; }
.plant-view .plant-stations button { justify-content: flex-start; gap: 9px; text-align: left; padding: 10px 12px; }
-.plant-view .plant-stations button[aria-pressed=true] { background: #edf7ed; border-color: #598a70; }
+.plant-view .plant-stations button[aria-pressed=true] { background: light-dark(#edf7ed, #1b2b25); border-color: light-dark(#598a70, #567e60); }
.plant-stations strong { display: block; font-size: 12px; }
-.plant-stations small { display: block; font-size: 10px; color: #68796d; margin-top: 4px; }
+.plant-stations small { display: block; font-size: 10px; color: light-dark(#68796d, #b6c8b5); margin-top: 4px; }
.plant-stations b { margin-left: auto; font-size: 12px; font-variant-numeric: tabular-nums; }
-.plant-station-dot.is-running { background: #239982; }
-.plant-station-dot.is-idle { background: #829497; }
-.plant-station-dot.is-warning, .plant-station-dot.is-blocked { background: #d28a3a; }
-.plant-station-dot.is-stopped { background: #e05044; }
-.plant-layout { margin: 0 20px 12px; border: 1px solid #e2e6dc; border-radius: 8px; font-size: 12px; }
-.plant-layout summary { padding: 10px 12px; cursor: pointer; color: #506553; }
+.plant-station-dot.is-running { background: light-dark(#239982, #284133); }
+.plant-station-dot.is-idle { background: light-dark(#829497, #21352c); }
+.plant-station-dot.is-warning, .plant-station-dot.is-blocked { background: light-dark(#d28a3a, #452e2a); }
+.plant-station-dot.is-stopped { background: light-dark(#e05044, #452e2a); }
+.plant-layout { margin: 0 20px 12px; border: 1px solid light-dark(#e2e6dc, #3c5142); border-radius: 8px; font-size: 12px; }
+.plant-layout summary { padding: 10px 12px; cursor: pointer; color: light-dark(#506553, #b6c8b5); }
.plant-layout p { padding: 0 12px; font-size: 12px; line-height: 1.6; max-width: 900px; }
.plant-layout-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 12px 12px; }
.plant-file { display: none; }
-.plant-vehicle-card { position: absolute; top: 18px; right: 18px; max-width: 240px; pointer-events: none; display: grid; gap: 5px; padding: 10px 13px; background: #fff8e9ed; border: 1px solid #dcb967; border-radius: 9px; color: #63502b; }
+.plant-vehicle-card { position: absolute; top: 18px; right: 18px; max-width: 240px; pointer-events: none; display: grid; gap: 5px; padding: 10px 13px; background: light-dark(#fff8e9ed, #1b2b25ed); border: 1px solid light-dark(#dcb967, #816936); border-radius: 9px; color: light-dark(#63502b, #edaa95); }
.plant-vehicle-card strong { font-size: 15px; font-variant-numeric: tabular-nums; }
.plant-vehicle-card span, .plant-vehicle-card small { font-size: 11px; }
-.plant-fleet-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; padding: 12px 20px; border-top: 1px solid #dce4d7; color: #41604d; font-size: 12px; background: #eef3e8; }
+.plant-fleet-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; padding: 12px 20px; border-top: 1px solid light-dark(#dce4d7, #3c5142); color: light-dark(#41604d, #b6c8b5); font-size: 12px; background: light-dark(#eef3e8, #21352c); }
.plant-fleet-stats b { font-size: 19px; padding-left: 4px; }
-.plant-fleet-stats small { color: #6c7a69; margin-left: auto; font-size: 10px; }
-.plant-manifest { margin: 10px 20px; font-size: 12px; border: 1px solid #dbe3d6; border-radius: 8px; }
-.plant-manifest summary { padding: 10px 12px; cursor: pointer; color: #355f48; }
-.plant-manifest p { margin: 0; padding: 8px 12px; color: #677564; line-height: 1.5; }
+.plant-fleet-stats small { color: light-dark(#6c7a69, #b6c8b5); margin-left: auto; font-size: 10px; }
+.plant-manifest { margin: 10px 20px; font-size: 12px; border: 1px solid light-dark(#dbe3d6, #3c5142); border-radius: 8px; }
+.plant-manifest summary { padding: 10px 12px; cursor: pointer; color: light-dark(#355f48, #a0d8b1); }
+.plant-manifest p { margin: 0; padding: 8px 12px; color: light-dark(#677564, #b6c8b5); line-height: 1.5; }
.plant-vehicle-list { display: grid; grid-template-columns: repeat(auto-fill,minmax(170px,1fr)); gap: 6px; padding: 8px 12px 12px; max-height: 280px; overflow: auto; }
.plant-view .plant-vehicle-list button { display: grid; gap: 3px; text-align: left; justify-content: stretch; }
-.plant-vehicle-list button[aria-pressed=true] { border-color: #c29232; background: #fff5db; }
-.plant-vehicle-list small { color: #74816b; }
-.plant-layout-error { color: #a43c32; }
-.plant-notice { margin: 6px 20px 12px; color: #6b705f; font-size: 12px; line-height: 1.5; }
-.plant-view.is-expanded { position: fixed; z-index: 60; inset: 16px; overflow: auto; background: #f7f8ef; border-radius: 14px; box-shadow: 0 0 0 30px #18382ca6; }
+.plant-vehicle-list button[aria-pressed=true] { border-color: light-dark(#c29232, #8d594a); background: light-dark(#fff5db, #284133); }
+.plant-vehicle-list small { color: light-dark(#74816b, #b6c8b5); }
+.plant-layout-error { color: light-dark(#a43c32, #edaa95); }
+.plant-notice { margin: 6px 20px 12px; color: light-dark(#6b705f, #b6c8b5); font-size: 12px; line-height: 1.5; }
+.plant-view.is-expanded { position: fixed; z-index: 60; inset: 16px; overflow: auto; background: light-dark(#f7f8ef, #1b2b25); border-radius: 14px; box-shadow: 0 0 0 30px #18382ca6; }
.plant-view.is-expanded .plant-viewport { height: calc(100vh - 265px); min-height: 340px; }
@media(max-width: 900px) { .plant-stations { grid-template-columns: repeat(2,minmax(0,1fr)); } .plant-overlay { padding: 8px 10px; top: 10px; left: 12px; } }
@media(max-width: 600px) {
diff --git a/apps/web/src/format.ts b/apps/web/src/format.ts
index 0394414..a90e22e 100644
--- a/apps/web/src/format.ts
+++ b/apps/web/src/format.ts
@@ -1,3 +1,5 @@
+import { locale } from './preferences';
+import { t } from './i18n';
/** Смена начинается в 08:00 виртуального времени. */
const SHIFT_START_SECONDS = 8 * 3600;
@@ -24,20 +26,28 @@ export function formatDuration(seconds: number): string {
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const rest = total % 60;
- if (hours > 0) return `${hours} ч ${pad(minutes)} мин`;
- if (minutes > 0) return `${minutes} мин ${pad(rest)} с`;
- return `${rest} с`;
+ if (hours > 0) return t(`${hours} ч ${pad(minutes)} мин`);
+ if (minutes > 0) return t(`${minutes} мин ${pad(rest)} с`);
+ return t(`${rest} с`);
}
-const integerFormat = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 });
-const oneDigitFormat = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
+const numberFormats = new Map();
+function currentFormats() {
+ const code = locale();
+ let formats = numberFormats.get(code);
+ if (!formats) {
+ formats = { integer: new Intl.NumberFormat(code, { maximumFractionDigits: 0 }), one: new Intl.NumberFormat(code, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) };
+ numberFormats.set(code, formats);
+ }
+ return formats;
+}
export function formatInt(value: number): string {
- return integerFormat.format(safe(value));
+ return currentFormats().integer.format(safe(value));
}
export function formatOne(value: number): string {
- return oneDigitFormat.format(safe(value));
+ return currentFormats().one.format(safe(value));
}
export function formatSigned(value: number): string {
@@ -56,5 +66,5 @@ export function clampPercent(value: number): number {
}
export function formatLocalTime(timestamp: number): string {
- return new Date(timestamp).toLocaleTimeString('ru-RU');
+ return new Date(timestamp).toLocaleTimeString(locale());
}
diff --git a/apps/web/src/history-viewer.css b/apps/web/src/history-viewer.css
index a0080c2..4eeec5b 100644
--- a/apps/web/src/history-viewer.css
+++ b/apps/web/src/history-viewer.css
@@ -112,11 +112,11 @@
.history-btn-primary {
background: var(--history-green);
border-color: var(--history-green);
- color: #ffffff;
+ color: light-dark(#ffffff, #ffffff);
}
.history-btn-primary:hover:not(:disabled) {
- background: #266a5c;
+ background: light-dark(#266a5c, #14392e);
}
.history-btn:disabled {
@@ -382,7 +382,7 @@
}
.history-row-selected {
- background: #eaf3f0;
+ background: light-dark(#eaf3f0, #21352c);
}
.history-row-button {
@@ -412,12 +412,12 @@
.history-row-button[aria-current='true'] {
border-color: var(--history-green);
background: var(--history-green);
- color: #ffffff;
+ color: light-dark(#ffffff, #ffffff);
font-weight: 600;
}
.history-row-button[aria-current='true'] small {
- color: #ffffff;
+ color: light-dark(#ffffff, #ffffff);
}
.history-sr-only {
diff --git a/apps/web/src/i18n.test.ts b/apps/web/src/i18n.test.ts
new file mode 100644
index 0000000..9465630
--- /dev/null
+++ b/apps/web/src/i18n.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it, afterEach } from 'vitest';
+import { readFileSync, readdirSync } from 'node:fs';
+import ts from 'typescript';
+import { resolve } from 'node:path';
+import { messages, t } from './i18n';
+import { getPreferences, parsePreferences, setPreferences } from './preferences';
+import { formatDuration, formatOne } from './format';
+
+afterEach(() => setPreferences({ language: 'ru', theme: 'light' }));
+describe('interface localization', () => {
+ it('preserves all placeholder identities in both translations', () => {
+ const slots = (text: string) => [...text.matchAll(/\{\d+\}/g)].map(match => match[0]).sort();
+ for (const [source, values] of Object.entries(messages)) {
+ expect(values, source).toHaveLength(2);
+ for (const value of values) { expect(value.length, source).toBeGreaterThan(0); expect(slots(value), source).toEqual(slots(source)); }
+ }
+ });
+ it('translates dynamic times, queue states and nested station names', () => {
+ expect(t('Через ≈ 12,5 мин', 'en')).toBe('In ≈ 12,5 min');
+ expect(t('Не заполнится за 30 мин', 'kk')).toBe('30 минут ішінде толмайды');
+ expect(t('Сварка: поток ограничен', 'en')).toBe('Welding: flow restricted');
+ expect(t('2 ожидают · 1 подъезжают', 'en')).toBe('2 waiting · 1 approaching');
+ expect(t('1 ч 05 мин', 'kk')).toBe('1 сағ 05 мин');
+ expect(t(' План ', 'en')).toBe(' Plan ');
+ });
+ it('keeps source and domain data intact, and handles unknown text', () => {
+ const data = { label: 'Мой набор данных', count: 700 };
+ expect(t(data, 'en')).toBe(data);
+ expect(t('Мой набор данных', 'en')).toBe('Мой набор данных');
+ expect(t('Onix', 'kk')).toBe('Onix');
+ expect(t('План', 'ru')).toBe('План');
+ });
+ it('formats durations and decimal numbers using the current language', () => {
+ setPreferences({ language: 'en' }); expect(formatDuration(3905)).toBe('1 h 05 min'); expect(formatOne(12.5)).toBe('12.5');
+ setPreferences({ language: 'kk' }); expect(formatDuration(125)).toBe('2 мин 05 с');
+ });
+ it('handles absent, corrupt or unsupported saved settings', () => {
+ for (const raw of [null, 'bad json', 'null', '42', '{"language":"xx","theme":"purple"}']) expect(parsePreferences(raw)).toEqual({ language: 'ru', theme: 'light' });
+ expect(parsePreferences('{"language":"kk","theme":"dark"}')).toEqual({ language: 'kk', theme: 'dark' });
+ setPreferences({ theme: 'dark' }); setPreferences({ language: 'en' }); expect(getPreferences()).toEqual({ language: 'en', theme: 'dark' });
+ });
+ it('covers every Russian source phrase and template used by the interface and model messages', () => {
+ const missing = new Set();
+ const walk = (directory: string) => {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const file = resolve(directory, entry.name);
+ if (entry.isDirectory()) { if (entry.name !== 'locales') walk(file); continue; }
+ if (!/\.tsx?$/.test(entry.name) || entry.name.includes('.test.')) continue;
+ const tree = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
+ const visit = (node: ts.Node) => {
+ let value = '';
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) value = node.text;
+ else if (ts.isTemplateExpression(node)) value = node.head.text + node.templateSpans.map((span, index) => '{' + index + '}' + span.literal.text).join('');
+ else if (ts.isJsxText(node)) value = node.text;
+ value = value.replace(/\s+/g, ' ').trim();
+ if (/[А-Яа-яЁё]/.test(value) && !['Русский', 'Қазақша'].includes(value) && !messages[value]) missing.add(value);
+ ts.forEachChild(node, visit);
+ };
+ visit(tree);
+ }
+ };
+ for (const directory of ['apps/web/src', 'packages/shared/src', 'packages/simulation/src']) walk(resolve(directory));
+ expect([...missing]).toEqual([]);
+ });
+});
diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts
new file mode 100644
index 0000000..94ab4bb
--- /dev/null
+++ b/apps/web/src/i18n.ts
@@ -0,0 +1,36 @@
+import catalog from './locales/catalog.json';
+import { getPreferences, type Language } from './preferences';
+// Russian source phrases are stable keys. Placeholders also cover messages produced by shared calculations.
+export const messages: Record = catalog;
+const caches = { kk: new Map(), en: new Map() };
+const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const patterns = Object.entries(messages).filter(([key]) => /\{\d+\}/.test(key)).map(([key, values]) => {
+ const ids: string[] = [];
+ const parts = key.split(/(\{\d+\})/g).map(part => /^\{\d+\}$/.test(part) ? (ids.push(part), '(.*?)') : escapeRegex(part));
+ return { regex: new RegExp('^' + parts.join('') + '$'), ids, values, length: key.length };
+}).sort((a, b) => b.length - a.length);
+function translate(value: string, language: Exclude, depth = 0): string {
+ if (!value || depth > 3) return value;
+ const cache = caches[language];
+ const cached = cache.get(value); if (cached !== undefined) return cached;
+ const source = value.replace(/\s+/g, ' ').trim();
+ const index = language === 'kk' ? 0 : 1;
+ let result = messages[source]?.[index];
+ if (result === undefined) for (const pattern of patterns) {
+ const match = source.match(pattern.regex); if (!match) continue;
+ result = pattern.values[index].replace(/\{\d+\}/g, token => translate(match[pattern.ids.indexOf(token) + 1] ?? token, language, depth + 1));
+ break;
+ }
+ if (result === undefined && source.includes(' · ')) result = source.split(' · ').map(part => translate(part, language, depth + 1)).join(' · ');
+ if (result === undefined) return value; // User-imported/source text and AI reports retain their original language.
+ const output = (value.match(/^\s*/)?.[0] ?? '') + result + (value.match(/\s*$/)?.[0] ?? '');
+ if (cache.size >= 2000) cache.clear(); cache.set(value, output);
+ return output;
+}
+/** Translate display text only. Values, React elements and domain data are never mutated. */
+export function t(value: T, language: Language = getPreferences().language): T {
+ if (language === 'ru') return value;
+ if (typeof value === 'string') return translate(value, language) as T;
+ if (Array.isArray(value)) return value.map(item => t(item, language)) as T;
+ return value;
+}
diff --git a/apps/web/src/locales/catalog.json b/apps/web/src/locales/catalog.json
new file mode 100644
index 0000000..8654884
--- /dev/null
+++ b/apps/web/src/locales/catalog.json
@@ -0,0 +1,3554 @@
+{
+ "Укажите HTTPS-адрес сервера. HTTP разрешён только для localhost.": [
+ "Сервердің HTTPS мекенжайын енгізіңіз. HTTP тек localhost үшін рұқсат етіледі.",
+ "Enter the server HTTPS address. HTTP is allowed only for localhost."
+ ],
+ "Это ключ OpenAI. Введите отдельный код доступа к демонстрации; API-ключ хранится только на сервере.": [
+ "Бұл OpenAI кілті. Демонстрацияға арналған бөлек кіру кодын енгізіңіз; API кілті тек серверде сақталады.",
+ "This is an OpenAI key. Enter the separate demo access code; the API key is stored only on the server."
+ ],
+ "Вставьте только строку кода доступа, без заголовка и инструкции из файла.": [
+ "Файлдағы тақырып пен нұсқаулықсыз, тек кіру кодын енгізіңіз.",
+ "Paste only the access code, without the file heading or instructions."
+ ],
+ "Сервер ИИ вернул некорректный ответ. Проверьте адрес подключения.": [
+ "ЖИ серверінің жауабы жарамсыз. Қосылу мекенжайын тексеріңіз.",
+ "The AI server returned an invalid response. Check the connection address."
+ ],
+ "Анализ временно недоступен.": [
+ "Талдау уақытша қолжетімсіз.",
+ "Analysis is temporarily unavailable."
+ ],
+ "Время ожидания истекло. Новый анализ можно запустить вручную.": [
+ "Күту уақыты аяқталды. Талдауды қайта іске қосуға болады.",
+ "The request timed out. You can start a new analysis manually."
+ ],
+ "Нет связи с сервером ИИ. Проверьте подключение.": [
+ "ЖИ серверімен байланыс жоқ. Қосылымды тексеріңіз.",
+ "Cannot reach the AI server. Check your connection."
+ ],
+ "Этот адрес не является сервером ИИ DriveIndUI.": [
+ "Бұл мекенжай DriveIndUI ЖИ серверіне тиесілі емес.",
+ "This address is not a DriveIndUI AI server."
+ ],
+ "Некорректный ответ ИИ.": [
+ "ЖИ жауабы жарамсыз.",
+ "Invalid AI response."
+ ],
+ "Ответ ИИ не прошёл проверку. Повторите анализ.": [
+ "ЖИ жауабы тексеруден өтпеді. Талдауды қайталаңыз.",
+ "The AI response failed validation. Run the analysis again."
+ ],
+ "Запрос отменён": [
+ "Сұрау тоқтатылды",
+ "Request cancelled"
+ ],
+ "Неизвестная ошибка": [
+ "Белгісіз қате",
+ "Unknown error"
+ ],
+ "Сервер вернул ошибку {0}": [
+ "Сервер {0} қатесін қайтарды",
+ "The server returned error {0}"
+ ],
+ "Сервер вернул пустой или некорректный ответ": [
+ "Сервер бос немесе жарамсыз жауап қайтарды",
+ "The server returned an empty or invalid response"
+ ],
+ "Превышено время ожидания ответа сервера": [
+ "Сервер жауабын күту уақыты аяқталды",
+ "The server response timed out"
+ ],
+ "Нет связи с сервером симуляции": [
+ "Симуляция серверімен байланыс жоқ",
+ "Cannot reach the simulation server"
+ ],
+ "Сервер вернул данные неожиданного формата": [
+ "Сервер деректерінің пішімі күтпеген түрде",
+ "The server returned an unexpected data format"
+ ],
+ "Локальная смена завершена.": [
+ "Жергілікті ауысым аяқталды.",
+ "The local shift has ended."
+ ],
+ "Сервер вернул результат расчёта неожиданного формата": [
+ "Сервер есебінің пішімі күтпеген түрде",
+ "The server returned an unexpected calculation format"
+ ],
+ "К содержимому": [
+ "Мазмұнға өту",
+ "Skip to content"
+ ],
+ "Открыть цех": [
+ "Цехты ашу",
+ "Open shop floor"
+ ],
+ "Производственная линия": [
+ "Өндірістік желі",
+ "Production line"
+ ],
+ "Показатели ·": [
+ "Көрсеткіштер ·",
+ "Metrics ·"
+ ],
+ "Раздел данных": [
+ "Деректер бөлімі",
+ "Data section"
+ ],
+ "Показатели кейса": [
+ "Кейс көрсеткіштері",
+ "Case metrics"
+ ],
+ "Импорт и параметры": [
+ "Импорт және параметрлер",
+ "Import and parameters"
+ ],
+ "Риски и методика": [
+ "Тәуекелдер және әдістеме",
+ "Risks and methodology"
+ ],
+ "Что показывает модель": [
+ "Модель нені көрсетеді",
+ "What the model shows"
+ ],
+ "Тестовые данные организатора за 1–2 октября. 3D и прогноз выпуска — сценарный расчёт, без подключения к оборудованию завода.": [
+ "Ұйымдастырушының 1–2 қазандағы сынақ деректері. 3D және өндіріс болжамы — зауыт жабдығына қосылмаған сценарийлік есеп.",
+ "Organizer test data for 1–2 October. The 3D view and output forecast are scenario calculations without a connection to factory equipment."
+ ],
+ "Смена сохраняется при переходе между страницами. Обновление публичного стенда начинает её заново; перед этим можно скачать параметры в разделе импорта.": [
+ "Беттер арасында ауысқанда ауысым сақталады. Жария стендті жаңартқанда ауысым қайта басталады; оған дейін импорт бөлімінен параметрлерді жүктеп алуға болады.",
+ "The shift is preserved between pages. Reloading the public demo starts a new shift; download the parameters from Import before reloading."
+ ],
+ "Обозначения и расчёт показателей": [
+ "Белгілер және көрсеткіштерді есептеу",
+ "Legend and metric calculations"
+ ],
+ "Не удалось рассчитать варианты. Повторите попытку.": [
+ "Нұсқаларды есептеу мүмкін болмады. Қайталап көріңіз.",
+ "Could not calculate the options. Please try again."
+ ],
+ "Расчёт занял слишком много времени. Повторите попытку.": [
+ "Есептеу тым ұзаққа созылды. Қайталап көріңіз.",
+ "The calculation took too long. Please try again."
+ ],
+ "Не удалось запустить расчёт на устройстве. Повторите попытку.": [
+ "Құрылғыда есептеуді бастау мүмкін болмады. Қайталап көріңіз.",
+ "Could not start the calculation on this device. Please try again."
+ ],
+ "Не удалось прочитать результат расчёта.": [
+ "Есеп нәтижесін оқу мүмкін болмады.",
+ "Could not read the calculation result."
+ ],
+ "Не удалось передать состояние линии для расчёта.": [
+ "Есептеуге желі күйін жіберу мүмкін болмады.",
+ "Could not pass the line state to the calculation."
+ ],
+ "Высокий": [
+ "Жоғары",
+ "High"
+ ],
+ "Внимание": [
+ "Назар аударыңыз",
+ "Attention"
+ ],
+ "Низкий": [
+ "Төмен",
+ "Low"
+ ],
+ "Простой": [
+ "Тоқтап тұру",
+ "Downtime"
+ ],
+ "Узкое место": [
+ "Тар орын",
+ "Bottleneck"
+ ],
+ "План": [
+ "Жоспар",
+ "Plan"
+ ],
+ "Качество": [
+ "Сапа",
+ "Quality"
+ ],
+ "Нет связи с ИИ.": [
+ "ЖИ-мен байланыс жоқ.",
+ "Cannot reach AI."
+ ],
+ "Введите код доступа, затем нажмите «Проверить подключение».": [
+ "Кіру кодын енгізіп, «Қосылымды тексеру» түймесін басыңыз.",
+ "Enter the access code, then select “Check connection”."
+ ],
+ "Проверяем подключение. Дождитесь подтверждения доступа.": [
+ "Қосылым тексерілуде. Кіру расталғанша күтіңіз.",
+ "Checking the connection. Wait for access confirmation."
+ ],
+ "Подтвердите код доступа кнопкой «Проверить подключение», затем запустите анализ.": [
+ "«Қосылымды тексеру» түймесімен кодты растаңыз, содан кейін талдауды бастаңыз.",
+ "Confirm the code with “Check connection”, then start the analysis."
+ ],
+ "Анализ не получен.": [
+ "Талдау алынбады.",
+ "No analysis received."
+ ],
+ "Проверьте адрес.": [
+ "Мекенжайды тексеріңіз.",
+ "Check the address."
+ ],
+ "Проверяем подключение…": [
+ "Қосылым тексерілуде…",
+ "Checking connection…"
+ ],
+ "Готов к анализу": [
+ "Талдауға дайын",
+ "Ready to analyse"
+ ],
+ "Подключение не подтверждено": [
+ "Қосылым расталмады",
+ "Connection not confirmed"
+ ],
+ "Проверьте код доступа": [
+ "Кіру кодын тексеріңіз",
+ "Check the access code"
+ ],
+ "Нужен код доступа": [
+ "Кіру коды қажет",
+ "Access code required"
+ ],
+ "OpenAI не подключён": [
+ "OpenAI қосылмаған",
+ "OpenAI is not connected"
+ ],
+ "ПРОГНОЗ РИСКОВ": [
+ "ТӘУЕКЕЛДЕР БОЛЖАМЫ",
+ "RISK FORECAST"
+ ],
+ "Анализ ИИ": [
+ "ЖИ талдауы",
+ "AI analysis"
+ ],
+ "ИИ сопоставит текущую загрузку, очереди и историю остановок — и предложит, что проверить в первую очередь.": [
+ "ЖИ ағымдағы жүктемені, кезектерді және тоқтау тарихын салыстырып, алдымен нені тексеру керегін ұсынады.",
+ "AI compares current utilization, queues and downtime history, then suggests what to check first."
+ ],
+ "Прогноз на": [
+ "Болжам мерзімі",
+ "Forecast for"
+ ],
+ "Период прогноза": [
+ "Болжам кезеңі",
+ "Forecast period"
+ ],
+ "мин": [
+ "мин",
+ "min"
+ ],
+ "Анализируем смену…": [
+ "Ауысым талдануда…",
+ "Analysing the shift…"
+ ],
+ "Обновить ИИ-анализ": [
+ "ЖИ талдауын жаңарту",
+ "Refresh AI analysis"
+ ],
+ "Проанализировать риски": [
+ "Тәуекелдерді талдау",
+ "Analyse risks"
+ ],
+ "Подключение": [
+ "Қосылым",
+ "Connection"
+ ],
+ "Подключение к ИИ-сервису": [
+ "ЖИ қызметіне қосылу",
+ "Connect to the AI service"
+ ],
+ "API-ключ хранится на сервере. Код доступа к демонстрации остаётся только в памяти этой вкладки.": [
+ "API кілті серверде сақталады. Демонстрацияның кіру коды тек осы қойындының жадында қалады.",
+ "The API key is stored on the server. The demo access code stays only in this tab’s memory."
+ ],
+ "Адрес сервера": [
+ "Сервер мекенжайы",
+ "Server address"
+ ],
+ "Код доступа": [
+ "Кіру коды",
+ "Access code"
+ ],
+ "Только строка кода демонстрации": [
+ "Тек демонстрация коды",
+ "Demo access code only"
+ ],
+ "Проверить подключение": [
+ "Қосылымды тексеру",
+ "Check connection"
+ ],
+ "Доступ подтверждён. Можно запускать ИИ-анализ.": [
+ "Кіру расталды. ЖИ талдауын бастауға болады.",
+ "Access confirmed. You can start the AI analysis."
+ ],
+ "Сервер доступен. Введите код демонстрации и нажмите «Проверить подключение».": [
+ "Сервер қолжетімді. Демонстрация кодын енгізіп, «Қосылымды тексеру» түймесін басыңыз.",
+ "The server is available. Enter the demo code and select “Check connection”."
+ ],
+ "Подключаемся к ИИ-сервису. Первый запуск после простоя может занять около минуты.": [
+ "ЖИ қызметіне қосылуда. Тоқтап тұрғаннан кейінгі алғашқы іске қосу бір минуттай уақыт алуы мүмкін.",
+ "Connecting to the AI service. The first start after inactivity may take about a minute."
+ ],
+ "Для ИИ-прогноза подключите сервер OpenAI. Ниже доступна локальная оценка мощности.": [
+ "ЖИ болжамы үшін OpenAI серверін қосыңыз. Төменде қуаттың жергілікті бағасы қолжетімді.",
+ "Connect the OpenAI server for AI forecasting. A local capacity estimate is available below."
+ ],
+ "Введите код демонстрации и подтвердите его кнопкой «Проверить подключение».": [
+ "Демонстрация кодын енгізіп, «Қосылымды тексеру» түймесімен растаңыз.",
+ "Enter the demo code and confirm it with “Check connection”."
+ ],
+ "Сервер OpenAI ещё не готов. Локальная оценка мощности работает независимо от него.": [
+ "OpenAI сервері әлі дайын емес. Жергілікті қуат есебі одан тәуелсіз жұмыс істейді.",
+ "The OpenAI server is not ready yet. The local capacity estimate works independently."
+ ],
+ "Подключить": [
+ "Қосу",
+ "Connect"
+ ],
+ "Результат анализа OpenAI": [
+ "OpenAI талдауының нәтижесі",
+ "OpenAI analysis result"
+ ],
+ "ВЫВОД OPENAI": [
+ "OPENAI ҚОРЫТЫНДЫСЫ",
+ "OPENAI FINDINGS"
+ ],
+ "Срез": [
+ "Күйі",
+ "Snapshot"
+ ],
+ "Показатели смены или период прогноза изменились. Вывод относится к сохранённому срезу.": [
+ "Ауысым көрсеткіштері немесе болжам кезеңі өзгерді. Қорытынды сақталған күйге қатысты.",
+ "Shift metrics or the forecast period have changed. This analysis refers to the saved snapshot."
+ ],
+ "Основание": [
+ "Негізі",
+ "Evidence"
+ ],
+ "Прогноз": [
+ "Болжам",
+ "Forecast"
+ ],
+ "Действие": [
+ "Әрекет",
+ "Action"
+ ],
+ "Уверенность:": [
+ "Сенімділік:",
+ "Confidence:"
+ ],
+ "низкая": [
+ "төмен",
+ "low"
+ ],
+ "средняя": [
+ "орташа",
+ "medium"
+ ],
+ "Следующие действия": [
+ "Келесі әрекеттер",
+ "Next steps"
+ ],
+ "Сравнить решения": [
+ "Шешімдерді салыстыру",
+ "Compare decisions"
+ ],
+ "Основания и границы анализа": [
+ "Талдаудың негіздері мен шектеулері",
+ "Analysis basis and limitations"
+ ],
+ "Учтены тестовые данные организатора за 1–2 октября и текущая симуляция.": [
+ "Ұйымдастырушының 1–2 қазандағы сынақ деректері мен ағымдағы симуляция ескерілген.",
+ "Includes the organizer’s test data for 1–2 October and the current simulation."
+ ],
+ "Учтена текущая конфигурация; история организатора к ней не присоединялась.": [
+ "Ағымдағы конфигурация ескерілген; ұйымдастырушының тарихы қосылмаған.",
+ "Uses the current configuration without the organizer’s historical data."
+ ],
+ "Автоматическое управление оборудованием не выполняется.": [
+ "Жабдық автоматты түрде басқарылмайды.",
+ "Equipment is not controlled automatically."
+ ],
+ "Повторно использован готовый ответ": [
+ "Дайын жауап қайта пайдаланылды",
+ "Cached response reused"
+ ],
+ "Оценка расхода: ${0}": [
+ "Болжамды шығын: ${0}",
+ "Estimated cost: ${0}"
+ ],
+ "токенов": [
+ "токен",
+ "tokens"
+ ],
+ "Локальная оценка мощности": [
+ "Қуаттың жергілікті бағасы",
+ "Local capacity estimate"
+ ],
+ "ТЕКУЩАЯ МОЩНОСТЬ": [
+ "АҒЫМДАҒЫ ҚУАТ",
+ "CURRENT CAPACITY"
+ ],
+ "Где может накапливаться очередь": [
+ "Кезек қай жерде жиналуы мүмкін",
+ "Where queues may build up"
+ ],
+ "Расчёт по циклам · без ИИ": [
+ "Циклдер бойынша есеп · ЖИ-сіз",
+ "Cycle-based calculation · no AI"
+ ],
+ "Требуемый темп до конца смены": [
+ "Ауысым соңына дейін қажетті қарқын",
+ "Required rate until shift end"
+ ],
+ "авто/ч": [
+ "авто/сағ",
+ "cars/h"
+ ],
+ "Максимальный выпуск без брака в час": [
+ "Сағатына ақаусыз өнімнің ең жоғары саны",
+ "Maximum good vehicles per hour"
+ ],
+ "Самый длинный цикл": [
+ "Ең ұзақ цикл",
+ "Longest cycle"
+ ],
+ "Смена завершена. Для нового прогноза начните следующую смену.": [
+ "Ауысым аяқталды. Жаңа болжам үшін келесі ауысымды бастаңыз.",
+ "The shift has ended. Start a new shift for another forecast."
+ ],
+ "Заказы смены выполнены. Новый поток не ожидается.": [
+ "Ауысым тапсырыстары орындалды. Жаңа ағын күтілмейді.",
+ "Shift orders are complete. No new inflow is expected."
+ ],
+ "Участок": [
+ "Учаске",
+ "Station"
+ ],
+ "Мощность, авто/ч": [
+ "Қуат, авто/сағ",
+ "Capacity, cars/h"
+ ],
+ "Машин в очереди сейчас": [
+ "Қазір кезекте тұрған көліктер",
+ "Vehicles queued now"
+ ],
+ "Машин в очереди через": [
+ "Кезектегі көліктер, кейін",
+ "Vehicles queued after"
+ ],
+ "Когда очередь заполнится": [
+ "Кезек қашан толады",
+ "When the queue fills up"
+ ],
+ "Остановлен сейчас": [
+ "Қазір тоқтап тұр",
+ "Stopped now"
+ ],
+ "авто": [
+ "авто",
+ "cars"
+ ],
+ "Не заполнится за {0} мин": [
+ "{0} минут ішінде толмайды",
+ "Will not fill within {0} min"
+ ],
+ "Свободных мест нет": [
+ "Бос орын жоқ",
+ "No free spaces"
+ ],
+ "Через ≈ {0} мин": [
+ "Шамамен {0} минуттан кейін",
+ "In ≈ {0} min"
+ ],
+ "Оценка при неизменной подаче и длительности циклов, без будущих событий сценария. Не учитывает дискретное движение, восстановление после остановки и обратное влияние заполненных буферов.": [
+ "Беріліс пен цикл ұзақтығы өзгермейді деп есептелген; сценарийдің болашақ оқиғалары ескерілмейді. Жеке көлік қозғалысы, тоқтаудан кейін қалпына келу және толған буферлердің кері әсері есепке алынбайды.",
+ "Assumes constant inflow and cycle times, without future scenario events. Excludes discrete movement, recovery after stops and upstream effects of full buffers."
+ ],
+ "Сварка": [
+ "Дәнекерлеу",
+ "Welding"
+ ],
+ "Окраска": [
+ "Бояу",
+ "Painting"
+ ],
+ "Сборка": [
+ "Құрастыру",
+ "Assembly"
+ ],
+ "Заказы на октябрь": [
+ "Қазан айының тапсырыстары",
+ "October orders"
+ ],
+ "Исходные показатели": [
+ "Бастапқы көрсеткіштер",
+ "Source metrics"
+ ],
+ "Допущения и эффект": [
+ "Болжамдар және әсер",
+ "Assumptions and impact"
+ ],
+ "Цель октября": [
+ "Қазан мақсаты",
+ "October target"
+ ],
+ "Назначено моделям": [
+ "Модельдерге бөлінген",
+ "Assigned to models"
+ ],
+ "Не распределено": [
+ "Бөлінбеген",
+ "Unallocated"
+ ],
+ "Заказы и календарь": [
+ "Тапсырыстар және күнтізбе",
+ "Orders and calendar"
+ ],
+ "Предпросмотр · ещё не применён": [
+ "Алдын ала қарау · әлі қолданылмаған",
+ "Preview · not applied yet"
+ ],
+ "В присланных данных по моделям распределено 4 800 из 5 500 автомобилей. Для оставшихся 700 модель не указана.": [
+ "Жіберілген деректерде 5 500 автокөліктің 4 800-і модельдерге бөлінген. Қалған 700 автокөліктің моделі көрсетілмеген.",
+ "The supplied data allocates 4,800 of 5,500 vehicles to models. The remaining 700 have no model assigned."
+ ],
+ "Рабочих дней в месяце": [
+ "Айдағы жұмыс күндері",
+ "Working days per month"
+ ],
+ "Порядковая смена": [
+ "Ауысымның реттік нөмірі",
+ "Shift number"
+ ],
+ "смен по 8 часов": [
+ "ауысым, әрқайсысы 8 сағат",
+ "shifts of 8 hours"
+ ],
+ "22 рабочих дня — допущение; календарь в кейсе не задан.": [
+ "22 жұмыс күні — болжам; кейсте күнтізбе берілмеген.",
+ "22 working days is an assumption; the case does not specify a calendar."
+ ],
+ "План годного выпуска по моделям": [
+ "Модельдер бойынша жарамды өнім жоспары",
+ "Good-output plan by model"
+ ],
+ "Модель": [
+ "Модель",
+ "Model"
+ ],
+ "В кейсе": [
+ "Кейсте",
+ "Case data"
+ ],
+ "Заказ / месяц": [
+ "Тапсырыс / ай",
+ "Order / month"
+ ],
+ "На смену": [
+ "Ауысымға",
+ "Per shift"
+ ],
+ "Годные / НЗП": [
+ "Жарамды / аяқталмаған",
+ "Good / WIP"
+ ],
+ "Месячный план {0}": [
+ "{0} айлық жоспары",
+ "Monthly plan for {0}"
+ ],
+ "Модель не назначена": [
+ "Модель тағайындалмаған",
+ "No model assigned"
+ ],
+ "В запуск не включается": [
+ "Өндіріске жіберілмейді",
+ "Excluded from production"
+ ],
+ "Требуется за выбранную смену": [
+ "Таңдалған ауысымға қажет",
+ "Required for selected shift"
+ ],
+ "годных авто": [
+ "жарамды авто",
+ "good cars"
+ ],
+ "Среднее:": [
+ "Орташа:",
+ "Average:"
+ ],
+ "/ смену": [
+ "/ ауысым",
+ "/ shift"
+ ],
+ "Обеспечено заказами": [
+ "Тапсырыстармен қамтылған",
+ "Covered by orders"
+ ],
+ "авто / смену": [
+ "авто / ауысым",
+ "cars / shift"
+ ],
+ "назначено за месяц": [
+ "айға тағайындалған",
+ "assigned per month"
+ ],
+ "Требуемый средний такт": [
+ "Қажетті орташа такт",
+ "Required average takt"
+ ],
+ "с / годное авто": [
+ "с / жарамды авто",
+ "s / good car"
+ ],
+ "Без брака, простоев и заполнения линии": [
+ "Ақау, тоқтау және желінің толуы ескерілмеген",
+ "Excluding rejects, downtime and line fill-up"
+ ],
+ "Распределите ещё {0} авто между моделями. Неназначенный объём не запускается в производство.": [
+ "Модельдерге тағы {0} авто бөліңіз. Бөлінбеген көлем өндіріске жіберілмейді.",
+ "Allocate another {0} cars to models. Unassigned volume is not released into production."
+ ],
+ "Цель обеспечена заказами. Проверьте выполнимость в разделе «Решения».": [
+ "Мақсат тапсырыстармен қамтылған. «Шешімдер» бөлімінде орындалуын тексеріңіз.",
+ "Orders cover the target. Check feasibility in Decisions."
+ ],
+ "Применить и сбросить смену": [
+ "Қолдану және ауысымды қайта бастау",
+ "Apply and reset shift"
+ ],
+ "Вернуть значения кейса": [
+ "Кейс мәндерін қайтару",
+ "Restore case values"
+ ],
+ "Как план влияет на выпуск": [
+ "Жоспар өнімге қалай әсер етеді",
+ "How the plan affects output"
+ ],
+ "Применение начинает смену на паузе. Машина резервирует заказ своей модели; после выхода брака возможна замена. Квоты всех смен в сумме равны месячному плану. Перенос НЗП и недовыпуска между сменами пока не моделируется.": [
+ "Қолданғанда ауысым үзілісте басталады. Көлік өз моделінің тапсырысын резервтейді; ақаулы өнімнен кейін ауыстыру мүмкін. Барлық ауысым квоталарының қосындысы айлық жоспарға тең. Аяқталмаған өндіріс пен кем шығарылған өнімді ауысымдар арасында көшіру әзірге модельденбейді.",
+ "Applying starts a paused shift. Each vehicle reserves its model’s order; rejects may be replaced. All shift quotas sum to the monthly plan. Carry-over of WIP and shortfalls between shifts is not yet modelled."
+ ],
+ "Работа линий и качество": [
+ "Желілер жұмысы және сапа",
+ "Line performance and quality"
+ ],
+ "Дата": [
+ "Күні",
+ "Date"
+ ],
+ "Оба дня": [
+ "Екі күн",
+ "Both days"
+ ],
+ "1 октября": [
+ "1 қазан",
+ "1 October"
+ ],
+ "2 октября": [
+ "2 қазан",
+ "2 October"
+ ],
+ "Работа линий и качество · значения из PDF, доля брака рассчитана по счётчикам": [
+ "Желілер жұмысы және сапа · мәндер PDF-тен, ақау үлесі санауыштар бойынша есептелген",
+ "Line performance and quality · PDF values; reject rates calculated from counts"
+ ],
+ "Дата / линия": [
+ "Күні / желі",
+ "Date / line"
+ ],
+ "Факт": [
+ "Нақты",
+ "Actual"
+ ],
+ "Работа, ч": [
+ "Жұмыс, сағ",
+ "Operating hours"
+ ],
+ "Загрузка": [
+ "Жүктеме",
+ "Utilization"
+ ],
+ "Брак, ед.": [
+ "Ақаулы, дана",
+ "Rejects, units"
+ ],
+ "Доля брака / ≤ 2%": [
+ "Ақау үлесі / ≤ 2%",
+ "Reject rate / ≤ 2%"
+ ],
+ "в PDF:": [
+ "PDF-те:",
+ "in PDF:"
+ ],
+ "Как читать исходные показатели": [
+ "Бастапқы көрсеткіштерді түсіну",
+ "How to read source metrics"
+ ],
+ "Агрегаты по датам, без номера смены. Выпуск участков не суммируется в выпуск завода. Промежуточные дефекты не складываются с окончательным браком: нет данных о переделке и пересечении изделий.": [
+ "Күн бойынша жиынтық, ауысым нөмірі жоқ. Учаскелердің өнімі зауыт өнімі ретінде қосылмайды. Аралық ақаулар соңғы ақауға қосылмайды: қайта өңдеу және бір өнімнің қайталануы туралы деректер жоқ.",
+ "Daily aggregates without shift numbers. Station output is not summed into factory output. Intermediate defects are not added to final rejects because rework and overlapping products are unknown."
+ ],
+ "Журнал остановок оборудования": [
+ "Жабдық тоқтауларының журналы",
+ "Equipment downtime log"
+ ],
+ "Порог 60 минут в сутки — для критического оборудования; критичность в исходнике не указана": [
+ "Тәулігіне 60 минут шегі — маңызды жабдық үшін; бастапқы деректе маңыздылығы көрсетілмеген",
+ "The 60-minute daily threshold applies to critical equipment; criticality is not specified in the source"
+ ],
+ "Оборудование": [
+ "Жабдық",
+ "Equipment"
+ ],
+ "Участок / причина": [
+ "Учаске / себеп",
+ "Station / reason"
+ ],
+ "До порога 60 мин": [
+ "60 минут шегіне дейін",
+ "Until the 60-min threshold"
+ ],
+ "Простой {0}": [
+ "{0} тоқтап тұруы",
+ "Downtime: {0}"
+ ],
+ "Границы учёта простоев": [
+ "Тоқтауды есепке алу шектері",
+ "Downtime accounting boundaries"
+ ],
+ "Суммы 65 и 85 минут относятся к разным станкам и не означают превышение одним станком. Начало событий не задано; временные траектории не восстанавливаются.": [
+ "65 және 85 минут қосындылары әртүрлі станоктарға қатысты; бір станок шектен асты дегенді білдірмейді. Оқиғалардың басталуы белгісіз; уақыт бойынша қозғалыс қалпына келтірілмейді.",
+ "Totals of 65 and 85 minutes refer to different machines, not a breach by one machine. Event start times are unknown, so timelines cannot be reconstructed."
+ ],
+ "OEE · цель ≥ 85% · показать оценку и допущения": [
+ "OEE · мақсат ≥ 85% · бағалау мен болжамдарды көрсету",
+ "OEE · target ≥ 85% · show estimate and assumptions"
+ ],
+ "Фактический OEE вычислить нельзя без идеального цикла и планового времени строки. Ниже — иллюстрация при 8 часах на строку и идеальном цикле 240 секунд (120 авто / 8 ч). Это не измеренный OEE и не подтверждение выполнения цели.": [
+ "Идеал цикл мен жоспарланған уақытсыз нақты OEE есептелмейді. Төмендегі мысалда бір жолға 8 сағат және 240 секунд идеал цикл (120 авто / 8 сағ) қабылданған. Бұл өлшенген OEE немесе мақсаттың орындалуының дәлелі емес.",
+ "Actual OEE needs an ideal cycle and planned time. The illustration assumes 8 hours per row and a 240-second ideal cycle (120 cars / 8 h). This is not measured OEE or proof of target achievement."
+ ],
+ "Линия / дата": [
+ "Желі / күні",
+ "Line / date"
+ ],
+ "A · доступность": [
+ "A · қолжетімділік",
+ "A · availability"
+ ],
+ "P · темп": [
+ "P · қарқын",
+ "P · performance"
+ ],
+ "Q · годные": [
+ "Q · жарамды",
+ "Q · quality"
+ ],
+ "Выше 100%: проверить цикл": [
+ "100%-дан жоғары: циклді тексеріңіз",
+ "Above 100%: check the cycle"
+ ],
+ "оценка": [
+ "бағалау",
+ "estimate"
+ ],
+ "Что требует решения руководителя": [
+ "Басшының шешімін қажет ететін мәселелер",
+ "Issues requiring a management decision"
+ ],
+ "Объяснимые сигналы. Двух дней недостаточно для вероятностного прогноза отказов.": [
+ "Түсіндірілетін белгілер. Ақаулар ықтималдығын болжау үшін екі күн жеткіліксіз.",
+ "Explainable signals. Two days are insufficient for a probabilistic failure forecast."
+ ],
+ "Правила и расчёты": [
+ "Ережелер және есептер",
+ "Rules and calculations"
+ ],
+ "01 / ПЛАН": [
+ "01 / ЖОСПАР",
+ "01 / PLAN"
+ ],
+ "700 автомобилей без модели": [
+ "Моделі белгісіз 700 автокөлік",
+ "700 vehicles without a model"
+ ],
+ "Исходные заказы покрывают": [
+ "Бастапқы тапсырыстар қамтиды:",
+ "Source orders cover"
+ ],
+ "% месячного минимума. Даже идеальное исполнение оставляет разрыв.": [
+ "% айлық минимумды. Мінсіз орындалса да, алшақтық қалады.",
+ "% of the monthly minimum. Even perfect execution leaves a gap."
+ ],
+ "Согласовать дополнительные заказы": [
+ "Қосымша тапсырыстарды келісу",
+ "Agree additional orders"
+ ],
+ "02 / КАЧЕСТВО": [
+ "02 / САПА",
+ "02 / QUALITY"
+ ],
+ "Окраска:": [
+ "Бояу:",
+ "Painting:"
+ ],
+ "% брака": [
+ "% ақау",
+ "% rejects"
+ ],
+ "10 дефектов на 231 изделие; 2 октября — 6 из 116 (": [
+ "231 өнімге 10 ақау; 2 қазанда — 116 өнімге 6 ақау (",
+ "10 defects in 231 units; on 2 October, 6 out of 116 ("
+ ],
+ "%). Обе записи выше предела 2%.": [
+ "%). Екі жазба да 2% шегінен жоғары.",
+ "%). Both records exceed the 2% limit."
+ ],
+ "Проверить фильтр и режим окраски; связь с дефектами пока не доказана": [
+ "Сүзгі мен бояу режимін тексеру; ақаулармен байланысы әлі дәлелденбеген",
+ "Check the filter and painting settings; a link to defects has not been established"
+ ],
+ "03 / НАДЁЖНОСТЬ": [
+ "03 / СЕНІМДІЛІК",
+ "03 / RELIABILITY"
+ ],
+ "Конвейер-03: 55 минут": [
+ "Конвейер-03: 55 минут",
+ "Conveyor-03: 55 minutes"
+ ],
+ "Если конвейер критический, запас до суточного лимита — только 5 минут. Следующая остановка может привести к превышению.": [
+ "Конвейер маңызды жабдық болса, тәуліктік шекке дейін тек 5 минут қалады. Келесі тоқтау шектен асуға әкелуі мүмкін.",
+ "If the conveyor is critical, only 5 minutes remain before the daily limit. Another stop may exceed it."
+ ],
+ "Проверить цепь, натяжение и запасные части": [
+ "Шынжырды, керілуін және қосалқы бөлшектерді тексеру",
+ "Check the chain, tension and spare parts"
+ ],
+ "Сценарный потенциал качества": [
+ "Сапаны жақсартудың сценарийлік әлеуеті",
+ "Quality improvement potential"
+ ],
+ "дефекта меньше / 231 изделие": [
+ "ақауға аз / 231 өнім",
+ "fewer defects / 231 units"
+ ],
+ "При снижении брака окраски до 2% и том же объёме обработки. Это ожидаемое снижение дефектов, а не гарантированный дополнительный выпуск. Денежный эффект не рассчитан: нет себестоимости, цены ремонта и данных о переделке.": [
+ "Бояу ақауы 2%-ға төмендеп, өңдеу көлемі өзгермесе. Бұл — кепілді қосымша өнім емес, күтілетін ақау азаюы. Ақшалай әсер есептелмеген: өзіндік құн, жөндеу бағасы және қайта өңдеу деректері жоқ.",
+ "Assumes painting rejects fall to 2% at the same volume. This estimates fewer defects, not guaranteed extra output. Financial impact is not calculated: production costs, repair prices and rework data are unavailable."
+ ],
+ "Связь данных с моделью и допущения": [
+ "Деректердің модельмен байланысы және болжамдар",
+ "Data mapping and assumptions"
+ ],
+ "Маршрут из PDF: склад комплектующих → сварка → окраска → сборка → ОТК → склад готовой продукции. Размеров нет; 3D-планировка условная.": [
+ "PDF маршруты: бөлшектер қоймасы → дәнекерлеу → бояу → құрастыру → сапаны бақылау → дайын өнім қоймасы. Өлшемдер жоқ; 3D сызбасы шартты.",
+ "PDF route: parts storage → welding → painting → assembly → quality control → finished goods. Dimensions are unavailable; the 3D layout is illustrative."
+ ],
+ "Эффективный цикл = сумма часов работы × 3600 / сумма изделий: сварка 236 с, окраска 237 с, сборка 239 с после округления. Это оценка по агрегатам, не паспортный цикл.": [
+ "Тиімді цикл = жұмыс сағаттарының қосындысы × 3600 / өнім саны: дөңгелектеуден кейін дәнекерлеу 236 с, бояу 237 с, құрастыру 239 с. Бұл — жиынтық деректер бағасы, жабдықтың паспорттық циклі емес.",
+ "Effective cycle = total operating hours × 3600 / total units: welding 236 s, painting 237 s, assembly 239 s after rounding. These are aggregate estimates, not rated machine cycles."
+ ],
+ "ОТК 180 с, буферы 8 мест, скорость ленты 0,5 условной единицы/с — допущения.": [
+ "Сапаны бақылау 180 с, буферлер 8 орын, таспа жылдамдығы 0,5 шартты бірлік/с — болжамдар.",
+ "Quality control at 180 s, 8-space buffers and belt speed of 0.5 relative units/s are assumptions."
+ ],
+ "Циклы одинаковы для трёх моделей: данных о различиях и переналадке нет. Смена начинается с пустой линии и первого кузова; начальный НЗП и перенос между сменами не предоставлены. Поэтому результат модели не приравнивается к факту из таблицы.": [
+ "Үш модельдің циклі бірдей: айырмашылықтар мен қайта баптау деректері жоқ. Ауысым бос желіден және алғашқы кузовтан басталады; бастапқы аяқталмаған өндіріс пен ауысымдар арасындағы қалдық берілмеген. Сондықтан модель нәтижесі кестедегі нақты мәнге теңестірілмейді.",
+ "All three models use the same cycles; differences and changeovers are unknown. A shift starts with an empty line and the first body. Initial WIP and carry-over are unavailable, so model output is not equated to table actuals."
+ ],
+ "Финальный брак модели 1,25% временно принят по сборке (3 / 240). Качество после ОТК неизвестно; брак сварки и окраски показан отдельно, не суммируется.": [
+ "Модельдің соңғы ақауы құрастыру бойынша уақытша 1,25% (3 / 240) деп алынған. Сапаны бақылаудан кейінгі сапа белгісіз; дәнекерлеу мен бояу ақаулары бөлек көрсетіліп, қосылмайды.",
+ "Final rejects are provisionally set at 1.25% from assembly (3 / 240). Quality after inspection is unknown; welding and painting defects are shown separately and are not added together."
+ ],
+ "Остановки: 40 мин для Камеры-02, 55 мин для Конвейера-03. Начало на 20-й минуте условное. Это сценарии, не воспроизведение дня.": [
+ "Тоқтаулар: Камера-02 үшін 40 мин, Конвейер-03 үшін 55 мин. 20-минутта басталуы шартты. Бұл — күнді қайталау емес, сценарийлер.",
+ "Stops: 40 min for Chamber-02 and 55 min for Conveyor-03. Starting at minute 20 is assumed. These are scenarios, not a replay of the day."
+ ],
+ "Время и простои не согласуются напрямую: окраска 1 октября — 7,5 ч + 40 мин = 8 ч 10 мин. Нужны границы смен и состав оборудования линии.": [
+ "Жұмыс пен тоқтау уақыты тікелей сәйкес келмейді: 1 қазанда бояу — 7,5 сағ + 40 мин = 8 сағ 10 мин. Ауысым шекаралары мен желі жабдықтарының құрамы қажет.",
+ "Operating time and downtime do not directly reconcile: painting on 1 October gives 7.5 h + 40 min = 8 h 10 min. Shift boundaries and line equipment scope are needed."
+ ],
+ "Данные устарели": [
+ "Деректер ескірген",
+ "Data is stale"
+ ],
+ "Смена завершена": [
+ "Ауысым аяқталды",
+ "Shift complete"
+ ],
+ "Идёт · ×{0}": [
+ "Орындалуда · ×{0}",
+ "Running · ×{0}"
+ ],
+ "Пауза": [
+ "Үзіліс",
+ "Paused"
+ ],
+ "Виртуальные часы смены": [
+ "Ауысымның виртуалды сағаты",
+ "Virtual shift clock"
+ ],
+ "Время смены (симуляция)": [
+ "Ауысым уақыты (симуляция)",
+ "Shift time (simulation)"
+ ],
+ "Время в симуляции {0}": [
+ "Симуляция уақыты {0}",
+ "Simulation time {0}"
+ ],
+ "Прогресс смены": [
+ "Ауысым барысы",
+ "Shift progress"
+ ],
+ "· прошло": [
+ "· өтті",
+ "· elapsed"
+ ],
+ "Продолжить смену": [
+ "Ауысымды жалғастыру",
+ "Continue shift"
+ ],
+ "Обслужить окраску": [
+ "Бояуға қызмет көрсету",
+ "Service painting"
+ ],
+ "Резерв мощности сборки": [
+ "Құрастыру қуатының резерві",
+ "Assembly capacity reserve"
+ ],
+ "Текущий режим": [
+ "Ағымдағы режим",
+ "Current mode"
+ ],
+ "Обслуживание": [
+ "Қызмет көрсету",
+ "Maintenance"
+ ],
+ "Удвоение мощности": [
+ "Қуатты екі есе арттыру",
+ "Double capacity"
+ ],
+ "Годный выпуск за смену: три варианта": [
+ "Ауысымдағы жарамды өнім: үш нұсқа",
+ "Good output per shift: three options"
+ ],
+ "График выпуска годных автомобилей по сценариям решений": [
+ "Шешім сценарийлері бойынша жарамды автокөлік шығару кестесі",
+ "Good vehicle output by decision scenario"
+ ],
+ "План:": [
+ "Жоспар:",
+ "Plan:"
+ ],
+ "шт.": [
+ "дана",
+ "units"
+ ],
+ "Резерв мощности": [
+ "Қуат резерві",
+ "Capacity reserve"
+ ],
+ "План смены": [
+ "Ауысым жоспары",
+ "Shift plan"
+ ],
+ "Камера-02 · 40 мин": [
+ "Камера-02 · 40 мин",
+ "Chamber-02 · 40 min"
+ ],
+ "Конвейер-03 · 55 мин": [
+ "Конвейер-03 · 55 мин",
+ "Conveyor-03 · 55 min"
+ ],
+ "Без остановок": [
+ "Тоқтаусыз",
+ "No stops"
+ ],
+ "Три варианта одной смены": [
+ "Бір ауысымның үш нұсқасы",
+ "Three options for one shift"
+ ],
+ "Расчёт не меняет текущую линию.": [
+ "Есеп ағымдағы желіні өзгертпейді.",
+ "The calculation does not change the current line."
+ ],
+ "Длительность ТО окраски:": [
+ "Бояуға қызмет көрсету ұзақтығы:",
+ "Painting maintenance duration:"
+ ],
+ "5 минут": [
+ "5 минут",
+ "5 minutes"
+ ],
+ "10 минут (базовое ТО)": [
+ "10 минут (базалық қызмет)",
+ "10 minutes (basic service)"
+ ],
+ "15 минут": [
+ "15 минут",
+ "15 minutes"
+ ],
+ "20 минут": [
+ "20 минут",
+ "20 minutes"
+ ],
+ "Подготовка резерва сборки:": [
+ "Құрастыру резервін дайындау:",
+ "Assembly reserve setup:"
+ ],
+ "0 минут (готов к пуску)": [
+ "0 минут (іске қосуға дайын)",
+ "0 minutes (ready to start)"
+ ],
+ "5 минут (переналадка)": [
+ "5 минут (қайта баптау)",
+ "5 minutes (changeover)"
+ ],
+ "10 минут": [
+ "10 минут",
+ "10 minutes"
+ ],
+ "Расчёт...": [
+ "Есептелуде…",
+ "Calculating…"
+ ],
+ "Выполняется расчёт вариантов на основе текущего состояния линии...": [
+ "Желінің ағымдағы күйі бойынша нұсқалар есептелуде…",
+ "Calculating options from the current line state…"
+ ],
+ "Ошибка расчёта:": [
+ "Есеп қатесі:",
+ "Calculation error:"
+ ],
+ "Повторить попытку": [
+ "Қайталау",
+ "Try again"
+ ],
+ "Смена завершена (": [
+ "Ауысым аяқталды (",
+ "Shift complete ("
+ ],
+ "). Сбросьте смену, чтобы сравнить новые решения.": [
+ "). Жаңа шешімдерді салыстыру үшін ауысымды қайта бастаңыз.",
+ "). Reset the shift to compare new decisions."
+ ],
+ "Параметры изменены (выбрано: ТО": [
+ "Параметрлер өзгерді (таңдалған қызмет көрсету:",
+ "Parameters changed (selected maintenance:"
+ ],
+ "мин, резерв": [
+ "мин, резерв",
+ "min, reserve"
+ ],
+ "мин). Ниже показан результат предыдущего расчёта (": [
+ "мин). Төменде алдыңғы есеп нәтижесі көрсетілген (",
+ "min). The previous calculation is shown below ("
+ ],
+ "мин /": [
+ "мин /",
+ "min /"
+ ],
+ "мин). Нажмите «Сравнить решения» для перерасчёта.": [
+ "мин). Қайта есептеу үшін «Шешімдерді салыстыру» түймесін басыңыз.",
+ "min). Select “Compare decisions” to recalculate."
+ ],
+ "Оцените действие до его выполнения": [
+ "Әрекетті орындамас бұрын бағалаңыз",
+ "Evaluate before taking action"
+ ],
+ "Выберите время подготовки и запустите расчёт до": [
+ "Дайындық уақытын таңдап, мына уақытқа дейін есептеңіз:",
+ "Choose a setup time and calculate until"
+ ],
+ "01 · Продолжить": [
+ "01 · Жалғастыру",
+ "01 · Continue"
+ ],
+ "Текущий сценарий": [
+ "Ағымдағы сценарий",
+ "Current scenario"
+ ],
+ "02 · Обслужить окраску": [
+ "02 · Бояуға қызмет көрсету",
+ "02 · Service painting"
+ ],
+ "Пауза на ТО, устранение дальнейших отказов": [
+ "Қызмет көрсетуге үзіліс, кейінгі ақауларды жою",
+ "Maintenance pause, preventing later failures"
+ ],
+ "03 · Резерв сборки": [
+ "03 · Құрастыру резерві",
+ "03 · Assembly reserve"
+ ],
+ "Подготовка, затем удвоенный темп": [
+ "Дайындық, содан кейін екі еселенген қарқын",
+ "Setup, then double the rate"
+ ],
+ "ИТОГ РАСЧЁТА": [
+ "ЕСЕП ҚОРЫТЫНДЫСЫ",
+ "CALCULATION RESULT"
+ ],
+ "{0}: +{1} годных авто": [
+ "{0}: +{1} жарамды авто",
+ "{0}: +{1} good cars"
+ ],
+ "Дополнительные действия не повышают выпуск": [
+ "Қосымша әрекеттер өнімді арттырмайды",
+ "Extra actions do not increase output"
+ ],
+ "{0} авто к концу смены при выбранных условиях.": [
+ "Таңдалған шарттарда ауысым соңына дейін {0} авто.",
+ "{0} cars by shift end under the selected conditions."
+ ],
+ "В этих условиях продолжение смены даёт не меньший выпуск.": [
+ "Бұл жағдайда ауысымды жалғастыру кем емес өнім береді.",
+ "Under these conditions, continuing the shift produces at least as much."
+ ],
+ "Сравнение по выпуску; стоимость ресурсов не учтена.": [
+ "Өнім көлемі салыстырылады; ресурс құны ескерілмеген.",
+ "Compared by output; resource costs are excluded."
+ ],
+ "Расчёт от:": [
+ "Есеп басталуы:",
+ "Calculated from:"
+ ],
+ "Сценарий:": [
+ "Сценарий:",
+ "Scenario:"
+ ],
+ "Горизонт: до": [
+ "Есеп мерзімі:",
+ "Calculate until"
+ ],
+ "План смены:": [
+ "Ауысым жоспары:",
+ "Shift plan:"
+ ],
+ "По моделям и условиям": [
+ "Модельдер мен шарттар бойынша",
+ "By model and conditions"
+ ],
+ "Годные к заказу модели за выбранную смену": [
+ "Таңдалған ауысымда модель тапсырысына жарамды өнім",
+ "Good units towards each model’s order in this shift"
+ ],
+ "Повторение такой смены весь месяц:": [
+ "Осындай ауысым бүкіл ай қайталанса:",
+ "If this shift were repeated all month:"
+ ],
+ "Грубая экстраполяция, не прогноз; квоты смен и перенос НЗП могут отличаться.": [
+ "Бұл — болжам емес, жуық экстраполяция; ауысым квоталары мен аяқталмаған өндіріс қалдығы өзгеруі мүмкін.",
+ "A rough extrapolation, not a forecast; shift quotas and WIP carry-over may differ."
+ ],
+ "Годная продукция:": [
+ "Жарамды өнім:",
+ "Good output:"
+ ],
+ "— (база)": [
+ "— (база)",
+ "— (baseline)"
+ ],
+ "{0} шт.": [
+ "{0} дана",
+ "{0} units"
+ ],
+ "Отклонение от плана (": [
+ "Жоспардан ауытқу (",
+ "Deviation from plan ("
+ ],
+ "шт.):": [
+ "дана):",
+ "units):"
+ ],
+ "Простой оборудования:": [
+ "Жабдықтың тоқтап тұруы:",
+ "Equipment downtime:"
+ ],
+ "{0} мин": [
+ "{0} мин",
+ "{0} min"
+ ],
+ "Остаток в линии (WIP):": [
+ "Желідегі қалдық (WIP):",
+ "Remaining in line (WIP):"
+ ],
+ "Разница — относительно продолжения смены. Пунктир отмечает начало сравнения.": [
+ "Айырма ауысымды жалғастырумен салыстырылады. Үзік сызық салыстыру басталуын көрсетеді.",
+ "Differences are relative to continuing the shift. The dashed line marks the comparison start."
+ ],
+ "Допущения модели и синтетические условия (": [
+ "Модель болжамдары және синтетикалық шарттар (",
+ "Model assumptions and synthetic conditions ("
+ ],
+ "Результат зависит от следующих условий учебной модели:": [
+ "Нәтиже оқу моделінің келесі шарттарына тәуелді:",
+ "Results depend on these demonstration model assumptions:"
+ ],
+ "Смена изменилась. Повторите расчёт для нового состояния.": [
+ "Ауысым өзгерді. Жаңа күй үшін есепті қайталаңыз.",
+ "The shift has changed. Recalculate for the new state."
+ ],
+ "Расчёт мощности по параметрам кейса, без дополнительных остановок.": [
+ "Қосымша тоқтауларсыз кейс параметрлері бойынша қуат есебі.",
+ "Capacity calculation from case parameters, without additional stops."
+ ],
+ "Окраска · остановка 40 мин": [
+ "Бояу · 40 мин тоқтау",
+ "Painting · 40-min stop"
+ ],
+ "Камера-02: замена фильтра. Начало на 20-й минуте условное, длительность взята из кейса.": [
+ "Камера-02: сүзгіні ауыстыру. 20-минутта басталуы шартты, ұзақтығы кейстен алынған.",
+ "Chamber-02: filter replacement. The minute-20 start is assumed; duration comes from the case."
+ ],
+ "Сборка · остановка 55 мин": [
+ "Құрастыру · 55 мин тоқтау",
+ "Assembly · 55-min stop"
+ ],
+ "Конвейер-03: обрыв цепи. Начало на 20-й минуте условное, длительность взята из кейса.": [
+ "Конвейер-03: шынжыр үзілуі. 20-минутта басталуы шартты, ұзақтығы кейстен алынған.",
+ "Conveyor-03: chain break. The minute-20 start is assumed; duration comes from the case."
+ ],
+ "Управление сменой": [
+ "Ауысымды басқару",
+ "Shift controls"
+ ],
+ "Нет связи": [
+ "Байланыс жоқ",
+ "Offline"
+ ],
+ "Смена идёт": [
+ "Ауысым жүріп жатыр",
+ "Shift running"
+ ],
+ "Приостановить смену": [
+ "Ауысымды тоқтата тұру",
+ "Pause shift"
+ ],
+ "Запустить смену": [
+ "Ауысымды бастау",
+ "Start shift"
+ ],
+ "Запустить": [
+ "Бастау",
+ "Start"
+ ],
+ "Скорость симуляции": [
+ "Симуляция жылдамдығы",
+ "Simulation speed"
+ ],
+ "Сценарий": [
+ "Сценарий",
+ "Scenario"
+ ],
+ "Сценарий — начинает новую смену": [
+ "Сценарий — жаңа ауысым басталады",
+ "Scenario — starts a new shift"
+ ],
+ "Сбросить смену": [
+ "Ауысымды қайта бастау",
+ "Reset shift"
+ ],
+ "Применяем команду": [
+ "Пәрмен қолданылуда",
+ "Applying command"
+ ],
+ "Скрыть сообщение об ошибке": [
+ "Қате хабарын жасыру",
+ "Dismiss error"
+ ],
+ "Конфигурация применена. Новая смена на паузе; можно запускать расчёт.": [
+ "Конфигурация қолданылды. Жаңа ауысым үзілісте; есепті бастауға болады.",
+ "Configuration applied. The new shift is paused and ready to run."
+ ],
+ "Конфигурация превышает 64 КиБ.": [
+ "Конфигурация көлемі 64 КиБ-тан асады.",
+ "Configuration exceeds 64 KiB."
+ ],
+ "Файл превышает 2 МиБ. Разделите данные на смены.": [
+ "Файл көлемі 2 МиБ-тан асады. Деректерді ауысымдар бойынша бөліңіз.",
+ "File exceeds 2 MiB. Split the data by shifts."
+ ],
+ "Не удалось прочитать JSON. Проверьте запятые, кавычки и кодировку UTF-8.": [
+ "JSON оқылмады. Үтірлерді, тырнақшаларды және UTF-8 кодтауын тексеріңіз.",
+ "Cannot read JSON. Check commas, quotation marks and UTF-8 encoding."
+ ],
+ "Файл «{0}» проверен. Ниже — параметры перед применением.": [
+ "«{0}» файлы тексерілді. Қолданар алдындағы параметрлер төменде.",
+ "File “{0}” validated. Review the parameters below before applying."
+ ],
+ "Загружено {0} записей из «{1}».": [
+ "«{1}» файлынан {0} жазба жүктелді.",
+ "Loaded {0} records from “{1}”."
+ ],
+ "Не удалось прочитать текст UTF-8. Сохраните файл в этой кодировке.": [
+ "UTF-8 мәтіні оқылмады. Файлды осы кодтауда сақтаңыз.",
+ "Cannot read UTF-8 text. Save the file with this encoding."
+ ],
+ "Не удалось прочитать файл.": [
+ "Файл оқылмады.",
+ "Could not read the file."
+ ],
+ "Подключить свои данные": [
+ "Өз деректерін қосу",
+ "Connect your data"
+ ],
+ "История из CSV": [
+ "CSV тарихы",
+ "CSV history"
+ ],
+ "Синтетическая модель": [
+ "Синтетикалық модель",
+ "Synthetic model"
+ ],
+ "Сценарный расчёт": [
+ "Сценарийлік есеп",
+ "Scenario calculation"
+ ],
+ "Режим данных": [
+ "Деректер режимі",
+ "Data mode"
+ ],
+ "Параметры модели": [
+ "Модель параметрлері",
+ "Model parameters"
+ ],
+ "История CSV": [
+ "CSV тарихы",
+ "CSV history"
+ ],
+ "Загрузить JSON": [
+ "JSON жүктеу",
+ "Upload JSON"
+ ],
+ "Шаблон JSON": [
+ "JSON үлгісі",
+ "JSON template"
+ ],
+ "Скачать текущую": [
+ "Ағымдағысын жүктеп алу",
+ "Download current"
+ ],
+ "Файл конфигурации JSON": [
+ "JSON конфигурация файлы",
+ "JSON configuration file"
+ ],
+ "Будет применено": [
+ "Қолданылады",
+ "To be applied"
+ ],
+ "Применение обнулит текущую смену и поставит новую на паузу. Сценарий и скорость времени сохранятся.": [
+ "Қолдану ағымдағы ауысымды өшіріп, жаңасын үзіліске қояды. Сценарий мен уақыт жылдамдығы сақталады.",
+ "Applying resets the current shift and pauses the new one. Scenario and speed are preserved."
+ ],
+ "Файл обрабатывается на этом устройстве.": [
+ "Файл осы құрылғыда өңделеді.",
+ "The file is processed on this device."
+ ],
+ "После применения параметры будут переданы серверу текущей сессии.": [
+ "Қолданғаннан кейін параметрлер ағымдағы сессия серверіне жіберіледі.",
+ "After applying, parameters are sent to the current session server."
+ ],
+ "Применить и начать новую смену": [
+ "Қолдану және жаңа ауысым бастау",
+ "Apply and start a new shift"
+ ],
+ "Отменить загрузку": [
+ "Жүктеуден бас тарту",
+ "Cancel upload"
+ ],
+ "Параметры активной модели": [
+ "Белсенді модель параметрлері",
+ "Active model parameters"
+ ],
+ "Сейчас применяется": [
+ "Қазір қолданылуда",
+ "Currently applied"
+ ],
+ "Время — секунды; скорость — условные единицы в секунду. Четыре участка, фиксированный порядок операций.": [
+ "Уақыт — секунд; жылдамдық — шартты бірлік/с. Төрт учаске, операциялар реті бекітілген.",
+ "Time is in seconds; speed in relative units/s. Four stations with a fixed operation sequence."
+ ],
+ "Учебные параметры готовы к применению.": [
+ "Оқу параметрлері қолдануға дайын.",
+ "Demo parameters are ready to apply."
+ ],
+ "Открыть учебную конфигурацию": [
+ "Оқу конфигурациясын ашу",
+ "Open demo configuration"
+ ],
+ "Загрузить историю CSV": [
+ "CSV тарихын жүктеу",
+ "Upload CSV history"
+ ],
+ "Шаблон CSV": [
+ "CSV үлгісі",
+ "CSV template"
+ ],
+ "Учебный пример · синтетические записи": [
+ "Оқу мысалы · синтетикалық жазбалар",
+ "Demo example · synthetic records"
+ ],
+ "Открыт синтетический пример формата. Это не данные завода.": [
+ "Пішімнің синтетикалық мысалы ашылды. Бұл зауыт деректері емес.",
+ "Synthetic format example opened. These are not factory data."
+ ],
+ "Открыть учебный пример": [
+ "Оқу мысалын ашу",
+ "Open demo example"
+ ],
+ "История удалена из текущего просмотра.": [
+ "Тарих ағымдағы көріністен жойылды.",
+ "History removed from the current view."
+ ],
+ "Закрыть историю": [
+ "Тарихты жабу",
+ "Close history"
+ ],
+ "Файл истории CSV": [
+ "CSV тарих файлы",
+ "CSV history file"
+ ],
+ "Формат CSV и хранение": [
+ "CSV пішімі және сақтау",
+ "CSV format and storage"
+ ],
+ "UTF-8, до 2 МиБ и 10 000 записей. Время — секунды от начала смены; выпуск, брак, план и простой — нарастающим итогом; НЗП — на момент записи. Файл остаётся на устройстве и исчезает при обновлении страницы.": [
+ "UTF-8, 2 МиБ және 10 000 жазбаға дейін. Уақыт — ауысым басынан секундпен; өнім, ақау, жоспар және тоқтау — жинақталған мәндер; аяқталмаған өндіріс — жазба сәтіндегі мән. Файл құрылғыда қалады және бетті жаңартқанда жойылады.",
+ "UTF-8, up to 2 MiB and 10,000 records. Time is seconds since shift start; output, rejects, plan and downtime are cumulative; WIP is a point-in-time value. The file stays on the device and is cleared on reload."
+ ],
+ "Проверяем файл…": [
+ "Файл тексерілуде…",
+ "Checking file…"
+ ],
+ "Файл не принят.": [
+ "Файл қабылданбады.",
+ "File rejected."
+ ],
+ "Ранее загруженная история сохранена.": [
+ "Бұрын жүктелген тарих сақталды.",
+ "Previously loaded history is preserved."
+ ],
+ "Текущие данные сохранены.": [
+ "Ағымдағы деректер сақталды.",
+ "Current data is preserved."
+ ],
+ "Загружаем просмотр истории…": [
+ "Тарих көрінісі жүктелуде…",
+ "Loading history viewer…"
+ ],
+ "Загрузите CSV или откройте учебный пример для просмотра истории.": [
+ "Тарихты көру үшін CSV жүктеңіз немесе оқу мысалын ашыңыз.",
+ "Upload a CSV or open the demo example to view history."
+ ],
+ "синтетические параметры": [
+ "синтетикалық параметрлер",
+ "synthetic parameters"
+ ],
+ "параметры для сценарного расчёта; происхождение загруженного файла не проверяется": [
+ "сценарийлік есеп параметрлері; жүктелген файлдың шығу тегі тексерілмейді",
+ "scenario parameters; the uploaded file’s provenance is not verified"
+ ],
+ "Месячный план:": [
+ "Айлық жоспар:",
+ "Monthly plan:"
+ ],
+ "авто ·": [
+ "авто ·",
+ "cars ·"
+ ],
+ "рабочих дней × 2 смены · смена №": [
+ "жұмыс күні × 2 ауысым · ауысым №",
+ "working days × 2 shifts · shift no."
+ ],
+ ". Не распределено:": [
+ ". Бөлінбеген:",
+ ". Unallocated:"
+ ],
+ "авто.": [
+ "авто.",
+ "cars."
+ ],
+ "Длительность": [
+ "Ұзақтығы",
+ "Duration"
+ ],
+ "с)": [
+ "с)",
+ "s)"
+ ],
+ "Подача кузова": [
+ "Кузов беру",
+ "Body feed"
+ ],
+ "каждые": [
+ "әр",
+ "every"
+ ],
+ "с": [
+ "с",
+ "s"
+ ],
+ "Вероятность брака": [
+ "Ақау ықтималдығы",
+ "Reject probability"
+ ],
+ "Скорость ленты": [
+ "Таспа жылдамдығы",
+ "Belt speed"
+ ],
+ "у.е./с": [
+ "ш.б./с",
+ "units/s"
+ ],
+ "Цикл, с": [
+ "Цикл, с",
+ "Cycle, s"
+ ],
+ "Буфер, авто": [
+ "Буфер, авто",
+ "Buffer, cars"
+ ],
+ "В пути": [
+ "Жолда",
+ "Moving"
+ ],
+ "В очереди": [
+ "Кезекте",
+ "Queued"
+ ],
+ "Обработка": [
+ "Өңделуде",
+ "Processing"
+ ],
+ "Выход занят": [
+ "Шығу жолы бос емес",
+ "Exit blocked"
+ ],
+ "К выходу": [
+ "Шығуға қарай",
+ "Outbound"
+ ],
+ "Размер схемы должен быть не больше 64 КБ.": [
+ "Сызба көлемі 64 КБ-тан аспауы керек.",
+ "Layout must not exceed 64 KB."
+ ],
+ "Не удалось прочитать JSON. Сверьте файл с шаблоном схемы.": [
+ "JSON оқылмады. Файлды сызба үлгісімен салыстырыңыз.",
+ "Cannot read JSON. Check the file against the layout template."
+ ],
+ "Не удалось загрузить схему.": [
+ "Сызба жүктелмеді.",
+ "Could not load the layout."
+ ],
+ "Вид производственной линии": [
+ "Өндірістік желі көрінісі",
+ "Production line view"
+ ],
+ "3D-цех": [
+ "3D цех",
+ "3D shop floor"
+ ],
+ "2D-схема": [
+ "2D сызба",
+ "2D diagram"
+ ],
+ "Камера": [
+ "Камера",
+ "Camera"
+ ],
+ "Общий вид": [
+ "Жалпы көрініс",
+ "Overview"
+ ],
+ "Сверху": [
+ "Жоғарыдан",
+ "Top view"
+ ],
+ "К участку": [
+ "Учаскеге",
+ "Focus station"
+ ],
+ "Следить за машиной": [
+ "Көлікті қадағалау",
+ "Follow vehicle"
+ ],
+ "Свернуть": [
+ "Кішірейту",
+ "Collapse"
+ ],
+ "Развернуть": [
+ "Кеңейту",
+ "Expand"
+ ],
+ "3D недоступно на этом устройстве. Открыта 2D-схема; расчёты и управление доступны.": [
+ "Бұл құрылғыда 3D қолжетімсіз. 2D сызба ашылды; есептеу мен басқару қолжетімді.",
+ "3D is unavailable on this device. The 2D diagram is open; calculations and controls remain available."
+ ],
+ "Машины на конвейере": [
+ "Конвейердегі көліктер",
+ "Vehicles on conveyor"
+ ],
+ "В линии": [
+ "Желіде",
+ "In line"
+ ],
+ "На обработке": [
+ "Өңдеуде",
+ "Processing"
+ ],
+ "Ожидают": [
+ "Күтуде",
+ "Waiting"
+ ],
+ "Интерактивный трёхмерный цех": [
+ "Интерактивті үшөлшемді цех",
+ "Interactive 3D shop floor"
+ ],
+ "Двумерная схема": [
+ "Екіөлшемді сызба",
+ "2D diagram"
+ ],
+ "Собираем трёхмерный цех…": [
+ "Үшөлшемді цех жүктелуде…",
+ "Loading the 3D shop floor…"
+ ],
+ "Готовим оборудование и камеру": [
+ "Жабдық пен камера дайындалуда",
+ "Preparing equipment and camera"
+ ],
+ "ЦЕХ /": [
+ "ЦЕХ /",
+ "SHOP FLOOR /"
+ ],
+ "ШТАТНЫЙ РЕЖИМ": [
+ "ҚАЛЫПТЫ РЕЖИМ",
+ "NORMAL OPERATION"
+ ],
+ "СЦЕНАРИЙ": [
+ "СЦЕНАРИЙ",
+ "SCENARIO"
+ ],
+ "Смена на паузе": [
+ "Ауысым үзілісте",
+ "Shift paused"
+ ],
+ "Размеры из загруженной схемы": [
+ "Жүктелген сызбаның өлшемдері",
+ "Dimensions from uploaded layout"
+ ],
+ "Условные размеры": [
+ "Шартты өлшемдер",
+ "Illustrative dimensions"
+ ],
+ "Автомобиль покинул линию": [
+ "Көлік желіден шықты",
+ "Vehicle has left the line"
+ ],
+ "Отмечен брак · следует к выходу": [
+ "Ақаулы деп белгіленген · шығуға барады",
+ "Marked as rejected · moving to exit"
+ ],
+ "Цвета моделей после окраски": [
+ "Бояудан кейінгі модель түстері",
+ "Model colours after painting"
+ ],
+ "Вращение — перетаскивание · масштаб — колесо": [
+ "Айналдыру — сүйреу · масштаб — дөңгелек",
+ "Drag to rotate · scroll to zoom"
+ ],
+ "Графика": [
+ "Графика",
+ "Graphics"
+ ],
+ "Качество 3D": [
+ "3D сапасы",
+ "3D quality"
+ ],
+ "Стандартная": [
+ "Стандартты",
+ "Standard"
+ ],
+ "Экономная": [
+ "Үнемді",
+ "Economy"
+ ],
+ "Выбрать участок в 3D": [
+ "3D учаскесін таңдау",
+ "Select a station in 3D"
+ ],
+ "Буфер": [
+ "Буфер",
+ "Buffer"
+ ],
+ "· ждут": [
+ "· күтуде",
+ "· waiting"
+ ],
+ "Анимация отключена согласно настройкам устройства. Показатели продолжают обновляться.": [
+ "Құрылғы баптауы бойынша анимация өшірілген. Көрсеткіштер жаңартыла береді.",
+ "Animation is disabled by device settings. Metrics continue to update."
+ ],
+ "Автомобили по ID ·": [
+ "ID бойынша көліктер ·",
+ "Vehicles by ID ·"
+ ],
+ "Выберите машину для наблюдения. Входной буфер включает подъезжающие машины и остановившуюся очередь.": [
+ "Қадағалау үшін көлікті таңдаңыз. Кіріс буферіне жақындаған көліктер мен тоқтаған кезек кіреді.",
+ "Select a vehicle to follow. The input buffer includes approaching vehicles and the stationary queue."
+ ],
+ "На конвейере нет автомобилей.": [
+ "Конвейерде көлік жоқ.",
+ "No vehicles on the conveyor."
+ ],
+ "Настроить размещение участков": [
+ "Учаскелердің орналасуын баптау",
+ "Configure station placement"
+ ],
+ "Можно загрузить расположение четырёх участков по шаблону. Файл читается на этом устройстве и меняет только 3D-размещение. Показатели рассчитывает модель; обновление страницы вернёт учебную схему.": [
+ "Төрт учаскенің орналасуын үлгі бойынша жүктеуге болады. Файл осы құрылғыда оқылып, тек 3D орналасуды өзгертеді. Көрсеткіштерді модель есептейді; бетті жаңарту оқу сызбасын қайтарады.",
+ "Upload the four-station layout using the template. The file is read on this device and changes only 3D placement. Metrics come from the model; reloading restores the demo layout."
+ ],
+ "Шаблон схемы": [
+ "Сызба үлгісі",
+ "Layout template"
+ ],
+ "Загрузить схему": [
+ "Сызбаны жүктеу",
+ "Upload layout"
+ ],
+ "Вернуть учебную схему": [
+ "Оқу сызбасын қайтару",
+ "Restore demo layout"
+ ],
+ "Файл схемы участков": [
+ "Учаскелер сызбасының файлы",
+ "Station layout file"
+ ],
+ "Загружена схема «": [
+ "Жүктелген сызба: «",
+ "Loaded layout “"
+ ],
+ "». Производственные параметры не изменены.": [
+ "». Өндіріс параметрлері өзгерген жоқ.",
+ "”. Production parameters are unchanged."
+ ],
+ "{0}. Статус: {1}. Очередь {2} из {3}.": [
+ "{0}. Күйі: {1}. Кезекте {2} / {3}.",
+ "{0}. Status: {1}. Queue {2} of {3}."
+ ],
+ "Загрузка {0} процентов.": [
+ "Жүктеме {0} пайыз.",
+ "Utilization {0} percent."
+ ],
+ "Есть активный инцидент.": [
+ "Белсенді оқиға бар.",
+ "There is an active incident."
+ ],
+ "ЭТАП {0}": [
+ "КЕЗЕҢ {0}",
+ "STAGE {0}"
+ ],
+ "Цикл": [
+ "Цикл",
+ "Cycle"
+ ],
+ "Активный инцидент: {0}": [
+ "Белсенді оқиға: {0}",
+ "Active incident: {0}"
+ ],
+ "Склад снабжения. Подано на линию {0} единиц. Значение расчётное.": [
+ "Жабдықтау қоймасы. Желіге {0} өнім берілді. Мән есептелген.",
+ "Supply warehouse. {0} units fed into the line. This is a calculated value."
+ ],
+ "ИСТОЧНИК": [
+ "КІРІС",
+ "INPUT"
+ ],
+ "Склад снабжения": [
+ "Жабдықтау қоймасы",
+ "Supply warehouse"
+ ],
+ "подано на линию": [
+ "желіге берілген",
+ "fed into line"
+ ],
+ "расчётная величина": [
+ "есептелген мән",
+ "calculated value"
+ ],
+ "Готовые автомобили: {0} из {1} по плану, брак {2}.": [
+ "Дайын көліктер: жоспардағы {1} ішінен {0}, ақаулы {2}.",
+ "Finished vehicles: {0} of {1} planned, {2} rejected."
+ ],
+ "ВЫХОД": [
+ "ШЫҒЫС",
+ "OUTPUT"
+ ],
+ "Готовые автомобили": [
+ "Дайын көліктер",
+ "Finished vehicles"
+ ],
+ "из {0} по плану": [
+ "жоспардағы {0} ішінен",
+ "of {0} planned"
+ ],
+ "брак: {0}": [
+ "ақаулы: {0}",
+ "rejects: {0}"
+ ],
+ "Очередь перед станцией «{0}»: {1} из {2}": [
+ "«{0}» алдындағы кезек: {1} / {2}",
+ "Queue before “{0}”: {1} of {2}"
+ ],
+ "Очередь {0}/{1}": [
+ "Кезек {0}/{1}",
+ "Queue {0}/{1}"
+ ],
+ "Годные": [
+ "Жарамды",
+ "Good units"
+ ],
+ "Схема линии (прокручиваемая область)": [
+ "Желі сызбасы (айналдырылатын аймақ)",
+ "Line diagram (scrollable area)"
+ ],
+ "Схема производственной линии: склад снабжения, сварка, окраска, сборка, контроль качества, готовые автомобили": [
+ "Өндірістік желі: жабдықтау қоймасы, дәнекерлеу, бояу, құрастыру, сапаны бақылау, дайын көліктер",
+ "Production line: supply warehouse, welding, painting, assembly, quality control, finished vehicles"
+ ],
+ "В потоке: {0} ед.": [
+ "Ағында: {0} дана",
+ "In flow: {0} units"
+ ],
+ "Схема иллюстративная · расчёт модели": [
+ "Сызба шартты · модель есебі",
+ "Illustrative diagram · model calculation"
+ ],
+ "Прокрутите схему по горизонтали, чтобы увидеть все этапы.": [
+ "Барлық кезеңді көру үшін сызбаны көлденең жылжытыңыз.",
+ "Scroll horizontally to see all stages."
+ ],
+ "Оценка выпуска к концу смены": [
+ "Ауысым соңындағы өнім бағасы",
+ "Estimated output at shift end"
+ ],
+ "Собираем статистику выпуска": [
+ "Өндіріс статистикасы жиналуда",
+ "Collecting output statistics"
+ ],
+ "Оценка появится после первого проверенного автомобиля.": [
+ "Бағалау алғашқы тексерілген көліктен кейін пайда болады.",
+ "An estimate appears after the first inspected vehicle."
+ ],
+ "Оценка по текущему темпу": [
+ "Ағымдағы қарқын бойынша бағалау",
+ "Estimate at current rate"
+ ],
+ "ед. к": [
+ "дана, мерзімі",
+ "units by"
+ ],
+ "Ниже плана": [
+ "Жоспардан төмен",
+ "Below plan"
+ ],
+ "Не ниже плана": [
+ "Жоспардан кем емес",
+ "On or above plan"
+ ],
+ "ед. к плану": [
+ "дана, жоспарға қатысты",
+ "units vs plan"
+ ],
+ "({0}% плана)": [
+ "({0}% жоспар)",
+ "({0}% of plan)"
+ ],
+ "Экстраполяция среднего темпа с начала смены. Не учитывает будущие сбои и управленческие действия.": [
+ "Ауысым басынан орташа қарқын экстраполяциясы. Болашақ ақаулар мен басқару әрекеттерін ескермейді.",
+ "Extrapolates the average rate since shift start. Excludes future failures and management actions."
+ ],
+ "Показатели из файла · происхождение не проверено": [
+ "Файл көрсеткіштері · шығу тегі тексерілмеген",
+ "File metrics · provenance not verified"
+ ],
+ "Показаны исходные записи. Движение отдельных автомобилей по агрегатам не восстанавливается.": [
+ "Бастапқы жазбалар көрсетілген. Жиынтық деректерден жеке көлік қозғалысы қалпына келтірілмейді.",
+ "Source records are shown. Individual vehicle movement cannot be reconstructed from aggregates."
+ ],
+ "{0} ч": [
+ "{0} сағ",
+ "{0} h"
+ ],
+ "{0} с": [
+ "{0} с",
+ "{0} s"
+ ],
+ "0 с": [
+ "0 с",
+ "0 s"
+ ],
+ "от начала файла": [
+ "файл басынан",
+ "since file start"
+ ],
+ "Выпущено": [
+ "Шығарылған",
+ "Produced"
+ ],
+ "Брак": [
+ "Ақаулы",
+ "Rejects"
+ ],
+ "В работе": [
+ "Өңдірісте",
+ "Work in progress"
+ ],
+ "Простои": [
+ "Тоқтаулар",
+ "Downtime"
+ ],
+ "Значения записи из файла": [
+ "Файл жазбасының мәндері",
+ "Values from file record"
+ ],
+ "В наборе нет записей для отображения.": [
+ "Көрсетілетін жазбалар жоқ.",
+ "No records to display."
+ ],
+ "Воспроизведение на паузе.": [
+ "Ойнату үзілісте.",
+ "Playback paused."
+ ],
+ "Идёт воспроизведение по записям.": [
+ "Жазбалар ойнатылуда.",
+ "Playing records."
+ ],
+ "Достигнута последняя запись, автовоспроизведение остановлено.": [
+ "Соңғы жазбаға жетті, автоматты ойнату тоқтады.",
+ "Last record reached; autoplay stopped."
+ ],
+ "Выпущено годных": [
+ "Жарамды өнім шығарылды",
+ "Good units produced"
+ ],
+ "годные / (годные + брак)": [
+ "жарамды / (жарамды + ақаулы)",
+ "good / (good + rejected)"
+ ],
+ "Отклонение от плана": [
+ "Жоспардан ауытқу",
+ "Deviation from plan"
+ ],
+ "годные − план": [
+ "жарамды − жоспар",
+ "good − plan"
+ ],
+ "Статус данных": [
+ "Деректер күйі",
+ "Data status"
+ ],
+ "Набор:": [
+ "Жиынтық:",
+ "Dataset:"
+ ],
+ "Воспроизведение записей": [
+ "Жазбаларды ойнату",
+ "Record playback"
+ ],
+ "« В начало": [
+ "« Басына",
+ "« First"
+ ],
+ "‹ Предыдущая запись": [
+ "‹ Алдыңғы жазба",
+ "‹ Previous record"
+ ],
+ "❚❚ Пауза": [
+ "❚❚ Үзіліс",
+ "❚❚ Pause"
+ ],
+ "▶ Воспроизвести": [
+ "▶ Ойнату",
+ "▶ Play"
+ ],
+ "Следующая запись ›": [
+ "Келесі жазба ›",
+ "Next record ›"
+ ],
+ "Выбранная запись (номер по порядку)": [
+ "Таңдалған жазба (реттік нөмір)",
+ "Selected record (sequence number)"
+ ],
+ "Запись {0} из {1}, {2} от начала файла": [
+ "Жазба {0} / {1}, файл басынан {2}",
+ "Record {0} of {1}, {2} since file start"
+ ],
+ "Запись": [
+ "Жазба",
+ "Record"
+ ],
+ "из": [
+ "ішінен",
+ "of"
+ ],
+ "от начала файла (": [
+ "файл басынан (",
+ "since file start ("
+ ],
+ "интервал от предыдущей записи:": [
+ "алдыңғы жазбадан аралық:",
+ "interval since previous record:"
+ ],
+ "Следующая исходная запись — каждые 0,9 с. Пропуски не заполняются.": [
+ "Келесі бастапқы жазба әр 0,9 секунд сайын көрсетіледі. Бос аралықтар толтырылмайды.",
+ "The next source record appears every 0.9 s. Gaps are not filled."
+ ],
+ "Показатели выбранной записи": [
+ "Таңдалған жазбаның көрсеткіштері",
+ "Selected record metrics"
+ ],
+ "Простои, суммарно": [
+ "Жалпы тоқтау",
+ "Total downtime"
+ ],
+ "— сумма по 4 единицам оборудования; может превышать прошедшее время.": [
+ "— 4 жабдық бойынша қосынды; өткен уақыттан асуы мүмкін.",
+ "— total for 4 machines; may exceed elapsed time."
+ ],
+ "График исторических записей": [
+ "Тарихи жазбалар кестесі",
+ "Historical records chart"
+ ],
+ "Годные и план по записям файла": [
+ "Файл жазбалары бойынша жарамды өнім және жоспар",
+ "Good output and plan from file records"
+ ],
+ "записей ·": [
+ "жазба ·",
+ "records ·"
+ ],
+ "от начала смены. Линии соединяют измерения ступенями; значения между записями неизвестны.": [
+ "ауысым басынан. Сызықтар өлшемдерді сатылы қосады; аралық мәндер белгісіз.",
+ "since shift start. Step lines connect measurements; values between records are unknown."
+ ],
+ "Исторические записи из файла:": [
+ "Файлдағы тарихи жазбалар:",
+ "Historical records in file:"
+ ],
+ "точек, от": [
+ "нүкте, бастап",
+ "points, from"
+ ],
+ "до": [
+ "дейін",
+ "to"
+ ],
+ "от начала файла. В последней записи годных": [
+ "файл басынан. Соңғы жазбада жарамды өнім",
+ "since file start. Last record good output"
+ ],
+ ", план": [
+ ", жоспар",
+ ", plan"
+ ],
+ ". Между записями значения не достраиваются (ступенчатая линия). Выбрана запись": [
+ ". Жазбалар арасындағы мәндер есептелмейді (сатылы сызық). Таңдалған жазба",
+ ". Values between records are not inferred (step line). Selected record"
+ ],
+ ", годных": [
+ ", жарамды",
+ ", good"
+ ],
+ "План (по файлу)": [
+ "Жоспар (файлдан)",
+ "Plan (from file)"
+ ],
+ "Выпущено годных (по файлу)": [
+ "Жарамды өнім (файлдан)",
+ "Good output (from file)"
+ ],
+ "Выбранная запись": [
+ "Таңдалған жазба",
+ "Selected record"
+ ],
+ "Все записи": [
+ "Барлық жазбалар",
+ "All records"
+ ],
+ "Записи": [
+ "Жазбалар",
+ "Records"
+ ],
+ "‹ Назад": [
+ "‹ Артқа",
+ "‹ Previous"
+ ],
+ "Страница": [
+ "Бет",
+ "Page"
+ ],
+ "Вперёд ›": [
+ "Алға ›",
+ "Next ›"
+ ],
+ "Записи из файла, строки": [
+ "Файл жазбалары, жолдар",
+ "File records, rows"
+ ],
+ ". Значения выбранной записи отмечены.": [
+ ". Таңдалған жазба мәндері белгіленген.",
+ ". Selected record values are highlighted."
+ ],
+ "Время от начала": [
+ "Басталғаннан бері",
+ "Time since start"
+ ],
+ "Простои, с": [
+ "Тоқтау, с",
+ "Downtime, s"
+ ],
+ "Инциденты смены": [
+ "Ауысым оқиғалары",
+ "Shift incidents"
+ ],
+ "Активных:": [
+ "Белсенді:",
+ "Active:"
+ ],
+ "Остановок в этой смене пока нет.": [
+ "Бұл ауысымда әлі тоқтау болған жоқ.",
+ "No stops in this shift yet."
+ ],
+ "Устранён": [
+ "Жойылған",
+ "Resolved"
+ ],
+ "Активен": [
+ "Белсенді",
+ "Active"
+ ],
+ "Причина и условия": [
+ "Себебі және шарттары",
+ "Cause and conditions"
+ ],
+ "Устранён в {0} · длительность {1}": [
+ "{0} кезінде жойылды · ұзақтығы {1}",
+ "Resolved at {0} · duration {1}"
+ ],
+ "Идёт уже {0} симуляции": [
+ "Симуляцияда {0} бойы жалғасуда",
+ "Ongoing for {0} of simulation time"
+ ],
+ "Ключевые показатели смены": [
+ "Ауысымның негізгі көрсеткіштері",
+ "Key shift metrics"
+ ],
+ "Годный выпуск": [
+ "Жарамды өнім",
+ "Good output"
+ ],
+ "По графику": [
+ "Кесте бойынша",
+ "On schedule"
+ ],
+ "{0} к графику": [
+ "Кестеге қатысты {0}",
+ "{0} vs schedule"
+ ],
+ "· сейчас нужно": [
+ "· қазір қажет",
+ "· needed now"
+ ],
+ "Данные о графике появятся после старта": [
+ "Кесте деректері іске қосылғаннан кейін пайда болады",
+ "Schedule data appears after starting"
+ ],
+ "Производительность": [
+ "Өнімділік",
+ "Throughput"
+ ],
+ "В потоке: {0} ед. · брак: {1} ед.": [
+ "Ағында: {0} дана · ақаулы: {1} дана",
+ "In flow: {0} units · rejects: {1} units"
+ ],
+ "ед./ч": [
+ "дана/сағ",
+ "units/h"
+ ],
+ "Простой оборудования": [
+ "Жабдықтың тоқтауы",
+ "Equipment downtime"
+ ],
+ "Сумма остановок участков": [
+ "Учаскелер тоқтауының қосындысы",
+ "Total station stops"
+ ],
+ "Доля годных": [
+ "Жарамды өнім үлесі",
+ "Good-output share"
+ ],
+ "Нет завершённых автомобилей": [
+ "Аяқталған көліктер жоқ",
+ "No completed vehicles"
+ ],
+ "Годных: {0} · брак: {1} ед.": [
+ "Жарамды: {0} · ақаулы: {1} дана",
+ "Good: {0} · rejects: {1} units"
+ ],
+ "Легенда": [
+ "Белгілер",
+ "Legend"
+ ],
+ "Как читать схему и график": [
+ "Сызба мен кестені түсіну",
+ "How to read the diagram and chart"
+ ],
+ "Статусы станций": [
+ "Учаскелер күйі",
+ "Station status"
+ ],
+ "Элементы схемы": [
+ "Сызба элементтері",
+ "Diagram elements"
+ ],
+ "Очередь": [
+ "Кезек",
+ "Queue"
+ ],
+ "— занятые и свободные места буфера перед станцией; янтарный цвет — буфер почти полон.": [
+ "— учаске алдындағы буфердің бос және бос емес орындары; сарғыш түс буфердің толуға жақын екенін білдіреді.",
+ "— occupied and free buffer spaces before a station; amber means the buffer is nearly full."
+ ],
+ "Бегущий пунктир": [
+ "Қозғалатын үзік сызық",
+ "Moving dashed line"
+ ],
+ "— поток изделий; анимация идёт только при запущенной симуляции и живой связи.": [
+ "— өнім ағыны; анимация симуляция іске қосылғанда және байланыс бар кезде ғана жүреді.",
+ "— product flow; animation runs only while the simulation is running and connected."
+ ],
+ "Значок «!»": [
+ "«!» белгісі",
+ "“!” symbol"
+ ],
+ "— у станции есть активный инцидент.": [
+ "— учаскеде белсенді оқиға бар.",
+ "— the station has an active incident."
+ ],
+ "О данных": [
+ "Деректер туралы",
+ "About the data"
+ ],
+ "Склад снабжения показывает число поданных на линию изделий. При занятом входе очередная подача пропускается. НЗП включает автомобили между контролем качества и выходом; выпуск учитывается на выходе с линии. Показатели рассчитаны моделью, схема иллюстративная.": [
+ "Жабдықтау қоймасы желіге берілген өнім санын көрсетеді. Кіріс бос болмаса, кезекті беру өткізіледі. Аяқталмаған өндіріс сапаны бақылау мен шығу арасындағы көліктерді қамтиды; өнім желіден шыққанда есептеледі. Көрсеткіштер модельмен есептелген, сызба шартты.",
+ "The supply warehouse shows units fed into the line. A feed is skipped when the entrance is occupied. WIP includes vehicles between quality control and the exit; output is counted at line exit. Metrics are modelled and the diagram is illustrative."
+ ],
+ "Выпуск за смену": [
+ "Ауысымдағы өнім",
+ "Shift output"
+ ],
+ "Загружаем график…": [
+ "Кесте жүктелуде…",
+ "Loading chart…"
+ ],
+ "ПЛАН ОКТЯБРЯ": [
+ "ҚАЗАН ЖОСПАРЫ",
+ "OCTOBER PLAN"
+ ],
+ "Настроить": [
+ "Баптау",
+ "Configure"
+ ],
+ "заказы": [
+ "тапсырыстар",
+ "orders"
+ ],
+ "Подключить месячный план": [
+ "Айлық жоспарды қосу",
+ "Connect monthly plan"
+ ],
+ "ещё не распределены по моделям": [
+ "модельдерге әлі бөлінбеген",
+ "not yet allocated to models"
+ ],
+ "распределены по моделям": [
+ "модельдерге бөлінген",
+ "allocated to models"
+ ],
+ "Распределить заказы": [
+ "Тапсырыстарды бөлу",
+ "Allocate orders"
+ ],
+ "Открыть план": [
+ "Жоспарды ашу",
+ "Open plan"
+ ],
+ "Состояние участков": [
+ "Учаскелер күйі",
+ "Station status"
+ ],
+ "Участки": [
+ "Учаскелер",
+ "Stations"
+ ],
+ "Последнее состояние": [
+ "Соңғы күй",
+ "Latest state"
+ ],
+ "{0} авто в линии": [
+ "Желіде {0} авто",
+ "{0} cars in line"
+ ],
+ "Открыть участок: {0}": [
+ "Учаскені ашу: {0}",
+ "Open station: {0}"
+ ],
+ "ЕСТЬ ОТКЛОНЕНИЕ": [
+ "АУЫТҚУ БАР",
+ "DEVIATION DETECTED"
+ ],
+ "ПРОВЕРИТЬ РЕШЕНИЕ": [
+ "ШЕШІМДІ ТЕКСЕРУ",
+ "CHECK A DECISION"
+ ],
+ "{0}: поток ограничен": [
+ "{0}: ағын шектелген",
+ "{0}: flow restricted"
+ ],
+ "Что изменит выпуск?": [
+ "Өнім көлемін не өзгертеді?",
+ "What could change output?"
+ ],
+ "Сравните восстановление потока и резерв мощности.": [
+ "Ағынды қалпына келтіру мен қуат резервін салыстырыңыз.",
+ "Compare flow recovery and capacity reserve."
+ ],
+ "Рассчитайте эффект обслуживания и резерва мощности до конца смены.": [
+ "Ауысым соңына дейін қызмет көрсету мен қуат резервінің әсерін есептеңіз.",
+ "Calculate the effect of maintenance and capacity reserve until shift end."
+ ],
+ "Сравнить варианты": [
+ "Нұсқаларды салыстыру",
+ "Compare options"
+ ],
+ "Время симуляции": [
+ "Симуляция уақыты",
+ "Simulation time"
+ ],
+ "ед.": [
+ "дана",
+ "units"
+ ],
+ "Сейчас произведено {0} ед., по плану к этому моменту {1} ед.": [
+ "Қазір {0} дана шығарылды, осы уақыттағы жоспар {1} дана.",
+ "Produced {0} units so far; the plan at this time is {1} units."
+ ],
+ "История выпуска пока пуста.": [
+ "Өндіріс тарихы әзірге бос.",
+ "Output history is empty."
+ ],
+ "к графику": [
+ "кестеге қатысты",
+ "vs schedule"
+ ],
+ "Обозначения графика": [
+ "Кесте белгілері",
+ "Chart legend"
+ ],
+ "Расчёт модели": [
+ "Модель есебі",
+ "Model calculation"
+ ],
+ "График выпуска: расчёт модели, план и оценка по текущему темпу": [
+ "Өнім кестесі: модель есебі, жоспар және ағымдағы қарқын бағасы",
+ "Output chart: model calculation, plan and estimate at current rate"
+ ],
+ "Сейчас": [
+ "Қазір",
+ "Now"
+ ],
+ "Информация о смене": [
+ "Ауысым туралы ақпарат",
+ "Shift information"
+ ],
+ "Закрыть информацию": [
+ "Ақпаратты жабу",
+ "Close information"
+ ],
+ "Смена сценария и сброс обнуляют выпуск и время. План и скорость сохраняются.": [
+ "Сценарийді ауыстыру мен қайта бастау өнім мен уақытты нөлдейді. Жоспар мен жылдамдық сақталады.",
+ "Changing the scenario or resetting clears output and time. The plan and speed are preserved."
+ ],
+ "Фокус смены": [
+ "Ауысым назары",
+ "Shift focus"
+ ],
+ "Активных инцидентов нет": [
+ "Белсенді оқиғалар жоқ",
+ "No active incidents"
+ ],
+ "Показатели": [
+ "Көрсеткіштер",
+ "Metrics"
+ ],
+ "Наибольшая очередь: {0}": [
+ "Ең үлкен кезек: {0}",
+ "Longest queue: {0}"
+ ],
+ "Входные очереди свободны": [
+ "Кіріс кезектері бос",
+ "Input queues are clear"
+ ],
+ "DriveIndUI — обзор": [
+ "DriveIndUI — шолу",
+ "DriveIndUI — overview"
+ ],
+ "ПРОИЗВОДСТВО / Allur": [
+ "ӨНДІРІС / Allur",
+ "PRODUCTION / Allur"
+ ],
+ "Основная навигация": [
+ "Негізгі навигация",
+ "Main navigation"
+ ],
+ "Тестовый кейс": [
+ "Сынақ кейсі",
+ "Test case"
+ ],
+ "Октябрь 2026": [
+ "Қазан 2026",
+ "October 2026"
+ ],
+ "Запускаем демонстрационную смену…": [
+ "Демонстрациялық ауысым басталуда…",
+ "Starting the demo shift…"
+ ],
+ "Создаём сессию симуляции и получаем первые данные линии.": [
+ "Симуляция сессиясы құрылып, желінің алғашқы деректері алынуда.",
+ "Creating a simulation session and loading the first line data."
+ ],
+ "Не удалось подключиться к серверу симуляции": [
+ "Симуляция серверіне қосылу мүмкін болмады",
+ "Could not connect to the simulation server"
+ ],
+ "Сервер недоступен.": [
+ "Сервер қолжетімсіз.",
+ "Server unavailable."
+ ],
+ "Автоматические попытки продолжаются каждые несколько секунд.": [
+ "Автоматты әрекеттер бірнеше секунд сайын қайталанады.",
+ "Automatic retries continue every few seconds."
+ ],
+ "Повторить сейчас": [
+ "Қазір қайталау",
+ "Retry now"
+ ],
+ "Связь с сервером потеряна.": [
+ "Сервермен байланыс үзілді.",
+ "Server connection lost."
+ ],
+ "Показаны последние полученные данные": [
+ "Соңғы алынған деректер көрсетілген",
+ "Showing the last received data"
+ ],
+ "(получены в {0})": [
+ "({0} кезінде алынған)",
+ "(received at {0})"
+ ],
+ ", схема остановлена. Повторяем подключение автоматически.": [
+ ", сызба тоқтатылған. Қосылым автоматты түрде қайталанады.",
+ "; the diagram is paused. Reconnecting automatically."
+ ],
+ "Причина:": [
+ "Себебі:",
+ "Reason:"
+ ],
+ "Повторить": [
+ "Қайталау",
+ "Retry"
+ ],
+ "Выбор станции": [
+ "Учаскені таңдау",
+ "Select station"
+ ],
+ "Активные инциденты": [
+ "Белсенді оқиғалар",
+ "Active incidents"
+ ],
+ "Активных инцидентов по станции нет.": [
+ "Учаскеде белсенді оқиғалар жоқ.",
+ "No active incidents at this station."
+ ],
+ "Прогресс цикла": [
+ "Цикл барысы",
+ "Cycle progress"
+ ],
+ "Цикл {0}": [
+ "Цикл {0}",
+ "Cycle {0}"
+ ],
+ "Нет изделия в обработке": [
+ "Өңделіп жатқан өнім жоқ",
+ "No unit being processed"
+ ],
+ "Входной буфер": [
+ "Кіріс буфері",
+ "Input buffer"
+ ],
+ "{0} из {1}": [
+ "{0} / {1}",
+ "{0} of {1}"
+ ],
+ "{0} ожидают · {1} подъезжают{2}": [
+ "{0} күтуде · {1} жақындауда{2}",
+ "{0} waiting · {1} approaching{2}"
+ ],
+ "· буфер заполнен": [
+ "· буфер толған",
+ "· buffer full"
+ ],
+ "Доля времени в работе с начала смены": [
+ "Ауысым басынан жұмыс уақытының үлесі",
+ "Share of time working since shift start"
+ ],
+ "Выпущено станцией": [
+ "Учаске шығарған өнім",
+ "Station output"
+ ],
+ "{0} ед.": [
+ "{0} дана",
+ "{0} units"
+ ],
+ "Текущий темп": [
+ "Ағымдағы қарқын",
+ "Current rate"
+ ],
+ "{0} ед./ч": [
+ "{0} дана/сағ",
+ "{0} units/h"
+ ],
+ "Мощность по текущему циклу": [
+ "Ағымдағы цикл бойынша қуат",
+ "Capacity at current cycle"
+ ],
+ "Без учёта ожидания и блокировок": [
+ "Күту мен бұғаттаулар ескерілмеген",
+ "Excluding waiting and blocking"
+ ],
+ "Только остановки оборудования": [
+ "Тек жабдық тоқтаулары",
+ "Equipment stops only"
+ ],
+ "Подключение…": [
+ "Қосылуда…",
+ "Connecting…"
+ ],
+ "Подключено": [
+ "Қосылған",
+ "Connected"
+ ],
+ "Связь потеряна": [
+ "Байланыс үзілді",
+ "Connection lost"
+ ],
+ "Allur / Октябрь 2026": [
+ "Allur / Қазан 2026",
+ "Allur / October 2026"
+ ],
+ "Локальный режим": [
+ "Жергілікті режим",
+ "Local mode"
+ ],
+ "Сессия": [
+ "Сессия",
+ "Session"
+ ],
+ "{0} ч {1} мин": [
+ "{0} сағ {1} мин",
+ "{0} h {1} min"
+ ],
+ "{0} мин {1} с": [
+ "{0} мин {1} с",
+ "{0} min {1} s"
+ ],
+ "Не найден корневой элемент #root": [
+ "#root түбір элементі табылмады",
+ "Root element #root not found"
+ ],
+ "Обзор смены": [
+ "Ауысымға шолу",
+ "Shift overview"
+ ],
+ "Обзор": [
+ "Шолу",
+ "Overview"
+ ],
+ "Показатели и отклонения": [
+ "Көрсеткіштер және ауытқулар",
+ "Metrics and deviations"
+ ],
+ "Производственный цех": [
+ "Өндірістік цех",
+ "Shop floor"
+ ],
+ "Цех": [
+ "Цех",
+ "Shop floor"
+ ],
+ "Оборудование, машины и очереди": [
+ "Жабдық, көліктер және кезектер",
+ "Equipment, vehicles and queues"
+ ],
+ "Производственный план": [
+ "Өндірістік жоспар",
+ "Production plan"
+ ],
+ "Заказы на месяц и квоты смены": [
+ "Айлық тапсырыстар және ауысым квоталары",
+ "Monthly orders and shift quotas"
+ ],
+ "Сравнение решений": [
+ "Шешімдерді салыстыру",
+ "Decision comparison"
+ ],
+ "Решения": [
+ "Шешімдер",
+ "Decisions"
+ ],
+ "Эффект до конца текущей смены": [
+ "Ағымдағы ауысым соңына дейінгі әсер",
+ "Impact until the end of this shift"
+ ],
+ "Данные и настройки": [
+ "Деректер және баптаулар",
+ "Data and settings"
+ ],
+ "Данные": [
+ "Деректер",
+ "Data"
+ ],
+ "Источники, импорт и методика": [
+ "Дереккөздер, импорт және әдістеме",
+ "Sources, import and methodology"
+ ],
+ "ИИ · прогноз рисков": [
+ "ЖИ · тәуекелдер болжамы",
+ "AI · risk forecast"
+ ],
+ "ИИ": [
+ "ЖИ",
+ "AI"
+ ],
+ "Простои, узкие места и рекомендации": [
+ "Тоқтаулар, тар орындар және ұсыныстар",
+ "Downtime, bottlenecks and recommendations"
+ ],
+ "Работает": [
+ "Жұмыс істеп тұр",
+ "Running"
+ ],
+ "Станция обрабатывает изделие.": [
+ "Учаске өнімді өңдеп жатыр.",
+ "The station is processing a unit."
+ ],
+ "Ожидание": [
+ "Күтуде",
+ "Waiting"
+ ],
+ "Участок ждёт подхода изделия к посту. Не считается простоем оборудования.": [
+ "Учаске өнімнің келуін күтеді. Жабдық тоқтауы ретінде саналмайды.",
+ "The station is waiting for a unit to arrive. This is not counted as equipment downtime."
+ ],
+ "Блокировка выхода": [
+ "Шығыс бұғатталған",
+ "Output blocked"
+ ],
+ "Блокировка": [
+ "Бұғаттау",
+ "Blocked"
+ ],
+ "Изделие готово, но выход занят или следующий буфер заполнен. Не считается простоем оборудования.": [
+ "Өнім дайын, бірақ шығыс бос емес немесе келесі буфер толған. Жабдық тоқтауы ретінде саналмайды.",
+ "The unit is ready, but the exit is occupied or the next buffer is full. This is not equipment downtime."
+ ],
+ "Остановлена": [
+ "Тоқтаған",
+ "Stopped"
+ ],
+ "Оборудование остановлено — это учитывается как простой.": [
+ "Жабдық тоқтаған — тоқтап тұру ретінде есептеледі.",
+ "Equipment is stopped; this counts as downtime."
+ ],
+ "Предупреждение": [
+ "Ескерту",
+ "Warning"
+ ],
+ "Отклонение в работе оборудования: возможно замедление.": [
+ "Жабдық жұмысында ауытқу бар: баяулауы мүмкін.",
+ "Equipment is operating abnormally and may slow down."
+ ],
+ "Критический": [
+ "Өте маңызды",
+ "Critical"
+ ],
+ "Для этих координат не хватает места для конвейера. Измените расположение или поворот участков.": [
+ "Бұл координаттарда конвейерге орын жетпейді. Учаскелердің орнын немесе бұрылуын өзгертіңіз.",
+ "Insufficient conveyor space at these coordinates. Change station positions or rotations."
+ ],
+ "Маршрут конвейера пересекает сам себя. Измените координаты или повороты участков.": [
+ "Конвейер маршруты өзін кесіп өтеді. Учаскелердің координаттарын немесе бұрылуын өзгертіңіз.",
+ "The conveyor route crosses itself. Change station coordinates or rotations."
+ ],
+ "Для 3D требуется поддержка WebGL. Доступна 2D-схема.": [
+ "3D үшін WebGL қолдауы қажет. 2D сызба қолжетімді.",
+ "3D requires WebGL support. The 2D diagram is available."
+ ],
+ "· буфер": [
+ "· буфер",
+ "· buffer"
+ ],
+ "Камера-02": [
+ "Камера-02",
+ "Chamber-02"
+ ],
+ "Конвейер-03": [
+ "Конвейер-03",
+ "Conveyor-03"
+ ],
+ "ОТК · условный пост": [
+ "Сапаны бақылау · шартты пост",
+ "QC · assumed station"
+ ],
+ "Склад комплектующих": [
+ "Бөлшектер қоймасы",
+ "Parts storage"
+ ],
+ "Склад готовой продукции": [
+ "Дайын өнім қоймасы",
+ "Finished goods storage"
+ ],
+ "Подано: {0}": [
+ "Берілген: {0}",
+ "Fed: {0}"
+ ],
+ "{0} годных · {1} брак": [
+ "{0} жарамды · {1} ақаулы",
+ "{0} good · {1} rejected"
+ ],
+ "Маршрут кейса · условная планировка": [
+ "Кейс маршруты · шартты орналасу",
+ "Case route · illustrative layout"
+ ],
+ "Неверный формат данных: ожидается объект": [
+ "Деректер пішімі жарамсыз: объект күтіледі",
+ "Invalid data format: expected an object"
+ ],
+ "Неподдерживаемая версия схемы (ожидается 1)": [
+ "Сызба нұсқасы қолдау таппайды (1 күтіледі)",
+ "Unsupported layout version (expected 1)"
+ ],
+ "Название схемы должно быть строкой": [
+ "Сызба атауы мәтін болуы керек",
+ "Layout title must be a string"
+ ],
+ "Название схемы не может быть пустым": [
+ "Сызба атауы бос болмауы керек",
+ "Layout title cannot be empty"
+ ],
+ "Название схемы не может превышать 80 символов": [
+ "Сызба атауы 80 таңбадан аспауы керек",
+ "Layout title must not exceed 80 characters"
+ ],
+ "Единицы измерения должны быть \"relative\" или \"meters\"": [
+ "Өлшем бірлігі \"relative\" немесе \"meters\" болуы керек",
+ "Units must be \"relative\" or \"meters\""
+ ],
+ "Отсутствуют или неверно заданы размеры цеха": [
+ "Цех өлшемдері жоқ немесе жарамсыз",
+ "Missing or invalid floor dimensions"
+ ],
+ "Размеры цеха должны быть числами": [
+ "Цех өлшемдері сан болуы керек",
+ "Floor dimensions must be numbers"
+ ],
+ "Размеры цеха должны быть конечными числами": [
+ "Цех өлшемдері ақырлы сандар болуы керек",
+ "Floor dimensions must be finite numbers"
+ ],
+ "Размеры цеха должны быть от 20 до 100": [
+ "Цех өлшемдері 20 мен 100 аралығында болуы керек",
+ "Floor dimensions must be between 20 and 100"
+ ],
+ "Отсутствует или неверно задан список станций": [
+ "Учаскелер тізімі жоқ немесе жарамсыз",
+ "Missing or invalid station list"
+ ],
+ "Должно быть ровно 4 станции": [
+ "Дәл 4 учаске болуы керек",
+ "Exactly 4 stations are required"
+ ],
+ "Неверный формат объекта станции": [
+ "Учаске объектісінің пішімі жарамсыз",
+ "Invalid station object format"
+ ],
+ "Неизвестная станция: {0}": [
+ "Белгісіз учаске: {0}",
+ "Unknown station: {0}"
+ ],
+ "Дублирование станции: {0}": [
+ "Учаске қайталанған: {0}",
+ "Duplicate station: {0}"
+ ],
+ "Неверная позиция для станции {0}": [
+ "{0} учаскесінің орны жарамсыз",
+ "Invalid position for station {0}"
+ ],
+ "Координаты станции {0} должны быть конечными числами": [
+ "{0} учаскесінің координаттары ақырлы сандар болуы керек",
+ "Coordinates for station {0} must be finite numbers"
+ ],
+ "Станция {0} выходит за границы цеха или слишком близко к краю (отступ 6)": [
+ "{0} учаскесі цех шегінен шығады немесе шетке тым жақын (аралық 6)",
+ "Station {0} is outside the floor or too close to the edge (margin 6)"
+ ],
+ "Неверный угол поворота для станции {0}: {1}": [
+ "{0} учаскесінің бұрылу бұрышы жарамсыз: {1}",
+ "Invalid rotation for station {0}: {1}"
+ ],
+ "Отсутствуют или неверно заданы терминалы": [
+ "Терминалдар жоқ немесе жарамсыз",
+ "Missing or invalid terminals"
+ ],
+ "Неверный формат терминала {0}": [
+ "{0} терминалының пішімі жарамсыз",
+ "Invalid terminal format: {0}"
+ ],
+ "Координаты терминала {0} должны быть конечными числами": [
+ "{0} терминалының координаттары ақырлы сандар болуы керек",
+ "Coordinates for terminal {0} must be finite numbers"
+ ],
+ "Терминал {0} выходит за границы цеха или слишком близко к краю (отступ 3)": [
+ "{0} терминалы цех шегінен шығады немесе шетке тым жақын (аралық 3)",
+ "Terminal {0} is outside the floor or too close to the edge (margin 3)"
+ ],
+ "Станции {0} и {1} расположены слишком близко": [
+ "{0} және {1} учаскелері тым жақын орналасқан",
+ "Stations {0} and {1} are too close"
+ ],
+ "Терминал {0} слишком близко к станции {1}": [
+ "{0} терминалы {1} учаскесіне тым жақын",
+ "Terminal {0} is too close to station {1}"
+ ],
+ "Терминалы supply и finished слишком близко друг к другу": [
+ "supply және finished терминалдары бір-біріне тым жақын",
+ "Supply and finished terminals are too close"
+ ],
+ "Обнаружены неизвестные свойства в корневом объекте": [
+ "Түбір объектіде белгісіз қасиеттер анықталды",
+ "Unknown properties in the root object"
+ ],
+ "Обнаружены неизвестные свойства в объекте floor": [
+ "floor объектісінде белгісіз қасиеттер анықталды",
+ "Unknown properties in floor"
+ ],
+ "Обнаружены неизвестные свойства в объекте станции {0}": [
+ "{0} учаскесінің объектісінде белгісіз қасиеттер анықталды",
+ "Unknown properties in station {0}"
+ ],
+ "Обнаружены неизвестные свойства в объекте terminals": [
+ "terminals объектісінде белгісіз қасиеттер анықталды",
+ "Unknown properties in terminals"
+ ],
+ "Сессия не найдена на сервере. Создаём новую смену…": [
+ "Сессия серверден табылмады. Жаңа ауысым құрылуда…",
+ "Session not found on the server. Creating a new shift…"
+ ],
+ "Не удалось применить команду: {0}": [
+ "Пәрменді қолдану мүмкін болмады: {0}",
+ "Could not apply command: {0}"
+ ],
+ "Контроль качества": [
+ "Сапаны бақылау",
+ "Quality control"
+ ],
+ "Тестовые данные организатора · 1–2 октября 2026": [
+ "Ұйымдастырушының сынақ деректері · 1–2 қазан 2026",
+ "Organizer test data · 1–2 October 2026"
+ ],
+ "Сварка-1": [
+ "Дәнекерлеу-1",
+ "Welding-1"
+ ],
+ "Окраска-1": [
+ "Бояу-1",
+ "Painting-1"
+ ],
+ "Сборка-1": [
+ "Құрастыру-1",
+ "Assembly-1"
+ ],
+ "Ошибка датчика": [
+ "Датчик қатесі",
+ "Sensor error"
+ ],
+ "Замена фильтра": [
+ "Сүзгіні ауыстыру",
+ "Filter replacement"
+ ],
+ "Обрыв цепи": [
+ "Шынжыр үзілуі",
+ "Chain break"
+ ],
+ "Плановое ТО": [
+ "Жоспарлы қызмет көрсету",
+ "Scheduled maintenance"
+ ],
+ "Allur · тестовый кейс · октябрь 2026": [
+ "Allur · сынақ кейсі · қазан 2026",
+ "Allur · test case · October 2026"
+ ],
+ "Подготовка и сварка кузова": [
+ "Кузовты дайындау және дәнекерлеу",
+ "Body preparation and welding"
+ ],
+ "Нанесение защитного покрытия": [
+ "Қорғаныш жабын жағу",
+ "Protective coating application"
+ ],
+ "Установка узлов и агрегатов": [
+ "Тораптар мен агрегаттарды орнату",
+ "Component and assembly installation"
+ ],
+ "Финальная проверка автомобиля": [
+ "Көлікті соңғы тексеру",
+ "Final vehicle inspection"
+ ],
+ "Штатная работа": [
+ "Қалыпты жұмыс",
+ "Normal operation"
+ ],
+ "Производственная линия работает без аварий.": [
+ "Өндірістік желі апатсыз жұмыс істейді.",
+ "The production line operates without failures."
+ ],
+ "Сбой оборудования": [
+ "Жабдық ақауы",
+ "Equipment failure"
+ ],
+ "Износ окрасочной установки приводит к замедлению и остановке.": [
+ "Бояу қондырғысының тозуы баяулауға және тоқтауға әкеледі.",
+ "Painting equipment wear causes slowdown and stoppage."
+ ],
+ "Снижение мощности сборки вызывает накопление очереди.": [
+ "Құрастыру қуатының төмендеуі кезектің жиналуына әкеледі.",
+ "Reduced assembly capacity causes queue build-up."
+ ],
+ "Ожидается объект месячного плана.": [
+ "Айлық жоспар объектісі күтіледі.",
+ "Expected a monthly plan object."
+ ],
+ "Неполный план или неизвестные поля.": [
+ "Жоспар толық емес немесе белгісіз өрістер бар.",
+ "Incomplete plan or unknown fields."
+ ],
+ "Месяц кейса: 2026-10.": [
+ "Кейс айы: 2026-10.",
+ "Case month: 2026-10."
+ ],
+ "Рабочих дней должно быть от 1 до 31.": [
+ "Жұмыс күндері 1–31 аралығында болуы керек.",
+ "Working days must be between 1 and 31."
+ ],
+ "В кейсе заданы две смены в сутки.": [
+ "Кейсте тәулігіне екі ауысым берілген.",
+ "The case specifies two shifts per day."
+ ],
+ "Месячная цель — от 5500 до 100000 автомобилей.": [
+ "Айлық мақсат — 5500–100000 автокөлік.",
+ "The monthly target must be 5,500–100,000 vehicles."
+ ],
+ "Номер смены вне календаря.": [
+ "Ауысым нөмірі күнтізбеден тыс.",
+ "Shift number is outside the calendar."
+ ],
+ "Нужны планы Onix, Cobalt и J7.": [
+ "Onix, Cobalt және J7 жоспарлары қажет.",
+ "Plans for Onix, Cobalt and J7 are required."
+ ],
+ "Планы моделей должны иметь уникальный id и целое количество от 0 до 100000.": [
+ "Модель жоспарларында бірегей id және 0–100000 аралығындағы бүтін сан болуы керек.",
+ "Model plans need a unique id and an integer quantity from 0 to 100,000."
+ ],
+ "Общий план моделей не должен превышать 100000.": [
+ "Модельдердің жалпы жоспары 100000-нан аспауы керек.",
+ "Total model plan must not exceed 100,000."
+ ],
+ "Выбранная смена не содержит плана.": [
+ "Таңдалған ауысымда жоспар жоқ.",
+ "The selected shift has no plan."
+ ],
+ "Учебная линия": [
+ "Оқу желісі",
+ "Demo line"
+ ],
+ "Демонстрационные параметры": [
+ "Демонстрациялық параметрлер",
+ "Demo parameters"
+ ],
+ "диагностика": [
+ "диагностика",
+ "diagnostics"
+ ],
+ "Достигнут предел диагностических сообщений (100). Дополнительные ошибки усечены.": [
+ "Диагностикалық хабарлар шегіне жетті (100). Қосымша қателер қысқартылды.",
+ "Diagnostic message limit reached (100). Additional errors are truncated."
+ ],
+ "Конфигурация должна быть объектом": [
+ "Конфигурация объект болуы керек",
+ "Configuration must be an object"
+ ],
+ "Неизвестное свойство конфигурации: '{0}'": [
+ "Конфигурацияның белгісіз қасиеті: '{0}'",
+ "Unknown configuration property: '{0}'"
+ ],
+ "Отсутствует обязательное свойство: '{0}'": [
+ "Міндетті қасиет жоқ: '{0}'",
+ "Missing required property: '{0}'"
+ ],
+ "Поле schemaVersion должно иметь значение 1": [
+ "schemaVersion өрісінің мәні 1 болуы керек",
+ "schemaVersion must be 1"
+ ],
+ "Поле name должно быть строкой": [
+ "name өрісі мәтін болуы керек",
+ "name must be a string"
+ ],
+ "Поле name должно быть непустой строкой длиной до 80 символов": [
+ "name өрісі 80 таңбаға дейінгі бос емес мәтін болуы керек",
+ "name must be a non-empty string of up to 80 characters"
+ ],
+ "Поле source должно быть объектом": [
+ "source өрісі объект болуы керек",
+ "source must be an object"
+ ],
+ "Неизвестное свойство в source: '{0}'": [
+ "source ішіндегі белгісіз қасиет: '{0}'",
+ "Unknown property in source: '{0}'"
+ ],
+ "Отсутствует обязательное свойство: source.kind": [
+ "Міндетті қасиет жоқ: source.kind",
+ "Missing required property: source.kind"
+ ],
+ "Поле source.kind должно быть 'synthetic' или 'provided'": [
+ "source.kind өрісі 'synthetic' немесе 'provided' болуы керек",
+ "source.kind must be 'synthetic' or 'provided'"
+ ],
+ "Отсутствует обязательное свойство: source.label": [
+ "Міндетті қасиет жоқ: source.label",
+ "Missing required property: source.label"
+ ],
+ "Поле source.label должно быть строкой": [
+ "source.label өрісі мәтін болуы керек",
+ "source.label must be a string"
+ ],
+ "Поле source.label должно быть непустой строкой длиной до 120 символов": [
+ "source.label өрісі 120 таңбаға дейінгі бос емес мәтін болуы керек",
+ "source.label must be a non-empty string of up to 120 characters"
+ ],
+ "Поле shiftSeconds должно быть целым числом в диапазоне от 60 до 86400": [
+ "shiftSeconds өрісі 60–86400 аралығындағы бүтін сан болуы керек",
+ "shiftSeconds must be an integer between 60 and 86400"
+ ],
+ "Поле shiftPlan должно быть целым числом в диапазоне от 1 до 100000": [
+ "shiftPlan өрісі 1–100000 аралығындағы бүтін сан болуы керек",
+ "shiftPlan must be an integer between 1 and 100000"
+ ],
+ "Поле supplyIntervalSeconds должно быть целым числом в диапазоне от 1 до 3600": [
+ "supplyIntervalSeconds өрісі 1–3600 аралығындағы бүтін сан болуы керек",
+ "supplyIntervalSeconds must be an integer between 1 and 3600"
+ ],
+ "Поле rejectRate должно быть числом в диапазоне от 0 до 1": [
+ "rejectRate өрісі 0–1 аралығындағы сан болуы керек",
+ "rejectRate must be a number between 0 and 1"
+ ],
+ "Поле conveyorSpeed должно быть числом в диапазоне от 0.05 до 2": [
+ "conveyorSpeed өрісі 0.05–2 аралығындағы сан болуы керек",
+ "conveyorSpeed must be a number between 0.05 and 2"
+ ],
+ "Поле stations должно быть массивом": [
+ "stations өрісі массив болуы керек",
+ "stations must be an array"
+ ],
+ "Поле stations должно содержать ровно 4 станции, получено {0}": [
+ "stations өрісінде дәл 4 учаске болуы керек, берілгені {0}",
+ "stations must contain exactly 4 stations; received {0}"
+ ],
+ "Элемент массива stations должен быть объектом": [
+ "stations массивінің элементі объект болуы керек",
+ "A stations array item must be an object"
+ ],
+ "Неизвестное свойство станции: '{0}'": [
+ "Учаскенің белгісіз қасиеті: '{0}'",
+ "Unknown station property: '{0}'"
+ ],
+ "Отсутствует обязательное свойство: id": [
+ "Міндетті қасиет жоқ: id",
+ "Missing required property: id"
+ ],
+ "Недопустимый идентификатор станции '{0}'. Ожидались: welding, painting, assembly, quality": [
+ "'{0}' учаске идентификаторы жарамсыз. Күтіледі: welding, painting, assembly, quality",
+ "Invalid station id '{0}'. Expected: welding, painting, assembly, quality"
+ ],
+ "Дублирующийся идентификатор станции: '{0}'": [
+ "Қайталанған учаске идентификаторы: '{0}'",
+ "Duplicate station id: '{0}'"
+ ],
+ "Отсутствует обязательное свойство: cycleSeconds": [
+ "Міндетті қасиет жоқ: cycleSeconds",
+ "Missing required property: cycleSeconds"
+ ],
+ "Поле cycleSeconds должно быть целым числом в диапазоне от 1 до 3600": [
+ "cycleSeconds өрісі 1–3600 аралығындағы бүтін сан болуы керек",
+ "cycleSeconds must be an integer between 1 and 3600"
+ ],
+ "Отсутствует обязательное свойство: bufferCapacity": [
+ "Міндетті қасиет жоқ: bufferCapacity",
+ "Missing required property: bufferCapacity"
+ ],
+ "Поле bufferCapacity должно быть целым числом в диапазоне от 1 до 9": [
+ "bufferCapacity өрісі 1–9 аралығындағы бүтін сан болуы керек",
+ "bufferCapacity must be an integer between 1 and 9"
+ ],
+ "В конфигурации отсутствует станция с каноническим идентификатором: '{0}'": [
+ "Конфигурацияда стандартты идентификаторлы учаске жоқ: '{0}'",
+ "Configuration is missing station id: '{0}'"
+ ],
+ "Месячный план кейса требует смену 8 часов.": [
+ "Кейстің айлық жоспары 8 сағаттық ауысымды талап етеді.",
+ "The case monthly plan requires an 8-hour shift."
+ ],
+ "План смены должен соответствовать распределению месячного плана.": [
+ "Ауысым жоспары айлық жоспар бөлінісіне сәйкес болуы керек.",
+ "The shift plan must match the monthly allocation."
+ ],
+ "Порядок участков приведён к схеме: сварка → окраска → сборка → контроль качества.": [
+ "Учаскелер реті сызбаға келтірілді: дәнекерлеу → бояу → құрастыру → сапаны бақылау.",
+ "Station order normalized: welding → painting → assembly → quality control."
+ ],
+ "Смена короче 35 минут: учебные события сбоев и обслуживания могут не успеть завершиться.": [
+ "Ауысым 35 минуттан қысқа: оқу ақаулары мен қызмет көрсету аяқталмауы мүмкін.",
+ "Shift is shorter than 35 minutes: demo failures and maintenance may not finish."
+ ],
+ "строка {0}": [
+ "жол {0}",
+ "row {0}"
+ ],
+ "В записи больше 6 столбцов. Проверьте разделитель.": [
+ "Жазбада 6-дан көп баған бар. Бөлгішті тексеріңіз.",
+ "Record has more than 6 columns. Check the delimiter."
+ ],
+ "Некорректный формат кавычек: обнаружены символы после закрывающей кавычки в строке {0}": [
+ "Тырнақша пішімі жарамсыз: {0}-жолда жабылған тырнақшадан кейін таңбалар бар",
+ "Invalid quoting: characters after the closing quote in row {0}"
+ ],
+ "Некорректный формат: символ кавычки внутри незакавыченного поля в строке {0}": [
+ "Пішім жарамсыз: {0}-жолда тырнақшасыз өрістің ішінде тырнақша бар",
+ "Invalid format: quote inside an unquoted field in row {0}"
+ ],
+ "Пустые строки допускаются только в конце файла.": [
+ "Бос жолдар тек файл соңында рұқсат етіледі.",
+ "Blank rows are allowed only at the end of the file."
+ ],
+ "файл": [
+ "файл",
+ "file"
+ ],
+ "Количество записей превышает допустимый максимум (10 000).": [
+ "Жазбалар саны шектен асады (10 000).",
+ "Record count exceeds the limit (10,000)."
+ ],
+ "Поле длиннее 256 символов. Ожидалось число или название столбца.": [
+ "Өріс 256 таңбадан ұзын. Сан немесе баған атауы күтіледі.",
+ "Field exceeds 256 characters. Expected a number or column name."
+ ],
+ "Незакрытая кавычка в строке {0}": [
+ "{0}-жолда тырнақша жабылмаған",
+ "Unclosed quote in row {0}"
+ ],
+ "строка {0}, {1}": [
+ "жол {0}, {1}",
+ "row {0}, {1}"
+ ],
+ "Поле '{0}' не может быть пустым": [
+ "'{0}' өрісі бос болмауы керек",
+ "Field '{0}' cannot be empty"
+ ],
+ "Поле '{0}' должно быть целым числом, получено: '{1}'": [
+ "'{0}' өрісі бүтін сан болуы керек, берілгені: '{1}'",
+ "Field '{0}' must be an integer; received: '{1}'"
+ ],
+ "Поле '{0}' не является допустимым числом": [
+ "'{0}' өрісіндегі сан жарамсыз",
+ "Field '{0}' is not a valid number"
+ ],
+ "Поле '{0}' не может быть отрицательным: {1}": [
+ "'{0}' өрісі теріс болмауы керек: {1}",
+ "Field '{0}' cannot be negative: {1}"
+ ],
+ "Значение '{0}' ({1}) выходит за допустимый диапазон [{2}, {3}]": [
+ "'{0}' мәні ({1}) рұқсат етілген [{2}, {3}] аралығынан тыс",
+ "Value '{0}' ({1}) is outside the range [{2}, {3}]"
+ ],
+ "Поле '{0}' должно быть числом, получено: '{1}'": [
+ "'{0}' өрісі сан болуы керек, берілгені: '{1}'",
+ "Field '{0}' must be a number; received: '{1}'"
+ ],
+ "Поле '{0}' должно быть конечным числом": [
+ "'{0}' өрісі ақырлы сан болуы керек",
+ "Field '{0}' must be finite"
+ ],
+ "Набор данных": [
+ "Деректер жиынтығы",
+ "Dataset"
+ ],
+ "Входные данные CSV должны быть строкой": [
+ "CSV кіріс деректері мәтін болуы керек",
+ "CSV input must be a string"
+ ],
+ "Размер файла превышает 2 МиБ.": [
+ "Файл көлемі 2 МиБ-тан асады.",
+ "File size exceeds 2 MiB."
+ ],
+ "Размер файла ({0} байт) превышает максимально допустимый предел 2 МБ": [
+ "Файл көлемі ({0} байт) рұқсат етілген 2 МБ шегінен асады",
+ "File size ({0} bytes) exceeds the 2 MB limit"
+ ],
+ "CSV-файл пуст": [
+ "CSV файлы бос",
+ "CSV file is empty"
+ ],
+ "строка 1": [
+ "жол 1",
+ "row 1"
+ ],
+ "Слишком длинный заголовок. Ожидалось шесть названий столбцов.": [
+ "Тақырып тым ұзын. Алты баған атауы күтіледі.",
+ "Header is too long. Expected six column names."
+ ],
+ "CSV-файл не содержит строк данных": [
+ "CSV файлында деректер жолдары жоқ",
+ "CSV file contains no data rows"
+ ],
+ "Неверное количество столбцов в строке заголовка: ожидалось 6, получено {0}": [
+ "Тақырыптағы баған саны қате: 6 күтілген, берілгені {0}",
+ "Wrong header column count: expected 6, received {0}"
+ ],
+ "строка {0}, колонка '{1}'": [
+ "жол {0}, баған '{1}'",
+ "row {0}, column '{1}'"
+ ],
+ "Неизвестный заголовок столбца: '{0}'": [
+ "Белгісіз баған атауы: '{0}'",
+ "Unknown column header: '{0}'"
+ ],
+ "Дублирующийся заголовок столбца: '{0}'": [
+ "Қайталанған баған атауы: '{0}'",
+ "Duplicate column header: '{0}'"
+ ],
+ "Отсутствует обязательный столбец: '{0}'": [
+ "Міндетті баған жоқ: '{0}'",
+ "Missing required column: '{0}'"
+ ],
+ "Файл должен содержать как минимум 2 записи данных, получено {0}": [
+ "Файлда кемінде 2 дерек жазбасы болуы керек, берілгені {0}",
+ "File must contain at least 2 data records; received {0}"
+ ],
+ "Количество записей в файле ({0}) превышает допустимый максимум (10 000)": [
+ "Файлдағы жазбалар саны ({0}) 10 000 шегінен асады",
+ "File record count ({0}) exceeds the limit (10,000)"
+ ],
+ "Проверка оставшихся строк остановлена.": [
+ "Қалған жолдарды тексеру тоқтатылды.",
+ "Validation of remaining rows stopped."
+ ],
+ "Неверное количество столбцов: ожидалось 6, получено {0}": [
+ "Баған саны қате: 6 күтілген, берілгені {0}",
+ "Wrong column count: expected 6, received {0}"
+ ],
+ "строка {0}, downtime_seconds": [
+ "жол {0}, downtime_seconds",
+ "row {0}, downtime_seconds"
+ ],
+ "Суммарное время простоя ({0} с) превышает 4 * время с начала смены ({1} с)": [
+ "Жалпы тоқтау уақыты ({0} с) ауысым басынан өткен уақыттың 4 есесінен ({1} с) асады",
+ "Total downtime ({0} s) exceeds 4 × elapsed shift time ({1} s)"
+ ],
+ "строка {0}, elapsed_seconds": [
+ "жол {0}, elapsed_seconds",
+ "row {0}, elapsed_seconds"
+ ],
+ "Дублирующееся значение времени: {0} с совпадает с предыдущей строкой {1}": [
+ "Уақыт мәні қайталанған: {0} с алдыңғы {1}-жолға сәйкес келеді",
+ "Duplicate timestamp: {0} s matches previous row {1}"
+ ],
+ "Нарушен хронологический порядок: время {0} с меньше предыдущего {1} с в строке {2}": [
+ "Хронология бұзылған: {0} с уақыты {2}-жолдағы алдыңғы {1} с уақытынан аз",
+ "Chronological order violated: {0} s is less than previous {1} s in row {2}"
+ ],
+ "строка {0}, good_units": [
+ "жол {0}, good_units",
+ "row {0}, good_units"
+ ],
+ "Накопленное количество годных изделий ({0}) уменьшилось по сравнению с предыдущей строкой ({1})": [
+ "Жинақталған жарамды өнім ({0}) алдыңғы жолдан ({1}) азайған",
+ "Cumulative good output ({0}) decreased from the previous row ({1})"
+ ],
+ "строка {0}, rejected_units": [
+ "жол {0}, rejected_units",
+ "row {0}, rejected_units"
+ ],
+ "Накопленное количество брака ({0}) уменьшилось по сравнению с предыдущей строкой ({1})": [
+ "Жинақталған ақаулы өнім ({0}) алдыңғы жолдан ({1}) азайған",
+ "Cumulative rejects ({0}) decreased from the previous row ({1})"
+ ],
+ "строка {0}, plan_units": [
+ "жол {0}, plan_units",
+ "row {0}, plan_units"
+ ],
+ "Накопленный план выпуска ({0}) уменьшился по сравнению с предыдущей строкой ({1})": [
+ "Жинақталған өнім жоспары ({0}) алдыңғы жолдан ({1}) азайған",
+ "Cumulative output plan ({0}) decreased from the previous row ({1})"
+ ],
+ "Накопленное время простоя ({0} с) уменьшилось по сравнению с предыдущей строкой ({1} с)": [
+ "Жинақталған тоқтау уақыты ({0} с) алдыңғы жолдан ({1} с) азайған",
+ "Cumulative downtime ({0} s) decreased from the previous row ({1} s)"
+ ],
+ "Прирост времени простоя ({0} с) превышает 4 * интервал времени ({1} с)": [
+ "Тоқтау өсімі ({0} с) уақыт аралығының 4 есесінен ({1} с) асады",
+ "Downtime increase ({0} s) exceeds 4 × time interval ({1} s)"
+ ],
+ "Набор данных должен содержать как минимум 2 валидные записи, получено {0}": [
+ "Жиынтықта кемінде 2 жарамды жазба болуы керек, берілгені {0}",
+ "Dataset must contain at least 2 valid records; received {0}"
+ ],
+ "Имя набора данных '{0}' является пользовательской меткой и не гарантирует верификацию источника данных.": [
+ "'{0}' жиынтық атауы — пайдаланушы белгісі; ол дереккөз тексерілгенін растамайды.",
+ "Dataset name '{0}' is a user label and does not verify the data source."
+ ],
+ "Первая запись начинается со времени {0} с (> 0 с). Данные представляют частичную историю смены.": [
+ "Алғашқы жазба {0} с уақытынан басталады (> 0 с). Бұл ауысымның ішінара тарихы.",
+ "The first record starts at {0} s (> 0 s). This is a partial shift history."
+ ],
+ "Обнаружен временной разрыв {0} с между строками {1} и {2} ({3} с -> {4} с), превышающий 3600 с.": [
+ "{1} және {2} жолдар арасында 3600 с-тан асатын {0} с үзіліс бар ({3} с -> {4} с).",
+ "A gap of {0} s between rows {1} and {2} ({3} s -> {4} s) exceeds 3600 s."
+ ],
+ "Никаких вмешательств. Линия продолжает работу по текущему сценарию.": [
+ "Араласусыз. Желі ағымдағы сценарий бойынша жұмысын жалғастырады.",
+ "No intervention. The line continues under the current scenario."
+ ],
+ "Техническое обслуживание (Окраска)": [
+ "Техникалық қызмет көрсету (Бояу)",
+ "Maintenance (Painting)"
+ ],
+ "Внеплановое обслуживание окрасочной установки ({0} мин).": [
+ "Бояу қондырғысына жоспардан тыс қызмет көрсету ({0} мин).",
+ "Unscheduled painting equipment service ({0} min)."
+ ],
+ "Остановка окраски на {0} минут для устранения всех неисправностей. После этого окраска работает на номинальной скорости.": [
+ "Барлық ақауларды жою үшін бояу {0} минутқа тоқтайды. Содан кейін бояу қалыпты жылдамдықпен жұмыс істейді.",
+ "Stop painting for {0} minutes to fix all faults, then resume at nominal speed."
+ ],
+ "Подготовка резерва (Сборка)": [
+ "Резервті дайындау (Құрастыру)",
+ "Reserve setup (Assembly)"
+ ],
+ "Остановка сборки на {0} мин для ввода резервной мощности.": [
+ "Резерв қуатын қосу үшін құрастыру {0} минутқа тоқтайды.",
+ "Stop assembly for {0} min to bring reserve capacity online."
+ ],
+ "Остановка сборки на {0} мин, затем удвоение скорости обработки участка. Общий выпуск ограничен остальной линией.": [
+ "Құрастыру {0} минутқа тоқтап, кейін өңдеу жылдамдығы екі еселенеді. Жалпы өнім желінің қалған бөлігімен шектеледі.",
+ "Stop assembly for {0} min, then double its processing speed. Total output is limited by the rest of the line."
+ ],
+ "Удвоение скорости обработки сборки без остановки. Общий выпуск ограничен остальной линией.": [
+ "Құрастыру жылдамдығы тоқтаусыз екі еселенеді. Жалпы өнім желінің қалған бөлігімен шектеледі.",
+ "Double assembly speed without stopping. Total output is limited by the rest of the line."
+ ],
+ "Месячные заказы распределены по моделям и сменам. Нераспределённая часть цели не запускается; пустой вход после выполнения заказов не является отказом оборудования.": [
+ "Айлық тапсырыстар модельдер мен ауысымдарға бөлінген. Мақсаттың бөлінбеген бөлігі іске қосылмайды; тапсырыстар орындалғаннан кейінгі бос кіріс жабдық ақауы емес.",
+ "Monthly orders are allocated by model and shift. Unallocated target volume is not started; an empty inlet after orders are complete is not equipment failure."
+ ],
+ "Циклы трёх линий оценены по агрегатам; цикл ОТК, транспорт и буферы условные. Финальный брак временно принят по сборке, поскольку данных после ОТК нет.": [
+ "Үш желінің циклдері жиынтық деректерден бағаланған; сапаны бақылау, тасымал және буферлер шартты. Соңғы ақау уақытша құрастырудан алынған, себебі сапаны бақылаудан кейінгі деректер жоқ.",
+ "Three line cycles are estimated from aggregates; quality control, transport and buffers are assumed. Final rejects provisionally use assembly data because post-inspection data are unavailable."
+ ],
+ "Расписание событий сценария известно точно (без прогнозирования).": [
+ "Сценарий оқиғаларының кестесі дәл белгілі (болжамсыз).",
+ "The scenario event schedule is known exactly (no forecasting)."
+ ],
+ "Вероятность брака каждого изделия — {0}%. Последовательность случайных исходов одинакова во всех ветвях; фактическая доля зависит от числа завершённых изделий.": [
+ "Әр өнімнің ақау ықтималдығы — {0}%. Кездейсоқ нәтижелер реті барлық тармақта бірдей; нақты үлес аяқталған өнім санына тәуелді.",
+ "Each unit has a {0}% reject probability. Random outcomes use the same sequence in all branches; the actual rate depends on completed volume."
+ ],
+ "Буферы ограничены. Кузов предлагается каждые {0} секунд; при полном входном буфере поступление пропускается.": [
+ "Буферлер шектеулі. Кузов әр {0} секунд сайын ұсынылады; кіріс буфері толса, беру өткізіледі.",
+ "Buffers are limited. A body is offered every {0} seconds; feeds are skipped when the input buffer is full."
+ ],
+ "Экономические затраты на ремонт и резерв не учитываются.": [
+ "Жөндеу мен резервтің экономикалық шығындары ескерілмейді.",
+ "Repair and reserve costs are excluded."
+ ],
+ "Резерв удваивает скорость обработки сборки. Это приближение мощности, а не модель двух параллельных постов. Сценарное замедление сборки сохраняется.": [
+ "Резерв құрастыру жылдамдығын екі еселейді. Бұл — екі параллель посттың моделі емес, қуаттың жуықтауы. Құрастырудың сценарийлік баяулауы сақталады.",
+ "Reserve doubles assembly speed. This approximates capacity rather than modelling two parallel stations. Scenario assembly slowdown remains in effect."
+ ],
+ "Обслуживание гарантированно устраняет будущие отказы окрасочной установки до конца смены.": [
+ "Қызмет көрсету ауысым соңына дейін бояу қондырғысының болашақ ақауларын толық жояды.",
+ "Maintenance is assumed to eliminate all subsequent painting failures until shift end."
+ ],
+ "Замедление окрасочной установки": [
+ "Бояу қондырғысының баяулауы",
+ "Painting equipment slowdown"
+ ],
+ "Износ оборудования: цикл окраски увеличен в 1,8 раза.": [
+ "Жабдық тозуы: бояу циклі 1,8 есе ұзарған.",
+ "Equipment wear: the painting cycle is 1.8 times longer."
+ ],
+ "Остановка окрасочной установки": [
+ "Бояу қондырғысының тоқтауы",
+ "Painting equipment stop"
+ ],
+ "Окрасочная установка остановлена, требуется ремонт оборудования.": [
+ "Бояу қондырғысы тоқтаған, жөндеу қажет.",
+ "Painting equipment is stopped and needs repair."
+ ],
+ "Снижение мощности сборки": [
+ "Құрастыру қуатының төмендеуі",
+ "Reduced assembly capacity"
+ ],
+ "Цикл сборки увеличен вдвое, перед участком растёт очередь.": [
+ "Құрастыру циклі екі есе ұзарған, учаске алдында кезек өсуде.",
+ "The assembly cycle has doubled and the upstream queue is growing."
+ ],
+ "Камера-02 · замена фильтра": [
+ "Камера-02 · сүзгіні ауыстыру",
+ "Chamber-02 · filter replacement"
+ ],
+ "Конвейер-03 · обрыв цепи": [
+ "Конвейер-03 · шынжыр үзілуі",
+ "Conveyor-03 · chain break"
+ ],
+ "Длительность {0} мин взята из тестового кейса. Начало на 20-й минуте задано для сценария; в исходнике время не указано.": [
+ "{0} минут ұзақтығы сынақ кейсінен алынған. 20-минутта басталуы сценарий үшін таңдалған; бастапқы деректе уақыт көрсетілмеген.",
+ "The {0}-minute duration comes from the test case. Starting at minute 20 is a scenario assumption; no start time is given in the source."
+ ],
+ "Язык интерфейса": [
+ "Интерфейс тілі",
+ "Interface language"
+ ],
+ "Включить тёмную тему": [
+ "Қараңғы тақырыпты қосу",
+ "Use dark theme"
+ ],
+ "Включить светлую тему": [
+ "Ашық тақырыпты қосу",
+ "Use light theme"
+ ],
+ "Тёмная тема": [
+ "Қараңғы тақырып",
+ "Dark theme"
+ ],
+ "Светлая тема": [
+ "Ашық тақырып",
+ "Light theme"
+ ],
+ "Машин в очереди через {0} мин": [
+ "{0} минуттан кейінгі кезектегі көліктер",
+ "Vehicles queued after {0} min"
+ ],
+ "{0} ч {1} мин {2} с": [
+ "{0} сағ {1} мин {2} с",
+ "{0} h {1} min {2} s"
+ ],
+ "Перетаскивание — вращение · правая кнопка — перемещение · колесо — масштаб": [
+ "Сүйреу — айналдыру · оң жақ батырма — жылжыту · дөңгелек — масштаб",
+ "Drag to rotate · right drag to pan · scroll to zoom"
+ ],
+ "Для ИИ-анализа нужен код доступа к серверу.": [
+ "ЖИ талдауы үшін серверге кіру коды қажет.",
+ "AI analysis requires the server access code."
+ ],
+ "Код доступа не принят. Вставьте только строку кода демонстрации, без пояснений. Ключ OpenAI здесь не нужен.": [
+ "Кіру коды қабылданбады. Түсіндірмесіз тек демонстрация кодын енгізіңіз. OpenAI кілті мұнда қажет емес.",
+ "Access code rejected. Paste only the demo code without explanations. Do not enter the OpenAI key here."
+ ],
+ "Не удалось проверить лимит запросов. Требуется настройка сервера.": [
+ "Сұраулар шегі тексерілмеді. Серверді баптау қажет.",
+ "Could not check the request limit. Server setup is required."
+ ],
+ "Достигнут лимит ИИ-анализов для демонстрации.": [
+ "Демонстрацияның ЖИ талдау шегіне жетті.",
+ "The demo AI analysis limit has been reached."
+ ],
+ "Не удалось сохранить счётчик лимита. Запрос к OpenAI не отправлен.": [
+ "Шек санауышы сақталмады. OpenAI сұрауы жіберілген жоқ.",
+ "Could not save the usage counter. No request was sent to OpenAI."
+ ],
+ "OpenAI ещё не подключён на сервере. Расчёт мощности доступен без подключения.": [
+ "Серверде OpenAI әлі қосылмаған. Қуат есебі қосылымсыз қолжетімді.",
+ "OpenAI is not yet connected on the server. Capacity calculations work without it."
+ ],
+ "Сервис выполняет два анализа. Повторите чуть позже.": [
+ "Қызмет екі талдау орындап жатыр. Сәл кейін қайталаңыз.",
+ "Two analyses are in progress. Try again shortly."
+ ],
+ "Новый анализ доступен через 30 секунд после предыдущего.": [
+ "Жаңа талдау алдыңғысынан 30 секунд өткен соң қолжетімді.",
+ "A new analysis is available 30 seconds after the previous one."
+ ],
+ "Слишком большой набор показателей.": [
+ "Көрсеткіштер жиынтығы тым үлкен.",
+ "The metrics payload is too large."
+ ],
+ "OpenAI не ответил вовремя. Повторите анализ позже.": [
+ "OpenAI уақытында жауап бермеді. Талдауды кейін қайталаңыз.",
+ "OpenAI did not respond in time. Try again later."
+ ],
+ "OpenAI отклонил запрос из-за лимита или баланса. Проверьте проект OpenAI Platform.": [
+ "OpenAI шек немесе баланс себебінен сұрауды қабылдамады. OpenAI Platform жобасын тексеріңіз.",
+ "OpenAI rejected the request due to a limit or balance. Check your OpenAI Platform project."
+ ],
+ "Проверьте API-ключ и доступ проекта к модели OpenAI.": [
+ "API кілтін және жобаның OpenAI моделіне қолжетімділігін тексеріңіз.",
+ "Check the API key and the project’s access to the OpenAI model."
+ ],
+ "Сервис OpenAI временно недоступен.": [
+ "OpenAI қызметі уақытша қолжетімсіз.",
+ "The OpenAI service is temporarily unavailable."
+ ],
+ "OpenAI вернул некорректный ответ.": [
+ "OpenAI жарамсыз жауап қайтарды.",
+ "OpenAI returned an invalid response."
+ ],
+ "Анализ не завершён. Готовый прогноз не получен.": [
+ "Талдау аяқталмады. Дайын болжам алынбады.",
+ "Analysis did not complete. No finished forecast was received."
+ ],
+ "Ответ OpenAI не прошёл проверку структуры.": [
+ "OpenAI жауабы құрылым тексеруінен өтпеді.",
+ "The OpenAI response failed structure validation."
+ ],
+ "Некорректные данные расхода API.": [
+ "API шығыны туралы деректер жарамсыз.",
+ "Invalid API usage data."
+ ]
+}
diff --git a/apps/web/src/preferences.ts b/apps/web/src/preferences.ts
new file mode 100644
index 0000000..f1ba634
--- /dev/null
+++ b/apps/web/src/preferences.ts
@@ -0,0 +1,36 @@
+import { useSyncExternalStore } from 'react';
+export type Language = 'ru' | 'kk' | 'en';
+export type Theme = 'light' | 'dark';
+export interface Preferences { language: Language; theme: Theme }
+export const PREFERENCES_KEY = 'driveindui.preferences';
+export function parsePreferences(raw: string | null): Preferences {
+ try {
+ const value = JSON.parse(raw ?? '{}');
+ return { language: ['ru', 'kk', 'en'].includes(value?.language) ? value.language : 'ru', theme: value?.theme === 'dark' ? 'dark' : 'light' };
+ } catch { return { language: 'ru', theme: 'light' }; }
+}
+function initial(): Preferences {
+ try { return parsePreferences(window.localStorage.getItem(PREFERENCES_KEY)); } catch { return parsePreferences(null); }
+}
+let current = initial();
+const listeners = new Set<() => void>();
+const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
+export const getPreferences = () => current;
+export const locale = () => ({ ru: 'ru-RU', kk: 'kk-KZ', en: 'en-GB' }[current.language]);
+function apply() {
+ if (typeof document === 'undefined') return;
+ document.documentElement.dataset.theme = current.theme;
+ document.documentElement.lang = current.language;
+ document.querySelector('meta[name="theme-color"]')?.setAttribute('content', current.theme === 'dark' ? '#101c19' : '#f2f4f0');
+}
+export function setPreferences(change: Partial) {
+ current = parsePreferences(JSON.stringify({ ...current, ...change }));
+ try { window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(current)); } catch { /* Private mode still supports in-memory preferences. */ }
+ apply(); listeners.forEach(listener => listener());
+}
+export function usePreferences() { return useSyncExternalStore(subscribe, getPreferences, getPreferences); }
+if (typeof window !== 'undefined') window.addEventListener('storage', event => {
+ if (event.key !== PREFERENCES_KEY && event.key !== null) return;
+ current = parsePreferences(event.newValue); apply(); listeners.forEach(listener => listener());
+});
+apply();
diff --git a/apps/web/src/stage2-flow.css b/apps/web/src/stage2-flow.css
index a10ea99..a184158 100644
--- a/apps/web/src/stage2-flow.css
+++ b/apps/web/src/stage2-flow.css
@@ -2,8 +2,8 @@
.flow-container {
position: relative;
- background: #faf8f5;
- border: 1px solid #e7dfd3;
+ background: light-dark(#faf8f5, #1b2b25);
+ border: 1px solid light-dark(#e7dfd3, #3c5142);
border-radius: 12px;
padding: 12px 14px;
box-shadow: 0 2px 8px rgba(30, 41, 59, 0.04);
@@ -11,7 +11,7 @@
}
.flow-container:focus-visible {
- border-color: #0284c7;
+ border-color: light-dark(#0284c7, #3c5142);
box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.2);
}
@@ -24,7 +24,7 @@
display: none;
margin: 6px 0 0;
font-size: 12px;
- color: #78716c;
+ color: light-dark(#78716c, #b6c8b5);
text-align: center;
}
@@ -44,7 +44,7 @@
}
.flow-floor-line {
- stroke: #ebd9c3;
+ stroke: light-dark(#ebd9c3, #ebd9c3);
stroke-width: 1;
stroke-dasharray: 4 4;
opacity: 0.6;
@@ -61,14 +61,14 @@
}
.flow-panel {
- fill: #ffffff;
- stroke: #e2d9cc;
+ fill: light-dark(#ffffff, #ffffff);
+ stroke: light-dark(#e2d9cc, #e2d9cc);
stroke-width: 1.2;
transition: stroke 0.15s ease, filter 0.15s ease;
}
.flow-node:hover:not(.node--terminal) .flow-panel {
- stroke: #0d9488;
+ stroke: light-dark(#0d9488, #a0d8b1);
filter: drop-shadow(0 4px 10px rgba(13, 148, 136, 0.1));
}
@@ -79,20 +79,20 @@
}
.flow-node:focus-visible .flow-focus {
- stroke: #0284c7;
+ stroke: light-dark(#0284c7, #b6c8b5);
}
.flow-node.is-selected .flow-panel {
- stroke: #0d9488;
+ stroke: light-dark(#0d9488, #a0d8b1);
stroke-width: 2.2;
}
.flow-top-stripe {
- fill: #0d9488;
+ fill: light-dark(#0d9488, #a0d8b1);
}
.flow-top-stripe--term {
- fill: #94a3b8;
+ fill: light-dark(#94a3b8, #a8bca7);
}
/* Typography */
@@ -100,197 +100,197 @@
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
- fill: #78716c;
+ fill: light-dark(#78716c, #b6c8b5);
}
.flow-name {
font-size: 13px;
font-weight: 700;
- fill: #1e293b;
+ fill: light-dark(#1e293b, #e0eade);
}
.flow-metric-lbl {
font-size: 11px;
- fill: #64748b;
+ fill: light-dark(#64748b, #b6c8b5);
}
.flow-metric-val {
font-size: 11px;
font-weight: 700;
- fill: #1e293b;
+ fill: light-dark(#1e293b, #e0eade);
}
.flow-term-num {
font-size: 20px;
font-weight: 800;
- fill: #1e293b;
+ fill: light-dark(#1e293b, #e0eade);
}
.flow-term-sub {
font-size: 11px;
font-weight: 600;
- fill: #475569;
+ fill: light-dark(#475569, #b6c8b5);
}
.flow-term-note {
font-size: 10px;
- fill: #94a3b8;
+ fill: light-dark(#94a3b8, #a8bca7);
}
.flow-footnote {
font-size: 11px;
- fill: #78716c;
+ fill: light-dark(#78716c, #b6c8b5);
}
/* Status badge */
.flow-badge {
- fill: #f1f5f9;
- stroke: #cbd5e1;
+ fill: light-dark(#f1f5f9, #f1f5f9);
+ stroke: light-dark(#cbd5e1, #cbd5e1);
stroke-width: 1;
}
.flow-badge-text {
font-size: 9.5px;
font-weight: 600;
- fill: #475569;
+ fill: light-dark(#475569, #b6c8b5);
}
-.st--running .flow-top-stripe { fill: #10b981; }
-.st--running .flow-badge { fill: #ecfdf5; stroke: #a7f3d0; }
-.st--running .flow-badge-text { fill: #065f46; }
-.st--running .flow-bar-fill { fill: #10b981; }
+.st--running .flow-top-stripe { fill: light-dark(#10b981, #a0d8b1); }
+.st--running .flow-badge { fill: light-dark(#ecfdf5, #ecfdf5); stroke: light-dark(#a7f3d0, #a7f3d0); }
+.st--running .flow-badge-text { fill: light-dark(#065f46, #a0d8b1); }
+.st--running .flow-bar-fill { fill: light-dark(#10b981, #a0d8b1); }
-.st--warning .flow-top-stripe { fill: #f59e0b; }
-.st--warning .flow-badge { fill: #fffbeb; stroke: #fde68a; }
-.st--warning .flow-badge-text { fill: #92400e; }
-.st--warning .flow-bar-fill { fill: #f59e0b; }
+.st--warning .flow-top-stripe { fill: light-dark(#f59e0b, #edaa95); }
+.st--warning .flow-badge { fill: light-dark(#fffbeb, #fffbeb); stroke: light-dark(#fde68a, #e6c183); }
+.st--warning .flow-badge-text { fill: light-dark(#92400e, #edaa95); }
+.st--warning .flow-bar-fill { fill: light-dark(#f59e0b, #edaa95); }
-.st--stopped .flow-top-stripe { fill: #ef4444; }
-.st--stopped .flow-badge { fill: #fef2f2; stroke: #fecaca; }
-.st--stopped .flow-badge-text { fill: #991b1b; }
-.st--stopped .flow-bar-fill { fill: #ef4444; }
+.st--stopped .flow-top-stripe { fill: light-dark(#ef4444, #edaa95); }
+.st--stopped .flow-badge { fill: light-dark(#fef2f2, #fef2f2); stroke: light-dark(#fecaca, #fecaca); }
+.st--stopped .flow-badge-text { fill: light-dark(#991b1b, #edaa95); }
+.st--stopped .flow-bar-fill { fill: light-dark(#ef4444, #edaa95); }
-.st--blocked .flow-top-stripe { fill: #f97316; }
-.st--blocked .flow-badge { fill: #fff7ed; stroke: #fed7aa; }
-.st--blocked .flow-badge-text { fill: #9a3412; }
-.st--blocked .flow-bar-fill { fill: #f97316; }
+.st--blocked .flow-top-stripe { fill: light-dark(#f97316, #edaa95); }
+.st--blocked .flow-badge { fill: light-dark(#fff7ed, #fff7ed); stroke: light-dark(#fed7aa, #fed7aa); }
+.st--blocked .flow-badge-text { fill: light-dark(#9a3412, #edaa95); }
+.st--blocked .flow-bar-fill { fill: light-dark(#f97316, #edaa95); }
-.st--idle .flow-top-stripe { fill: #94a3b8; }
-.st--idle .flow-badge { fill: #f8fafc; stroke: #e2e8f0; }
-.st--idle .flow-badge-text { fill: #64748b; }
-.st--idle .flow-bar-fill { fill: #94a3b8; }
+.st--idle .flow-top-stripe { fill: light-dark(#94a3b8, #a8bca7); }
+.st--idle .flow-badge { fill: light-dark(#f8fafc, #f8fafc); stroke: light-dark(#e2e8f0, #e2e8f0); }
+.st--idle .flow-badge-text { fill: light-dark(#64748b, #b6c8b5); }
+.st--idle .flow-bar-fill { fill: light-dark(#94a3b8, #a8bca7); }
/* Progress & Util Bars */
.flow-bar-bg {
- fill: #f1ede4;
+ fill: light-dark(#f1ede4, #f1ede4);
}
.flow-bar-fill--util {
- fill: #0284c7;
+ fill: light-dark(#0284c7, #b6c8b5);
}
/* Incident alert badge */
.flow-alert circle {
- fill: #ef4444;
+ fill: light-dark(#ef4444, #edaa95);
}
.flow-alert text {
- fill: #ffffff;
+ fill: light-dark(#ffffff, #ffffff);
font-size: 11px;
font-weight: 800;
}
.flow-alert--warning circle {
- fill: #f59e0b;
+ fill: light-dark(#f59e0b, #edaa95);
}
/* Queue Buffer */
.flow-queue-box {
- fill: #ffffff;
- stroke: #e2d9cc;
+ fill: light-dark(#ffffff, #ffffff);
+ stroke: light-dark(#e2d9cc, #e2d9cc);
stroke-width: 1;
}
.flow-queue-title {
font-size: 8.5px;
font-weight: 600;
- fill: #64748b;
+ fill: light-dark(#64748b, #b6c8b5);
}
.flow-queue-slot {
- fill: #e8e3d8;
+ fill: light-dark(#e8e3d8, #e8e3d8);
}
.flow-queue-slot.is-filled {
- fill: #0d9488;
+ fill: light-dark(#0d9488, #a0d8b1);
}
.flow-queue-slot.is-heavy {
- fill: #d97706;
+ fill: light-dark(#d97706, #edaa95);
}
/* Connectors & Flow Lines */
.flow-conn-base {
- stroke: #dcd4c6;
+ stroke: light-dark(#dcd4c6, #dcd4c6);
stroke-width: 4;
stroke-linecap: round;
}
.flow-conn-line {
- stroke: #0d9488;
+ stroke: light-dark(#0d9488, #a0d8b1);
stroke-width: 2.5;
stroke-linecap: round;
opacity: 0.4;
}
.flow-conn-arrow {
- fill: #0d9488;
+ fill: light-dark(#0d9488, #a0d8b1);
}
.conn--halt .flow-conn-line,
.conn--halt .flow-conn-arrow {
- stroke: #94a3b8;
- fill: #94a3b8;
+ stroke: light-dark(#94a3b8, #a8bca7);
+ fill: light-dark(#94a3b8, #a8bca7);
opacity: 0.6;
}
.conn--full .flow-conn-line,
.conn--full .flow-conn-arrow {
- stroke: #d97706;
- fill: #d97706;
+ stroke: light-dark(#d97706, #edaa95);
+ fill: light-dark(#d97706, #edaa95);
}
/* Equipment bay graphics */
.equip-bg {
- fill: #f8f6f1;
- stroke: #e7dfd2;
+ fill: light-dark(#f8f6f1, #f8f6f1);
+ stroke: light-dark(#e7dfd2, #e7dfd2);
stroke-width: 1;
}
.equip-rail-base {
- stroke: #d6ccbd;
+ stroke: light-dark(#d6ccbd, #d6ccbd);
stroke-width: 2;
}
.equip-rail-rollers {
- stroke: #a89f8f;
+ stroke: light-dark(#a89f8f, #a8bca7);
stroke-width: 2;
stroke-dasharray: 4 6;
}
.equip-rack-frame {
- fill: #fdfcfb;
- stroke: #a8a29e;
+ fill: light-dark(#fdfcfb, #fdfcfb);
+ stroke: light-dark(#a8a29e, #a8bca7);
stroke-width: 1.5;
}
.equip-rack-shelf {
- stroke: #a8a29e;
+ stroke: light-dark(#a8a29e, #a8bca7);
stroke-width: 1.2;
}
.spray-cone {
- fill: #38bdf8;
+ fill: light-dark(#38bdf8, #a8bca7);
opacity: 0.18;
transition: opacity 0.2s ease;
}
diff --git a/apps/web/src/stage2-layout.css b/apps/web/src/stage2-layout.css
index 93aad32..577a4c2 100644
--- a/apps/web/src/stage2-layout.css
+++ b/apps/web/src/stage2-layout.css
@@ -33,14 +33,14 @@
.stage2 .timeline__item { grid-template-columns: 65px 16px minmax(0, 1fr); }
.stage2 .timeline__title { font-size: 13px; }
.stage2 .timeline__desc, .stage2 .timeline__meta { font-size: 12px; }
-.shift-focus { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 11px 18px; border: 1px solid #c9ded7; background: #eaf2ed; border-radius: 12px; color: #245d50; }
-.shift-focus--alert { color: #7d5314; background: #fcf1d9; border-color: #e7ce9b; }
+.shift-focus { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 11px 18px; border: 1px solid light-dark(#c9ded7, #3c5142); background: light-dark(#eaf2ed, #21352c); border-radius: 12px; color: light-dark(#245d50, #a0d8b1); }
+.shift-focus--alert { color: light-dark(#7d5314, #edaa95); background: light-dark(#fcf1d9, #284133); border-color: light-dark(#e7ce9b, #816936); }
.shift-focus__lead { display: flex; align-items: center; gap: 12px; min-width: 0; }
.shift-focus__lead > div { display: grid; gap: 2px; }
.shift-focus__lead strong { font-size: 13px; }
.shift-focus__caption { font-size: 10px; text-transform: uppercase; letter-spacing: .09em; }
.queue-focus { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 8px; border: 1px solid currentColor; background: transparent; color: inherit; font-size: 12px; cursor: pointer; }
-.queue-focus:hover { background: #ffffff80; }
+.queue-focus:hover { background: light-dark(#ffffff80, #1b2b2580); }
.queue-focus strong { font-family: var(--mono); white-space: nowrap; }
.station-disclosure { border-top: 1px solid var(--line); }
.station-disclosure > summary, .legend-disclosure > summary { padding: 10px 20px; font-weight: 600; font-size: 13px; cursor: pointer; color: var(--ink-2); }
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 950e1c1..18fea6b 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -122,7 +122,7 @@ select {
height: 42px;
border-radius: 12px;
background: var(--ink);
- color: #fff;
+ color: light-dark(#fff, #fff);
}
.rail__nav ul {
@@ -148,7 +148,7 @@ select {
}
.rail__link:hover {
- background: #efece3;
+ background: light-dark(#efece3, #21352c);
color: var(--ink);
}
@@ -222,13 +222,13 @@ select {
.conn-chip--online {
color: var(--accent-strong);
- border-color: #b8d6cc;
+ border-color: light-dark(#b8d6cc, #3c5142);
background: var(--accent-soft);
}
.conn-chip--lost {
color: var(--red-ink);
- border-color: #efc3be;
+ border-color: light-dark(#efc3be, #8d594a);
background: var(--red-soft);
}
@@ -292,7 +292,7 @@ select {
gap: 7px;
padding: 4px 11px;
border-radius: 999px;
- background: #efece3;
+ background: light-dark(#efece3, #21352c);
border: 1px solid var(--line);
color: var(--ink-2);
font-size: 12.5px;
@@ -302,19 +302,19 @@ select {
.pill--run {
background: var(--accent-soft);
- border-color: #b8d6cc;
+ border-color: light-dark(#b8d6cc, #3c5142);
color: var(--accent-strong);
}
.pill--pause,
.pill--done {
- background: #efece3;
+ background: light-dark(#efece3, #21352c);
color: var(--ink-2);
}
.pill--stale {
background: var(--amber-soft);
- border-color: #e6c98a;
+ border-color: light-dark(#e6c98a, #816936);
color: var(--amber-ink);
}
@@ -396,7 +396,7 @@ h1 {
.bar {
height: 7px;
border-radius: 999px;
- background: #ebe7dc;
+ background: light-dark(#ebe7dc, #21352c);
overflow: hidden;
}
@@ -479,7 +479,7 @@ h1 {
}
.btn:hover:not(:disabled) {
- background: #f1eee5;
+ background: light-dark(#f1eee5, #21352c);
border-color: var(--ink-3);
}
@@ -491,12 +491,12 @@ h1 {
min-width: 140px;
background: var(--accent-strong);
border-color: var(--accent-strong);
- color: #fff;
+ color: light-dark(#fff, #fff);
}
.btn--primary:hover:not(:disabled) {
- background: #17503f;
- border-color: #17503f;
+ background: light-dark(#17503f, #14392e);
+ border-color: light-dark(#17503f, #567e60);
}
.btn--small {
@@ -516,7 +516,7 @@ h1 {
gap: 2px;
padding: 3px;
border-radius: 11px;
- background: #efece3;
+ background: light-dark(#efece3, #21352c);
border: 1px solid var(--line);
}
@@ -600,13 +600,13 @@ h1 {
.alert--error {
background: var(--red-soft);
- border-color: #efc3be;
- color: #7c211c;
+ border-color: light-dark(#efc3be, #8d594a);
+ color: light-dark(#7c211c, #edaa95);
}
.alert--warn {
background: var(--amber-soft);
- border-color: #e6c98a;
+ border-color: light-dark(#e6c98a, #816936);
color: var(--amber-ink);
}
@@ -760,7 +760,7 @@ h1 {
.node.is-selected .node__box {
stroke: var(--accent-strong);
stroke-width: 2.4;
- fill: #f6fbf9;
+ fill: light-dark(#f6fbf9, #f6fbf9);
}
.node__focus {
@@ -794,12 +794,12 @@ h1 {
}
.node__alert circle {
- stroke: #fff;
+ stroke: light-dark(#fff, #fff);
stroke-width: 2;
}
.node__alert text {
- fill: #fff;
+ fill: light-dark(#fff, #fff);
font-size: 14px;
font-weight: 800;
}
@@ -859,7 +859,7 @@ h1 {
}
.bar-track {
- fill: #ebe7dc;
+ fill: light-dark(#ebe7dc, #ebe7dc);
}
.bar-fill {
@@ -886,8 +886,8 @@ h1 {
}
.slot {
- fill: #efece3;
- stroke: #ddd8cb;
+ fill: light-dark(#efece3, #efece3);
+ stroke: light-dark(#ddd8cb, #ddd8cb);
stroke-width: 1;
}
@@ -908,7 +908,7 @@ h1 {
}
.conn--flow .conn__track {
- stroke: #a6cfc3;
+ stroke: light-dark(#a6cfc3, #a0d8b1);
}
.conn--full .conn__track {
@@ -1026,11 +1026,11 @@ h1 {
.chip[aria-pressed='true'] {
background: var(--ink);
border-color: var(--ink);
- color: #fff;
+ color: light-dark(#fff, #fff);
}
.chip[aria-pressed='true'] .glyph {
- color: #fff;
+ color: light-dark(#fff, #fff);
}
.details__title {
@@ -1187,7 +1187,7 @@ h1 {
}
.swatch--forecast {
- border-top: 3px dotted #3a9aa6;
+ border-top: 3px dotted light-dark(#3a9aa6, #3c5142);
}
.chart-wrap {
@@ -1334,7 +1334,7 @@ h1 {
}
.legend__alert-text {
- fill: #fff;
+ fill: light-dark(#fff, #fff);
font-size: 13px;
font-weight: 800;
}
@@ -1506,7 +1506,7 @@ h1 {
.spinner__track {
fill: none;
- stroke: #e3dfd3;
+ stroke: light-dark(#e3dfd3, #e3dfd3);
stroke-width: 4;
}
@@ -1527,7 +1527,7 @@ h1 {
.skeleton {
height: 120px;
border-radius: var(--radius);
- background: linear-gradient(90deg, #ece8de 0%, #f6f3ea 50%, #ece8de 100%);
+ background: linear-gradient(90deg, light-dark(#ece8de, #21352c) 0%, light-dark(#f6f3ea, #1b2b25) 50%, light-dark(#ece8de, #21352c) 100%);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
diff --git a/apps/web/src/three/CameraRig.test.ts b/apps/web/src/three/CameraRig.test.ts
new file mode 100644
index 0000000..b84a548
--- /dev/null
+++ b/apps/web/src/three/CameraRig.test.ts
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
+import { act, createElement as h, useImperativeHandle } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { PerspectiveCamera, Vector3 } from 'three';
+import { CameraRig, type CameraRequest } from './CameraRig';
+import { DEFAULT_LAYOUT } from './factoryLayout';
+import { buildConveyorRoute } from './conveyorPath';
+import { ConveyorMotion } from './conveyorMotion';
+
+const mock = vi.hoisted(() => ({ scene: null as any, frame: null as any, controls: null as any, props: null as any }));
+vi.mock('@react-three/fiber', () => ({ useThree: () => mock.scene, useFrame: (callback: unknown) => { mock.frame = callback; } }));
+vi.mock('@react-three/drei', () => ({ OrbitControls: (props: any) => { mock.props = props; useImperativeHandle(props.ref, () => mock.controls); return null; } }));
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+let root: Root, host: HTMLDivElement;
+const route = buildConveyorRoute(DEFAULT_LAYOUT);
+let motion: ConveyorMotion;
+const request = (view: CameraRequest['view'], sequence: number): CameraRequest => ({ view, sequence, station: 'welding', vehicleId: 'car-1' });
+const render = async (cameraRequest: CameraRequest, reducedMotion = false) => act(async () => root.render(h(CameraRig, { request: cameraRequest, layout: DEFAULT_LAYOUT, route, motion, reducedMotion })));
+beforeEach(() => {
+ mock.scene = { camera: new PerspectiveCamera(42, 1.5, .1, 2000), size: { width: 900, height: 600 }, invalidate: vi.fn() };
+ mock.controls = { target: new Vector3(), enableDamping: true, update: vi.fn() };
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host); motion = new ConveyorMotion();
+});
+afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
+describe('camera navigation', () => {
+ it('preserves a manually chosen pose on resize and hide/show', async () => {
+ const command = request('overview', 0); await render(command);
+ mock.scene.camera.position.set(40, 18, 27); mock.controls.target.set(9, 1, 5);
+ mock.scene.size = { width: 500, height: 650 }; await render(command);
+ expect(mock.scene.camera.position.toArray()).toEqual([40, 18, 27]); expect(mock.controls.target.toArray()).toEqual([9, 1, 5]);
+ mock.scene.size = { width: 0, height: 0 }; await render(command);
+ mock.scene.size = { width: 1000, height: 600 }; await render(command);
+ expect(mock.scene.camera.position.toArray()).toEqual([40, 18, 27]);
+ });
+ it('waits for a measurable canvas before applying a requested view', async () => {
+ mock.scene.size = { width: 0, height: 0 }; const command = request('station', 1); await render(command, true);
+ expect(mock.controls.update).not.toHaveBeenCalled();
+ mock.scene.size = { width: 900, height: 600 }; await render(command, true);
+ expect(mock.controls.target.toArray()).toEqual([12, 1, -15]);
+ });
+ it('animates a requested focus and lets a manual gesture cancel it immediately', async () => {
+ await render(request('overview', 0)); const initial = mock.scene.camera.position.clone();
+ await render(request('station', 1)); expect(mock.scene.camera.position.equals(initial)).toBe(true);
+ mock.frame({}, 1 / 60); expect(mock.scene.camera.position.equals(initial)).toBe(false);
+ mock.props.onStart(); const manual = mock.scene.camera.position.clone(); mock.frame({}, 1 / 60);
+ expect(mock.scene.camera.position.equals(manual)).toBe(true);
+ });
+ it('stops vehicle tracking after manual navigation', async () => {
+ const car = (distance: number) => ({ id: 'car-1', serial: 1, distance, speed: .5, stage: 'welding' as const, state: 'moving' as const, appearance: 'body' as const, outcome: 'pending' as const });
+ motion.ingest([car(5)], 's', 10, performance.now(), false, 4);
+ await render(request('vehicle', 1), true); const initial = mock.scene.camera.position.clone();
+ motion.ingest([car(10)], 's', 20, performance.now(), false, 4); mock.frame({}, 1 / 60);
+ expect(mock.scene.camera.position.equals(initial)).toBe(false);
+ mock.props.onStart(); const manual = mock.scene.camera.position.clone();
+ motion.ingest([car(15)], 's', 30, performance.now(), false, 4); mock.frame({}, 1 / 60);
+ expect(mock.scene.camera.position.equals(manual)).toBe(true);
+ });
+});
diff --git a/apps/web/src/three/CameraRig.tsx b/apps/web/src/three/CameraRig.tsx
new file mode 100644
index 0000000..6b2e720
--- /dev/null
+++ b/apps/web/src/three/CameraRig.tsx
@@ -0,0 +1,86 @@
+import { useEffect, useRef } from 'react';
+import { useFrame, useThree } from '@react-three/fiber';
+import { OrbitControls } from '@react-three/drei';
+import { Vector3, type PerspectiveCamera } from 'three';
+import type { OrbitControls as OrbitControlsType } from 'three-stdlib';
+import type { StationId } from '@driveindui/shared';
+import type { FactoryLayout } from './factoryLayout';
+import { sampleConveyor, type ConveyorRoute } from './conveyorPath';
+import type { ConveyorMotion } from './conveyorMotion';
+import { constrainPan, overviewDistance } from './cameraGeometry';
+
+export type CameraView = 'overview' | 'top' | 'station' | 'vehicle';
+export interface CameraRequest { view: CameraView; sequence: number; station: StationId; vehicleId?: string }
+export function CameraRig({ request, layout, route, motion, reducedMotion }: { request: CameraRequest; layout: FactoryLayout; route: ConveyorRoute; motion: ConveyorMotion; reducedMotion: boolean }) {
+ const controls = useRef(null);
+ const { camera, size, invalidate } = useThree();
+ const applied = useRef<{ request: CameraRequest; layout: FactoryLayout } | null>(null);
+ const transition = useRef<{ position: Vector3; target: Vector3 } | null>(null);
+ const following = useRef(false);
+ const followPoint = useRef(new Vector3());
+ useEffect(() => {
+ const orbit = controls.current;
+ // A hidden workspace can have a zero-sized canvas. Never frame it until it is visible.
+ if (!orbit || size.width < 2 || size.height < 2) return;
+ // Resizing/fullscreen changes only the aspect ratio; it must not overwrite manual navigation.
+ if (applied.current?.request === request && applied.current.layout === layout) return;
+ const first = applied.current === null;
+ const target = new Vector3(0, 0, 0);
+ const offset = new Vector3(0.35, 1.05, 1.25).normalize();
+ const aspect = size.width / size.height;
+ let distance = overviewDistance(layout.floor.width, layout.floor.depth, aspect, (camera as PerspectiveCamera).fov, offset);
+ following.current = false;
+ if (request.view === 'station') {
+ const station = layout.stations.find(item => item.id === request.station)!;
+ target.set(station.position[0], 1, station.position[1]);
+ distance = Math.max(16, 14 / Math.max(0.45, aspect));
+ } else if (request.view === 'vehicle' && request.vehicleId) {
+ const vehicleDistance = motion.distance(request.vehicleId, performance.now());
+ if (vehicleDistance === undefined) return;
+ const pose = sampleConveyor(route, vehicleDistance);
+ target.set(pose.x, 0.8, pose.z);
+ followPoint.current.copy(target); following.current = true;
+ distance = Math.max(14, 13 / Math.max(0.45, aspect));
+ } else if (request.view === 'top') {
+ offset.set(0, 1, 0.05).normalize();
+ distance = overviewDistance(layout.floor.width, layout.floor.depth, aspect, (camera as PerspectiveCamera).fov, offset);
+ }
+ const destination = target.clone().addScaledVector(offset, distance);
+ applied.current = { request, layout };
+ // Flush residual damping before beginning a deliberate camera move.
+ const damping = orbit.enableDamping; orbit.enableDamping = false; orbit.update(); orbit.enableDamping = damping;
+ if (first || reducedMotion) {
+ camera.position.copy(destination); orbit.target.copy(target); orbit.update(); transition.current = null;
+ } else transition.current = { position: destination, target };
+ invalidate();
+ }, [request, layout, route, motion, size.width, size.height, camera, invalidate, reducedMotion]);
+ useFrame((_, delta) => {
+ const orbit = controls.current;
+ if (!orbit) return;
+ const next = transition.current;
+ if (following.current && request.vehicleId) {
+ const distance = motion.distance(request.vehicleId, performance.now());
+ if (distance === undefined) following.current = false;
+ else {
+ const pose = sampleConveyor(route, distance);
+ const dx = pose.x - followPoint.current.x, dz = pose.z - followPoint.current.z;
+ if (next) { next.position.x += dx; next.position.z += dz; next.target.set(pose.x, .8, pose.z); }
+ else { camera.position.x += dx; camera.position.z += dz; orbit.target.set(pose.x, .8, pose.z); }
+ followPoint.current.set(pose.x, .8, pose.z);
+ orbit.update();
+ }
+ }
+ if (next) {
+ const alpha = reducedMotion ? 1 : 1 - Math.exp(-12 * Math.min(delta, .05));
+ camera.position.lerp(next.position, alpha); orbit.target.lerp(next.target, alpha);
+ if (camera.position.distanceToSquared(next.position) < .0004 && orbit.target.distanceToSquared(next.target) < .0004) {
+ camera.position.copy(next.position); orbit.target.copy(next.target); transition.current = null;
+ }
+ orbit.update(); invalidate();
+ }
+ });
+ return { transition.current = null; following.current = false; }}
+ onChange={() => { if (controls.current) constrainPan(camera.position, controls.current.target, layout.floor.width, layout.floor.depth); invalidate(); }} />;
+}
diff --git a/apps/web/src/three/ConveyorFleet.tsx b/apps/web/src/three/ConveyorFleet.tsx
index 1cd97fc..922a8c3 100644
--- a/apps/web/src/three/ConveyorFleet.tsx
+++ b/apps/web/src/three/ConveyorFleet.tsx
@@ -66,7 +66,7 @@ export function ConveyorFleet({ vehicles, route, motion, selectedId, onSelect }:
return
{PARTS.map(part => { if (mesh) meshes.current.set(part, mesh); else meshes.current.delete(part); }}
args={[geometries[part], undefined, capacity]} frustumCulled={false} castShadow={part === 'body' || part === 'tires'} receiveShadow
- onClick={event => { const id = event.instanceId; if (id !== undefined && vehicles[id]) { event.stopPropagation(); onSelect(vehicles[id].id); } }}>
+ onClick={event => { if (event.delta > 5) return; const id = event.instanceId; if (id !== undefined && vehicles[id]) { event.stopPropagation(); onSelect(vehicles[id].id); } }}>
)}
diff --git a/apps/web/src/three/FactoryCanvas.tsx b/apps/web/src/three/FactoryCanvas.tsx
index 285d077..d07d5af 100644
--- a/apps/web/src/three/FactoryCanvas.tsx
+++ b/apps/web/src/three/FactoryCanvas.tsx
@@ -1,20 +1,20 @@
+import { t } from '../i18n';
import { useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
-import { OrbitControls } from '@react-three/drei';
-import { Vector3, type PerspectiveCamera } from 'three';
-import type { OrbitControls as OrbitControlsType } from 'three-stdlib';
+import { Vector3 } from 'three';
import type { SessionSnapshot, StationId, StationSnapshot, StationStatus } from '@driveindui/shared';
import { EquipmentModel } from './EquipmentModel';
import { stationCanAnimate, type FactoryLayout, type LayoutStation } from './factoryLayout';
-import { buildConveyorRoute, sampleConveyor, type ConveyorRoute } from './conveyorPath';
+import { buildConveyorRoute } from './conveyorPath';
import { ConveyorBelt } from './ConveyorBelt';
import { ConveyorFleet } from './ConveyorFleet';
import { ConveyorMotion } from './conveyorMotion';
import { STATUS_META } from '../status';
import { FactoryScenery } from './FactoryScenery';
+import { CameraRig, type CameraRequest } from './CameraRig';
+import { usePreferences } from '../preferences';
-export type CameraView = 'overview' | 'top' | 'station' | 'vehicle';
-export interface CameraRequest { view: CameraView; sequence: number; station: StationId; vehicleId?: string }
+export type { CameraRequest, CameraView } from './CameraRig';
export interface FactoryCanvasProps {
snapshot: SessionSnapshot;
selected: StationId;
@@ -34,61 +34,6 @@ const COLORS: Record = {
running: '#239982', idle: '#829497', warning: '#edb34f', blocked: '#d28a3a', stopped: '#e05044',
};
-function CameraRig({ request, layout, route, motion }: { request: CameraRequest; layout: FactoryLayout; route: ConveyorRoute; motion: ConveyorMotion }) {
- const controls = useRef(null);
- const { camera, size, invalidate } = useThree();
- useEffect(() => {
- const orbit = controls.current;
- if (!orbit) return;
- const target = new Vector3(0, 0, 0);
- const offset = new Vector3(0.35, 1.05, 1.25).normalize();
- const aspect = size.width / Math.max(1, size.height);
- const { width, depth } = layout.floor;
- // Fit all corners in camera space, including perspective depth near the camera.
- // A flat width/height estimate clips the outlet on wide, short viewports.
- const right = new Vector3(offset.z, 0, -offset.x).normalize();
- const up = new Vector3().crossVectors(offset, right).normalize();
- const tanFov = Math.tan((camera as PerspectiveCamera).fov * Math.PI / 360);
- let distance = 0;
- for (const x of [-width / 2, width / 2]) for (const z of [-depth / 2, depth / 2]) for (const y of [0, 6]) {
- const corner = new Vector3(x, y, z);
- const towardCamera = corner.dot(offset);
- distance = Math.max(distance, towardCamera + Math.abs(corner.dot(up)) / tanFov,
- towardCamera + Math.abs(corner.dot(right)) / (tanFov * aspect));
- }
- distance *= 1.08;
- if (request.view === 'station') {
- const station = layout.stations.find(item => item.id === request.station)!;
- target.set(station.position[0], 1, station.position[1]);
- distance = Math.max(16, 14 / Math.max(0.45, aspect));
- } else if (request.view === 'vehicle' && request.vehicleId) {
- const pose = sampleConveyor(route, motion.distance(request.vehicleId, performance.now()) ?? 0);
- target.set(pose.x, 0.8, pose.z);
- distance = Math.max(14, 13 / Math.max(0.45, aspect));
- } else if (request.view === 'top') {
- offset.set(0, 1, 0.001).normalize();
- distance = Math.max(layout.floor.depth * 1.4, layout.floor.width / Math.max(0.45, aspect) * 1.4);
- }
- camera.position.copy(target).addScaledVector(offset, distance);
- (camera as PerspectiveCamera).zoom = 1;
- camera.updateProjectionMatrix();
- orbit.target.copy(target);
- orbit.update();
- invalidate();
- }, [request, layout, route, motion, size.width, size.height, camera, invalidate]);
- useFrame(() => {
- const orbit = controls.current;
- if (!orbit || request.view !== 'vehicle' || !request.vehicleId) return;
- const distance = motion.distance(request.vehicleId, performance.now());
- if (distance === undefined) return;
- const pose = sampleConveyor(route, distance);
- camera.position.x += pose.x - orbit.target.x;
- camera.position.z += pose.z - orbit.target.z;
- orbit.target.set(pose.x, 0.8, pose.z);
- orbit.update();
- });
- return ;
-}
function ContextGuard({ onFailure }: { onFailure: () => void }) {
const canvas = useThree(state => state.gl.domElement);
@@ -118,7 +63,7 @@ function Station({ placement, station, selected, animate, reducedMotion, onSelec
}) {
const color = COLORS[station.status];
return
- { event.stopPropagation(); onSelect(station.id); }}>
+ { if (event.delta > 5) return; event.stopPropagation(); onSelect(station.id); }}>
@@ -179,6 +124,7 @@ function LabelProjection({ anchors, labels }: { anchors: LabelAnchor[]; labels:
export default function FactoryCanvas(props: FactoryCanvasProps) {
const { layout, snapshot, selected, onSelect, animate, reducedMotion, renderActive, quality, cameraRequest, onFailure, selectedVehicleId, onSelectVehicle } = props;
+ const { theme } = usePreferences();
const route = useMemo(() => buildConveyorRoute(layout), [layout]);
const motion = useMemo(() => new ConveyorMotion(), []);
useLayoutEffect(() => {
@@ -191,9 +137,9 @@ export default function FactoryCanvas(props: FactoryCanvasProps) {
{ id: 'finished', position: [layout.terminals.finished[0], 2.5, layout.terminals.finished[1]] },
], [layout]);
return <>Для 3D требуется поддержка WebGL. Доступна 2D-схема.}>
-
+ frameloop={!renderActive ? 'never' : animate && !reducedMotion ? 'always' : 'demand'} camera={{ position: [35, 35, 45], fov: 42, near: 0.1, far: 2000 }}
+ gl={{ antialias: true, powerPreference: 'default' }} fallback={{t("Для 3D требуется поддержка WebGL. Доступна 2D-схема.")} }>
+
-
+
@@ -218,10 +164,10 @@ export default function FactoryCanvas(props: FactoryCanvasProps) {
const station = snapshot.stations.find(item => item.id === anchor.id);
return { if (element) labels.current.set(anchor.id, element); else labels.current.delete(anchor.id); }}>
{station ?
onSelect(station.id)} aria-pressed={selected === station.id}>
- {station.name}
- {STATUS_META[station.status].short} · буфер {station.inputQueue}/{station.bufferCapacity}
- {station.id === 'welding' ? 'ABB-01 / ABB-04' : station.id === 'painting' ? 'Камера-02' : station.id === 'assembly' ? 'Конвейер-03' : 'ОТК · условный пост'}
- :
{anchor.id === 'supply' ? 'Склад комплектующих' : 'Склад готовой продукции'}{anchor.id === 'supply' ? `Подано: ${snapshot.introducedUnits}` : `${snapshot.goodUnits} годных · ${snapshot.rejectedUnits} брак`}
}
+
{t(station.name)}
+
{t(STATUS_META[station.status].short)} {t(" · буфер ")}{t(station.inputQueue)}/{t(station.bufferCapacity)}
+
{t(station.id === 'welding' ? 'ABB-01 / ABB-04' : station.id === 'painting' ? 'Камера-02' : station.id === 'assembly' ? 'Конвейер-03' : 'ОТК · условный пост')}
+ :
{t(anchor.id === 'supply' ? 'Склад комплектующих' : 'Склад готовой продукции')}{t(anchor.id === 'supply' ? `Подано: ${snapshot.introducedUnits}` : `${snapshot.goodUnits} годных · ${snapshot.rejectedUnits} брак`)}
}
;
})}
>;
diff --git a/apps/web/src/three/cameraGeometry.test.ts b/apps/web/src/three/cameraGeometry.test.ts
new file mode 100644
index 0000000..2f675ac
--- /dev/null
+++ b/apps/web/src/three/cameraGeometry.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+import { PerspectiveCamera, Vector3 } from 'three';
+import { overviewDistance, constrainPan } from './cameraGeometry';
+
+describe('shop floor camera', () => {
+ it('keeps all floor corners and equipment height inside portrait and wide views', () => {
+ for (const width of [20, 52, 100]) for (const depth of [20, 48, 100]) for (const aspect of [.45, 1, 2.5]) {
+ for (const offset of [new Vector3(.35, 1.05, 1.25).normalize(), new Vector3(0, 1, .05).normalize()]) {
+ const camera = new PerspectiveCamera(42, aspect, .1, 2000);
+ camera.position.copy(offset).multiplyScalar(overviewDistance(width, depth, aspect, 42, offset));
+ camera.lookAt(0, 0, 0); camera.updateMatrixWorld();
+ for (const x of [-width / 2, width / 2]) for (const z of [-depth / 2, depth / 2]) for (const y of [0, 6]) {
+ const projected = new Vector3(x, y, z).project(camera);
+ expect(Math.abs(projected.x)).toBeLessThan(1);
+ expect(Math.abs(projected.y)).toBeLessThan(1);
+ expect(projected.z).toBeLessThan(1);
+ }
+ }
+ }
+ });
+ it('bounds panning without changing the direction or zoom', () => {
+ const target = new Vector3(500, 1, -300), camera = new Vector3(520, 35, -260);
+ const direction = camera.clone().sub(target);
+ constrainPan(camera, target, 52, 48);
+ expect(target.toArray()).toEqual([34, 1, -32]);
+ expect(camera.clone().sub(target).toArray()).toEqual(direction.toArray());
+ const previous = camera.clone(); constrainPan(camera, target, 52, 48); expect(camera.equals(previous)).toBe(true);
+ });
+});
diff --git a/apps/web/src/three/cameraGeometry.ts b/apps/web/src/three/cameraGeometry.ts
new file mode 100644
index 0000000..9309844
--- /dev/null
+++ b/apps/web/src/three/cameraGeometry.ts
@@ -0,0 +1,24 @@
+import { Vector3 } from 'three';
+
+/** Perspective framing uses every floor corner, including depth towards the camera. */
+export function overviewDistance(width: number, depth: number, aspect: number, fov: number, offset: Vector3): number {
+ const safeAspect = Math.max(0.1, Number.isFinite(aspect) ? aspect : 1);
+ const right = new Vector3(offset.z, 0, -offset.x).normalize();
+ const up = new Vector3().crossVectors(offset, right).normalize();
+ const tanFov = Math.tan(fov * Math.PI / 360);
+ let distance = 0;
+ for (const x of [-width / 2, width / 2]) for (const z of [-depth / 2, depth / 2]) for (const y of [0, 6]) {
+ const corner = new Vector3(x, y, z);
+ distance = Math.max(distance, corner.dot(offset) + Math.abs(corner.dot(up)) / tanFov,
+ corner.dot(offset) + Math.abs(corner.dot(right)) / (tanFov * safeAspect));
+ }
+ return distance * 1.08;
+}
+
+/** Pan stays on the shop floor; translate both camera and target to preserve the view direction. */
+export function constrainPan(position: Vector3, target: Vector3, width: number, depth: number): void {
+ const x = Math.max(-width / 2 - 8, Math.min(width / 2 + 8, target.x));
+ const z = Math.max(-depth / 2 - 8, Math.min(depth / 2 + 8, target.z));
+ position.x += x - target.x; position.z += z - target.z;
+ target.x = x; target.z = z;
+}
diff --git a/apps/web/src/workspaces.css b/apps/web/src/workspaces.css
index 077a6f2..53f4a3f 100644
--- a/apps/web/src/workspaces.css
+++ b/apps/web/src/workspaces.css
@@ -1,7 +1,7 @@
/* Workspace chrome is scoped so the existing 2D diagram keeps its own styles. */
.workbench.app {
grid-template-columns:200px minmax(0,1fr);
- background:#f2f4f0;
+ background:light-dark(#f2f4f0, #1b2b25);
}
.workbench [hidden] {
@@ -11,8 +11,8 @@
.workbench .rail {
align-items:stretch;
padding:25px 16px;
- background:#163c38;
- color:#e5efea;
+ background:light-dark(#163c38, #14392e);
+ color:light-dark(#e5efea, #e5efea);
border:0;
gap:0;
}
@@ -21,7 +21,7 @@
display:flex;
gap:11px;
align-items:center;
- color:#f4f4e8;
+ color:light-dark(#f4f4e8, #f4f4e8);
text-decoration:none;
margin:0 0 48px;
font-size:19px;
@@ -30,7 +30,7 @@
}
.workspace-brand svg {
- color:#b9d296;
+ color:light-dark(#b9d296, #a0d8b1);
flex-shrink:0;
}
@@ -38,7 +38,7 @@
display:block;
font-size:9px;
letter-spacing:.04em;
- color:#9bb7a9;
+ color:light-dark(#9bb7a9, #a8bca7);
font-weight:500;
margin-top:4px;
}
@@ -46,7 +46,7 @@
.rail-caption {
font-size:8px;
letter-spacing:.13em;
- color:#96b2a3;
+ color:light-dark(#96b2a3, #a8bca7);
margin:0 10px 15px;
}
@@ -61,7 +61,7 @@
width:100%;
padding:13px 12px;
border-radius:8px;
- color:#bbd0c4;
+ color:light-dark(#bbd0c4, #a8bca7);
font-size:13px;
font-weight:500;
}
@@ -69,35 +69,35 @@
.workbench .rail__link small {
margin-left:auto;
font-size:9px;
- color:#7c9f8f;
+ color:light-dark(#7c9f8f, #a8bca7);
}
.workbench .rail__link:hover {
- background:#214b43;
- color:#fff;
+ background:light-dark(#214b43, #14392e);
+ color:light-dark(#fff, #fff);
}
.workbench .rail__link[aria-current=page] {
- background:#dfeacb;
- color:#183e34;
+ background:light-dark(#dfeacb, #284133);
+ color:light-dark(#183e34, #e0eade);
font-weight:650;
}
.workbench .rail__link[aria-current=page] small {
- color:#5e7863;
+ color:light-dark(#5e7863, #b6c8b5);
}
.rail-footer {
margin-top:auto;
padding:20px 10px 0;
- border-top:1px solid #36554b;
+ border-top:1px solid light-dark(#36554b, #567e60);
font-size:11px;
- color:#ccdccf;
+ color:light-dark(#ccdccf, #ccdccf);
}
.rail-footer small {
display:block;
- color:#8fac9b;
+ color:light-dark(#8fac9b, #a8bca7);
font-size:10px;
margin:6px 0 0 14px;
}
@@ -107,16 +107,16 @@
width:6px;
height:6px;
border-radius:50%;
- background:#b9d296;
+ background:light-dark(#b9d296, #284133);
margin-right:8px;
}
.workbench .topbar {
height:65px;
min-height:65px;
- background:#f7f8f3f2;
+ background:light-dark(#f7f8f3f2, #1b2b25f2);
padding:12px 28px;
- border-bottom:1px solid #dfe5da;
+ border-bottom:1px solid light-dark(#dfe5da, #3c5142);
box-shadow:none;
}
@@ -164,7 +164,7 @@
.workspace-eyebrow {
font-size:10px;
letter-spacing:.08em;
- color:#72806e;
+ color:light-dark(#72806e, #b6c8b5);
font-weight:550;
}
@@ -187,8 +187,8 @@
top:-60px;
z-index:100;
padding:12px;
- background:#fff;
- color:#163c38;
+ background:light-dark(#fff, #1b2b25);
+ color:light-dark(#163c38, #e0eade);
}
.skip-link:focus {
@@ -197,8 +197,8 @@
.workbench .session-toolbar {
padding:12px 16px;
- background:#fffefb;
- border-color:#dce3d7;
+ background:light-dark(#fffefb, #1b2b25);
+ border-color:light-dark(#dce3d7, #3c5142);
box-shadow:none;
}
@@ -210,14 +210,14 @@
.session-time {
min-width:93px;
- border-right:1px solid #e0e5da;
+ border-right:1px solid light-dark(#e0e5da, #3c5142);
padding-right:16px;
}
.session-time span {
display:block;
font-size:9px;
- color:#788273;
+ color:light-dark(#788273, #b6c8b5);
}
.session-time strong {
@@ -245,17 +245,17 @@
gap:10px;
margin-left:auto;
font-size:10px;
- color:#7c8874;
+ color:light-dark(#7c8874, #b6c8b5);
}
.session-scenario select {
max-width:260px;
padding:7px 10px;
- border:1px solid #dce3d5;
+ border:1px solid light-dark(#dce3d5, #3c5142);
border-radius:7px;
- background:#f8f9f3;
+ background:light-dark(#f8f9f3, #1b2b25);
font-size:11px;
- color:#344d40;
+ color:light-dark(#344d40, #e0eade);
min-height:34px;
}
@@ -269,7 +269,7 @@
display:grid;
place-items:center;
list-style:none;
- color:#74836d;
+ color:light-dark(#74836d, #b6c8b5);
padding:6px;
}
@@ -283,8 +283,8 @@
top:37px;
width:270px;
padding:16px;
- background:#fff;
- border:1px solid #dce3d7;
+ background:light-dark(#fff, #1b2b25);
+ border:1px solid light-dark(#dce3d7, #3c5142);
box-shadow:0 10px 35px #18382c20;
border-radius:10px;
font-size:12px;
@@ -293,7 +293,7 @@
.session-info p {
margin-top:8px;
- color:#687962;
+ color:light-dark(#687962, #b6c8b5);
}
.session-toolbar .alert {
@@ -372,8 +372,8 @@
.overview-plan {
display:block;
- background:#1e4940;
- color:#f1f5df;
+ background:light-dark(#1e4940, #14392e);
+ color:light-dark(#f1f5df, #f1f5df);
border-radius:14px;
padding:19px;
text-decoration:none;
@@ -386,7 +386,7 @@
justify-content:space-between;
font-size:9px;
letter-spacing:.1em;
- color:#bad0b9;
+ color:light-dark(#bad0b9, #a8bca7);
}
.overview-plan>strong {
@@ -400,12 +400,12 @@
font-size:12px;
letter-spacing:0;
font-weight:400;
- color:#c0d4be;
+ color:light-dark(#c0d4be, #c0d4be);
}
.overview-plan p {
font-size:11px;
- color:#cfddc8;
+ color:light-dark(#cfddc8, #cfddc8);
margin:2px 0 16px;
}
@@ -422,7 +422,7 @@
justify-content:space-between;
align-items:center;
padding:16px 20px;
- border-bottom:1px solid #e3e6dc;
+ border-bottom:1px solid light-dark(#e3e6dc, #3c5142);
}
.overview-section-head h2,.overview-next h2 {
@@ -432,7 +432,7 @@
.overview-section-head>span {
font-size:11px;
- color:#7c8675;
+ color:light-dark(#7c8675, #b6c8b5);
}
.station-overview-grid {
@@ -443,7 +443,7 @@
.station-overview-grid button {
background:transparent;
border:0;
- border-right:1px solid #e3e6dc;
+ border-right:1px solid light-dark(#e3e6dc, #3c5142);
padding:17px 18px;
text-align:left;
cursor:pointer;
@@ -455,7 +455,7 @@
}
.station-overview-grid button:hover {
- background:#f2f6eb;
+ background:light-dark(#f2f6eb, #1b2b25);
}
.station-overview-top {
@@ -466,7 +466,7 @@
}
.station-overview-top>small {
- color:#879581;
+ color:light-dark(#879581, #b6c8b5);
font-size:10px;
}
@@ -474,23 +474,23 @@
font-size:9px;
border-radius:20px;
padding:3px 7px;
- background:#eff1e9;
- color:#677660;
+ background:light-dark(#eff1e9, #21352c);
+ color:light-dark(#677660, #b6c8b5);
}
.station-state.is-running {
- background:#e6efe3;
- color:#3a7257;
+ background:light-dark(#e6efe3, #21352c);
+ color:light-dark(#3a7257, #a0d8b1);
}
.station-state.is-stopped {
- background:#fae5df;
- color:#ae4433;
+ background:light-dark(#fae5df, #284133);
+ color:light-dark(#ae4433, #edaa95);
}
.station-state.is-blocked,.station-state.is-warning {
- background:#fbecd0;
- color:#9f6d26;
+ background:light-dark(#fbecd0, #284133);
+ color:light-dark(#9f6d26, #edaa95);
}
.station-overview-grid button>strong {
@@ -503,7 +503,7 @@
}
.station-overview-grid strong svg {
- color:#8e9b82;
+ color:light-dark(#8e9b82, #a8bca7);
flex-shrink:0;
}
@@ -512,18 +512,18 @@
justify-content:space-between;
gap:8px;
font-size:10px;
- color:#7c8875;
+ color:light-dark(#7c8875, #b6c8b5);
}
.station-overview-metrics b {
- color:#435a45;
+ color:light-dark(#435a45, #b6c8b5);
font-size:11px;
margin-left:4px;
}
.station-capacity {
height:3px;
- background:#e7eadf;
+ background:light-dark(#e7eadf, #21352c);
border-radius:2px;
margin-top:9px;
overflow:hidden;
@@ -531,7 +531,7 @@
.station-capacity i {
display:block;
- background:#709d79;
+ background:light-dark(#709d79, #284133);
height:100%;
}
@@ -551,7 +551,7 @@
.overview-next p {
font-size:12px;
- color:#75816e;
+ color:light-dark(#75816e, #b6c8b5);
line-height:1.65;
}
@@ -566,7 +566,7 @@
.incident-description {
font-size:11px;
- color:#718168;
+ color:light-dark(#718168, #b6c8b5);
margin-top:6px;
}
@@ -578,7 +578,7 @@
display:flex;
gap:5px;
padding:5px;
- background:#e9ede2;
+ background:light-dark(#e9ede2, #21352c);
border-radius:10px;
width:fit-content;
max-width:100%;
@@ -591,12 +591,12 @@
padding:10px 16px;
border-radius:7px;
font-size:12px;
- color:#738169;
+ color:light-dark(#738169, #b6c8b5);
}
.workspace-tabs button[aria-pressed=true] {
- background:#fffefa;
- color:#274e3c;
+ background:light-dark(#fffefa, #1b2b25);
+ color:light-dark(#274e3c, #a0d8b1);
box-shadow:0 1px 5px #21382510;
font-weight:600;
}
@@ -624,12 +624,12 @@
.plan-totals {
display:grid;
grid-template-columns:repeat(3,1fr);
- border-bottom:1px solid #e0e5d9;
+ border-bottom:1px solid light-dark(#e0e5d9, #3c5142);
}
.plan-totals>div {
padding:22px 25px;
- border-right:1px solid #e0e5d9;
+ border-right:1px solid light-dark(#e0e5d9, #3c5142);
}
.plan-totals>div:last-child {
@@ -639,7 +639,7 @@
.plan-totals span {
display:block;
font-size:11px;
- color:#7a846f;
+ color:light-dark(#7a846f, #b6c8b5);
}
.plan-totals strong {
@@ -652,16 +652,16 @@
.plan-totals strong small {
font-size:12px;
font-weight:400;
- color:#7b8a70;
+ color:light-dark(#7b8a70, #b6c8b5);
letter-spacing:0;
}
.plan-totals .is-warning {
- background:#faf0db;
+ background:light-dark(#faf0db, #284133);
}
.plan-totals .is-warning strong {
- color:#977132;
+ color:light-dark(#977132, #edaa95);
}
.workbench .case-plan-summary {
@@ -733,7 +733,7 @@
.method-intro p {
font-size:13px;
line-height:1.75;
- color:#6e7c64;
+ color:light-dark(#6e7c64, #b6c8b5);
max-width:800px;
margin-top:8px;
}
@@ -802,8 +802,8 @@
.workbench .cmp-panel {
gap:18px;
padding:23px;
- border-color:#dce3d7;
- background:#fffefb;
+ border-color:light-dark(#dce3d7, #3c5142);
+ background:light-dark(#fffefb, #1b2b25);
}
.workbench .cmp-heading {
@@ -811,8 +811,8 @@
}
.workbench .cmp-controls {
- background:#f2f5eb;
- border-color:#e0e7d6;
+ background:light-dark(#f2f5eb, #1b2b25);
+ border-color:light-dark(#e0e7d6, #3c5142);
}
.workbench .cmp-empty {
@@ -830,9 +830,9 @@
.decision-preview article {
padding:18px;
- border:1px solid #e2e6da;
+ border:1px solid light-dark(#e2e6da, #3c5142);
border-radius:10px;
- background:#f8f9f3;
+ background:light-dark(#f8f9f3, #1b2b25);
}
.decision-preview b {
@@ -843,15 +843,15 @@
.decision-preview span {
display:block;
font-size:11px;
- color:#79876e;
+ color:light-dark(#79876e, #b6c8b5);
margin-top:8px;
line-height:1.6;
}
.decision-result {
padding:20px 22px;
- background:#e8f0dc;
- border:1px solid #d7e4c6;
+ background:light-dark(#e8f0dc, #21352c);
+ border:1px solid light-dark(#d7e4c6, #3c5142);
border-radius:12px;
margin-bottom:16px;
}
@@ -859,7 +859,7 @@
.decision-result>span {
font-size:9px;
letter-spacing:.12em;
- color:#658152;
+ color:light-dark(#658152, #a0d8b1);
}
.decision-result h3 {
@@ -870,14 +870,14 @@
.decision-result p {
font-size:12px;
- color:#6f8062;
+ color:light-dark(#6f8062, #b6c8b5);
line-height:1.65;
}
.decision-detail {
font-size:11px;
- color:#6a7b5e;
- border-top:1px solid #e3e7dc;
+ color:light-dark(#6a7b5e, #b6c8b5);
+ border-top:1px solid light-dark(#e3e7dc, #3c5142);
padding-top:10px;
}
@@ -1008,7 +1008,7 @@
grid-template-columns:repeat(2,1fr);
}
.station-overview-grid button {
- border-bottom:1px solid #e3e6dc;
+ border-bottom:1px solid light-dark(#e3e6dc, #3c5142);
}
.station-overview-grid button:nth-child(2) {
border-right:0;
@@ -1048,8 +1048,8 @@
right:0;
height:auto;
padding:6px 10px max(7px,env(safe-area-inset-bottom));
- background:#f8faf4;
- border-top:1px solid #dbe3d2;
+ background:light-dark(#f8faf4, #1b2b25);
+ border-top:1px solid light-dark(#dbe3d2, #3c5142);
z-index:50;
display:block;
}
@@ -1062,7 +1062,7 @@
gap:4px;
}
.workbench .rail__link {
- color:#6f8165;
+ color:light-dark(#6f8165, #b6c8b5);
min-width:0;
padding:7px 2px;
font-size:9px;
@@ -1074,8 +1074,8 @@
height:18px;
}
.workbench .rail__link[aria-current=page] {
- background:#e2eccf;
- color:#31583b;
+ background:light-dark(#e2eccf, #284133);
+ color:light-dark(#31583b, #a0d8b1);
}
.workbench .content {
padding:18px 13px 88px;
diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md
new file mode 100644
index 0000000..730a3fa
--- /dev/null
+++ b/docs/INTERFACE.md
@@ -0,0 +1,23 @@
+# Настройки интерфейса
+
+В верхней панели доступны языки RU (русский), KZ (қазақша) и ENG (English), а также переключатель светлой и тёмной темы. Выбор сохраняется в localStorage (`driveindui.preferences`). При недоступном хранилище он работает до обновления страницы. Переключение не создаёт новую сессию и не сбрасывает смену, план или выбор машины.
+
+Переводятся навигация, подписи, таблицы, подсказки, стандартные события модели и диагностика импорта. Числа и длительности используют выбранную локаль. Названия пользовательских наборов, импортированные данные и свободный текст ответа OpenAI сохраняют исходный язык; серверный запрос и API не изменены.
+
+Кнопка i рядом с таймером открывает информацию о сценарии поверх содержимого страницы. Закрытие: кнопка в окне, Escape, повторный клик по i или клик снаружи. Escape возвращает фокус кнопке i.
+
+## Камера цеха
+
+- Левая кнопка и перетаскивание — вращение; правая — перемещение по плоскости; колесо — масштаб.
+- Кнопки ракурса плавно переводят камеру. Любое ручное управление прерывает переход и слежение за машиной. Чтобы снова следить, нажмите «Следить за машиной».
+- Изменение размеров, разворачивание цеха и переход между страницами сохраняют выбранное положение камеры. «Общий вид» заново вписывает сцену в текущие размеры.
+- Перетаскивание по оборудованию или автомобилю не выбирает объект. Камера ограничена сверху относительно пола; перемещение не позволяет потерять цех далеко за границами сцены.
+- При системном reduced motion переходы мгновенные. CSS-анимации и инерция камеры также отключаются.
+
+## Поддержка
+
+Словарь `apps/web/src/locales/catalog.json` использует русскую фразу как ключ и пару переводов [казахский, английский]. Параметры сообщений обозначаются {0}, {1} и далее. Локализация применяется при отображении, не изменяя данные симулятора. Тест проверяет полноту словаря и сохранение параметров.
+
+Палитра использует CSS light-dark() и переменные цветов. Требуется современный браузер. Геометрия 2D-схемы и расчёты производства не меняются.
+
+Автоматические проверки: действия переключателей и окна информации в DOM, запрос кода до анализа ИИ, сохранение состояния, перевод сообщений и чисел, вписывание цеха и поведение камеры при изменении размера, скрытии и ручном управлении.
diff --git a/package-lock.json b/package-lock.json
index 0d79672..ebcfc3f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@types/three": "^0.186.0",
"concurrently": "^9.0.0",
"esbuild": "^0.25.0",
+ "jsdom": "26.1.0",
"tsx": "^4.20.0",
"typescript": "~5.9.0",
"vitest": "^4.1.11"
@@ -56,6 +57,27 @@
"vite": "^7.0.0"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -354,6 +376,121 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
@@ -1997,6 +2134,16 @@
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -2440,6 +2587,20 @@
"node": ">= 8"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2567,6 +2728,20 @@
"node": ">=12"
}
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2585,6 +2760,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -2638,6 +2820,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -2984,6 +3179,19 @@
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3004,6 +3212,47 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -3074,6 +3323,13 @@
"node": ">=8"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@@ -3098,6 +3354,46 @@
"react": "^19.0.0"
}
},
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3321,6 +3617,13 @@
"node": ">=18"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.28",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
+ "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
@@ -3344,6 +3647,19 @@
"node": ">=14.0.0"
}
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3503,6 +3819,16 @@
"lie": "^3.0.2"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -3732,6 +4058,13 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -3773,6 +4106,26 @@
"node": ">=10"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
@@ -3988,6 +4341,13 @@
"react": ">=17.0"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@@ -4088,6 +4448,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/toad-cache": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
@@ -4106,6 +4486,32 @@
"node": ">=0.6"
}
},
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -5434,6 +5840,19 @@
}
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/webgl-constants": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
@@ -5445,6 +5864,54 @@
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5495,6 +5962,45 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/package.json b/package.json
index c01dbd6..1e6e658 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"@types/three": "^0.186.0",
"concurrently": "^9.0.0",
"esbuild": "^0.25.0",
+ "jsdom": "26.1.0",
"tsx": "^4.20.0",
"typescript": "~5.9.0",
"vitest": "^4.1.11"