Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
Svelte
at mathquill
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300<script lang="ts"> import { animationExportDurationSeconds, animationExportFrameTimes, animationExportSettingsError, type AnimationExportProgress, type AnimationExportSettings, type AnimationExportTrack } from '#lib/engine/animation-export.js';
let { open = false, dark = false, preparing = false, busy = false, tracks = [], settings, progress = { completedFrames: 0, totalFrames: 0 }, previewUrl = null, error = '', status = '', onsettingschange, ongenerate, oncancel, onclose, ondownload, oncopy }: { open?: boolean; dark?: boolean; preparing?: boolean; busy?: boolean; tracks?: AnimationExportTrack[]; settings: AnimationExportSettings; progress?: AnimationExportProgress; previewUrl?: string | null; error?: string; status?: string; onsettingschange: (settings: AnimationExportSettings) => void; ongenerate: () => void; oncancel: () => void; onclose: () => void; ondownload: () => void; oncopy: () => void; } = $props();
let settingsError = $derived(animationExportSettingsError(settings));
let frameCount = $derived( settingsError === null ? animationExportFrameTimes(settings).length : 0 );
let durationSeconds = $derived( settingsError === null ? animationExportDurationSeconds(settings) : 0 );
function updateSetting(name: keyof AnimationExportSettings, event: Event) { const value = Number((event.currentTarget as HTMLInputElement).value); onsettingschange({ ...settings, [name]: value }); }
function handleKeydown(event: KeyboardEvent) { if (open && event.key === 'Escape' && !busy) { event.preventDefault(); onclose(); } }</script>
<svelte:window onkeydown={handleKeydown} />
{#if open} <div class:dark class="backdrop"> <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="gif-export-title" tabindex="-1"> <header> <div> <h2 id="gif-export-title">Export GIF</h2> <p>Capture all enabled slider variables on one shared timeline.</p> </div> <button type="button" class="close" aria-label="Close GIF export" onclick={onclose} disabled={busy}>×</button> </header>
{#if preparing} <div class="preparing" role="status">Preparing the graph for export…</div> {:else if previewUrl} <div class="preview-wrap"> <img src={previewUrl} alt="Animated preview of the exported graph" /> <p class="preview-note">This GIF is local to your browser. It has not been uploaded or saved to your account.</p> </div> {#if error}<p class="error" role="alert">{error}</p>{/if} {#if status}<p class="status" role="status">{status}</p>{/if} <footer class="result-actions"> <button type="button" onclick={ondownload}>Download GIF</button> <button type="button" onclick={oncopy}>Copy GIF</button> <button type="button" class="secondary" onclick={onclose}>Close</button> </footer> {:else} <form onsubmit={(event) => { event.preventDefault(); ongenerate(); }}> <div class="settings-grid"> <label> <span>One-way sweeps</span> <input type="number" min="1" step="1" value={settings.oneWayDurations} oninput={(event) => updateSetting('oneWayDurations', event)} /> </label> <label> <span>Width (pixels)</span> <input type="number" min="320" step="1" value={settings.width} oninput={(event) => updateSetting('width', event)} /> </label> <label> <span>Height (pixels)</span> <input type="number" min="240" step="1" value={settings.height} oninput={(event) => updateSetting('height', event)} /> </label> <label> <span>Frame rate</span> <select value={settings.fps} onchange={(event) => updateSetting('fps', event)}> {#each [15, 20, 24, 30, 45, 60] as fps} <option value={fps}>{fps} FPS</option> {/each} </select> </label> </div> <p class="help">One sweep is 4 seconds at speed 1. Different per-variable speeds or one-shot modes may not return to the same state at the loop boundary.</p>
{#if settingsError}<p class="error" role="alert">{settingsError}</p>{/if} {#if tracks.length === 0} <p class="empty">No enabled scalar variables with valid sliders are available to animate.</p> {:else} <div class="tracks"> <strong>Variables in this GIF</strong> {#each tracks as track} <div class="track"> <span>{track.name}</span> <small>{track.settings.direction === 'backward' ? 'backward' : 'forward'} · {track.slider.min} to {track.slider.max}</small> </div> {/each} </div> {/if}
<p class="estimate"> {#if frameCount > 0}{settings.oneWayDurations} one-way sweeps · {durationSeconds} seconds · {frameCount.toLocaleString()} frames · {settings.width} × {settings.height} · loops forever{/if} </p> {#if error}<p class="error" role="alert">{error}</p>{/if} <footer class="actions"> <button type="submit" disabled={busy || tracks.length === 0 || settingsError !== null}> {busy ? 'Generating…' : 'Generate GIF'} </button> <button type="button" class="secondary" onclick={busy ? oncancel : onclose}> {busy ? 'Cancel' : 'Close'} </button> </footer> {#if busy} <div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax={progress.totalFrames} aria-valuenow={progress.completedFrames}> <div style={`width: ${progress.totalFrames ? (progress.completedFrames / progress.totalFrames) * 100 : 0}%`}></div> </div> <p class="estimate">Rendering frame {progress.completedFrames.toLocaleString()} of {progress.totalFrames.toLocaleString()}…</p> {/if} </form> {/if} </div> </div>{/if}
<style> .backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgb(15 23 42 / 58%); color: #0f172a; }
.dialog { width: min(560px, 100%); max-height: min(760px, calc(100dvh - 40px)); overflow: auto; border: 1px solid #cbd5e1; border-radius: 12px; background: #f8fafc; box-shadow: 0 24px 80px rgb(15 23 42 / 35%); }
.backdrop.dark .dialog { border-color: #475569; background: #111827; color: #e2e8f0; }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 20px 14px; border-bottom: 1px solid #cbd5e1; background: #e2e8f0; }
.dark header { border-color: #334155; background: #1e293b; }
h2 { margin: 0; font-size: 20px; } header p { margin: 4px 0 0; color: #64748b; font-size: 13px; } .dark header p { color: #94a3b8; }
.close { width: 30px; height: 30px; padding: 0; border: 1px solid #94a3b8; border-radius: 6px; background: transparent; color: inherit; font-size: 22px; line-height: 1; cursor: pointer; }
form, .preparing, .preview-wrap { padding: 18px 20px; } .preparing { color: #475569; } .dark .preparing { color: #cbd5e1; }
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
label { display: grid; gap: 5px; color: #475569; font-size: 12px; font-weight: 700; } .dark label { color: #cbd5e1; } input, select { width: 100%; box-sizing: border-box; padding: 7px 8px; border: 1px solid #94a3b8; border-radius: 5px; background: white; color: #0f172a; font: inherit; } .dark input, .dark select { border-color: #475569; background: #0f172a; color: #e2e8f0; }
.tracks { display: grid; gap: 5px; margin-top: 17px; padding: 10px; border: 1px solid #cbd5e1; border-radius: 7px; background: white; } .dark .tracks { border-color: #334155; background: #0f172a; } .track { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; } .track small { color: #64748b; text-align: right; } .empty, .error, .status, .estimate, .preview-note { font-size: 12px; } .empty { margin: 17px 0 0; color: #92400e; } .error { margin: 12px 0 0; color: #be123c; } .help { margin: 13px 0 0; color: #64748b; font-size: 12px; line-height: 1.45; } .dark .help { color: #94a3b8; } .status { margin: 0 20px 14px; color: #166534; } .estimate { min-height: 1.2em; margin: 14px 0 0; color: #64748b; } .dark .estimate, .dark .track small, .dark .preview-note { color: #94a3b8; }
.actions, .result-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } button { padding: 8px 11px; border: 1px solid #64748b; border-radius: 6px; background: #2563eb; color: white; font: 650 13px/1.2 system-ui, sans-serif; cursor: pointer; } button:hover:not(:disabled), button:focus-visible:not(:disabled) { background: #1d4ed8; } button:disabled { cursor: default; opacity: 0.5; } button.secondary { background: transparent; color: #334155; } .dark button.secondary { color: #e2e8f0; }
.progress { height: 8px; margin-top: 14px; overflow: hidden; border-radius: 999px; background: #cbd5e1; } .progress div { height: 100%; border-radius: inherit; background: #2563eb; transition: width 120ms linear; } .preview-wrap img { display: block; width: 100%; height: auto; border: 1px solid #cbd5e1; background: white; } .preview-note { margin: 9px 0 0; color: #64748b; }
@media (max-width: 480px) { .backdrop { padding: 8px; } .dialog { max-height: calc(100dvh - 16px); } .settings-grid { grid-template-columns: 1fr; } .track { flex-direction: column; gap: 2px; } .track small { text-align: left; } }</style>