shadow-zone / commit
Add custom preset save/delete and per-preset Reset button
index.html | 93 +++++++++++++++++- src/controls.ts | 299 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 360 insertions(+), 32 deletions(-) diff --git a/index.html b/index.html index 77beced..f82daa0 100644 --- a/index.html +++ b/index.html @@ -196,6 +196,76 @@ #app { flex-direction: column; } #sidebar { width: 100%; border-left: none; border-top: 1px solid #2a2a40; } } + + /* ── Preset controls ──────────────────────────────────────────── */ + .sidebar-btn { + background: #16162a; + color: #a0a0c0; + border: 1px solid #3a3a58; + border-radius: 3px; + padding: 2px 7px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; + } + .sidebar-btn:hover:not(:disabled) { + background: #1e1e36; + color: #d0d0ff; + border-color: #5050a0; + } + .sidebar-btn:disabled { + opacity: 0.35; + cursor: default; + } + .sidebar-btn--danger:hover:not(:disabled) { + background: #2a1a1a; + border-color: #584040; + color: #e08080; + } + .preset-actions { + display: flex; + gap: 5px; + margin-top: 5px; + } + #save-preset-form { + background: #0e0e20; + border: 1px solid #3a3a58; + border-radius: 4px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + } + .emoji-grid { + display: flex; + flex-wrap: wrap; + gap: 3px; + } + .emoji-btn { + width: 26px; + height: 26px; + background: #16162a; + border: 1px solid #2a2a40; + border-radius: 3px; + cursor: pointer; + font-size: 15px; + line-height: 1; + padding: 0; + } + .emoji-btn:hover { background: #1e1e36; border-color: #4040a0; } + .emoji-btn.selected { border-color: #6496ff; background: #1a1a3a; } + #preset-name-input { + background: #16162a; + color: #d0d0e0; + border: 1px solid #3a3a58; + border-radius: 3px; + padding: 4px 7px; + font-size: 12px; + width: 100%; + } + #preset-name-input::placeholder { color: #505070; } + #preset-name-input:focus { outline: none; border-color: #6496ff; } + .preset-form-btns { display: flex; gap: 5px; } </style> </head> <body> @@ -207,7 +277,7 @@ <div id="sidebar"> <div class="control-group"> - <label>Preset</label> + <label>Preset <button id="reset-btn" class="sidebar-btn" type="button" disabled>↺ Reset</button></label> <select id="preset"> <option value="earth">🌍 Earth (PREM-like)</option> <option value="solid">🪨 Solid Earth</option> @@ -215,7 +285,27 @@ <option value="giant-core">🔴 Giant Core</option> <option value="tiny-core">🔵 Tiny Core</option> <option value="fast-core">⚡ Fast Core</option> + <option id="custom-section-marker" disabled style="display:none"></option> + <option disabled>──────────────</option> + <option value="neg-refraction">⚠️ Negative Refraction</option> + <option value="zero-index">⚠️ Zero-Index Core</option> + <option value="spiral-trap">⚠️ Spiral Trap</option> + <option value="retrograde">⚠️ Retrograde Core</option> + <option value="absorber">⚠️ Perfect Absorber</option> </select> + <div class="preset-actions"> + <button id="save-preset-btn" class="sidebar-btn" type="button">+ Save as…</button> + <button id="delete-preset-btn" class="sidebar-btn sidebar-btn--danger" type="button" style="display:none">✕ Delete</button> + </div> + </div> + + <div id="save-preset-form" style="display:none"> + <div id="emoji-grid" class="emoji-grid"></div> + <input id="preset-name-input" type="text" maxlength="40" placeholder="Preset name…" autocomplete="off" /> + <div class="preset-form-btns"> + <button id="save-confirm-btn" class="sidebar-btn" type="button" disabled>Save</button> + <button id="save-cancel-btn" class="sidebar-btn" type="button">Cancel</button> + </div> </div> <details id="description-details"> @@ -289,6 +379,7 @@ </div> </div> + <input type="hidden" id="core-modifier-hidden" value=""> <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/src/controls.ts b/src/controls.ts index 45d0e6f..b39f962 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -10,10 +10,50 @@ export interface AppState { waveType: 'P' | 'S' | 'both'; numRays: number; preset: string; - /** Currently selected earthquake dataset id, or '' for none */ earthquakeId: string; + /** Impossible-core physics modifier — derived from the active preset, not a slider */ + coreModifier: string; } +// ── Custom preset persistence ───────────────────────────────────────────────── + +interface CustomPreset { + id: string; + emoji: string; + name: string; + state: { + outerCoreRadius: number; + outerCoreVTop: number; + outerCoreVBottom: number; + innerCoreRadius: number; + innerCoreV: number; + waveType: AppState['waveType']; + numRays: number; + coreModifier: string; + }; +} + +const STORAGE_KEY = 'sz-custom-presets'; + +function loadCustomPresets(): CustomPreset[] { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as CustomPreset[]; + } catch { + return []; + } +} + +function persistCustomPresets(presets: CustomPreset[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +const EMOJI_LIST = [ + '🌍','🌙','🪐','⭐','⚫','💎','🔥','❄️','🌊','🪨', + '💡','⚡','🌀','🎯','🌋','🏔️','🧲','🔮','🫧','🧊', +]; + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + function el<T extends HTMLElement>(id: string): T { return document.getElementById(id) as T; } @@ -24,10 +64,12 @@ function syncDisplay(inputId: string, displayId: string, decimals = 0) { display.textContent = parseFloat(input.value).toFixed(decimals); } +// ── Main ────────────────────────────────────────────────────────────────────── + export function initControls(onChange: (state: AppState) => void): AppState { const state: AppState = { ...PRESETS['earth'], earthquakeId: '' }; - // Populate earthquake selector dynamically + // ── Earthquake selector ─────────────────────────────────────────────────── const eqSelect = el<HTMLSelectElement>('earthquake-select'); const noneOpt = document.createElement('option'); noneOpt.value = ''; @@ -40,63 +82,258 @@ export function initControls(onChange: (state: AppState) => void): AppState { eqSelect.appendChild(opt); } + // ── Custom presets ──────────────────────────────────────────────────────── + let customPresets = loadCustomPresets(); + + /** Returns a full AppState for any preset id (standard or custom). */ + function getPresetState(id: string): AppState | null { + if (PRESETS[id]) return { ...PRESETS[id] }; + const cp = customPresets.find(p => p.id === id); + if (cp) { + return { + ...PRESETS['earth'], + ...cp.state, + preset: id, + earthquakeId: '', + }; + } + return null; + } + + /** + * Syncs the custom-preset portion of the <select> dropdown. + * Custom options are tagged with data-custom and inserted before the + * invisible #custom-section-marker, which sits above the ⚠️ impossible cores. + */ + function rebuildCustomOptions() { + const presetEl = el<HTMLSelectElement>('preset'); + const marker = document.getElementById('custom-section-marker')!; + presetEl.querySelectorAll('[data-custom]').forEach(o => o.remove()); + if (customPresets.length === 0) return; + const divider = document.createElement('option'); + divider.disabled = true; + divider.textContent = '── Custom ──'; + divider.setAttribute('data-custom', 'divider'); + presetEl.insertBefore(divider, marker); + for (const cp of customPresets) { + const opt = document.createElement('option'); + opt.value = cp.id; + opt.textContent = `${cp.emoji} ${cp.name}`; + opt.setAttribute('data-custom', 'preset'); + presetEl.insertBefore(opt, marker); + } + } + + // ── DOM read / write ────────────────────────────────────────────────────── + function readState(): AppState { return { - outerCoreRadius: parseFloat(el<HTMLInputElement>('outer-core-radius').value), - outerCoreVTop: parseFloat(el<HTMLInputElement>('outer-core-v-top').value), + outerCoreRadius: parseFloat(el<HTMLInputElement>('outer-core-radius').value), + outerCoreVTop: parseFloat(el<HTMLInputElement>('outer-core-v-top').value), outerCoreVBottom: parseFloat(el<HTMLInputElement>('outer-core-v-bottom').value), - innerCoreRadius: parseFloat(el<HTMLInputElement>('inner-core-radius').value), - innerCoreV: parseFloat(el<HTMLInputElement>('inner-core-v').value), - waveType: el<HTMLSelectElement>('wave-type').value as AppState['waveType'], - numRays: parseInt(el<HTMLInputElement>('num-rays').value, 10), - preset: el<HTMLSelectElement>('preset').value, + innerCoreRadius: parseFloat(el<HTMLInputElement>('inner-core-radius').value), + innerCoreV: parseFloat(el<HTMLInputElement>('inner-core-v').value), + waveType: el<HTMLSelectElement>('wave-type').value as AppState['waveType'], + numRays: parseInt(el<HTMLInputElement>('num-rays').value, 10), + preset: el<HTMLSelectElement>('preset').value, earthquakeId: el<HTMLSelectElement>('earthquake-select').value, + // coreModifier is stored in a hidden input; it's set by applyStateToDOM + // and not tied to any visible slider. + coreModifier: el<HTMLInputElement>('core-modifier-hidden').value, }; } function applyStateToDOM(s: AppState) { - el<HTMLInputElement>('outer-core-radius').value = String(s.outerCoreRadius); - el<HTMLInputElement>('outer-core-v-top').value = String(s.outerCoreVTop); - el<HTMLInputElement>('outer-core-v-bottom').value = String(s.outerCoreVBottom); - el<HTMLInputElement>('inner-core-radius').value = String(s.innerCoreRadius); - el<HTMLInputElement>('inner-core-v').value = String(s.innerCoreV); - el<HTMLSelectElement>('wave-type').value = s.waveType; - el<HTMLInputElement>('num-rays').value = String(s.numRays); - el<HTMLSelectElement>('preset').value = s.preset; - - syncDisplay('outer-core-radius', 'outer-core-radius-val', 0); - syncDisplay('outer-core-v-top', 'outer-core-v-top-val', 1); - syncDisplay('outer-core-v-bottom', 'outer-core-v-bottom-val', 1); - syncDisplay('inner-core-radius', 'inner-core-radius-val', 0); - syncDisplay('inner-core-v', 'inner-core-v-val', 1); - syncDisplay('num-rays', 'num-rays-val', 0); + el<HTMLInputElement>('outer-core-radius').value = String(s.outerCoreRadius); + el<HTMLInputElement>('outer-core-v-top').value = String(s.outerCoreVTop); + el<HTMLInputElement>('outer-core-v-bottom').value = String(s.outerCoreVBottom); + el<HTMLInputElement>('inner-core-radius').value = String(s.innerCoreRadius); + el<HTMLInputElement>('inner-core-v').value = String(s.innerCoreV); + el<HTMLSelectElement>('wave-type').value = s.waveType; + el<HTMLInputElement>('num-rays').value = String(s.numRays); + el<HTMLSelectElement>('preset').value = s.preset; + el<HTMLInputElement>('core-modifier-hidden').value = s.coreModifier ?? ''; + + syncDisplay('outer-core-radius', 'outer-core-radius-val', 0); + syncDisplay('outer-core-v-top', 'outer-core-v-top-val', 1); + syncDisplay('outer-core-v-bottom','outer-core-v-bottom-val',1); + syncDisplay('inner-core-radius', 'inner-core-radius-val', 0); + syncDisplay('inner-core-v', 'inner-core-v-val', 1); + syncDisplay('num-rays', 'num-rays-val', 0); + } + + // ── Reset button ────────────────────────────────────────────────────────── + + let referenceState: AppState = { ...state }; + + function isStateChanged(): boolean { + const s = readState(); + return s.outerCoreRadius !== referenceState.outerCoreRadius + || s.outerCoreVTop !== referenceState.outerCoreVTop + || s.outerCoreVBottom !== referenceState.outerCoreVBottom + || s.innerCoreRadius !== referenceState.innerCoreRadius + || s.innerCoreV !== referenceState.innerCoreV + || s.waveType !== referenceState.waveType + || s.numRays !== referenceState.numRays + || s.coreModifier !== referenceState.coreModifier; + } + + function updateResetBtn() { + el<HTMLButtonElement>('reset-btn').disabled = !isStateChanged(); + } + + function updateDeleteBtn(presetId: string) { + el<HTMLButtonElement>('delete-preset-btn').style.display = + customPresets.some(p => p.id === presetId) ? '' : 'none'; + } + + // ── Save form ───────────────────────────────────────────────────────────── + + let selectedEmoji = ''; + + function initEmojiGrid() { + const grid = el<HTMLDivElement>('emoji-grid'); + for (const emoji of EMOJI_LIST) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'emoji-btn'; + btn.textContent = emoji; + btn.addEventListener('click', () => { + selectedEmoji = emoji; + grid.querySelectorAll('.emoji-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + updateSaveConfirmBtn(); + }); + grid.appendChild(btn); + } + } + + function updateSaveConfirmBtn() { + const name = el<HTMLInputElement>('preset-name-input').value.trim(); + el<HTMLButtonElement>('save-confirm-btn').disabled = !selectedEmoji || !name; + } + + function openSaveForm() { + selectedEmoji = ''; + el<HTMLInputElement>('preset-name-input').value = ''; + el<HTMLDivElement>('emoji-grid').querySelectorAll('.emoji-btn') + .forEach(b => b.classList.remove('selected')); + updateSaveConfirmBtn(); + el('save-preset-form').style.display = ''; + el<HTMLInputElement>('preset-name-input').focus(); } + function closeSaveForm() { + el('save-preset-form').style.display = 'none'; + } + + function confirmSave() { + const name = el<HTMLInputElement>('preset-name-input').value.trim(); + if (!selectedEmoji || !name) return; + + const id = `custom-${Date.now()}`; + const s = readState(); + const cp: CustomPreset = { + id, + emoji: selectedEmoji, + name, + state: { + outerCoreRadius: s.outerCoreRadius, + outerCoreVTop: s.outerCoreVTop, + outerCoreVBottom: s.outerCoreVBottom, + innerCoreRadius: s.innerCoreRadius, + innerCoreV: s.innerCoreV, + waveType: s.waveType, + numRays: s.numRays, + coreModifier: s.coreModifier, + }, + }; + customPresets.push(cp); + persistCustomPresets(customPresets); + rebuildCustomOptions(); + + // Switch the dropdown to the newly-saved preset + el<HTMLSelectElement>('preset').value = id; + const saved = getPresetState(id)!; + Object.assign(state, saved); + referenceState = { ...saved }; + closeSaveForm(); + fire(); + updateDeleteBtn(id); + } + + function deleteCustomPreset() { + const id = el<HTMLSelectElement>('preset').value; + customPresets = customPresets.filter(p => p.id !== id); + persistCustomPresets(customPresets); + rebuildCustomOptions(); + + // Fall back to Earth preset + const earth = getPresetState('earth')!; + applyStateToDOM(earth); + Object.assign(state, earth); + referenceState = { ...earth }; + fire(); + updateDeleteBtn('earth'); + } + + // ── Update loop ─────────────────────────────────────────────────────────── + function fire() { const s = readState(); Object.assign(state, s); applyStateToDOM(s); onChange(s); + updateResetBtn(); } - // Preset change: load preset values into DOM then fire + // ── Event listeners ─────────────────────────────────────────────────────── + el('preset').addEventListener('change', () => { - const preset = PRESETS[el<HTMLSelectElement>('preset').value]; - if (preset) { - applyStateToDOM(preset); - Object.assign(state, preset); + const id = el<HTMLSelectElement>('preset').value; + const ps = getPresetState(id); + if (ps) { + applyStateToDOM(ps); + Object.assign(state, ps); + referenceState = { ...ps }; } + closeSaveForm(); fire(); + updateDeleteBtn(id); }); - const sliders = ['outer-core-radius', 'outer-core-v-top', 'outer-core-v-bottom', - 'inner-core-radius', 'inner-core-v', 'num-rays']; + const sliders = [ + 'outer-core-radius', 'outer-core-v-top', 'outer-core-v-bottom', + 'inner-core-radius', 'inner-core-v', 'num-rays', + ]; for (const id of sliders) { el(id).addEventListener('input', fire); } el('wave-type').addEventListener('change', fire); el('earthquake-select').addEventListener('change', fire); + el('reset-btn').addEventListener('click', () => { + applyStateToDOM(referenceState); + Object.assign(state, referenceState); + fire(); + }); + + el('save-preset-btn').addEventListener('click', openSaveForm); + el('save-cancel-btn').addEventListener('click', closeSaveForm); + el('save-confirm-btn').addEventListener('click', confirmSave); + + el<HTMLInputElement>('preset-name-input').addEventListener('input', updateSaveConfirmBtn); + el<HTMLInputElement>('preset-name-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') confirmSave(); + if (e.key === 'Escape') closeSaveForm(); + }); + + el('delete-preset-btn').addEventListener('click', deleteCustomPreset); + + // ── Init ────────────────────────────────────────────────────────────────── + + initEmojiGrid(); + rebuildCustomOptions(); applyStateToDOM(state); return state; }