fuzzyworld

shadow-zone / commit

Add custom preset save/delete and per-preset Reset button

2ef16b5de0da8109a9a2a18643cbe1c7ec52280f · Pavel Safronov · 2026-06-13 15:20:58 -0700 · parents e86a2c693b73

Add custom preset save/delete and per-preset Reset button

Save as…: captures current slider values + coreModifier with a
user-chosen emoji and name, persists to localStorage, and injects
the preset into the dropdown above the impossible cores section.

Reset: disabled when values match the active preset's reference
state; enabled on any divergence; restores preset defaults.
Standard presets reset to hard-coded defaults; custom presets
reset to their saved values.

Delete: visible only for custom presets; removes from storage and
dropdown then falls back to Earth.

coreModifier is now stored in a hidden input so it survives slider
adjustments and works correctly for both standard and custom presets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 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;
 }