Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305<script lang="ts"> import { normalizeSliderValue, scalarAssignmentSource } from '#lib/document/expressions.js'; import ExpressionEditor from './ExpressionEditor.svelte'; import type { Expression3d, ExpressionRole3d, ScalarVariable, SceneDiagnostic } from '#lib/engine/types.js';
let { expression, editorGeneration, role = 'plot', resolvedColor = '#2563eb', diagnostic, variable, selected = false, position = 1, reorderMode = false, reorderPosition = 1, dragging = false, resetKey = 0, onupdate, onremove, onselect, onreorderpointerdown, onreorderkeydown, onsliderinteractionchange, onslidervaluechange }: { expression: Expression3d; editorGeneration: number; role?: ExpressionRole3d; resolvedColor?: string; diagnostic?: SceneDiagnostic; variable?: ScalarVariable; selected?: boolean; position?: number; reorderMode?: boolean; reorderPosition?: number; dragging?: boolean; resetKey?: number; onupdate: (expression: Expression3d) => 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; } = $props();
let pendingSliderValue = $state<number | null>(null); let sliderDragging = $state(false); let sliderElement = $state<HTMLInputElement>(); let lastSource = $state<string>(); let lastResetKey = $state<number>(); let lastVariableIdentity = $state<string>(); let displayedVariableValue = $derived(pendingSliderValue ?? variable?.value ?? 0);
let displayedExpressionSource = $derived( 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 updateVariable(event: Event) { if (!variable?.slider) return;
// A resolved value may come from a formula, so only explicit slider input // can replace that authored expression with a scalar assignment. const value = normalizeSliderValue( Number((event.currentTarget as HTMLInputElement).value), variable.slider );
pendingSliderValue = value; const commit = !sliderDragging; onslidervaluechange(variable.name, value, commit);
if (commit) pendingSliderValue = null; }
function beginSliderDrag() { sliderDragging = true; onsliderinteractionchange(true); }
function endSliderDrag() { if (!sliderDragging) return;
if (variable && pendingSliderValue !== null) { onslidervaluechange(variable.name, pendingSliderValue, true); }
pendingSliderValue = null; sliderDragging = false; onsliderinteractionchange(false); }
function updateSliderSetting(setting: 'min' | 'max' | 'step', event: Event) { clearPendingSlider(); 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 }); }
$effect(() => { const source = expression.source;
if (lastSource !== undefined && source !== lastSource) { clearPendingSlider(); }
lastSource = source; });
$effect(() => { const currentResetKey = resetKey;
if (lastResetKey !== undefined && currentResetKey !== lastResetKey) { clearPendingSlider(); }
lastResetKey = currentResetKey; });
$effect(() => { const identity = variable ? `${expression.id}:${variable.name}` : '';
if (lastVariableIdentity !== undefined && identity !== lastVariableIdentity) { clearPendingSlider(); }
lastVariableIdentity = identity; });
$effect(() => { const enabled = expression.enabled;
if (!enabled) clearPendingSlider(); });
$effect(() => { if (!sliderElement || sliderDragging || pendingSliderValue !== null || !variable?.slider) { return; }
sliderElement.value = String(variable.value); });
</script>
<article class:selected class:error={Boolean(diagnostic)} class:disabled={!expression.enabled} 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 ${position}`} /> <span></span> </label> <input class="color" type="color" value={expression.color ?? resolvedColor} oninput={updateColor} onfocus={onselect} aria-label={`Expression ${position} color`} /> {:else} <span class="dependency-role" title="This row defines a dependency and is not drawn directly"> {role === 'variable' ? 'var' : 'fn'} </span> <span class="dependency-marker" aria-hidden="true">↳</span> {/if}
<div class="input-area"> <ExpressionEditor {expression} {editorGeneration} label={`Expression ${position}`} displaySource={displayedExpressionSource === expression.source ? undefined : displayedExpressionSource} invalid={Boolean(diagnostic)} diagnostic={diagnostic?.message ?? ''} onupdate={(updated) => { clearPendingSlider(); onupdate(updated); }} onfocus={onselect} /> {#if variable?.slider} <div class="slider-row"> <output>{Number(displayedVariableValue.toPrecision(10))}</output> <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)} /></label> <label><span>Max</span><input value={expression.slider?.max ?? ''} placeholder={String(variable.slider.max)} onchange={(event) => updateSliderSetting('max', event)} /></label> <label><span>Step</span><input value={expression.slider?.step ?? ''} placeholder={String(variable.slider.step)} onchange={(event) => updateSliderSetting('step', event)} /></label> </div> </details> {/if} </div>
<div class="row-actions"> <button class="remove" type="button" onclick={onremove} aria-label={`Remove expression ${position}`}>×</button> </div></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; } article.disabled { opacity:.55; } .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; } .slider-row { display:grid; grid-template-columns:auto minmax(0,1fr); gap:8px; align-items:center; margin-top:7px; } .slider-row output { min-width:3.5rem; color:#475569; font:12px ui-monospace,SFMono-Regular,Menlo,monospace; text-align:right; } .slider { width:100%; accent-color:#2563eb; } .slider-settings { margin-top:5px; color:#64748b; font-size:11px; } .slider-settings summary { cursor:pointer; } .setting-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:5px; margin-top:5px; } .setting-grid label { display:grid; gap:2px; } .setting-grid input { width:100%; min-width:0; box-sizing:border-box; padding:3px; border:1px solid #cbd5e1; border-radius:3px; font:inherit; color:#334155; } .remove { width:30px; padding:0; border:0; background:transparent; color:#94a3b8; font-size:1.2rem; cursor:pointer; } .remove:hover { color:#b91c1c; } .row-actions { display:flex; align-items:center; } @media (max-width:760px) { article { padding:9px 4px; } }</style>