Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681<script lang="ts"> import { BUILD_COMMIT, BUILD_COMMIT_DISPLAY } from '#lib/build-info.js'; import type { Expression2d, ExpressionRole2d, MatrixVariablePreview, RegressionResult, ScalarVariable, SequenceVariablePreview, SceneDiagnostic, Viewport } from '#lib/engine/types.js'; import type { GraphAppearance } from '#lib/graph/appearance.js'; import type { ExpressionAppearance } from '#lib/graph/expression-appearance.js'; import type { GraphTextElement } from '#lib/graph/text-elements.js'; import { automaticExpressionColors, materializeExpressionColors } from '#lib/graph/expression-colors.js'; import { diagnosticForExpression, moveExpression } from '#lib/document/expressions.js'; import { expressionsFromBatchText } from '#lib/document/batch-text.js'; import { presetGroups } from '#lib/document/presets.js'; import CalculatorHeader from './CalculatorHeader.svelte'; import BatchTextEditor from './BatchTextEditor.svelte'; import ExpressionViewSwitch from './ExpressionViewSwitch.svelte'; import ExpressionRow from './ExpressionRow.svelte'; import GraphTextSection from './GraphTextSection.svelte'; import GraphSettings from './GraphSettings.svelte';
let { graphName, documentDescription, expressions, expressionAppearances, diagnostics, variables, sequenceVariables = [], matrixVariables = [], regressions = [], expressionRoles = {}, resolvedExpressionColors = {}, pointExpressionIds = [], fillExpressionIds = [], textElements = [], selectedTextId = null, selectedId, onupdate, onappearanceupdate, onremove, onadd, onreorder, onbatchchange, onpreset, oncollapse, onhelp, onnamechange, ondescriptionchange, onnew, onselect, ontextadd, ontextselect, ontextupdate, ontextremove, onsliderinteractionchange, onslidervaluechange, animatedVariables, onanimationchange, onallanimationschange, onresizeinteractionchange, viewport, getTextViewport = () => viewport, equalAxisScale, complexMode, appearance, shareStatus, activeSavedGraphId, documentScope, documentDirty, editorGeneration, onviewportchange, onviewportreset, onequalaxisscalechange, oncomplexmodechange, onappearancechange, onshare, onpublish, publishingBusy, onexportpng, onexportgif, onsave, onsaveas, ondelete }: { graphName: string; documentDescription: string; expressions: Expression2d[]; expressionAppearances: Record<string, ExpressionAppearance>; diagnostics: SceneDiagnostic[]; variables: ScalarVariable[]; sequenceVariables?: SequenceVariablePreview[]; matrixVariables?: MatrixVariablePreview[]; regressions?: RegressionResult[]; expressionRoles?: Record<string, ExpressionRole2d>; resolvedExpressionColors?: Record<string, string>; pointExpressionIds?: string[]; fillExpressionIds?: string[]; textElements?: GraphTextElement[]; selectedTextId?: string | null; selectedId: string | null; onupdate: (expression: Expression2d) => void; onappearanceupdate: (id: string, appearance: ExpressionAppearance) => void; onremove: (id: string) => void; onadd: () => void; onreorder: (expressions: Expression2d[]) => void; onbatchchange: (expressions: Expression2d[]) => void; onpreset: (id: string) => void; oncollapse: () => void; onhelp: () => void; onnamechange: (name: string) => void; ondescriptionchange: (description: string) => void; onnew: () => void; onselect: (id: string) => void; ontextadd: () => void; ontextselect: (id: string | null) => void; ontextupdate: (text: GraphTextElement) => void; ontextremove: (id: string) => void; onsliderinteractionchange: (active: boolean) => void; onslidervaluechange: (name: string, value: number, commit: boolean) => void; animatedVariables: Record<string, boolean>; onanimationchange: (name: string, active: boolean) => void; onallanimationschange: (active: boolean) => void; onresizeinteractionchange: (active: boolean) => void; viewport: Viewport; getTextViewport?: () => Viewport; equalAxisScale: boolean; complexMode: boolean; appearance: GraphAppearance; shareStatus: string; activeSavedGraphId: string | null; documentScope: 'none' | 'local' | 'private'; documentDirty: boolean; editorGeneration: number; onviewportchange: ( viewport: Viewport, changed: 'x_min' | 'x_max' | 'y_min' | 'y_max' ) => void; onviewportreset: () => void; onequalaxisscalechange: (active: boolean) => void; oncomplexmodechange: (active: boolean) => void; onappearancechange: (appearance: GraphAppearance) => void; onshare: () => void; onpublish: () => void; publishingBusy: boolean; onexportpng: () => void; onexportgif: () => void; onsave: () => void; onsaveas: () => void; ondelete: () => void; } = $props();
let animatableCount = $derived(variables.filter((variable) => variable.slider).length);
let activeAnimationCount = $derived( variables.filter((variable) => variable.slider && animatedVariables[variable.name]).length );
let fallbackExpressionColors = $derived(automaticExpressionColors(expressions, expressionRoles));
let currentExpressionColors = $derived({ ...fallbackExpressionColors, ...resolvedExpressionColors });
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<Expression2d[] | null>(null); let reorderPointerId: number | null = null; let reorderHandle: HTMLElement | null = null; let displayedExpressions = $derived(draggedExpressions ?? expressions);
function beginPanelResize(event: PointerEvent) { resizeStart = { pointerX: event.clientX, width: panelWidth }; onresizeinteractionchange(true); (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; onresizeinteractionchange(false); const handle = event.currentTarget as HTMLElement;
if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); }
function losePanelResizeCapture() { if (!resizeStart) return; resizeStart = null; onresizeinteractionchange(false); }
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 diagnosticFor(id: string) { return diagnosticForExpression(diagnostics, id); }
function variableFor(id: string) { return variables.find((variable) => variable.expression_id === id); }
function sequenceVariableFor(id: string) { return sequenceVariables.find((variable) => variable.expression_id === id); }
function matrixVariableFor(id: string) { return matrixVariables.find((variable) => variable.expression_id === id); }
function regressionFor(id: string) { return regressions.find((regression) => regression.expression_id === id); }
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();
if (next && activeAnimationCount > 0) onallanimationschange(false); 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; // A null color means "choose from the palette by position." Freeze that // currently displayed choice before moving rows so reorder changes only // stacking/order, not the identity users associate with each curve. draggedExpressions = materializeExpressionColors(expressions, currentExpressionColors); onselect(id); (event.currentTarget as HTMLElement).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 ) ); }</script>
<svelte:window onpointermove={continueExpressionReorder} onpointerup={finishExpressionReorder} onpointercancel={cancelExpressionReorder}/>
<aside id="expression-panel" aria-label="Graph elements" 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="2d" documentName={graphName} {documentDescription} {documentDirty} documentScope={documentScope} hasSavedDocument={documentScope !== 'none'} {presetGroups} status={shareStatus} dark={appearance.theme === 'dark'} {oncollapse} {onnamechange} {ondescriptionchange} {onpreset} {onnew} {onhelp} {onsave} {onsaveas} {ondelete} > {#snippet extraActions()} <button type="button" onclick={onshare}>Share</button> <button type="button" onclick={onpublish} disabled={publishingBusy}> {publishingBusy ? 'Publishing…' : 'Publish'} </button> <button type="button" onclick={onexportpng}>Export PNG</button> <button type="button" onclick={onexportgif} disabled={animatableCount === 0}>Export GIF</button> {/snippet} </CalculatorHeader> {#if !textMode && animatableCount > 0} <div class="animation-toolbar" aria-label="Graph animation controls"> <button type="button" onclick={() => onallanimationschange(activeAnimationCount === 0)} > {activeAnimationCount > 0 ? '❚❚ Pause all' : '▶ Play all'} </button> <span> {activeAnimationCount > 0 ? `${activeAnimationCount} of ${animatableCount} animating` : `${animatableCount} animation${animatableCount === 1 ? '' : 's'} available`} </span> </div> {/if} <GraphSettings {viewport} {equalAxisScale} {complexMode} {appearance} oncomplexmodechange={oncomplexmodechange} onboundschange={onviewportchange} onresetscale={onviewportreset} onscalechange={onequalaxisscalechange} {onappearancechange} />
<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}`)} <ExpressionRow {expression} {editorGeneration} role={expressionRoles[expression.id] ?? 'plot'} resolvedColor={resolvedExpressionColors[expression.id] ?? fallbackExpressionColors[expression.id]} plotKind={pointExpressionIds.includes(expression.id) ? 'points' : fillExpressionIds.includes(expression.id) ? 'fill' : 'other'} appearance={expressionAppearances[expression.id]} diagnostic={diagnosticFor(expression.id)} variable={variableFor(expression.id)} sequenceVariable={sequenceVariableFor(expression.id)} matrixVariable={matrixVariableFor(expression.id)} regression={regressionFor(expression.id)} selected={selectedId === expression.id} {reorderMode} reorderPosition={index + 1} dragging={draggedExpressionId === expression.id} onupdate={onupdate} onappearanceupdate={(appearance) => onappearanceupdate(expression.id, appearance)} onremove={() => onremove(expression.id)} onselect={() => onselect(expression.id)} onreorderpointerdown={(event) => beginExpressionReorder(event, expression.id)} onreorderkeydown={(event) => reorderExpressionWithKeyboard(event, expression.id)} onsliderinteractionchange={onsliderinteractionchange} onslidervaluechange={onslidervaluechange} animated={Boolean(variableFor(expression.id) && animatedVariables[variableFor(expression.id)!.name])} onanimationchange={onanimationchange} /> {/each} <button class="add-expression" type="button" onclick={onadd}>+ Add expression</button> <GraphTextSection texts={textElements} selectedId={selectedTextId} {viewport} getViewport={getTextViewport} onadd={ontextadd} onselect={ontextselect} onupdate={ontextupdate} onremove={ontextremove} /> </div> {/if}
<footer title={`Full commit: ${BUILD_COMMIT}`} data-build-commit={BUILD_COMMIT}> Build <code>{BUILD_COMMIT_DISPLAY}</code> </footer></aside>
<style> aside { 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; 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; }
.animation-toolbar { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-bottom: 1px solid #cbd5e1; background: #f1f5f9; color: #64748b; font-size: 12px; }
.animation-toolbar button { padding: 5px 8px; border: 1px solid #94a3b8; border-radius: 5px; background: white; color: #334155; font-weight: 650; cursor: pointer; }
.animation-toolbar button:hover, .animation-toolbar button:focus-visible { border-color: #3b82f6; color: #1d4ed8; }
.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; }
.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; }
.rows { min-height: 0; flex: 1; overflow-y: auto; }
footer { padding: 9px 14px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #64748b; background: #fff; }
@media (max-width: 760px) { aside { width: auto; min-width: 0; max-width: none; border-top: 1px solid #cbd5e1; border-right: 0; }
.resize-handle { display: none; }
footer { display: none; } }</style>