shadow-zone / files / src / controls.ts
controls.ts
0ed88387 2026-06-12 Pavel Safronov | 1 | import { PRESETS } from './earth'; |
| 2 | import { EARTHQUAKE_DATASETS } from './earthquakeData'; | |
| 3 | ||
| 4 | export interface AppState { | |
| 5 | outerCoreRadius: number; | |
| 6 | outerCoreVTop: number; | |
| 7 | outerCoreVBottom: number; | |
| 8 | innerCoreRadius: number; | |
| 9 | innerCoreV: number; | |
| 10 | waveType: 'P' | 'S' | 'both'; | |
| 11 | numRays: number; | |
| 12 | preset: string; | |
| 13 | earthquakeId: string; | |
2ef16b5d 2026-06-13 Pavel Safronov | 14 | /** Impossible-core physics modifier — derived from the active preset, not a slider */ |
| 15 | coreModifier: string; | |
0ed88387 2026-06-12 Pavel Safronov | 16 | } |
| 17 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 18 | // ── Custom preset persistence ───────────────────────────────────────────────── |
| 19 | ||
| 20 | interface CustomPreset { | |
| 21 | id: string; | |
| 22 | emoji: string; | |
| 23 | name: string; | |
| 24 | state: { | |
| 25 | outerCoreRadius: number; | |
| 26 | outerCoreVTop: number; | |
| 27 | outerCoreVBottom: number; | |
| 28 | innerCoreRadius: number; | |
| 29 | innerCoreV: number; | |
| 30 | waveType: AppState['waveType']; | |
| 31 | numRays: number; | |
| 32 | coreModifier: string; | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | const STORAGE_KEY = 'sz-custom-presets'; | |
| 37 | ||
| 38 | function loadCustomPresets(): CustomPreset[] { | |
| 39 | try { | |
| 40 | return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as CustomPreset[]; | |
| 41 | } catch { | |
| 42 | return []; | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | function persistCustomPresets(presets: CustomPreset[]): void { | |
| 47 | localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); | |
| 48 | } | |
| 49 | ||
| 50 | const EMOJI_LIST = [ | |
| 51 | '🌍','🌙','🪐','⭐','⚫','💎','🔥','❄️','🌊','🪨', | |
| 52 | '💡','⚡','🌀','🎯','🌋','🏔️','🧲','🔮','🫧','🧊', | |
| 53 | ]; | |
| 54 | ||
| 55 | // ── DOM helpers ─────────────────────────────────────────────────────────────── | |
| 56 | ||
0ed88387 2026-06-12 Pavel Safronov | 57 | function el<T extends HTMLElement>(id: string): T { |
| 58 | return document.getElementById(id) as T; | |
| 59 | } | |
| 60 | ||
| 61 | function syncDisplay(inputId: string, displayId: string, decimals = 0) { | |
| 62 | const input = el<HTMLInputElement>(inputId); | |
| 63 | const display = el<HTMLSpanElement>(displayId); | |
| 64 | display.textContent = parseFloat(input.value).toFixed(decimals); | |
| 65 | } | |
| 66 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 67 | // ── Main ────────────────────────────────────────────────────────────────────── |
| 68 | ||
0ed88387 2026-06-12 Pavel Safronov | 69 | export function initControls(onChange: (state: AppState) => void): AppState { |
| 70 | const state: AppState = { ...PRESETS['earth'], earthquakeId: '' }; | |
| 71 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 72 | // ── Earthquake selector ─────────────────────────────────────────────────── |
0ed88387 2026-06-12 Pavel Safronov | 73 | const eqSelect = el<HTMLSelectElement>('earthquake-select'); |
| 74 | const noneOpt = document.createElement('option'); | |
| 75 | noneOpt.value = ''; | |
| 76 | noneOpt.textContent = '— None —'; | |
| 77 | eqSelect.appendChild(noneOpt); | |
| 78 | for (const ds of EARTHQUAKE_DATASETS) { | |
| 79 | const opt = document.createElement('option'); | |
| 80 | opt.value = ds.id; | |
| 81 | opt.textContent = ds.name; | |
| 82 | eqSelect.appendChild(opt); | |
| 83 | } | |
| 84 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 85 | // ── Custom presets ──────────────────────────────────────────────────────── |
| 86 | let customPresets = loadCustomPresets(); | |
| 87 | ||
| 88 | /** Returns a full AppState for any preset id (standard or custom). */ | |
| 89 | function getPresetState(id: string): AppState | null { | |
| 90 | if (PRESETS[id]) return { ...PRESETS[id] }; | |
| 91 | const cp = customPresets.find(p => p.id === id); | |
| 92 | if (cp) { | |
| 93 | return { | |
| 94 | ...PRESETS['earth'], | |
| 95 | ...cp.state, | |
| 96 | preset: id, | |
| 97 | earthquakeId: '', | |
| 98 | }; | |
| 99 | } | |
| 100 | return null; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Syncs the custom-preset portion of the <select> dropdown. | |
| 105 | * Custom options are tagged with data-custom and inserted before the | |
| 106 | * invisible #custom-section-marker, which sits above the ⚠️ impossible cores. | |
| 107 | */ | |
| 108 | function rebuildCustomOptions() { | |
| 109 | const presetEl = el<HTMLSelectElement>('preset'); | |
| 110 | const marker = document.getElementById('custom-section-marker')!; | |
| 111 | presetEl.querySelectorAll('[data-custom]').forEach(o => o.remove()); | |
| 112 | if (customPresets.length === 0) return; | |
| 113 | const divider = document.createElement('option'); | |
| 114 | divider.disabled = true; | |
| 115 | divider.textContent = '── Custom ──'; | |
| 116 | divider.setAttribute('data-custom', 'divider'); | |
| 117 | presetEl.insertBefore(divider, marker); | |
| 118 | for (const cp of customPresets) { | |
| 119 | const opt = document.createElement('option'); | |
| 120 | opt.value = cp.id; | |
| 121 | opt.textContent = `${cp.emoji} ${cp.name}`; | |
| 122 | opt.setAttribute('data-custom', 'preset'); | |
| 123 | presetEl.insertBefore(opt, marker); | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | // ── DOM read / write ────────────────────────────────────────────────────── | |
| 128 | ||
0ed88387 2026-06-12 Pavel Safronov | 129 | function readState(): AppState { |
| 130 | return { | |
2ef16b5d 2026-06-13 Pavel Safronov | 131 | outerCoreRadius: parseFloat(el<HTMLInputElement>('outer-core-radius').value), |
| 132 | outerCoreVTop: parseFloat(el<HTMLInputElement>('outer-core-v-top').value), | |
0ed88387 2026-06-12 Pavel Safronov | 133 | outerCoreVBottom: parseFloat(el<HTMLInputElement>('outer-core-v-bottom').value), |
2ef16b5d 2026-06-13 Pavel Safronov | 134 | innerCoreRadius: parseFloat(el<HTMLInputElement>('inner-core-radius').value), |
| 135 | innerCoreV: parseFloat(el<HTMLInputElement>('inner-core-v').value), | |
| 136 | waveType: el<HTMLSelectElement>('wave-type').value as AppState['waveType'], | |
| 137 | numRays: parseInt(el<HTMLInputElement>('num-rays').value, 10), | |
| 138 | preset: el<HTMLSelectElement>('preset').value, | |
0ed88387 2026-06-12 Pavel Safronov | 139 | earthquakeId: el<HTMLSelectElement>('earthquake-select').value, |
2ef16b5d 2026-06-13 Pavel Safronov | 140 | // coreModifier is stored in a hidden input; it's set by applyStateToDOM |
| 141 | // and not tied to any visible slider. | |
| 142 | coreModifier: el<HTMLInputElement>('core-modifier-hidden').value, | |
0ed88387 2026-06-12 Pavel Safronov | 143 | }; |
| 144 | } | |
| 145 | ||
| 146 | function applyStateToDOM(s: AppState) { | |
2ef16b5d 2026-06-13 Pavel Safronov | 147 | el<HTMLInputElement>('outer-core-radius').value = String(s.outerCoreRadius); |
| 148 | el<HTMLInputElement>('outer-core-v-top').value = String(s.outerCoreVTop); | |
| 149 | el<HTMLInputElement>('outer-core-v-bottom').value = String(s.outerCoreVBottom); | |
| 150 | el<HTMLInputElement>('inner-core-radius').value = String(s.innerCoreRadius); | |
| 151 | el<HTMLInputElement>('inner-core-v').value = String(s.innerCoreV); | |
| 152 | el<HTMLSelectElement>('wave-type').value = s.waveType; | |
| 153 | el<HTMLInputElement>('num-rays').value = String(s.numRays); | |
| 154 | el<HTMLSelectElement>('preset').value = s.preset; | |
| 155 | el<HTMLInputElement>('core-modifier-hidden').value = s.coreModifier ?? ''; | |
| 156 | ||
| 157 | syncDisplay('outer-core-radius', 'outer-core-radius-val', 0); | |
| 158 | syncDisplay('outer-core-v-top', 'outer-core-v-top-val', 1); | |
| 159 | syncDisplay('outer-core-v-bottom','outer-core-v-bottom-val',1); | |
| 160 | syncDisplay('inner-core-radius', 'inner-core-radius-val', 0); | |
| 161 | syncDisplay('inner-core-v', 'inner-core-v-val', 1); | |
| 162 | syncDisplay('num-rays', 'num-rays-val', 0); | |
| 163 | } | |
| 164 | ||
| 165 | // ── Reset button ────────────────────────────────────────────────────────── | |
| 166 | ||
| 167 | let referenceState: AppState = { ...state }; | |
| 168 | ||
| 169 | function isStateChanged(): boolean { | |
| 170 | const s = readState(); | |
| 171 | return s.outerCoreRadius !== referenceState.outerCoreRadius | |
| 172 | || s.outerCoreVTop !== referenceState.outerCoreVTop | |
| 173 | || s.outerCoreVBottom !== referenceState.outerCoreVBottom | |
| 174 | || s.innerCoreRadius !== referenceState.innerCoreRadius | |
| 175 | || s.innerCoreV !== referenceState.innerCoreV | |
| 176 | || s.waveType !== referenceState.waveType | |
| 177 | || s.numRays !== referenceState.numRays | |
| 178 | || s.coreModifier !== referenceState.coreModifier; | |
| 179 | } | |
| 180 | ||
| 181 | function updateResetBtn() { | |
| 182 | el<HTMLButtonElement>('reset-btn').disabled = !isStateChanged(); | |
| 183 | } | |
| 184 | ||
| 185 | function updateDeleteBtn(presetId: string) { | |
| 186 | el<HTMLButtonElement>('delete-preset-btn').style.display = | |
| 187 | customPresets.some(p => p.id === presetId) ? '' : 'none'; | |
| 188 | } | |
| 189 | ||
| 190 | // ── Save form ───────────────────────────────────────────────────────────── | |
| 191 | ||
| 192 | let selectedEmoji = ''; | |
| 193 | ||
| 194 | function initEmojiGrid() { | |
| 195 | const grid = el<HTMLDivElement>('emoji-grid'); | |
| 196 | for (const emoji of EMOJI_LIST) { | |
| 197 | const btn = document.createElement('button'); | |
| 198 | btn.type = 'button'; | |
| 199 | btn.className = 'emoji-btn'; | |
| 200 | btn.textContent = emoji; | |
| 201 | btn.addEventListener('click', () => { | |
| 202 | selectedEmoji = emoji; | |
| 203 | grid.querySelectorAll('.emoji-btn').forEach(b => b.classList.remove('selected')); | |
| 204 | btn.classList.add('selected'); | |
| 205 | updateSaveConfirmBtn(); | |
| 206 | }); | |
| 207 | grid.appendChild(btn); | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | function updateSaveConfirmBtn() { | |
| 212 | const name = el<HTMLInputElement>('preset-name-input').value.trim(); | |
| 213 | el<HTMLButtonElement>('save-confirm-btn').disabled = !selectedEmoji || !name; | |
| 214 | } | |
| 215 | ||
| 216 | function openSaveForm() { | |
| 217 | selectedEmoji = ''; | |
| 218 | el<HTMLInputElement>('preset-name-input').value = ''; | |
| 219 | el<HTMLDivElement>('emoji-grid').querySelectorAll('.emoji-btn') | |
| 220 | .forEach(b => b.classList.remove('selected')); | |
| 221 | updateSaveConfirmBtn(); | |
| 222 | el('save-preset-form').style.display = ''; | |
| 223 | el<HTMLInputElement>('preset-name-input').focus(); | |
0ed88387 2026-06-12 Pavel Safronov | 224 | } |
| 225 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 226 | function closeSaveForm() { |
| 227 | el('save-preset-form').style.display = 'none'; | |
| 228 | } | |
| 229 | ||
| 230 | function confirmSave() { | |
| 231 | const name = el<HTMLInputElement>('preset-name-input').value.trim(); | |
| 232 | if (!selectedEmoji || !name) return; | |
| 233 | ||
| 234 | const id = `custom-${Date.now()}`; | |
| 235 | const s = readState(); | |
| 236 | const cp: CustomPreset = { | |
| 237 | id, | |
| 238 | emoji: selectedEmoji, | |
| 239 | name, | |
| 240 | state: { | |
| 241 | outerCoreRadius: s.outerCoreRadius, | |
| 242 | outerCoreVTop: s.outerCoreVTop, | |
| 243 | outerCoreVBottom: s.outerCoreVBottom, | |
| 244 | innerCoreRadius: s.innerCoreRadius, | |
| 245 | innerCoreV: s.innerCoreV, | |
| 246 | waveType: s.waveType, | |
| 247 | numRays: s.numRays, | |
| 248 | coreModifier: s.coreModifier, | |
| 249 | }, | |
| 250 | }; | |
| 251 | customPresets.push(cp); | |
| 252 | persistCustomPresets(customPresets); | |
| 253 | rebuildCustomOptions(); | |
| 254 | ||
| 255 | // Switch the dropdown to the newly-saved preset | |
| 256 | el<HTMLSelectElement>('preset').value = id; | |
| 257 | const saved = getPresetState(id)!; | |
| 258 | Object.assign(state, saved); | |
| 259 | referenceState = { ...saved }; | |
| 260 | closeSaveForm(); | |
| 261 | fire(); | |
| 262 | updateDeleteBtn(id); | |
| 263 | } | |
| 264 | ||
| 265 | function deleteCustomPreset() { | |
| 266 | const id = el<HTMLSelectElement>('preset').value; | |
| 267 | customPresets = customPresets.filter(p => p.id !== id); | |
| 268 | persistCustomPresets(customPresets); | |
| 269 | rebuildCustomOptions(); | |
| 270 | ||
| 271 | // Fall back to Earth preset | |
| 272 | const earth = getPresetState('earth')!; | |
| 273 | applyStateToDOM(earth); | |
| 274 | Object.assign(state, earth); | |
| 275 | referenceState = { ...earth }; | |
| 276 | fire(); | |
| 277 | updateDeleteBtn('earth'); | |
| 278 | } | |
| 279 | ||
| 280 | // ── Update loop ─────────────────────────────────────────────────────────── | |
| 281 | ||
0ed88387 2026-06-12 Pavel Safronov | 282 | function fire() { |
| 283 | const s = readState(); | |
| 284 | Object.assign(state, s); | |
| 285 | applyStateToDOM(s); | |
| 286 | onChange(s); | |
2ef16b5d 2026-06-13 Pavel Safronov | 287 | updateResetBtn(); |
0ed88387 2026-06-12 Pavel Safronov | 288 | } |
| 289 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 290 | // ── Event listeners ─────────────────────────────────────────────────────── |
| 291 | ||
0ed88387 2026-06-12 Pavel Safronov | 292 | el('preset').addEventListener('change', () => { |
2ef16b5d 2026-06-13 Pavel Safronov | 293 | const id = el<HTMLSelectElement>('preset').value; |
| 294 | const ps = getPresetState(id); | |
| 295 | if (ps) { | |
| 296 | applyStateToDOM(ps); | |
| 297 | Object.assign(state, ps); | |
| 298 | referenceState = { ...ps }; | |
0ed88387 2026-06-12 Pavel Safronov | 299 | } |
2ef16b5d 2026-06-13 Pavel Safronov | 300 | closeSaveForm(); |
0ed88387 2026-06-12 Pavel Safronov | 301 | fire(); |
2ef16b5d 2026-06-13 Pavel Safronov | 302 | updateDeleteBtn(id); |
0ed88387 2026-06-12 Pavel Safronov | 303 | }); |
| 304 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 305 | const sliders = [ |
| 306 | 'outer-core-radius', 'outer-core-v-top', 'outer-core-v-bottom', | |
| 307 | 'inner-core-radius', 'inner-core-v', 'num-rays', | |
| 308 | ]; | |
0ed88387 2026-06-12 Pavel Safronov | 309 | for (const id of sliders) { |
| 310 | el(id).addEventListener('input', fire); | |
| 311 | } | |
| 312 | el('wave-type').addEventListener('change', fire); | |
| 313 | el('earthquake-select').addEventListener('change', fire); | |
| 314 | ||
2ef16b5d 2026-06-13 Pavel Safronov | 315 | el('reset-btn').addEventListener('click', () => { |
| 316 | applyStateToDOM(referenceState); | |
| 317 | Object.assign(state, referenceState); | |
| 318 | fire(); | |
| 319 | }); | |
| 320 | ||
| 321 | el('save-preset-btn').addEventListener('click', openSaveForm); | |
| 322 | el('save-cancel-btn').addEventListener('click', closeSaveForm); | |
| 323 | el('save-confirm-btn').addEventListener('click', confirmSave); | |
| 324 | ||
| 325 | el<HTMLInputElement>('preset-name-input').addEventListener('input', updateSaveConfirmBtn); | |
| 326 | el<HTMLInputElement>('preset-name-input').addEventListener('keydown', (e) => { | |
| 327 | if (e.key === 'Enter') confirmSave(); | |
| 328 | if (e.key === 'Escape') closeSaveForm(); | |
| 329 | }); | |
| 330 | ||
| 331 | el('delete-preset-btn').addEventListener('click', deleteCustomPreset); | |
| 332 | ||
| 333 | // ── Init ────────────────────────────────────────────────────────────────── | |
| 334 | ||
| 335 | initEmojiGrid(); | |
| 336 | rebuildCustomOptions(); | |
0ed88387 2026-06-12 Pavel Safronov | 337 | applyStateToDOM(state); |
| 338 | return state; | |
| 339 | } |