Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641<script lang="ts"> import { MAX_GRAPH_TEXT_COORDINATE, MAX_GRAPH_TEXT_FONT_SIZE_PX, MAX_GRAPH_TEXT_HEIGHT_PX, MAX_GRAPH_TEXT_WIDTH_PX, MIN_GRAPH_TEXT_FONT_SIZE_PX, MIN_GRAPH_TEXT_HEIGHT_PX, MIN_GRAPH_TEXT_WIDTH_PX, GRAPH_TEXT_HORIZONTAL_ANCHORS, GRAPH_TEXT_VERTICAL_ANCHORS, isGraphTextEnabled, setGraphTextAnchor, type GraphTextHorizontalAnchor, type GraphTextVerticalAnchor, type GraphTextElement } from '#lib/graph/text-elements.js'; import type { Viewport } from '#lib/engine/types.js'; import { MAX_SNAPSHOT_TEXT_ELEMENTS, MAX_SNAPSHOT_TEXT_LENGTH } from '#lib/document/graph-snapshot.js';
let { texts, selectedId, viewport, getViewport = () => viewport, onadd, onselect, onupdate, onremove }: { texts: GraphTextElement[]; selectedId: string | null; viewport: Viewport; getViewport?: () => Viewport; onadd: () => void; onselect: (id: string | null) => void; onupdate: (text: GraphTextElement) => void; onremove: (id: string) => void; } = $props();
let selectedText = $derived(texts.find((text) => text.id === selectedId) ?? null);
function sourcePreview(source: string): string { const flattened = source.replace(/\s+/g, ' ').trim();
return flattened.length > 100 ? `${flattened.slice(0, 97)}…` : flattened || 'Empty text'; }
function updateNumber( text: GraphTextElement, field: 'position.x' | 'position.y' | 'widthPx' | 'heightPx' | 'fontSizePx', event: Event ) { const input = event.currentTarget as HTMLInputElement;
if (!input.value.trim()) { input.value = String(currentValue(text, field));
return; }
let value = Number(input.value);
if (!Number.isFinite(value)) { input.value = String(currentValue(text, field));
return; }
if (field === 'position.x' || field === 'position.y') { if (Math.abs(value) > MAX_GRAPH_TEXT_COORDINATE) { input.value = String(currentValue(text, field));
return; }
const position = { ...text.position, [field === 'position.x' ? 'x' : 'y']: value }; onupdate({ ...text, position });
return; }
const limits = field === 'widthPx' ? [MIN_GRAPH_TEXT_WIDTH_PX, MAX_GRAPH_TEXT_WIDTH_PX] : field === 'heightPx' ? [MIN_GRAPH_TEXT_HEIGHT_PX, MAX_GRAPH_TEXT_HEIGHT_PX] : [MIN_GRAPH_TEXT_FONT_SIZE_PX, MAX_GRAPH_TEXT_FONT_SIZE_PX];
value = Math.max(limits[0], Math.min(limits[1], value)); input.value = String(value); onupdate({ ...text, [field]: value }); }
function currentValue( text: GraphTextElement, field: 'position.x' | 'position.y' | 'widthPx' | 'heightPx' | 'fontSizePx' ): number { if (field === 'position.x') return text.position.x;
if (field === 'position.y') return text.position.y;
return text[field]; }
function selectAnchor( text: GraphTextElement, horizontal: GraphTextHorizontalAnchor, vertical: GraphTextVerticalAnchor ) { onupdate(setGraphTextAnchor(text, { horizontal, vertical }, getViewport())); }</script>
<section class="text-section" aria-labelledby="text-section-title"> <header> <h2 id="text-section-title">Text</h2> <button class="add-text" type="button" disabled={texts.length >= MAX_SNAPSHOT_TEXT_ELEMENTS} onclick={onadd} > + Add text </button> </header> {#if texts.length === 0} <p class="empty-state">No text elements</p> {:else} <div class="text-list" aria-label="Text elements"> {#each texts as text, index (text.id)} <div class="text-row-container"> <label class="toggle" title={isGraphTextEnabled(text) ? 'Disable text' : 'Enable text'}> <input type="checkbox" checked={isGraphTextEnabled(text)} aria-label={`${isGraphTextEnabled(text) ? 'Disable' : 'Enable'} text ${index + 1}`} onchange={(event) => onupdate({ ...text, enabled: event.currentTarget.checked })} /> <span></span> </label> <button class="text-row" class:selected={selectedId === text.id} class:disabled={!isGraphTextEnabled(text)} type="button" data-text-id={text.id} aria-expanded={selectedId === text.id} onclick={() => onselect(selectedId === text.id ? null : text.id)} > <span class="text-row-title"><span aria-hidden="true">{selectedId === text.id ? '▾' : '▸'}</span> Text {index + 1}</span> <span class="text-row-preview">{sourcePreview(text.source)}</span> </button> </div> {#if selectedText?.id === text.id} <div class="text-editor" data-selected-text-id={selectedText.id}> <label class="source-field"> <span>Text source (Markdown and LaTeX)</span> <textarea aria-label="Text source" maxlength={MAX_SNAPSHOT_TEXT_LENGTH} value={selectedText.source} oninput={(event) => onupdate({ ...selectedText!, source: event.currentTarget.value })} ></textarea> </label> <p class="text-policy"> Raw HTML is disabled. Images require a same-origin or CORS-readable source; links are display-only on the graph. </p> <div class="text-fields"> <label> <span>Anchor x (graph units)</span> <input type="number" step="any" min={-MAX_GRAPH_TEXT_COORDINATE} max={MAX_GRAPH_TEXT_COORDINATE} data-text-field="position-x" value={selectedText.position.x} onchange={(event) => updateNumber(selectedText!, 'position.x', event)} /> </label> <label> <span>Anchor y (graph units)</span> <input type="number" step="any" min={-MAX_GRAPH_TEXT_COORDINATE} max={MAX_GRAPH_TEXT_COORDINATE} data-text-field="position-y" value={selectedText.position.y} onchange={(event) => updateNumber(selectedText!, 'position.y', event)} /> </label> <fieldset class="anchor-field"> <legend>Anchor point</legend> <div class="anchor-grid" role="group" aria-label="Text box anchor point"> {#each GRAPH_TEXT_VERTICAL_ANCHORS as vertical} {#each GRAPH_TEXT_HORIZONTAL_ANCHORS as horizontal} <button type="button" class:selected={selectedText.anchor.horizontal === horizontal && selectedText.anchor.vertical === vertical} data-text-anchor={`${vertical}-${horizontal}`} aria-label={`Anchor ${vertical} ${horizontal}`} aria-pressed={selectedText.anchor.horizontal === horizontal && selectedText.anchor.vertical === vertical} title={`${vertical} ${horizontal}`} onclick={() => selectAnchor(selectedText!, horizontal, vertical)} ></button> {/each} {/each} </div> </fieldset> <label> <span>Width (px)</span> <input type="number" min={MIN_GRAPH_TEXT_WIDTH_PX} max={MAX_GRAPH_TEXT_WIDTH_PX} step="1" data-text-field="width" value={selectedText.widthPx} onchange={(event) => updateNumber(selectedText!, 'widthPx', event)} /> </label> <label> <span>Height (px)</span> <input type="number" min={MIN_GRAPH_TEXT_HEIGHT_PX} max={MAX_GRAPH_TEXT_HEIGHT_PX} step="1" data-text-field="height" value={selectedText.heightPx} onchange={(event) => updateNumber(selectedText!, 'heightPx', event)} /> </label> <label> <span>Font (px)</span> <input type="number" min={MIN_GRAPH_TEXT_FONT_SIZE_PX} max={MAX_GRAPH_TEXT_FONT_SIZE_PX} step="1" data-text-field="font-size" value={selectedText.fontSizePx} onchange={(event) => updateNumber(selectedText!, 'fontSizePx', event)} /> </label> <label> <span>Text alignment</span> <select value={selectedText.align} onchange={(event) => onupdate({ ...selectedText!, align: event.currentTarget.value as GraphTextElement['align'] })} > <option value="left">Left</option> <option value="center">Center</option> <option value="right">Right</option> </select> </label> <fieldset class="color-field"> <legend>Text color</legend> <div class="color-options"> <input type="color" aria-label="Text color" data-text-color value={selectedText.color ?? '#0f172a'} oninput={(event) => onupdate({ ...selectedText!, color: event.currentTarget.value })} /> <label class="color-auto"> <input type="checkbox" checked={selectedText.color === null} onchange={(event) => onupdate({ ...selectedText!, color: event.currentTarget.checked ? null : '#0f172a' })} /> <span>Use graph theme</span> </label> </div> </fieldset> </div> <button class="delete-text" type="button" onclick={() => onremove(selectedText!.id)}> Delete text </button> </div> {/if} {/each} </div> {/if}</section>
<style> .text-section { padding: 8px 10px 12px; border-top: 1px solid #cbd5e1; background: #f8fafc; }
.text-section header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; }
.text-section h2 { margin: 0; color: #475569; font-size: 12px; font-weight: 700; letter-spacing: 0.025em; text-transform: uppercase; }
.add-text, .delete-text { padding: 5px 8px; border: 1px solid #94a3b8; border-radius: 5px; background: white; color: #334155; font: 650 13px/1.2 system-ui, sans-serif; cursor: pointer; }
.add-text:hover, .add-text:focus-visible, .delete-text:hover, .delete-text:focus-visible { border-color: #3b82f6; color: #1d4ed8; }
.add-text:disabled { cursor: default; opacity: 0.5; }
.text-list { display: grid; gap: 4px; }
.text-row-container { display: flex; align-items: center; gap: 6px; }
.toggle { position: relative; flex: 0 0 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); } .toggle input:focus-visible + span { outline: 2px solid #2563eb; outline-offset: 2px; }
.text-row { display: grid; flex: 1; min-width: 0; gap: 2px; padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 5px; background: #fff; color: #334155; text-align: left; cursor: pointer; }
.text-row:hover, .text-row:focus-visible { border-color: #60a5fa; outline: 0; }
.text-row.selected { border-color: #2563eb; box-shadow: inset 3px 0 #2563eb; }
.text-row.disabled { opacity: 0.6; }
.text-row-title { font-size: 12px; font-weight: 700; }
.text-row-preview { overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.empty-state { margin: 0; color: #64748b; font-size: 12px; }
.text-editor { display: grid; gap: 7px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e2e8f0; }
.text-editor label { display: grid; min-width: 0; gap: 3px; color: #475569; font-size: 12px; font-weight: 600; }
.text-editor input, .text-editor select, .text-editor textarea { box-sizing: border-box; width: 100%; padding: 5px 6px; border: 1px solid #cbd5e1; border-radius: 4px; background: white; color: #0f172a; font: 13px/1.35 system-ui, sans-serif; }
.text-editor textarea { min-height: 70px; resize: vertical; }
.text-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
.anchor-field, .color-field { display: grid; grid-column: 1 / -1; justify-items: start; gap: 4px; min-width: 0; margin: 0; padding: 0; border: 0; color: #475569; }
.anchor-field legend, .color-field legend { padding: 0; font-size: 12px; font-weight: 600; }
.anchor-grid { display: grid; grid-template-columns: repeat(3, 22px); grid-template-rows: repeat(3, 22px); gap: 2px; }
.anchor-grid button { position: relative; width: 22px; height: 22px; padding: 0; border: 1px solid #94a3b8; border-radius: 3px; background: white; cursor: pointer; }
.anchor-grid button::after { position: absolute; top: 50%; left: 50%; width: 4px; height: 4px; border-radius: 50%; background: #64748b; content: ''; transform: translate(-50%, -50%); }
.anchor-grid button.selected { border-color: #2563eb; background: #dbeafe; }
.anchor-grid button.selected::after { width: 8px; height: 8px; background: #1d4ed8; }
.color-options { display: flex; align-items: center; gap: 10px; }
.text-editor .color-options input[type='color'] { width: 42px; height: 29px; padding: 2px; cursor: pointer; }
.text-editor .color-options .color-auto { display: flex; align-items: center; gap: 5px; }
.text-editor .color-auto input[type='checkbox'] { width: auto; margin: 0; }
.text-policy { margin: -2px 0 0; color: #64748b; font-size: 11px; }
.delete-text { justify-self: start; border-color: #fecaca; color: #b91c1c; }
:global(.dark) .text-section { border-color: #334155; background: #111827; }
:global(.dark) .text-section h2, :global(.dark) .text-editor label { color: #94a3b8; }
:global(.dark) .anchor-field, :global(.dark) .color-field { color: #94a3b8; }
:global(.dark) .add-text, :global(.dark) .delete-text, :global(.dark) .text-row, :global(.dark) .text-editor input, :global(.dark) .text-editor select, :global(.dark) .text-editor textarea { border-color: #475569; background: #0f172a; color: #e2e8f0; }
:global(.dark) .anchor-grid button { border-color: #475569; background: #0f172a; }
:global(.dark) .anchor-grid button.selected { border-color: #60a5fa; background: #1e3a5f; }
:global(.dark) .anchor-grid button::after { background: #94a3b8; }
:global(.dark) .anchor-grid button.selected::after { background: #93c5fd; }
:global(.dark) .text-row-preview, :global(.dark) .empty-state, :global(.dark) .text-policy { color: #94a3b8; }
:global(main.dark #expression-panel) .text-row.selected { border-color: #60a5fa; box-shadow: inset 3px 0 #60a5fa; }
:global(.dark) .text-editor { border-top-color: #334155; }
@media (max-width: 760px) { .text-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }</style>