Merge remote-tracking branch 'upstream/main' into fix/drone-airspace

Keep both sides at the end of the de, fr, it, ja, pt and zh-Hant locale
files, where main's scene authoring strings landed next to this layer's.
This commit is contained in:
Matt Van Horn committed 2026-10-30 23:12:52 -07:00
commit c6a83b78df
48 files changed
+1971 -195

No files matched your search

+4 -4
View File
@@ -172,10 +172,10 @@ test('Context reads as one segmented control with legible type and no halo', ()
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
// Default theme only, expanded only: Cyber keeps its red titles and the
// collapsed launcher matches its sibling launchers.
assert.match(
// The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again.
assert.equal(
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
/color:\s*var\(--text-secondary\)/,
'',
);
});
+226
View File
@@ -0,0 +1,226 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
a22WebcamToSource,
loadA22SourcesFromOpenDataHub,
} from '../../server/providers/cctv/sources.js';
import {
A22_DEFAULT_GROUND_ELEVATION_M,
A22_IMAGE_ORIGIN,
DEFAULT_A22_CCTV_URL,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { staticFrameRefreshMs } from './cctvLod.js';
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
const item = (
patch = {},
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
) => ({
Id: `A22_${km}`,
Active: true,
Source: 'a22',
Shortname: 'ADS Sciliar',
Webcamname: { it: 'ADS Sciliar' },
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
Mapping: { a22: { km } },
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
LicenseInfo: {
ClosedData: false,
LicenseHolder: 'https://www.autobrennero.it/',
},
...patch,
});
test('an A22 webcam maps to a source on the pinned operator frame', () => {
const source = a22WebcamToSource(item());
assert.equal(source.id, 'it-a22-km68');
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
assert.equal(source.city, 'A22 Brennero');
assert.equal(source.cityId, 'a22');
assert.equal(source.provider, 'Autostrada del Brennero');
assert.equal(source.lat, 46.53591);
assert.equal(source.lon, 11.49772);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'a22-opendatahub');
assert.equal(source.code, 'ADS SCILIAR');
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
assert.equal(source.snapshotUrl, source.url);
});
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
const brenner = a22WebcamToSource(
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
);
const ponte = a22WebcamToSource(
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
);
assert.ok(brenner.groundElevationM > 1300);
assert.ok(ponte.groundElevationM < 50);
// A kilometre the table does not know falls back to the corridor prior.
const unknown = a22WebcamToSource(
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
);
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
});
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
assert.equal(a22WebcamToSource(item({ Active: false })), null);
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
),
null,
);
// The frame must be exactly the camera's own kilometre image.
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
),
null,
);
assert.equal(
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
null,
);
// Milan: plausible, but off the Brenner–Modena corridor.
assert.equal(
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
null,
);
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
assert.equal(a22WebcamToSource(null), null);
});
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
TotalResults: 3,
Items: [
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
item(),
item(),
item(
{ Shortname: 'Brennero' },
{ km: '1', lat: 46.99682, lon: 11.50218 },
),
],
});
});
const cameras = await loadA22SourcesFromOpenDataHub();
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
assert.deepEqual(
cameras.map((c) => c.id),
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
);
fetchMock.mock.mockImplementation(
async () => new Response('nope', { status: 503 }),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(
async () =>
new Response(null, {
status: 302,
headers: { Location: 'https://evil.test/' },
}),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(async () => {
throw new Error('offline');
});
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
});
const runCatalog = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === DEFAULT_A22_CCTV_URL) {
return Response.json({ Items: [item()] });
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
const withEnv = async (patch, fn) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_A22_ENABLED;
Object.assign(process.env, patch);
await fn();
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('the A22 lane is wired into the catalog', async (t) => {
await withEnv({}, async () => {
const { requested, sources } = await runCatalog(t);
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
['it-a22-km68'],
);
});
});
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
const { requested, sources } = await runCatalog(t);
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22'),
[],
);
});
});
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/WebCamImg/km77.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
const source = a22WebcamToSource(item());
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
});
+3
View File
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
// matches the other highway packs' 3-minute cadence.
'servei català de trànsit': 3 * 60 * 1000,
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
// tracks the minute); 3 min matches the other highway packs.
'autostrada del brennero': 3 * 60 * 1000,
});
/**
+7
View File
@@ -350,6 +350,13 @@ export const DATA_CREDITS = [
key: 'panoramax',
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
},
{
key: 'a22-cctv',
html:
'Motorway webcams (A22 Brennero–Modena): images © ' +
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
},
{
key: 'gbfs',
html:
+226 -2
View File
@@ -1,7 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import dns from 'node:dns/promises';
import https from 'node:https';
import net from 'node:net';
import { Readable } from 'node:stream';
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
import {
describeFetchError,
fetchFirms,
fetchIpv4,
isAddressSelectionFailure,
} from '../../server/providers/firms.js';
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
const start = config.indexOf(' async function refreshUpstream(key) {');
@@ -17,8 +28,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
// Inject only its upstream, clock and filter dependencies; keep its aggregation
// and source-status code intact, including failures while consuming records.
function createRefresh(fetchSource, filter = filterTrailing24h) {
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
}
test('FIRMS retains large sources in order and filters expired rows', async () => {
@@ -78,3 +89,216 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
assert.deepEqual(result.fires, []);
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
});
// #68: Node's network-family auto-selection gives each address a short attempt
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
// reports only `fetch failed`. This is the shape Node 24 throws for it.
function addressSelectionFailure() {
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
return new TypeError('fetch failed', { cause });
}
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
function wrappedAddressSelectionFailure() {
const inner = addressSelectionFailure().cause;
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
return new TypeError('fetch failed', { cause });
}
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
test('FIRMS failures log the socket cause, never the keyed URL', () => {
const described = describeFetchError(addressSelectionFailure());
assert.equal(described,
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
assert.ok(!described.includes('SECRET'));
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
});
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
const retried = [];
await fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw shape(); },
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
warn() {},
});
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
}
const signal = new AbortController().signal;
const ok = new Response('csv');
const retried = [];
const warnings = [];
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
warn: (message) => warnings.push(message),
});
assert.equal(response, ok);
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
assert.ok(!warnings[0].includes('SECRET'));
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async () => { throw failure; },
warn() {},
}), (err) => err === failure, 'a failed retry reports its own error');
});
test('FIRMS does not retry anything but an address-selection failure', async () => {
const deps = (fetchImpl) => ({
fetchImpl,
fetchIpv4Impl: async () => assert.fail('must not retry'),
warn: () => assert.fail('must not warn'),
});
const serverError = new Response('down', { status: 503 });
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
for (const thrown of [
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
new TypeError('fetch failed', { cause: new AggregateError([]) }),
new TypeError('fetch failed'),
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
]) {
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
}
const controller = new AbortController();
controller.abort();
const aborted = addressSelectionFailure();
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
});
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
// same port and record which one the retry reaches; the TLS handshake then
// fails against a plain socket, which is fine — only the connection matters.
const reached = [];
const listen = (host, port) => new Promise((resolve, reject) => {
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
server.once('error', reject).listen(port, host, () => resolve(server));
});
const v4 = await listen('127.0.0.1', 0);
t.after(() => v4.close());
const { port } = v4.address();
let v6;
try {
v6 = await listen('::1', port);
} catch {
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
return;
}
t.after(() => v6.close());
const order = await dns.lookup('localhost', { all: true });
if (order[0]?.family !== 6) {
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
return;
}
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
assert.deepEqual(reached, ['127.0.0.1']);
});
// Answer the IPv4 retry with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of crashing the test runner.
function mockIpv4Status(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'text/csv' },
}),
),
);
return request;
});
return crashed;
}
for (const status of [204, 205, 304]) {
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
await assert.rejects(
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
});
// The fixtures above copy Node's error shape; check it against a real fetch so
// a Node or undici change that stops matching fails here instead of silently
// disabling the retry. A closed port on `localhost` refuses both addresses.
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
const probe = net.createServer();
try {
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
} catch {
t.skip('no IPv6 loopback on this host');
return;
}
const { port } = probe.address();
await new Promise((resolve) => probe.close(resolve));
const v4 = net.createServer();
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
await new Promise((resolve) => v4.close(resolve));
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
assert.ok(err, 'a closed port refuses the connection');
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
});
// #68 only stays fixed while both upstream calls go through fetchFirms; the
// refresh harness injects fetchSource, so guard the call sites directly.
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
const calls = [];
const response = new Response('latitude,longitude\n');
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
() => [],
1024,
AbortSignal,
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
);
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
assert.equal(calls.length, 2);
assert.equal(calls[0][0], 'fetchFirms');
assert.ok(calls[0][2] instanceof AbortSignal);
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
});
+24 -7
View File
@@ -837,13 +837,7 @@
"SAVE CAL": "KAL. SPEICHERN",
"RESET CAL": "KAL. ZURÜCKSETZEN",
"SCENE SUMMARY": "SZENEN-ÜBERSICHT",
"SCENES": "SZENEN",
"Scene recipe": "Szenenrezept",
"NEW": "NEU",
"CAPTURE SHOT": "AUFNAHME SPEICHERN",
"UPDATE SHOT": "AUFNAHME AKTUALISIEREN",
"START": "START",
"EXPORT PRESETS": "PRESETS EXPORTIEREN",
"IMPORT": "IMPORTIEREN",
"RUN LOG": "PROTOKOLL",
"POWER UP": "STARTKLAR",
@@ -2938,5 +2932,28 @@
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
"Prohibited": "Sperrgebiet",
"Restricted": "Flugbeschränkungsgebiet",
"Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete"
"Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete",
"scene|PLAY": "START",
"AUTHORING": "ERSTELLUNG",
"Create and edit scenes": "Szenen erstellen und bearbeiten",
"SCENE": "SZENE",
"Scene": "Szene",
"NEW SCENE": "NEUE SZENE",
"Start a new, empty scene": "Neue, leere Szene beginnen",
"DELETE": "LÖSCHEN",
"Delete this scene and all of its shots": "Diese Szene mit allen Aufnahmen löschen",
"SHOTS": "AUFNAHMEN",
"+ CAPTURE SHOT": "+ AUFNAHME SPEICHERN",
"Add the current view, style and layers as the next shot": "Aktuelle Ansicht, Stil und Ebenen als nächste Aufnahme hinzufügen",
"Replace the selected shot with the current view": "Ausgewählte Aufnahme durch die aktuelle Ansicht ersetzen",
"Click a shot to select it, double-click to rename.": "Aufnahme anklicken zum Auswählen, Doppelklick zum Umbenennen.",
"Play from this scene through the rest of the project": "Ab dieser Szene bis zum Ende des Projekts abspielen",
"Stop playback (Esc)": "Wiedergabe stoppen (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Aufnahme nach der ausgewählten abspielen, bei Bedarf in der nächsten Szene",
"SAVE & SHARE": "SPEICHERN & TEILEN",
"Download every scene as a JSON file you can import later": "Alle Szenen als JSON-Datei zum späteren Import herunterladen",
"Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden",
"Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen",
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen"
}
+24 -7
View File
@@ -752,14 +752,8 @@
"The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "El globo ya funciona sin claves. Cada clave de abajo activa otra fuente real — pega una y queda guardada en la configuración local de esta app, y luego el servidor se reinicia solo. Las claves del servidor se quedan en esta máquina; Google Maps y Cesium ion funcionan en el navegador y sus claves deben restringirse en la consola del proveedor. Las claves que configuraste en otro lugar se muestran, pero nunca se modifican.",
"ESC to close": "ESC para cerrar",
"The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps activa el planeta fotorrealista; todo lo demás se superpone.",
"SCENES": "ESCENAS",
"Scene recipe": "Receta de escena",
"NEW": "NUEVA",
"DEL": "BORRAR",
"CAPTURE SHOT": "CAPTURAR PLANO",
"UPDATE SHOT": "ACTUALIZAR PLANO",
"START": "INICIAR",
"EXPORT PRESETS": "EXPORTAR PREAJUSTES",
"IMPORT": "IMPORTAR",
"RUN LOG": "REGISTRO DE EJECUCIÓN",
"Starting live contacts…": "Iniciando contactos en vivo…",
@@ -3008,5 +3002,28 @@
"Restricted": "Restringido",
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas"
"route proxy error": "error del proxy de rutas",
"AUTHORING": "CREACIÓN",
"Create and edit scenes": "Crea y edita escenas",
"SCENE": "ESCENA",
"Scene": "Escena",
"NEW SCENE": "NUEVA ESCENA",
"Start a new, empty scene": "Empieza una escena nueva y vacía",
"DELETE": "ELIMINAR",
"Delete this scene and all of its shots": "Elimina esta escena y todos sus planos",
"SHOTS": "PLANOS",
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
"Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano",
"Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual",
"Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.",
"scene|PLAY": "REPROD.",
"Stop playback (Esc)": "Detén la reproducción (Esc)",
"SAVE & SHARE": "GUARDAR Y COMPARTIR",
"Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde",
"Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado",
"Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción",
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto",
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta",
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos"
}
+24 -7
View File
@@ -2959,13 +2959,7 @@
"ALL": "TOUTES",
"FLAT": "CLASSIQUES",
"SINCE": "DEPUIS",
"SCENES": "SCÈNES",
"Scene recipe": "Recette de scène",
"NEW": "NOUV.",
"CAPTURE SHOT": "CAPTURER UN PLAN",
"UPDATE SHOT": "MAJ DU PLAN",
"START": "LANCER",
"EXPORT PRESETS": "EXPORTER LES PRÉRÉGLAGES",
"IMPORT": "IMPORTER",
"RUN LOG": "JOURNAL",
"POWER UP": "ALLUMER",
@@ -3008,5 +3002,28 @@
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
"Prohibited": "Zone interdite",
"Restricted": "Zone réglementée",
"Prohibited & Restricted Airspace": "Zones interdites et réglementées"
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
"scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes",
"SCENE": "SCÈNE",
"Scene": "Scène",
"NEW SCENE": "NOUVELLE SCÈNE",
"Start a new, empty scene": "Commencer une nouvelle scène vide",
"DELETE": "SUPPRIMER",
"Delete this scene and all of its shots": "Supprimer cette scène et tous ses plans",
"SHOTS": "PLANS",
"+ CAPTURE SHOT": "+ CAPTURER UN PLAN",
"Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant",
"Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle",
"Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.",
"Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet",
"Stop playback (Esc)": "Arrêter la lecture (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin",
"SAVE & SHARE": "ENREGISTRER ET PARTAGER",
"Download every scene as a JSON file you can import later": "Télécharger toutes les scènes dans un fichier JSON à importer plus tard",
"Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté",
"Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture",
"Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans",
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources"
}
+24 -7
View File
@@ -2959,13 +2959,7 @@
"ALL": "TUTTE",
"FLAT": "PIATTE",
"SINCE": "DAL",
"SCENES": "SCENE",
"Scene recipe": "Ricetta della scena",
"NEW": "NUOVA",
"CAPTURE SHOT": "CATTURA SCATTO",
"UPDATE SHOT": "AGGIORNA SCATTO",
"START": "AVVIA",
"EXPORT PRESETS": "ESPORTA PRESET",
"IMPORT": "IMPORTA",
"RUN LOG": "LOG ESECUZIONE",
"POWER UP": "ACCENDI",
@@ -3008,5 +3002,28 @@
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
"Prohibited": "Zona proibita",
"Restricted": "Zona regolamentata",
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate"
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
"scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene",
"SCENE": "SCENA",
"Scene": "Scena",
"NEW SCENE": "NUOVA SCENA",
"Start a new, empty scene": "Inizia una nuova scena vuota",
"DELETE": "ELIMINA",
"Delete this scene and all of its shots": "Elimina questa scena e tutti i suoi scatti",
"SHOTS": "SCATTI",
"+ CAPTURE SHOT": "+ CATTURA SCATTO",
"Add the current view, style and layers as the next shot": "Aggiungi la vista, lo stile e i livelli attuali come scatto successivo",
"Replace the selected shot with the current view": "Sostituisci lo scatto selezionato con la vista attuale",
"Click a shot to select it, double-click to rename.": "Fai clic su uno scatto per selezionarlo, doppio clic per rinominarlo.",
"Play from this scene through the rest of the project": "Riproduci da questa scena fino alla fine del progetto",
"Stop playback (Esc)": "Interrompi la riproduzione (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Riproduci lo scatto dopo quello selezionato, passando alla scena successiva se serve",
"SAVE & SHARE": "SALVA E CONDIVIDI",
"Download every scene as a JSON file you can import later": "Scarica tutte le scene in un file JSON da importare in seguito",
"Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato",
"Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione",
"Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti",
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse"
}
+24 -7
View File
@@ -2817,13 +2817,7 @@
"ALL": "すべて",
"FLAT": "平面",
"SINCE": "期間",
"SCENES": "シーン",
"Scene recipe": "シーンレシピ",
"NEW": "新規",
"CAPTURE SHOT": "ショット取得",
"UPDATE SHOT": "ショット更新",
"START": "開始",
"EXPORT PRESETS": "エクスポート",
"IMPORT": "インポート",
"RUN LOG": "実行ログ",
"POWER UP": "パワーアップ",
@@ -2868,5 +2862,28 @@
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
"Prohibited": "飛行禁止区域",
"Restricted": "制限区域",
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域"
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
"scene|PLAY": "再生",
"AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集",
"SCENE": "シーン",
"Scene": "シーン",
"NEW SCENE": "新規シーン",
"Start a new, empty scene": "空の新しいシーンを作成",
"DELETE": "削除",
"Delete this scene and all of its shots": "このシーンとすべてのショットを削除",
"SHOTS": "ショット",
"+ CAPTURE SHOT": "+ ショット取得",
"Add the current view, style and layers as the next shot": "現在の視点・スタイル・レイヤーを次のショットとして追加",
"Replace the selected shot with the current view": "選択中のショットを現在の視点で置き換え",
"Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。",
"Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生",
"Stop playback (Esc)": "再生を停止(Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)",
"SAVE & SHARE": "保存と共有",
"Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード",
"Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む",
"Download the timing log of the last play": "前回の再生のタイミングログをダウンロード",
"Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集",
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード"
}
+24 -7
View File
@@ -2961,13 +2961,7 @@
"ALL": "TODAS",
"FLAT": "PLANAS",
"SINCE": "DESDE",
"SCENES": "CENAS",
"Scene recipe": "Receita de cena",
"NEW": "NOVA",
"CAPTURE SHOT": "CAPTURAR PLANO",
"UPDATE SHOT": "ATUALIZAR PLANO",
"START": "INICIAR",
"EXPORT PRESETS": "EXPORTAR PREDEF.",
"IMPORT": "IMPORTAR",
"RUN LOG": "REGISTRO",
"POWER UP": "ATIVAR",
@@ -3008,5 +3002,28 @@
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
"Prohibited": "Área proibida",
"Restricted": "Área restrita",
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas"
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
"scene|PLAY": "INICIAR",
"AUTHORING": "CRIAÇÃO",
"Create and edit scenes": "Criar e editar cenas",
"SCENE": "CENA",
"Scene": "Cena",
"NEW SCENE": "NOVA CENA",
"Start a new, empty scene": "Começar uma nova cena vazia",
"DELETE": "EXCLUIR",
"Delete this scene and all of its shots": "Excluir esta cena e todos os seus planos",
"SHOTS": "PLANOS",
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
"Add the current view, style and layers as the next shot": "Adicionar a vista, o estilo e as camadas atuais como próximo plano",
"Replace the selected shot with the current view": "Substituir o plano selecionado pela vista atual",
"Click a shot to select it, double-click to rename.": "Clique em um plano para selecioná-lo e clique duas vezes para renomeá-lo.",
"Play from this scene through the rest of the project": "Reproduzir desta cena até o fim do projeto",
"Stop playback (Esc)": "Parar a reprodução (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduzir o plano após o selecionado, passando à próxima cena se necessário",
"SAVE & SHARE": "SALVAR E COMPARTILHAR",
"Download every scene as a JSON file you can import later": "Baixar todas as cenas em um arquivo JSON para importar depois",
"Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado",
"Download the timing log of the last play": "Baixar o registro de tempos da última reprodução",
"Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos",
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos"
}
+24 -7
View File
@@ -795,21 +795,15 @@
"East offset from catalog position (m) — click to type": "Смещение на восток от позиции каталога (м) — нажмите для ввода",
"SAVE CAL": "СОХРАНИТЬ КАЛИБР.",
"RESET CAL": "СБРОСИТЬ КАЛИБР.",
"NEW": "НОВАЯ",
"DEL": "УДАЛ.",
"CAPTURE SHOT": "СДЕЛАТЬ КАДР",
"UPDATE SHOT": "ОБНОВИТЬ КАДР",
"START": "СТАРТ",
"EXPORT PRESETS": "ЭКСПОРТ ПРЕСЕТОВ",
"IMPORT": "ИМПОРТ",
"RUN LOG": "ЖУРНАЛ ЗАПУСКОВ",
"CALIBRATION": "КАЛИБРОВКА",
"Camera pose — click a value to type": "Поза камеры — щёлкните по значению, чтобы ввести",
"SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ",
"CCTV camera": "Камера CCTV",
"Scene recipe": "Рецепт сцены",
"DATA LAYERS": "СЛОИ ДАННЫХ",
"SCENES": "СЦЕНЫ",
"Close key setup": "Закрыть настройку ключей",
"SAVE KEYS": "СОХРАНИТЬ КЛЮЧИ",
"Power up the globe": "Подключите потоки данных",
@@ -3078,5 +3072,28 @@
"Restricted": "Зона ограничения",
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов"
"route proxy error": "ошибка прокси маршрутов",
"AUTHORING": "РЕДАКТОР",
"Create and edit scenes": "Создание и редактирование сцен",
"SCENE": "СЦЕНА",
"Scene": "Сцена",
"NEW SCENE": "НОВАЯ СЦЕНА",
"Start a new, empty scene": "Создать новую пустую сцену",
"DELETE": "УДАЛИТЬ",
"Delete this scene and all of its shots": "Удалить эту сцену и все её кадры",
"SHOTS": "КАДРЫ",
"+ CAPTURE SHOT": "+ СДЕЛАТЬ КАДР",
"Add the current view, style and layers as the next shot": "Добавить текущий вид, стиль и слои следующим кадром",
"Replace the selected shot with the current view": "Заменить выбранный кадр текущим видом",
"Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.",
"scene|PLAY": "ПУСК",
"Stop playback (Esc)": "Остановить воспроизведение (Esc)",
"SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ",
"Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта",
"Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON",
"Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения",
"Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту",
"Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене",
"Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров",
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов"
}
+24 -7
View File
@@ -3029,13 +3029,7 @@
"ALL": "УСІ",
"FLAT": "ПЛОСКІ",
"SINCE": "З ДАТИ",
"SCENES": "СЦЕНИ",
"Scene recipe": "Рецепт сцени",
"NEW": "НОВА",
"CAPTURE SHOT": "ЗНЯТИ КАДР",
"UPDATE SHOT": "ОНОВИТИ КАДР",
"START": "СТАРТ",
"EXPORT PRESETS": "ЕКСПОРТУВАТИ ПРЕСЕТИ",
"IMPORT": "ІМПОРТУВАТИ",
"RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ",
"POWER UP": "ЗАПУСК",
@@ -3078,5 +3072,28 @@
"Restricted": "Зона обмеження",
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів"
"Cyclone request timed out": "Минув час очікування запиту циклонів",
"scene|PLAY": "СТАРТ",
"AUTHORING": "РЕДАКТОР",
"Create and edit scenes": "Створення й редагування сцен",
"SCENE": "СЦЕНА",
"Scene": "Сцена",
"NEW SCENE": "НОВА СЦЕНА",
"Start a new, empty scene": "Створити нову порожню сцену",
"DELETE": "ВИДАЛИТИ",
"Delete this scene and all of its shots": "Видалити цю сцену з усіма кадрами",
"SHOTS": "КАДРИ",
"+ CAPTURE SHOT": "+ ЗНЯТИ КАДР",
"Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр",
"Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом",
"Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.",
"Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту",
"Stop playback (Esc)": "Зупинити відтворення (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени",
"SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ",
"Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту",
"Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON",
"Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення",
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів"
}
+24 -7
View File
@@ -2712,13 +2712,7 @@
"ALL": "全部",
"FLAT": "平面",
"SINCE": "起始时间",
"SCENES": "场景",
"Scene recipe": "场景方案",
"NEW": "新建",
"CAPTURE SHOT": "保存镜头",
"UPDATE SHOT": "更新镜头",
"START": "启动",
"EXPORT PRESETS": "导出预设",
"IMPORT": "导入",
"RUN LOG": "运行日志",
"POWER UP": "增强",
@@ -2868,5 +2862,28 @@
"Restricted": "限制区",
"Prohibited & Restricted Airspace": "禁区与限制区空域",
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
"route proxy error": "路线代理出错"
"route proxy error": "路线代理出错",
"AUTHORING": "创作",
"Create and edit scenes": "创建和编辑场景",
"SCENE": "场景",
"Scene": "场景",
"NEW SCENE": "新建场景",
"Start a new, empty scene": "新建一个空场景",
"DELETE": "删除",
"Delete this scene and all of its shots": "删除此场景及其所有镜头",
"SHOTS": "镜头",
"+ CAPTURE SHOT": "+ 保存镜头",
"Add the current view, style and layers as the next shot": "将当前视角、样式和图层添加为下一个镜头",
"Replace the selected shot with the current view": "用当前视角替换所选镜头",
"Click a shot to select it, double-click to rename.": "单击镜头即可选中,双击可重命名。",
"scene|PLAY": "播放",
"Stop playback (Esc)": "停止播放(Esc)",
"SAVE & SHARE": "保存与分享",
"Download every scene as a JSON file you can import later": "将所有场景下载为 JSON 文件,以便日后导入",
"Load scenes from an exported JSON file": "从导出的 JSON 文件加载场景",
"Download the timing log of the last play": "下载上次播放的计时日志",
"Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景",
"Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情",
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包"
}
+24 -7
View File
@@ -2700,13 +2700,7 @@
"ALL": "全部",
"FLAT": "平面",
"SINCE": "起始時間",
"SCENES": "場景",
"Scene recipe": "場景方案",
"NEW": "新建",
"CAPTURE SHOT": "保存鏡頭",
"UPDATE SHOT": "更新鏡頭",
"START": "啟動",
"EXPORT PRESETS": "導出預設",
"IMPORT": "導入",
"RUN LOG": "運行日誌",
"POWER UP": "增強",
@@ -2868,5 +2862,28 @@
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
"Prohibited": "禁航區",
"Restricted": "限航區",
"Prohibited & Restricted Airspace": "禁航區與限航區空域"
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
"AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景",
"SCENE": "場景",
"Scene": "場景",
"NEW SCENE": "新建場景",
"Start a new, empty scene": "新建一個空場景",
"DELETE": "刪除",
"Delete this scene and all of its shots": "刪除此場景及其所有鏡頭",
"SHOTS": "鏡頭",
"+ CAPTURE SHOT": "+ 保存鏡頭",
"Add the current view, style and layers as the next shot": "將當前視角、樣式和圖層添加為下一個鏡頭",
"Replace the selected shot with the current view": "用當前視角替換所選鏡頭",
"Click a shot to select it, double-click to rename.": "單擊鏡頭即可選中,雙擊可重命名。",
"scene|PLAY": "播放",
"Stop playback (Esc)": "停止播放(Esc)",
"SAVE & SHARE": "保存與分享",
"Download every scene as a JSON file you can import later": "將所有場景下載為 JSON 文件,以便日後導入",
"Load scenes from an exported JSON file": "從導出的 JSON 文件加載場景",
"Download the timing log of the last play": "下載上次播放的計時日誌",
"Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景",
"Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情",
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包"
}
+250
View File
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
const css = readStylesheet(new URL('../../style.css', import.meta.url));
const clean = (selector) =>
selector
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, ' ')
.trim();
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
([, selector, body]) => ({
selector: clean(selector),
body: body.replace(/\/\*[\s\S]*?\*\//g, ''),
}),
);
const railRules = rules.filter(({ selector }) =>
selector.includes('#left-panel-stack, #right-context-rail'),
);
const hoverBlock =
css.match(
/@media \(hover: hover\) and \(pointer: fine\) \{((?:[^{}]*\{[^{}]*\})*)\s*\}/g,
) || [];
test('a side panel lights up on hover only where a pointer can hover', () => {
const lit = railRules.filter(({ selector }) => selector.includes(':hover'));
assert.ok(lit.length > 0, 'the rail hover rule is missing');
for (const { selector } of lit) {
assert.ok(
hoverBlock.some((block) => clean(block).includes(selector)),
`${selector} must sit inside @media (hover: hover) and (pointer: fine)`,
);
}
});
test('hover matches the dock tray hover, not its pinned look', () => {
const [hover] = railRules.filter(({ selector }) =>
selector.endsWith(':hover'),
);
assert.ok(hover, 'the rail surface hover rule is missing');
assert.match(hover.body, /border-color:\s*var\(--glass-border-hover\)/);
assert.doesNotMatch(hover.body, /box-shadow/);
assert.match(hover.body, /--rail-label-hover:\s*var\(--accent\)/);
// A low-specificity list: no ID or state selector inside it lifts the rule.
assert.match(hover.selector, /:where\(/);
});
test('hover recolours only the hovered panel header, never nested sections', () => {
for (const { selector } of railRules) {
assert.ok(
!/\.radio-panel-inner/.test(selector),
`Radio sits inside Context and must not light up as its own panel: ${selector}`,
);
assert.doesNotMatch(
selector,
/:hover\s+:is\(/,
'a descendant colour list would reach labels in the panel body',
);
}
const colour = railRules.find(
({ body }) => /--rail-label-hover/.test(body) && /color:/.test(body),
);
assert.ok(colour, 'the rail title colour rule is missing');
assert.match(colour.selector, /\.panel-header > \.panel-title/);
const radio = rules.find(({ selector }) => selector === '#radio-panel');
assert.match(radio?.body || '', /--rail-label-hover:\s*initial/);
});
test('no later rule dims the open Context title below its siblings', () => {
const dimmed = rules.filter(
({ selector, body }) =>
/#global-context-panel:not\(\.collapsed\) \.panel-title$/.test(
selector,
) && /color:/.test(body),
);
assert.deepEqual(dimmed, []);
});
test('collapsed launchers on both rails keep titles inside the card', () => {
const ellipsis = railRules.find(
({ selector, body }) =>
selector.endsWith(
':is(.panel-header > .panel-title, .pp-header-label)',
) && /text-overflow:\s*ellipsis/.test(body),
);
assert.ok(
ellipsis,
'rail titles must end in an ellipsis rather than overflow',
);
assert.match(ellipsis.body, /min-width:\s*0/);
const translated = rules.find(
({ selector }) =>
selector.startsWith("html:not([lang='en'])") &&
selector.includes('#left-panel-stack, #right-context-rail') &&
selector.includes('.panel-collapsible.collapsed'),
);
assert.ok(translated, 'translated collapsed titles must fit on both rails');
assert.match(translated.body, /font-size:\s*8px/);
// Chinese and Japanese titles fit at full size; 8px would put them under
// the 9px CJK floor in cjk.css.
assert.match(translated.selector, /:not\(:lang\(zh\)\):not\(:lang\(ja\)\)/);
// Only the rail's own launchers: Radio, nested in Context, keeps its type.
assert.match(
translated.selector,
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header > \.panel-title$/,
);
});
test('a collapsed launcher drops its divider so English titles fit whole', () => {
const launcher = (tail) =>
railRules.filter(({ selector }) =>
selector
.split(/,(?![^(]*\))/)
.some((part) =>
part
.trim()
.endsWith(
`.collapsed > [class$='-panel-inner'] > .panel-header${tail}`,
),
),
);
const [header] = launcher('');
const [divider] = launcher(' > .panel-divider');
const [toggle] = launcher(' > .panel-collapse-btn');
assert.match(header?.body || '', /gap:\s*8px/, 'the launcher header gap');
assert.match(divider?.body || '', /display:\s*none/, 'the launcher divider');
assert.match(
toggle?.body || '',
/margin-left:\s*auto/,
'the launcher toggle',
);
const button = rules.find(
({ selector }) => selector === '.panel-collapse-btn',
);
assert.match(
button?.body || '',
/flex-shrink:\s*0/,
'a long title must not squeeze the toggle',
);
});
test('the default theme geometry change leaves cyber gutters alone', () => {
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
const base = rules.find(
({ selector, body }) => selector === surface && /padding:/.test(body),
);
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
}
const display = rules.find(
({ selector }) =>
selector === '#pp-toggles > .pp-header-row .pp-collapse-btn',
);
assert.match(display?.body || '', /font-size:\s*12px/);
});
test('rail titles and dock labels share one set of label tokens', () => {
// command-dock-trays.css sets the dock's label type; earlier dock rules
// for the same selector are overridden by it.
const dock = rules.find(
({ selector, body }) =>
selector ===
'#command-dock .panel-title, #command-dock .location-toolbar-label' &&
/--dock-label-/.test(body),
);
const rail = railRules.find(({ body }) =>
/--dock-label-font-size/.test(body),
);
assert.match(dock?.body || '', /font-size:\s*var\(--dock-label-font-size\)/);
assert.match(
dock?.body || '',
/letter-spacing:\s*var\(--dock-label-letter-spacing\)/,
);
// The dock's rem type, capped at its 16px-root size: a collapsed launcher
// is a fixed 176px, so a larger browser font must not cut titles short.
assert.match(
rail?.body || '',
/font-size:\s*min\(var\(--dock-label-font-size\),\s*9\.28px\)/,
);
assert.match(
rail?.body || '',
/letter-spacing:\s*min\(var\(--dock-label-letter-spacing\),\s*2\.08px\)/,
);
});
test('default theme overrides never reach the cyber theme', () => {
const notCyber = ":root:not([data-ui-theme='cyber'])";
const padding = rules.find(
({ selector, body }) =>
selector.endsWith(
':is(.data-panel-inner, .global-context-panel-inner)',
) && /padding-left:\s*14px/.test(body),
);
assert.ok(
padding?.selector.startsWith(notCyber),
'the 14px gutters must skip cyber',
);
const display = rules.find(
({ selector, body }) =>
selector.endsWith('#pp-toggles > .pp-header-row .pp-collapse-btn') &&
/font-size:\s*13px/.test(body),
);
assert.ok(
display?.selector.startsWith(notCyber),
"Display's 22px toggle must skip cyber",
);
// Cyber draws its title marker at left: -8px; a clip on the title would cut it.
for (const { selector, body } of rules) {
if (!/\.panel-title/.test(selector) || !/overflow:\s*hidden/.test(body))
continue;
if (!/#left-panel-stack|#right-context-rail/.test(selector)) continue;
assert.ok(
selector.startsWith(notCyber),
`${selector} clips the cyber title marker`,
);
}
});
test('launcher compaction and header colour stay on the rail panels themselves', () => {
// The divider, gap and toggle rules; the translated type rule is checked
// above.
const compact = railRules.filter(
({ selector }) =>
/\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/.test(
selector,
) && !selector.startsWith('html:'),
);
assert.equal(compact.length, 3);
for (const { selector } of compact) {
// Each launcher is a direct child of its rail: Radio, nested inside
// Context, is not a launcher. The header must not itself be required to
// be a rail child.
assert.match(
selector,
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/,
);
assert.match(
selector,
/#right-context-rail\) > #pp-toggles\.collapsed > \.pp-header-row/,
);
}
const colour = railRules.find(({ body }) =>
/--rail-label-hover, /.test(body),
);
assert.match(
colour.selector,
/:is\(\.panel-header, \.pp-header-row\) > \.panel-collapse-btn/,
);
assert.doesNotMatch(colour.selector, /, \.panel-collapse-btn\)/);
});
+38 -8
View File
@@ -21,6 +21,9 @@ export class SceneControls {
this.rowRemovers = [];
this.playbackKeyRemover = null;
if (!elements.select) return;
// The radio's PLAY means "listen"; this one plays the scene.
const playLabel = t('PLAY', { context: 'scene' });
if (elements.start) elements.start.textContent = playLabel;
if (subscribe)
this.unsubscribe = subscribe((notification) =>
this.present(notification),
@@ -94,7 +97,21 @@ export class SceneControls {
this.renderShotList();
if (type === 'selection-changed')
presentSceneSelection(this.elements.shots, state.selectedShotId);
if (initial || type === 'buttons-changed') this.setButtons(state.running);
if (
initial ||
[
'buttons-changed',
'selection-changed',
'scene-options-changed',
'scene-created',
'scene-deleted',
'shots-changed',
'shot-captured',
'shot-deleted',
'project-imported',
].includes(type)
)
this.setButtons(state.running);
if (initial || type === 'progress-changed')
this.setProgress(state.progress);
if (initial || type === 'status-changed') this.updateStatus(state.status);
@@ -183,11 +200,9 @@ export class SceneControls {
this.listen(element, type, callback, this.rowRemovers),
select: (id) => {
this.run('selectShot', id);
if (!this.destroyed)
presentSceneSelection(
this.elements.shots,
this.read().selectedShotId,
);
if (this.destroyed) return;
presentSceneSelection(this.elements.shots, this.read().selectedShotId);
this.setButtons(this.read().running);
},
rename: (sceneId, shotId, title) =>
this.run('renameShot', sceneId, shotId, title),
@@ -197,8 +212,22 @@ export class SceneControls {
}
setButtons(running) {
if (!this.destroyed)
presentSceneButtons(this.elements, running, this.read().hasRun);
if (this.destroyed) return;
const state = this.read();
const scene = state.scenes?.find(
(item) => item.id === state.selectedSceneId,
);
const shots = scene?.shots ?? [];
presentSceneButtons(this.elements, running, state.hasRun, {
hasScene: !!scene,
// PLAY and NEXT run on into the following scenes.
playableShots: (state.scenes ?? []).reduce(
(total, item) => total + (item.shots?.length ?? 0),
0,
),
hasSelectedShot: shots.some((shot) => shot.id === state.selectedShotId),
});
this.elements.panel?.classList.toggle('has-shots', shots.length > 0);
}
setProgress(progress) {
@@ -247,6 +276,7 @@ export class SceneControls {
])
remove();
if (this.elements.shots) this.elements.shots.textContent = '';
this.elements.panel?.classList.toggle('has-shots', false);
presentSceneRuntime(this.elements.runtime, '');
this.setPlaybackActive(false);
}
+181
View File
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
import { createStateChannel } from '../app/stateChannel.js';
import { SceneControls } from './sceneControls.js';
import { SceneDirector } from '../scenes/director.js';
import { mountSceneSharing } from './sceneSharing.js';
import { setLocale } from '../i18n/index.js';
function fixture() {
class Element extends EventTarget {
@@ -169,6 +171,46 @@ test('Scene controls render the supplied selection and running/download states',
}
});
test('Authoring disables idle actions that would do nothing', () => {
const f = fixture();
try {
assert.equal(f.elements.start.textContent, 'PLAY');
assert.equal(f.elements.start.disabled, false);
assert.equal(f.elements.update.disabled, false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
f.state.selectedShotId = null;
f.owner.setButtons(false);
assert.equal(f.elements.update.disabled, true);
assert.equal(f.elements.start.disabled, false);
// PLAY and NEXT run on into the following scenes, so an empty selected
// scene beside one with shots keeps them available.
f.state.scenes[0].shots = [];
f.state.scenes.push({ id: 'scene-b', title: 'B', shots: [{ id: 'b1' }] });
f.owner.setButtons(false);
assert.equal(f.elements.start.disabled, false);
assert.equal(f.elements.next.disabled, false);
assert.equal(f.elements.update.disabled, true);
f.state.scenes.pop();
f.owner.setButtons(false);
assert.equal(f.elements.start.disabled, true);
assert.equal(f.elements.next.disabled, true);
assert.equal(f.elements.capture.disabled, false);
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
f.state.scenes = [];
f.state.selectedSceneId = null;
f.owner.setButtons(false);
assert.equal(f.elements.delete.disabled, true);
assert.equal(f.elements.capture.disabled, true);
assert.equal(f.elements.new.disabled, false);
} finally {
f.restore();
}
});
test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => {
const f = fixture();
try {
@@ -435,3 +477,142 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
f.restore();
}
});
test('Authoring refreshes idle buttons from published changes', () => {
const f = fixture();
try {
f.owner.destroy();
const channel = createStateChannel(() => f.state);
const owner = new SceneControls({
read: () => f.state,
actions: f.actions,
elements: f.elements,
subscribe: (listener) => channel.subscribe(listener),
});
const disabled = (name) => f.elements[name].disabled;
assert.equal(disabled('start'), false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
f.state.selectedShotId = null;
channel.publish({ type: 'selection-changed' });
assert.equal(disabled('update'), true);
f.state.scenes[0].shots = [];
channel.publish({ type: 'shot-deleted' });
assert.equal(disabled('start'), true);
assert.equal(disabled('next'), true);
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
// A run that ends hands the buttons back with the idle rules applied.
f.state.running = true;
channel.publish({ type: 'buttons-changed' });
assert.equal(disabled('capture'), true);
assert.equal(disabled('stop'), false);
f.state.running = false;
channel.publish({ type: 'buttons-changed' });
assert.equal(disabled('capture'), false);
assert.equal(disabled('start'), true);
f.state.scenes = [];
f.state.selectedSceneId = null;
channel.publish({ type: 'scene-deleted' });
assert.equal(disabled('delete'), true);
assert.equal(disabled('capture'), true);
assert.equal(disabled('new'), false);
f.state.scenes = [{ id: 'scene-b', title: 'B', shots: [{ id: 's' }] }];
f.state.selectedSceneId = 'scene-b';
f.state.selectedShotId = 's';
channel.publish({ type: 'project-imported' });
assert.equal(disabled('start'), false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
owner.destroy();
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
channel.destroy();
} finally {
f.restore();
}
});
test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => {
context.after(() => setLocale('en'));
setLocale('es');
const f = fixture();
try {
assert.equal(f.elements.start.textContent, 'REPROD.');
} finally {
f.restore();
}
});
test('The sharing row sits under SAVE & SHARE, after the file row', () => {
const saved = globalThis.document;
const node = (className = '') => {
const element = {
className,
children: [],
dataset: {},
parent: null,
addEventListener() {},
removeEventListener() {},
append(...items) {
for (const item of items) {
item.parent = element;
element.children.push(item);
}
},
appendChild(item) {
element.append(item);
},
after(item) {
const siblings = element.parent.children;
item.parent = element.parent;
siblings.splice(siblings.indexOf(element) + 1, 0, item);
},
remove() {
const siblings = element.parent?.children;
if (siblings) siblings.splice(siblings.indexOf(element), 1);
},
querySelector(selector) {
const name = selector.slice(1);
const walk = (at) => {
for (const child of at.children) {
if (child.className.split(' ').includes(name)) return child;
const found = walk(child);
if (found) return found;
}
return null;
};
return walk(element);
},
};
return element;
};
globalThis.document = { createElement: () => node() };
try {
const panel = node();
const inner = node('scene-panel-inner');
const fileRow = node('scene-controls scene-file-controls');
const status = node('scene-status');
panel.append(inner);
inner.append(fileRow, status);
const dispose = mountSceneSharing({ edit() {}, share() {} }, panel);
const bar = inner.children[1];
assert.equal(bar.className, 'scene-controls');
assert.equal(inner.children[2], status);
assert.ok(bar.children.every((button) => button.title));
dispose();
assert.deepEqual(inner.children, [fileRow, status]);
// Without the file row, the bar still lands inside the panel.
const plain = node();
const plainInner = node('scene-panel-inner');
plain.append(plainInner);
mountSceneSharing({ edit() {}, share() {} }, plain)();
mountSceneSharing({ edit() {}, share() {} }, plain);
assert.equal(plainInner.children.length, 1);
} finally {
globalThis.document = saved;
}
});
+21 -2
View File
@@ -171,7 +171,26 @@ export function presentSceneSelection(element, selectedShotId) {
}
}
export function presentSceneButtons(elements, running, hasRun) {
/**
* While a scene plays, the authoring and file actions here are off and STOP
* is on. Otherwise a button is off when it would do nothing: DELETE and
* CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot, PLAY and
* NEXT when no scene in the project has a shot (both continue into the
* following scenes).
*/
export function presentSceneButtons(
elements,
running,
hasRun,
{ hasScene = true, playableShots = 1, hasSelectedShot = true } = {},
) {
const idleOff = {
delete: !hasScene,
capture: !hasScene,
update: !hasSelectedShot,
start: playableShots === 0,
next: playableShots === 0,
};
for (const name of [
'start',
'next',
@@ -183,7 +202,7 @@ export function presentSceneButtons(elements, running, hasRun) {
'export',
'import',
]) {
if (elements[name]) elements[name].disabled = running;
if (elements[name]) elements[name].disabled = running || !!idleOff[name];
}
if (elements.stop) elements.stop.disabled = !running;
if (elements.download) elements.download.disabled = !hasRun;
+16 -5
View File
@@ -181,17 +181,28 @@ export function mountSceneSharing(
bar.dataset.directorAuthoring = '';
bar.className = 'scene-controls';
const entries = [
[t('EDIT DETAILS'), edit],
[t('SHARE SCENE'), share],
[t('EDIT DETAILS'), t('Edit anchors, data packs and shot details'), edit],
[
t('SHARE SCENE'),
t('Download this scene as JSON or an asset bundle'),
share,
],
];
const removers = [];
for (const [text, fn] of entries) {
const b = createActionButton({ label: text, className: 'scene-btn' });
for (const [text, title, fn] of entries) {
const b = createActionButton({
label: text,
title,
className: 'scene-btn',
});
b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn));
bar.append(b);
}
(panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
// Beside EXPORT / IMPORT, under SAVE & SHARE.
const fileRow = panel.querySelector?.('.scene-file-controls');
if (fileRow) fileRow.after(bar);
else (panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
return () => {
for (const remove of removers) remove();
bar.remove();
+4 -4
View File
@@ -17,10 +17,10 @@
display: inline-flex;
align-items: center;
gap: 0.42rem;
color: rgba(206, 232, 239, 0.72);
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.13rem;
color: var(--dock-label-color);
font-size: var(--dock-label-font-size);
font-weight: var(--dock-label-font-weight);
letter-spacing: var(--dock-label-letter-spacing);
}
#command-dock .dock-label-icon {
+157 -15
View File
@@ -81,16 +81,104 @@
white-space: nowrap;
}
/* A longer translated title in a collapsed rail launcher tightens its
tracking rather than pushing the disclosure button out of the card. */
html:not([lang='en'])
#right-context-rail
.panel-collapsible.collapsed
.panel-title {
/* Side panel titles read like the command dock's labels: one type for every
panel, collapsed or open, and bright enough to read over the globe. The
dock's type is in rem, but a collapsed launcher is a fixed 176px, so the
rail caps it at its 16px-root size: a larger browser font must not cut
Recent Imagery short. */
:is(#left-panel-stack, #right-context-rail)
:is(.panel-header > .panel-title, .pp-header-label) {
font-family: var(--font-mono);
font-size: min(var(--dock-label-font-size), 9.28px);
font-weight: var(--dock-label-font-weight);
letter-spacing: min(var(--dock-label-letter-spacing), 2.08px);
text-transform: uppercase;
}
/* A hovered panel sets --rail-label-hover on itself (see below); its own
header picks it up. Radio sits inside Context and keeps its own colour. */
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:is(
.panel-header > .panel-title,
.pp-header-label,
:is(.panel-header, .pp-header-row) > .panel-collapse-btn
) {
color: var(--rail-label-hover, var(--dock-label-color));
}
#radio-panel {
--rail-label-hover: initial;
}
/* A title never pushes its toggle out of the card: one that does not fit
ends in an ellipsis. Cyber draws a marker left of the title, which a clip
would cut, and keeps its own header layout. */
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:is(.panel-header > .panel-title, .pp-header-label) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 1.5px;
}
/* A collapsed launcher is 176px wide and its divider has no room to draw,
so the header drops it and packs closer. English titles such as Recent
Imagery then fit whole. Only the rail's own launchers: Radio, nested in
Context, keeps its divider. */
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header,
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> #pp-toggles.collapsed
> .pp-header-row {
gap: 8px;
}
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header
> .panel-divider,
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> #pp-toggles.collapsed
> .pp-header-row
> .panel-divider {
display: none;
}
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header
> .panel-collapse-btn,
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> #pp-toggles.collapsed
> .pp-header-row
> .panel-collapse-btn {
margin-left: auto;
}
/* Translated titles run longer (Aktuelle Bilddaten), so a collapsed rail
launcher sets them in compact type rather than pushing the disclosure
button out of the card or cutting the word short. Chinese and Japanese
titles fit at full size and stay at or above their 9px floor (cjk.css);
Radio, nested in Context, is not a launcher. */
html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header
> .panel-title {
font-size: 8px;
letter-spacing: 1px;
}
.panel-divider {
@@ -109,6 +197,8 @@ html:not([lang='en'])
font-family: var(--font-mono);
font-size: 13px;
line-height: 1;
/* A long title or a header action must not squeeze the toggle. */
flex-shrink: 0;
cursor: pointer;
transition:
background-color var(--transition-fast),
@@ -116,17 +206,50 @@ html:not([lang='en'])
color var(--transition-fast);
}
/* A long translated title must not squeeze the toggle into a sliver. */
html:not([lang='en']) .panel-collapse-btn {
flex-shrink: 0;
}
.panel-collapse-btn:hover {
border-color: var(--glass-border-hover);
color: var(--text-primary);
background: rgba(255, 255, 255, 0.07);
}
/* A side panel answers the pointer as the command dock's trays do: a
brighter edge and an accent title and toggle. Touch screens have no hover,
so a tap must not leave a panel lit. */
@media (hover: hover) and (pointer: fine) {
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:where(
.data-panel-inner,
.scene-panel-inner,
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner,
#pp-toggles:not(.collapsed),
#pp-toggles.collapsed > .pp-header-row
) {
transition: border-color var(--transition-fast);
}
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:where(
.data-panel-inner,
.scene-panel-inner,
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner,
#pp-toggles:not(.collapsed),
#pp-toggles.collapsed > .pp-header-row
):hover {
--rail-label-hover: var(--accent);
border-color: var(--glass-border-hover);
}
}
/* ── Button Grid ──────────────────────────────── */
.button-grid {
display: flex;
@@ -431,12 +554,21 @@ html:not([lang='en']) .panel-collapse-btn {
}
#pp-toggles .pp-header-label {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.4px;
color: var(--text-dim);
}
/* Display's title and toggle sit where the other panels' do: 14px of panel
padding plus a 4px header inset. The cyber theme sets its own insets. */
:root:not([data-ui-theme='cyber']) #pp-toggles.collapsed > .pp-header-row {
padding: 8px 18px;
}
:root:not([data-ui-theme='cyber'])
#pp-toggles:not(.collapsed)
> .pp-header-row {
padding: 0 10px;
}
#pp-toggles > .pp-header-row .pp-collapse-btn {
width: 20px;
height: 20px;
@@ -445,6 +577,16 @@ html:not([lang='en']) .panel-collapse-btn {
padding: 0;
}
/* In the default theme Display's toggle matches the other panels' 22px. */
:root:not([data-ui-theme='cyber'])
#pp-toggles
> .pp-header-row
.pp-collapse-btn {
width: 22px;
height: 22px;
font-size: 13px;
}
#pp-toggles.collapsed {
gap: 0;
}
+5
View File
@@ -21,6 +21,11 @@
--panel-radius: 16px;
--btn-radius: 10px;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
/* Label type shared by the command dock and the side panel titles. */
--dock-label-color: rgba(206, 232, 239, 0.72);
--dock-label-font-size: 0.58rem;
--dock-label-font-weight: 500;
--dock-label-letter-spacing: 0.13rem;
--transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--left-stack-x: 52px;
--right-rail-x: 52px;
+10 -8
View File
@@ -249,7 +249,7 @@
box-sizing: border-box;
justify-content: center;
overflow: visible;
padding: 8px 10px 8px 14px;
padding: 8px 14px;
}
#right-context-rail #global-context-panel.collapsed .panel-header {
margin: 0;
@@ -989,13 +989,6 @@
#global-context-panel .panel-glow {
display: none;
}
/* Expanded, its section titles (Context, Radio) stay legible over bright
imagery. Cyber keeps its own red titles; collapsed launchers match siblings. */
:root:not([data-ui-theme='cyber'])
#global-context-panel:not(.collapsed)
.panel-title {
color: var(--text-secondary);
}
.global-context-panel-inner {
position: relative;
display: flex;
@@ -1011,6 +1004,15 @@
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
}
/* Data Layers and Context share the 14px side padding of the other default
theme panels, so every title and toggle sits 19px from its edge. Cyber
keeps its own gutters. */
:root:not([data-ui-theme='cyber'])
:is(.data-panel-inner, .global-context-panel-inner) {
padding-right: 14px;
padding-left: 14px;
}
.global-context-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
-4
View File
@@ -56,10 +56,6 @@
#recent-imagery-split-line[hidden] {
display: none !important;
}
#recent-imagery-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#recent-imagery-panel.collapsed .panel-header {
margin-bottom: 2px;
}
+122 -32
View File
@@ -1,6 +1,8 @@
/* ── Scene Director Panel ───────────────────── */
#scene-panel {
--panel-expanded-width: 360px;
/* Secondary text in Authoring: dimmer than labels, but legible on the globe. */
--scene-quiet: rgba(232, 234, 237, 0.62);
position: fixed;
top: calc(var(--left-stack-top) + (var(--left-stack-gap) * 2));
left: var(--left-stack-x);
@@ -150,15 +152,15 @@
flex-wrap: wrap;
}
/* The scene's name takes its own row; NEW SCENE / DELETE share the next. */
#scene-select {
flex: 1;
flex: 1 1 100%;
min-width: 0;
min-height: 32px;
font-size: 12px;
}
/* A translated scene name needs the width: the select takes its own row,
NEW / DEL share the next one. */
html:not([lang='en']) #scene-select {
flex: 1 1 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -168,8 +170,7 @@ html:not([lang='en']) #scene-select {
}
/* A translated action longer than its button wraps inside the button instead
of running past it. English keeps its one-line buttons ("EXPORT PRESETS"
included). */
of running past it. English keeps its one-line buttons. */
html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
@@ -180,6 +181,70 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
min-width: 72px;
}
#scene-panel .scene-btn {
font-size: 10px;
padding: 8px 10px;
}
/* Authoring's steps: SCENE, SHOTS, PREVIEW, SAVE & SHARE. */
.scene-step-heading {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 6px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1.6px;
color: var(--scene-quiet);
}
.scene-step-heading::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.08);
}
.panel-header + .scene-step-heading {
margin-top: 2px;
}
/* One primary action per step: CAPTURE SHOT and PLAY. The panel id keeps
this above Cyber's shared button surface. */
#scene-panel .scene-btn-primary:not(:disabled) {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
/* The file row is housekeeping; it stays quieter than the steps above. */
#scene-panel .scene-file-controls .scene-btn {
font-size: 9px;
/* Room for one long translated word (IMPORTIEREN, ІМПОРТУВАТИ) per line. */
padding: 8px 4px;
letter-spacing: 0.6px;
background: transparent;
}
/* Keep the shared hover on the restyled actions. */
#scene-panel .scene-btn-primary:hover:not(:disabled),
#scene-panel .scene-file-controls .scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
.scene-shot-hint {
margin: 0 0 8px;
font-size: 11px;
line-height: 1.4;
color: var(--scene-quiet);
}
#scene-panel:not(.has-shots) .scene-shot-hint {
display: none;
}
#scene-start-btn,
#scene-stop-btn,
#scene-next-btn {
@@ -190,12 +255,14 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 160px;
min-height: 96px;
max-height: 260px;
/* Two shot rows stay visible; the panel scrolls when space is short. */
min-height: 128px;
flex: 1;
overflow-y: auto;
padding: 4px 2px 6px;
margin-bottom: 8px;
margin-bottom: 4px;
counter-reset: scene-shot;
}
.scene-shot-list::-webkit-scrollbar {
@@ -210,19 +277,21 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
.scene-shot-empty {
border: 1px dashed rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 10px 8px;
padding: 16px 12px;
text-align: center;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: var(--text-dim);
font-size: 11px;
letter-spacing: 0.3px;
line-height: 1.5;
color: var(--scene-quiet);
}
.scene-shot-row {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 9px;
padding: 7px 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
counter-increment: scene-shot;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
@@ -243,12 +312,38 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
.scene-shot-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1px;
font-size: 12px;
letter-spacing: 0.4px;
color: var(--text-primary);
}
/* While renaming, the label holds only the editor at full width. */
.scene-shot-label:has(.scene-shot-rename) {
overflow: visible;
white-space: normal;
}
.scene-shot-label:has(.scene-shot-rename)::before {
content: none;
}
/* Shots are numbered 01, 02… in playback order. */
.scene-shot-label::before {
content: counter(scene-shot, decimal-leading-zero);
margin-right: 8px;
font-size: 10px;
color: var(--scene-quiet);
}
.scene-shot-row.active .scene-shot-label::before {
color: var(--accent);
}
.scene-shot-rename {
width: 100%;
min-width: 0;
@@ -260,11 +355,11 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
}
.scene-shot-meta {
margin-top: 4px;
margin-top: 5px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.8px;
color: var(--text-dim);
font-size: 10px;
letter-spacing: 0.4px;
color: var(--scene-quiet);
}
.scene-shot-actions {
@@ -275,7 +370,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
.scene-progress {
margin-top: 8px;
width: 100%;
height: 18px;
height: 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -305,9 +400,10 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#scene-status {
margin-top: 8px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--text-secondary);
font-size: 11px;
letter-spacing: 0.4px;
line-height: 1.4;
color: var(--scene-quiet);
}
#scene-runtime {
@@ -357,6 +453,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#cctv-panel.collapsed .cctv-summary-label,
#cctv-panel.collapsed #cctv-summary,
#scene-panel.collapsed .scene-controls,
#scene-panel.collapsed .scene-step-heading,
#scene-panel.collapsed .scene-shot-hint,
#scene-panel.collapsed .scene-shot-list,
#scene-panel.collapsed .scene-progress,
#scene-panel.collapsed #scene-status,
@@ -378,14 +476,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
width: 100%;
}
#data-panel.collapsed .panel-title,
#cctv-panel.collapsed .panel-title,
#radio-panel.collapsed .panel-title,
#scene-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#control-panel.collapsed .panel-inner,
#data-panel.collapsed .data-panel-inner,
#cctv-panel.collapsed .cctv-panel-inner,
-4
View File
@@ -50,10 +50,6 @@
.weather-readout[hidden] {
display: none !important;
}
#weather-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#weather-panel.collapsed .panel-header {
margin-bottom: 2px;
}
+19 -14
View File
@@ -147,35 +147,40 @@
<div class="panel-glow"></div>
<div class="scene-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n>SCENES</span>
<span class="panel-title" title="Create and edit scenes" data-i18n data-i18n-title>AUTHORING</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<p class="scene-step-heading" data-i18n>SCENE</p>
<div class="scene-controls">
<select class="gev-field" id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>DEL</button>
<select class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label></select>
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" title="Start a new, empty scene" data-i18n data-i18n-title>NEW SCENE</button>
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Delete this scene and all of its shots" data-i18n data-i18n-title>DELETE</button>
</div>
<p class="scene-step-heading" data-i18n>SHOTS</p>
<div class="scene-controls">
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" data-i18n>UPDATE SHOT</button>
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Add the current view, style and layers as the next shot" data-i18n data-i18n-title>+ CAPTURE SHOT</button>
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" title="Replace the selected shot with the current view" data-i18n data-i18n-title>UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<p class="scene-shot-hint" data-i18n>Click a shot to select it, double-click to rename.</p>
<p class="scene-step-heading" data-i18n>PREVIEW</p>
<div class="scene-controls">
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Play from this scene through the rest of the project" data-i18n-title data-i18n-runtime>PLAY</button>
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Stop playback (Esc)" data-i18n data-i18n-title>STOP</button>
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" title="Play the shot after the selected one, moving on to the next scene when needed" data-i18n data-i18n-title>NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" data-i18n>RUN LOG</button>
<p class="scene-step-heading" data-i18n>SAVE &amp; SHARE</p>
<div class="scene-controls scene-file-controls">
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" title="Download every scene as a JSON file you can import later" data-i18n data-i18n-title>EXPORT</button>
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" title="Load scenes from an exported JSON file" data-i18n data-i18n-title>IMPORT</button>
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" title="Download the timing log of the last play" data-i18n data-i18n-title>RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">
<div id="scene-progress-fill">0%</div>
</div>
<div id="scene-status" data-i18n>Ready</div>
<div id="scene-status" role="status" aria-live="polite" data-i18n>Ready</div>
</div>
</div>
+1
View File
@@ -97,6 +97,7 @@ const PANEL_ALIASES = new Map([
['global context', 'global-context-panel'],
['right context', 'global-context-panel'],
['context right panel', 'global-context-panel'],
['authoring', 'scene-panel'],
['scenes', 'scene-panel'],
['scene', 'scene-panel'],
['post processing', 'pp-toggles'],
+23
View File
@@ -3931,3 +3931,26 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.ok, true);
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
});
test('set_panel_open accepts the Authoring name for the scene panel', async () => {
const calls = [];
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000),
},
},
styleManager: {
setPanelCollapsed: (...args) => calls.push(args),
},
dataManager: { layers: new Map(), isEnabled: () => false, getAll: () => [] },
});
for (const panel of ['authoring', 'Authoring', 'scenes']) {
const result = await runner('set_panel_open', { panelId: panel, open: true });
assert.equal(result.panelId, 'scene-panel');
}
assert.deepEqual(calls.at(-1), ['scene-panel', false, { explicit: true }]);
});