import type { Viewport } from '#lib/engine/types.js'; import { mathToPixel, pixelToMath, type PixelPoint } from '#lib/graph/viewport.js'; export interface GraphTextElement { id: string; source: string; /** Absent in older documents; those text elements remain visible. */ enabled?: boolean; /** Graph coordinate of the selected anchor point on the display box. */ position: { x: number; y: number }; anchor: GraphTextAnchor; /** Display dimensions in CSS pixels; these do not scale with the graph camera. */ widthPx: number; heightPx: number; fontSizePx: number; /** Null follows the graph theme; explicit colors apply to text and math. */ color: string | null; align: 'left' | 'center' | 'right'; } export function isGraphTextEnabled(text: Pick): boolean { return text.enabled !== false; } export type GraphTextHorizontalAnchor = 'left' | 'center' | 'right'; export type GraphTextVerticalAnchor = 'top' | 'middle' | 'bottom'; export interface GraphTextAnchor { horizontal: GraphTextHorizontalAnchor; vertical: GraphTextVerticalAnchor; } export const GRAPH_TEXT_HORIZONTAL_ANCHORS = ['left', 'center', 'right'] as const; export const GRAPH_TEXT_VERTICAL_ANCHORS = ['top', 'middle', 'bottom'] as const; export const DEFAULT_GRAPH_TEXT_ANCHOR = { horizontal: 'center', vertical: 'middle' } as const satisfies GraphTextAnchor; export interface GraphTextScreenBounds { left: number; top: number; width: number; height: number; } export const DEFAULT_GRAPH_TEXT_WIDTH_PX = 240; export const DEFAULT_GRAPH_TEXT_HEIGHT_PX = 96; export const DEFAULT_GRAPH_TEXT_FONT_SIZE_PX = 16; export const MIN_GRAPH_TEXT_WIDTH_PX = 80; export const MAX_GRAPH_TEXT_WIDTH_PX = 2_000; export const MIN_GRAPH_TEXT_HEIGHT_PX = 40; export const MAX_GRAPH_TEXT_HEIGHT_PX = 2_000; export const MIN_GRAPH_TEXT_FONT_SIZE_PX = 8; export const MAX_GRAPH_TEXT_FONT_SIZE_PX = 96; export const MAX_GRAPH_TEXT_COORDINATE = 1e12; export function isGraphTextColor(value: unknown): value is string { return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value); } export function graphTextScreenBounds( text: Pick, viewport: Viewport ): GraphTextScreenBounds { const anchor = mathToPixel(viewport, text.position.x, text.position.y); return { left: anchor.x - text.widthPx * horizontalAnchorFraction(text.anchor.horizontal), top: anchor.y - text.heightPx * verticalAnchorFraction(text.anchor.vertical), width: text.widthPx, height: text.heightPx }; } /** Keep the current box in place while changing which point stores its graph coordinate. */ export function setGraphTextAnchor( text: GraphTextElement, anchor: GraphTextAnchor, viewport: Viewport ): GraphTextElement { const bounds = graphTextScreenBounds(text, viewport); const position = pixelToMath( viewport, bounds.left + text.widthPx * horizontalAnchorFraction(anchor.horizontal), bounds.top + text.heightPx * verticalAnchorFraction(anchor.vertical) ); if ( Math.abs(position.x) > MAX_GRAPH_TEXT_COORDINATE || Math.abs(position.y) > MAX_GRAPH_TEXT_COORDINATE ) { return text; } return { ...text, anchor: { ...anchor }, position }; } /** Keep the selected graph anchor under its grabbed screen point in the live camera. */ export function moveGraphTextAnchor( viewport: Viewport, pointer: PixelPoint, anchorOffset: PixelPoint ): { x: number; y: number } { return pixelToMath(viewport, pointer.x + anchorOffset.x, pointer.y + anchorOffset.y); } /** The graph-coordinate top-left corner stays fixed while the bottom-right handle moves. */ export function graphTextTopLeftAnchor( text: Pick, viewport: Viewport ): { x: number; y: number } { const bounds = graphTextScreenBounds(text, viewport); return pixelToMath(viewport, bounds.left, bounds.top); } export function resizeGraphTextFromBottomRight( text: GraphTextElement, topLeftAnchor: { x: number; y: number }, pointer: PixelPoint, viewport: Viewport ): GraphTextElement { const topLeft = mathToPixel(viewport, topLeftAnchor.x, topLeftAnchor.y); const widthPx = clamp( pointer.x - topLeft.x, MIN_GRAPH_TEXT_WIDTH_PX, MAX_GRAPH_TEXT_WIDTH_PX ); const heightPx = clamp( pointer.y - topLeft.y, MIN_GRAPH_TEXT_HEIGHT_PX, MAX_GRAPH_TEXT_HEIGHT_PX ); const position = pixelToMath( viewport, topLeft.x + widthPx * horizontalAnchorFraction(text.anchor.horizontal), topLeft.y + heightPx * verticalAnchorFraction(text.anchor.vertical) ); return { ...text, position, widthPx, heightPx }; } function horizontalAnchorFraction(anchor: GraphTextHorizontalAnchor): number { return anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; } function verticalAnchorFraction(anchor: GraphTextVerticalAnchor): number { return anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; } function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)); }