Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780<script lang="ts"> import { formatMatrixPreview, formatRegressionResult, formatSequencePreview, normalizeSliderValue, scalarAssignmentSource } from '#lib/document/expressions.js'; import { DEFAULT_SLIDER_ANIMATION } from '#lib/engine/animation.js'; import ExpressionEditor from './ExpressionEditor.svelte'; import { DEFAULT_EXPRESSION_APPEARANCE, type ExpressionAppearance } from '#lib/graph/expression-appearance.js'; import type { Expression2d, ExpressionRole2d, MatrixVariablePreview, RegressionResult, ScalarVariable, SequenceVariablePreview, SceneDiagnostic, SliderAnimationSettings, SliderSettings } from '#lib/engine/types.js';
let { expression, editorGeneration, role = 'plot', resolvedColor = '#2563eb', plotKind = 'other', appearance = DEFAULT_EXPRESSION_APPEARANCE, diagnostic, variable, sequenceVariable, matrixVariable, regression, selected = false, reorderMode = false, reorderPosition = 1, dragging = false, onupdate, onappearanceupdate, onremove, onselect, onreorderpointerdown, onreorderkeydown, onsliderinteractionchange, onslidervaluechange, animated = false, onanimationchange }: { expression: Expression2d; editorGeneration: number; role?: ExpressionRole2d; resolvedColor?: string; plotKind?: 'points' | 'fill' | 'other'; appearance?: ExpressionAppearance; diagnostic?: SceneDiagnostic; variable?: ScalarVariable; sequenceVariable?: SequenceVariablePreview; matrixVariable?: MatrixVariablePreview; regression?: RegressionResult; selected?: boolean; reorderMode?: boolean; reorderPosition?: number; dragging?: boolean; onupdate: (expression: Expression2d) => void; onappearanceupdate: (appearance: ExpressionAppearance) => void; onremove: () => void; onselect: () => void; onreorderpointerdown?: (event: PointerEvent) => void; onreorderkeydown?: (event: KeyboardEvent) => void; onsliderinteractionchange: (active: boolean) => void; onslidervaluechange: (name: string, value: number, commit: boolean) => void; animated?: boolean; onanimationchange: (name: string, active: boolean) => void; } = $props();
let pendingSliderValue = $state<number | null>(null); let sliderDragging = $state(false); let sliderElement = $state<HTMLInputElement>(); let displayedVariableValue = $derived(pendingSliderValue ?? variable?.value ?? 0);
let displayedExpressionSource = $derived( (animated || sliderDragging || pendingSliderValue !== null) && variable ? scalarAssignmentSource(expression.input_mode, variable.name, displayedVariableValue) : expression.source );
let directlyVisible = $derived(role === 'plot');
function clearPendingSlider() { pendingSliderValue = null;
if (!sliderDragging) return; sliderDragging = false; onsliderinteractionchange(false); }
function updateColor(event: Event) { onupdate({ ...expression, color: (event.currentTarget as HTMLInputElement).value }); }
function updateAppearance( setting: keyof ExpressionAppearance, event: Event ) { const input = event.currentTarget as HTMLInputElement | HTMLSelectElement; onappearanceupdate({ ...appearance, [setting]: setting === 'pointStyle' ? input.value : Number(input.value) }); }
function focusExpression() { onselect();
if (!animated || !variable) return; onanimationchange(variable.name, false); }
function updateVariable(event: Event) { if (!variable) return;
// Commit a scalar assignment only after explicit slider input. The // resolved scene value can come from a formula such as atan2(...), so // normalizing it on mount would silently replace the authored expression. const value = normalizeSliderValue( Number((event.currentTarget as HTMLInputElement).value), variable.slider! );
pendingSliderValue = value; onslidervaluechange(variable.name, value, !sliderDragging); }
function beginSliderDrag() { sliderDragging = true; onsliderinteractionchange(true); }
function endSliderDrag() { if (!sliderDragging) return;
if (variable && pendingSliderValue !== null) { onslidervaluechange(variable.name, pendingSliderValue, true); }
sliderDragging = false; onsliderinteractionchange(false); }
function updateSliderSetting(setting: 'min' | 'max' | 'step', event: Event) { const value = (event.currentTarget as HTMLInputElement).value; const slider = { ...expression.slider, [setting]: value || undefined }; const hasSettings = Boolean(slider.min || slider.max || slider.step); onupdate({ ...expression, slider: hasSettings ? slider : undefined }); }
function updateAnimationSetting( setting: keyof SliderAnimationSettings, event: Event ) { const input = event.currentTarget as HTMLSelectElement; const current = expression.slider?.animation ?? DEFAULT_SLIDER_ANIMATION; const value = setting === 'speed' ? Number(input.value) : input.value; onupdate({ ...expression, slider: { ...expression.slider, animation: { ...current, [setting]: value } } }); }
// Rendering is asynchronous. Keep the range thumb on the newest input instead // of rebinding it to the previous scene while that input is being rendered. $effect(() => { if ( !sliderDragging && pendingSliderValue !== null && variable && Math.abs(variable.value - pendingSliderValue) <= 1e-9 ) { pendingSliderValue = null; } });
// Do not make the range a controlled input. Rewriting its value from an // asynchronous scene during a native drag changes the browser's drag origin // and can manufacture jumps that were never present in the input events. $effect(() => { if (!sliderElement || sliderDragging || pendingSliderValue !== null || !variable?.slider) { return; }
sliderElement.value = String(variable.value); });
</script>
<article class:selected class:error={Boolean(diagnostic)} class:reorder-enabled={reorderMode} class:dragging data-expression-id={expression.id}> {#if reorderMode} <button type="button" class="reorder-handle" aria-label={`Move expression ${reorderPosition}`} title="Drag to reorder; use arrow keys to move" onpointerdown={onreorderpointerdown} onkeydown={onreorderkeydown} > <svg viewBox="0 0 12 18" aria-hidden="true"> <circle cx="3" cy="3" r="1.25" /> <circle cx="9" cy="3" r="1.25" /> <circle cx="3" cy="9" r="1.25" /> <circle cx="9" cy="9" r="1.25" /> <circle cx="3" cy="15" r="1.25" /> <circle cx="9" cy="15" r="1.25" /> </svg> </button> {/if} {#if directlyVisible} <label class="toggle" title={expression.enabled ? 'Disable expression' : 'Enable expression'}> <input type="checkbox" checked={expression.enabled} onchange={() => { onselect(); onupdate({ ...expression, enabled: !expression.enabled }); }} aria-label={`${expression.enabled ? 'Disable' : 'Enable'} expression ${reorderPosition}`} /> <span></span> </label>
<input class="color" type="color" value={expression.color ?? resolvedColor} oninput={updateColor} onfocus={onselect} aria-label={`Expression ${reorderPosition} color`} /> {:else} <span class="dependency-role" title="This row defines a dependency and is not drawn directly"> {role === 'variable' ? 'var' : role === 'function' ? 'fn' : 'fit'} </span> <span class="dependency-marker" aria-hidden="true">↳</span> {/if}
<div class="input-area"> <ExpressionEditor {expression} {editorGeneration} label={`Expression ${reorderPosition}`} displaySource={displayedExpressionSource === expression.source ? undefined : displayedExpressionSource} invalid={Boolean(diagnostic)} diagnostic={diagnostic?.message ?? ''} onupdate={(updated) => { clearPendingSlider(); onupdate(updated); }} onfocus={focusExpression} /> {#if sequenceVariable && !diagnostic} <output class="resolved-sequence" aria-label={`${sequenceVariable.name} resolved value`}> ↳ {formatSequencePreview(sequenceVariable)} </output> {/if} {#if matrixVariable && !diagnostic} <output class="resolved-sequence" aria-label={`${matrixVariable.name} resolved value`}> ↳ {formatMatrixPreview(matrixVariable)} </output> {/if} {#if regression && !diagnostic} <output class="resolved-sequence" aria-label="Resolved regression"> ↳ {formatRegressionResult(regression)} </output> {/if} {#if directlyVisible} <details class="expression-appearance"> <summary>Style</summary> <div class="appearance-grid"> {#if plotKind === 'points'} <label> <span>Marker</span> <select value={appearance.pointStyle} onchange={(event) => updateAppearance('pointStyle', event)} aria-label="Point marker" > <option value="circle">Disc</option> <option value="ring">Ring</option> <option value="cross">X</option> <option value="square">Square</option> <option value="diamond">Diamond</option> <option value="triangle">Triangle</option> </select> </label> <label> <span>Radius <output>{appearance.pointRadius.toFixed(1)} px</output></span> <input type="range" min="2" max="16" step="0.5" value={appearance.pointRadius} oninput={(event) => updateAppearance('pointRadius', event)} aria-label="Point radius" /> </label> <label> <span>Opacity <output>{Math.round(appearance.pointOpacity * 100)}%</output></span> <input type="range" min="0" max="1" step="0.05" value={appearance.pointOpacity} oninput={(event) => updateAppearance('pointOpacity', event)} aria-label="Point opacity" /> </label> {:else} <label> <span>Width <output>{appearance.lineWidth.toFixed(1)} px</output></span> <input type="range" min="0.5" max="10" step="0.5" value={appearance.lineWidth} oninput={(event) => updateAppearance('lineWidth', event)} aria-label="Line width" /> </label> <label> <span>Line opacity <output>{Math.round(appearance.lineOpacity * 100)}%</output></span> <input type="range" min="0" max="1" step="0.05" value={appearance.lineOpacity} oninput={(event) => updateAppearance('lineOpacity', event)} aria-label="Line opacity" /> </label> <label class:unavailable={plotKind !== 'fill'}> <span>Fill opacity <output>{Math.round(appearance.fillOpacity * 100)}%</output></span> <input type="range" min="0" max="1" step="0.05" value={appearance.fillOpacity} oninput={(event) => updateAppearance('fillOpacity', event)} aria-label="Fill opacity" disabled={plotKind !== 'fill'} /> </label> {/if} </div> </details> {/if} {#if variable?.slider} <div class="slider-row"> <button class="animate" type="button" onclick={() => onanimationchange(variable.name, !animated)} aria-label={`${animated ? 'Pause' : 'Animate'} ${variable.name}`} title={animated ? 'Pause animation' : 'Animate variable'} >{animated ? '❚❚' : '▶'}</button > <input bind:this={sliderElement} class="slider" type="range" min={variable.slider.min} max={variable.slider.max} step={variable.slider.step} oninput={updateVariable} onpointerdown={beginSliderDrag} onpointerup={endSliderDrag} onpointercancel={endSliderDrag} onlostpointercapture={endSliderDrag} onblur={endSliderDrag} onfocus={onselect} aria-label={`${variable.name} value`} /> </div> <details class="slider-settings"> <summary>Slider settings</summary> <div class="setting-grid"> <label> <span>Min</span> <input value={expression.slider?.min ?? ''} placeholder={String(variable.slider.min)} onchange={(event) => updateSliderSetting('min', event)} spellcheck="false" /> </label> <label> <span>Max</span> <input value={expression.slider?.max ?? ''} placeholder={String(variable.slider.max)} onchange={(event) => updateSliderSetting('max', event)} spellcheck="false" /> </label> <label> <span>Step</span> <input value={expression.slider?.step ?? ''} placeholder={String(variable.slider.step)} onchange={(event) => updateSliderSetting('step', event)} spellcheck="false" /> </label> </div> <div class="animation-grid"> <label> <span>Speed</span> <select value={String( expression.slider?.animation?.speed ?? DEFAULT_SLIDER_ANIMATION.speed )} onchange={(event) => updateAnimationSetting('speed', event)} > <option value="0.25">0.25×</option> <option value="0.5">0.5×</option> <option value="1">1×</option> <option value="2">2×</option> <option value="4">4×</option> </select> </label> <label> <span>Direction</span> <select value={expression.slider?.animation?.direction ?? DEFAULT_SLIDER_ANIMATION.direction} onchange={(event) => updateAnimationSetting('direction', event)} > <option value="forward">Forward</option> <option value="backward">Backward</option> </select> </label> <label> <span>Repeat</span> <select value={expression.slider?.animation?.mode ?? DEFAULT_SLIDER_ANIMATION.mode} onchange={(event) => updateAnimationSetting('mode', event)} > <option value="bounce">Bounce</option> <option value="loop">Loop</option> <option value="once">Once</option> </select> </label> </div> </details> {/if} </div>
<button class="remove" type="button" onclick={onremove} aria-label="Remove expression">×</button></article>
<style> article { display: grid; grid-template-columns: 28px 30px minmax(0, 1fr) 30px; gap: 8px; align-items: start; padding: 12px 10px; border-bottom: 1px solid #e2e8f0; border-left: 3px solid transparent; background: #fff; }
article.reorder-enabled { grid-template-columns: 22px 28px 30px minmax(0, 1fr) 30px; }
article.dragging { position: relative; z-index: 1; background: #eff6ff; box-shadow: inset 0 0 0 2px #60a5fa; }
.reorder-handle { display: grid; width: 22px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 4px; background: transparent; color: #94a3b8; cursor: grab; touch-action: none; }
.reorder-handle:hover, .reorder-handle:focus-visible { background: #e2e8f0; color: #334155; outline: 2px solid #93c5fd; outline-offset: 1px; }
article.dragging .reorder-handle { color: #1d4ed8; cursor: grabbing; }
.reorder-handle svg { width: 12px; height: 18px; fill: currentColor; }
article.selected { border-left-color: #334155; background: #f8fafc; }
article.error { background: #fffafa; }
.toggle { position: relative; width: 28px; height: 24px; cursor: pointer; }
.toggle input { position: absolute; opacity: 0; }
.toggle span { display: block; width: 26px; height: 16px; margin-top: 4px; border-radius: 999px; background: #cbd5e1; }
.toggle span::after { content: ''; display: block; width: 12px; height: 12px; margin: 2px; border-radius: 50%; background: white; transition: transform 120ms ease; }
.toggle input:checked + span { background: #334155; }
.toggle input:checked + span::after { transform: translateX(10px); }
.color { width: 28px; height: 28px; padding: 0; border: 0; background: transparent; cursor: pointer; }
.dependency-role { box-sizing: border-box; width: 28px; margin-top: 3px; padding: 2px 3px; border: 1px solid #cbd5e1; border-radius: 4px; background: #f8fafc; color: #64748b; font: 600 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; }
.dependency-marker { width: 28px; padding-top: 3px; color: #94a3b8; font-size: 18px; line-height: 1; text-align: center; }
.input-area { min-width: 0; }
.resolved-sequence { display: block; margin: 4px 2px 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; color: #64748b; overflow-wrap: anywhere; }
.slider-row { display: grid; grid-template-columns: 26px minmax(0, 1fr); align-items: center; gap: 8px; margin-top: 8px; }
.animate { width: 26px; height: 26px; padding: 0; border: 1px solid #94a3b8; border-radius: 50%; background: white; color: #334155; font-size: 10px; cursor: pointer; }
.animate:hover, .animate:focus-visible { border-color: #3b82f6; color: #1d4ed8; }
.slider { width: 100%; margin: 0; accent-color: #2563eb; cursor: pointer; }
.slider-settings { margin-top: 7px; color: #64748b; font-size: 12px; }
.expression-appearance { margin-top: 7px; color: #64748b; font-size: 12px; }
.expression-appearance summary { width: max-content; cursor: pointer; user-select: none; }
.appearance-grid { display: grid; gap: 7px; margin-top: 7px; }
.appearance-grid label { display: grid; gap: 2px; }
.appearance-grid label.unavailable { opacity: 0.5; }
.appearance-grid span { display: flex; justify-content: space-between; font-weight: 650; }
.appearance-grid output { font-weight: 500; font-variant-numeric: tabular-nums; }
.appearance-grid input { width: 100%; margin: 0; accent-color: #2563eb; cursor: pointer; }
.appearance-grid select { width: 100%; padding: 5px 7px; border: 1px solid #cbd5e1; border-radius: 6px; background: white; color: #334155; }
.slider-settings summary { width: max-content; cursor: pointer; user-select: none; }
.setting-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 7px; }
.animation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e2e8f0; }
.setting-grid label, .animation-grid label { display: grid; gap: 3px; }
.setting-grid span, .animation-grid span { font-weight: 650; }
.setting-grid input, .animation-grid select { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px 5px; border: 1px solid #cbd5e1; border-radius: 4px; font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: #0f172a; background: white; }
.remove { width: 28px; height: 28px; padding: 0; border: 0; border-radius: 4px; background: transparent; font-size: 24px; line-height: 1; color: #64748b; cursor: pointer; }
.remove:hover, .remove:focus-visible { background: #fee2e2; color: #b91c1c; }</style>