fuzzyworld

shadow-zone / files / src / controls.ts

controls.ts

12.8 KB · history · blame

1import { PRESETS } from './earth';
2import { EARTHQUAKE_DATASETS } from './earthquakeData';
3
4export 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
20interface 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
36const STORAGE_KEY = 'sz-custom-presets';
37
38function loadCustomPresets(): CustomPreset[] {
39 try {
40 return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as CustomPreset[];
41 } catch {
42 return [];
43 }
44}
45
46function persistCustomPresets(presets: CustomPreset[]): void {
47 localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
48}
49
50const EMOJI_LIST = [
51 '🌍','🌙','🪐','⭐','⚫','💎','🔥','❄️','🌊','🪨',
52 '💡','⚡','🌀','🎯','🌋','🏔️','🧲','🔮','🫧','🧊',
53];
54
55// ── DOM helpers ───────────────────────────────────────────────────────────────
56
57function el<T extends HTMLElement>(id: string): T {
58 return document.getElementById(id) as T;
59}
60
61function 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
69export 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}