shadow-zone / files / src / controls.ts
controls.ts
| 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; |
| 14 | /** Impossible-core physics modifier — derived from the active preset, not a slider */ |
| 15 | coreModifier: string; |
| 16 | } |
| 17 | |
| 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 | |
| 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 | |
| 67 | // ── Main ────────────────────────────────────────────────────────────────────── |
| 68 | |
| 69 | export function initControls(onChange: (state: AppState) => void): AppState { |
| 70 | const state: AppState = { ...PRESETS['earth'], earthquakeId: '' }; |
| 71 | |
| 72 | // ── Earthquake selector ─────────────────────────────────────────────────── |
| 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 | |
| 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 | |
| 129 | function readState(): AppState { |
| 130 | return { |
| 131 | outerCoreRadius: parseFloat(el<HTMLInputElement>('outer-core-radius').value), |
| 132 | outerCoreVTop: parseFloat(el<HTMLInputElement>('outer-core-v-top').value), |
| 133 | outerCoreVBottom: parseFloat(el<HTMLInputElement>('outer-core-v-bottom').value), |
| 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, |
| 139 | earthquakeId: el<HTMLSelectElement>('earthquake-select').value, |
| 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, |
| 143 | }; |
| 144 | } |
| 145 | |
| 146 | function applyStateToDOM(s: AppState) { |
| 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(); |
| 224 | } |
| 225 | |
| 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 | |
| 282 | function fire() { |
| 283 | const s = readState(); |
| 284 | Object.assign(state, s); |
| 285 | applyStateToDOM(s); |
| 286 | onChange(s); |
| 287 | updateResetBtn(); |
| 288 | } |
| 289 | |
| 290 | // ── Event listeners ─────────────────────────────────────────────────────── |
| 291 | |
| 292 | el('preset').addEventListener('change', () => { |
| 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 }; |
| 299 | } |
| 300 | closeSaveForm(); |
| 301 | fire(); |
| 302 | updateDeleteBtn(id); |
| 303 | }); |
| 304 | |
| 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 | ]; |
| 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 | |
| 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(); |
| 337 | applyStateToDOM(state); |
| 338 | return state; |
| 339 | } |