4240 lines
183 KiB
JavaScript
4240 lines
183 KiB
JavaScript
/**
|
|
* Main Application Controller - 10 Sci-Fi Universes Architecture
|
|
* Coordinates Starfleet, Whoniverse, Industrial, Bioships, Retro Future, Military,
|
|
* Deep Space, Outlaw, Space Stations, and Comedy universes.
|
|
*/
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// 1. Initialize Audio Subsystems
|
|
const audioManager = new AudioManager();
|
|
const hullDrone = new HullDroneSynth(audioManager);
|
|
const warpCore = new WarpCoreSynth(audioManager);
|
|
const lifeSupport = new LifeSupportSynth(audioManager);
|
|
const telemetry = new TelemetrySynth(audioManager);
|
|
const alerts = new AlertSynth(audioManager);
|
|
const whoniverseAudio = new WhoniverseAudioSynth(audioManager);
|
|
const expandedAudio = new ExpandedSciFiAudioSynth(audioManager);
|
|
const engineTransitions = new EngineTransitionSynth(audioManager);
|
|
window.expandedAudio = expandedAudio;
|
|
window.whoniverseAudio = whoniverseAudio;
|
|
window.engineTransitions = engineTransitions;
|
|
const visualizer = new StarshipVisualizer(audioManager, warpCore);
|
|
const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport);
|
|
window.observationEngine = observationEngine;
|
|
|
|
let isPlaying = false;
|
|
let activeUniverseId = 'starfleet';
|
|
let activePresetId = 'tng-bridge';
|
|
window.activeUniverseId = activeUniverseId;
|
|
window.activePresetId = activePresetId;
|
|
|
|
// 2. DOM Elements
|
|
const headerUniverseBtn = document.getElementById('header-universe-btn');
|
|
const headerUniverseLabel = document.getElementById('header-universe-label');
|
|
const universeDropdown = document.getElementById('universe-dropdown');
|
|
const headerShipName = document.getElementById('header-ship-name');
|
|
const headerMatrixLabel = document.getElementById('header-matrix-label');
|
|
|
|
const sidebarTitle = document.getElementById('sidebar-title');
|
|
const presetContainer = document.getElementById('preset-container');
|
|
const currentPresetTitle = document.getElementById('current-preset-title');
|
|
const currentPresetDesc = document.getElementById('current-preset-desc');
|
|
const statAudioStatus = document.getElementById('stat-audio-status');
|
|
const statCoreFreq = document.getElementById('stat-core-freq');
|
|
|
|
const btnPlay = document.getElementById('btn-master-play');
|
|
const playIcon = document.getElementById('play-icon');
|
|
const playText = document.getElementById('play-text');
|
|
|
|
// Master Volume Console Elements
|
|
const sliderMasterVol = document.getElementById('slider-master-vol');
|
|
const valMasterVol = document.getElementById('val-master-vol');
|
|
const valMasterDb = document.getElementById('val-master-db');
|
|
const btnMasterMute = document.getElementById('btn-master-mute');
|
|
const btnHeaderMute = document.getElementById('btn-header-mute');
|
|
const headerVolVal = document.getElementById('header-vol-val');
|
|
const btnVolDown = document.getElementById('btn-vol-down');
|
|
const btnVolUp = document.getElementById('btn-vol-up');
|
|
const volMeterLeds = document.getElementById('vol-meter-leds');
|
|
const volStepButtons = document.querySelectorAll('.btn-vol-step');
|
|
|
|
// Channel Sliders
|
|
const sliderHullVol = document.getElementById('slider-hull-vol');
|
|
const valHullVol = document.getElementById('val-hull-vol');
|
|
const sliderHullFreq = document.getElementById('slider-hull-freq');
|
|
const valHullFreq = document.getElementById('val-hull-freq');
|
|
const sliderHullCutoff = document.getElementById('slider-hull-cutoff');
|
|
const valHullCutoff = document.getElementById('val-hull-cutoff');
|
|
|
|
const sliderWarpVol = document.getElementById('slider-warp-vol');
|
|
const valWarpVol = document.getElementById('val-warp-vol');
|
|
const sliderWarpBpm = document.getElementById('slider-warp-bpm');
|
|
const valWarpBpm = document.getElementById('val-warp-bpm');
|
|
const sliderWarpCarrier = document.getElementById('slider-warp-carrier');
|
|
const valWarpCarrier = document.getElementById('val-warp-carrier');
|
|
|
|
const sliderAirVol = document.getElementById('slider-air-vol');
|
|
const valAirVol = document.getElementById('val-air-vol');
|
|
const sliderAirCutoff = document.getElementById('slider-air-cutoff');
|
|
const valAirCutoff = document.getElementById('val-air-cutoff');
|
|
|
|
const sliderTelemetryVol = document.getElementById('slider-telemetry-vol');
|
|
const valTelemetryVol = document.getElementById('val-telemetry-vol');
|
|
const sliderTelemetryDensity = document.getElementById('slider-telemetry-density');
|
|
const valTelemetryDensity = document.getElementById('val-telemetry-density');
|
|
|
|
// Visualizer & Dynamic Containers
|
|
const coreVisualizerTitle = document.getElementById('core-visualizer-title');
|
|
const coreVisualizerLabel = document.getElementById('core-visualizer-label');
|
|
const soundboardTitle = document.getElementById('soundboard-title');
|
|
const universeEventsContainer = document.getElementById('universe-events-container');
|
|
const soundboardContainer = document.getElementById('soundboard-container');
|
|
|
|
// Sleep Timer
|
|
const timerDisplay = document.getElementById('timer-display');
|
|
const timerButtons = document.querySelectorAll('.btn-timer');
|
|
|
|
// 3. Universe Management & Switching
|
|
function initUniverseDropdown() {
|
|
universeDropdown.innerHTML = '';
|
|
Object.values(UniverseRegistry).forEach(u => {
|
|
const btn = document.createElement('button');
|
|
btn.className = `universe-item ${u.id === activeUniverseId ? 'active' : ''}`;
|
|
btn.innerHTML = `<span class="u-icon">${u.icon}</span> <span>${u.name}</span>`;
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
setUniverse(u.id);
|
|
});
|
|
universeDropdown.appendChild(btn);
|
|
});
|
|
|
|
headerUniverseBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
universeDropdown.classList.toggle('show');
|
|
});
|
|
|
|
document.addEventListener('click', () => {
|
|
universeDropdown.classList.remove('show');
|
|
});
|
|
}
|
|
|
|
function setUniverse(universeId) {
|
|
const universe = UniverseRegistry[universeId];
|
|
if (!universe) return;
|
|
|
|
activeUniverseId = universeId;
|
|
universeDropdown.classList.remove('show');
|
|
|
|
// Update Dropdown UI active class
|
|
document.querySelectorAll('.universe-item').forEach(el => {
|
|
el.classList.toggle('active', el.textContent.includes(universe.name));
|
|
});
|
|
|
|
// Update Header and Theme Class
|
|
headerUniverseLabel.textContent = universe.shortCode;
|
|
document.body.className = universe.themeClass;
|
|
headerMatrixLabel.textContent = universe.headerTitle;
|
|
|
|
// Custom titles per universe
|
|
const titlesMap = {
|
|
'starfleet': {
|
|
sidebar: 'STARSHIP PRESETS',
|
|
visTitle: 'INTERMIX CASCADE',
|
|
visLabel: 'INTERMIX CHAMBER // ACTIVE',
|
|
soundboard: 'LCARS KEYPAD'
|
|
},
|
|
'whoniverse': {
|
|
sidebar: 'TARDIS CONSOLES',
|
|
visTitle: 'CENTRAL TIME ROTOR COLUMN',
|
|
visLabel: 'VORTEX ROTOR // OPERATIONAL',
|
|
soundboard: 'TARDIS CONSOLE CONTROLS'
|
|
},
|
|
'industrial': {
|
|
sidebar: 'INDUSTRIAL FREIGHTERS',
|
|
visTitle: 'COMPRESSION FURNACE',
|
|
visLabel: 'CONTAINMENT CORE // ACTIVE',
|
|
soundboard: 'INDUSTRIAL INSTRUMENTATION'
|
|
},
|
|
'bioships': {
|
|
sidebar: 'SENTIENT LEVIATHANS',
|
|
visTitle: 'ORGANIC BIO-HEART SAC',
|
|
visLabel: 'VASCULAR NEXUS // PULSING',
|
|
soundboard: 'NEURAL LINK SENSORS'
|
|
},
|
|
'retrofuture': {
|
|
sidebar: 'GOLDEN AGE EXPLORATION',
|
|
visTitle: 'ASTROGATOR OSCILLOSCOPE',
|
|
visLabel: 'ANALOG BEAM // ROTATING',
|
|
soundboard: 'RETRO CONSOLE SWITCHES'
|
|
},
|
|
'military': {
|
|
sidebar: 'BATTLEFLEET WARSHIPS',
|
|
visTitle: 'TACTICAL FLYWHEEL',
|
|
visLabel: 'CONDITION ONE // ARMED',
|
|
soundboard: 'CIC TACTICAL KEYPAD'
|
|
},
|
|
'deepspace': {
|
|
sidebar: 'DEEP EXPEDITION ARKS',
|
|
visTitle: 'GRAVITY SINGULARITY CORE',
|
|
visLabel: 'EVENT HORIZON // LOCKED',
|
|
soundboard: 'VOID ACOUSTIC CONSOLE'
|
|
},
|
|
'outlaw': {
|
|
sidebar: 'FRONTIER & SALVAGE SHIPS',
|
|
visTitle: 'PATCHWORK IMPULSE DRIVE',
|
|
visLabel: 'KINETIC THRUSTERS // PRIMED',
|
|
soundboard: 'OUTLAW COCKPIT CONTROLS'
|
|
},
|
|
'spacestations': {
|
|
sidebar: 'ORBITAL STATIONS & DOCKS',
|
|
visTitle: 'CENTRIFUGAL DISTRIBUTION HUB',
|
|
visLabel: 'STATION ROTOR // DOCKED',
|
|
soundboard: 'STATION OPS CONSOLE'
|
|
},
|
|
'comedy': {
|
|
sidebar: 'WHIMSICAL & ADVENTURE FLEET',
|
|
visTitle: 'IMPROBABILITY ENGINE',
|
|
visLabel: 'REALITY FIELD // SHIFTING',
|
|
soundboard: 'CHEERFUL CONSOLE KEYPAD'
|
|
}
|
|
};
|
|
|
|
const conf = titlesMap[universeId] || titlesMap['starfleet'];
|
|
sidebarTitle.textContent = conf.sidebar;
|
|
coreVisualizerTitle.textContent = conf.visTitle;
|
|
coreVisualizerLabel.textContent = conf.visLabel;
|
|
soundboardTitle.textContent = conf.soundboard;
|
|
|
|
// Configure Visualizer mode
|
|
visualizer.setMode(universe.visualizer || 'warp-core');
|
|
|
|
// Render Event Buttons & Soundboard
|
|
renderUniverseEvents(universe);
|
|
renderUniverseSoundboard(universe);
|
|
|
|
// Render Presets
|
|
renderUniversePresets(universe);
|
|
|
|
// Select Default Preset
|
|
const firstPresetId = Object.keys(universe.presets)[0];
|
|
if (firstPresetId) {
|
|
selectPreset(firstPresetId);
|
|
}
|
|
|
|
if (observationActive && typeof refreshObservation === 'function') {
|
|
refreshObservation();
|
|
}
|
|
}
|
|
|
|
function renderUniverseEvents(universe) {
|
|
universeEventsContainer.innerHTML = '';
|
|
universe.events.forEach(evt => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-lcars-pill';
|
|
btn.id = evt.id;
|
|
btn.textContent = evt.label;
|
|
|
|
btn.addEventListener('click', async () => {
|
|
await audioManager.resume();
|
|
handleUniverseEvent(evt.type, btn);
|
|
});
|
|
|
|
universeEventsContainer.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function renderUniverseSoundboard(universe) {
|
|
soundboardContainer.innerHTML = '';
|
|
universe.soundboard.forEach(key => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-chirp';
|
|
btn.id = key.id;
|
|
btn.textContent = key.label;
|
|
if (key.span) {
|
|
btn.style.gridColumn = `span ${key.span}`;
|
|
}
|
|
|
|
btn.addEventListener('click', async () => {
|
|
await audioManager.resume();
|
|
handleSoundboardTrigger(key.id);
|
|
});
|
|
|
|
soundboardContainer.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function renderUniversePresets(universe) {
|
|
presetContainer.innerHTML = '';
|
|
Object.values(universe.presets).forEach(preset => {
|
|
const btn = document.createElement('button');
|
|
btn.className = `preset-btn ${preset.id === activePresetId ? 'active' : ''}`;
|
|
btn.setAttribute('data-preset-id', preset.id);
|
|
|
|
btn.innerHTML = `
|
|
<span>${preset.name}</span>
|
|
<span class="ship-era-tag">${(preset.era || 'SHIP').toUpperCase()} // ${preset.warp.pulseShape.toUpperCase()} CORE</span>
|
|
`;
|
|
|
|
btn.addEventListener('click', () => {
|
|
selectPreset(preset.id);
|
|
});
|
|
|
|
presetContainer.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
// 4a. Phase 2 Preset Ambience
|
|
// Single funnel that stops every automated loop (legacy + registry) and then
|
|
// re-engages the ambience matching the currently selected preset.
|
|
function startPresetAmbience(presetId) {
|
|
expandedAudio.stopAllLoops();
|
|
whoniverseAudio.stopAmbientLoops();
|
|
|
|
// Legacy Phase 1 loops, kept on their original presets
|
|
if (presetId === 'sta-sevastopol') {
|
|
expandedAudio.startStationSparks(4000, 11000);
|
|
} else if (presetId === 'sta-gateway' || presetId.includes('sickbay') || presetId.includes('med')) {
|
|
expandedAudio.startMedicalMonitor(1.15);
|
|
}
|
|
|
|
// Phase 2 audio_gaps.md rows #25-#45
|
|
if (presetId === 'bio-vorlon') expandedAudio.startVorlonSong(); // #26
|
|
if (presetId === 'bio-wraith') expandedAudio.startHullRegen(); // #28
|
|
if (presetId === 'deep-event-horizon' || presetId === 'deep-icarus-ii') expandedAudio.startHullStrainMoans(); // #30
|
|
// Phase 3 (audio_gaps.md #46-#54)
|
|
if (presetId === 'sta-babylon-5') expandedAudio.startZocaloChatter(); // #46
|
|
if (presetId === 'ind-nostromo') expandedAudio.startTeletypeChatter(); // #51
|
|
if (presetId === 'ret-jupiter-2') expandedAudio.startHeterodyneDrone(); // #54
|
|
if (presetId === 'deep-icarus-ii') expandedAudio.startIcarusBeacon(); // #31
|
|
if (presetId === 'deep-event-horizon' || presetId === 'deep-nightflyer') expandedAudio.startVoidWhispers(); // #32
|
|
if (presetId === 'com-protector') expandedAudio.startBerylliumThrum(); // #33
|
|
if (presetId === 'out-marauder') expandedAudio.startRepulsorliftDrone(); // #35
|
|
if (presetId === 'out-bebop') expandedAudio.startBebopChug(); // #36
|
|
if (presetId === 'mil-agamemnon') expandedAudio.startCarouselGroan(); // #37
|
|
if (presetId === 'mil-viper') expandedAudio.startOxygenRegulator(); // #39
|
|
if (presetId === 'ind-nostromo') expandedAudio.startPipeDrips(); // #40
|
|
if (presetId === 'ind-serenity' || presetId === 'ind-starbug') expandedAudio.startSteamVent(); // #41
|
|
if (presetId === 'ret-discovery-one') expandedAudio.startHalBreathing(); // #43
|
|
if (presetId === 'ret-cygnus') expandedAudio.startCygnusChug(); // #45
|
|
}
|
|
|
|
// 4. Select & Apply Preset
|
|
function selectPreset(presetId) {
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
if (!universe) return;
|
|
|
|
const preset = universe.presets[presetId];
|
|
if (!preset) return;
|
|
|
|
activePresetId = presetId;
|
|
window.activePresetId = presetId;
|
|
|
|
document.querySelectorAll('.preset-btn').forEach(b => {
|
|
b.classList.toggle('active', b.getAttribute('data-preset-id') === presetId);
|
|
});
|
|
|
|
headerShipName.textContent = preset.name.toUpperCase();
|
|
currentPresetTitle.textContent = preset.name.toUpperCase();
|
|
currentPresetDesc.textContent = preset.description;
|
|
statCoreFreq.textContent = `${preset.warp.carrierFreq.toFixed(1)} Hz`;
|
|
|
|
hullDrone.applyPreset(preset.hull);
|
|
warpCore.applyPreset(preset.warp);
|
|
lifeSupport.applyPreset(preset.lifeSupport);
|
|
telemetry.applyPreset(preset.telemetry);
|
|
|
|
syncSlidersToSynthParams();
|
|
|
|
expandedAudio.stopAllLoops();
|
|
whoniverseAudio.stopAmbientLoops();
|
|
if (isPlaying) {
|
|
startPresetAmbience(presetId);
|
|
if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeDematSwitch();
|
|
else if (activeUniverseId === 'industrial') expandedAudio.synthesizeDockingClamp();
|
|
else telemetry.synthesizeLCARSSingleChirp();
|
|
}
|
|
|
|
if (observationActive && typeof refreshObservation === 'function') {
|
|
refreshObservation();
|
|
}
|
|
}
|
|
|
|
function syncSlidersToSynthParams() {
|
|
sliderHullVol.value = hullDrone.params.volume;
|
|
valHullVol.textContent = `${Math.round(hullDrone.params.volume * 100)}%`;
|
|
sliderHullFreq.value = hullDrone.params.baseFreq;
|
|
valHullFreq.textContent = `${hullDrone.params.baseFreq} Hz`;
|
|
sliderHullCutoff.value = hullDrone.params.filterCutoff;
|
|
valHullCutoff.textContent = `${hullDrone.params.filterCutoff} Hz`;
|
|
|
|
sliderWarpVol.value = warpCore.params.volume;
|
|
valWarpVol.textContent = `${Math.round(warpCore.params.volume * 100)}%`;
|
|
sliderWarpBpm.value = warpCore.params.bpm;
|
|
valWarpBpm.textContent = `${warpCore.params.bpm} BPM`;
|
|
sliderWarpCarrier.value = warpCore.params.carrierFreq;
|
|
valWarpCarrier.textContent = `${warpCore.params.carrierFreq} Hz`;
|
|
|
|
sliderAirVol.value = lifeSupport.params.volume;
|
|
valAirVol.textContent = `${Math.round(lifeSupport.params.volume * 100)}%`;
|
|
sliderAirCutoff.value = lifeSupport.params.lowpassFreq;
|
|
valAirCutoff.textContent = `${lifeSupport.params.lowpassFreq} Hz`;
|
|
|
|
sliderTelemetryVol.value = telemetry.params.volume;
|
|
valTelemetryVol.textContent = `${Math.round(telemetry.params.volume * 100)}%`;
|
|
sliderTelemetryDensity.value = telemetry.params.density;
|
|
valTelemetryDensity.textContent = `${Math.round(telemetry.params.density * 100)}%`;
|
|
}
|
|
|
|
// 5. Play / Pause Master Toggle
|
|
let isTransitioning = false;
|
|
const ENGINE_TRANSITION_SECS = 2.5;
|
|
|
|
async function togglePlay() {
|
|
if (isTransitioning) return;
|
|
|
|
const engineProfile = typeof getEngineProfileForPreset === 'function'
|
|
? getEngineProfileForPreset(activePresetId, activeUniverseId)
|
|
: 'galaxy';
|
|
|
|
if (!isPlaying) {
|
|
isTransitioning = true;
|
|
await audioManager.resume();
|
|
|
|
// Flashing state while engaging
|
|
btnPlay.classList.add('engaging');
|
|
btnPlay.classList.remove('playing', 'stopping');
|
|
statAudioStatus.textContent = 'ENGAGING...';
|
|
statAudioStatus.style.color = '#ffcc00';
|
|
|
|
// Layered & Staged Procedural Startup Script for this Profile
|
|
engineTransitions.playStartup(engineProfile, ENGINE_TRANSITION_SECS);
|
|
|
|
// Acoustic chime / chirp feedback
|
|
if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeTelepathicChime();
|
|
else if (activeUniverseId === 'bioships') expandedAudio.synthesizeStarburst();
|
|
else telemetry.synthesizeLCARSDoubleChirp();
|
|
|
|
// Start engine sounds with smooth spool-up ramp
|
|
hullDrone.start(ENGINE_TRANSITION_SECS);
|
|
warpCore.start(ENGINE_TRANSITION_SECS);
|
|
lifeSupport.start(ENGINE_TRANSITION_SECS);
|
|
telemetry.start();
|
|
|
|
setTimeout(() => {
|
|
isPlaying = true;
|
|
isTransitioning = false;
|
|
|
|
btnPlay.classList.remove('engaging');
|
|
btnPlay.classList.add('playing');
|
|
playIcon.textContent = '■';
|
|
playText.textContent = 'FULL STOP';
|
|
statAudioStatus.textContent = 'ONLINE';
|
|
statAudioStatus.style.color = '#00ff88';
|
|
|
|
if (activePresetId) {
|
|
startPresetAmbience(activePresetId);
|
|
}
|
|
}, ENGINE_TRANSITION_SECS * 1000);
|
|
|
|
} else {
|
|
isTransitioning = true;
|
|
|
|
// Flashing state while stopping
|
|
btnPlay.classList.remove('playing', 'engaging');
|
|
btnPlay.classList.add('stopping');
|
|
statAudioStatus.textContent = 'STOPPING...';
|
|
statAudioStatus.style.color = '#ff9900';
|
|
|
|
// Layered & Staged Procedural Shutdown Script for this Profile
|
|
engineTransitions.playShutdown(engineProfile, ENGINE_TRANSITION_SECS);
|
|
|
|
if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeLeverClunk();
|
|
else telemetry.synthesizeLCARSDoubleChirp();
|
|
|
|
// Stop engine sounds with smooth spool-down fade
|
|
hullDrone.stop(ENGINE_TRANSITION_SECS);
|
|
warpCore.stop(ENGINE_TRANSITION_SECS);
|
|
lifeSupport.stop(ENGINE_TRANSITION_SECS);
|
|
telemetry.stop();
|
|
expandedAudio.stopAllLoops();
|
|
whoniverseAudio.stopAmbientLoops();
|
|
alerts.stopAlert();
|
|
whoniverseAudio.stopCloisterBell();
|
|
resetAlertButtons();
|
|
|
|
setTimeout(() => {
|
|
isPlaying = false;
|
|
isTransitioning = false;
|
|
|
|
btnPlay.classList.remove('stopping');
|
|
playIcon.textContent = '▶';
|
|
playText.textContent = 'ENGAGE';
|
|
statAudioStatus.textContent = 'STANDBY';
|
|
statAudioStatus.style.color = '#ffcc00';
|
|
}, ENGINE_TRANSITION_SECS * 1000);
|
|
}
|
|
}
|
|
|
|
btnPlay.addEventListener('click', togglePlay);
|
|
|
|
// 6. Master Volume Control System
|
|
function updateMasterVolumeUI(val, isMuted = false) {
|
|
sliderMasterVol.value = val;
|
|
const pct = Math.round(val * 100);
|
|
valMasterVol.textContent = isMuted ? 'MUTED' : `${pct}%`;
|
|
headerVolVal.textContent = isMuted ? 'MUTE' : `${pct}%`;
|
|
|
|
if (isMuted || val <= 0.001) {
|
|
valMasterDb.textContent = '-∞ dB';
|
|
} else {
|
|
const db = 20 * Math.log10(val);
|
|
valMasterDb.textContent = `${db >= 0 ? '+' : ''}${db.toFixed(1)} dB`;
|
|
}
|
|
|
|
btnMasterMute.classList.toggle('muted', isMuted);
|
|
btnHeaderMute.classList.toggle('muted', isMuted);
|
|
btnMasterMute.textContent = isMuted ? 'UNMUTE' : 'MUTE';
|
|
btnHeaderMute.textContent = isMuted ? 'MUTED' : 'MUTE';
|
|
|
|
if (volMeterLeds) {
|
|
const pips = volMeterLeds.querySelectorAll('.led-pip');
|
|
const activeCount = isMuted ? 0 : Math.round(val * pips.length);
|
|
pips.forEach((pip, idx) => {
|
|
pip.classList.toggle('active', idx < activeCount);
|
|
});
|
|
}
|
|
|
|
volStepButtons.forEach(btn => {
|
|
const stepVal = parseFloat(btn.getAttribute('data-vol'));
|
|
btn.classList.toggle('active', !isMuted && Math.abs(stepVal - val) < 0.04);
|
|
});
|
|
}
|
|
|
|
function setMasterVolume(val) {
|
|
const clamped = Math.max(0, Math.min(1, val));
|
|
if (audioManager.isMuted) audioManager.setMute(false);
|
|
audioManager.setMasterVolume(clamped);
|
|
updateMasterVolumeUI(clamped, false);
|
|
}
|
|
|
|
function toggleMute() {
|
|
const isMuted = audioManager.toggleMute();
|
|
updateMasterVolumeUI(audioManager.getMasterVolume(), isMuted);
|
|
}
|
|
|
|
sliderMasterVol.addEventListener('input', (e) => setMasterVolume(parseFloat(e.target.value)));
|
|
btnMasterMute.addEventListener('click', toggleMute);
|
|
btnHeaderMute.addEventListener('click', toggleMute);
|
|
btnVolDown.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() - 0.05));
|
|
btnVolUp.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() + 0.05));
|
|
|
|
volStepButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const stepVal = parseFloat(btn.getAttribute('data-vol'));
|
|
setMasterVolume(stepVal);
|
|
});
|
|
});
|
|
|
|
// 7. Channel Synthesizer Sliders
|
|
sliderHullVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
hullDrone.setVolume(val);
|
|
valHullVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderHullFreq.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
hullDrone.setBaseFreq(val);
|
|
valHullFreq.textContent = `${val} Hz`;
|
|
});
|
|
sliderHullCutoff.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
hullDrone.setFilterCutoff(val);
|
|
valHullCutoff.textContent = `${val} Hz`;
|
|
});
|
|
|
|
sliderWarpVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
warpCore.setVolume(val);
|
|
valWarpVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderWarpBpm.addEventListener('input', (e) => {
|
|
const val = parseInt(e.target.value);
|
|
warpCore.setBpm(val);
|
|
valWarpBpm.textContent = `${val} BPM`;
|
|
});
|
|
sliderWarpCarrier.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
warpCore.setCarrierFreq(val);
|
|
valWarpCarrier.textContent = `${val} Hz`;
|
|
statCoreFreq.textContent = `${val.toFixed(1)} Hz`;
|
|
});
|
|
|
|
sliderAirVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
lifeSupport.setVolume(val);
|
|
valAirVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderAirCutoff.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
lifeSupport.setFilterCutoff(val);
|
|
valAirCutoff.textContent = `${val} Hz`;
|
|
});
|
|
|
|
sliderTelemetryVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
telemetry.setVolume(val);
|
|
valTelemetryVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderTelemetryDensity.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
telemetry.setDensity(val);
|
|
valTelemetryDensity.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
|
|
// 8. Universe Event Handlers
|
|
function resetAlertButtons() {
|
|
document.querySelectorAll('.btn-lcars-pill').forEach(b => b.classList.remove('active-alert'));
|
|
}
|
|
|
|
function handleUniverseEvent(type, btnElement) {
|
|
switch (type) {
|
|
case 'warp':
|
|
case 'quantum':
|
|
alerts.synthesizeWarpJump();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'alert-red':
|
|
case 'action-stations':
|
|
case 'scramble':
|
|
if (alerts.activeAlert === 'red') {
|
|
alerts.stopAlert();
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
alerts.triggerRedAlert('tng');
|
|
}
|
|
break;
|
|
case 'alert-yellow':
|
|
if (alerts.activeAlert === 'yellow') {
|
|
alerts.stopAlert();
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
alerts.triggerYellowAlert();
|
|
}
|
|
break;
|
|
case 'demat':
|
|
whoniverseAudio.synthesizeDematCycle(4);
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'vortex':
|
|
whoniverseAudio.synthesizeDematCycle(1);
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'cloister':
|
|
if (whoniverseAudio.activeCloister) {
|
|
whoniverseAudio.stopCloisterBell();
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
whoniverseAudio.triggerCloisterBell();
|
|
}
|
|
break;
|
|
case 'epstein':
|
|
case 'subdrive':
|
|
expandedAudio.synthesizeEpsteinBurn();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'purge':
|
|
case 'decompress':
|
|
case 'docking':
|
|
expandedAudio.synthesizeDockingClamp();
|
|
break;
|
|
case 'vent':
|
|
whoniverseAudio.synthesizeFastReturn();
|
|
break;
|
|
case 'starburst':
|
|
case 'biodefense':
|
|
expandedAudio.synthesizeStarburst();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'neural':
|
|
expandedAudio.synthesizeNeuralBondSwell();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'centrifuge':
|
|
case 'astrogator':
|
|
expandedAudio.synthesizeRetroAstrogator();
|
|
break;
|
|
case 'hal':
|
|
expandedAudio.synthesizeHalChime();
|
|
break;
|
|
case 'ftl-jump':
|
|
expandedAudio.synthesizeFtlJump();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'flak':
|
|
expandedAudio.synthesizeFlakBarrageBurst();
|
|
break;
|
|
case 'singularity':
|
|
case 'gravity':
|
|
case 'rotation':
|
|
expandedAudio.synthesizeSingularityEngage();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'solar':
|
|
expandedAudio.synthesizeSolarRoar();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'afterburner':
|
|
expandedAudio.synthesizeAfterburner();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'ludicrous':
|
|
expandedAudio.synthesizeLudicrousSpeed();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'improbability':
|
|
expandedAudio.synthesizeImprobabilityFlip();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
default:
|
|
telemetry.synthesizeLCARSSingleChirp();
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleSoundboardTrigger(id) {
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
|
|
const era = preset ? preset.era : 'tng';
|
|
|
|
switch (id) {
|
|
// Star Trek
|
|
case 'btn-comm-badge':
|
|
expandedAudio.synthesizeCommBadge();
|
|
break;
|
|
case 'btn-door-swish':
|
|
expandedAudio.synthesizeDoorSwish(era);
|
|
break;
|
|
case 'btn-bosun-whistle':
|
|
expandedAudio.synthesizeBosunWhistle();
|
|
break;
|
|
case 'btn-medical-monitor':
|
|
expandedAudio.synthesizeMedicalMonitor();
|
|
break;
|
|
case 'btn-chirp-single':
|
|
case 'btn-switch-pop':
|
|
telemetry.synthesizeLCARSSingleChirp();
|
|
break;
|
|
case 'btn-chirp-double':
|
|
telemetry.synthesizeLCARSDoubleChirp();
|
|
break;
|
|
case 'btn-chirp-ack':
|
|
telemetry.synthesizeLCARSSequence();
|
|
break;
|
|
case 'btn-chirp-sweep':
|
|
telemetry.synthesizeSensorSweep();
|
|
break;
|
|
case 'btn-chirp-door':
|
|
telemetry.synthesizeDoorChime();
|
|
break;
|
|
|
|
// Whoniverse
|
|
case 'btn-sonic':
|
|
whoniverseAudio.synthesizeSonicScrewdriver();
|
|
break;
|
|
case 'btn-lever-clunk':
|
|
whoniverseAudio.synthesizeLeverClunk();
|
|
break;
|
|
case 'btn-tardis-door':
|
|
whoniverseAudio.synthesizeTardisDoor();
|
|
break;
|
|
case 'btn-fast-return':
|
|
whoniverseAudio.synthesizeFastReturn();
|
|
break;
|
|
case 'btn-demat-switch':
|
|
case 'btn-relay-click':
|
|
whoniverseAudio.synthesizeDematSwitch();
|
|
break;
|
|
case 'btn-telepathic':
|
|
case 'btn-telepathic-chime':
|
|
whoniverseAudio.synthesizeTelepathicChime();
|
|
break;
|
|
case 'btn-scanner':
|
|
whoniverseAudio.synthesizeDematSwitch();
|
|
telemetry.synthesizeSensorSweep();
|
|
break;
|
|
|
|
// Space Stations
|
|
case 'btn-air-handler':
|
|
expandedAudio.synthesizeAirHandlerThud();
|
|
break;
|
|
case 'btn-mag-boot':
|
|
expandedAudio.synthesizeMagBootLatch();
|
|
break;
|
|
case 'btn-commlock':
|
|
expandedAudio.synthesizeCommlockTone();
|
|
break;
|
|
case 'btn-sevas-spark':
|
|
expandedAudio.synthesizeSevastopolSpark();
|
|
break;
|
|
case 'btn-station-ecg':
|
|
expandedAudio.synthesizeStationMedicalPing();
|
|
break;
|
|
case 'btn-tram-gong':
|
|
whoniverseAudio.synthesizeCloisterStrike();
|
|
break;
|
|
|
|
// Deep Space
|
|
case 'btn-solar-roar':
|
|
expandedAudio.synthesizeSolarRoar();
|
|
break;
|
|
case 'btn-cryo-sigh':
|
|
expandedAudio.synthesizeCryoDepressurize();
|
|
break;
|
|
case 'btn-orion-thump':
|
|
expandedAudio.synthesizeOrionPulseThump();
|
|
break;
|
|
|
|
// Outlaw & Frontier
|
|
case 'btn-tape-clunk':
|
|
expandedAudio.synthesizeCassetteTransport();
|
|
break;
|
|
case 'btn-grappler-servo':
|
|
expandedAudio.synthesizeGrapplerServo();
|
|
break;
|
|
case 'btn-radio-static':
|
|
expandedAudio.synthesizeRadioStatic();
|
|
break;
|
|
case 'btn-afterburner-snd':
|
|
expandedAudio.synthesizeAfterburner();
|
|
break;
|
|
|
|
// Comedy & Adventure
|
|
case 'btn-cheerful-door':
|
|
expandedAudio.synthesizeCheerfulDoor();
|
|
break;
|
|
case 'btn-tea-machine':
|
|
expandedAudio.synthesizeTeaDispenser();
|
|
break;
|
|
case 'btn-kettle-whistle':
|
|
expandedAudio.synthesizeKettleWhistle();
|
|
break;
|
|
case 'btn-plaid-alarm':
|
|
expandedAudio.synthesizePlaidAlarm();
|
|
break;
|
|
case 'btn-beryllium-pulse':
|
|
expandedAudio.synthesizeImprobabilityFlip();
|
|
break;
|
|
|
|
// General / Industrial / Military
|
|
case 'btn-geiger':
|
|
expandedAudio.synthesizeGeigerBurst();
|
|
break;
|
|
case 'btn-dock-clamp':
|
|
case 'btn-pneumatic-door':
|
|
expandedAudio.synthesizeDockingClamp();
|
|
break;
|
|
case 'btn-dradis-ping':
|
|
expandedAudio.synthesizeDradisPing();
|
|
break;
|
|
case 'btn-hal-chime':
|
|
expandedAudio.synthesizeHalChime();
|
|
break;
|
|
|
|
// Phase 2 (audio_gaps.md #21-#45)
|
|
case 'btn-cardassian-door':
|
|
expandedAudio.synthesizeCardassianDoor();
|
|
break;
|
|
case 'btn-turbolift':
|
|
expandedAudio.synthesizeTurboliftWhoosh();
|
|
break;
|
|
case 'btn-replicator':
|
|
expandedAudio.synthesizeReplicatorShimmer();
|
|
break;
|
|
case 'btn-scanner-activate':
|
|
whoniverseAudio.synthesizeScannerActivate();
|
|
break;
|
|
case 'btn-type-clatter':
|
|
whoniverseAudio.synthesizeTypeClatter();
|
|
break;
|
|
case 'btn-vorlon-song':
|
|
expandedAudio.synthesizeVorlonSingingSwell();
|
|
break;
|
|
case 'btn-neural-bond':
|
|
expandedAudio.synthesizeNeuralBondSwell();
|
|
break;
|
|
case 'btn-hull-regen':
|
|
expandedAudio.synthesizeHullRegenBurst();
|
|
break;
|
|
case 'btn-belter-telemetry':
|
|
expandedAudio.synthesizeBelterTelemetryJitter();
|
|
break;
|
|
case 'btn-hull-strain':
|
|
expandedAudio.synthesizeHullStrainMoan();
|
|
break;
|
|
case 'btn-icarus-beacon':
|
|
expandedAudio.synthesizeIcarusBeacon();
|
|
break;
|
|
case 'btn-void-whisper':
|
|
expandedAudio.synthesizeVoidWhisper();
|
|
break;
|
|
case 'btn-beryllium-thrum':
|
|
expandedAudio.synthesizeBerylliumThrumSwell();
|
|
break;
|
|
case 'btn-omega-13':
|
|
expandedAudio.synthesizeOmega13Whine();
|
|
break;
|
|
case 'btn-repulsor-drone':
|
|
expandedAudio.synthesizeRepulsorliftDrone();
|
|
break;
|
|
case 'btn-bebop-chug':
|
|
expandedAudio.synthesizeBebopChugStroke();
|
|
break;
|
|
case 'btn-carousel-groan':
|
|
expandedAudio.synthesizeCarouselGroanCycle();
|
|
break;
|
|
case 'btn-slipstream':
|
|
expandedAudio.synthesizeSlipstreamSurge();
|
|
break;
|
|
case 'btn-oxygen-reg':
|
|
expandedAudio.synthesizeOxygenRegulatorCycle();
|
|
break;
|
|
case 'btn-pipe-drips':
|
|
expandedAudio.synthesizePipeDrip();
|
|
break;
|
|
case 'btn-steam-vent':
|
|
expandedAudio.synthesizeSteamVentSwell();
|
|
break;
|
|
case 'btn-crash-couch':
|
|
expandedAudio.synthesizeCrashCouchGimbal();
|
|
break;
|
|
case 'btn-hal-breath':
|
|
expandedAudio.synthesizeHalBreathCycle();
|
|
break;
|
|
case 'btn-death-blossom':
|
|
expandedAudio.synthesizeDeathBlossomSurge();
|
|
break;
|
|
case 'btn-cygnus-chug':
|
|
expandedAudio.synthesizeCygnusPistonChug();
|
|
break;
|
|
|
|
// Phase 3 (audio_gaps.md #48-#53)
|
|
case 'btn-transporter':
|
|
expandedAudio.synthesizeTransporterCycle();
|
|
break;
|
|
case 'btn-breech-clank':
|
|
expandedAudio.synthesizeBreechClank();
|
|
break;
|
|
case 'btn-dock-groan':
|
|
expandedAudio.synthesizeDockingGroan();
|
|
break;
|
|
case 'btn-tape-spooler':
|
|
expandedAudio.synthesizeTapeSpooler();
|
|
break;
|
|
case 'btn-teletype':
|
|
expandedAudio.synthesizeTeletypeChatterPhrase();
|
|
break;
|
|
case 'btn-zocalo-chatter':
|
|
if (expandedAudio.zocaloChatterActive) expandedAudio.stopZocaloChatter();
|
|
else expandedAudio.startZocaloChatter();
|
|
break;
|
|
case 'btn-hetero-drone':
|
|
if (expandedAudio.heterodyneDroneActive) expandedAudio.stopHeterodyneDrone();
|
|
else expandedAudio.startHeterodyneDrone();
|
|
break;
|
|
|
|
default:
|
|
telemetry.synthesizeLCARSSingleChirp();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 9. Sleep Timer
|
|
timerButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const minutes = parseInt(btn.getAttribute('data-minutes'));
|
|
if (minutes === 0) {
|
|
audioManager.stopSleepTimer();
|
|
timerDisplay.textContent = 'TIMER INACTIVE (CONTINUOUS)';
|
|
} else {
|
|
audioManager.startSleepTimer(
|
|
minutes,
|
|
(secondsLeft) => {
|
|
const m = Math.floor(secondsLeft / 60);
|
|
const s = secondsLeft % 60;
|
|
timerDisplay.textContent = `SLEEP TIMER: ${m}:${s < 10 ? '0' : ''}${s}`;
|
|
},
|
|
() => {
|
|
timerDisplay.textContent = 'SLEEP TIMER COMPLETED';
|
|
if (isPlaying) togglePlay();
|
|
}
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
// =========================================================================
|
|
// OBSERVATION MODE
|
|
// One passive full-screen view with theme-specific inline SVG displays.
|
|
// Deliberately exits only on an actual click/tap (or Escape).
|
|
// =========================================================================
|
|
const btnObservation = document.getElementById('btn-observation');
|
|
const observationOverlay = document.getElementById('observation-overlay');
|
|
const observationStage = document.getElementById('observation-stage');
|
|
const observationSimLayer = document.getElementById('observation-sim-layer');
|
|
const observationUniverse = document.getElementById('observation-universe');
|
|
const observationProfile = document.getElementById('observation-profile');
|
|
const observationStatus = document.getElementById('observation-status');
|
|
const observationEventLayer = document.getElementById('observation-event-layer');
|
|
const sliderObservationActivity = document.getElementById('slider-observation-activity');
|
|
const valObservationActivity = document.getElementById('val-observation-activity');
|
|
const toggleObservationHud = document.getElementById('toggle-observation-hud');
|
|
const valObservationHud = document.getElementById('val-observation-hud');
|
|
const observationChrome = observationOverlay.querySelector('.observation-chrome');
|
|
const observationReturn = observationOverlay.querySelector('.observation-return');
|
|
|
|
let observationActive = false;
|
|
let observationActivity = 0.60;
|
|
window.observationActivity = observationActivity;
|
|
let observationAmbientTimer = null;
|
|
let observationTransientSerial = 0;
|
|
|
|
function obsClamp(v, lo, hi) {
|
|
return Math.max(lo, Math.min(hi, v));
|
|
}
|
|
|
|
function obsRand(min, max) {
|
|
return min + Math.random() * (max - min);
|
|
}
|
|
|
|
function obsInt(min, max) {
|
|
return Math.floor(obsRand(min, max + 1));
|
|
}
|
|
|
|
function obsPick(items) {
|
|
return items[Math.floor(Math.random() * items.length)];
|
|
}
|
|
|
|
function obsHex(n, width = 3) {
|
|
return Math.floor(n).toString(16).toUpperCase().padStart(width, '0');
|
|
}
|
|
|
|
|
|
// =========================================================================
|
|
// PERSISTENT OBSERVATION SCENE ENGINE
|
|
// Original visual worlds, not franchise reproductions. Persistent entities
|
|
// use simple stateful motion rather than one-shot CSS decorations.
|
|
// =========================================================================
|
|
let observationSceneRaf = null;
|
|
let observationSceneLast = 0;
|
|
let observationSceneEntities = [];
|
|
let observationSceneBoostUntil = 0;
|
|
let observationSplineMorphs = [];
|
|
|
|
function obsEaseSmooth(t) {
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
function obsEaseInCubic(t) {
|
|
return t * t * t;
|
|
}
|
|
|
|
function obsEaseOutCubic(t) {
|
|
return 1 - Math.pow(1 - t, 3);
|
|
}
|
|
|
|
function obsBezierPoint(t, p0, p1, p2, p3) {
|
|
const u = 1 - t;
|
|
const tt = t * t;
|
|
const uu = u * u;
|
|
const uuu = uu * u;
|
|
const ttt = tt * t;
|
|
return {
|
|
x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x,
|
|
y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y
|
|
};
|
|
}
|
|
|
|
function obsBezierDerivative(t, p0, p1, p2, p3) {
|
|
const u = 1 - t;
|
|
return {
|
|
x: 3*u*u*(p1.x-p0.x) + 6*u*t*(p2.x-p1.x) + 3*t*t*(p3.x-p2.x),
|
|
y: 3*u*u*(p1.y-p0.y) + 6*u*t*(p2.y-p1.y) + 3*t*t*(p3.y-p2.y)
|
|
};
|
|
}
|
|
|
|
|
|
// =========================================================================
|
|
// LONG-LIVED SPLINE MORPH ENGINE
|
|
// Applies only to persistent pathwork in the main observation stage.
|
|
// The goal is subtle recalculation/breathing, not chaotic wriggling.
|
|
// =========================================================================
|
|
|
|
function obsThemeSplineScale(theme) {
|
|
return ({
|
|
starfleet: 0.60,
|
|
whoniverse: 1.05,
|
|
industrial: 0.48,
|
|
bioships: 1.28,
|
|
retrofuture: 0.72,
|
|
military: 0.44,
|
|
deepspace: 0.34,
|
|
outlaw: 0.86,
|
|
spacestations: 0.50,
|
|
comedy: 0.90
|
|
})[theme] || 0.65;
|
|
}
|
|
|
|
function obsTokenizePath(d) {
|
|
return (d.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:e[-+]?\d+)?/g) || []);
|
|
}
|
|
|
|
function obsParsePathSegments(d) {
|
|
const tokens = obsTokenizePath(d);
|
|
const segments = [];
|
|
let i = 0;
|
|
while (i < tokens.length) {
|
|
const cmd = tokens[i++];
|
|
if (!/^[A-Za-z]$/.test(cmd)) break;
|
|
const upper = cmd.toUpperCase();
|
|
|
|
let count = 0;
|
|
if (upper === 'M' || upper === 'L' || upper === 'T') count = 2;
|
|
else if (upper === 'Q' || upper === 'S') count = 4;
|
|
else if (upper === 'C') count = 6;
|
|
else if (upper === 'H' || upper === 'V') count = 1;
|
|
else if (upper === 'Z') count = 0;
|
|
else count = 0;
|
|
|
|
if (upper == 'Z') {
|
|
segments.push({ cmd, values: [] });
|
|
continue;
|
|
}
|
|
|
|
const values = [];
|
|
while (i < tokens.length && !/^[A-Za-z]$/.test(tokens[i])) {
|
|
values.push(Number(tokens[i++]));
|
|
}
|
|
if (!values.length) {
|
|
segments.push({ cmd, values: [] });
|
|
continue;
|
|
}
|
|
|
|
if (count == 0) {
|
|
segments.push({ cmd, values });
|
|
continue;
|
|
}
|
|
|
|
// Split repeated coordinate groups into separate segments. For repeated M,
|
|
// SVG treats the subsequent groups as implicit L commands.
|
|
for (let start = 0, segIdx = 0; start < values.length; start += count, segIdx++) {
|
|
const slice = values.slice(start, start + count);
|
|
if (slice.length < count) break;
|
|
const segCmd = (upper === 'M' && segIdx > 0) ? (cmd === upper ? 'L' : 'l') : cmd;
|
|
segments.push({ cmd: segCmd, values: slice });
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
function obsBuildPathFromSegments(segments) {
|
|
return segments.map(seg => {
|
|
if (!seg.values || !seg.values.length) return seg.cmd;
|
|
const nums = seg.values.map(v => {
|
|
const n = Math.abs(v) < 1e-6 ? 0 : v;
|
|
return Number(n.toFixed(2)).toString();
|
|
}).join(' ');
|
|
return `${seg.cmd}${nums}`;
|
|
}).join(' ');
|
|
}
|
|
|
|
function obsCreateSplineMorphState(pathEl) {
|
|
if (!pathEl) return null;
|
|
const d = pathEl.getAttribute('d');
|
|
if (!d || !/[CQST]/i.test(d)) return null;
|
|
|
|
const segments = obsParsePathSegments(d);
|
|
if (!segments.length) return null;
|
|
|
|
const bbox = pathEl.getBBox();
|
|
const width = Math.max(80, bbox.width || 80);
|
|
const height = Math.max(80, bbox.height || 80);
|
|
const routeLike = pathEl.classList.contains('obs-route-wander');
|
|
const themeScale = obsThemeSplineScale(activeUniverseId);
|
|
const kindScale = routeLike ? 1.0 : 0.7;
|
|
|
|
let drawable = segments
|
|
.map((seg, idx) => ({ idx, upper: seg.cmd.toUpperCase(), count: seg.values.length }))
|
|
.filter(item => ['Q', 'T', 'S', 'C'].includes(item.upper));
|
|
const lastDrawableIdx = drawable.length ? drawable[drawable.length - 1].idx : -1;
|
|
|
|
const ampFractions = [];
|
|
segments.forEach((seg, segIdx) => {
|
|
const upper = seg.cmd.toUpperCase();
|
|
let frac = [];
|
|
|
|
if (upper === 'M' || upper === 'L') {
|
|
frac = new Array(seg.values.length).fill(0);
|
|
} else if (upper === 'C') {
|
|
frac = [1, 1, 1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18];
|
|
} else if (upper === 'S') {
|
|
frac = [1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18];
|
|
} else if (upper === 'Q') {
|
|
frac = [0.92, 0.92, segIdx === lastDrawableIdx ? 0 : 0.15, segIdx === lastDrawableIdx ? 0 : 0.15];
|
|
} else if (upper === 'T') {
|
|
frac = [segIdx === lastDrawableIdx ? 0 : 0.14, segIdx === lastDrawableIdx ? 0 : 0.14];
|
|
} else {
|
|
frac = new Array(seg.values.length).fill(0);
|
|
}
|
|
ampFractions.push(frac);
|
|
});
|
|
|
|
const offsets = segments.map(seg => new Array(seg.values.length).fill(0));
|
|
const targets = segments.map(seg => new Array(seg.values.length).fill(0));
|
|
|
|
function amplitudeFor(segIndex, valueIndex) {
|
|
const seg = segments[segIndex];
|
|
const frac = (ampFractions[segIndex] && ampFractions[segIndex][valueIndex]) || 0;
|
|
if (frac <= 0) return 0;
|
|
const upper = seg.cmd.toUpperCase();
|
|
const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y');
|
|
const span = axis === 'x' ? width : height;
|
|
const baseFrac = routeLike ? 0.038 : 0.026;
|
|
return span * baseFrac * frac * kindScale * themeScale;
|
|
}
|
|
|
|
return {
|
|
el: pathEl,
|
|
baseD: d,
|
|
segments,
|
|
ampFractions,
|
|
offsets,
|
|
targets,
|
|
nextRetargetAt: performance.now() + obsRand(2200, 7800),
|
|
lastRetargetAt: performance.now(),
|
|
width,
|
|
height,
|
|
routeLike,
|
|
targetMultiplier: 1
|
|
};
|
|
}
|
|
|
|
function initializeObservationSplineMorphs() {
|
|
observationSplineMorphs = [];
|
|
if (!observationStage) return;
|
|
|
|
const paths = observationStage.querySelectorAll('path.obs-dashflow, path.obs-route-wander');
|
|
paths.forEach(pathEl => {
|
|
const state = obsCreateSplineMorphState(pathEl);
|
|
if (state) observationSplineMorphs.push(state);
|
|
});
|
|
}
|
|
|
|
function stopObservationSplineMorphs() {
|
|
observationSplineMorphs.forEach(state => {
|
|
if (state.el && state.el.isConnected) state.el.setAttribute('d', state.baseD);
|
|
});
|
|
observationSplineMorphs = [];
|
|
}
|
|
|
|
function retargetObservationSpline(state, now, forceScale = 1) {
|
|
const activity = observationActivity;
|
|
state.targetMultiplier = forceScale;
|
|
state.lastRetargetAt = now;
|
|
|
|
state.segments.forEach((seg, segIndex) => {
|
|
seg.values.forEach((_, valueIndex) => {
|
|
const amp = (function() {
|
|
const frac = (state.ampFractions[segIndex] && state.ampFractions[segIndex][valueIndex]) || 0;
|
|
if (frac <= 0) return 0;
|
|
const upper = seg.cmd.toUpperCase();
|
|
const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y');
|
|
const span = axis === 'x' ? state.width : state.height;
|
|
const baseFrac = state.routeLike ? 0.038 : 0.026;
|
|
return span * baseFrac * frac * (state.routeLike ? 1.0 : 0.7) * obsThemeSplineScale(activeUniverseId) * forceScale;
|
|
})();
|
|
|
|
if (amp <= 0) {
|
|
state.targets[segIndex][valueIndex] = 0;
|
|
return;
|
|
}
|
|
|
|
const bias = state.routeLike ? 1.0 : 0.75;
|
|
state.targets[segIndex][valueIndex] = obsRand(-amp * bias, amp * bias);
|
|
});
|
|
});
|
|
|
|
const slow = state.routeLike ? 32000 : 42000;
|
|
const fast = state.routeLike ? 8500 : 14000;
|
|
const next = slow - (slow - fast) * Math.pow(activity, 0.82);
|
|
state.nextRetargetAt = now + next * obsRand(0.70, 1.35);
|
|
}
|
|
|
|
function updateObservationSplineMorphs(now, speedScale) {
|
|
if (!observationSplineMorphs.length) return;
|
|
|
|
const themeScale = obsThemeSplineScale(activeUniverseId);
|
|
observationSplineMorphs = observationSplineMorphs.filter(state => state.el && state.el.isConnected);
|
|
|
|
observationSplineMorphs.forEach(state => {
|
|
if (now >= state.nextRetargetAt) {
|
|
retargetObservationSpline(state, now, 1);
|
|
}
|
|
|
|
const follow = (0.012 + observationActivity * 0.030 + Math.max(0, speedScale - 0.4) * 0.006) * themeScale;
|
|
|
|
state.segments.forEach((seg, segIndex) => {
|
|
seg.values.forEach((baseValue, valueIndex) => {
|
|
const current = state.offsets[segIndex][valueIndex];
|
|
const target = state.targets[segIndex][valueIndex];
|
|
state.offsets[segIndex][valueIndex] = current + (target - current) * follow;
|
|
});
|
|
});
|
|
|
|
const morphed = state.segments.map((seg, segIndex) => ({
|
|
cmd: seg.cmd,
|
|
values: seg.values.map((baseValue, valueIndex) => baseValue + state.offsets[segIndex][valueIndex])
|
|
}));
|
|
|
|
state.el.setAttribute('d', obsBuildPathFromSegments(morphed));
|
|
});
|
|
}
|
|
|
|
function pulseObservationSplineMorphs() {
|
|
if (!observationActive || !observationSplineMorphs.length) return;
|
|
|
|
const candidates = observationSplineMorphs.filter(state => state.el && state.el.isConnected);
|
|
if (!candidates.length) return;
|
|
|
|
const count = observationActivity > 0.72 && Math.random() < observationActivity ? 2 : 1;
|
|
const shuffled = [...candidates].sort(() => Math.random() - 0.5);
|
|
|
|
for (let i = 0; i < Math.min(count, shuffled.length); i++) {
|
|
retargetObservationSpline(shuffled[i], performance.now(), 1.22 + observationActivity * 0.38);
|
|
}
|
|
}
|
|
|
|
|
|
function obsSceneShip(kind='scout', color='#dbeafe', accent='#38bdf8') {
|
|
if (kind === 'freighter') {
|
|
return `
|
|
<g>
|
|
<path d="M-42 -13H23L43 0L23 13H-42L-55 5V-5Z" fill="${color}" opacity=".82"/>
|
|
<rect x="-26" y="-18" width="28" height="36" rx="5" fill="${accent}" opacity=".22"/>
|
|
<path d="M-50 -5H-63M-50 5H-63" stroke="${accent}" stroke-width="4" opacity=".9"/>
|
|
<circle cx="-66" cy="-5" r="2.5" fill="#f97316"/>
|
|
<circle cx="-66" cy="5" r="2.5" fill="#f97316"/>
|
|
</g>`;
|
|
}
|
|
if (kind === 'shuttle') {
|
|
return `
|
|
<g>
|
|
<path d="M-25 -10L19 -6L31 0L19 6L-25 10L-34 0Z" fill="${color}" opacity=".88"/>
|
|
<path d="M-17 -13L2 -7M-17 13L2 7" stroke="${accent}" stroke-width="3" opacity=".65"/>
|
|
<path d="M-35 0H-48" stroke="${accent}" stroke-width="3" opacity=".7"/>
|
|
</g>`;
|
|
}
|
|
if (kind === 'tug') {
|
|
return `
|
|
<g>
|
|
<rect x="-26" y="-13" width="42" height="26" rx="7" fill="${color}" opacity=".82"/>
|
|
<path d="M16 -8L34 0L16 8Z" fill="${accent}" opacity=".6"/>
|
|
<path d="M-26 -7H-40M-26 7H-40" stroke="#f97316" stroke-width="3"/>
|
|
</g>`;
|
|
}
|
|
return `
|
|
<g>
|
|
<path d="M-30 -10L30 0L-30 10L-17 0Z" fill="${color}" opacity=".9"/>
|
|
<path d="M-10 -13L10 0L-10 13" fill="none" stroke="${accent}" stroke-width="3" opacity=".65"/>
|
|
<path d="M-33 0H-48" stroke="${accent}" stroke-width="3" opacity=".65"/>
|
|
</g>`;
|
|
}
|
|
|
|
function obsSceneDefs() {
|
|
return `
|
|
<defs>
|
|
<filter id="obsSceneGlow">
|
|
<feGaussianBlur stdDeviation="4" result="blur"/>
|
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
<clipPath id="obsSceneStationClip">
|
|
<rect x="220" y="140" width="1160" height="540" rx="28"/>
|
|
</clipPath>
|
|
</defs>`;
|
|
}
|
|
|
|
function obsFlightMarkup(id, shipMarkup, cfg) {
|
|
return `
|
|
<g id="${id}" class="obs-scene-entity"
|
|
data-obs-kind="flight"
|
|
data-x0="${cfg.x0}" data-y0="${cfg.y0}"
|
|
data-x1="${cfg.x1}" data-y1="${cfg.y1}"
|
|
data-x2="${cfg.x2}" data-y2="${cfg.y2}"
|
|
data-x3="${cfg.x3}" data-y3="${cfg.y3}"
|
|
data-duration="${cfg.duration}"
|
|
data-delay="${cfg.delay || 0}"
|
|
data-mode="${cfg.mode || 'cruise'}"
|
|
data-scale0="${cfg.scale0 ?? 1}"
|
|
data-scale1="${cfg.scale1 ?? 1}"
|
|
data-minactivity="${cfg.minActivity ?? 0}"
|
|
data-opacity="${cfg.opacity ?? .8}">
|
|
${shipMarkup}
|
|
</g>`;
|
|
}
|
|
|
|
function obsOrbitMarkup(id, body, cfg) {
|
|
return `
|
|
<g id="${id}" class="obs-scene-entity"
|
|
data-obs-kind="orbit"
|
|
data-cx="${cfg.cx}" data-cy="${cfg.cy}"
|
|
data-rx="${cfg.rx}" data-ry="${cfg.ry}"
|
|
data-duration="${cfg.duration}"
|
|
data-phase="${cfg.phase || 0}"
|
|
data-scale0="${cfg.scale0 ?? 1}"
|
|
data-scale1="${cfg.scale1 ?? 1}"
|
|
data-minactivity="${cfg.minActivity ?? 0}"
|
|
data-opacity="${cfg.opacity ?? .7}">
|
|
${body}
|
|
</g>`;
|
|
}
|
|
|
|
function obsFloatMarkup(id, body, cfg) {
|
|
return `
|
|
<g id="${id}" class="obs-scene-entity"
|
|
data-obs-kind="float"
|
|
data-cx="${cfg.cx}" data-cy="${cfg.cy}"
|
|
data-ampx="${cfg.ampX || 0}" data-ampy="${cfg.ampY || 0}"
|
|
data-duration="${cfg.duration}"
|
|
data-phase="${cfg.phase || 0}"
|
|
data-minactivity="${cfg.minActivity ?? 0}"
|
|
data-opacity="${cfg.opacity ?? .7}">
|
|
${body}
|
|
</g>`;
|
|
}
|
|
|
|
function buildObservationSceneMarkup(theme) {
|
|
const defs = obsSceneDefs();
|
|
|
|
if (theme === 'starfleet') {
|
|
return `${defs}
|
|
<g opacity=".82">
|
|
${obsFlightMarkup('sf-flight-1', obsSceneShip('scout','#e0f2fe','#38bdf8'), {
|
|
x0:410,y0:640,x1:520,y1:520,x2:880,y2:245,x3:1110,y3:330,duration:26000,delay:900,mode:'arrival',scale0:.35,scale1:.75,minActivity:.15
|
|
})}
|
|
${obsFlightMarkup('sf-flight-2', obsSceneShip('shuttle','#fde68a','#f97316'), {
|
|
x0:1190,y0:600,x1:1030,y1:500,x2:680,y2:420,x3:510,y3:260,duration:31000,delay:4600,mode:'cruise',scale0:.28,scale1:.55,minActivity:.35
|
|
})}
|
|
${obsOrbitMarkup('sf-orbit-1', `<circle r="7" fill="#7dd3fc"/><circle r="24" fill="none" stroke="#38bdf8" opacity=".36"/>`, {
|
|
cx:800,cy:455,rx:245,ry:155,duration:24000,phase:.2,scale0:.65,scale1:1.05,minActivity:.05
|
|
})}
|
|
${obsOrbitMarkup('sf-orbit-2', `<rect x="-6" y="-6" width="12" height="12" fill="#f97316" transform="rotate(45)"/>`, {
|
|
cx:800,cy:455,rx:165,ry:245,duration:39000,phase:.62,scale0:.5,scale1:.9,minActivity:.5
|
|
})}
|
|
</g>`;
|
|
}
|
|
|
|
if (theme === 'whoniverse') {
|
|
const glyph = (char,color,size) => `<text x="0" y="0" text-anchor="middle" dominant-baseline="middle" fill="${color}" font-size="${size}" font-family="monospace">${char}</text>`;
|
|
return `${defs}
|
|
${obsOrbitMarkup('who-glyph-1', glyph('◎','#00e5ff',38), {cx:800,cy:455,rx:335,ry:160,duration:23000,phase:.1,scale0:.55,scale1:1.2,minActivity:.05})}
|
|
${obsOrbitMarkup('who-glyph-2', glyph('∆','#d4af37',30), {cx:800,cy:455,rx:255,ry:280,duration:31000,phase:.42,scale0:.5,scale1:1,minActivity:.2})}
|
|
${obsOrbitMarkup('who-glyph-3', glyph('∞','#00e5ff',28), {cx:800,cy:455,rx:420,ry:110,duration:39000,phase:.73,scale0:.4,scale1:1.15,minActivity:.45})}
|
|
${obsFloatMarkup('who-fragment-1', `<path d="M-55 0Q0-42 55 0Q0 42-55 0Z" fill="none" stroke="#00e5ff" stroke-width="3" opacity=".45"/>`,
|
|
{cx:480,cy:520,ampX:95,ampY:75,duration:17000,phase:.35,minActivity:.25})}
|
|
${obsFloatMarkup('who-fragment-2', `<path d="M-38-38L38 38M38-38L-38 38" stroke="#d4af37" stroke-width="3" opacity=".42"/>`,
|
|
{cx:1110,cy:330,ampX:70,ampY:110,duration:21000,phase:.8,minActivity:.55})}`;
|
|
}
|
|
|
|
if (theme === 'industrial') {
|
|
// v13co: the sim layer sits ABOVE the stage (z-index 2 vs 1), so anything
|
|
// placed here draws over the camera console. Every entity is therefore
|
|
// parented to a clip matching one feed's picture area. The v12 entities
|
|
// (a wheeled cart, a crane hook, a fan and two steam blobs) were free
|
|
// floating in screen space, which is what made the smoke look detached
|
|
// and put a rolling "cart" across the cargo bay. They are replaced by
|
|
// motion that belongs to a specific feed.
|
|
const feedClip = (id, x, y) =>
|
|
`<clipPath id="${id}"><rect x="${x}" y="${y}" width="485" height="189"/></clipPath>`;
|
|
return `${defs}
|
|
<defs>
|
|
${feedClip('indSimCam1', 290, 259)}
|
|
${feedClip('indSimCam2', 825, 259)}
|
|
${feedClip('indSimCam3', 290, 483)}
|
|
${feedClip('indSimCam4', 825, 483)}
|
|
</defs>
|
|
<g clip-path="url(#indSimCam1)" opacity=".7">
|
|
${obsFloatMarkup('ind-bay-drone', `
|
|
<g>
|
|
<rect x="-9" y="-5" width="18" height="10" rx="3" fill="#2b2118" stroke="#7c4a16" stroke-width="1.2"/>
|
|
<circle cx="0" cy="7" r="3" fill="#ffd08a" class="obs-scene-flicker"/>
|
|
<ellipse cx="0" cy="24" rx="14" ry="17" fill="#ffb347" opacity=".045"/>
|
|
</g>`, {cx:532,cy:420,ampX:186,ampY:11,duration:31000,phase:.15,minActivity:.2,opacity:.66})}
|
|
</g>
|
|
<g clip-path="url(#indSimCam2)" opacity=".62">
|
|
${obsFloatMarkup('ind-ember-1', `<circle r="2.2" fill="#fdba74"/>`,
|
|
{cx:1035,cy:352,ampX:38,ampY:52,duration:13000,phase:.2,minActivity:.25,opacity:.6})}
|
|
${obsFloatMarkup('ind-ember-2', `<circle r="1.6" fill="#fb923c"/>`,
|
|
{cx:1092,cy:320,ampX:46,ampY:44,duration:17000,phase:.62,minActivity:.45,opacity:.5})}
|
|
${obsFloatMarkup('ind-ember-3', `<circle r="2.6" fill="#fed7aa"/>`,
|
|
{cx:1160,cy:372,ampX:30,ampY:40,duration:21000,phase:.85,minActivity:.6,opacity:.42})}
|
|
</g>
|
|
<g clip-path="url(#indSimCam3)" opacity=".5">
|
|
${obsFloatMarkup('ind-lock-tell', `
|
|
<g><rect x="-16" y="-2" width="32" height="4" rx="2" fill="#38bdf8" opacity=".5"/></g>`,
|
|
{cx:532,cy:640,ampX:0,ampY:13,duration:23000,phase:.3,minActivity:.5,opacity:.4})}
|
|
</g>
|
|
<g clip-path="url(#indSimCam4)" opacity=".6">
|
|
${obsFloatMarkup('ind-corridor-lamp', `
|
|
<g><ellipse rx="26" ry="9" fill="#ffb347" opacity=".1"/><circle r="2.4" fill="#ffe0b0"/></g>`,
|
|
{cx:1067,cy:600,ampX:118,ampY:26,duration:27000,phase:.4,minActivity:.3,opacity:.55})}
|
|
</g>`;
|
|
}
|
|
|
|
if (theme === 'bioships') {
|
|
const particle = (color,r=5) => `<circle r="${r}" fill="${color}" class="obs-scene-soft"/>`;
|
|
return `${defs}
|
|
${obsFlightMarkup('bio-flow-1', particle('#a7f3d0',5), {x0:300,y0:520,x1:520,y1:220,x2:760,y2:680,x3:1110,y3:350,duration:15000,delay:0,mode:'cruise',scale0:.7,scale1:1,minActivity:.05,opacity:.7})}
|
|
${obsFlightMarkup('bio-flow-2', particle('#10b981',4), {x0:1240,y0:610,x1:970,y1:430,x2:880,y2:720,x3:480,y3:390,duration:18000,delay:1600,mode:'cruise',scale0:.5,scale1:.9,minActivity:.15,opacity:.65})}
|
|
${obsFlightMarkup('bio-flow-3', particle('#8b5cf6',6), {x0:340,y0:290,x1:620,y1:570,x2:920,y2:180,x3:1300,y3:510,duration:22000,delay:4200,mode:'cruise',scale0:.55,scale1:1.05,minActivity:.35,opacity:.58})}
|
|
${obsOrbitMarkup('bio-node-1', `<circle r="14" fill="#10b981" opacity=".3"/><circle r="5" fill="#a7f3d0"/>`, {cx:800,cy:470,rx:210,ry:125,duration:19000,phase:.3,scale0:.65,scale1:1.2,minActivity:.3})}
|
|
${obsFloatMarkup('bio-membrane', `<path d="M-120 0Q-60-70 0-35Q60-70 120 0Q60 70 0 35Q-60 70-120 0Z" fill="#8b5cf6" opacity=".08" stroke="#10b981" stroke-width="4" class="obs-scene-breathe"/>`,
|
|
{cx:800,cy:470,ampX:26,ampY:18,duration:13000,phase:.1,minActivity:.55,opacity:.6})}`;
|
|
}
|
|
|
|
if (theme === 'retrofuture') {
|
|
const vectorShip = `<path d="M-28-11L30 0L-28 11L-12 0Z" fill="none" stroke="#86efac" stroke-width="3"/><circle r="3" fill="#22c55e"/>`;
|
|
return `${defs}
|
|
${obsFlightMarkup('retro-v1', vectorShip, {x0:350,y0:610,x1:510,y1:430,x2:950,y2:330,x3:1210,y3:500,duration:25000,delay:0,mode:'cruise',scale0:.5,scale1:.8,minActivity:.1,opacity:.65})}
|
|
${obsFlightMarkup('retro-v2', vectorShip, {x0:1180,y0:260,x1:1010,y1:570,x2:610,y2:610,x3:430,y3:330,duration:33000,delay:4300,mode:'cruise',scale0:.35,scale1:.65,minActivity:.45,opacity:.52})}
|
|
${obsOrbitMarkup('retro-blip', `<circle r="5" fill="#86efac"/><circle r="25" fill="none" stroke="#22c55e" opacity=".25"/>`, {cx:800,cy:460,rx:235,ry:235,duration:30000,phase:.15,scale0:.55,scale1:.95,minActivity:.05})}
|
|
${obsFloatMarkup('retro-liss', `<path d="M-100 0C-50-95 50 95 100 0C50-95-50 95-100 0Z" fill="none" stroke="#22c55e" stroke-width="2.5" opacity=".3"/>`,
|
|
{cx:800,cy:460,ampX:45,ampY:25,duration:14000,phase:.42,minActivity:.7,opacity:.5})}`;
|
|
}
|
|
|
|
if (theme === 'military') {
|
|
const tri = `<path d="M0-15L14 14H-14Z" fill="#eab308" opacity=".75"/>`;
|
|
return `${defs}
|
|
${obsFlightMarkup('mil-form', `
|
|
<g>
|
|
${tri}
|
|
<g transform="translate(-58 38) scale(.78)">${tri}</g>
|
|
<g transform="translate(58 38) scale(.78)">${tri}</g>
|
|
<g transform="translate(0 82) scale(.68)">${tri}</g>
|
|
</g>`, {x0:390,y0:620,x1:550,y1:410,x2:900,y2:290,x3:1160,y3:390,duration:28000,delay:800,mode:'cruise',scale0:.4,scale1:.7,minActivity:.15,opacity:.65})}
|
|
${obsFlightMarkup('mil-hostile', `<path d="M-18-14L20 0L-18 14L-8 0Z" fill="#ef4444" opacity=".72"/>`,
|
|
{x0:1190,y0:650,x1:1020,y1:520,x2:660,y2:430,x3:470,y3:240,duration:22000,delay:6200,mode:'arrival',scale0:.35,scale1:.6,minActivity:.45,opacity:.58})}
|
|
${obsOrbitMarkup('mil-cap', `<rect x="-8" y="-8" width="16" height="16" fill="none" stroke="#eab308" stroke-width="3" transform="rotate(45)"/>`,
|
|
{cx:800,cy:470,rx:300,ry:185,duration:41000,phase:.5,scale0:.45,scale1:.75,minActivity:.65,opacity:.52})}`;
|
|
}
|
|
|
|
if (theme === 'deepspace') {
|
|
return `${defs}
|
|
${obsFlightMarkup('deep-ship', obsSceneShip('shuttle','#e0e7ff','#818cf8'), {
|
|
x0:230,y0:570,x1:520,y1:500,x2:900,y2:350,x3:1370,y3:300,duration:52000,delay:0,mode:'cruise',scale0:.12,scale1:.28,minActivity:.2,opacity:.6
|
|
})}
|
|
${obsFlightMarkup('deep-comet', `<g><circle r="6" fill="#fff"/><path d="M-12 0H-170" stroke="#c7d2fe" stroke-width="3" opacity=".6"/></g>`, {
|
|
x0:180,y0:700,x1:480,y1:520,x2:930,y2:230,x3:1490,y3:150,duration:36000,delay:7500,mode:'cruise',scale0:.55,scale1:.9,minActivity:.5,opacity:.52
|
|
})}
|
|
${obsFloatMarkup('deep-planet', `
|
|
<g>
|
|
<circle r="118" fill="#172554" opacity=".5"/>
|
|
<path d="M-110 20A118 118 0 0 1 106-36" fill="none" stroke="#6366f1" stroke-width="4" opacity=".45"/>
|
|
<ellipse rx="160" ry="35" fill="none" stroke="#38bdf8" stroke-width="3" opacity=".18"/>
|
|
</g>`, {cx:1200,cy:590,ampX:80,ampY:24,duration:95000,phase:.22,minActivity:.05,opacity:.55})}`;
|
|
}
|
|
|
|
if (theme === 'outlaw') {
|
|
return `${defs}
|
|
${obsFlightMarkup('out-runner', `
|
|
<g>
|
|
<path d="M-38-12H18L38 0L18 12H-38L-50 4V-4Z" fill="#fbcfe8" opacity=".7"/>
|
|
<path d="M-38-15L-5-9M-38 15L-5 9" stroke="#f59e0b" stroke-width="3"/>
|
|
<path d="M-52-5H-72M-52 5H-72" stroke="#ec4899" stroke-width="4" opacity=".65"/>
|
|
</g>`, {x0:300,y0:610,x1:480,y1:430,x2:940,y2:300,x3:1290,y3:410,duration:31000,delay:1400,mode:'cruise',scale0:.35,scale1:.75,minActivity:.1,opacity:.62})}
|
|
${obsFlightMarkup('out-pursuer', obsSceneShip('scout','#f9a8d4','#8b5cf6'), {
|
|
x0:1280,y0:590,x1:1070,y1:520,x2:730,y2:520,x3:420,y3:310,duration:27000,delay:7900,mode:'arrival',scale0:.25,scale1:.52,minActivity:.5,opacity:.5
|
|
})}
|
|
${obsFloatMarkup('out-needle', `
|
|
<g>
|
|
<path d="M-70 45A85 85 0 0 1 70 45" fill="none" stroke="#f59e0b" stroke-width="5" opacity=".42"/>
|
|
<path d="M0 45L52-4" stroke="#ec4899" stroke-width="4"/>
|
|
<circle cy="45" r="8" fill="#8b5cf6"/>
|
|
</g>`, {cx:1250,cy:535,ampX:8,ampY:4,duration:9000,phase:.2,minActivity:.25,opacity:.58})}`;
|
|
}
|
|
|
|
if (theme === 'spacestations') {
|
|
const clipOpen = `<g clip-path="url(#obsSceneStationClip)">`;
|
|
const clipClose = `</g>`;
|
|
return `${defs}${clipOpen}
|
|
${obsFlightMarkup('st-arrival', obsSceneShip('shuttle','#e0f2fe','#38bdf8'), {
|
|
x0:270,y0:520,x1:500,y1:455,x2:920,y2:380,x3:1195,y3:415,duration:36000,delay:0,mode:'arrival',scale0:.16,scale1:.62,minActivity:.05,opacity:.75
|
|
})}
|
|
${obsFlightMarkup('st-depart', obsSceneShip('tug','#bae6fd','#f97316'), {
|
|
x0:1180,y0:485,x1:1030,y1:430,x2:650,y2:310,x3:260,y3:255,duration:30000,delay:8400,mode:'depart',scale0:.58,scale1:.16,minActivity:.2,opacity:.68
|
|
})}
|
|
${obsFlightMarkup('st-freighter', obsSceneShip('freighter','#cbd5e1','#0ea5e9'), {
|
|
x0:185,y0:255,x1:530,y1:210,x2:920,y2:230,x3:1420,y3:300,duration:52000,delay:3800,mode:'cruise',scale0:.26,scale1:.42,minActivity:.4,opacity:.55
|
|
})}
|
|
${obsOrbitMarkup('st-hold', obsSceneShip('shuttle','#7dd3fc','#fdba74'), {
|
|
cx:800,cy:410,rx:290,ry:145,duration:43000,phase:.18,scale0:.18,scale1:.32,minActivity:.62,opacity:.5
|
|
})}
|
|
${obsFlightMarkup('st-nearpass', obsSceneShip('freighter','#f1f5f9','#38bdf8'), {
|
|
x0:170,y0:650,x1:470,y1:565,x2:950,y2:535,x3:1450,y3:600,duration:42000,delay:14500,mode:'cruise',scale0:.42,scale1:.78,minActivity:.78,opacity:.48
|
|
})}
|
|
${clipClose}`;
|
|
}
|
|
|
|
if (theme === 'comedy') {
|
|
return `${defs}
|
|
${obsFlightMarkup('com-tour', `
|
|
<g>
|
|
<path d="M-36-12L26-8L42 0L26 8L-36 12L-48 0Z" fill="#cffafe" opacity=".75"/>
|
|
<circle cx="-8" cy="0" r="7" fill="#fbbf24" opacity=".7"/>
|
|
<path d="M-50 0H-72" stroke="#f43f5e" stroke-width="4"/>
|
|
</g>`, {x0:260,y0:600,x1:520,y1:255,x2:1030,y2:670,x3:1350,y3:330,duration:36000,delay:1200,mode:'cruise',scale0:.32,scale1:.62,minActivity:.08,opacity:.65})}
|
|
${obsOrbitMarkup('com-orbit', `<circle r="18" fill="#fbbf24" opacity=".25"/><text text-anchor="middle" y="6" fill="#67e8f9" font-size="20" font-family="monospace">?</text>`,
|
|
{cx:800,cy:445,rx:330,ry:160,duration:33000,phase:.35,scale0:.65,scale1:1.15,minActivity:.45,opacity:.58})}
|
|
${obsFloatMarkup('com-cube', `<rect x="-22" y="-22" width="44" height="44" rx="8" fill="none" stroke="#f43f5e" stroke-width="4" transform="rotate(14)"/>`,
|
|
{cx:1120,cy:570,ampX:90,ampY:70,duration:18000,phase:.7,minActivity:.7,opacity:.52})}`;
|
|
}
|
|
|
|
return defs;
|
|
}
|
|
|
|
function registerObservationSceneEntities() {
|
|
observationSceneEntities = [];
|
|
if (!observationSimLayer) return;
|
|
|
|
observationSimLayer.querySelectorAll('[data-obs-kind]').forEach(el => {
|
|
const d = el.dataset;
|
|
const sceneNow = performance.now();
|
|
const common = {
|
|
el,
|
|
kind: d.obsKind,
|
|
minActivity: Number(d.minactivity || 0),
|
|
baseOpacity: Number(d.opacity || .7),
|
|
phase: Number(d.phase || 0),
|
|
startedAt: sceneNow,
|
|
inactiveUntil: sceneNow
|
|
};
|
|
|
|
if (d.obsKind === 'flight') {
|
|
const firstStart = sceneNow + Number(d.delay || 0);
|
|
observationSceneEntities.push({
|
|
...common,
|
|
startedAt: firstStart,
|
|
inactiveUntil: firstStart,
|
|
p0:{x:Number(d.x0),y:Number(d.y0)},
|
|
p1:{x:Number(d.x1),y:Number(d.y1)},
|
|
p2:{x:Number(d.x2),y:Number(d.y2)},
|
|
p3:{x:Number(d.x3),y:Number(d.y3)},
|
|
duration:Number(d.duration || 30000),
|
|
mode:d.mode || 'cruise',
|
|
scale0:Number(d.scale0 || 1),
|
|
scale1:Number(d.scale1 || 1),
|
|
cycle:0
|
|
});
|
|
} else if (d.obsKind === 'orbit') {
|
|
observationSceneEntities.push({
|
|
...common,
|
|
cx:Number(d.cx),cy:Number(d.cy),
|
|
rx:Number(d.rx),ry:Number(d.ry),
|
|
duration:Number(d.duration || 30000),
|
|
scale0:Number(d.scale0 || 1),
|
|
scale1:Number(d.scale1 || 1)
|
|
});
|
|
} else if (d.obsKind === 'float') {
|
|
observationSceneEntities.push({
|
|
...common,
|
|
cx:Number(d.cx),cy:Number(d.cy),
|
|
ampX:Number(d.ampx || 0),ampY:Number(d.ampy || 0),
|
|
duration:Number(d.duration || 20000)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function observationSceneResetFlight(entity, now) {
|
|
entity.cycle += 1;
|
|
const restBase = 5500 - observationActivity * 3500;
|
|
entity.inactiveUntil = now + obsRand(restBase * .35, restBase * 1.35);
|
|
entity.startedAt = entity.inactiveUntil;
|
|
}
|
|
|
|
function updateObservationSceneEntity(entity, now, speedScale) {
|
|
const visibleByActivity = observationActivity + .001 >= entity.minActivity;
|
|
if (!visibleByActivity) {
|
|
entity.el.style.opacity = '0';
|
|
return;
|
|
}
|
|
|
|
if (entity.kind === 'flight') {
|
|
if (now < entity.inactiveUntil) {
|
|
entity.el.style.opacity = '0';
|
|
return;
|
|
}
|
|
|
|
const elapsed = (now - entity.startedAt) * speedScale;
|
|
let raw = elapsed / entity.duration;
|
|
|
|
if (raw >= 1) {
|
|
observationSceneResetFlight(entity, now);
|
|
entity.el.style.opacity = '0';
|
|
return;
|
|
}
|
|
|
|
raw = obsClamp(raw, 0, 1);
|
|
let t = raw;
|
|
if (entity.mode === 'arrival') t = obsEaseOutCubic(raw);
|
|
else if (entity.mode === 'depart') t = obsEaseInCubic(raw);
|
|
else t = obsEaseSmooth(raw);
|
|
|
|
const p = obsBezierPoint(t, entity.p0, entity.p1, entity.p2, entity.p3);
|
|
const v = obsBezierDerivative(t, entity.p0, entity.p1, entity.p2, entity.p3);
|
|
const angle = Math.atan2(v.y, v.x) * 180 / Math.PI;
|
|
const scale = entity.scale0 + (entity.scale1 - entity.scale0) * t;
|
|
|
|
const edgeFade = Math.min(1, raw / .08, (1-raw) / .08);
|
|
const opacity = entity.baseOpacity * obsClamp(edgeFade,0,1);
|
|
entity.el.style.opacity = String(opacity);
|
|
entity.el.setAttribute('transform', `translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) rotate(${angle.toFixed(2)}) scale(${scale.toFixed(3)})`);
|
|
return;
|
|
}
|
|
|
|
if (entity.kind === 'orbit') {
|
|
const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) % 1;
|
|
const a = cycle * Math.PI * 2;
|
|
const x = entity.cx + Math.cos(a) * entity.rx;
|
|
const y = entity.cy + Math.sin(a) * entity.ry;
|
|
const depth = (Math.sin(a) + 1) / 2;
|
|
const scale = entity.scale0 + (entity.scale1 - entity.scale0) * depth;
|
|
entity.el.style.opacity = String(entity.baseOpacity * (.55 + depth * .45));
|
|
entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${scale.toFixed(3)})`);
|
|
return;
|
|
}
|
|
|
|
if (entity.kind === 'float') {
|
|
const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) * Math.PI * 2;
|
|
const x = entity.cx + Math.sin(cycle) * entity.ampX;
|
|
const y = entity.cy + Math.cos(cycle * .73) * entity.ampY;
|
|
entity.el.style.opacity = String(entity.baseOpacity);
|
|
entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)})`);
|
|
}
|
|
}
|
|
|
|
function observationSceneFrame(now) {
|
|
observationSceneRaf = null;
|
|
if (!observationActive || !observationSimLayer) return;
|
|
|
|
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
const baseSpeed = reduceMotion ? .08 : (.32 + observationActivity * 1.18);
|
|
const boost = now < observationSceneBoostUntil ? .38 : 0;
|
|
const speedScale = baseSpeed + boost;
|
|
|
|
for (const entity of observationSceneEntities) {
|
|
updateObservationSceneEntity(entity, now, speedScale);
|
|
}
|
|
|
|
updateObservationSplineMorphs(now, speedScale);
|
|
|
|
observationSceneLast = now;
|
|
observationSceneRaf = requestAnimationFrame(observationSceneFrame);
|
|
}
|
|
|
|
function startObservationScene() {
|
|
stopObservationScene();
|
|
if (!observationSimLayer) return;
|
|
observationSimLayer.innerHTML = `<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice"
|
|
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${buildObservationSceneMarkup(activeUniverseId)}</svg>`;
|
|
registerObservationSceneEntities();
|
|
observationSceneLast = performance.now();
|
|
observationSceneRaf = requestAnimationFrame(observationSceneFrame);
|
|
}
|
|
|
|
function stopObservationScene() {
|
|
if (observationSceneRaf) {
|
|
cancelAnimationFrame(observationSceneRaf);
|
|
observationSceneRaf = null;
|
|
}
|
|
observationSceneEntities = [];
|
|
if (observationSimLayer) observationSimLayer.innerHTML = '';
|
|
}
|
|
|
|
function pulseObservationScene() {
|
|
if (!observationActive) return;
|
|
observationSceneBoostUntil = performance.now() + 1200 + observationActivity * 900;
|
|
|
|
const eligible = observationSceneEntities.filter(e => observationActivity + .001 >= e.minActivity);
|
|
if (!eligible.length) return;
|
|
const chosen = obsPick(eligible);
|
|
|
|
// Telemetry can wake a waiting scene object early without assigning the
|
|
// sound a literal fictional meaning.
|
|
if (chosen.kind === 'flight' && performance.now() < chosen.inactiveUntil && Math.random() < .55) {
|
|
const wakeNow = performance.now();
|
|
chosen.inactiveUntil = wakeNow;
|
|
chosen.startedAt = wakeNow;
|
|
}
|
|
}
|
|
|
|
|
|
function transientSvg(body) {
|
|
return `<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice"
|
|
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${body}</svg>`;
|
|
}
|
|
|
|
function spawnObservationTransient(body, lifetime = 4200) {
|
|
if (!observationActive || !observationEventLayer) return;
|
|
const node = document.createElement('div');
|
|
node.className = 'obs-transient';
|
|
node.dataset.serial = String(++observationTransientSerial);
|
|
node.style.setProperty('--obs-life', `${lifetime}ms`);
|
|
node.innerHTML = transientSvg(body);
|
|
observationEventLayer.appendChild(node);
|
|
|
|
const maxTransient = 5 + Math.round(observationActivity * 8);
|
|
while (observationEventLayer.children.length > maxTransient) {
|
|
observationEventLayer.firstElementChild.remove();
|
|
}
|
|
|
|
window.setTimeout(() => node.remove(), lifetime + 300);
|
|
}
|
|
|
|
function obsTextCard(x, y, title, line, color = 'var(--primary-accent)', width = 290) {
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<g style="transform-box:fill-box;transform-origin:center;animation:obs-transient-pop .7s ease-out both">
|
|
<rect width="${width}" height="82" rx="7" fill="rgba(0,0,0,.72)" stroke="${color}" stroke-width="2"/>
|
|
<rect x="0" y="0" width="7" height="82" fill="${color}" opacity=".85"/>
|
|
<text x="22" y="31" fill="${color}" font-size="15" font-family="monospace">${title}</text>
|
|
<text x="22" y="57" fill="#e8f4ff" opacity=".78" font-size="13" font-family="monospace">${line}</text>
|
|
</g>
|
|
</g>`;
|
|
}
|
|
|
|
function obsExhibitX(width = 290) {
|
|
const leftMargin = 240;
|
|
const rightMargin = 240;
|
|
return obsInt(leftMargin, 1600 - rightMargin - width);
|
|
}
|
|
|
|
function obsExhibitY(height = 82) {
|
|
const topMargin = 220;
|
|
const bottomMargin = 190;
|
|
return obsInt(topMargin, 900 - bottomMargin - height);
|
|
}
|
|
|
|
function obsExhibitCardPosition(width = 290, height = 82) {
|
|
return { x: obsExhibitX(width), y: obsExhibitY(height) };
|
|
}
|
|
|
|
function obsContact(x, y, color = 'var(--primary-accent)', label = 'CONTACT') {
|
|
const size = obsInt(26, 54);
|
|
return `
|
|
<g transform="translate(${x} ${y})" stroke="${color}" fill="none" stroke-width="3">
|
|
<g style="transform-box:fill-box;transform-origin:center;animation:obs-transient-pop .65s ease-out both">
|
|
<path d="M${-size},${-size/2}v${-size/2}h${size/2} M${size},${-size/2}v${-size/2}h${-size/2}
|
|
M${-size},${size/2}v${size/2}h${size/2} M${size},${size/2}v${size/2}h${-size/2}"/>
|
|
<circle r="5" fill="${color}" stroke="none"/>
|
|
<circle r="${size * 1.25}" opacity=".25"/>
|
|
<text x="${size + 18}" y="6" fill="${color}" stroke="none" font-size="14" font-family="monospace">${label}</text>
|
|
</g>
|
|
</g>`;
|
|
}
|
|
|
|
function obsExpandingRing(x, y, color = 'var(--primary-accent)', radius = 70) {
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="${radius}" fill="none" stroke="${color}" stroke-width="4"
|
|
style="transform-box:fill-box;transform-origin:center;animation:obs-transient-ring 2.8s ease-out forwards"/>
|
|
<circle r="7" fill="${color}" opacity=".8"/>
|
|
</g>`;
|
|
}
|
|
|
|
function generateStarfleetActivity(power) {
|
|
const effect = obsPick(['contact','vector','data','ring','diagnostic','streak']);
|
|
if (effect === 'contact') {
|
|
const x = obsInt(420,1180), y = obsInt(230,690);
|
|
return spawnObservationTransient(
|
|
obsContact(x,y,'var(--primary-accent)',`CONTACT ${obsInt(2,99).toString().padStart(2,'0')}`) +
|
|
`<path d="M800 455L${x} ${y}" stroke="var(--tertiary-accent)" stroke-width="2" opacity=".55" stroke-dasharray="10 9"/>`,
|
|
4300
|
|
);
|
|
}
|
|
if (effect === 'vector') {
|
|
const y1=obsInt(260,680), y2=obsInt(220,690);
|
|
return spawnObservationTransient(`
|
|
<path d="M230 ${y1} C520 ${obsInt(220,690)} 940 ${obsInt(220,690)} 1380 ${y2}"
|
|
fill="none" stroke="var(--tertiary-accent)" stroke-width="${2+power*3}" stroke-dasharray="18 12"
|
|
class="obs-route-wander"/>
|
|
<circle cx="1380" cy="${y2}" r="7" fill="var(--primary-accent)"/>
|
|
`, 5200);
|
|
}
|
|
if (effect === 'data') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y,
|
|
obsPick(['SENSOR UPDATE','NAV SOLUTION','SYSTEM QUERY','PASSIVE ARRAY']),
|
|
obsPick([
|
|
`BEARING ${obsInt(0,359).toString().padStart(3,'0')}.${obsInt(0,9)}°`,
|
|
`RANGE ${obsInt(2,980)}.${obsInt(0,9)} Mm`,
|
|
`SUBSPACE VAR ${obsRand(.01,.99).toFixed(3)}`,
|
|
`VECTOR ${obsInt(100,999)}-${obsInt(10,99)}`
|
|
])
|
|
), 4200);
|
|
}
|
|
if (effect === 'diagnostic') {
|
|
const bars = Array.from({length: obsInt(5,9)}, (_,i) =>
|
|
`<rect x="${270+i*44}" y="${obsInt(250,580)}" width="30" height="${obsInt(30,160)}"
|
|
fill="var(--secondary-accent)" opacity="${obsRand(.25,.85).toFixed(2)}"/>`).join('');
|
|
return spawnObservationTransient(`<g class="obs-ambient-glow">${bars}</g>`, 3500);
|
|
}
|
|
if (effect === 'streak') {
|
|
const lines = Array.from({length: obsInt(8,16)}, () => {
|
|
const y=obsInt(160,760), x=obsInt(150,1300), len=obsInt(70,240);
|
|
return `<line x1="${x}" y1="${y}" x2="${x+len}" y2="${y}" stroke="#dbeafe" stroke-width="${obsRand(1,3)}" opacity="${obsRand(.25,.75)}"/>`;
|
|
}).join('');
|
|
return spawnObservationTransient(`<g style="animation:obs-slide-horizontal-wide 2.5s ease-out both">${lines}</g>`, 3000);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(250,680),'var(--primary-accent)',obsInt(45,90)), 3200);
|
|
}
|
|
|
|
function generateWhoniverseActivity(power) {
|
|
const effect = obsPick(['echo','glyphs','coordinate','warp','rings']);
|
|
if (effect === 'echo') {
|
|
const x=obsInt(430,1170), y=obsInt(270,650);
|
|
return spawnObservationTransient(`
|
|
${[0,1,2].map(i=>`
|
|
<circle cx="${x+i*28}" cy="${y+i*18}" r="${50+i*25}" fill="none" stroke="${i===1?'#d4af37':'#00e5ff'}"
|
|
stroke-width="${3-i*.5}" opacity="${.7-i*.2}"
|
|
style="transform-box:fill-box;transform-origin:center;animation:obs-transient-ring ${2.5+i*.7}s ease-out forwards"/>
|
|
`).join('')}
|
|
`, 4700);
|
|
}
|
|
if (effect === 'glyphs') {
|
|
const glyphs = Array.from({length: obsInt(4,9)}, (_,i) =>
|
|
`<text x="${obsInt(180,1420)}" y="${obsInt(200,720)}" fill="${i%2?'#00e5ff':'#d4af37'}"
|
|
font-family="monospace" font-size="${obsInt(18,42)}" opacity="${obsRand(.3,.8)}"
|
|
style="animation:obs-drift-up ${obsRand(3.5,7).toFixed(1)}s ease-out forwards">${obsPick(['◉','◎','⌁','∆','∴','∞','⊙','⊛'])}</text>`
|
|
).join('');
|
|
return spawnObservationTransient(glyphs, 6500);
|
|
}
|
|
if (effect === 'coordinate') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y, 'TEMPORAL COORDINATE',
|
|
`${obsInt(0,99)}:${obsInt(0,99)}:${obsInt(0,99)} ∴ ${obsInt(1,13)}.${obsInt(0,9)}`, '#00e5ff'
|
|
), 4300);
|
|
}
|
|
if (effect === 'warp') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(800 450)" opacity=".75">
|
|
<ellipse rx="210" ry="70" fill="none" stroke="#00e5ff" stroke-width="5" class="obs-spin"/>
|
|
<ellipse rx="330" ry="115" fill="none" stroke="#d4af37" stroke-width="3" class="obs-spin-rev"/>
|
|
<circle r="35" fill="#00e5ff" opacity=".28" class="obs-pulse"/>
|
|
</g>
|
|
`, 4400);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(800,450,obsPick(['#00e5ff','#d4af37']),obsInt(60,120)), 3500);
|
|
}
|
|
|
|
function generateIndustrialActivity(power) {
|
|
// v13co: every industrial transient is clipped to ONE feed's picture area.
|
|
// In v12 these drew in raw screen space, so steam puffs and signal bars
|
|
// spilled across bezels and the console furniture.
|
|
const feeds = [
|
|
{ix:290, iy:259, id:'CAM 01', where:'CARGO BAY 2'},
|
|
{ix:825, iy:259, id:'CAM 02', where:'REACTOR ACCESS'},
|
|
{ix:290, iy:483, id:'CAM 03', where:'AIRLOCK C'},
|
|
{ix:825, iy:483, id:'CAM 04', where:'MACHINE CORRIDOR'}
|
|
];
|
|
const f = obsPick(feeds);
|
|
const W = 485, H = 189;
|
|
const clipId = `indFx-${Math.random().toString(36).slice(2, 9)}`;
|
|
const inFeed = (body) => `
|
|
<defs>
|
|
<clipPath id="${clipId}"><rect x="${f.ix}" y="${f.iy}" width="${W}" height="${H}"/></clipPath>
|
|
<radialGradient id="indVapour" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="#efe2cf" stop-opacity=".5"/>
|
|
<stop offset="55%" stop-color="#dcc9ad" stop-opacity=".22"/>
|
|
<stop offset="100%" stop-color="#c9b596" stop-opacity="0"/>
|
|
</radialGradient>
|
|
</defs>
|
|
<g clip-path="url(#${clipId})"><g transform="translate(${f.ix} ${f.iy})">${body}</g></g>`;
|
|
|
|
const canCycle = (f.id === 'CAM 01' || f.id === 'CAM 03');
|
|
const effect = obsPick(['signal', 'dropout', 'vapour', 'motion', 'gain', 'warning']
|
|
.concat(canCycle ? ['door'] : []));
|
|
|
|
// Digital break-up: displaced scan blocks plus a recalibration caption.
|
|
if (effect === 'signal') {
|
|
const blocks = Array.from({length: obsInt(9, 15)}, () => {
|
|
const by = obsInt(0, H - 14);
|
|
return `<rect x="${obsInt(-18, 18)}" y="${by}" width="${W}" height="${obsInt(3, 11)}"
|
|
fill="#ffaa00" opacity="${obsRand(.1, .34).toFixed(2)}"/>`;
|
|
}).join('');
|
|
return spawnObservationTransient(inFeed(`
|
|
<g opacity=".07"><rect width="${W}" height="${H}" fill="#ffffff" class="obs-flicker"/></g>
|
|
${blocks}
|
|
<rect y="${H / 2 - 20}" width="${W}" height="40" fill="#000000" opacity=".5"/>
|
|
<text x="${W / 2}" y="${H / 2 + 7}" text-anchor="middle" fill="#ffaa00"
|
|
font-size="15" font-family="monospace">SIGNAL RECALIBRATION</text>
|
|
`), 3600);
|
|
}
|
|
|
|
// Total feed loss, then reacquire.
|
|
if (effect === 'dropout') {
|
|
return spawnObservationTransient(inFeed(`
|
|
<rect width="${W}" height="${H}" fill="#050505" opacity=".92"/>
|
|
<g class="obs-flicker">
|
|
<text x="${W / 2}" y="${H / 2 - 4}" text-anchor="middle" fill="#ef4444"
|
|
font-size="17" font-family="monospace">NO SIGNAL</text>
|
|
<text x="${W / 2}" y="${H / 2 + 22}" text-anchor="middle" fill="#8a6a42"
|
|
font-size="11" font-family="monospace">${f.id} // REACQUIRING</text>
|
|
</g>
|
|
`), 2600);
|
|
}
|
|
|
|
// A vent release inside the room the camera is actually looking at.
|
|
if (effect === 'vapour') {
|
|
const x = obsInt(90, W - 90), y = H - obsInt(16, 40);
|
|
const puffs = Array.from({length: obsInt(6, 11)}, (_, i) => {
|
|
const dur = obsRand(2.8, 5.2).toFixed(1);
|
|
return `<circle cx="${x + obsInt(-26, 26)}" cy="${y}" r="${obsInt(12, 30)}" fill="url(#indVapour)" opacity="0"
|
|
style="--vx:${obsInt(-30, 30)}px;animation:obs-vapour ${dur}s ease-out forwards;animation-delay:${(i * 0.22).toFixed(2)}s"/>`;
|
|
}).join('');
|
|
return spawnObservationTransient(inFeed(`
|
|
${puffs}
|
|
<rect x="${x - 22}" y="${y - 3}" width="44" height="6" rx="2" fill="#2b2118"/>
|
|
`), 6200);
|
|
}
|
|
|
|
// Motion detection bracket that tracks across the picture.
|
|
if (effect === 'motion') {
|
|
const bx = obsInt(40, W - 190), by = obsInt(46, H - 130);
|
|
return spawnObservationTransient(inFeed(`
|
|
<g style="animation:obs-slide-horizontal 4.2s ease-in-out both">
|
|
<g transform="translate(${bx} ${by})" stroke="#ff3b30" stroke-width="3" fill="none">
|
|
<path d="M0 14V0h14M104 0h14v14M118 56v14h-14M14 70H0V56"/>
|
|
<rect width="118" height="70" opacity=".14" fill="#ff3b30" stroke="none"/>
|
|
</g>
|
|
</g>
|
|
<rect y="${H - 46}" width="188" height="20" fill="#7f1d1d" opacity=".8"/>
|
|
<text x="10" y="${H - 31}" fill="#ffe4e6" font-family="monospace" font-size="11">MOTION // ${f.id}</text>
|
|
`), 4600);
|
|
}
|
|
|
|
// Pressure-door cycle seen head on.
|
|
if (effect === 'door') {
|
|
const mid = W / 2, top = 58, bot = H - 52;
|
|
return spawnObservationTransient(inFeed(`
|
|
<g opacity=".72">
|
|
<rect x="${mid - 84}" y="${top}" width="80" height="${bot - top}" fill="#1d160f" stroke="#7c4a16" stroke-width="2">
|
|
<animate attributeName="width" values="80;10;80" dur="4.2s" repeatCount="1" fill="freeze"/>
|
|
</rect>
|
|
<rect x="${mid + 4}" y="${top}" width="80" height="${bot - top}" fill="#1d160f" stroke="#7c4a16" stroke-width="2">
|
|
<animate attributeName="x" values="${mid + 4};${mid + 74};${mid + 4}" dur="4.2s" repeatCount="1" fill="freeze"/>
|
|
<animate attributeName="width" values="80;10;80" dur="4.2s" repeatCount="1" fill="freeze"/>
|
|
</rect>
|
|
</g>
|
|
<rect x="${mid - 84}" y="${top - 7}" width="168" height="6" fill="#b8860f" opacity=".55"/>
|
|
<text x="${mid}" y="${bot + 19}" text-anchor="middle" fill="#ffaa00"
|
|
font-family="monospace" font-size="11">HATCH CYCLE // ${f.where}</text>
|
|
`), 4800);
|
|
}
|
|
|
|
// Auto-iris hunting after a lighting change.
|
|
if (effect === 'gain') {
|
|
return spawnObservationTransient(inFeed(`
|
|
<rect width="${W}" height="${H}" fill="#ffe0b0"
|
|
style="animation:obs-feed-agc 1.9s ease-out forwards"/>
|
|
<text x="12" y="${H - 30}" fill="#c8964a" font-family="monospace" font-size="11">AGC // IRIS ADJUST</text>
|
|
`), 2400);
|
|
}
|
|
|
|
// Plant readout card — stays on the console rail, not over a feed.
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y,
|
|
obsPick(['PRESSURE TRANSIENT', 'COOLANT LOOP', 'MAINTENANCE BUS', 'LOCAL POWER', 'GANTRY INTERLOCK']),
|
|
obsPick([
|
|
`P-${obsInt(1, 9)} ${obsInt(30, 98)}%`,
|
|
`FLOW ${obsInt(120, 980)} L/M`,
|
|
`BUS ${obsHex(obsInt(100, 999))} NOMINAL`,
|
|
`VALVE ${obsInt(1, 44)} CYCLING`,
|
|
`HOIST ${obsInt(1, 4)} LOAD ${obsRand(.4, 4.2).toFixed(1)} T`
|
|
]),
|
|
'#ffaa00'
|
|
), 4500);
|
|
}
|
|
|
|
function generateBioshipActivity(power) {
|
|
const effect = obsPick(['neural','spores','ripple','tendril','organ','metric']);
|
|
if (effect === 'neural') {
|
|
const y1=obsInt(260,650), y2=obsInt(260,650);
|
|
return spawnObservationTransient(`
|
|
<path d="M210 ${y1} C450 ${obsInt(190,720)} 650 ${obsInt(190,720)} 800 450 S1160 ${obsInt(190,720)} 1400 ${y2}"
|
|
fill="none" stroke="#a7f3d0" stroke-width="${3+power*4}" stroke-linecap="round" class="obs-dashflow"
|
|
opacity=".8"/>
|
|
`, 3300);
|
|
}
|
|
if (effect === 'spores') {
|
|
const spores=Array.from({length:obsInt(10,24)},()=>`
|
|
<circle cx="${obsInt(260,1340)}" cy="${obsInt(260,700)}" r="${obsRand(2,8).toFixed(1)}"
|
|
fill="${obsPick(['#a7f3d0','#10b981','#8b5cf6'])}" opacity="${obsRand(.25,.8).toFixed(2)}"
|
|
style="animation:obs-drift-up ${obsRand(3,7).toFixed(1)}s ease-out forwards"/>`).join('');
|
|
return spawnObservationTransient(spores, 7000);
|
|
}
|
|
if (effect === 'tendril') {
|
|
const sx=obsPick([180,1420]), sy=obsInt(300,660);
|
|
return spawnObservationTransient(`
|
|
<path d="M${sx} ${sy} C${obsInt(350,650)} ${obsInt(200,700)} ${obsInt(950,1250)} ${obsInt(200,700)} 800 450"
|
|
fill="none" stroke="#10b981" stroke-width="${obsInt(5,12)}" opacity=".45"
|
|
stroke-linecap="round" class="obs-route-wander"/>
|
|
`, 5200);
|
|
}
|
|
if (effect === 'organ') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsInt(500,1100)} ${obsInt(340,580)})">
|
|
<g style="transform-box:fill-box;transform-origin:center;animation:obs-transient-pop 1.2s ease-out both">
|
|
<ellipse rx="${obsInt(45,95)}" ry="${obsInt(70,150)}" fill="#8b5cf6" opacity=".14" stroke="#10b981" stroke-width="5" class="obs-organic-undulate"/>
|
|
<ellipse rx="${obsInt(18,35)}" ry="${obsInt(35,70)}" fill="#10b981" opacity=".5" class="obs-pulse"/>
|
|
</g>
|
|
</g>
|
|
`, 5000);
|
|
}
|
|
if (effect === 'metric') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y, obsPick(['NEURAL RESPONSE','METABOLIC FIELD','SYMBIOTIC VARIANCE','BIOELECTRIC FLUX']),
|
|
`${obsRand(0.1,9.9).toFixed(2)} // ${obsInt(20,99)}%`, '#10b981'
|
|
), 4300);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(800,470,obsPick(['#10b981','#8b5cf6','#a7f3d0']),obsInt(55,110)), 3700);
|
|
}
|
|
|
|
function generateRetroActivity(power) {
|
|
const effect = obsPick(['blip','scope','counter','vector','bloom','reel']);
|
|
if (effect === 'blip') {
|
|
return spawnObservationTransient(
|
|
obsContact(obsInt(470,1130),obsInt(260,660),'#86efac',`OBJ ${obsInt(1,99)}`), 4300
|
|
);
|
|
}
|
|
if (effect === 'scope') {
|
|
const y=obsInt(560,720);
|
|
return spawnObservationTransient(`
|
|
<path d="M130 ${y} ${Array.from({length:14},(_,i)=>`L${180+i*95} ${y+obsInt(-70,70)}`).join(' ')}"
|
|
fill="none" stroke="#22c55e" stroke-width="4" opacity=".78" class="obs-dashflow"/>
|
|
`, 4500);
|
|
}
|
|
if (effect === 'counter') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y, obsPick(['ASTROGATOR','VECTOR MEMORY','TAPE INDEX','OBJECT FILE']),
|
|
`${obsInt(0,9999).toString().padStart(4,'0')} / ${obsInt(0,9999).toString().padStart(4,'0')}`,'#22c55e'
|
|
),4300);
|
|
}
|
|
if (effect === 'reel') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsPick([260,1340])} ${obsInt(350,580)})">
|
|
<circle r="62" fill="none" stroke="#22c55e" stroke-width="6" opacity=".55" class="obs-spin"/>
|
|
<circle r="22" fill="none" stroke="#86efac" stroke-width="5" stroke-dasharray="8 8" class="obs-spin-rev"/>
|
|
</g>`, 4800);
|
|
}
|
|
if (effect === 'bloom') {
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(480,1120),obsInt(270,650),'#86efac',obsInt(35,75)),3000);
|
|
}
|
|
return spawnObservationTransient(`
|
|
<path d="M${obsInt(120,300)} ${obsInt(650,760)} Q800 ${obsInt(180,360)} ${obsInt(1300,1490)} ${obsInt(250,660)}"
|
|
fill="none" stroke="#22c55e" stroke-width="3" opacity=".7" class="obs-route-wander"/>
|
|
`, 5200);
|
|
}
|
|
|
|
function generateMilitaryActivity(power) {
|
|
const effect = obsPick(['contact','intercept','formation','sector','status','sweep']);
|
|
if (effect === 'contact') {
|
|
const pos = obsExhibitCardPosition(320, 82);
|
|
return spawnObservationTransient(
|
|
obsContact(obsInt(430,1170),obsInt(240,680),'#eab308',obsPick(['UNKNOWN','TRACK','CONTACT','RETURN'])) +
|
|
obsTextCard(pos.x,pos.y,'TACTICAL UPDATE',`TRACK ${obsHex(obsInt(1,4095))} // ${obsInt(2,98)}%`,'#eab308',320),
|
|
4600
|
|
);
|
|
}
|
|
if (effect === 'intercept') {
|
|
const x=obsInt(450,1150),y=obsInt(250,650);
|
|
return spawnObservationTransient(`
|
|
<path d="M800 470 Q${obsInt(600,1000)} ${obsInt(180,700)} ${x} ${y}"
|
|
fill="none" stroke="#eab308" stroke-width="4" stroke-dasharray="15 10" class="obs-route-wander"/>
|
|
${obsContact(x,y,'#ef4444','VECTOR')}
|
|
`, 4700);
|
|
}
|
|
if (effect === 'formation') {
|
|
const cx=obsInt(600,1000), cy=obsInt(340,600);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${cx} ${cy})" fill="#eab308" opacity=".75">
|
|
<g style="animation:obs-slide-horizontal 5s ease-in-out both">
|
|
<polygon points="0,-14 12,12 -12,12"/>
|
|
<polygon points="-55,30 -43,56 -67,56"/>
|
|
<polygon points="55,30 67,56 43,56"/>
|
|
<polygon points="0,70 12,96 -12,96"/>
|
|
</g>
|
|
</g>`, 5200);
|
|
}
|
|
if (effect === 'sector') {
|
|
return spawnObservationTransient(`
|
|
<path d="M800 470L800 170A300 300 0 0 1 ${obsInt(1020,1110)} ${obsInt(250,390)}Z"
|
|
fill="${obsPick(['#eab308','#ef4444'])}" opacity=".12" class="obs-pulse"/>
|
|
`, 3800);
|
|
}
|
|
if (effect === 'status') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,obsPick(['TACTICAL ARRAY','TRACK CORRELATION','FORMATION UPDATE','IFF QUERY']),
|
|
obsPick([`SECTOR ${obsInt(1,12)} / CLEAR`,`RETURN ${obsHex(obsInt(1,4095))}`,`VECTOR ${obsInt(0,359)}.${obsInt(0,9)}°`,`CONF ${obsInt(34,99)}%`]),'#eab308'
|
|
),4200);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(800,470,'#eab308',obsInt(65,130)),3500);
|
|
}
|
|
|
|
function generateDeepSpaceActivity(power) {
|
|
const effect = obsPick(['anomaly','comet','lens','spectral','planet','signal']);
|
|
if (effect === 'anomaly') {
|
|
const x=obsInt(430,1180), y=obsInt(230,650);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="18" fill="#000"/>
|
|
<circle r="45" fill="none" stroke="#818cf8" stroke-width="4" opacity=".65" class="obs-spin"/>
|
|
<ellipse rx="120" ry="34" fill="none" stroke="#38bdf8" stroke-width="3" opacity=".35" class="obs-spin-rev"/>
|
|
<circle r="145" fill="#6366f1" opacity=".04" class="obs-pulse"/>
|
|
</g>
|
|
`, 6500);
|
|
}
|
|
if (effect === 'comet') {
|
|
return spawnObservationTransient(`
|
|
<g style="animation:obs-comet-drift 5s linear forwards">
|
|
<path d="M0 0L-260 60" stroke="#eef2ff" stroke-width="3" opacity=".6"/>
|
|
<circle r="6" fill="#fff"/>
|
|
</g>`, 5200);
|
|
}
|
|
if (effect === 'lens') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsInt(500,1100)} ${obsInt(300,580)})" opacity=".55">
|
|
<ellipse rx="210" ry="70" fill="none" stroke="#6366f1" stroke-width="5" class="obs-spin-slow"/>
|
|
<ellipse rx="130" ry="42" fill="none" stroke="#38bdf8" stroke-width="3" class="obs-spin-rev"/>
|
|
</g>`, 6800);
|
|
}
|
|
if (effect === 'planet') {
|
|
const y=obsInt(260,620), r=obsInt(45,110);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(0 ${y})">
|
|
<g style="animation:obs-slide-horizontal-wide 10s linear forwards">
|
|
<circle cx="180" cy="0" r="${r}" fill="#312e81" opacity=".45"/>
|
|
<path d="M${180-r} 0A${r} ${r} 0 0 1 ${180+r} -10" fill="none" stroke="#818cf8" stroke-width="3" opacity=".5"/>
|
|
</g>
|
|
</g>`, 10000);
|
|
}
|
|
if (effect === 'spectral') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,obsPick(['SPECTRAL EVENT','GRAVITIC VARIANCE','DEEP FIELD RETURN','OPTICAL TRANSIENT']),
|
|
`${obsRand(.001,9.999).toFixed(3)} / ${obsInt(1,999)} ly`,'#818cf8'
|
|
),4800);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(240,660),'#38bdf8',obsInt(45,95)),4000);
|
|
}
|
|
|
|
function generateOutlawActivity(power) {
|
|
const effect = obsPick(['glitch','route','contact','signal','gauge','rear']);
|
|
if (effect === 'glitch') {
|
|
const bars=Array.from({length:obsInt(8,18)},()=>`
|
|
<rect x="${obsInt(80,1300)}" y="${obsInt(160,760)}" width="${obsInt(80,420)}" height="${obsInt(3,18)}"
|
|
fill="${obsPick(['#ec4899','#f59e0b','#8b5cf6'])}" opacity="${obsRand(.08,.45)}"/>`).join('');
|
|
return spawnObservationTransient(`<g class="obs-flicker">${bars}</g>`,3000);
|
|
}
|
|
if (effect === 'route') {
|
|
return spawnObservationTransient(`
|
|
<path d="M120 ${obsInt(600,760)} Q${obsInt(350,620)} ${obsInt(200,690)} 800 ${obsInt(300,620)} T1480 ${obsInt(180,700)}"
|
|
fill="none" stroke="#f59e0b" stroke-width="5" stroke-dasharray="20 12" class="obs-route-wander"/>
|
|
<text x="1240" y="${obsInt(250,650)}" fill="#f472b6" font-size="16" font-family="monospace">ROUTE RECALC...</text>
|
|
`,5600);
|
|
}
|
|
if (effect === 'contact' || effect === 'rear') {
|
|
return spawnObservationTransient(obsContact(
|
|
obsInt(430,1170),obsInt(240,670),obsPick(['#ec4899','#f59e0b']),'UNKNOWN'
|
|
),4200);
|
|
}
|
|
if (effect === 'signal') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,obsPick(['TRANSPONDER HIT','SIGNAL QUALITY','NAV PATCH','RADIO BURST']),
|
|
`${obsInt(9,99)}% // ${obsHex(obsInt(1,4095))}`,'#ec4899'
|
|
),4300);
|
|
}
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsPick([290,1310])} ${obsInt(360,580)})">
|
|
<path d="M-80 70A100 100 0 0 1 80 70" fill="none" stroke="#f59e0b" stroke-width="6" opacity=".6"/>
|
|
<line x1="0" y1="70" x2="${obsInt(-70,70)}" y2="${obsInt(-5,55)}" stroke="#ec4899" stroke-width="5"/>
|
|
</g>`,4500);
|
|
}
|
|
|
|
function generateStationActivity(power) {
|
|
const effect = obsPick(['dock','depart','guidance','traffic','beacon','queue']);
|
|
if (effect === 'dock') {
|
|
const y = obsInt(250, 520);
|
|
return spawnObservationTransient(`
|
|
<path d="M260 ${y} C520 ${y-85} 780 ${y+50} 1160 ${y-22}"
|
|
fill="none" stroke="#38bdf8" stroke-width="3.5" stroke-dasharray="18 12" class="obs-route-wander"/>
|
|
<polygon points="1132,${y-35} 1166,${y-22} 1132,${y-9}" fill="#f97316"/>
|
|
<circle cx="1170" cy="${y-22}" r="6" fill="#fdba74" class="obs-blink"/>
|
|
<text x="972" y="${y-52}" fill="#fdba74" font-size="16" font-family="monospace">DOCK VECTOR ${obsInt(1,18)}</text>
|
|
`, 6200);
|
|
}
|
|
if (effect === 'depart') {
|
|
const y = obsInt(235, 540);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(0 ${y})">
|
|
<g style="animation:obs-slide-horizontal-wide 8s linear forwards">
|
|
<g transform="translate(260 0)">
|
|
<path d="M0 0L30 10L0 20L8 10Z" fill="#e0f2fe"/>
|
|
<path d="M-25 10H6" stroke="#38bdf8" stroke-width="2"/>
|
|
<circle cx="-32" cy="10" r="4" fill="#f97316" class="obs-blink"/>
|
|
</g>
|
|
</g>
|
|
</g>`, 7000);
|
|
}
|
|
if (effect === 'guidance') {
|
|
const x = obsInt(470, 1100), y = obsInt(230, 570);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="24" fill="none" stroke="#38bdf8" stroke-width="3" class="obs-pulse"/>
|
|
<circle r="48" fill="none" stroke="#7dd3fc" stroke-width="2" opacity=".55" class="obs-pulse" style="animation-delay:-1.1s"/>
|
|
<path d="M-80 0H80M0-80V80" stroke="#38bdf8" stroke-width="2" opacity=".42"/>
|
|
</g>`, 4600);
|
|
}
|
|
if (effect === 'beacon') {
|
|
const x = obsPick([248, 1352]);
|
|
const y = obsPick([168, 648]);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="12" fill="#f97316" opacity=".42" class="obs-pulse"/>
|
|
<circle r="34" fill="none" stroke="#fdba74" stroke-width="3"
|
|
style="transform-box:fill-box;transform-origin:center;animation:obs-transient-ring 3.2s ease-out forwards"/>
|
|
</g>`, 3600);
|
|
}
|
|
if (effect === 'queue') {
|
|
const pos = obsExhibitCardPosition(310, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y,
|
|
obsPick(['TRAFFIC CONTROL','DOCK STATUS','PORT AUTHORITY','BERTH SCHEDULER']),
|
|
obsPick([
|
|
`QUEUE ${obsInt(2,18)} // CLEARANCE`,
|
|
`BAY ${obsInt(1,24)} // ARRIVAL WINDOW`,
|
|
`HOLD ARC ${obsRand(0.8,9.9).toFixed(1)} // ROUTING`,
|
|
`${obsInt(2,84)} OBJECTS // PORT FLOW`
|
|
]),
|
|
'#38bdf8', 310
|
|
), 4600);
|
|
}
|
|
const x = obsInt(330, 1270), y = obsInt(205, 610);
|
|
return spawnObservationTransient(`
|
|
<path d="M${x-140} ${y+45} C${x-60} ${y-35} ${x+70} ${y+30} ${x+150} ${y-15}"
|
|
fill="none" stroke="#0ea5e9" stroke-width="2.5" stroke-dasharray="10 9" class="obs-route-wander"/>
|
|
<text x="${x-8}" y="${y-22}" fill="#7dd3fc" font-size="15" font-family="monospace">TRAFFIC FLOW ${obsInt(1,9)}</text>
|
|
`, 5200);
|
|
}
|
|
|
|
function generateComedyActivity(power) {
|
|
const effect = obsPick(['route','oddity','planet','status','contact','geometry']);
|
|
if (effect === 'route') {
|
|
return spawnObservationTransient(`
|
|
<path d="M120 690 C360 ${obsInt(160,720)} 640 ${obsInt(160,720)} 800 450 S1190 ${obsInt(160,720)} 1460 ${obsInt(180,690)}"
|
|
fill="none" stroke="#fbbf24" stroke-width="5" stroke-dasharray="18 12" class="obs-route-wander"/>
|
|
`,6000);
|
|
}
|
|
if (effect === 'oddity') {
|
|
const pos = obsExhibitCardPosition(330, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,
|
|
obsPick(['NAVIGATION UPDATE','ENVIRONMENTAL NOTE','SERVICE MESSAGE','SENSOR CONCLUSION']),
|
|
obsPick([
|
|
`ROUTE CONFIDENCE ${obsRand(96,103).toFixed(1)}%`,
|
|
`TEA PROBABILITY ${obsInt(12,99)}%`,
|
|
`MOSTLY NOMINAL`,
|
|
`OBJECT: PROBABLY HARMLESS`,
|
|
`PANIC INDEX ${obsInt(0,3)} / 10`,
|
|
`SCENIC DETOUR +${obsInt(2,48)} MIN`
|
|
]),'#06b6d4',330
|
|
),5000);
|
|
}
|
|
if (effect === 'planet') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(0 ${obsInt(300,600)})">
|
|
<g style="animation:obs-slide-horizontal-wide 9s linear forwards">
|
|
<circle cx="180" cy="0" r="${obsInt(35,90)}" fill="${obsPick(['#fbbf24','#06b6d4','#f43f5e'])}" opacity=".32"/>
|
|
<circle cx="180" cy="0" r="${obsInt(18,35)}" fill="#fff" opacity=".08"/>
|
|
</g>
|
|
</g>`,9200);
|
|
}
|
|
if (effect === 'geometry') {
|
|
const sides=obsPick([3,4,5,6]);
|
|
const r=obsInt(55,110), cx=obsInt(500,1100),cy=obsInt(300,620);
|
|
const pts=Array.from({length:sides},(_,i)=>{
|
|
const a=-Math.PI/2+i*Math.PI*2/sides;
|
|
return `${(cx+Math.cos(a)*r).toFixed(1)},${(cy+Math.sin(a)*r).toFixed(1)}`;
|
|
}).join(' ');
|
|
return spawnObservationTransient(`<polygon points="${pts}" fill="none" stroke="#f43f5e" stroke-width="5" class="obs-spin"/>`,5200);
|
|
}
|
|
if (effect === 'contact') {
|
|
return spawnObservationTransient(obsContact(obsInt(450,1150),obsInt(250,650),'#67e8f9',obsPick(['FRIENDLY?','THING','OBJECT','PROBABLY FINE'])),4300);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500);
|
|
}
|
|
|
|
function triggerObservationActivity(source = 'ambient') {
|
|
if (!observationActive || observationActivity <= 0.001) return;
|
|
|
|
// Telemetry is a synchronization pulse, but the slider controls whether
|
|
// that pulse becomes visible. At maximum every telemetry pulse produces
|
|
// a visible response; at low levels many pass quietly.
|
|
if (source === 'telemetry') {
|
|
const responseChance = 0.18 + observationActivity * 0.82;
|
|
if (Math.random() > responseChance) return;
|
|
}
|
|
|
|
let count = 1;
|
|
if (source === 'telemetry' && observationActivity > .55 && Math.random() < observationActivity) count++;
|
|
if (observationActivity > .82 && Math.random() < (observationActivity - .7)) count++;
|
|
|
|
const generator = {
|
|
starfleet: generateStarfleetActivity,
|
|
whoniverse: generateWhoniverseActivity,
|
|
industrial: generateIndustrialActivity,
|
|
bioships: generateBioshipActivity,
|
|
retrofuture: generateRetroActivity,
|
|
military: generateMilitaryActivity,
|
|
deepspace: generateDeepSpaceActivity,
|
|
outlaw: generateOutlawActivity,
|
|
spacestations: generateStationActivity,
|
|
comedy: generateComedyActivity
|
|
}[activeUniverseId] || generateStarfleetActivity;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
window.setTimeout(() => generator(observationActivity), i * obsInt(120,380));
|
|
}
|
|
}
|
|
|
|
function scheduleObservationAmbientActivity() {
|
|
if (observationAmbientTimer) {
|
|
clearTimeout(observationAmbientTimer);
|
|
observationAmbientTimer = null;
|
|
}
|
|
if (!observationActive || observationActivity <= .01) return;
|
|
|
|
// Quiet = occasional subtle life. Active = a busy generative display.
|
|
const slow = 12500;
|
|
const fast = 1500;
|
|
const base = slow - (slow - fast) * Math.pow(observationActivity, .82);
|
|
const jitter = base * obsRand(.25,.75);
|
|
|
|
observationAmbientTimer = window.setTimeout(() => {
|
|
triggerObservationActivity('ambient');
|
|
scheduleObservationAmbientActivity();
|
|
}, base + jitter);
|
|
}
|
|
|
|
function setObservationActivity(raw) {
|
|
observationActivity = obsClamp(Number(raw) / 100, 0, 1);
|
|
window.observationActivity = observationActivity;
|
|
observationOverlay.style.setProperty('--obs-activity', String(observationActivity));
|
|
const pct = Math.round(observationActivity * 100);
|
|
valObservationActivity.textContent = `${pct}%`;
|
|
if (observationActive) {
|
|
observationStatus.innerHTML =
|
|
`OBSERVATION ACTIVE<br>PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}<br>VISUAL ACTIVITY: ${pct}%`;
|
|
scheduleObservationAmbientActivity();
|
|
}
|
|
}
|
|
|
|
function resetObservationReturnFade() {
|
|
if (!observationReturn) return;
|
|
|
|
observationReturn.style.transition = 'none';
|
|
observationReturn.classList.remove('observation-return-fade');
|
|
void observationReturn.offsetWidth;
|
|
observationReturn.style.transition = '';
|
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (observationActive) {
|
|
observationReturn.classList.add('observation-return-fade');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function resetObservationHudFade() {
|
|
if (!observationChrome) return;
|
|
|
|
// Remove transition momentarily so each OBSERVATION session starts fully visible.
|
|
observationChrome.style.transition = 'none';
|
|
observationChrome.classList.remove('observation-hud-fade');
|
|
void observationChrome.offsetWidth;
|
|
observationChrome.style.transition = '';
|
|
|
|
if (!toggleObservationHud.checked) {
|
|
// Start the thirty-second fade on the next frame.
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (observationActive && !toggleObservationHud.checked) {
|
|
observationChrome.classList.add('observation-hud-fade');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateObservationHudMode() {
|
|
const hold = toggleObservationHud.checked;
|
|
valObservationHud.textContent = hold ? 'ON' : 'FADE';
|
|
|
|
if (!observationActive || !observationChrome) return;
|
|
|
|
if (hold) {
|
|
observationChrome.style.transition = 'none';
|
|
observationChrome.classList.remove('observation-hud-fade');
|
|
void observationChrome.offsetWidth;
|
|
observationChrome.style.transition = '';
|
|
} else {
|
|
resetObservationHudFade();
|
|
}
|
|
}
|
|
|
|
toggleObservationHud.addEventListener('change', updateObservationHudMode);
|
|
updateObservationHudMode();
|
|
|
|
sliderObservationActivity.addEventListener('input', () => {
|
|
setObservationActivity(sliderObservationActivity.value);
|
|
if (observationActive) scheduleObservationInstrumentTick();
|
|
});
|
|
|
|
window.addEventListener('scifi-telemetry-activity', (event) => {
|
|
if (!observationActive) return;
|
|
triggerObservationActivity('telemetry');
|
|
nudgeObservationInstruments(event.detail ? event.detail.density : null);
|
|
pulseObservationScene();
|
|
pulseObservationSplineMorphs();
|
|
});
|
|
|
|
setObservationActivity(sliderObservationActivity.value);
|
|
|
|
|
|
// =========================================================================
|
|
// LIVE OBSERVATION INSTRUMENTATION
|
|
// The fixed OBSERVATION chrome is intentionally outside this system.
|
|
// Only fantasy <text> elements inside the main SVG/event layers participate.
|
|
// =========================================================================
|
|
|
|
let observationInstrumentStates = [];
|
|
let observationInstrumentTimer = null;
|
|
let observationTransientObserver = null;
|
|
|
|
const observationTextVocabulary = {
|
|
starfleet: ['NOMINAL','PASSIVE','SCANNING','CORRELATING','TRACKING','VERIFIED','STABLE'],
|
|
whoniverse: ['VORTEX LOCK','CONVERGING','DRIFTING','RECURSIVE','RESOLVING','TEMPORAL HOLD'],
|
|
industrial: ['SECURE FEED','MONITORING','SIGNAL HOLD','CYCLING','LOCAL CHECK','STABLE'],
|
|
bioships: ['QUIESCENT','RESPONDING','ADAPTING','RESONANT','SYNCHRONIZED','PULSING'],
|
|
retrofuture: ['READY','READ','PROCESS','HOLD','TRACK','STABLE'],
|
|
military: ['PASSIVE','TRACKING','CORRELATING','VERIFIED','WATCH','CLEAR'],
|
|
deepspace: ['DEEP FIELD','OBSERVING','SPECTRAL HOLD','PASSIVE','RESOLVING','STABLE'],
|
|
outlaw: ['MANUAL','INTERMITTENT','PATCHED','SEARCHING','HOLDING','DIRTY LOCK'],
|
|
spacestations: ['PASSIVE WATCH','TRAFFIC HOLD','DOCKING','ROUTING','OBSERVING','CLEAR'],
|
|
comedy: ['PROBABLY FINE','MOSTLY NOMINAL','STILL FINE','SCENIC','TEA ADJACENT','UNNECESSARILY PRECISE']
|
|
};
|
|
|
|
const observationPlainTextVariants = {
|
|
comedy: {
|
|
'TEA SOURCE DETECTED': ['TEA SOURCE DETECTED','TEA SOURCE PROBABLE','TEA SOURCE LOST','BEVERAGE VECTOR ACQUIRED'],
|
|
'NO IMMEDIATE CAUSE FOR PANIC': ['NO IMMEDIATE CAUSE FOR PANIC','STILL NO CAUSE FOR PANIC','PANIC NOT CURRENTLY REQUIRED','SITUATION MOSTLY NORMAL'],
|
|
'SCENIC NAVIGATION': ['SCENIC NAVIGATION','SCENIC RECALCULATION','ROUTE APPRECIATION','NAVIGATION, BUT NICER']
|
|
}
|
|
};
|
|
|
|
function getObservationTelemetryDensity() {
|
|
if (typeof telemetry !== 'undefined' && telemetry && telemetry.params) {
|
|
return obsClamp(Number(telemetry.params.density) || 0, 0, 1);
|
|
}
|
|
const slider = document.getElementById('slider-telemetry-density');
|
|
return slider ? obsClamp(Number(slider.value) || 0, 0, 1) : 0.5;
|
|
}
|
|
|
|
function classifyObservationNumber(source, raw, index) {
|
|
const upper = source.toUpperCase();
|
|
const decimals = raw.includes('.') ? raw.split('.')[1].length : 0;
|
|
const unsignedRaw = raw.replace(/^[-+]/, '');
|
|
const integerPart = unsignedRaw.split('.')[0];
|
|
const leadingZero = integerPart.length > 1 && integerPart.startsWith('0');
|
|
const resolution = Math.pow(10, -decimals);
|
|
const base = Number(raw);
|
|
|
|
let kind = 'generic';
|
|
let min = -Infinity;
|
|
let max = Infinity;
|
|
let circular = false;
|
|
let macroScale = Math.max(resolution * 2, Math.abs(base || 1) * 0.025);
|
|
|
|
const after = source.slice(index + raw.length, index + raw.length + 4);
|
|
const before = source.slice(Math.max(0, index - 24), index).toUpperCase();
|
|
|
|
if (after.includes('%') || upper.includes('QUALITY') || upper.includes('CONFIDENCE') || upper.includes('EFFICIENCY')) {
|
|
kind = 'percent';
|
|
min = 0;
|
|
max = upper.includes('CONFIDENCE') ? 103 : 100;
|
|
macroScale = 8;
|
|
} else if (upper.includes('NAV VECTOR') || upper.includes('BEARING') || upper.includes('HEADING') || upper.includes('VECTOR ')) {
|
|
kind = 'heading';
|
|
min = 0;
|
|
max = 360;
|
|
circular = true;
|
|
macroScale = 18;
|
|
} else if (upper.includes('CAM ') || upper.includes('CONTACT ') || upper.includes('TRACK ') ||
|
|
upper.includes('BAY ') || upper.includes('SECTOR ') || upper.includes('OBJECT ') ||
|
|
before.endsWith('CAM ') || before.endsWith('TRACK ')) {
|
|
kind = 'identifier';
|
|
min = 0;
|
|
max = leadingZero ? Math.pow(10, integerPart.length) - 1 : Math.max(99, base + 24);
|
|
macroScale = Math.max(1, Math.min(4, 1 + base * .02));
|
|
} else if (upper.includes('RANGE') || upper.includes('DIST') || upper.includes(' LY') || upper.includes(' MM')) {
|
|
kind = 'range';
|
|
min = 0;
|
|
max = Math.max(base * 3, base + 1000);
|
|
macroScale = Math.max(resolution * 3, Math.abs(base || 1) * .18);
|
|
} else if (upper.includes('TIME') || upper.includes(':')) {
|
|
kind = 'clockish';
|
|
min = 0;
|
|
max = decimals ? 99.9 : 99;
|
|
macroScale = decimals ? 2.5 : 5;
|
|
} else if (base >= 0 && base <= 100) {
|
|
min = 0;
|
|
max = 100;
|
|
macroScale = Math.max(resolution * 3, 4);
|
|
}
|
|
|
|
return {
|
|
raw,
|
|
base,
|
|
current: base,
|
|
target: base,
|
|
decimals,
|
|
integerWidth: integerPart.length,
|
|
leadingZero,
|
|
resolution,
|
|
kind,
|
|
min,
|
|
max,
|
|
circular,
|
|
macroScale,
|
|
flutterOffset: 0,
|
|
nextFlutterAt: performance.now() + obsRand(350, 1400),
|
|
correctionKick: 0,
|
|
correctionUntil: 0
|
|
};
|
|
}
|
|
|
|
function getStatusMutationRule(source) {
|
|
const text = source.trim();
|
|
const upper = text.toUpperCase();
|
|
|
|
// Camera location names are identities, not status fields.
|
|
if (/^CAM\s+\d+\s*\/\//i.test(text)) return null;
|
|
|
|
if (text.includes('//')) {
|
|
const splitAt = text.lastIndexOf('//');
|
|
const prefix = text.slice(0, splitAt + 2).trimEnd();
|
|
const suffix = text.slice(splitAt + 2).trim();
|
|
|
|
// These are display/status phrases and can safely wander.
|
|
if (/(PASSIVE|NOMINAL|ACTIVE|OBSERVATION|WATCH|FEED|SENSORS|SECURE|FIELD|ARRAY|CONTROL)/i.test(suffix) ||
|
|
/(SENSOR|VECTOR|TACTICAL|OPTICAL|BIOLOGICAL|TRAFFIC|LIVE)/i.test(prefix)) {
|
|
return { type: 'suffix', prefix, original: suffix };
|
|
}
|
|
}
|
|
|
|
// A few colon-delimited fantasy statuses.
|
|
const colonMatch = text.match(/^(ROUTE|TRANSPONDER)\s*:\s*(.+)$/i);
|
|
if (colonMatch) {
|
|
return { type: 'colon', prefix: colonMatch[1].toUpperCase(), original: colonMatch[2].trim() };
|
|
}
|
|
|
|
const plain = observationPlainTextVariants[activeUniverseId];
|
|
if (plain && plain[upper]) {
|
|
return { type: 'plain', original: upper, variants: plain[upper] };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function instrumentObservationTextNode(node) {
|
|
if (!node || node.nodeType !== 1 || node.dataset.obsLiveInstrumented === '1') return;
|
|
|
|
const source = (node.textContent || '').trim();
|
|
if (!source) return;
|
|
|
|
const numericMatches = [...source.matchAll(/[-+]?\d+(?:\.\d+)?/g)];
|
|
const numbers = numericMatches.map(m => classifyObservationNumber(source, m[0], m.index));
|
|
|
|
const state = {
|
|
node,
|
|
source,
|
|
numbers,
|
|
numberMatches: numericMatches.map(m => ({ raw: m[0], index: m.index })),
|
|
statusRule: getStatusMutationRule(source),
|
|
statusOverride: null,
|
|
statusUntil: 0,
|
|
nextTextGlitchAt: performance.now() + obsRand(2500, 9000),
|
|
glitchUntil: 0,
|
|
glitchSeed: 0
|
|
};
|
|
|
|
node.dataset.obsLiveInstrumented = '1';
|
|
node.classList.add('obs-live-instrument');
|
|
observationInstrumentStates.push(state);
|
|
}
|
|
|
|
function instrumentObservationTree(root) {
|
|
if (!root || !root.querySelectorAll) return;
|
|
if (root.matches && root.matches('text')) instrumentObservationTextNode(root);
|
|
root.querySelectorAll('text').forEach(instrumentObservationTextNode);
|
|
}
|
|
|
|
function initializeObservationLiveInstrumentation() {
|
|
stopObservationLiveInstrumentation(false);
|
|
observationInstrumentStates = [];
|
|
|
|
instrumentObservationTree(observationStage);
|
|
instrumentObservationTree(observationEventLayer);
|
|
|
|
observationTransientObserver = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
mutation.addedNodes.forEach(node => {
|
|
if (node.nodeType === 1) instrumentObservationTree(node);
|
|
});
|
|
}
|
|
});
|
|
observationTransientObserver.observe(observationEventLayer, { childList: true, subtree: true });
|
|
|
|
scheduleObservationInstrumentTick();
|
|
}
|
|
|
|
function stopObservationLiveInstrumentation(clearStates = true) {
|
|
if (observationInstrumentTimer) {
|
|
clearTimeout(observationInstrumentTimer);
|
|
observationInstrumentTimer = null;
|
|
}
|
|
if (observationTransientObserver) {
|
|
observationTransientObserver.disconnect();
|
|
observationTransientObserver = null;
|
|
}
|
|
if (clearStates) observationInstrumentStates = [];
|
|
}
|
|
|
|
function formatObservationNumber(value, token) {
|
|
let v = value;
|
|
|
|
if (token.circular) {
|
|
const span = token.max - token.min;
|
|
v = ((v - token.min) % span + span) % span + token.min;
|
|
} else {
|
|
if (Number.isFinite(token.min)) v = Math.max(token.min, v);
|
|
if (Number.isFinite(token.max)) v = Math.min(token.max, v);
|
|
}
|
|
|
|
// Identity-like fields stay integer but can still "flutter" by one count.
|
|
if (token.kind === 'identifier') v = Math.round(v);
|
|
|
|
let result = token.decimals > 0 ? v.toFixed(token.decimals) : String(Math.round(v));
|
|
|
|
if (token.leadingZero) {
|
|
const sign = result.startsWith('-') ? '-' : '';
|
|
const unsigned = result.replace(/^-/, '');
|
|
const parts = unsigned.split('.');
|
|
parts[0] = parts[0].padStart(token.integerWidth, '0');
|
|
result = sign + parts.join('.');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function shortestCircularDelta(current, target, min, max) {
|
|
const span = max - min;
|
|
let delta = target - current;
|
|
if (delta > span / 2) delta -= span;
|
|
if (delta < -span / 2) delta += span;
|
|
return delta;
|
|
}
|
|
|
|
function rebuildObservationText(state, now) {
|
|
if (!state.node || !state.node.isConnected) return;
|
|
|
|
let cursor = 0;
|
|
let built = '';
|
|
|
|
state.numberMatches.forEach((match, i) => {
|
|
const token = state.numbers[i];
|
|
built += state.source.slice(cursor, match.index);
|
|
|
|
let displayValue = token.current + token.flutterOffset;
|
|
|
|
if (token.correctionUntil > now) {
|
|
const remaining = (token.correctionUntil - now) / 1400;
|
|
displayValue += token.correctionKick * Math.max(0, Math.min(1, remaining));
|
|
}
|
|
|
|
built += formatObservationNumber(displayValue, token);
|
|
cursor = match.index + match.raw.length;
|
|
});
|
|
|
|
built += state.source.slice(cursor);
|
|
|
|
if (state.statusOverride && state.statusUntil > now && state.statusRule) {
|
|
const rule = state.statusRule;
|
|
if (rule.type === 'suffix') {
|
|
const splitAt = built.lastIndexOf('//');
|
|
if (splitAt >= 0) built = `${built.slice(0, splitAt + 2)} ${state.statusOverride}`;
|
|
} else if (rule.type === 'colon') {
|
|
const colonAt = built.indexOf(':');
|
|
if (colonAt >= 0) built = `${built.slice(0, colonAt + 1)} ${state.statusOverride}`;
|
|
} else if (rule.type === 'plain') {
|
|
built = state.statusOverride;
|
|
}
|
|
} else if (state.statusOverride && state.statusUntil <= now) {
|
|
state.statusOverride = null;
|
|
}
|
|
|
|
// Very brief display corruption. This is visual noise only and always recovers.
|
|
if (state.glitchUntil > now && built.length > 3) {
|
|
const chars = [...built];
|
|
const candidates = [];
|
|
for (let i = 1; i < chars.length - 1; i++) {
|
|
if (/[A-Z0-9]/i.test(chars[i])) candidates.push(i);
|
|
}
|
|
if (candidates.length) {
|
|
const idx = candidates[state.glitchSeed % candidates.length];
|
|
chars[idx] = obsPick(['_','?','·','Ξ','0','1']);
|
|
built = chars.join('');
|
|
state.node.classList.add('obs-live-glitch');
|
|
}
|
|
} else {
|
|
state.node.classList.remove('obs-live-glitch');
|
|
}
|
|
|
|
state.node.textContent = built;
|
|
}
|
|
|
|
function updateObservationNumberToken(token, now) {
|
|
const activity = observationActivity;
|
|
|
|
let delta = token.circular
|
|
? shortestCircularDelta(token.current, token.target, token.min, token.max)
|
|
: token.target - token.current;
|
|
|
|
// Activity controls how quickly the displayed instrument tracks its target.
|
|
const trackingFraction = 0.07 + activity * 0.31;
|
|
if (Math.abs(delta) > token.resolution * .15) {
|
|
token.current += delta * trackingFraction;
|
|
} else {
|
|
token.current = token.target;
|
|
}
|
|
|
|
// Micro flutter: the least-significant digit hunts around the real value.
|
|
if (now >= token.nextFlutterAt) {
|
|
const flutterChance = 0.34 + activity * 0.56;
|
|
if (Math.random() < flutterChance) {
|
|
const direction = obsPick([-1, 1]);
|
|
const steps = Math.random() < (.18 + activity * .2) ? 2 : 1;
|
|
const identifierDamping = token.kind === 'identifier' ? .55 : 1;
|
|
token.flutterOffset = direction * token.resolution * steps * identifierDamping;
|
|
} else {
|
|
token.flutterOffset = 0;
|
|
}
|
|
|
|
const slow = 1850;
|
|
const fast = 240;
|
|
token.nextFlutterAt = now + (slow - (slow-fast) * activity) * obsRand(.65, 1.35);
|
|
} else if (Math.random() < .16 + activity * .18) {
|
|
token.flutterOffset *= .45;
|
|
if (Math.abs(token.flutterOffset) < token.resolution * .15) token.flutterOffset = 0;
|
|
}
|
|
|
|
if (token.circular) {
|
|
const span = token.max - token.min;
|
|
token.current = ((token.current - token.min) % span + span) % span + token.min;
|
|
} else {
|
|
if (Number.isFinite(token.min)) token.current = Math.max(token.min, token.current);
|
|
if (Number.isFinite(token.max)) token.current = Math.min(token.max, token.current);
|
|
}
|
|
}
|
|
|
|
function maybeGlitchObservationText(state, now) {
|
|
if (now < state.nextTextGlitchAt) return;
|
|
|
|
const activity = observationActivity;
|
|
const density = getObservationTelemetryDensity();
|
|
const chance = .06 + activity * .18 + density * .05;
|
|
|
|
if (Math.random() < chance) {
|
|
state.glitchSeed = obsInt(0, 100000);
|
|
state.glitchUntil = now + obsRand(90, 260 + activity * 180);
|
|
}
|
|
|
|
const slow = 17000;
|
|
const fast = 2800;
|
|
state.nextTextGlitchAt = now + (slow - (slow-fast) * activity) * obsRand(.6, 1.4);
|
|
}
|
|
|
|
function observationInstrumentTick() {
|
|
observationInstrumentTimer = null;
|
|
if (!observationActive) return;
|
|
|
|
const now = performance.now();
|
|
observationInstrumentStates = observationInstrumentStates.filter(state => state.node && state.node.isConnected);
|
|
|
|
for (const state of observationInstrumentStates) {
|
|
state.numbers.forEach(token => updateObservationNumberToken(token, now));
|
|
maybeGlitchObservationText(state, now);
|
|
rebuildObservationText(state, now);
|
|
}
|
|
|
|
scheduleObservationInstrumentTick();
|
|
}
|
|
|
|
function scheduleObservationInstrumentTick() {
|
|
if (!observationActive) return;
|
|
if (observationInstrumentTimer) clearTimeout(observationInstrumentTimer);
|
|
|
|
// Observation level directly controls the "instrument refresh rate".
|
|
// At quiet settings values settle visibly and slowly; at high settings
|
|
// they look like fast live telemetry.
|
|
const slowMs = 900;
|
|
const fastMs = 105;
|
|
const delay = slowMs - (slowMs - fastMs) * Math.pow(observationActivity, .88);
|
|
observationInstrumentTimer = setTimeout(observationInstrumentTick, delay);
|
|
}
|
|
|
|
function chooseObservationTarget(token, density) {
|
|
const amplitudeFloor = token.resolution;
|
|
const amplitude = Math.max(
|
|
amplitudeFloor,
|
|
token.macroScale * (.06 + Math.pow(density, 1.12))
|
|
);
|
|
|
|
let delta;
|
|
if (token.kind === 'identifier') {
|
|
const maxStep = Math.max(1, Math.round(1 + density * 3));
|
|
delta = obsPick([-1,1]) * obsInt(1, maxStep);
|
|
} else {
|
|
delta = obsRand(-amplitude, amplitude);
|
|
}
|
|
|
|
let target = token.target + delta;
|
|
|
|
if (token.circular) {
|
|
const span = token.max - token.min;
|
|
target = ((target - token.min) % span + span) % span + token.min;
|
|
} else {
|
|
if (Number.isFinite(token.min)) target = Math.max(token.min, target);
|
|
if (Number.isFinite(token.max)) target = Math.min(token.max, target);
|
|
}
|
|
|
|
token.target = target;
|
|
|
|
// Sometimes a display control-loop overshoots and corrects itself.
|
|
if (Math.random() < .28 + observationActivity * .24) {
|
|
const sign = Math.random() < .5 ? -1 : 1;
|
|
const kickSteps = token.kind === 'identifier' ? 1 : obsRand(.6, 2.2 + observationActivity * 1.5);
|
|
token.correctionKick = sign * token.resolution * kickSteps;
|
|
token.correctionUntil = performance.now() + obsRand(550, 1450);
|
|
}
|
|
}
|
|
|
|
function chooseObservationStatusOverride(state, density) {
|
|
if (!state.statusRule) return;
|
|
|
|
let variants;
|
|
const rule = state.statusRule;
|
|
|
|
if (rule.type === 'plain') {
|
|
variants = rule.variants || [];
|
|
} else {
|
|
variants = observationTextVocabulary[activeUniverseId] || observationTextVocabulary.starfleet;
|
|
}
|
|
|
|
if (!variants.length) return;
|
|
|
|
const original = rule.original.toUpperCase();
|
|
const choices = variants.filter(v => v !== original);
|
|
if (!choices.length) return;
|
|
|
|
state.statusOverride = obsPick(choices);
|
|
|
|
// Higher observation activity means state changes read faster and clear sooner.
|
|
const holdSlow = 6200;
|
|
const holdFast = 1700;
|
|
state.statusUntil = performance.now() +
|
|
(holdSlow - (holdSlow-holdFast) * observationActivity) * obsRand(.7, 1.35);
|
|
}
|
|
|
|
function nudgeObservationInstruments(densityOverride = null) {
|
|
if (!observationActive) return;
|
|
|
|
const density = densityOverride == null
|
|
? getObservationTelemetryDensity()
|
|
: obsClamp(Number(densityOverride) || 0, 0, 1);
|
|
|
|
const liveStates = observationInstrumentStates.filter(s => s.node && s.node.isConnected);
|
|
const numericStates = liveStates.filter(s => s.numbers.length);
|
|
const statusStates = liveStates.filter(s => s.statusRule);
|
|
|
|
// Density controls how much of the imaginary system is disturbed by a chirp.
|
|
const desiredNumeric = Math.min(
|
|
numericStates.length,
|
|
Math.max(1, Math.round(1 + density * (2 + numericStates.length * .22)))
|
|
);
|
|
|
|
const shuffledNumbers = [...numericStates].sort(() => Math.random() - .5);
|
|
for (let i = 0; i < desiredNumeric; i++) {
|
|
const state = shuffledNumbers[i];
|
|
state.numbers.forEach(token => {
|
|
// A dense chirp can perturb several values in a compound readout.
|
|
if (Math.random() < .55 + density * .4) chooseObservationTarget(token, density);
|
|
});
|
|
}
|
|
|
|
// Text states are less frequent than numeric movement. They read as system
|
|
// transitions rather than a constantly changing label soup.
|
|
if (statusStates.length && Math.random() < .28 + density * .5) {
|
|
const desiredText = density > .72 && Math.random() < density ? 2 : 1;
|
|
const shuffledText = [...statusStates].sort(() => Math.random() - .5);
|
|
for (let i = 0; i < Math.min(desiredText, shuffledText.length); i++) {
|
|
chooseObservationStatusOverride(shuffledText[i], density);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function seededUnit(seed) {
|
|
let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
}
|
|
|
|
function makeStars(count = 90, width = 1600, height = 900, tint = '#dbeafe', seedOffset = 0) {
|
|
let stars = '';
|
|
for (let i = 0; i < count; i++) {
|
|
const x = Math.floor(seededUnit(i + 1 + seedOffset) * width);
|
|
const y = Math.floor(seededUnit((i + 1 + seedOffset) * 7.31) * height);
|
|
const r = (0.55 + seededUnit((i + seedOffset + 4) * 3.17) * 1.65).toFixed(2);
|
|
const o = (0.25 + seededUnit((i + seedOffset + 9) * 5.19) * 0.72).toFixed(2);
|
|
stars += `<circle cx="${x}" cy="${y}" r="${r}" fill="${tint}" opacity="${o}"/>`;
|
|
}
|
|
return stars;
|
|
}
|
|
|
|
function svgFrame(inner, defs = '') {
|
|
return `
|
|
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice"
|
|
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Theme observation display">
|
|
<defs>
|
|
<radialGradient id="obsGlow" cx="50%" cy="50%">
|
|
<stop offset="0%" stop-color="var(--primary-accent)" stop-opacity=".32"/>
|
|
<stop offset="65%" stop-color="var(--primary-accent)" stop-opacity=".05"/>
|
|
<stop offset="100%" stop-color="#000" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<linearGradient id="obsFade" x1="0" x2="1">
|
|
<stop offset="0%" stop-color="var(--primary-accent)" stop-opacity=".05"/>
|
|
<stop offset="50%" stop-color="var(--primary-accent)" stop-opacity=".42"/>
|
|
<stop offset="100%" stop-color="var(--primary-accent)" stop-opacity=".05"/>
|
|
</linearGradient>
|
|
<filter id="obsSoftGlow">
|
|
<feGaussianBlur stdDeviation="5" result="blur"/>
|
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
${defs}
|
|
</defs>
|
|
${inner}
|
|
</svg>`;
|
|
}
|
|
|
|
function renderObservationStarfleet() {
|
|
const starsA = makeStars(115, 1750, 900, '#dbeafe', 2);
|
|
const starsB = makeStars(55, 1750, 900, '#93c5fd', 220);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#02050a"/>
|
|
<g style="animation:obs-drift-left 80s linear infinite">${starsA}</g>
|
|
<g opacity=".55" style="animation:obs-drift-right 120s linear infinite">${starsB}</g>
|
|
|
|
<circle cx="800" cy="455" r="285" fill="url(#obsGlow)" opacity=".45"/>
|
|
<g fill="none" stroke="var(--primary-accent)" opacity=".46">
|
|
<circle cx="800" cy="455" r="240" stroke-width="2"/>
|
|
<circle cx="800" cy="455" r="170" stroke-width="1"/>
|
|
<circle cx="800" cy="455" r="100" stroke-width="1"/>
|
|
<path d="M520 455H1080M800 175V735" opacity=".42"/>
|
|
</g>
|
|
<g class="obs-spin-slow" fill="none" stroke="var(--tertiary-accent)" stroke-width="3" opacity=".55">
|
|
<path d="M800 200 A255 255 0 0 1 1055 455"/>
|
|
<path d="M800 710 A255 255 0 0 1 545 455"/>
|
|
</g>
|
|
<g fill="var(--primary-accent)" filter="url(#obsSoftGlow)">
|
|
<circle class="obs-blink" cx="976" cy="355" r="5"/>
|
|
<circle cx="650" cy="535" r="4" opacity=".8"/>
|
|
<circle class="obs-blink" cx="885" cy="610" r="3"/>
|
|
</g>
|
|
<g transform="translate(800 455)" opacity=".52">
|
|
<g class="obs-spin-slow">
|
|
<circle cx="0" cy="-205" r="5" fill="var(--tertiary-accent)"/>
|
|
<circle cx="177" cy="102" r="3" fill="var(--primary-accent)"/>
|
|
<circle cx="-155" cy="135" r="4" fill="var(--secondary-accent)"/>
|
|
</g>
|
|
</g>
|
|
<g opacity=".38" class="obs-ambient-glow">
|
|
<path d="M250 620 C430 550 515 675 690 600 S1040 525 1355 615"
|
|
fill="none" stroke="var(--tertiary-accent)" stroke-width="2" class="obs-dashflow"/>
|
|
</g>
|
|
|
|
<g fill="none" stroke="var(--secondary-accent)" stroke-width="7" opacity=".8">
|
|
<path d="M68 160H360M68 160V310"/>
|
|
<path d="M1532 160H1240M1532 160V310"/>
|
|
<path d="M68 740H360M68 740V600"/>
|
|
<path d="M1532 740H1240M1532 740V600"/>
|
|
</g>
|
|
<g fill="var(--secondary-accent)" opacity=".72">
|
|
<rect x="85" y="198" width="210" height="16" rx="8"/>
|
|
<rect x="85" y="226" width="150" height="11" rx="6"/>
|
|
<rect x="1305" y="198" width="210" height="16" rx="8"/>
|
|
<rect x="1365" y="226" width="150" height="11" rx="6"/>
|
|
</g>
|
|
<text x="94" y="700" fill="var(--tertiary-accent)" opacity=".65" font-size="18" font-family="monospace">LONG RANGE SENSOR ARRAY // PASSIVE</text>
|
|
<text x="1290" y="700" fill="var(--tertiary-accent)" opacity=".65" font-size="18" font-family="monospace" text-anchor="end">NAV VECTOR 034.8 // NOMINAL</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationWhoniverse() {
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#020817"/>
|
|
<circle cx="800" cy="455" r="420" fill="url(#obsGlow)" opacity=".5"/>
|
|
<g fill="none" stroke="#00e5ff" opacity=".38">
|
|
<ellipse class="obs-spin" cx="800" cy="455" rx="430" ry="170" stroke-width="3"/>
|
|
<g transform="rotate(58 800 455)">
|
|
<ellipse class="obs-spin-rev" cx="800" cy="455" rx="350" ry="105" stroke-width="2"/>
|
|
</g>
|
|
<g transform="rotate(-34 800 455)">
|
|
<ellipse class="obs-spin-slow" cx="800" cy="455" rx="270" ry="70" stroke-width="4"/>
|
|
</g>
|
|
</g>
|
|
<g class="obs-breathe">
|
|
<rect x="760" y="220" width="80" height="470" rx="30" fill="#00e5ff" opacity=".08"/>
|
|
<rect x="782" y="250" width="36" height="410" rx="18" fill="#d4af37" opacity=".34"/>
|
|
<circle cx="800" cy="455" r="64" fill="#00e5ff" opacity=".2" filter="url(#obsSoftGlow)"/>
|
|
</g>
|
|
<g fill="none" stroke="#d4af37" opacity=".5" stroke-width="2">
|
|
<circle class="obs-spin-slow" cx="800" cy="455" r="115" stroke-dasharray="8 16"/>
|
|
<circle class="obs-spin-rev" cx="800" cy="455" r="180" stroke-dasharray="3 28"/>
|
|
</g>
|
|
<path d="M185 700 C420 570 520 770 720 640 S1090 560 1410 705"
|
|
fill="none" stroke="#00e5ff" stroke-width="3" opacity=".34" class="obs-dashflow"/>
|
|
<g opacity=".48" font-family="monospace" fill="#d4af37">
|
|
<text x="325" y="330" font-size="22" class="obs-spin-slow">◉</text>
|
|
<text x="1220" y="520" font-size="28" class="obs-spin-rev">◎</text>
|
|
<text x="420" y="610" font-size="18" class="obs-ambient-wobble">⌁ 37.4 ∴ 9</text>
|
|
<text x="1120" y="290" font-size="18" class="obs-ambient-wobble">∆ 04:11:∞</text>
|
|
</g>
|
|
<text x="800" y="775" text-anchor="middle" fill="#d4af37" font-size="21" font-family="monospace" opacity=".72">
|
|
TEMPORAL DISPLACEMENT // VORTEX OBSERVATION
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationIndustrial() {
|
|
// The industrial viewport frame (getViewportFrameSvg) masks everything
|
|
// outside x 140..1460, y 160..740, so the whole surveillance console is
|
|
// laid out inside that safe area. v12 ran the feeds to x 110..1490 and had
|
|
// its edges eaten by the bulkhead.
|
|
const SAFE_X0 = 156, SAFE_X1 = 1444;
|
|
|
|
// Feeds are authored at 650x270 and drawn through a uniform scale, so the
|
|
// scenes below can keep one readable coordinate space.
|
|
const CW = 650, CH = 270, S = 0.78;
|
|
const IX = 14, IY = 14, IW = 622, IH = 242;
|
|
const CAM_X = [279, 814], CAM_Y = [248, 472];
|
|
|
|
// Identity text (camera names, equipment designations, container IDs) is
|
|
// opted OUT of the live-instrumentation number drift by pre-stamping the
|
|
// flag it checks. Only telemetry should wander; "CAM 02" must not quietly
|
|
// become "CAM 01", which is exactly what v12 did to every label it drew.
|
|
const ID = 'data-obs-live-instrumented="1"';
|
|
|
|
// ---------------------------------------------------------------- parts
|
|
const crate = (x, y, w, h, body, edge, id, idColor) => {
|
|
let ribs = '';
|
|
for (let i = 9; i < w - 5; i += 9) ribs += `M${i} 4V${h - 4}`;
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<rect width="${w}" height="${h}" rx="1.5" fill="${body}" stroke="#070504" stroke-width="1.5"/>
|
|
<path d="${ribs}" fill="none" stroke="${edge}" stroke-width="1" opacity=".32"/>
|
|
<rect width="${w}" height="5" fill="#ffffff" opacity=".05"/>
|
|
<rect y="${h - 6}" width="${w}" height="6" fill="#000000" opacity=".34"/>
|
|
<g fill="#241a12">
|
|
<rect width="8" height="8"/><rect x="${w - 8}" width="8" height="8"/>
|
|
<rect y="${h - 8}" width="8" height="8"/><rect x="${w - 8}" y="${h - 8}" width="8" height="8"/>
|
|
</g>
|
|
<text x="${w / 2}" y="${h / 2 + 4}" text-anchor="middle" font-family="monospace"
|
|
font-size="11" fill="${idColor}" opacity=".78" ${ID}>${id}</text>
|
|
</g>`;
|
|
};
|
|
|
|
// Vent vapour authored as in-scene geometry so the camera clip contains it.
|
|
// v12 floated these on the sim layer, which is why steam drifted over the
|
|
// bezels and read as detached from any feed.
|
|
const vapour = (x, y, count, seed, scale = 1) => {
|
|
let out = '';
|
|
for (let i = 0; i < count; i++) {
|
|
const dur = (4.5 + seededUnit(seed + i) * 3.6).toFixed(1);
|
|
const delay = (i * (dur / count)).toFixed(1);
|
|
const vx = Math.round(-24 + seededUnit(seed + i * 3.1) * 48);
|
|
const r = ((9 + seededUnit(seed + i * 5.7) * 13) * scale).toFixed(1);
|
|
out += `<circle cx="${x}" cy="${y}" r="${r}" fill="url(#indVapour)" opacity="0"
|
|
style="--vx:${vx}px;animation:obs-vapour ${dur}s ease-out infinite;animation-delay:-${delay}s"/>`;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const gauge = (x, y, r, label, needleColor, delay) => {
|
|
let ticks = '';
|
|
for (let a = -120; a <= 120; a += 30) {
|
|
const rad = (a - 90) * Math.PI / 180;
|
|
ticks += `M${(Math.cos(rad) * (r - 3)).toFixed(1)} ${(Math.sin(rad) * (r - 3)).toFixed(1)}` +
|
|
`L${(Math.cos(rad) * (r - 8)).toFixed(1)} ${(Math.sin(rad) * (r - 8)).toFixed(1)}`;
|
|
}
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="${r}" fill="#0c0a08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<circle r="${r - 2}" fill="none" stroke="#1a1410" stroke-width="1"/>
|
|
<path d="${ticks}" stroke="#6b5537" stroke-width="1.4" fill="none"/>
|
|
<g style="transform-box:fill-box;transform-origin:bottom center;animation:obs-needle-hunt ${(9 + delay).toFixed(1)}s ease-in-out infinite;animation-delay:-${delay}s">
|
|
<rect x="-1" y="${-(r - 6)}" width="2" height="${r - 6}" fill="${needleColor}"/>
|
|
</g>
|
|
<circle r="2.6" fill="#8a5b25"/>
|
|
<text y="${r + 13}" text-anchor="middle" font-family="monospace" font-size="11" fill="#9a7a4c" ${ID}>${label}</text>
|
|
</g>`;
|
|
};
|
|
|
|
// Per-feed video artifacts. This replaces v12's single screen-wide sweep
|
|
// bar: each feed rolls, tears and grains on its own clock, inside its own
|
|
// clip, so the artifacts read as video rather than as a screen overlay.
|
|
const feedFx = (i) => `
|
|
<g clip-path="url(#indCamClip)">
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="url(#indScan)" opacity=".5"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" filter="url(#indGrainF)" class="obs-feed-grain"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#ffd9a0" class="obs-feed-agc"
|
|
style="animation-delay:-${(i * 2.3).toFixed(1)}s"/>
|
|
<g class="obs-feed-roll" style="animation-delay:-${(i * 1.9).toFixed(1)}s">
|
|
<rect x="${IX}" y="0" width="${IW}" height="60" fill="url(#indRoll)"/>
|
|
</g>
|
|
<g class="obs-feed-tear" style="animation-delay:-${(i * 3.1).toFixed(1)}s">
|
|
<rect x="${IX}" y="0" width="${IW}" height="6" fill="#ffffff" opacity=".22"/>
|
|
<rect x="${IX}" y="6" width="${IW}" height="3" fill="#ffaa00" opacity=".28"/>
|
|
<rect x="${IX}" y="11" width="${IW}" height="2" fill="#ffffff" opacity=".14"/>
|
|
</g>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="url(#indFeedVig)"/>
|
|
</g>`;
|
|
|
|
const cam = (i, label, code, status, scene) => `
|
|
<g transform="translate(${CAM_X[i % 2]} ${CAM_Y[i < 2 ? 0 : 1]}) scale(${S})">
|
|
<rect x="-6" y="-6" width="${CW + 12}" height="${CH + 12}" rx="9" fill="#0b0907" stroke="#2a1d10" stroke-width="2"/>
|
|
<rect width="${CW}" height="${CH}" rx="5" fill="url(#indBezel)" stroke="#7c4a16" stroke-width="2"/>
|
|
<g fill="#2e2216">
|
|
<circle cx="7" cy="7" r="3"/><circle cx="${CW - 7}" cy="7" r="3"/>
|
|
<circle cx="7" cy="${CH - 7}" r="3"/><circle cx="${CW - 7}" cy="${CH - 7}" r="3"/>
|
|
</g>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#07090a"/>
|
|
<g clip-path="url(#indCamClip)">
|
|
<g style="animation:obs-crt-jump ${13 + i * 2}s steps(1,end) infinite">${scene}</g>
|
|
</g>
|
|
${feedFx(i)}
|
|
<g font-family="monospace">
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="26" fill="#000000" opacity=".45"/>
|
|
<circle cx="${IX + 15}" cy="${IY + 13}" r="4.5" fill="#ef4444" class="obs-blink"/>
|
|
<text x="${IX + 28}" y="${IY + 19}" fill="#ffaa00" font-size="17" ${ID}>${label}</text>
|
|
<text x="${IX + IW - 10}" y="${IY + 19}" text-anchor="end" fill="#a8814d" font-size="14" ${ID}>${code}</text>
|
|
<rect x="${IX}" y="${IY + IH - 24}" width="${IW}" height="24" fill="#000000" opacity=".45"/>
|
|
<text x="${IX + 10}" y="${IY + IH - 7}" fill="#c8964a" font-size="14" ${ID}>${status}</text>
|
|
<text x="${IX + IW - 10}" y="${IY + IH - 7}" text-anchor="end" fill="#7f6238" font-size="13" ${ID}>LIVE // SECURE FEED</text>
|
|
</g>
|
|
<g class="obs-cam-focus" style="animation-delay:-${i * 8}s">
|
|
<rect x="-6" y="-6" width="${CW + 12}" height="${CH + 12}" rx="9" fill="none" stroke="#ffaa00" stroke-width="3"/>
|
|
<rect x="${IX + IW - 112}" y="${IY + IH - 52}" width="102" height="21" rx="2" fill="#7f1d1d" opacity=".88"/>
|
|
<text x="${IX + IW - 61}" y="${IY + IH - 37}" text-anchor="middle" font-family="monospace" font-size="13" fill="#ffe4e6" ${ID}>SELECTED</text>
|
|
</g>
|
|
</g>`;
|
|
|
|
// ------------------------------------------------- procedural geometry
|
|
let bayChev = '';
|
|
for (let i = 0; i < 660; i += 16) bayChev += `M${i} 222L${i + 8} 212H${i + 16}L${i + 8} 222Z`;
|
|
let bayDeck = '';
|
|
for (let i = 20; i < 640; i += 44) bayDeck += `M${i} 214V256`;
|
|
|
|
let coreSeg = '';
|
|
for (let a = 0; a < 360; a += 45) {
|
|
const rad = a * Math.PI / 180;
|
|
coreSeg += `M${(Math.cos(rad) * 62).toFixed(1)} ${(Math.sin(rad) * 62).toFixed(1)}` +
|
|
`L${(Math.cos(rad) * 86).toFixed(1)} ${(Math.sin(rad) * 86).toFixed(1)}`;
|
|
}
|
|
let rods = '';
|
|
for (let i = 0; i < 7; i++) {
|
|
const rx = -60 + i * 20;
|
|
rods += `<g style="animation:obs-rod-travel ${(9 + i * 1.7).toFixed(1)}s ease-in-out infinite;animation-delay:-${i}s">
|
|
<rect x="${rx - 3}" y="0" width="6" height="34" fill="#6b5537"/>
|
|
<rect x="${rx - 4.5}" y="30" width="9" height="8" rx="1" fill="#b45309"/>
|
|
</g>`;
|
|
}
|
|
const trefoil = [0, 120, 240].map(a =>
|
|
`<path transform="rotate(${a})" d="M-4.6 -6.4A8 8 0 0 1 4.6 -6.4L2.5 -2.8A4 4 0 0 0 -2.5 -2.8Z"/>`).join('');
|
|
let plantGrate = '';
|
|
for (let i = 20; i < 640; i += 40) plantGrate += `M${i} 210V256`;
|
|
|
|
let dogs = '';
|
|
for (let a = 0; a < 360; a += 45) {
|
|
const rad = a * Math.PI / 180;
|
|
dogs += `<g transform="translate(${(Math.cos(rad) * 66).toFixed(1)} ${(Math.sin(rad) * 66).toFixed(1)}) rotate(${a})">
|
|
<rect x="-7" y="-6" width="14" height="12" rx="2" fill="#4a565f" stroke="#1b2126" stroke-width="1.4"/>
|
|
<rect x="-2" y="-6" width="4" height="12" fill="#8fa3b0" opacity=".45"/>
|
|
</g>`;
|
|
}
|
|
let hazRing = '';
|
|
for (let a = 0; a < 360; a += 15) {
|
|
if ((a / 15) % 2) continue;
|
|
const r0 = 78, r1 = 90, a0 = a * Math.PI / 180, a1 = (a + 15) * Math.PI / 180;
|
|
hazRing += `M${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}` +
|
|
`L${(Math.cos(a0) * r1).toFixed(1)} ${(Math.sin(a0) * r1).toFixed(1)}` +
|
|
`A${r1} ${r1} 0 0 1 ${(Math.cos(a1) * r1).toFixed(1)} ${(Math.sin(a1) * r1).toFixed(1)}` +
|
|
`L${(Math.cos(a1) * r0).toFixed(1)} ${(Math.sin(a1) * r0).toFixed(1)}` +
|
|
`A${r0} ${r0} 0 0 0 ${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}Z`;
|
|
}
|
|
const lamp = (y, txt, col, i) => `
|
|
<g transform="translate(0 ${y})">
|
|
<g style="animation:obs-airlock-cycle 16s linear infinite;animation-delay:-${(i * 16 / 3).toFixed(2)}s">
|
|
<circle cx="-34" r="9" fill="${col}" opacity=".22"/>
|
|
<circle cx="-34" r="5" fill="${col}"/>
|
|
</g>
|
|
<circle cx="-34" r="5" fill="none" stroke="#2b343b" stroke-width="1.4"/>
|
|
<text x="-22" y="4" font-family="monospace" font-size="12" fill="#9aa9b4" ${ID}>${txt}</text>
|
|
</g>`;
|
|
const suits = [0, 1, 2].map(i => `
|
|
<g transform="translate(${i * 30} 0)">
|
|
<rect width="26" height="88" rx="2" fill="#0e1215" stroke="#28313a" stroke-width="1.4"/>
|
|
<rect x="4" y="8" width="18" height="54" rx="8" fill="#1b2228"/>
|
|
<circle cx="13" cy="18" r="6" fill="#0a0e11" stroke="#3a454e" stroke-width="1.2"/>
|
|
<rect x="7" y="28" width="12" height="26" rx="3" fill="#2a3138"/>
|
|
<circle cx="13" cy="78" r="3" fill="${i === 1 ? '#f59e0b' : '#22c55e'}"/>
|
|
</g>`).join('');
|
|
let lockFloor = '';
|
|
for (let i = 20; i < 640; i += 40) lockFloor += `M${i} 214V256`;
|
|
|
|
const vpx = 325, vpy = 128;
|
|
let corrFrames = '', corrFloor = '', corrLamps = '';
|
|
for (let i = 0; i < 7; i++) {
|
|
const s = Math.pow(0.76, i);
|
|
const x0 = vpx + (24 - vpx) * s, x1 = vpx + (626 - vpx) * s;
|
|
const y0 = vpy + (22 - vpy) * s, y1 = vpy + (248 - vpy) * s;
|
|
corrFrames += `<rect x="${x0.toFixed(1)}" y="${y0.toFixed(1)}" width="${(x1 - x0).toFixed(1)}" height="${(y1 - y0).toFixed(1)}"
|
|
fill="none" stroke="#2e2418" stroke-width="${(10 * s).toFixed(1)}" opacity="${(0.92 - i * 0.09).toFixed(2)}"/>`;
|
|
corrFloor += `<path d="M${x0.toFixed(1)} ${y1.toFixed(1)}H${x1.toFixed(1)}" stroke="#2a2118" stroke-width="${(2 * s + 0.4).toFixed(1)}"/>`;
|
|
}
|
|
for (let x = 24; x <= 626; x += 60) {
|
|
corrFloor += `<path d="M${x} 248L${vpx} ${vpy}" stroke="#221a12" stroke-width="1" opacity=".45"/>`;
|
|
}
|
|
for (let i = 1; i < 5; i++) {
|
|
const s = Math.pow(0.76, i);
|
|
const y = vpy + (34 - vpy) * s, w = 62 * s;
|
|
corrLamps += `<g${i === 2 ? ' class="obs-lamp-fail"' : ''}>
|
|
<rect x="${(vpx - w / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${(5 * s + 1).toFixed(1)}" rx="1" fill="#ffe4b5" opacity=".5"/>
|
|
<ellipse cx="${vpx}" cy="${(y + 18 * s).toFixed(1)}" rx="${(w * 1.5).toFixed(1)}" ry="${(24 * s).toFixed(1)}" fill="#f59e0b" opacity=".05"/>
|
|
</g>`;
|
|
}
|
|
const fanBlades = [0, 72, 144, 216, 288].map(a =>
|
|
`<path transform="rotate(${a})" d="M0 0Q14-6 22-17Q10-23 0-8Z"/>`).join('');
|
|
const drip = (x, y, d) =>
|
|
`<g style="animation:obs-drip ${(6 + d).toFixed(1)}s ease-in infinite;animation-delay:-${d}s">
|
|
<ellipse cx="${x}" cy="${y}" rx="1.6" ry="3.4" fill="#9fb6c4" opacity=".65"/></g>`;
|
|
|
|
// ------------------------------------------------------- CAM 01 :: BAY
|
|
// A working cargo hold: a gantry crane on an overhead rail running a real
|
|
// pick-and-place cycle over stacked containers. v12 had a wheeled cart
|
|
// sliding back and forth, which read as a car and made no sense on a ship.
|
|
const sceneCargo = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#0a0b0c"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="url(#indBayLight)"/>
|
|
|
|
<rect x="46" y="34" width="558" height="180" fill="#131110"/>
|
|
<g stroke="#231b14" stroke-width="1.5" fill="none" opacity=".9">
|
|
<path d="M110 34v180M190 34v180M270 34v180M398 34v180M478 34v180M556 34v180"/>
|
|
<path d="M46 80h558M46 126h558M46 172h558"/>
|
|
</g>
|
|
<g>
|
|
<rect x="286" y="112" width="96" height="102" fill="#0c0b0a" stroke="#3d2e1e" stroke-width="2"/>
|
|
<path d="M334 112v102" stroke="#241c14" stroke-width="2"/>
|
|
<rect x="286" y="112" width="96" height="7" fill="#a8801f" opacity=".45"/>
|
|
<circle cx="334" cy="164" r="10" fill="none" stroke="#7c4a16" stroke-width="2"/>
|
|
<circle cx="334" cy="164" r="3" fill="#5c4529"/>
|
|
<text x="334" y="206" text-anchor="middle" font-family="monospace" font-size="11" fill="#8a6a42" opacity=".8" ${ID}>BAY 2 // AFT</text>
|
|
</g>
|
|
<g fill="none" stroke="#2c2218" stroke-width="6"><path d="M46 52h46v162M604 52h-46v162"/></g>
|
|
<path d="M46 52h46v162" fill="none" stroke="#f59e0b" stroke-width="2" stroke-dasharray="11 9" class="obs-pipe-flow" opacity=".4"/>
|
|
|
|
<rect x="${IX}" y="214" width="${IW}" height="42" fill="#100e0c"/>
|
|
<path d="M14 230h622M14 244h622${bayDeck}" stroke="#241d15" stroke-width="1.2" fill="none" opacity=".85"/>
|
|
<path d="${bayChev}" fill="#b8860f" opacity=".4"/>
|
|
<g fill="#241c14">
|
|
<circle cx="70" cy="236" r="3"/><circle cx="300" cy="236" r="3"/><circle cx="560" cy="236" r="3"/>
|
|
</g>
|
|
|
|
<g class="obs-bay-shudder">
|
|
${crate(90, 174, 88, 40, '#5d4427', '#8a6a42', 'CB-4471', '#e8c88a')}
|
|
<g style="animation:obs-gantry-taken var(--obs-gantry-clock) linear infinite">
|
|
${crate(90, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
|
|
</g>
|
|
${crate(182, 174, 88, 40, '#4f3a22', '#7d6039', 'HAZ-3/09', '#f0b64f')}
|
|
<g stroke="#3f3a2e" stroke-width="1.4" opacity=".7" fill="none">
|
|
<path d="M182 186h88M182 202h88M206 174v40M248 174v40"/>
|
|
</g>
|
|
${crate(380, 174, 88, 40, '#5a4426', '#886946', 'CB-3310', '#e8c88a')}
|
|
<g style="animation:obs-gantry-placed var(--obs-gantry-clock) linear infinite">
|
|
${crate(380, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
|
|
</g>
|
|
${crate(472, 174, 88, 40, '#453120', '#6f5535', 'ORE-C', '#d9b070')}
|
|
</g>
|
|
|
|
<g transform="translate(134 0)">
|
|
<g style="animation:obs-gantry-traverse var(--obs-gantry-clock) ease-in-out infinite">
|
|
<g transform="translate(0 62)">
|
|
<g style="animation:obs-gantry-hoist var(--obs-gantry-clock) ease-in-out infinite">
|
|
<rect x="-1.5" y="-300" width="3" height="300" fill="#5f4a33"/>
|
|
<rect x="-2.5" y="-300" width="1" height="300" fill="#8a6f4f" opacity=".55"/>
|
|
<rect x="-50" y="0" width="100" height="9" rx="2" fill="#8a5b25" stroke="#241a10" stroke-width="1.2"/>
|
|
<rect x="-50" y="0" width="100" height="3" fill="#c98a3c" opacity=".45"/>
|
|
<rect x="-46" y="9" width="10" height="8" fill="#3a2b1b"/>
|
|
<rect x="36" y="9" width="10" height="8" fill="#3a2b1b"/>
|
|
<circle cx="0" cy="4.5" r="3" style="animation:obs-gantry-lamp var(--obs-gantry-clock) linear infinite"/>
|
|
<g style="animation:obs-gantry-load var(--obs-gantry-clock) linear infinite">
|
|
${crate(-44, 6, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
|
|
</g>
|
|
</g>
|
|
</g>
|
|
<g transform="translate(0 46)">
|
|
<rect x="-24" y="0" width="48" height="18" rx="2" fill="#3a2b1b" stroke="#7c4a16" stroke-width="1.4"/>
|
|
<circle cx="-15" cy="1" r="4" fill="#181109"/><circle cx="15" cy="1" r="4" fill="#181109"/>
|
|
<rect x="-9" y="6" width="18" height="5" rx="1" fill="#ffaa00" opacity=".55"/>
|
|
</g>
|
|
</g>
|
|
</g>
|
|
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="28" fill="#0b0a09"/>
|
|
<g stroke="#241c14" stroke-width="2" fill="none">
|
|
<path d="M60 14v28M200 14v28M340 14v28M480 14v28M600 14v28"/>
|
|
</g>
|
|
<rect x="34" y="42" width="582" height="9" rx="2" fill="#2f2418" stroke="#5c4529" stroke-width="1.2"/>
|
|
<rect x="34" y="42" width="582" height="3" fill="#7c623c" opacity=".5"/>
|
|
|
|
<g transform="translate(586 66)">
|
|
<g class="obs-beacon-sweep">
|
|
<circle r="46" fill="none"/>
|
|
<path d="M0 0L-46 26L-46 -26Z" fill="#f59e0b" opacity=".11"/>
|
|
</g>
|
|
<rect x="-8" y="-11" width="16" height="6" fill="#2a2118"/>
|
|
<circle r="7" fill="#7c1d0d"/>
|
|
<circle r="5" fill="#f59e0b" class="obs-beacon-flash"/>
|
|
</g>
|
|
|
|
${vapour(250, 250, 6, 12.4, .8)}
|
|
|
|
<g stroke="#2f2418" stroke-width="3" fill="none" opacity=".9">
|
|
<path d="M14 248h622M100 240v16M330 240v16M560 240v16"/>
|
|
</g>
|
|
<g font-family="monospace" font-size="13" fill="#8a6a42" opacity=".8">
|
|
<text x="26" y="66" ${ID}>GANTRY 4 // AUTO CYCLE</text>
|
|
<text x="26" y="84">LOAD 2.4 T</text>
|
|
</g>`;
|
|
|
|
// --------------------------------------------------- CAM 02 :: REACTOR
|
|
const sceneReactor = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#0a0806"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="196" fill="#12100d"/>
|
|
<g stroke="#221a12" stroke-width="1.5" fill="none" opacity=".85">
|
|
<path d="M14 58h622M14 102h622M14 146h622M100 14v196M420 14v196M544 14v196"/>
|
|
</g>
|
|
|
|
<g transform="translate(240 18)">
|
|
<rect x="-74" y="-4" width="148" height="14" rx="2" fill="#1c1611" stroke="#3a2b1b" stroke-width="1.4"/>
|
|
<g transform="translate(0 10)">${rods}</g>
|
|
</g>
|
|
|
|
<g transform="translate(240 128)">
|
|
<circle r="92" fill="#f59e0b" class="obs-core-halo"/>
|
|
<circle r="86" fill="#0b0907"/>
|
|
<circle r="80" fill="none" stroke="#3a2b1b" stroke-width="11"/>
|
|
<path d="${coreSeg}" stroke="#5c4529" stroke-width="2.2" fill="none" opacity=".75"/>
|
|
<circle r="62" fill="none" stroke="#241a10" stroke-width="6"/>
|
|
<g class="obs-pivot" style="animation:obs-rotate 14s linear infinite">
|
|
<circle r="52" fill="none" stroke="#b45309" stroke-width="5" stroke-dasharray="26 16" opacity=".7"/>
|
|
</g>
|
|
<g class="obs-pivot" style="animation:obs-rotate-rev 22s linear infinite">
|
|
<circle r="40" fill="none" stroke="#f59e0b" stroke-width="3" stroke-dasharray="6 12" opacity=".55"/>
|
|
</g>
|
|
<g class="obs-pivot obs-core-load">
|
|
<circle r="30" fill="#fb923c" opacity=".22"/>
|
|
<circle r="18" fill="#fdba74" opacity=".5"/>
|
|
<circle r="9" fill="#fff7ed" opacity=".85"/>
|
|
</g>
|
|
</g>
|
|
|
|
<g fill="none" stroke="#2b2118" stroke-width="9">
|
|
<path d="M14 178h84q14 0 14-14v-44M636 178h-84q-14 0-14-14v-44"/>
|
|
</g>
|
|
<path d="M14 178h84q14 0 14-14v-44" fill="none" stroke="#38bdf8" stroke-width="2.4" stroke-dasharray="12 10" class="obs-pipe-flow" opacity=".45"/>
|
|
<path d="M636 178h-84q-14 0-14-14v-44" fill="none" stroke="#f97316" stroke-width="2.4" stroke-dasharray="12 10" class="obs-pipe-flow" opacity=".42" style="animation-direction:reverse"/>
|
|
|
|
${gauge(424, 74, 26, 'TEMP', '#f59e0b', 0)}
|
|
${gauge(490, 74, 26, 'PRESS', '#38bdf8', 1.4)}
|
|
${gauge(556, 74, 26, 'FLUX', '#22c55e', 2.8)}
|
|
|
|
<g transform="translate(590 152)">
|
|
<rect x="-17" y="-17" width="34" height="34" rx="2" fill="#3d3208" stroke="#ca8a04" stroke-width="1.5"/>
|
|
<g fill="#facc15"><circle r="2.6"/>${trefoil}</g>
|
|
</g>
|
|
|
|
<g class="obs-heat-shimmer"><rect x="150" y="192" width="182" height="20" fill="url(#indHeat)"/></g>
|
|
${vapour(392, 206, 5, 31.7, .85)}
|
|
|
|
<rect x="${IX}" y="210" width="${IW}" height="46" fill="#0d0b09"/>
|
|
<path d="M14 226h622M14 242h622${plantGrate}" stroke="#2a2118" stroke-width="1.2" fill="none" opacity=".8"/>
|
|
<g stroke="#4a3a26" fill="none" opacity=".9">
|
|
<path d="M14 214h622" stroke-width="3"/>
|
|
<path d="M14 230h622" stroke-width="1.6"/>
|
|
<path d="M74 210v46M204 210v46M334 210v46M464 210v46M594 210v46" stroke-width="3"/>
|
|
</g>
|
|
<g font-family="monospace" font-size="13" fill="#8a6a42" opacity=".8">
|
|
<text x="26" y="66" ${ID}>FUSION PLANT 1</text>
|
|
<text x="26" y="84">OUTPUT 68%</text>
|
|
</g>`;
|
|
|
|
// --------------------------------------------------- CAM 03 :: AIRLOCK
|
|
const sceneAirlock = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#08090a"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="200" fill="#111417"/>
|
|
<g stroke="#1d2429" stroke-width="1.5" fill="none" opacity=".9">
|
|
<path d="M14 56h622M14 100h622M14 144h622M126 14v200M476 14v200"/>
|
|
</g>
|
|
|
|
<g transform="translate(30 62)">${suits}</g>
|
|
|
|
<g transform="translate(300 122)">
|
|
<circle r="96" fill="#0c0f11" stroke="#232a30" stroke-width="4"/>
|
|
<path d="${hazRing}" fill="#b8860f" opacity=".5"/>
|
|
<circle r="72" fill="#141a1f" stroke="#3a454e" stroke-width="5"/>
|
|
<g class="obs-pivot" style="animation:obs-dog-ring 16s ease-in-out infinite">
|
|
<circle r="74" fill="none"/>
|
|
${dogs}
|
|
</g>
|
|
<circle r="52" fill="#0a0d10" stroke="#2b343b" stroke-width="3"/>
|
|
<circle r="34" fill="#04070c"/>
|
|
<g>
|
|
<circle cx="-14" cy="-9" r="1.2" fill="#dbeafe"/>
|
|
<circle cx="8" cy="-18" r=".9" fill="#bfdbfe"/>
|
|
<circle cx="17" cy="6" r="1.4" fill="#e0f2fe"/>
|
|
<circle cx="-6" cy="14" r="1" fill="#93c5fd"/>
|
|
<circle cx="-22" cy="10" r=".8" fill="#dbeafe"/>
|
|
<circle cx="24" cy="-7" r="1" fill="#ffffff"/>
|
|
<circle cx="2" cy="24" r=".9" fill="#bfdbfe"/>
|
|
</g>
|
|
<circle r="34" fill="none" stroke="#3a454e" stroke-width="5"/>
|
|
<path d="M-24 -18A34 34 0 0 1 4 -33" fill="none" stroke="#cbd5e1" stroke-width="3" opacity=".16"/>
|
|
</g>
|
|
|
|
<g transform="translate(300 26)">
|
|
<rect x="-15" y="-7" width="30" height="8" rx="2" fill="#1b2126"/>
|
|
<circle cy="5" r="7" fill="#7f1d1d"/>
|
|
<circle cy="5" r="5" fill="#ef4444" class="obs-beacon-flash"/>
|
|
</g>
|
|
|
|
<g transform="translate(556 54)">
|
|
<rect x="-54" y="-16" width="108" height="96" rx="3" fill="#0d1114" stroke="#28313a" stroke-width="1.5"/>
|
|
<text y="-3" text-anchor="middle" font-family="monospace" font-size="11" fill="#7c8b96" ${ID}>CYCLE STATE</text>
|
|
${lamp(20, 'VACUUM', '#38bdf8', 0)}
|
|
${lamp(44, 'EQUALIZE', '#f59e0b', 1)}
|
|
${lamp(68, 'PRESSURE', '#22c55e', 2)}
|
|
</g>
|
|
|
|
${gauge(556, 172, 28, 'CHAMBER kPa', '#38bdf8', 1.1)}
|
|
${vapour(300, 212, 5, 57.3, .7)}
|
|
|
|
<rect x="${IX}" y="214" width="${IW}" height="42" fill="#0b0e10"/>
|
|
<path d="M14 230h622M14 244h622${lockFloor}" stroke="#1d2429" stroke-width="1.2" fill="none" opacity=".85"/>
|
|
<path d="${bayChev}" fill="#b8860f" opacity=".34"/>
|
|
<g font-family="monospace" font-size="13" fill="#7c8b96" opacity=".8">
|
|
<text x="26" y="66" ${ID}>AIRLOCK C</text>
|
|
<text x="26" y="84" ${ID}>OUTER DOOR SEALED</text>
|
|
</g>`;
|
|
|
|
// -------------------------------------------------- CAM 04 :: CORRIDOR
|
|
const sceneCorridor = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#090a0a"/>
|
|
<g clip-path="url(#indCorridorFloor)">
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#100e0c"/>
|
|
<g fill="none">${corrFloor}</g>
|
|
</g>
|
|
<g stroke="#241c14" stroke-width="1.4" fill="none" opacity=".55">
|
|
<path d="M24 22L${vpx} ${vpy}M626 22L${vpx} ${vpy}M24 248L${vpx} ${vpy}M626 248L${vpx} ${vpy}"/>
|
|
</g>
|
|
<g stroke="#332617" stroke-width="4" fill="none" opacity=".75">
|
|
<path d="M24 40L${vpx} ${vpy}M626 40L${vpx} ${vpy}"/>
|
|
</g>
|
|
<g stroke="#2b2118" stroke-width="2.6" fill="none" opacity=".7">
|
|
<path d="M24 78L${vpx} ${vpy}M626 78L${vpx} ${vpy}M24 104L${vpx} ${vpy}M626 104L${vpx} ${vpy}"/>
|
|
</g>
|
|
<g fill="none">${corrFrames}</g>
|
|
${corrLamps}
|
|
|
|
<g transform="translate(${vpx} ${vpy})">
|
|
<circle r="31" fill="#f59e0b" opacity=".06"/>
|
|
<circle r="29" fill="#0a0c0d"/>
|
|
<g class="obs-pivot" style="animation:obs-rotate 1.7s linear infinite">
|
|
<circle r="25" fill="none"/>
|
|
<g fill="#4a3a26" opacity=".92">${fanBlades}</g>
|
|
</g>
|
|
<circle r="6" fill="#241a10"/>
|
|
<g stroke="#161009" stroke-width="2.4" opacity=".9" fill="none">
|
|
<path d="M-28-15H28M-28-5H28M-28 5H28M-28 15H28"/>
|
|
</g>
|
|
<circle r="29" fill="none" stroke="#5c4529" stroke-width="2.4"/>
|
|
</g>
|
|
|
|
<g transform="translate(84 176)">
|
|
<rect x="-4" y="18" width="8" height="16" fill="#2b2118"/>
|
|
<circle r="19" fill="none" stroke="#3a2b1b" stroke-width="5"/>
|
|
<g class="obs-pivot" style="animation:obs-rotate 26s linear infinite">
|
|
<circle r="17" fill="none"/>
|
|
<path d="M0-17V17M-17 0H17M-12-12L12 12M12-12L-12 12" stroke="#6b5537" stroke-width="3" fill="none"/>
|
|
</g>
|
|
<circle r="4" fill="#241a10"/>
|
|
</g>
|
|
<g fill="none" stroke="#2c2218" stroke-width="6" opacity=".85">
|
|
<path d="M84 195v53M566 150v98"/>
|
|
</g>
|
|
<path d="M84 195v53" fill="none" stroke="#38bdf8" stroke-width="2" stroke-dasharray="10 9" class="obs-pipe-flow" opacity=".35"/>
|
|
|
|
${drip(196, 96, 1.4)}
|
|
${drip(438, 82, 3.1)}
|
|
${drip(268, 66, 5.2)}
|
|
${vapour(520, 236, 5, 78.1, .75)}
|
|
|
|
<g font-family="monospace" font-size="13" fill="#8a6a42" opacity=".8">
|
|
<text x="26" y="66" ${ID}>FRAME 22 TO 34</text>
|
|
<text x="26" y="84" ${ID}>EXTRACTOR 3 ONLINE</text>
|
|
</g>`;
|
|
|
|
// ------------------------------------------------- console side rails
|
|
let rails = '';
|
|
for (let i = 0; i < 8; i++) {
|
|
const y = 252 + i * 54;
|
|
const fill = 22 + ((i * 41) % 62);
|
|
rails += `<rect x="160" y="${y}" width="94" height="38" rx="2" fill="#1a1309" stroke="#3d2e1e" stroke-width="1.2"/>
|
|
<rect x="160" y="${y}" width="${fill}" height="38" rx="2" fill="#b45309" opacity=".5" class="obs-ambient-glow" style="animation-delay:-${(i * 0.7).toFixed(1)}s"/>
|
|
<rect x="1346" y="${y}" width="94" height="38" rx="2" fill="#1a1309" stroke="#3d2e1e" stroke-width="1.2"/>
|
|
<rect x="${1440 - fill}" y="${y}" width="${fill}" height="38" rx="2" fill="#b45309" opacity=".5" class="obs-ambient-glow" style="animation-delay:-${(i * 0.55).toFixed(1)}s"/>`;
|
|
}
|
|
|
|
const defs = `
|
|
<linearGradient id="indBezel" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#241a12"/>
|
|
<stop offset="45%" stop-color="#15100b"/>
|
|
<stop offset="100%" stop-color="#0a0705"/>
|
|
</linearGradient>
|
|
<linearGradient id="indRoll" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
|
|
<stop offset="44%" stop-color="#ffe6bd" stop-opacity=".12"/>
|
|
<stop offset="52%" stop-color="#ffffff" stop-opacity=".2"/>
|
|
<stop offset="60%" stop-color="#ffe6bd" stop-opacity=".09"/>
|
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
|
</linearGradient>
|
|
<linearGradient id="indHeat" x1="0" y1="1" x2="0" y2="0">
|
|
<stop offset="0%" stop-color="#fb923c" stop-opacity=".5"/>
|
|
<stop offset="100%" stop-color="#fb923c" stop-opacity="0"/>
|
|
</linearGradient>
|
|
<radialGradient id="indFeedVig" cx="50%" cy="50%" r="72%">
|
|
<stop offset="52%" stop-color="#000000" stop-opacity="0"/>
|
|
<stop offset="100%" stop-color="#000000" stop-opacity=".6"/>
|
|
</radialGradient>
|
|
<radialGradient id="indBayLight" cx="50%" cy="26%" r="68%">
|
|
<stop offset="0%" stop-color="#f59e0b" stop-opacity=".09"/>
|
|
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<radialGradient id="indRoomLight" cx="50%" cy="46%" r="60%">
|
|
<stop offset="0%" stop-color="#b45309" stop-opacity=".07"/>
|
|
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<radialGradient id="indVapour" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="#efe2cf" stop-opacity=".5"/>
|
|
<stop offset="55%" stop-color="#dcc9ad" stop-opacity=".22"/>
|
|
<stop offset="100%" stop-color="#c9b596" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<pattern id="indScan" width="4" height="4" patternUnits="userSpaceOnUse">
|
|
<rect width="4" height="1.7" fill="#000000" opacity=".62"/>
|
|
</pattern>
|
|
<filter id="indGrainF" x="0" y="0" width="100%" height="100%">
|
|
<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="3" stitchTiles="stitch"/>
|
|
<feColorMatrix type="saturate" values="0"/>
|
|
</filter>
|
|
<clipPath id="indCamClip"><rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" rx="3"/></clipPath>
|
|
<clipPath id="indCorridorFloor"><polygon points="14,256 636,256 636,248 325,128 14,248"/></clipPath>`;
|
|
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#070706"/>
|
|
<rect width="1600" height="900" fill="url(#indRoomLight)"/>
|
|
|
|
${cam(0, 'CAM 01 // CARGO BAY 2', 'C-01 4K/IR', 'GANTRY 4 AUTO // 2 CONTACTS', sceneCargo)}
|
|
${cam(1, 'CAM 02 // REACTOR ACCESS', 'C-02 4K/RAD', 'PLANT 1 NOMINAL // LOOP A', sceneReactor)}
|
|
${cam(2, 'CAM 03 // AIRLOCK C', 'C-03 4K/LL', 'OUTER DOOR SEALED // NO EVA', sceneAirlock)}
|
|
${cam(3, 'CAM 04 // MACHINE CORRIDOR', 'C-04 4K/IR', 'FRAME 22-34 // EXTRACTOR ON', sceneCorridor)}
|
|
|
|
<g font-family="monospace">
|
|
<rect x="${SAFE_X0}" y="170" width="500" height="66" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<rect x="${SAFE_X0}" y="170" width="6" height="66" fill="#ffaa00"/>
|
|
<text x="${SAFE_X0 + 22}" y="198" fill="#ffaa00" font-size="22" ${ID}>CSV KESTREL - HEAVY HAULER</text>
|
|
<text x="${SAFE_X0 + 22}" y="222" fill="#8a6a42" font-size="14" ${ID}>REG CB-4471 // DECK 4 SURVEILLANCE RING</text>
|
|
|
|
<rect x="668" y="170" width="330" height="66" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<text x="686" y="192" fill="#8a6a42" font-size="13" ${ID}>SHIP TIME</text>
|
|
<text x="686" y="224" fill="#ffaa00" font-size="26">04:17:33</text>
|
|
<text x="980" y="192" text-anchor="end" fill="#8a6a42" font-size="13" ${ID}>RECORDER</text>
|
|
<text x="980" y="216" text-anchor="end" fill="#c8964a" font-size="17" ${ID}>ARMED</text>
|
|
<circle cx="964" cy="228" r="4.5" fill="#ef4444" class="obs-blink"/>
|
|
|
|
<rect x="1010" y="170" width="434" height="66" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<text x="1028" y="192" fill="#22c55e" font-size="16" ${ID}>4 OF 4 FEEDS LOCKED</text>
|
|
<text x="1028" y="214" fill="#8a6a42" font-size="13">BANDWIDTH 62% // ARCHIVE 71% FULL</text>
|
|
<rect x="1028" y="222" width="398" height="6" rx="3" fill="#1a1309"/>
|
|
<rect x="1028" y="222" width="248" height="6" rx="3" fill="#b45309" opacity=".8"/>
|
|
</g>
|
|
|
|
${rails}
|
|
|
|
<g stroke="#3d2e1e" stroke-width="2" fill="none" opacity=".6">
|
|
<path d="M800 252v90M800 372v86M800 476v90M800 596v86"/>
|
|
</g>
|
|
<g font-family="monospace" font-size="12" fill="#6b5233" opacity=".85">
|
|
<text x="800" y="362" text-anchor="middle" transform="rotate(-90 800 362)" ${ID}>BUS A</text>
|
|
<text x="800" y="586" text-anchor="middle" transform="rotate(-90 800 586)" ${ID}>BUS B</text>
|
|
</g>
|
|
|
|
<g font-family="monospace">
|
|
<rect x="${SAFE_X0}" y="694" width="1288" height="36" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="1.5"/>
|
|
<rect x="${SAFE_X0}" y="694" width="6" height="36" fill="#b45309"/>
|
|
<text x="${SAFE_X0 + 22}" y="718" fill="#c8964a" font-size="14" ${ID}>MANIFEST 4471-C // ORE CONCENTRATE + SEALED HAZ-3</text>
|
|
<g font-size="14" fill="#8a6a42">
|
|
<text x="700" y="718">HULL 12%</text>
|
|
<text x="830" y="718">BAY 101.3 KPA</text>
|
|
<text x="1010" y="718">RCTR 68%</text>
|
|
<text x="1130" y="718">O2 20.9%</text>
|
|
<text x="1250" y="718">GRAV 0.94 G</text>
|
|
</g>
|
|
<text x="1426" y="718" text-anchor="end" fill="#ffaa00" font-size="14" ${ID}>NOMINAL</text>
|
|
</g>
|
|
`, defs);
|
|
}
|
|
|
|
function renderObservationBioships() {
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#010b07"/>
|
|
<circle cx="800" cy="455" r="390" fill="url(#obsGlow)" opacity=".5"/>
|
|
<g fill="none" stroke="#10b981" opacity=".35">
|
|
<path d="M190 470 C370 260 510 690 690 455 S1010 220 1400 470" stroke-width="18"/>
|
|
<path d="M210 575 C420 410 510 750 760 520 S1130 380 1390 590" stroke-width="6"/>
|
|
<path d="M255 315 C470 520 600 180 830 405 S1180 620 1375 320" stroke-width="5"/>
|
|
</g>
|
|
<g class="obs-breathe">
|
|
<path d="M800 238 C920 300 1010 395 985 505 C962 610 882 680 800 716 C718 680 638 610 615 505 C590 395 680 300 800 238Z"
|
|
fill="#10b981" opacity=".11" stroke="#8b5cf6" stroke-width="8"/>
|
|
<ellipse cx="800" cy="470" rx="100" ry="142" fill="#8b5cf6" opacity=".12"/>
|
|
<ellipse cx="800" cy="470" rx="52" ry="92" fill="#10b981" opacity=".33" filter="url(#obsSoftGlow)"/>
|
|
</g>
|
|
<g fill="#a7f3d0" opacity=".8">
|
|
<circle class="obs-pulse" cx="515" cy="345" r="7"/>
|
|
<circle class="obs-pulse" cx="1115" cy="565" r="6" style="animation-delay:-1.7s"/>
|
|
<circle class="obs-pulse" cx="455" cy="600" r="5" style="animation-delay:-2.3s"/>
|
|
<circle class="obs-pulse" cx="1165" cy="320" r="5" style="animation-delay:-.9s"/>
|
|
<circle r="5" fill="#8b5cf6">
|
|
<animateMotion dur="10s" repeatCount="indefinite"
|
|
path="M330,500 C530,220 720,650 920,410 S1210,260 1320,470"/>
|
|
</circle>
|
|
<circle r="3.5" fill="#10b981">
|
|
<animateMotion dur="14s" repeatCount="indefinite"
|
|
path="M1240,650 C1010,480 900,760 690,535 S420,350 270,520"/>
|
|
</circle>
|
|
</g>
|
|
<g stroke="#10b981" stroke-width="3" opacity=".55" fill="none">
|
|
<path d="M515 345 C610 360 660 420 712 450" class="obs-dashflow"/>
|
|
<path d="M1115 565 C1015 560 965 520 892 492" class="obs-dashflow"/>
|
|
</g>
|
|
<text x="800" y="785" text-anchor="middle" fill="#a7f3d0" font-size="20" font-family="monospace" opacity=".65">
|
|
BIOLOGICAL SYSTEMS // PASSIVE NEURAL OBSERVATION
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationRetro() {
|
|
const stars = makeStars(95, 1600, 900, '#86efac', 720);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#010903"/>
|
|
<g opacity=".44">${stars}</g>
|
|
<g transform="translate(800 460)" fill="none" stroke="#22c55e">
|
|
<circle r="275" opacity=".55" stroke-width="3"/>
|
|
<circle r="205" opacity=".35"/>
|
|
<circle r="135" opacity=".35"/>
|
|
<circle r="65" opacity=".35"/>
|
|
<path d="M-275 0H275M0-275V275" opacity=".3"/>
|
|
<g class="obs-spin">
|
|
<path d="M0 0L0-275L70-230Z" fill="#22c55e" opacity=".18" stroke="none"/>
|
|
<line x1="0" y1="0" x2="0" y2="-275" stroke="#86efac" stroke-width="4"/>
|
|
</g>
|
|
</g>
|
|
<g fill="#86efac" filter="url(#obsSoftGlow)">
|
|
<circle class="obs-blink" cx="945" cy="360" r="6"/>
|
|
<circle cx="675" cy="570" r="4"/>
|
|
<circle class="obs-blink" cx="1040" cy="520" r="4" style="animation-delay:-1.1s"/>
|
|
</g>
|
|
<path d="M180 730 C250 675 310 785 380 730 S510 675 580 730 S710 785 780 730 S910 675 980 730 S1110 785 1180 730 S1310 675 1420 730"
|
|
fill="none" stroke="#22c55e" stroke-width="3" opacity=".65" class="obs-dashflow"/>
|
|
<g transform="translate(220 500)" opacity=".55">
|
|
<circle r="44" fill="none" stroke="#86efac" stroke-width="3" class="obs-spin"/>
|
|
<circle r="22" fill="none" stroke="#22c55e" stroke-width="5" stroke-dasharray="7 5" class="obs-spin-rev"/>
|
|
</g>
|
|
<g class="obs-crt-jump" opacity=".5">
|
|
<path d="M1160 520h250M1160 540h180M1160 560h220" stroke="#22c55e" stroke-width="3"/>
|
|
</g>
|
|
<g fill="#22c55e" opacity=".72" font-family="monospace">
|
|
<text x="105" y="210" font-size="18">ASTROGATION ARRAY</text>
|
|
<text x="105" y="238" font-size="14">VECTOR MEMORY: LOCKED</text>
|
|
<text x="1490" y="210" font-size="18" text-anchor="end">SCAN 360°</text>
|
|
<text x="1490" y="238" font-size="14" text-anchor="end">ANALOG BEAM: ACTIVE</text>
|
|
</g>
|
|
`);
|
|
}
|
|
|
|
function renderObservationMilitary() {
|
|
const stars = makeStars(110, 1650, 900, '#fef3c7', 960);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#050506"/>
|
|
<g opacity=".42">${stars}</g>
|
|
<g transform="translate(800 470)" fill="none" stroke="#eab308">
|
|
<circle r="300" opacity=".25"/>
|
|
<circle r="220" opacity=".25"/>
|
|
<circle r="140" opacity=".25"/>
|
|
<circle r="60" opacity=".25"/>
|
|
<path d="M-330 0H330M0-330V330" opacity=".22"/>
|
|
</g>
|
|
<g stroke="#eab308" fill="none" stroke-width="3">
|
|
<path d="M1040 322 l28 -28 h42 l28 28 v42 l-28 28 h-42 l-28 -28z" class="obs-blink"/>
|
|
<path d="M570 545 l18 -18 h30 l18 18 v30 l-18 18 h-30 l-18 -18z" opacity=".65"/>
|
|
<path d="M930 625 l15 -15 h25 l15 15 v25 l-15 15 h-25 l-15 -15z" opacity=".5"/>
|
|
</g>
|
|
<g stroke="#ca8a04" fill="none" stroke-width="2" opacity=".6">
|
|
<path d="M800 470L1074 343" class="obs-dashflow"/>
|
|
<path d="M800 470L603 560" class="obs-dashflow"/>
|
|
</g>
|
|
<g transform="translate(800 470)" opacity=".28">
|
|
<g class="obs-spin-slow">
|
|
<path d="M0 0L0-300L62-255Z" fill="#eab308" stroke="none"/>
|
|
</g>
|
|
</g>
|
|
<g fill="#eab308" opacity=".52">
|
|
<circle r="4"><animateMotion dur="16s" repeatCount="indefinite" path="M420,650 L1180,295"/></circle>
|
|
<circle r="3"><animateMotion dur="23s" repeatCount="indefinite" path="M1240,610 L530,300"/></circle>
|
|
</g>
|
|
<g fill="#eab308" font-family="monospace">
|
|
<text x="1068" y="285" font-size="15">CONTACT 03 // TRACKING</text>
|
|
<text x="108" y="715" font-size="17" opacity=".72">TACTICAL WATCH // PASSIVE</text>
|
|
<text x="1490" y="715" text-anchor="end" font-size="17" opacity=".72">WEAPONS SAFE // SENSORS ACTIVE</text>
|
|
</g>
|
|
<g opacity=".75">
|
|
<rect x="120" y="250" width="190" height="12" fill="#eab308"/>
|
|
<rect x="120" y="278" width="130" height="8" fill="#71717a"/>
|
|
<rect x="1290" y="250" width="190" height="12" fill="#eab308"/>
|
|
<rect x="1350" y="278" width="130" height="8" fill="#71717a"/>
|
|
</g>
|
|
`);
|
|
}
|
|
|
|
function renderObservationDeepSpace() {
|
|
const starsA = makeStars(155, 1800, 900, '#eef2ff', 1220);
|
|
const starsB = makeStars(65, 1800, 900, '#818cf8', 1430);
|
|
// Planets, nebula clouds, dust lanes, derelicts and anomalies now live in the canvas
|
|
// engine's procedural, session-seeded Deep Space field (see ObservationEngine's
|
|
// regenerateDeepSpaceField / renderDeepSpaceField). This legacy SVG layer is kept
|
|
// intentionally sparse -- just a comet accent and the HUD label -- so it never paints a
|
|
// fixed duplicate planet/nebula on top of that varying scene.
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#010207"/>
|
|
<g style="animation:obs-drift-left 125s linear infinite">${starsA}</g>
|
|
<g opacity=".48" style="animation:obs-drift-right 180s linear infinite">${starsB}</g>
|
|
<g opacity=".34" style="animation:obs-comet-drift 42s linear infinite">
|
|
<path d="M0 0L-180 38" stroke="#eef2ff" stroke-width="2"/>
|
|
<circle cx="0" cy="0" r="4" fill="#ffffff" filter="url(#obsSoftGlow)"/>
|
|
</g>
|
|
<text x="800" y="790" text-anchor="middle" fill="#818cf8" font-family="monospace" font-size="18" opacity=".55">
|
|
LONG RANGE OPTICAL // DEEP FIELD OBSERVATION
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationOutlaw() {
|
|
const stars = makeStars(115, 1700, 900, '#fbcfe8', 1710);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#09040c"/>
|
|
<g style="animation:obs-drift-left 95s linear infinite" opacity=".55">${stars}</g>
|
|
<path d="M120 720 Q510 430 800 465 T1480 230" fill="none" stroke="#f59e0b" stroke-width="4" opacity=".45" class="obs-dashflow"/>
|
|
<g fill="none" stroke="#ec4899" opacity=".55">
|
|
<path d="M630 390h120v-45M970 390H850v-45M630 540h120v45M970 540H850v45" stroke-width="4"/>
|
|
<circle cx="800" cy="465" r="95" opacity=".25"/>
|
|
</g>
|
|
<g transform="translate(120 210)">
|
|
<g class="obs-crt-jump">
|
|
<rect width="290" height="170" rx="8" fill="#130919" stroke="#8b5cf6" stroke-width="3"/>
|
|
<text x="18" y="32" fill="#f472b6" font-size="15" font-family="monospace">REAR FEED // 02</text>
|
|
<path d="M40 130L120 80L180 112L250 55" stroke="#f59e0b" fill="none" stroke-width="4"/>
|
|
<circle cx="225" cy="68" r="5" fill="#ec4899" class="obs-blink"/>
|
|
</g>
|
|
</g>
|
|
<g fill="#f472b6" font-family="monospace" opacity=".72">
|
|
<text x="1190" y="675" font-size="17">ROUTE: MANUAL</text>
|
|
<text x="1190" y="702" font-size="14">TRANSPONDER: INTERMITTENT</text>
|
|
<text x="1190" y="729" font-size="14">SIGNAL QUALITY: 62%</text>
|
|
</g>
|
|
<rect x="490" y="785" width="620" height="4" fill="url(#obsFade)" opacity=".7"/>
|
|
`);
|
|
}
|
|
|
|
function renderObservationStations() {
|
|
const starsNear = makeStars(105, 1160, 540, '#e0f2fe', 1990);
|
|
const starsFar = makeStars(55, 1160, 540, '#7dd3fc', 2310);
|
|
const telemetryModule = (x, y, w, h, title, line1, line2 = '') => `
|
|
<g transform="translate(${x} ${y})">
|
|
<rect width="${w}" height="${h}" rx="10" fill="rgba(6,16,27,.78)" stroke="#0284c7" stroke-width="3"/>
|
|
<rect x="0" y="0" width="8" height="${h}" rx="4" fill="#f97316" opacity=".9"/>
|
|
<text x="22" y="28" fill="#fdba74" font-size="14" font-family="monospace">${title}</text>
|
|
<text x="22" y="55" fill="#bae6fd" font-size="16" font-family="monospace">${line1}</text>
|
|
${line2 ? `<text x="22" y="79" fill="#7dd3fc" opacity=".82" font-size="13" font-family="monospace">${line2}</text>` : ''}
|
|
</g>`;
|
|
|
|
return svgFrame(`
|
|
<defs>
|
|
<clipPath id="stationViewportClip">
|
|
<rect x="220" y="140" width="1160" height="540" rx="28"/>
|
|
</clipPath>
|
|
</defs>
|
|
|
|
<!-- Station Bulkhead Wall with Viewport Window Cutout (Native Station Viewport) -->
|
|
<path class="obs-station-bulkhead" d="M0,0 H1600 V900 H0 Z M220,140 h1160 a28,28 0 0 1 28,28 v484 a28,28 0 0 1 -28,28 h-1160 a28,28 0 0 1 -28,-28 v-484 a28,28 0 0 1 28,-28 Z" fill="#02070d" fill-rule="evenodd"/>
|
|
|
|
<!-- Station Viewport Structural Bezel & Outer Trim -->
|
|
<g class="obs-station-frame" transform="translate(220 140)">
|
|
<rect width="1160" height="540" rx="28" fill="none" stroke="#38bdf8" stroke-width="6"/>
|
|
<rect x="14" y="14" width="1132" height="512" rx="20" fill="none" stroke="#0ea5e9" stroke-width="2" opacity=".75"/>
|
|
</g>
|
|
|
|
<g clip-path="url(#stationViewportClip)">
|
|
|
|
<g transform="translate(220 140)">
|
|
<g style="animation:obs-drift-left 170s linear infinite" opacity=".82">${starsNear}</g>
|
|
<g style="animation:obs-drift-right 260s linear infinite" opacity=".42">${starsFar}</g>
|
|
|
|
<!-- Static sky glow removed: the rotating 360-degree canvas panorama now owns every
|
|
celestial element in this scene, so a fixed nebula haze would sit still in the
|
|
window while the rest of the sky wheels past it. -->
|
|
|
|
|
|
<g opacity=".62">
|
|
<g>
|
|
<path d="M885 540 L1090 408 L1186 438 L1010 572 Z"
|
|
fill="#0f3d57" opacity=".46"/>
|
|
<path d="M895 532 L1088 412" stroke="#38bdf8" stroke-width="3" opacity=".45"/>
|
|
<path d="M940 502 L1120 392" stroke="#7dd3fc" stroke-width="2" opacity=".28"/>
|
|
<circle cx="1114" cy="394" r="4" fill="#f97316" class="obs-blink"/>
|
|
<circle cx="1030" cy="446" r="3" fill="#7dd3fc" opacity=".65"/>
|
|
</g>
|
|
<g>
|
|
<path d="M148 448 L306 338 L372 366 L236 480 Z"
|
|
fill="#123247" opacity=".38"/>
|
|
<path d="M156 442 L304 342" stroke="#38bdf8" stroke-width="2.5" opacity=".36"/>
|
|
<path d="M194 418 L334 328" stroke="#7dd3fc" stroke-width="2" opacity=".22"/>
|
|
<circle cx="320" cy="336" r="3.5" fill="#f97316" class="obs-blink"/>
|
|
</g>
|
|
<g opacity=".34">
|
|
<path d="M0 470 H150" stroke="#164e63" stroke-width="18"/>
|
|
<path d="M1010 34 H1160" stroke="#164e63" stroke-width="14"/>
|
|
</g>
|
|
</g>
|
|
|
|
<g opacity=".65">
|
|
<path d="M160 385 C360 315 520 350 682 280 S945 188 1110 214"
|
|
fill="none" stroke="#38bdf8" stroke-width="2.5" stroke-dasharray="14 10" class="obs-route-wander"/>
|
|
<circle cx="1098" cy="217" r="5" fill="#f97316" class="obs-blink"/>
|
|
</g>
|
|
|
|
<g opacity=".58">
|
|
<path d="M990 430 C1030 382 1080 325 1168 262" fill="none" stroke="#fdba74" stroke-width="2" stroke-dasharray="9 9"/>
|
|
<circle cx="1168" cy="262" r="6" fill="#fdba74"/>
|
|
</g>
|
|
</g>
|
|
</g>
|
|
|
|
<g opacity=".95">
|
|
<path d="M350 140H1250" stroke="#38bdf8" stroke-width="4"/>
|
|
<path d="M350 680H1250" stroke="#38bdf8" stroke-width="4"/>
|
|
<path d="M220 270V550" stroke="#38bdf8" stroke-width="4"/>
|
|
<path d="M1380 270V550" stroke="#38bdf8" stroke-width="4"/>
|
|
|
|
<g stroke="#0ea5e9" stroke-width="5">
|
|
<path d="M220 230H220V140H310"/>
|
|
<path d="M1290 140H1380V230"/>
|
|
<path d="M220 590V680H310"/>
|
|
<path d="M1290 680H1380V590"/>
|
|
</g>
|
|
</g>
|
|
|
|
${telemetryModule(105, 170, 300, 96, 'OBSERVATION DECK', 'RING RATE 01.8 // STABLE', 'STARFIELD DRIFT 004.2')}
|
|
${telemetryModule(1195, 170, 300, 96, 'TRAFFIC CONTROL', 'DOCK VECTOR 118.4 // ROUTING', 'QUEUE 03 // CLEARANCE')}
|
|
${telemetryModule(105, 590, 320, 96, 'PORT STATUS', 'BAY 12 // ARRIVAL WINDOW', 'HOLDING ARC 02.7')}
|
|
${telemetryModule(1175, 590, 320, 96, 'TRANSIT SPINE', 'LOCAL FLOW 07 // NOMINAL', 'HABITAT SPIN 00.9')}
|
|
|
|
<g fill="none" stroke="#38bdf8" opacity=".45">
|
|
<circle cx="800" cy="410" r="300" stroke-width="2"/>
|
|
<circle cx="800" cy="410" r="240" stroke-width="1"/>
|
|
<path d="M510 410H1090M800 140V680" stroke-width="1"/>
|
|
</g>
|
|
|
|
<text x="800" y="760" text-anchor="middle" fill="#fdba74" font-size="20" font-family="monospace" opacity=".72">
|
|
STATION OBSERVATION // ROTATIONAL TRAFFIC WINDOW
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationComedy() {
|
|
const stars = makeStars(120, 1700, 900, '#cffafe', 2260);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#07061a"/>
|
|
<g style="animation:obs-drift-left 105s linear infinite" opacity=".58">${stars}</g>
|
|
<path d="M160 700 C420 590 540 330 800 410 S1160 650 1430 250"
|
|
fill="none" stroke="#06b6d4" stroke-width="5" opacity=".5" class="obs-dashflow"/>
|
|
<g transform="translate(800 445)">
|
|
<circle r="170" fill="#06b6d4" opacity=".04"/>
|
|
<circle r="115" fill="none" stroke="#fbbf24" stroke-width="4" opacity=".55" class="obs-spin-slow"/>
|
|
<circle r="62" fill="none" stroke="#f43f5e" stroke-width="7" stroke-dasharray="22 15" class="obs-spin-rev"/>
|
|
<circle r="16" fill="#06b6d4" opacity=".75" class="obs-pulse"/>
|
|
</g>
|
|
<g font-family="monospace">
|
|
<text x="135" y="250" fill="#67e8f9" font-size="18">SCENIC NAVIGATION</text>
|
|
<text x="135" y="280" fill="#fbbf24" font-size="14">ROUTE CONFIDENCE: 99.7% PROBABLY FINE</text>
|
|
<text x="1465" y="650" text-anchor="end" fill="#f472b6" font-size="16" class="obs-blink">TEA SOURCE DETECTED</text>
|
|
<text x="1465" y="680" text-anchor="end" fill="#67e8f9" font-size="14">NO IMMEDIATE CAUSE FOR PANIC</text>
|
|
</g>
|
|
<g style="animation:obs-planet-drift 95s linear infinite" opacity=".62">
|
|
<circle cx="0" cy="335" r="45" fill="#fbbf24" opacity=".12"/>
|
|
<circle cx="0" cy="335" r="26" fill="#fbbf24" opacity=".18"/>
|
|
<circle cx="-54" cy="335" r="4" fill="#67e8f9"/>
|
|
</g>
|
|
`);
|
|
}
|
|
|
|
function getObservationSvg(universeId) {
|
|
switch (universeId) {
|
|
case 'starfleet': return renderObservationStarfleet();
|
|
case 'whoniverse': return renderObservationWhoniverse();
|
|
case 'industrial': return renderObservationIndustrial();
|
|
case 'bioships': return renderObservationBioships();
|
|
case 'retrofuture': return renderObservationRetro();
|
|
case 'military': return renderObservationMilitary();
|
|
case 'deepspace': return renderObservationDeepSpace();
|
|
case 'outlaw': return renderObservationOutlaw();
|
|
case 'spacestations': return renderObservationStations();
|
|
case 'comedy': return renderObservationComedy();
|
|
default: return renderObservationStarfleet();
|
|
}
|
|
}
|
|
|
|
function refreshObservation() {
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
|
|
if (!universe || !preset) return;
|
|
|
|
observationUniverse.textContent = `${universe.shortCode} // OBSERVATION`;
|
|
observationProfile.textContent = preset.name.toUpperCase();
|
|
observationStatus.innerHTML =
|
|
`OBSERVATION ACTIVE<br>PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}<br>VISUAL ACTIVITY: ${Math.round(observationActivity * 100)}%`;
|
|
|
|
observationOverlay.style.setProperty('--obs-activity', String(observationActivity));
|
|
observationStage.innerHTML = getObservationSvg(activeUniverseId);
|
|
initializeObservationSplineMorphs();
|
|
startObservationScene();
|
|
initializeObservationLiveInstrumentation();
|
|
|
|
if (observationEngine) {
|
|
observationEngine.start(activeUniverseId);
|
|
}
|
|
}
|
|
|
|
function enterObservation() {
|
|
if (observationActive) return;
|
|
observationActive = true;
|
|
observationOverlay.classList.add('active');
|
|
observationOverlay.setAttribute('aria-hidden', 'false');
|
|
refreshObservation();
|
|
resetObservationHudFade();
|
|
resetObservationReturnFade();
|
|
scheduleObservationAmbientActivity();
|
|
|
|
// Give the display an immediate but restrained sign of life.
|
|
if (observationActivity > .08) {
|
|
window.setTimeout(() => triggerObservationActivity('ambient'), 700);
|
|
}
|
|
}
|
|
|
|
function exitObservation() {
|
|
if (!observationActive) return;
|
|
observationActive = false;
|
|
|
|
if (observationEngine) {
|
|
observationEngine.stop();
|
|
}
|
|
|
|
if (observationAmbientTimer) {
|
|
clearTimeout(observationAmbientTimer);
|
|
observationAmbientTimer = null;
|
|
}
|
|
|
|
stopObservationLiveInstrumentation();
|
|
stopObservationSplineMorphs();
|
|
stopObservationScene();
|
|
|
|
if (observationChrome) {
|
|
observationChrome.style.transition = 'none';
|
|
observationChrome.classList.remove('observation-hud-fade');
|
|
void observationChrome.offsetWidth;
|
|
observationChrome.style.transition = '';
|
|
}
|
|
|
|
if (observationReturn) {
|
|
observationReturn.style.transition = 'none';
|
|
observationReturn.classList.remove('observation-return-fade');
|
|
void observationReturn.offsetWidth;
|
|
observationReturn.style.transition = '';
|
|
}
|
|
|
|
observationOverlay.classList.remove('active');
|
|
observationOverlay.setAttribute('aria-hidden', 'true');
|
|
observationStage.innerHTML = '';
|
|
observationEventLayer.innerHTML = '';
|
|
}
|
|
|
|
window.exitObservation = exitObservation;
|
|
window.refreshObservation = refreshObservation;
|
|
window.enterObservation = enterObservation;
|
|
window.selectPreset = selectPreset;
|
|
window.setUniverse = setUniverse;
|
|
|
|
btnObservation.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
enterObservation();
|
|
});
|
|
|
|
// Click background to exit, but ignore clicks on the control dock or its buttons
|
|
observationOverlay.addEventListener('click', (e) => {
|
|
if (e.target.closest('#observation-control-dock')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
exitObservation();
|
|
});
|
|
|
|
const observationReturnPill = document.getElementById('observation-return-pill');
|
|
if (observationReturnPill) {
|
|
observationReturnPill.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
exitObservation();
|
|
});
|
|
}
|
|
|
|
// 10. Keyboard Shortcuts
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
|
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
togglePlay();
|
|
} else if (e.code === 'KeyM') {
|
|
toggleMute();
|
|
} else if (e.code === 'KeyW') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.toggleWarpFlight();
|
|
return;
|
|
}
|
|
const primaryActionBtn = universeEventsContainer.children[0];
|
|
if (primaryActionBtn) primaryActionBtn.click();
|
|
} else if (e.code === 'KeyF') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.toggleViewportFrame();
|
|
return;
|
|
}
|
|
} else if (e.code === 'KeyP') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.togglePillars();
|
|
return;
|
|
}
|
|
} else if (e.code === 'KeyR') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.toggleAlert();
|
|
return;
|
|
}
|
|
const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0];
|
|
if (firstAlertBtn) firstAlertBtn.click();
|
|
} else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') {
|
|
e.preventDefault();
|
|
setMasterVolume(audioManager.getMasterVolume() + 0.05);
|
|
} else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
|
|
e.preventDefault();
|
|
setMasterVolume(audioManager.getMasterVolume() - 0.05);
|
|
} else if (e.code >= 'Digit1' && e.code <= 'Digit9') {
|
|
const num = parseInt(e.code.replace('Digit', ''));
|
|
setMasterVolume(num * 0.1);
|
|
} else if (e.code === 'Digit0') {
|
|
setMasterVolume(1.0);
|
|
} else if (e.code === 'Escape') {
|
|
if (observationActive) {
|
|
exitObservation();
|
|
return;
|
|
}
|
|
alerts.stopAlert();
|
|
whoniverseAudio.stopCloisterBell();
|
|
resetAlertButtons();
|
|
}
|
|
});
|
|
|
|
// 11. Initial Setup
|
|
initUniverseDropdown();
|
|
setUniverse('starfleet');
|
|
updateMasterVolumeUI(0.75, false);
|
|
visualizer.init('spectrum-canvas', 'warp-core-canvas');
|
|
});
|