Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308<script lang="ts"> import { tick } from 'svelte'; import type { Viewport } from '#lib/engine/types.js'; import { graphTextScreenBounds, graphTextTopLeftAnchor, isGraphTextEnabled, moveGraphTextAnchor, resizeGraphTextFromBottomRight, type GraphTextElement } from '#lib/graph/text-elements.js'; import { mathToPixel } from '#lib/graph/viewport.js'; import { escapeGraphTextHtml, renderGraphTextMarkup } from '#lib/graph/text-markdown.js'; import { renderGraphMath } from '#lib/graph/mathjax-text.js';
let { texts = [], viewport, editable = false, selectedId = null, getViewport = () => viewport, onselect = () => {}, onchange = () => {} }: { texts?: GraphTextElement[]; viewport: Viewport; editable?: boolean; selectedId?: string | null; getViewport?: () => Viewport; onselect?: (id: string) => void; onchange?: (text: GraphTextElement) => void; } = $props();
let layer: HTMLDivElement; let rendered = $state<Record<string, string>>({}); let renderGeneration = 0; let renderedSignature = ''; let visibleTexts = $derived(texts.filter(isGraphTextEnabled));
let gesture: { pointerId: number; textId: string; kind: 'move' | 'resize'; anchorOffset?: { x: number; y: number }; topLeftAnchor?: { x: number; y: number }; } | null = null;
$effect(() => { const current = visibleTexts.map((text) => ({ id: text.id, source: text.source })); const signature = JSON.stringify(current);
if (signature === renderedSignature) return; renderedSignature = signature; const generation = ++renderGeneration; void Promise.all( current.map(async ({ id, source }) => { try { const result = await renderGraphTextMarkup(source, renderGraphMath);
return [id, result.html] as const; } catch (error) { console.error('Graph text source rendering failed', error);
return [id, escapeGraphTextHtml(source)] as const; } }) ).then((entries) => { if (generation === renderGeneration) rendered = Object.fromEntries(entries); }); });
$effect(() => { const dimensions = visibleTexts.map(({ id, widthPx, heightPx, fontSizePx }) => ({ id, widthPx, heightPx, fontSizePx }));
void rendered; void tick().then(() => { for (const text of dimensions) { const item = Array.from(layer.querySelectorAll<HTMLElement>('.text-item')) .find((element) => element.dataset.textId === text.id);
if (!item) continue;
for (const math of item.querySelectorAll<HTMLElement>('.graph-text-math')) { math.style.fontSize = '';
for (let attempt = 0; attempt < 3; attempt += 1) { const bounds = math.getBoundingClientRect();
if (!bounds.width || !bounds.height) break;
const scale = Math.min( 1, (text.widthPx - 8) / bounds.width, (text.heightPx - 8) / bounds.height );
if (scale >= 1) break; math.style.fontSize = `${Number.parseFloat(getComputedStyle(math).fontSize) * scale}px`; } } } }); });
function pointerPoint(event: PointerEvent) { const rect = layer.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }
function beginGesture(event: PointerEvent, text: GraphTextElement) { if (!editable || (event.pointerType === 'mouse' && event.button !== 0)) return; event.stopPropagation(); onselect(text.id); const viewport = getViewport(); const point = pointerPoint(event); const anchorPoint = mathToPixel(viewport, text.position.x, text.position.y);
const resize = event.target instanceof Element && event.target.closest('[data-text-resize-handle]') !== null;
gesture = { pointerId: event.pointerId, textId: text.id, kind: resize ? 'resize' : 'move', ...(resize ? { topLeftAnchor: graphTextTopLeftAnchor(text, viewport) } : { anchorOffset: { x: anchorPoint.x - point.x, y: anchorPoint.y - point.y } }) }; (event.currentTarget as HTMLDivElement).setPointerCapture(event.pointerId); }
function moveGesture(event: PointerEvent) { const active = gesture;
if (!active || active.pointerId !== event.pointerId) return; event.stopPropagation(); const text = texts.find((candidate) => candidate.id === active.textId);
if (!text) return; const viewport = getViewport(); const point = pointerPoint(event);
if (active.kind === 'move' && active.anchorOffset) { onchange({ ...text, position: moveGraphTextAnchor(viewport, point, active.anchorOffset) }); } else if (active.kind === 'resize' && active.topLeftAnchor) { onchange(resizeGraphTextFromBottomRight(text, active.topLeftAnchor, point, viewport)); } }
function endGesture(event: PointerEvent) { if (gesture?.pointerId !== event.pointerId) return; event.stopPropagation(); gesture = null; }</script>
<div bind:this={layer} class="text-layer" aria-hidden={!editable && visibleTexts.length === 0}> {#each visibleTexts as text (text.id)} {@const bounds = graphTextScreenBounds(text, viewport)} <div class="text-item" data-text-id={text.id} class:selected={editable && selectedId === text.id} class:editable class:customColor={text.color !== null} role="group" style:left={`${bounds.left}px`} style:top={`${bounds.top}px`} style:width={`${bounds.width}px`} style:height={`${bounds.height}px`} style:font-size={`${text.fontSizePx}px`} style:color={text.color ?? undefined} style:text-align={text.align} aria-label={`Graph text: ${text.source}`} onpointerdown={(event) => beginGesture(event, text)} onpointermove={moveGesture} onpointerup={endGesture} onpointercancel={endGesture} onlostpointercapture={endGesture} > <div class="text-content">{@html rendered[text.id] ?? escapeGraphTextHtml(text.source)}</div> {#if editable && selectedId === text.id} <button type="button" class="resize-handle" data-text-resize-handle aria-label="Resize text element" title="Drag to resize" onpointerdown={(event) => beginGesture(event, text)} ></button> {/if} </div> {/each}</div>
<style> .text-layer { position: absolute; inset: 0; overflow: hidden; pointer-events: none; user-select: none; }
.text-item { position: absolute; box-sizing: border-box; overflow: hidden; padding: 4px; color: #0f172a; font-family: system-ui, sans-serif; line-height: 1.3; white-space: pre-wrap; word-break: break-word; }
:global(.dark) .text-item { color: #f8fafc; }
.text-item.editable { pointer-events: auto; cursor: move; touch-action: none; }
.text-item.selected { outline: 1px solid #2563eb; outline-offset: -1px; }
.text-item.selected::before { position: absolute; inset: 0; border: 1px dashed rgb(37 99 235 / 65%); content: ''; pointer-events: none; }
.text-content :global(p) { margin: 0; }
.text-content :global(code) { padding: 0.05em 0.2em; border-radius: 0.2em; background: rgb(148 163 184 / 20%); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.text-content :global(a) { color: #2563eb; text-decoration: underline; text-decoration-style: dotted; }
.text-item.customColor .text-content :global(a) { color: inherit; }
.text-content :global(.graph-text-math mjx-container) { margin: 0 !important; }
.text-content :global(.graph-text-math) { display: inline-block; width: max-content; white-space: nowrap; vertical-align: middle; }
.text-content :global(img) { max-width: 100%; max-height: 100%; vertical-align: middle; object-fit: contain; }
.text-content :global(.graph-text-image-error) { color: #b45309; font-size: 0.85em; font-style: italic; }
.resize-handle { position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; padding: 0; border: 1px solid #fff; border-radius: 3px 0 0; background: #2563eb; cursor: nwse-resize; touch-action: none; }</style>