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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563<script lang="ts"> import { BUILD_COMMIT, BUILD_COMMIT_DISPLAY } from '#lib/build-info.js'; import { moveExpression } from '#lib/document/expressions.js'; import { expressionsFromBatchText } from '#lib/document/batch-text.js'; import { presetGroups3d } from '#lib/document/presets-3d.js'; import type { Bounds3d, Expression3d, ExpressionRole3d, RenderScene3d, ScalarVariable } from '#lib/engine/types.js'; import { DEFAULT_LIGHTING_MODE_3D, type LightingMode3d } from '#lib/graph/canvas/surface-lighting-3d.js'; import { automaticExpressionColors, materializeExpressionColors } from '#lib/graph/expression-colors.js'; import CalculatorHeader from './CalculatorHeader.svelte'; import BatchTextEditor from './BatchTextEditor.svelte'; import ExpressionViewSwitch from './ExpressionViewSwitch.svelte'; import GraphSettings3d from './GraphSettings3d.svelte'; import ExpressionRow3d from './ExpressionRow3d.svelte';
let { expressions, scene, selectedId, onupdate, onremove, onadd, onselect, onreorder, onbatchchange, oncollapse, onslidervaluechange, onsliderinteractionchange, onpreset, documentName, documentDescription, onnamechange, ondescriptionchange, onnew, activeDocumentId, documentScope, documentDirty, editorGeneration, shareStatus, onsave, onsaveas, ondelete, onpublish, onexportpng, resetKey = 0, bounds, onboundschange, onboundsreset, onboundsresize, complexMode, lightingMode = DEFAULT_LIGHTING_MODE_3D, onlightingmodechange, oncomplexmodechange }: { expressions: Expression3d[]; scene: RenderScene3d | null; selectedId: string | null; onupdate: (expression: Expression3d) => void; onremove: (id: string) => void; onadd: () => void; onselect: (id: string) => void; onreorder: (expressions: Expression3d[]) => void; onbatchchange: (expressions: Expression3d[]) => void; oncollapse: () => void; onslidervaluechange: (name: string, value: number, commit: boolean) => void; onsliderinteractionchange: (active: boolean) => void; onpreset: (id: string) => void; documentName: string; documentDescription: string; onnamechange: (name: string) => void; ondescriptionchange: (description: string) => void; onnew: () => void; activeDocumentId: string | null; documentScope: 'none' | 'local' | 'private'; documentDirty: boolean; editorGeneration: number; shareStatus: string; onsave: () => void; onsaveas: () => void; ondelete: () => void; onpublish?: () => void | Promise<void>; onexportpng?: () => void | Promise<void>; resetKey?: number; bounds: Bounds3d; onboundschange: (bounds: Bounds3d) => void; onboundsreset: () => void; onboundsresize: (factor: number) => void; complexMode: boolean; lightingMode?: LightingMode3d; onlightingmodechange?: (mode: LightingMode3d) => void; oncomplexmodechange?: (active: boolean) => void; } = $props();
function diagnosticFor(id: string) { return scene?.diagnostics.find((diagnostic) => diagnostic.expression_id === id); }
let expressionRoles = $derived(scene?.expression_roles ?? {}); let fallbackExpressionColors = $derived(automaticExpressionColors(expressions, expressionRoles)); let panelWidth = $state(360); let resizeStart: { pointerX: number; width: number } | null = null; let reorderMode = $state(false); let textMode = $state(false); let textModeStatus = $state(''); let batchEditor = $state<BatchTextEditor>(); let draggedExpressionId = $state<string | null>(null); let draggedExpressions = $state.raw<Expression3d[] | null>(null); let reorderPointerId: number | null = null; let reorderHandle: HTMLElement | null = null; let displayedExpressions = $derived(draggedExpressions ?? expressions);
let currentExpressionColors = $derived( Object.fromEntries(expressions.map((expression) => [expression.id, resolvedColor(expression)])) );
let lastResetKey = $state<number | undefined>();
function roleFor(id: string): ExpressionRole3d { return expressionRoles[id] ?? 'plot'; }
function variableFor(id: string): ScalarVariable | undefined { return scene?.variables.find((variable) => variable.expression_id === id); }
function resolvedColor(expression: Expression3d) { if (expression.color) return expression.color;
return scene?.resources.find((resource) => resource.key.expression_id === expression.id)?.color ?? fallbackExpressionColors[expression.id] ?? '#2563eb'; }
function beginPanelResize(event: PointerEvent) { resizeStart = { pointerX: event.clientX, width: panelWidth }; (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); }
function resizePanel(event: PointerEvent) { if (!resizeStart) return; const maximumWidth = Math.min(720, window.innerWidth * 0.7); panelWidth = Math.max( 290, Math.min(maximumWidth, resizeStart.width + event.clientX - resizeStart.pointerX) ); }
function endPanelResize(event: PointerEvent) { if (!resizeStart) return; resizeStart = null; const handle = event.currentTarget as HTMLElement;
if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); }
function losePanelResizeCapture() { resizeStart = null; }
function resizePanelWithKeyboard(event: KeyboardEvent) { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); const maximumWidth = Math.min(720, window.innerWidth * 0.7); const direction = event.key === 'ArrowLeft' ? -1 : 1; panelWidth = Math.max(290, Math.min(maximumWidth, panelWidth + direction * 20)); }
function toggleReorderMode() { reorderMode = !reorderMode;
if (!reorderMode) cancelExpressionReorder(); }
function setTextMode(next: boolean) { if (next === textMode || (textMode && !batchEditor?.canLeave())) return; const unfinished = next ? expressions.find((expression) => expression.visual_draft) : undefined;
if (unfinished?.visual_draft) { const position = expressions.findIndex((expression) => expression.id === unfinished.id) + 1; textModeStatus = `Finish or clear the unfinished visual draft in expression ${position} before opening TXT. Its current ASCII source is unavailable, and the draft remains saved.`;
return; }
textModeStatus = '';
if (reorderMode) toggleReorderMode(); textMode = next; }
function beginExpressionReorder(event: PointerEvent, id: string) { if (event.button !== 0 || !event.isPrimary) return; event.preventDefault(); draggedExpressionId = id; reorderPointerId = event.pointerId; reorderHandle = event.currentTarget as HTMLElement; draggedExpressions = materializeExpressionColors(expressions, currentExpressionColors); onselect(id); reorderHandle.setPointerCapture(event.pointerId); }
function continueExpressionReorder(event: PointerEvent) { if ( event.pointerId !== reorderPointerId || !draggedExpressionId || !draggedExpressions ) { return; }
const hit = document .elementFromPoint(event.clientX, event.clientY) ?.closest<HTMLElement>('[data-expression-id]');
const targetId = hit?.dataset.expressionId;
if (!targetId || targetId === draggedExpressionId) return; draggedExpressions = moveExpression(draggedExpressions, draggedExpressionId, targetId); }
function finishExpressionReorder(event: PointerEvent) { if ( event.pointerId !== reorderPointerId || !draggedExpressionId || !draggedExpressions ) { return; }
const reordered = draggedExpressions; clearExpressionReorder(); onreorder(reordered); }
function clearExpressionReorder() { if ( reorderHandle && reorderPointerId !== null && reorderHandle.hasPointerCapture(reorderPointerId) ) { reorderHandle.releasePointerCapture(reorderPointerId); }
draggedExpressionId = null; draggedExpressions = null; reorderPointerId = null; reorderHandle = null; }
function cancelExpressionReorder(event?: PointerEvent) { if (event && event.pointerId !== reorderPointerId) return; clearExpressionReorder(); }
function reorderExpressionWithKeyboard(event: KeyboardEvent, id: string) { if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; const index = expressions.findIndex((expression) => expression.id === id); const targetIndex = index + (event.key === 'ArrowUp' ? -1 : 1); const target = expressions[targetIndex];
if (!target) return; event.preventDefault(); onselect(id); onreorder( moveExpression( materializeExpressionColors(expressions, currentExpressionColors), id, target.id ) ); }
$effect(() => { const currentKey = resetKey;
if (lastResetKey === undefined) { lastResetKey = currentKey;
return; }
if (currentKey === lastResetKey) return; lastResetKey = currentKey; cancelExpressionReorder(); reorderMode = false; });
</script>
<svelte:window onpointermove={continueExpressionReorder} onpointerup={finishExpressionReorder} onpointercancel={cancelExpressionReorder}/>
<aside id="expression-panel-3d" class="panel" aria-label="Expressions" style:--panel-width={`${panelWidth}px`}> <button type="button" class="resize-handle" aria-label="Resize expression panel" title="Drag to resize expression panel; double-click to reset" onkeydown={resizePanelWithKeyboard} onpointerdown={beginPanelResize} onpointermove={resizePanel} onpointerup={endPanelResize} onpointercancel={endPanelResize} onlostpointercapture={losePanelResizeCapture} ondblclick={() => (panelWidth = 360)} ></button> <CalculatorHeader mode="3d" documentName={documentName} {documentDescription} {documentDirty} documentScope={documentScope} hasSavedDocument={documentScope !== 'none'} presetGroups={presetGroups3d} status={shareStatus} {oncollapse} {ondescriptionchange} {onnamechange} {onpreset} {onnew} {onsave} {onsaveas} {ondelete} > {#snippet extraActions()} {#if onpublish}<button type="button" onclick={onpublish}>Publish</button>{/if} {#if onexportpng}<button type="button" onclick={onexportpng}>Export PNG</button>{/if} {/snippet} </CalculatorHeader> <GraphSettings3d {bounds} {onboundschange} {onboundsreset} {onboundsresize} {complexMode} {lightingMode} onlightingmodechange={onlightingmodechange} oncomplexmodechange={oncomplexmodechange} /> <div class="expression-actions"> <span>Expressions</span> <div> {#if !textMode} <button type="button" class:active={reorderMode} aria-pressed={reorderMode} onclick={toggleReorderMode} disabled={expressions.length < 2} > {reorderMode ? '⇅ Done' : '⇅ Reorder'} </button> {/if} <ExpressionViewSwitch {textMode} onselect={setTextMode} /> </div> </div> {#if textModeStatus}<p class="batch-mode-status" role="alert">{textModeStatus}</p>{/if} {#if textMode} <BatchTextEditor bind:this={batchEditor} {expressions} onapply={(sources) => onbatchchange(expressionsFromBatchText(sources, expressions))} /> {:else} <div class="rows"> {#each displayedExpressions as expression, index (`${editorGeneration}:${expression.id}`)} <ExpressionRow3d {expression} {editorGeneration} role={roleFor(expression.id)} resolvedColor={resolvedColor(expression)} diagnostic={diagnosticFor(expression.id)} variable={variableFor(expression.id)} selected={selectedId === expression.id} position={index + 1} reorderMode={reorderMode} reorderPosition={index + 1} dragging={draggedExpressionId === expression.id} {resetKey} onupdate={onupdate} onremove={() => onremove(expression.id)} onselect={() => onselect(expression.id)} onreorderpointerdown={(event) => beginExpressionReorder(event, expression.id)} onreorderkeydown={(event) => reorderExpressionWithKeyboard(event, expression.id)} onsliderinteractionchange={onsliderinteractionchange} onslidervaluechange={onslidervaluechange} /> {/each} <button class="add-expression" type="button" onclick={onadd}>+ Add expression</button> </div> {/if} <footer> <span>Orbit with drag, pan with right drag, zoom with the wheel. Hold Shift while scrolling to change mathematical bounds.</span> <span class="build-info" title={`Full commit: ${BUILD_COMMIT}`} data-build-commit={BUILD_COMMIT}> Build <code>{BUILD_COMMIT_DISPLAY}</code> </span> </footer></aside>
<style> .panel { position: relative; display: flex; width: var(--panel-width); min-width: 290px; max-width: min(720px, 70vw); min-height: 0; flex-direction: column; background: #f8fafc; border-right: 1px solid #cbd5e1; color: #0f172a; overflow: hidden; }
.resize-handle { position: absolute; top: 0; right: -5px; bottom: 0; z-index: 5; width: 10px; padding: 0; border: 0; border-radius: 0; background: transparent; cursor: col-resize; touch-action: none; }
.resize-handle:hover, .resize-handle:focus-visible { background: rgb(59 130 246 / 20%); outline: 0; }
.expression-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 10px; border-bottom: 1px solid #cbd5e1; background: #e2e8f0; color: #475569; font-size: 12px; font-weight: 700; letter-spacing: 0.025em; text-transform: uppercase; }
.expression-actions button { padding: 5px 8px; border: 1px solid #94a3b8; border-radius: 5px; background: white; color: #334155; font: 650 13px/1.2 system-ui, sans-serif; letter-spacing: normal; text-transform: none; cursor: pointer; }
.expression-actions > div { display: flex; gap: 6px; } .batch-mode-status { margin: 0; padding: 0 12px 8px; color: #9f1239; font-size: 12px; line-height: 1.4; }
.expression-actions button:disabled { cursor: default; opacity: 0.45; }
.expression-actions button:hover, .expression-actions button:focus-visible { border-color: #3b82f6; color: #1d4ed8; }
.expression-actions button.active { border-color: #1d4ed8; background: #2563eb; color: white; }
.expression-actions button.active:hover, .expression-actions button.active:focus-visible { border-color: #1e40af; background: #1d4ed8; color: white; }
.rows { min-height: 0; flex: 1; overflow-y: auto; }
.add-expression { display: block; flex: 0 0 auto; margin: 8px auto; padding: 7px 10px; border: 1px dashed #94a3b8; border-radius: 5px; background: #f8fafc; color: #334155; font: 650 13px/1.2 system-ui, sans-serif; cursor: pointer; }
.add-expression:hover, .add-expression:focus-visible { border-color: #3b82f6; color: #1d4ed8; outline: 0; }
footer { display: grid; gap: 0.35rem; flex: 0 0 auto; padding: 9px 14px; border-top: 1px solid #e2e8f0; background: #fff; color: #64748b; font-size: 12px; line-height: 1.45; }
.build-info { font-size: 0.68rem; }
@media (max-width: 760px) { .panel { width: auto; min-width: 0; max-width: none; border-top: 1px solid #cbd5e1; border-right: 0; }
.resize-handle, footer { display: none; }
}</style>