openmirai
blockcalculatorsource: ok · hit
Mirai Scientific Calculator
An editable scientific, graphing, statistics, and math tools calculator.
npx shadcn add "https://www.openmirai.dev/r/calculator.json"free
Where it goes
statsdata-table
Interaction
animated-counter
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@openmirai/calculator-core@^0.2.0lucide-react@^1.28.0
Registry dependencies
badgebuttoncardinputscroll-areaselectseparatorswitchtabstextareatooltip
Files22 · source cached 10/8/2026, 3:13:07 PM
../../packages/calculator-registry/src/components/mirai-calculator/calculator-empty-state-action.tsx
interface CalculatorEmptyStateActionProps {
label: string
onClick: () => void
}
/** Renders the inline Add action used by calculator empty-state guidance. */
export function CalculatorEmptyStateAction({ label, onClick }: CalculatorEmptyStateActionProps) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="font-semibold text-foreground underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
Add
</button>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/calculator-extension-definitions.tsx
import { GraphingMode } from "@/components/mirai-calculator/modes/graphing-mode"
import { ScientificMode } from "@/components/mirai-calculator/modes/scientific-mode"
import { StatisticsMode } from "@/components/mirai-calculator/modes/statistics-mode"
import { ToolsMode } from "@/components/mirai-calculator/modes/tools-mode"
import { CalculatorExtension } from "@openmirai/calculator-core/configuration"
import type {
CalculatorEngine,
AngleMode,
NumberFormatOptions,
} from "@openmirai/calculator-core/engine"
import type { GraphingInitialData } from "@openmirai/calculator-core/graphing-data"
import type { GraphView } from "@openmirai/calculator-core/graphing-view"
import type { StatisticsInitialData } from "@openmirai/calculator-core/statistics-data"
import type { ToolsInitialData } from "@openmirai/calculator-core/tools"
interface ModeRendererProps {
resetSignal: number
engine: CalculatorEngine
activeAngleMode: AngleMode
ans: number
definitions: Array<string>
setDefinitions: (definitions: Array<string>) => void
setAns: (ans: number) => void
formatOptions: NumberFormatOptions
formatNumber: (value: number) => string
setVariables: (variables: Record<string, number>) => void
graphView: GraphView
setGraphView: (view: GraphView) => void
graphGridVisible: boolean
setGraphGridVisible: (visible: boolean) => void
resolvedTheme: "light" | "dark"
defaultGraphingData?: GraphingInitialData
defaultStatisticsData?: StatisticsInitialData
defaultToolsData?: ToolsInitialData
}
interface ExtensionDefinition {
label: string
compactLabel: string
usesAngleMode: boolean
hasGraphSettings: boolean
render: (props: ModeRendererProps) => React.ReactNode
}
export const CALCULATOR_EXTENSION_DEFINITIONS: Record<CalculatorExtension, ExtensionDefinition> = {
[CalculatorExtension.SCIENTIFIC]: {
label: "Scientific",
compactLabel: "Sci",
usesAngleMode: true,
hasGraphSettings: false,
render: ({ resetSignal, engine, definitions, setDefinitions, setAns, formatOptions }) => (
<ScientificMode
key={`scientific-${resetSignal}`}
engine={engine}
definitions={definitions}
onDefinitionsChange={setDefinitions}
onAnsChange={setAns}
formatOptions={formatOptions}
/>
),
},
[CalculatorExtension.GRAPHING]: {
label: "Graphing",
compactLabel: "Graph",
usesAngleMode: true,
hasGraphSettings: true,
render: ({
resetSignal,
activeAngleMode,
ans,
definitions,
formatNumber,
setVariables,
graphView,
setGraphView,
graphGridVisible,
setGraphGridVisible,
resolvedTheme,
defaultGraphingData,
}) => (
<GraphingMode
key={`graphing-${resetSignal}`}
angleMode={activeAngleMode}
ans={ans}
definitions={definitions}
formatNumber={formatNumber}
onVariablesChange={setVariables}
view={graphView}
onViewChange={setGraphView}
gridVisible={graphGridVisible}
onGridVisibleChange={setGraphGridVisible}
colorScheme={resolvedTheme}
defaultData={defaultGraphingData}
/>
),
},
[CalculatorExtension.STATISTICS]: {
label: "Stats",
compactLabel: "Stats",
usesAngleMode: false,
hasGraphSettings: false,
render: ({ resetSignal, formatNumber, resolvedTheme, defaultStatisticsData }) => (
<StatisticsMode
key={`statistics-${resetSignal}`}
formatNumber={formatNumber}
colorScheme={resolvedTheme}
defaultData={defaultStatisticsData}
/>
),
},
[CalculatorExtension.TOOLS]: {
label: "Tools",
compactLabel: "Tools",
usesAngleMode: false,
hasGraphSettings: false,
render: ({ resetSignal, formatNumber, defaultToolsData }) => (
<ToolsMode
key={`tools-${resetSignal}`}
formatNumber={formatNumber}
defaultData={defaultToolsData}
/>
),
},
}
../../packages/calculator-registry/src/components/mirai-calculator/calculator-header-icon-button.tsx
import { buttonVariants } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
/** Renders a compact calculator header control with a themed tooltip. */
export function CalculatorHeaderIconButton({
label,
children,
onClick,
portalTheme,
}: {
label: string
children: React.ReactNode
onClick: () => void
portalTheme: "light" | "dark"
}) {
return (
<Tooltip>
<TooltipTrigger
onClick={onClick}
aria-label={label}
className={cn(buttonVariants({ variant: "outline", size: "icon" }), "size-7.5 rounded-md")}
>
{children}
</TooltipTrigger>
<TooltipContent
className={cn("mirai-calculator-package mirai-calculator-portal z-2147483002", portalTheme)}
data-theme={portalTheme}
>
{label}
</TooltipContent>
</Tooltip>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/calculator-settings-dialog.tsx
import { useEffect } from "react"
import { createPortal } from "react-dom"
import { Settings2, X } from "lucide-react"
import {
CALCULATOR_ANGLE_MODE_LABELS,
CALCULATOR_DECIMAL_OPTIONS,
CALCULATOR_GRAPH_BOUNDARY_KEYS,
CALCULATOR_NOTATION_LABELS,
CALCULATOR_THEME_LABELS,
} from "@/components/mirai-calculator/calculator-ui-config"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import type {
CalculatorDisplaySettings,
CalculatorTheme,
} from "@openmirai/calculator-core/configuration"
import type { AngleMode } from "@openmirai/calculator-core/engine"
import type { GraphView } from "@openmirai/calculator-core/graphing-view"
import { cn } from "@/lib/utils"
interface CalculatorSettingsDialogProps {
settingsOpen: boolean
calculatorContainer: HTMLDivElement | null
setSettingsOpen: React.Dispatch<React.SetStateAction<boolean>>
resolvedTheme: "light" | "dark"
showsAngleMode: boolean
activeAngleMode: AngleMode
setAngleMode: (mode: AngleMode) => void
displaySettings: CalculatorDisplaySettings
setDisplaySettings: React.Dispatch<React.SetStateAction<CalculatorDisplaySettings>>
showsGraphSettings: boolean
graphGridVisible: boolean
setGraphGridVisible: React.Dispatch<React.SetStateAction<boolean>>
graphView: GraphView
updateGraphBoundary: (key: keyof GraphView, source: string) => void
activeTheme: CalculatorTheme
setTheme: (theme: CalculatorTheme) => void
}
/** Renders calculator settings inside the calculator container rather than the host page. */
export function CalculatorSettingsDialog({
settingsOpen,
calculatorContainer,
setSettingsOpen,
resolvedTheme,
showsAngleMode,
activeAngleMode,
setAngleMode,
displaySettings,
setDisplaySettings,
showsGraphSettings,
graphGridVisible,
setGraphGridVisible,
graphView,
updateGraphBoundary,
activeTheme,
setTheme,
}: CalculatorSettingsDialogProps) {
useEffect(() => {
if (!settingsOpen) {
return undefined
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSettingsOpen(false)
}
}
document.addEventListener("keydown", closeOnEscape)
return () => document.removeEventListener("keydown", closeOnEscape)
}, [settingsOpen, setSettingsOpen])
return (
<>
{settingsOpen && calculatorContainer
? createPortal(
<div
role="presentation"
className="absolute inset-0 z-60 flex items-center justify-center overflow-hidden rounded-[inherit] bg-black/25 p-3 backdrop-blur-[7px]"
onPointerDown={(event) => {
if (event.target === event.currentTarget) {
setSettingsOpen(false)
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="Calculator settings"
tabIndex={-1}
className={cn(
"mirai-calculator-package mirai-calculator-portal mirai-calculator-settings-dialog relative grid min-h-0 max-h-[calc(100%-1.5rem)] w-[calc(100%-1.5rem)] max-w-160 grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden rounded-xl border bg-popover p-0 shadow-2xl ring-1 ring-foreground/10",
resolvedTheme
)}
data-theme={resolvedTheme}
>
<Button
type="button"
aria-label="Close calculator settings"
variant="ghost"
size="icon-sm"
onClick={() => setSettingsOpen(false)}
className="absolute top-2 right-2 z-10"
>
<X />
</Button>
<div className="border-b bg-muted/20 px-5 py-4 pr-12">
<div className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground shadow-xs">
<Settings2 className="size-4" />
</span>
<div className="min-w-0">
<h2 className="text-sm font-semibold">Calculator settings</h2>
<p className="mt-1 text-xs text-muted-foreground">
Format results and tune the active workspace.
</p>
</div>
</div>
</div>
<div
data-calculator-settings-body=""
className="grid min-h-0 gap-4 overflow-y-auto overscroll-contain p-5 sm:grid-cols-2"
>
<section
aria-labelledby="mirai-number-format-heading"
className="rounded-lg border bg-background/60 p-4"
>
<h3
id="mirai-number-format-heading"
className="mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground"
>
Number format
</h3>
<div className="grid grid-cols-2 gap-3">
{showsAngleMode && (
<div className="grid gap-1.5 text-xs font-medium text-muted-foreground">
<span>Angle mode</span>
<Select
value={activeAngleMode}
onValueChange={(value: string | null) => {
if (value === "degrees" || value === "radians") {
setAngleMode(value)
}
}}
>
<SelectTrigger
size="sm"
className="w-full bg-background"
aria-label="Angle mode"
>
<SelectValue>
{CALCULATOR_ANGLE_MODE_LABELS[activeAngleMode]}
</SelectValue>
</SelectTrigger>
<SelectContent
className={cn(
"mirai-calculator-package mirai-calculator-portal z-2147483002",
resolvedTheme
)}
data-theme={resolvedTheme}
>
<SelectItem value="degrees">Degrees</SelectItem>
<SelectItem value="radians">Radians</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-1.5 text-xs font-medium text-muted-foreground">
<span>Notation</span>
<Select
value={displaySettings.notation}
onValueChange={(value: string | null) => {
if (value !== "auto" && value !== "scientific") {
return
}
setDisplaySettings((current) => ({
...current,
notation: value,
}))
}}
>
<SelectTrigger
size="sm"
className="w-full bg-background"
aria-label="Notation"
>
<SelectValue>
{CALCULATOR_NOTATION_LABELS[displaySettings.notation]}
</SelectValue>
</SelectTrigger>
<SelectContent
className={cn(
"mirai-calculator-package mirai-calculator-portal z-2147483002",
resolvedTheme
)}
data-theme={resolvedTheme}
>
<SelectItem value="auto">Automatic</SelectItem>
<SelectItem value="scientific">Scientific</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5 text-xs font-medium text-muted-foreground">
<span>Decimals</span>
<Select
value={String(displaySettings.decimals)}
onValueChange={(value) => {
if (value === null) {
return
}
setDisplaySettings((current) => ({
...current,
decimals: value === "auto" ? "auto" : Number(value),
}))
}}
>
<SelectTrigger
size="sm"
className="w-full bg-background"
aria-label="Decimals"
>
<SelectValue>
{displaySettings.decimals === "auto"
? "Auto"
: String(displaySettings.decimals)}
</SelectValue>
</SelectTrigger>
<SelectContent
className={cn(
"mirai-calculator-package mirai-calculator-portal z-2147483002",
resolvedTheme
)}
data-theme={resolvedTheme}
>
<SelectItem value="auto">Auto</SelectItem>
{CALCULATOR_DECIMAL_OPTIONS.map((value) => (
<SelectItem key={value} value={String(value)}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label
htmlFor="mirai-significant-figures"
className="grid gap-1.5 text-xs font-medium text-muted-foreground"
>
Significant figures
<Input
id="mirai-significant-figures"
type="number"
min={1}
max={15}
value={displaySettings.significantFigures}
onChange={(event) =>
setDisplaySettings((current) => ({
...current,
significantFigures: Math.max(
1,
Math.min(15, Number(event.target.value) || 1)
),
}))
}
className="h-7 w-full bg-background"
/>
</label>
</div>
<div className="mt-3 flex items-center justify-between gap-4 rounded-md border bg-muted/25 px-3 py-2.5 text-sm">
<span>
<strong className="block text-xs font-medium">Thousands separators</strong>
<small className="text-xs text-muted-foreground">
Group large values for readability.
</small>
</span>
<Switch
aria-label="Thousands separators"
checked={displaySettings.thousandsSeparator}
onCheckedChange={(checked) =>
setDisplaySettings((current) => ({
...current,
thousandsSeparator: checked,
}))
}
/>
</div>
</section>
{showsGraphSettings && (
<section
aria-labelledby="mirai-graph-viewport-heading"
className="rounded-lg border bg-background/60 p-4"
>
<div className="mb-2 flex items-center justify-between gap-3">
<h3
id="mirai-graph-viewport-heading"
className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground"
>
Graph viewport
</h3>
<div className="flex items-center gap-2 text-xs">
<span>Grid lines</span>
<Switch
aria-label="Grid lines"
checked={graphGridVisible}
onCheckedChange={setGraphGridVisible}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{CALCULATOR_GRAPH_BOUNDARY_KEYS.map((key) => (
<label
key={key}
htmlFor={`mirai-graph-${key}`}
className="grid gap-1.5 text-xs font-medium text-muted-foreground"
>
{key.replace("min", " min").replace("max", " max")}
<Input
id={`mirai-graph-${key}`}
type="number"
value={graphView[key]}
onChange={(event) => updateGraphBoundary(key, event.target.value)}
className="h-7 w-full bg-background font-mono"
/>
</label>
))}
</div>
</section>
)}
<section
aria-labelledby="mirai-appearance-heading"
className="rounded-lg border bg-background/60 p-4"
>
<h3
id="mirai-appearance-heading"
className="mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground"
>
Appearance
</h3>
<div className="grid gap-1.5 text-xs font-medium text-muted-foreground">
<span>Theme</span>
<Select
value={activeTheme}
onValueChange={(value: string | null) => {
if (value === "light" || value === "dark" || value === "system") {
setTheme(value)
}
}}
>
<SelectTrigger
size="sm"
className="w-full bg-background"
aria-label="Theme"
>
<SelectValue>{CALCULATOR_THEME_LABELS[activeTheme]}</SelectValue>
</SelectTrigger>
<SelectContent
className={cn(
"mirai-calculator-package mirai-calculator-portal z-2147483002",
resolvedTheme
)}
data-theme={resolvedTheme}
>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
</section>
</div>
<div className="flex justify-end border-t bg-muted/20 px-5 py-3">
<Button variant="outline" size="sm" onClick={() => setSettingsOpen(false)}>
Done
</Button>
</div>
</section>
</div>,
calculatorContainer
)
: null}
</>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/calculator-ui-config.ts
/** Supported decimal-place options displayed by calculator settings. */
export const CALCULATOR_DECIMAL_OPTIONS = [0, 1, 2, 3, 4, 6] as const
/** Graph viewport properties exposed by calculator settings. */
export const CALCULATOR_GRAPH_BOUNDARY_KEYS = ["xmin", "xmax", "ymin", "ymax"] as const
/** Human-readable angle-mode labels. */
export const CALCULATOR_ANGLE_MODE_LABELS = {
degrees: "Degrees",
radians: "Radians",
} as const
/** Human-readable number-notation labels. */
export const CALCULATOR_NOTATION_LABELS = {
auto: "Automatic",
scientific: "Scientific",
} as const
/** Human-readable calculator theme labels. */
export const CALCULATOR_THEME_LABELS = {
dark: "Dark",
light: "Light",
system: "System",
} as const
/** Semantic shadcn chart tokens used by calculator visualizations. */
export const CALCULATOR_CHART_TOKENS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
] as const
../../packages/calculator-registry/src/components/mirai-calculator/calculator-viewport-portal.tsx
import { useLayoutEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
/** Moves calculator content between its inline host and the fullscreen viewport layer. */
export function CalculatorViewportPortal({
fullscreen,
children,
}: {
fullscreen: boolean
children: React.ReactNode
}) {
const inlineContainerRef = useRef<HTMLDivElement>(null)
const [portalHost, setPortalHost] = useState<HTMLDivElement | null>(null)
useLayoutEffect(() => {
const host = document.createElement("div")
host.style.display = "contents"
setPortalHost(host)
return () => host.remove()
}, [])
useLayoutEffect(() => {
if (!portalHost) {
return
}
const target = fullscreen ? document.body : inlineContainerRef.current
target?.append(portalHost)
}, [fullscreen, portalHost])
return (
<div ref={inlineContainerRef} style={{ display: "contents" }}>
{portalHost
? createPortal(
<>
{fullscreen && (
<div
aria-hidden="true"
data-mirai-fullscreen-guard=""
className="fixed inset-0 z-2147482999 bg-black/35 backdrop-blur-[1px]"
/>
)}
{children}
</>,
portalHost
)
: children}
</div>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/canvas-theme.ts
import { CALCULATOR_CHART_TOKENS } from "@/components/mirai-calculator/calculator-ui-config"
/** Reads a shadcn color property with a computed foreground or background fallback. */
export function readCanvasColor(element: Element, property: string): string {
const computed = getComputedStyle(element)
const value = computed.getPropertyValue(property).trim()
return value || (property === "--background" ? computed.backgroundColor : computed.color)
}
/** Resolves a CSS custom-property token to a canvas-compatible color. */
export function resolveCanvasColor(element: Element, token: string): string {
const computed = getComputedStyle(element)
const match = /^var\((--[^)]+)\)$/.exec(token.trim())
if (!match) {
return token || computed.color
}
return computed.getPropertyValue(match[1]).trim() || computed.color
}
/** Returns every configured shadcn chart color resolved for a canvas element. */
export function readCanvasChartColors(element: Element): Array<string> {
return CALCULATOR_CHART_TOKENS.map((token) => resolveCanvasColor(element, token))
}
../../packages/calculator-registry/src/components/mirai-calculator/mirai-calculator.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Calculator as CalculatorIcon,
Expand,
EyeOff,
Minimize2,
Moon,
RotateCcw,
Settings2,
Sun,
X,
} from "lucide-react"
import { CalculatorHeaderIconButton } from "@/components/mirai-calculator/calculator-header-icon-button"
import { CALCULATOR_EXTENSION_DEFINITIONS } from "@/components/mirai-calculator/calculator-extension-definitions"
import { CalculatorSettingsDialog } from "@/components/mirai-calculator/calculator-settings-dialog"
import { CalculatorViewportPortal } from "@/components/mirai-calculator/calculator-viewport-portal"
import {
resolveCalculatorTheme,
useSystemDarkMode,
} from "@/components/mirai-calculator/use-system-dark-mode"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import {
CalculatorExtension,
DEFAULT_DISPLAY_SETTINGS,
DEFAULT_GRAPH_VIEW,
EMPTY_CALCULATOR_DEFINITIONS,
calculatorNumberFormatOptions,
collectSliderVariables,
normalizeCalculatorExtensions,
} from "@openmirai/calculator-core/configuration"
import type {
CalculatorDisplaySettings,
CalculatorMode,
CalculatorTheme,
} from "@openmirai/calculator-core/configuration"
import { CalculatorEngine } from "@openmirai/calculator-core/engine"
import type { AngleMode, NumberFormatOptions } from "@openmirai/calculator-core/engine"
import type { GraphingInitialData } from "@openmirai/calculator-core/graphing-data"
import type { GraphView } from "@openmirai/calculator-core/graphing-view"
import type { StatisticsInitialData } from "@openmirai/calculator-core/statistics-data"
import type { ToolsInitialData as ToolsInitialDataValue } from "@openmirai/calculator-core/tools"
import { cn } from "@/lib/utils"
export interface MiraiCalculatorProps {
className?: string
extensions?: ReadonlyArray<CalculatorExtension>
mode?: CalculatorMode
defaultMode?: CalculatorMode
onModeChange?: (mode: CalculatorMode) => void
angleMode?: AngleMode
defaultAngleMode?: AngleMode
onAngleModeChange?: (mode: AngleMode) => void
theme?: CalculatorTheme
defaultTheme?: CalculatorTheme
onThemeChange?: (theme: CalculatorTheme) => void
hidden?: boolean
defaultHidden?: boolean
onHiddenChange?: (hidden: boolean) => void
startFullscreen?: boolean
title?: string
onClose?: () => void
defaultDefinitions?: ReadonlyArray<string>
defaultGraphingData?: GraphingInitialData
defaultStatisticsData?: StatisticsInitialData
defaultToolsData?: ToolsInitialDataValue
}
/** Composes enabled calculator extensions into a configurable, container-responsive panel. */
export function MiraiCalculator({
className,
extensions,
mode,
defaultMode = CalculatorExtension.SCIENTIFIC,
onModeChange,
angleMode,
defaultAngleMode = "degrees",
onAngleModeChange,
theme,
defaultTheme = "light",
onThemeChange,
hidden,
defaultHidden = false,
onHiddenChange,
startFullscreen = false,
title = "Calculator",
onClose,
defaultDefinitions = EMPTY_CALCULATOR_DEFINITIONS,
defaultGraphingData,
defaultStatisticsData,
defaultToolsData,
}: MiraiCalculatorProps) {
const [internalMode, setInternalMode] = useState(defaultMode)
const [internalAngleMode, setInternalAngleMode] = useState(defaultAngleMode)
const [internalTheme, setInternalTheme] = useState(defaultTheme)
const [internalHidden, setInternalHidden] = useState(defaultHidden)
const systemDark = useSystemDarkMode()
const [fullscreen, setFullscreen] = useState(startFullscreen)
const [settingsOpen, setSettingsOpen] = useState(false)
const [displaySettings, setDisplaySettings] = useState<CalculatorDisplaySettings>(() => ({
...DEFAULT_DISPLAY_SETTINGS,
}))
const [graphView, setGraphView] = useState<GraphView>(() => ({ ...DEFAULT_GRAPH_VIEW }))
const [graphGridVisible, setGraphGridVisible] = useState(true)
const [definitions, setDefinitions] = useState(() => [...defaultDefinitions])
const [variables, setVariables] = useState<Record<string, number>>(() =>
collectSliderVariables(
(defaultGraphingData?.expressions ?? []).map((expression) => ({
expression: expression.value,
value: expression.slider?.value,
}))
)
)
const [ans, setAns] = useState(0)
const [resetSignal, setResetSignal] = useState(0)
const calculatorRef = useRef<HTMLDivElement>(null)
const enabledExtensions = useMemo(() => normalizeCalculatorExtensions(extensions), [extensions])
const requestedMode = mode ?? internalMode
const activeMode = enabledExtensions.includes(requestedMode)
? requestedMode
: enabledExtensions[0]
const activeAngleMode = angleMode ?? internalAngleMode
const activeTheme = theme ?? internalTheme
const activeHidden = hidden ?? internalHidden
const resolvedTheme = resolveCalculatorTheme(activeTheme, systemDark)
const showsAngleMode = enabledExtensions.some(
(extension) => CALCULATOR_EXTENSION_DEFINITIONS[extension].usesAngleMode
)
const showsGraphSettings = enabledExtensions.some(
(extension) => CALCULATOR_EXTENSION_DEFINITIONS[extension].hasGraphSettings
)
useEffect(() => {
if (!fullscreen) {
return
}
const previousOverflow = document.documentElement.style.overflow
document.documentElement.style.overflow = "hidden"
return () => {
document.documentElement.style.overflow = previousOverflow
}
}, [fullscreen])
const setMode = (nextMode: CalculatorMode) => {
if (mode === undefined) {
setInternalMode(nextMode)
}
onModeChange?.(nextMode)
}
const setAngleMode = (nextMode: AngleMode) => {
if (angleMode === undefined) {
setInternalAngleMode(nextMode)
}
onAngleModeChange?.(nextMode)
}
const setTheme = (nextTheme: CalculatorTheme) => {
if (theme === undefined) {
setInternalTheme(nextTheme)
}
onThemeChange?.(nextTheme)
}
const setHidden = (nextHidden: boolean) => {
if (hidden === undefined) {
setInternalHidden(nextHidden)
}
onHiddenChange?.(nextHidden)
if (nextHidden) {
setFullscreen(false)
setSettingsOpen(false)
}
}
const formatOptions = useMemo<NumberFormatOptions>(
() => calculatorNumberFormatOptions(displaySettings),
[displaySettings]
)
const engine = useMemo(
() =>
new CalculatorEngine({
angleMode: activeAngleMode,
ans,
definitions,
variables,
}),
[activeAngleMode, ans, definitions, variables]
)
const formatNumber = useCallback(
(value: number) => engine.format(value, formatOptions),
[engine, formatOptions]
)
const reset = () => {
setDisplaySettings({ ...DEFAULT_DISPLAY_SETTINGS })
setGraphView({ ...DEFAULT_GRAPH_VIEW })
setGraphGridVisible(true)
setDefinitions([...defaultDefinitions])
setVariables({})
setAns(0)
setResetSignal((signal) => signal + 1)
}
const updateGraphBoundary = (key: keyof GraphView, source: string) => {
const value = Number(source)
if (!Number.isFinite(value)) {
return
}
setGraphView((current) => {
const next = { ...current, [key]: value }
if (next.xmin >= next.xmax || next.ymin >= next.ymax) {
return current
}
return next
})
}
return (
<TooltipProvider>
<div className="mirai-calculator-package" style={{ display: "contents" }}>
<div className="relative isolate h-full min-h-0 w-full">
{activeHidden && (
<Tooltip>
<TooltipTrigger
type="button"
aria-label="Show calculator"
onClick={() => setHidden(false)}
className={cn(
buttonVariants({ variant: "default", size: "icon" }),
"mirai-calculator size-12 rounded-xl shadow-xl",
resolvedTheme
)}
data-calculator-hidden-launcher=""
data-theme={resolvedTheme}
>
<CalculatorIcon className="size-5" />
</TooltipTrigger>
<TooltipContent
className={cn(
"mirai-calculator-package mirai-calculator-portal z-2147483002",
resolvedTheme
)}
data-theme={resolvedTheme}
>
Show calculator
</TooltipContent>
</Tooltip>
)}
<CalculatorViewportPortal fullscreen={fullscreen}>
<div
ref={calculatorRef}
hidden={activeHidden}
className={cn(
"mirai-calculator @container relative flex h-full min-h-0 w-full min-w-0 max-w-full flex-col overflow-hidden rounded-xl border bg-background text-sm text-foreground tabular-nums shadow-xl [&_canvas]:block",
resolvedTheme,
fullscreen &&
"fixed inset-3 z-2147483000 h-[calc(100dvh-1.5rem)] min-h-0 w-auto max-w-none rounded-xl",
className
)}
data-theme={resolvedTheme}
>
<header
data-calculator-drag-handle=""
className="mirai-calculator-header flex min-h-13 shrink-0 touch-none flex-nowrap items-center gap-3 overflow-hidden border-b bg-card px-3 py-2 select-none @max-[699px]:gap-1"
>
<span className="sr-only">{title}</span>
<span
aria-hidden="true"
className="grid shrink-0 grid-cols-2 gap-0.75 p-0.5 @max-[359px]:hidden"
>
{Array.from({ length: 6 }, (_, index) => (
<span key={index} className="size-0.75 rounded-full bg-muted-foreground/35" />
))}
</span>
{enabledExtensions.length === 1 && (
<span
data-calculator-mode-label
className="flex h-8 min-w-0 shrink-0 items-center text-sm font-medium"
>
{CALCULATOR_EXTENSION_DEFINITIONS[enabledExtensions[0]].label}
</span>
)}
{enabledExtensions.length > 1 && (
<>
<div className="mirai-mode-select hidden min-w-0 flex-1 @max-[699px]:block">
<Select
value={activeMode}
onValueChange={(value: string | null) => {
if (
value !== null &&
enabledExtensions.includes(value as CalculatorExtension)
) {
setMode(value as CalculatorExtension)
}
}}
>
<SelectTrigger
aria-label="Calculator mode"
size="sm"
className="h-8 w-full min-w-0 rounded-lg border-transparent bg-muted shadow-none"
>
<SelectValue>
{CALCULATOR_EXTENSION_DEFINITIONS[activeMode].label}
</SelectValue>
</SelectTrigger>
<SelectContent
className={cn(
"mirai-calculator-package mirai-calculator-portal z-2147483002",
resolvedTheme
)}
data-theme={resolvedTheme}
>
{enabledExtensions.map((extension) => (
<SelectItem key={extension} value={extension}>
{CALCULATOR_EXTENSION_DEFINITIONS[extension].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<nav
aria-label="Calculator modes"
className="mirai-mode-switcher relative grid min-w-0 max-w-full flex-[1_1_364px] gap-0.5 overflow-hidden rounded-lg bg-muted p-0.75 @max-[699px]:hidden"
style={{
gridTemplateColumns: `repeat(${enabledExtensions.length}, minmax(0, 1fr))`,
}}
>
{enabledExtensions.map((extension) => (
<Button
key={extension}
aria-label={CALCULATOR_EXTENSION_DEFINITIONS[extension].label}
variant="ghost"
size="sm"
onClick={() => setMode(extension)}
aria-current={activeMode === extension ? "page" : undefined}
data-active={activeMode === extension ? "" : undefined}
className="h-7 min-w-0 w-full rounded-md bg-transparent px-1 text-sm font-medium shadow-none transition-[background-color,box-shadow,color] hover:bg-foreground/8! hover:text-foreground! focus-visible:border-transparent focus-visible:ring-0 focus-visible:outline-none data-active:bg-background data-active:text-foreground data-active:shadow-sm data-active:hover:bg-background!"
>
<span
aria-hidden="true"
className="mirai-mode-label @max-[699px]:text-xs"
data-compact-label={
CALCULATOR_EXTENSION_DEFINITIONS[extension].compactLabel
}
>
{CALCULATOR_EXTENSION_DEFINITIONS[extension].label}
</span>
</Button>
))}
</nav>
</>
)}
<div className="mirai-header-actions ml-auto flex shrink-0 items-center gap-2 @max-[699px]:gap-1">
<CalculatorHeaderIconButton
label={`Use ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
portalTheme={resolvedTheme}
>
{resolvedTheme === "dark" ? <Sun /> : <Moon />}
</CalculatorHeaderIconButton>
<Button
type="button"
aria-label="Calculator settings"
variant="outline"
size="icon"
onClick={() => setSettingsOpen(true)}
className={cn(
"size-7.5 rounded-md",
settingsOpen && "bg-muted text-foreground"
)}
>
<Settings2 />
</Button>
<CalculatorSettingsDialog
settingsOpen={settingsOpen}
calculatorContainer={calculatorRef.current}
setSettingsOpen={setSettingsOpen}
resolvedTheme={resolvedTheme}
showsAngleMode={showsAngleMode}
activeAngleMode={activeAngleMode}
setAngleMode={setAngleMode}
displaySettings={displaySettings}
setDisplaySettings={setDisplaySettings}
showsGraphSettings={showsGraphSettings}
graphGridVisible={graphGridVisible}
setGraphGridVisible={setGraphGridVisible}
graphView={graphView}
updateGraphBoundary={updateGraphBoundary}
activeTheme={activeTheme}
setTheme={setTheme}
/>
<CalculatorHeaderIconButton
label="Hide calculator"
onClick={() => setHidden(true)}
portalTheme={resolvedTheme}
>
<EyeOff />
</CalculatorHeaderIconButton>
<Button
aria-label="Reset"
variant="outline"
size="sm"
onClick={reset}
className="mirai-header-reset h-7.5 min-w-7.5 rounded-md px-2.5 text-xs text-muted-foreground @max-[699px]:w-7.5 @max-[699px]:px-0"
>
<RotateCcw
aria-hidden="true"
className="mirai-header-reset-icon hidden size-4 @max-[699px]:block"
/>
<span className="mirai-header-reset-label @max-[699px]:hidden">Reset</span>
</Button>
<CalculatorHeaderIconButton
label={fullscreen ? "Exit full screen" : "Enter full screen"}
onClick={() => setFullscreen((value) => !value)}
portalTheme={resolvedTheme}
>
{fullscreen ? <Minimize2 /> : <Expand />}
</CalculatorHeaderIconButton>
{onClose && (
<CalculatorHeaderIconButton
label="Close calculator"
onClick={onClose}
portalTheme={resolvedTheme}
>
<X />
</CalculatorHeaderIconButton>
)}
</div>
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
{activeMode ? (
CALCULATOR_EXTENSION_DEFINITIONS[activeMode].render({
resetSignal,
engine,
activeAngleMode,
ans,
definitions,
setDefinitions,
setAns,
formatOptions,
formatNumber,
setVariables,
graphView,
setGraphView,
graphGridVisible,
setGraphGridVisible,
resolvedTheme,
defaultGraphingData,
defaultStatisticsData,
defaultToolsData,
})
) : (
<div
role="status"
className="flex min-h-48 flex-1 items-center justify-center p-8 text-center text-sm text-muted-foreground"
>
No calculator extensions enabled
</div>
)}
</main>
</div>
</CalculatorViewportPortal>
</div>
</div>
</TooltipProvider>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/graphing-canvas-viewport.tsx
import { Grid3X3, Home, LocateFixed, X, ZoomIn, ZoomOut } from "lucide-react"
import { CALCULATOR_CHART_TOKENS } from "@/components/mirai-calculator/calculator-ui-config"
import type {
GraphMarkCluster,
GraphTrace,
} from "@/components/mirai-calculator/modes/graphing-state"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { DEFAULT_GRAPH_VIEW } from "@openmirai/calculator-core/configuration"
import type { GraphView } from "@openmirai/calculator-core/graphing-view"
import { cn } from "@/lib/utils"
interface GraphTracePosition {
x: number
y: number
labelOnLeft: boolean
labelBelow: boolean
}
interface GraphingCanvasViewportProps {
graphHostRef: React.RefObject<HTMLDivElement | null>
canvasRef: React.RefObject<HTMLCanvasElement | null>
trace: GraphTrace | null
tracePosition: GraphTracePosition | null
handlePointerDown: React.PointerEventHandler<HTMLCanvasElement>
handlePointerMove: React.PointerEventHandler<HTMLCanvasElement>
handlePointerUp: React.PointerEventHandler<HTMLCanvasElement>
formatNumber: (value: number) => string
zoom: (factor: number) => void
setView: (view: GraphView) => void
gridVisible: boolean
onGridVisibleChange: (visible: boolean) => void
analysisOpen: boolean
setAnalysisOpen: (open: boolean) => void
marks: Array<GraphMarkCluster>
setTrace: (trace: GraphTrace | null) => void
}
/** Renders the interactive graph canvas, controls, trace label, and analysis overlay. */
export function GraphingCanvasViewport({
graphHostRef,
canvasRef,
trace,
tracePosition,
handlePointerDown,
handlePointerMove,
handlePointerUp,
formatNumber,
zoom,
setView,
gridVisible,
onGridVisibleChange,
analysisOpen,
setAnalysisOpen,
marks,
setTrace,
}: GraphingCanvasViewportProps) {
return (
<section className="mirai-graphing-canvas relative min-h-0 overflow-hidden overscroll-contain bg-background @max-[699px]:order-1 @max-[699px]:min-h-80">
<div ref={graphHostRef} className="absolute inset-0" aria-label="Interactive graph">
<canvas
ref={canvasRef}
className="size-full touch-none cursor-crosshair"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
{trace && tracePosition && (
<div
data-graph-trace-coordinate=""
className="pointer-events-none absolute z-20"
style={{ left: tracePosition.x, top: tracePosition.y }}
>
<span
aria-hidden="true"
className="absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background shadow-sm"
style={{ backgroundColor: trace.color }}
/>
<output
aria-live="polite"
className={cn(
"absolute w-max max-w-48 rounded-md border bg-background/95 px-2 py-1 font-mono text-xs text-foreground shadow-md backdrop-blur",
tracePosition.labelOnLeft ? "right-3" : "left-3",
tracePosition.labelBelow ? "top-3" : "bottom-3"
)}
>
({formatNumber(trace.x)}, {formatNumber(trace.y)})
</output>
</div>
)}
</div>
<div className="absolute top-3 left-3 flex rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
<Button variant="ghost" size="icon-sm" onClick={() => zoom(0.7)} aria-label="Zoom in">
<ZoomIn />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => zoom(1 / 0.7)} aria-label="Zoom out">
<ZoomOut />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView({ ...DEFAULT_GRAPH_VIEW })}
aria-label="Reset graph view"
>
<Home />
</Button>
<Button
variant={gridVisible ? "secondary" : "ghost"}
size="icon-sm"
onClick={() => onGridVisibleChange(!gridVisible)}
aria-label="Toggle grid"
>
<Grid3X3 />
</Button>
</div>
{analysisOpen ? (
<div
data-graph-analysis
className="absolute right-3 bottom-3 max-h-[48%] w-[min(300px,calc(100%-1.5rem))] overflow-auto rounded-lg border bg-background/95 p-3 shadow-sm backdrop-blur"
>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
<LocateFixed className="size-3.5 text-primary" />
Analysis
</h3>
<div className="flex items-center">
{trace && (
<Button variant="ghost" size="sm" onClick={() => setTrace(null)}>
Clear trace
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={() => setAnalysisOpen(false)}
aria-label="Hide analysis"
>
<X />
</Button>
</div>
</div>
{trace && (
<>
<div className="rounded-md bg-muted px-3 py-2 font-mono text-xs">
({formatNumber(trace.x)}, {formatNumber(trace.y)})
</div>
<Separator className="my-2" />
</>
)}
<div className="space-y-1">
{marks.slice(0, 12).map((mark, index) => (
<Button
variant="ghost"
key={`${mark.labels.join("-")}-${mark.x}-${index}`}
onClick={() =>
setTrace({
x: mark.x,
y: mark.y,
color: mark.colors.at(-1) ?? CALCULATOR_CHART_TOKENS[4],
})
}
className="h-auto w-full justify-between gap-2 px-2 py-1.5 text-xs"
>
<span className="truncate capitalize">
{mark.labels.join(" · ")}
{mark.count > 1 ? ` ×${String(mark.count)}` : ""}
</span>
<span className="shrink-0 font-mono text-muted-foreground">
({formatNumber(mark.x)}, {formatNumber(mark.y)})
</span>
</Button>
))}
{marks.length === 0 && !trace && (
<p className="py-2 text-xs text-muted-foreground">
Touch a curve to trace and drag it. Zeros, extrema, and intersections appear here.
</p>
)}
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setAnalysisOpen(true)}
className="absolute right-3 bottom-3 bg-background/95 shadow-sm backdrop-blur"
>
<LocateFixed />
Analysis
</Button>
)}
</section>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/graphing-sidebar.tsx
import { Play, Plus, Square, X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { CalculatorEmptyStateAction } from "@/components/mirai-calculator/calculator-empty-state-action"
import type {
GraphExpressionRow,
GraphTableColumn,
GraphTableRow,
TableGraphPoint,
} from "@/components/mirai-calculator/modes/graphing-state"
import type { CompiledGraphExpression } from "@openmirai/calculator-core/graphing"
import type { RegressionResult } from "@openmirai/calculator-core/statistics"
import { cn } from "@/lib/utils"
interface GraphingSidebarProps {
compiled: Array<{ row: GraphExpressionRow; expression: CompiledGraphExpression }>
rows: Array<GraphExpressionRow>
tableColumns: Array<GraphTableColumn>
table: Array<GraphTableRow>
tableMinimumWidth: number
tableSeries: Array<{
column: GraphTableColumn & { color: string }
points: Array<TableGraphPoint>
regression: RegressionResult
}>
formatNumber: (value: number) => string
updateRow: (id: number, patch: Partial<GraphExpressionRow>) => void
commitRows: React.Dispatch<React.SetStateAction<Array<GraphExpressionRow>>>
addTableVariable: () => void
addTableRow: () => void
setTable: React.Dispatch<React.SetStateAction<Array<GraphTableRow>>>
setTableColumns: React.Dispatch<React.SetStateAction<Array<GraphTableColumn>>>
removeTableVariable: (columnId: number) => void
updateCell: (rowId: number, columnId: number, value: string) => void
addExpression: () => void
}
function describeGraphExpression(
expression: CompiledGraphExpression,
formatNumber: (value: number) => string
): string {
switch (expression.kind) {
case "invalid":
return expression.message
case "explicit":
return "Plotted"
case "implicit":
return "Implicit relation"
case "points":
return `${expression.points.length} plotted points`
case "variable":
return `${expression.name} = ${formatNumber(expression.value)}`
}
}
/** Renders graph expressions, editable table data, and regression summaries. */
export function GraphingSidebar({
compiled,
rows,
tableColumns,
table,
tableMinimumWidth,
tableSeries,
formatNumber,
updateRow,
commitRows,
addTableVariable,
addTableRow,
setTable,
setTableColumns,
removeTableVariable,
updateCell,
addExpression,
}: GraphingSidebarProps) {
return (
<aside className="mirai-graphing-sidebar flex min-h-0 flex-col border-r bg-card @max-[699px]:order-2 @max-[699px]:min-h-75 @max-[699px]:max-h-[50%] @max-[699px]:border-r-0 @max-[699px]:border-b">
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y">
{compiled.map(({ row, expression }) => {
const slider = row.slider
return (
<div key={row.id} className="flex gap-2.5 px-3 py-3">
<Button
variant="ghost"
size="icon"
onClick={() => updateRow(row.id, { visible: !row.visible })}
aria-label={row.visible ? "Hide expression" : "Show expression"}
className="mt-0.5 size-5.5 shrink-0 rounded-full border-2 p-0"
style={{
borderColor: row.color,
backgroundColor: row.visible ? row.color : "transparent",
}}
>
<span className="sr-only">{row.visible ? "Visible" : "Hidden"}</span>
</Button>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-1.5">
<Input
value={row.text}
onChange={(event) => updateRow(row.id, { text: event.target.value })}
aria-label={`Graph expression ${row.id}`}
placeholder="y = 2x + 5"
className="h-7 min-w-0 rounded-none border-0 border-b border-transparent bg-transparent px-0 font-mono text-base font-medium shadow-none focus-visible:border-primary focus-visible:ring-0 dark:bg-transparent"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() =>
commitRows((current) => current.filter((item) => item.id !== row.id))
}
aria-label={`Delete expression ${row.id}`}
className="text-muted-foreground hover:text-destructive"
>
<X />
</Button>
</div>
<p
className={cn(
"text-xs text-muted-foreground",
expression.kind === "invalid" && "text-destructive"
)}
>
{describeGraphExpression(expression, formatNumber)}
</p>
{slider && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{slider.min}</span>
<Input
type="range"
min={slider.min}
max={slider.max}
step={slider.step}
value={slider.value}
onChange={(event) =>
updateRow(row.id, {
slider: {
...slider,
value: Number(event.target.value),
},
})
}
aria-label={`Slider for expression ${row.id}`}
className="h-5 flex-1 border-0 p-0 shadow-none"
/>
<span className="text-xs text-muted-foreground">{slider.max}</span>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateRow(row.id, {
slider: {
...slider,
playing: !slider.playing,
},
})
}
aria-label={slider.playing ? "Stop slider" : "Play slider"}
className="rounded-full text-primary"
>
{slider.playing ? <Square /> : <Play />}
</Button>
</div>
)}
</div>
</div>
)
})}
{rows.length === 0 && (
<div className="px-4 py-6 text-center">
<p className="text-sm font-semibold">No expressions yet</p>
<p className="mt-1 text-xs text-muted-foreground">
<CalculatorEmptyStateAction label="Add expression" onClick={addExpression} /> one to
start graphing.
</p>
</div>
)}
<section className="p-3">
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<h3 className="mr-auto text-sm font-semibold">Table 1</h3>
<Button
variant="outline"
size="sm"
onClick={addTableVariable}
className="h-7 rounded-md text-xs"
>
<Plus />
Variable
</Button>
<Button
variant="outline"
size="sm"
onClick={addTableRow}
className="h-7 rounded-md text-xs"
>
<Plus />
Add row
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setTable([])}
className="h-7 rounded-md text-xs"
>
Clear
</Button>
</div>
<div className="mirai-graphing-table-scroll min-w-0 max-w-full overflow-x-auto overscroll-x-contain border-y contain-[inline-size]">
<table
className="w-full table-fixed border-collapse text-sm"
style={{ minWidth: tableMinimumWidth }}
>
<colgroup>
<col className="w-9" />
{tableColumns.map((column) => (
<col className="w-28" key={column.id} />
))}
<col className="w-8" />
</colgroup>
<thead className="bg-muted/35">
<tr>
<th
scope="col"
className="w-9 border-r px-2 py-1 text-center font-mono text-xs text-muted-foreground"
>
#
</th>
{tableColumns.map((column) => (
<th key={column.id} scope="col" className="border-r p-0 font-normal">
<div className="flex items-center">
{column.color && (
<span
aria-hidden="true"
className="ml-2 size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: column.color }}
/>
)}
<Input
value={column.label}
onChange={(event) =>
setTableColumns((current) =>
current.map((item) =>
item.id === column.id
? { ...item, label: event.target.value }
: item
)
)
}
aria-label={`Table variable ${column.label}`}
className="h-8 w-full min-w-0 rounded-none border-0 bg-transparent px-2 font-mono text-sm font-semibold shadow-none focus-visible:ring-0 dark:bg-transparent"
/>
{column.role === "y" && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => removeTableVariable(column.id)}
aria-label={`Remove variable ${column.label}`}
className="rounded-none text-muted-foreground hover:text-destructive"
>
<X />
</Button>
)}
</div>
</th>
))}
<th scope="col" className="w-8">
<span className="sr-only">Row actions</span>
</th>
</tr>
</thead>
<tbody>
{table.map((row, rowIndex) => (
<tr key={row.id} className="border-t">
<th
scope="row"
className="border-r px-2 text-center font-mono text-xs font-normal text-muted-foreground"
>
{rowIndex + 1}
</th>
{tableColumns.map((column) => (
<td key={column.id} className="border-r p-0">
<Input
value={row.values[column.id] ?? ""}
onChange={(event) => updateCell(row.id, column.id, event.target.value)}
aria-label={`${column.label} row ${rowIndex + 1}`}
className="h-8 w-full min-w-0 rounded-none border-0 bg-transparent px-2 font-mono text-sm shadow-none focus-visible:ring-1 dark:bg-transparent"
/>
</td>
))}
<td className="p-0">
<Button
variant="ghost"
size="icon-sm"
onClick={() =>
setTable((current) => current.filter((item) => item.id !== row.id))
}
aria-label={`Remove table row ${rowIndex + 1}`}
className="rounded-none text-muted-foreground hover:text-destructive"
>
<X />
</Button>
</td>
</tr>
))}
{table.length === 0 && (
<tr>
<td
colSpan={tableColumns.length + 2}
className="h-12 px-3 text-center text-xs text-muted-foreground"
>
<CalculatorEmptyStateAction label="Add table row" onClick={addTableRow} /> a
row to plot table data.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="p-3">
<h3 className="mb-3 text-sm font-semibold">Regression</h3>
<div className="space-y-3">
{tableSeries.map(({ column, regression }) => (
<div key={column.id}>
<Badge variant="secondary" className="mb-2 font-mono text-xs">
{column.label} ~ m{tableColumns[0]?.label ?? "x"} + b
</Badge>
<div className="grid grid-cols-3 gap-2">
{[
["m", regression.params[1]?.value],
["b", regression.params[0]?.value],
["R²", regression.r2],
].map(([label, value]) => (
<div key={String(label)} className="rounded-lg border p-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div
className={cn(
"font-mono text-sm font-semibold",
label === "R²" && "text-primary"
)}
>
{typeof value === "number" && Number.isFinite(value)
? formatNumber(value)
: "—"}
</div>
</div>
))}
</div>
</div>
))}
{tableSeries.length === 0 && (
<p className="text-xs text-muted-foreground">
<CalculatorEmptyStateAction label="Add y variable" onClick={addTableVariable} /> a
y variable to fit data.
</p>
)}
</div>
</section>
</div>
</ScrollArea>
<div className="flex shrink-0 gap-2 border-t bg-muted/35 p-3">
<Button variant="outline" size="sm" onClick={addExpression} className="flex-1 rounded-md">
<Plus />
Expression
</Button>
<Button
variant="outline"
size="sm"
onClick={() => commitRows([])}
className="rounded-md text-muted-foreground hover:text-destructive"
>
Clear all
</Button>
</div>
</aside>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/graphing-state.ts
import { CALCULATOR_CHART_TOKENS } from "@/components/mirai-calculator/calculator-ui-config"
import type { GraphingInitialData } from "@openmirai/calculator-core/graphing-data"
import type { GraphPoint } from "@openmirai/calculator-core/graphing-view"
/** Editable graph expression state owned by the graphing interface. */
export interface GraphExpressionRow {
id: number
text: string
color: string
visible: boolean
slider?: {
min: number
max: number
step: number
value: number
playing: boolean
}
}
/** Editable table row keyed by graph table column identifiers. */
export interface GraphTableRow {
id: number
values: Record<number, string>
}
/** Editable graph table column. */
export interface GraphTableColumn {
id: number
label: string
color?: string
role: "x" | "y"
}
/** Clustered graph-analysis point with presentation metadata. */
export interface GraphMarkCluster extends GraphPoint {
labels: Array<string>
colors: Array<string>
count: number
}
/** Plottable table point connected to its editable source cells. */
export interface TableGraphPoint extends GraphPoint {
rowId: number
columnId: number
color: string
}
/** Active trace point and its optional editable source. */
export interface GraphTrace extends GraphPoint {
color: string
source?:
| { kind: "expression"; rowId: number }
| { kind: "table"; rowId: number; columnId: number }
}
/** Maximum pointer distance in viewport pixels for selecting a graph point. */
export const GRAPH_POINT_HIT_RADIUS = 14
/** Maximum number of implicit-render cache entries retained by the graph canvas. */
export const IMPLICIT_CACHE_LIMIT = 24
/** Fixed graph-table index column width in pixels for canvas-independent table sizing. */
export const TABLE_INDEX_COLUMN_WIDTH = 36
/** Fixed editable graph-table value column width. */
export const TABLE_VALUE_COLUMN_WIDTH = 112
/** Fixed graph-table row-action column width. */
export const TABLE_ACTION_COLUMN_WIDTH = 32
/** Converts consumer graphing defaults into editable expression rows. */
export function createInitialGraphExpressionRows(
data: GraphingInitialData | undefined
): Array<GraphExpressionRow> {
return (data?.expressions ?? []).map((expression, index) => ({
id: index + 1,
text: expression.value,
color: expression.color ?? CALCULATOR_CHART_TOKENS[index % CALCULATOR_CHART_TOKENS.length],
visible: expression.visible ?? true,
slider: expression.slider
? { ...expression.slider, playing: expression.slider.playing ?? false }
: undefined,
}))
}
/** Converts consumer graphing defaults into editable multi-variable table state. */
export function createInitialGraphTableState(data: GraphingInitialData | undefined): {
columns: Array<GraphTableColumn>
rows: Array<GraphTableRow>
} {
const table = data?.table
const series = table?.series?.length
? table.series
: [{ label: "y₁", color: CALCULATOR_CHART_TOKENS[2], values: [] }]
const columns: Array<GraphTableColumn> = [
{ id: 1, label: table?.xLabel ?? "x₁", role: "x" },
...series.map((item, index) => ({
id: index + 2,
label: item.label ?? `y${String(index + 1)}`,
color: item.color ?? CALCULATOR_CHART_TOKENS[(index + 2) % CALCULATOR_CHART_TOKENS.length],
role: "y" as const,
})),
]
const rowCount = Math.max(
table?.xValues?.length ?? 0,
...series.map((item) => item.values.length)
)
const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({
id: rowIndex + 1,
values: Object.fromEntries([
[1, String(table?.xValues?.[rowIndex] ?? "")],
...series.map((item, seriesIndex) => [seriesIndex + 2, String(item.values[rowIndex] ?? "")]),
]),
}))
return { columns, rows }
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/graphing-mode.tsx
import { useEffect, useCallback, useMemo, useRef, useState } from "react"
import { readCanvasColor, resolveCanvasColor } from "@/components/mirai-calculator/canvas-theme"
import { CALCULATOR_CHART_TOKENS } from "@/components/mirai-calculator/calculator-ui-config"
import { GraphingCanvasViewport } from "@/components/mirai-calculator/modes/graphing-canvas-viewport"
import { GraphingSidebar } from "@/components/mirai-calculator/modes/graphing-sidebar"
import {
GRAPH_POINT_HIT_RADIUS,
IMPLICIT_CACHE_LIMIT,
TABLE_ACTION_COLUMN_WIDTH,
TABLE_INDEX_COLUMN_WIDTH,
TABLE_VALUE_COLUMN_WIDTH,
createInitialGraphExpressionRows,
createInitialGraphTableState,
} from "@/components/mirai-calculator/modes/graphing-state"
import type {
GraphExpressionRow,
GraphMarkCluster,
GraphTableColumn,
GraphTableRow,
GraphTrace,
TableGraphPoint,
} from "@/components/mirai-calculator/modes/graphing-state"
import { collectSliderVariables } from "@openmirai/calculator-core/configuration"
import { CalculatorEngine } from "@openmirai/calculator-core/engine"
import type { AngleMode } from "@openmirai/calculator-core/engine"
import {
clusterGraphPoints,
compileGraphExpression,
findExtrema,
findIntersections,
findRoots,
graphGridStep,
sampleExplicitGraphSegments,
sampleImplicitContourSegments,
} from "@openmirai/calculator-core/graphing"
import type { CompiledGraphExpression } from "@openmirai/calculator-core/graphing"
import type { GraphingInitialData } from "@openmirai/calculator-core/graphing-data"
import {
fitGraphViewToAspect,
graphPointToViewport,
panGraphView,
projectPointToGraphSegments,
viewportPointToGraph,
zoomGraphView,
} from "@openmirai/calculator-core/graphing-view"
import type { GraphPoint, GraphSegment, GraphView } from "@openmirai/calculator-core/graphing-view"
import { fitRegression } from "@openmirai/calculator-core/statistics"
type ExplicitYExpression = Extract<CompiledGraphExpression, { kind: "explicit" }> & { axis: "y" }
export interface GraphingModeProps {
angleMode: AngleMode
ans: number
definitions: Array<string>
formatNumber: (value: number) => string
onVariablesChange: (variables: Record<string, number>) => void
view: GraphView
onViewChange: (view: GraphView) => void
gridVisible: boolean
onGridVisibleChange: (visible: boolean) => void
colorScheme?: "light" | "dark"
defaultData?: GraphingInitialData
}
/** Renders the interactive graph workspace with expressions, tables, tracing, and analysis. */
export function GraphingMode({
angleMode,
ans,
definitions,
formatNumber: formatValue,
onVariablesChange,
view,
onViewChange,
gridVisible,
onGridVisibleChange,
colorScheme = "light",
defaultData,
}: GraphingModeProps) {
const [initialTable] = useState(() => createInitialGraphTableState(defaultData))
const [rows, setRows] = useState<Array<GraphExpressionRow>>(() =>
createInitialGraphExpressionRows(defaultData)
)
const rowsRef = useRef(rows)
const [tableColumns, setTableColumns] = useState<Array<GraphTableColumn>>(initialTable.columns)
const [table, setTable] = useState<Array<GraphTableRow>>(initialTable.rows)
const tableMinimumWidth =
TABLE_INDEX_COLUMN_WIDTH +
tableColumns.length * TABLE_VALUE_COLUMN_WIDTH +
TABLE_ACTION_COLUMN_WIDTH
const [trace, setTrace] = useState<GraphTrace | null>(null)
const [analysisOpen, setAnalysisOpen] = useState(false)
const [interacting, setInteracting] = useState(false)
const [graphSize, setGraphSize] = useState({ width: 0, height: 0 })
const nextRowId = useRef(rows.length + 1)
const nextTableRowId = useRef(table.length + 1)
const nextTableColumnId = useRef(tableColumns.length + 1)
const canvasRef = useRef<HTMLCanvasElement>(null)
const graphHostRef = useRef<HTMLDivElement>(null)
const [renderedSegmentsByRow] = useState(() => new Map<number, Array<GraphSegment>>())
const [implicitCache] = useState(
() =>
new Map<
number,
{
residual: (x: number, y: number) => number
key: string
segments: ReturnType<typeof sampleImplicitContourSegments>
}
>()
)
const interactionTimerRef = useRef<number | null>(null)
const dragRef = useRef<
| {
kind: "view"
pointerId: number
startX: number
startY: number
view: GraphView
renderedView: GraphView
moved: boolean
}
| { kind: "trace"; pointerId: number; rowId: number; color: string }
| { kind: "table"; pointerId: number; rowId: number; columnId: number; color: string }
| null
>(null)
const formatNumber = useCallback(
(value: number) => formatValue(Number.isFinite(value) ? Number(value.toPrecision(7)) : value),
[formatValue]
)
const commitRows = useCallback(
(
update:
| Array<GraphExpressionRow>
| ((current: Array<GraphExpressionRow>) => Array<GraphExpressionRow>)
) => {
const nextRows = typeof update === "function" ? update(rowsRef.current) : update
rowsRef.current = nextRows
setRows(nextRows)
onVariablesChange(
collectSliderVariables(
nextRows.map((row) => ({ expression: row.text, value: row.slider?.value }))
)
)
},
[onVariablesChange]
)
const tracePosition = useMemo(() => {
if (!trace || graphSize.width <= 0 || graphSize.height <= 0) {
return null
}
const renderedView = fitGraphViewToAspect(view, graphSize.width, graphSize.height)
const point = graphPointToViewport(trace, renderedView, graphSize.width, graphSize.height)
if (!point) {
return null
}
return {
...point,
labelOnLeft: trace.x > (renderedView.xmin + renderedView.xmax) / 2,
labelBelow: trace.y > renderedView.ymax - (renderedView.ymax - renderedView.ymin) * 0.12,
}
}, [graphSize.height, graphSize.width, trace, view])
const setView = useCallback(
(next: GraphView | ((current: GraphView) => GraphView)) => {
onViewChange(typeof next === "function" ? next(view) : next)
},
[onViewChange, view]
)
const variables = useMemo(
() =>
collectSliderVariables(
rows.map((row) => ({ expression: row.text, value: row.slider?.value }))
),
[rows]
)
const engine = useMemo(
() =>
new CalculatorEngine({
angleMode,
ans,
definitions,
variables,
}),
[angleMode, ans, definitions, variables]
)
const compiled = useMemo(
() =>
rows.map((row) => ({
row,
expression: compileGraphExpression(row.text, engine),
})),
[engine, rows]
)
const tableSeries = useMemo(() => {
const xColumn = tableColumns.find((column) => column.role === "x")
if (!xColumn) {
return []
}
return tableColumns
.filter(
(column): column is GraphTableColumn & { color: string } =>
column.role === "y" && !!column.color
)
.map((column) => {
const points = table.flatMap<TableGraphPoint>((row) => {
const x = Number(row.values[xColumn.id])
const y = Number(row.values[column.id])
return Number.isFinite(x) && Number.isFinite(y)
? [{ x, y, rowId: row.id, columnId: column.id, color: column.color }]
: []
})
return {
column,
points,
regression: fitRegression(
points.map((point) => point.x),
points.map((point) => point.y),
"linear"
),
}
})
}, [table, tableColumns])
const tablePoints = useMemo(() => tableSeries.flatMap((series) => series.points), [tableSeries])
const marks = useMemo<Array<GraphMarkCluster>>(() => {
if (!analysisOpen) {
return []
}
const visibleFunctions = compiled.filter(
(
item
): item is {
row: GraphExpressionRow
expression: ExplicitYExpression
} => item.row.visible && item.expression.kind === "explicit" && item.expression.axis === "y"
)
const result: Array<GraphPoint & { label: string; color: string }> = []
for (const item of visibleFunctions) {
try {
for (const x of findRoots(item.expression.evaluate, view.xmin, view.xmax)) {
result.push({ x, y: 0, label: "zero", color: item.row.color })
}
for (const point of findExtrema(item.expression.evaluate, view.xmin, view.xmax)) {
result.push({
x: point.x,
y: point.y,
label: point.kind,
color: item.row.color,
})
}
} catch {
// A partially defined function is still allowed to render where possible.
}
}
for (let left = 0; left < visibleFunctions.length; left += 1) {
for (let right = left + 1; right < visibleFunctions.length; right += 1) {
try {
for (const point of findIntersections(
visibleFunctions[left].expression.evaluate,
visibleFunctions[right].expression.evaluate,
view.xmin,
view.xmax
)) {
result.push({
...point,
label: "intersection",
color: CALCULATOR_CHART_TOKENS[4],
})
}
} catch {
// Ignore an invalid pair while keeping the remaining graph usable.
}
}
}
const tolerance = Math.max(view.xmax - view.xmin, view.ymax - view.ymin) * 1e-6
return clusterGraphPoints(result.slice(0, 50), tolerance).map(({ point, indexes }) => ({
...point,
labels: [...new Set(indexes.map((index) => result[index].label))],
colors: [...new Set(indexes.map((index) => result[index].color))],
count: indexes.length,
}))
}, [analysisOpen, compiled, view.xmax, view.xmin, view.ymax, view.ymin])
useEffect(() => {
const canvas = canvasRef.current
const host = graphHostRef.current
if (!canvas || !host) {
return
}
const draw = () => {
const bounds = host.getBoundingClientRect()
const width = Math.max(1, Math.floor(bounds.width))
const height = Math.max(1, Math.floor(bounds.height))
setGraphSize((current) =>
current.width === width && current.height === height ? current : { width, height }
)
const ratio = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = width * ratio
canvas.height = height * ratio
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const context = canvas.getContext("2d")
if (!context) {
return
}
context.setTransform(ratio, 0, 0, ratio, 0, 0)
const background = readCanvasColor(canvas, "--background")
const border = readCanvasColor(canvas, "--border")
const muted = readCanvasColor(canvas, "--muted-foreground")
const renderedView = fitGraphViewToAspect(view, width, height)
const xToCanvas = (x: number) =>
((x - renderedView.xmin) / (renderedView.xmax - renderedView.xmin)) * width
const yToCanvas = (y: number) =>
height - ((y - renderedView.ymin) / (renderedView.ymax - renderedView.ymin)) * height
const renderedSegments = new Map<number, Array<GraphSegment>>()
context.clearRect(0, 0, width, height)
context.fillStyle = background
context.fillRect(0, 0, width, height)
if (gridVisible) {
const xStep = graphGridStep(renderedView.xmax - renderedView.xmin)
const yStep = graphGridStep(renderedView.ymax - renderedView.ymin)
context.strokeStyle = border
context.lineWidth = 1
context.font = "10px system-ui, -apple-system, sans-serif"
context.fillStyle = muted
for (
let x = Math.ceil(renderedView.xmin / xStep) * xStep;
x <= renderedView.xmax;
x += xStep
) {
const pixel = xToCanvas(x)
context.beginPath()
context.moveTo(pixel, 0)
context.lineTo(pixel, height)
context.stroke()
if (Math.abs(x) > 1e-10) {
context.fillText(formatNumber(x), pixel + 4, yToCanvas(0) + 12)
}
}
for (
let y = Math.ceil(renderedView.ymin / yStep) * yStep;
y <= renderedView.ymax;
y += yStep
) {
const pixel = yToCanvas(y)
context.beginPath()
context.moveTo(0, pixel)
context.lineTo(width, pixel)
context.stroke()
if (Math.abs(y) > 1e-10) {
context.fillText(formatNumber(y), xToCanvas(0) + 5, pixel - 4)
}
}
}
context.strokeStyle = muted
context.lineWidth = 1.3
if (renderedView.ymin <= 0 && renderedView.ymax >= 0) {
context.beginPath()
context.moveTo(0, yToCanvas(0))
context.lineTo(width, yToCanvas(0))
context.stroke()
}
if (renderedView.xmin <= 0 && renderedView.xmax >= 0) {
context.beginPath()
context.moveTo(xToCanvas(0), 0)
context.lineTo(xToCanvas(0), height)
context.stroke()
}
for (const { row, expression } of compiled) {
if (!row.visible || expression.kind === "invalid") {
continue
}
const rowColor = resolveCanvasColor(canvas, row.color)
context.strokeStyle = rowColor
context.fillStyle = rowColor
context.lineWidth = 2.2
context.lineCap = "round"
context.lineJoin = "round"
if (expression.kind === "explicit") {
const segments = sampleExplicitGraphSegments(
expression.evaluate,
expression.axis,
renderedView,
width,
height
)
context.beginPath()
for (const segment of segments) {
const { from, to } = segment
context.moveTo(xToCanvas(from.x), yToCanvas(from.y))
context.lineTo(xToCanvas(to.x), yToCanvas(to.y))
}
context.stroke()
renderedSegments.set(row.id, segments)
} else if (expression.kind === "points") {
for (const point of expression.points) {
context.beginPath()
context.arc(xToCanvas(point.x), yToCanvas(point.y), 4.5, 0, Math.PI * 2)
context.fill()
}
} else if (expression.kind === "implicit") {
const columns = interacting
? Math.max(36, Math.min(72, Math.ceil(width / 14)))
: Math.max(48, Math.min(120, Math.ceil(width / 9)))
const rowsCount = interacting
? Math.max(32, Math.min(56, Math.ceil(height / 14)))
: Math.max(40, Math.min(96, Math.ceil(height / 9)))
const cacheKey = [
renderedView.xmin,
renderedView.xmax,
renderedView.ymin,
renderedView.ymax,
columns,
rowsCount,
].join(":")
const cached = implicitCache.get(row.id)
const segments =
cached?.residual === expression.residual && cached.key === cacheKey
? cached.segments
: sampleImplicitContourSegments(expression.residual, renderedView, columns, rowsCount)
if (segments !== cached?.segments) {
implicitCache.set(row.id, {
residual: expression.residual,
key: cacheKey,
segments,
})
if (implicitCache.size > IMPLICIT_CACHE_LIMIT) {
const oldestRowId = implicitCache.keys().next().value
if (typeof oldestRowId === "number") {
implicitCache.delete(oldestRowId)
}
}
}
const isolatedPoints: Array<GraphPoint> = []
context.beginPath()
for (const segment of segments) {
const fromX = xToCanvas(segment.from.x)
const fromY = yToCanvas(segment.from.y)
const toX = xToCanvas(segment.to.x)
const toY = yToCanvas(segment.to.y)
if (segment.from.x === segment.to.x && segment.from.y === segment.to.y) {
isolatedPoints.push({ x: fromX, y: fromY })
continue
}
context.moveTo(fromX, fromY)
context.lineTo(toX, toY)
}
context.stroke()
for (const point of isolatedPoints) {
context.beginPath()
context.arc(point.x, point.y, 2.2, 0, Math.PI * 2)
context.fill()
}
renderedSegments.set(row.id, segments)
}
}
renderedSegmentsByRow.clear()
for (const [rowId, segments] of renderedSegments) {
renderedSegmentsByRow.set(rowId, segments)
}
for (const point of tablePoints) {
context.fillStyle = resolveCanvasColor(canvas, point.color)
context.beginPath()
context.arc(xToCanvas(point.x), yToCanvas(point.y), 4.5, 0, Math.PI * 2)
context.fill()
}
for (const mark of marks) {
context.fillStyle = background
context.strokeStyle = resolveCanvasColor(
canvas,
mark.colors.at(-1) ?? CALCULATOR_CHART_TOKENS[4]
)
context.lineWidth = 2
context.beginPath()
context.arc(xToCanvas(mark.x), yToCanvas(mark.y), 5, 0, Math.PI * 2)
context.fill()
context.stroke()
}
}
let frame = window.requestAnimationFrame(draw)
const scheduleDraw = () => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(draw)
}
const observer = new ResizeObserver(scheduleDraw)
observer.observe(host)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
}
}, [
colorScheme,
compiled,
formatNumber,
gridVisible,
implicitCache,
interacting,
marks,
renderedSegmentsByRow,
tablePoints,
view,
])
const hasPlayingSliders = rows.some((row) => row.slider?.playing)
useEffect(() => {
if (!hasPlayingSliders) {
return
}
const timer = window.setInterval(() => {
commitRows((currentRows) =>
currentRows.map((current) => {
if (!current.slider?.playing) {
return current
}
const next = current.slider.value + current.slider.step
return {
...current,
slider: {
...current.slider,
value: next > current.slider.max ? current.slider.min : next,
},
}
})
)
}, 100)
return () => window.clearInterval(timer)
}, [commitRows, hasPlayingSliders])
const zoom = useCallback(
(factor: number) => {
setView((current) => zoomGraphView(current, factor))
},
[setView]
)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) {
return
}
const handleWheel = (event: WheelEvent) => {
event.preventDefault()
event.stopPropagation()
setInteracting(true)
if (interactionTimerRef.current !== null) {
window.clearTimeout(interactionTimerRef.current)
}
interactionTimerRef.current = window.setTimeout(() => {
setInteracting(false)
interactionTimerRef.current = null
}, 140)
zoom(event.deltaY > 0 ? 1.12 : 1 / 1.12)
}
canvas.addEventListener("wheel", handleWheel, { passive: false })
return () => {
canvas.removeEventListener("wheel", handleWheel)
if (interactionTimerRef.current !== null) {
window.clearTimeout(interactionTimerRef.current)
}
}
}, [zoom])
const graphCoordinates = (clientX: number, clientY: number): GraphPoint => {
const canvas = canvasRef.current
if (!canvas) {
return { x: view.xmin, y: view.ymax }
}
const bounds = canvas.getBoundingClientRect()
const renderedView = fitGraphViewToAspect(view, bounds.width, bounds.height)
return (
viewportPointToGraph(
{ x: clientX - bounds.left, y: clientY - bounds.top },
renderedView,
bounds.width,
bounds.height
) ?? { x: renderedView.xmin, y: renderedView.ymax }
)
}
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
event.currentTarget.setPointerCapture(event.pointerId)
const bounds = event.currentTarget.getBoundingClientRect()
const measuredSize = {
width: Math.max(1, Math.floor(bounds.width)),
height: Math.max(1, Math.floor(bounds.height)),
}
setGraphSize((current) =>
current.width === measuredSize.width && current.height === measuredSize.height
? current
: measuredSize
)
const renderedView = fitGraphViewToAspect(view, bounds.width, bounds.height)
const graphPoint = graphCoordinates(event.clientX, event.clientY)
const pointerX = event.clientX - bounds.left
const pointerY = event.clientY - bounds.top
const tablePoint = [...tablePoints].reverse().find((point) => {
const viewportPoint = graphPointToViewport(point, renderedView, bounds.width, bounds.height)
return (
viewportPoint !== null &&
Math.hypot(viewportPoint.x - pointerX, viewportPoint.y - pointerY) <= GRAPH_POINT_HIT_RADIUS
)
})
if (tablePoint) {
setTrace({
...tablePoint,
source: { kind: "table", rowId: tablePoint.rowId, columnId: tablePoint.columnId },
})
dragRef.current = {
kind: "table",
pointerId: event.pointerId,
rowId: tablePoint.rowId,
columnId: tablePoint.columnId,
color: tablePoint.color,
}
setInteracting(true)
return
}
let closestCurve: { row: GraphExpressionRow; point: GraphPoint } | null = null
let closestDistance = GRAPH_POINT_HIT_RADIUS + 1
for (const item of compiled) {
const { expression, row } = item
if (!row.visible || (expression.kind !== "explicit" && expression.kind !== "implicit")) {
continue
}
const renderedSegments = renderedSegmentsByRow.get(row.id)
const fallbackSegments =
expression.kind === "explicit"
? sampleExplicitGraphSegments(
expression.evaluate,
expression.axis,
renderedView,
bounds.width,
bounds.height
)
: []
if (!renderedSegments && fallbackSegments.length > 0) {
renderedSegmentsByRow.set(row.id, fallbackSegments)
}
const projection = projectPointToGraphSegments(
renderedSegments ?? fallbackSegments,
graphPoint,
renderedView,
bounds.width,
bounds.height
)
if (projection && projection.distance <= closestDistance) {
closestCurve = { row, point: projection.point }
closestDistance = projection.distance
}
}
if (closestCurve) {
setTrace({
...closestCurve.point,
color: closestCurve.row.color,
source: { kind: "expression", rowId: closestCurve.row.id },
})
dragRef.current = {
kind: "trace",
pointerId: event.pointerId,
rowId: closestCurve.row.id,
color: closestCurve.row.color,
}
setInteracting(true)
return
}
dragRef.current = {
kind: "view",
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
view,
renderedView,
moved: false,
}
setInteracting(true)
}
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current
const bounds = event.currentTarget.getBoundingClientRect()
if (!drag || drag.pointerId !== event.pointerId) {
return
}
event.preventDefault()
const point = graphCoordinates(event.clientX, event.clientY)
if (drag.kind === "trace") {
const renderedView = fitGraphViewToAspect(view, bounds.width, bounds.height)
const projection = projectPointToGraphSegments(
renderedSegmentsByRow.get(drag.rowId) ?? [],
point,
renderedView,
bounds.width,
bounds.height
)
if (projection) {
setTrace({
...projection.point,
color: drag.color,
source: { kind: "expression", rowId: drag.rowId },
})
}
return
}
if (drag.kind === "table") {
const xColumn = tableColumns.find((column) => column.role === "x")
if (!xColumn) {
return
}
const xValue = String(Number(point.x.toPrecision(10)))
const yValue = String(Number(point.y.toPrecision(10)))
setTable((current) =>
current.map((row) =>
row.id === drag.rowId
? {
...row,
values: {
...row.values,
[xColumn.id]: xValue,
[drag.columnId]: yValue,
},
}
: row
)
)
setTrace({
x: point.x,
y: point.y,
color: drag.color,
source: { kind: "table", rowId: drag.rowId, columnId: drag.columnId },
})
return
}
const dx = event.clientX - drag.startX
const dy = event.clientY - drag.startY
if (Math.abs(dx) + Math.abs(dy) > 3) {
drag.moved = true
}
setView(panGraphView(drag.view, drag.renderedView, dx, dy, bounds.width, bounds.height))
}
const handlePointerUp = (event: React.PointerEvent<HTMLCanvasElement>) => {
const completedDrag = dragRef.current
dragRef.current = null
if (completedDrag?.kind === "trace" || completedDrag?.kind === "table") {
setTrace(null)
}
setInteracting(false)
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
const updateRow = (id: number, patch: Partial<GraphExpressionRow>) => {
commitRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)))
}
const addExpression = () => {
const id = nextRowId.current
nextRowId.current += 1
commitRows((current) => [
...current,
{
id,
text: "",
color: CALCULATOR_CHART_TOKENS[(id - 1) % CALCULATOR_CHART_TOKENS.length],
visible: true,
},
])
}
const updateCell = (rowId: number, columnId: number, value: string) => {
setTable((current) =>
current.map((row) =>
row.id === rowId ? { ...row, values: { ...row.values, [columnId]: value } } : row
)
)
}
const addTableRow = () => {
const id = nextTableRowId.current
nextTableRowId.current += 1
setTable((current) => [
...current,
{ id, values: Object.fromEntries(tableColumns.map((column) => [column.id, ""])) },
])
}
const addTableVariable = () => {
const id = nextTableColumnId.current
nextTableColumnId.current += 1
const variableIndex = tableColumns.filter((column) => column.role === "y").length + 1
setTableColumns((current) => [
...current,
{
id,
label: `y${String(variableIndex)}`,
color: CALCULATOR_CHART_TOKENS[(variableIndex + 1) % CALCULATOR_CHART_TOKENS.length],
role: "y",
},
])
}
const removeTableVariable = (columnId: number) => {
setTableColumns((current) => current.filter((column) => column.id !== columnId))
setTable((current) =>
current.map((row) => {
const values = { ...row.values }
delete values[columnId]
return { ...row, values }
})
)
}
return (
<div className="mirai-graphing-layout grid min-h-0 flex-1 grid-cols-[340px_minmax(0,1fr)] overflow-hidden @max-[699px]:grid-cols-1 @max-[699px]:overflow-y-auto">
<GraphingSidebar
compiled={compiled}
rows={rows}
tableColumns={tableColumns}
table={table}
tableMinimumWidth={tableMinimumWidth}
tableSeries={tableSeries}
formatNumber={formatNumber}
updateRow={updateRow}
commitRows={commitRows}
addTableVariable={addTableVariable}
addTableRow={addTableRow}
setTable={setTable}
setTableColumns={setTableColumns}
removeTableVariable={removeTableVariable}
updateCell={updateCell}
addExpression={addExpression}
/>
<GraphingCanvasViewport
graphHostRef={graphHostRef}
canvasRef={canvasRef}
trace={trace}
tracePosition={tracePosition}
handlePointerDown={handlePointerDown}
handlePointerMove={handlePointerMove}
handlePointerUp={handlePointerUp}
formatNumber={formatNumber}
zoom={zoom}
setView={setView}
gridVisible={gridVisible}
onGridVisibleChange={onGridVisibleChange}
analysisOpen={analysisOpen}
setAnalysisOpen={setAnalysisOpen}
marks={marks}
setTrace={setTrace}
/>
</div>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/scientific-key-label.tsx
import type { ScientificKeyDefinition } from "@/components/mirai-calculator/modes/scientific-keypad-config"
/** Renders structured keypad notation with the host application's mathematical font fallback. */
export function ScientificKeyLabel({ definition }: { definition: ScientificKeyDefinition }) {
const notation = definition.notation
if (!notation) {
return definition.label
}
return (
<span
aria-hidden="true"
data-math-notation=""
className="inline-flex items-baseline font-[math,var(--font-sans,system-ui),sans-serif]! leading-none"
>
{notation.kind === "fraction" && (
<span data-math-fraction="">
{notation.numerator}⁄{notation.denominator}
</span>
)}
{notation.kind === "root" && (
<span>
<sup className="mr-px leading-none">{notation.index}</sup>√{notation.radicand}
</span>
)}
{notation.kind === "subscript" && (
<span>
{notation.base}
<sub className="leading-none">{notation.script}</sub>
</span>
)}
{notation.kind === "superscript" && (
<span>
{notation.base}
<sup className="leading-none">{notation.script}</sup>
</span>
)}
</span>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/scientific-keypad.tsx
import { ScientificKeyLabel } from "@/components/mirai-calculator/modes/scientific-key-label"
import {
SCIENTIFIC_KEY_SETS,
getScientificKeyLabelSize,
} from "@/components/mirai-calculator/modes/scientific-keypad-config"
import type {
ScientificKeyDefinition,
ScientificKeypadTab,
} from "@/components/mirai-calculator/modes/scientific-keypad-config"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { cn } from "@/lib/utils"
interface ScientificKeypadProps {
tab: ScientificKeypadTab
onTabChange: (tab: ScientificKeypadTab) => void
onPressKey: (key: ScientificKeyDefinition) => void
}
/** Renders the scientific keypad tabs and their calculator keys. */
export function ScientificKeypad({ tab, onTabChange, onPressKey }: ScientificKeypadProps) {
return (
<Tabs
value={tab}
onValueChange={(value) => {
switch (value) {
case "basic":
case "functions":
case "trig":
case "stats":
case "variables":
onTabChange(value)
break
}
}}
className="min-h-0 flex-1 gap-0"
>
<TabsList
variant="line"
className="mirai-keypad-tabs-list h-10! w-full flex-none justify-start gap-1 overflow-x-auto overflow-y-hidden overscroll-x-contain scroll-px-4 rounded-none border-y bg-muted/45 px-4 py-0"
>
<TabsTrigger
className="mirai-keypad-tab h-10 flex-none rounded-none bg-transparent! px-3 text-sm font-semibold text-muted-foreground shadow-none! hover:bg-transparent! data-active:bg-transparent! data-active:text-primary after:right-0 after:-bottom-px! after:left-0 after:h-0.5! after:bg-primary!"
value="basic"
>
Basic
</TabsTrigger>
<TabsTrigger
className="mirai-keypad-tab h-10 flex-none rounded-none bg-transparent! px-3 text-sm font-semibold text-muted-foreground shadow-none! hover:bg-transparent! data-active:bg-transparent! data-active:text-primary after:right-0 after:-bottom-px! after:left-0 after:h-0.5! after:bg-primary!"
value="functions"
>
Functions
</TabsTrigger>
<TabsTrigger
className="mirai-keypad-tab h-10 flex-none rounded-none bg-transparent! px-3 text-sm font-semibold text-muted-foreground shadow-none! hover:bg-transparent! data-active:bg-transparent! data-active:text-primary after:right-0 after:-bottom-px! after:left-0 after:h-0.5! after:bg-primary!"
value="trig"
>
Trig
</TabsTrigger>
<TabsTrigger
className="mirai-keypad-tab h-10 flex-none rounded-none bg-transparent! px-3 text-sm font-semibold text-muted-foreground shadow-none! hover:bg-transparent! data-active:bg-transparent! data-active:text-primary after:right-0 after:-bottom-px! after:left-0 after:h-0.5! after:bg-primary!"
value="stats"
>
Stats
</TabsTrigger>
<TabsTrigger
className="mirai-keypad-tab h-10 flex-none rounded-none bg-transparent! px-3 text-sm font-semibold text-muted-foreground shadow-none! hover:bg-transparent! data-active:bg-transparent! data-active:text-primary after:right-0 after:-bottom-px! after:left-0 after:h-0.5! after:bg-primary!"
value="variables"
>
Variables
</TabsTrigger>
</TabsList>
<div className="grid min-h-61.5 flex-1 grid-cols-6 auto-rows-fr gap-2 p-4">
{SCIENTIFIC_KEY_SETS[tab].map((key) => {
const Icon = key.icon
return (
<Button
key={key.ariaLabel ?? key.label}
variant={key.tone === "primary" ? "default" : "outline"}
onClick={() => onPressKey(key)}
aria-label={key.ariaLabel ?? key.label}
data-key-label-size={key.icon ? "single" : getScientificKeyLabelSize(key.label)}
className={cn(
"mirai-scientific-key h-auto min-h-11 min-w-0 overflow-hidden rounded-[6px] px-[clamp(0.125rem,1cqi,0.75rem)] font-sans text-sm leading-tight font-medium whitespace-nowrap data-[key-label-size=single]:text-lg data-[key-label-size=short]:text-base data-[key-label-size=medium]:text-sm data-[key-label-size=long]:px-px data-[key-label-size=long]:text-xs",
key.tone === "operator" &&
"border-primary/25 bg-primary/5 text-primary hover:bg-primary/10",
key.span === 2 && "col-span-2"
)}
>
{Icon ? <Icon aria-hidden="true" /> : <ScientificKeyLabel definition={key} />}
</Button>
)
})}
</div>
</Tabs>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/scientific-keypad-config.ts
import { Delete } from "lucide-react"
import type { LucideIcon } from "lucide-react"
export interface ScientificKeyDefinition {
label: string
notation?: ScientificKeyNotation
icon?: LucideIcon
token?: string
action?: "absolute" | "backspace" | "clear" | "evaluate" | "left" | "right"
tone?: "default" | "operator" | "primary"
ariaLabel?: string
span?: 2
}
type ScientificKeyNotation =
| { kind: "fraction"; numerator: string; denominator: string }
| { kind: "root"; index: string; radicand: string }
| { kind: "subscript"; base: string; script: string }
| { kind: "superscript"; base: string; script: string }
const BASIC_KEYS: Array<ScientificKeyDefinition> = [
{ label: "(" },
{ label: ")" },
{ label: "%", ariaLabel: "Percent" },
{ label: "÷", tone: "operator", ariaLabel: "Divide" },
{
label: "Backspace",
icon: Delete,
action: "backspace",
ariaLabel: "Backspace",
},
{ label: "C", action: "clear", ariaLabel: "Clear" },
{ label: "7" },
{ label: "8" },
{ label: "9" },
{ label: "×", tone: "operator", ariaLabel: "Multiply" },
{ label: "x²", token: "²", notation: { kind: "superscript", base: "x", script: "2" } },
{ label: "√", token: "√(" },
{ label: "4" },
{ label: "5" },
{ label: "6" },
{ label: "−", tone: "operator", ariaLabel: "Subtract" },
{ label: "xʸ", token: "^", notation: { kind: "superscript", base: "x", script: "y" } },
{ label: "π", token: "π" },
{ label: "1" },
{ label: "2" },
{ label: "3" },
{ label: "+", tone: "operator", ariaLabel: "Add" },
{ label: "|x|", action: "absolute", ariaLabel: "Absolute value" },
{ label: "e", token: "e" },
{ label: "±", token: "−", ariaLabel: "Toggle sign" },
{ label: "0" },
{ label: "." },
{
label: "a⁄b",
token: "÷",
ariaLabel: "Fraction",
notation: { kind: "fraction", numerator: "a", denominator: "b" },
},
{
label: "=",
action: "evaluate",
tone: "primary",
ariaLabel: "Equals",
span: 2,
},
]
const FUNCTION_KEYS: Array<ScientificKeyDefinition> = [
{ label: "x²", token: "²", notation: { kind: "superscript", base: "x", script: "2" } },
{ label: "x³", token: "³", notation: { kind: "superscript", base: "x", script: "3" } },
{ label: "xʸ", token: "^", notation: { kind: "superscript", base: "x", script: "y" } },
{ label: "√", token: "√(" },
{ label: "∛", token: "∛(" },
{
label: "1/x",
token: "1÷(",
notation: { kind: "fraction", numerator: "1", denominator: "x" },
},
{ label: "|x|", action: "absolute", ariaLabel: "Absolute value" },
{
label: "10ˣ",
token: "10^(",
notation: { kind: "superscript", base: "10", script: "x" },
},
{ label: "eˣ", token: "exp(", notation: { kind: "superscript", base: "e", script: "x" } },
{ label: "log", token: "log(" },
{ label: "ln", token: "ln(" },
{
label: "logᵦ",
token: "logb(",
ariaLabel: "Log base",
notation: { kind: "subscript", base: "log", script: "b" },
},
{
label: "n√x",
token: "nthroot(",
ariaLabel: "Nth root",
notation: { kind: "root", index: "n", radicand: "x" },
},
{ label: "x!", token: "!" },
{ label: "%", token: "%" },
{ label: "abs", token: "abs(" },
{ label: "floor", token: "floor(" },
{ label: "ceil", token: "ceil(" },
{ label: "round", token: "round(" },
{ label: "mod", token: "mod(" },
]
const TRIG_KEYS: Array<ScientificKeyDefinition> = [
{ label: "sin", token: "sin(" },
{ label: "cos", token: "cos(" },
{ label: "tan", token: "tan(" },
{ label: "sec", token: "sec(" },
{ label: "csc", token: "csc(" },
{ label: "cot", token: "cot(" },
{
label: "sin⁻¹",
token: "asin(",
notation: { kind: "superscript", base: "sin", script: "−1" },
},
{
label: "cos⁻¹",
token: "acos(",
notation: { kind: "superscript", base: "cos", script: "−1" },
},
{
label: "tan⁻¹",
token: "atan(",
notation: { kind: "superscript", base: "tan", script: "−1" },
},
{ label: "sinh", token: "sinh(" },
{ label: "cosh", token: "cosh(" },
{ label: "tanh", token: "tanh(" },
{
label: "π/2",
token: "π÷2",
notation: { kind: "fraction", numerator: "π", denominator: "2" },
},
{
label: "π/3",
token: "π÷3",
notation: { kind: "fraction", numerator: "π", denominator: "3" },
},
{
label: "π/4",
token: "π÷4",
notation: { kind: "fraction", numerator: "π", denominator: "4" },
},
{
label: "π/6",
token: "π÷6",
notation: { kind: "fraction", numerator: "π", denominator: "6" },
},
{ label: "deg→rad", token: "×π÷180" },
{ label: "rad→deg", token: "×180÷π" },
{ label: "(", token: "(" },
{ label: ")", token: ")" },
]
const STATS_KEYS: Array<ScientificKeyDefinition> = [
{ label: "[ ]", token: "[" },
{ label: ",", token: "," },
{ label: "mean", token: "mean([" },
{ label: "median", token: "median([" },
{ label: "mode", token: "mode([" },
{ label: "Σ", token: "sum([" },
{ label: "min", token: "min([" },
{ label: "max", token: "max([" },
{ label: "range", token: "range([" },
{
label: "Q₁",
token: "quartile([",
notation: { kind: "subscript", base: "Q", script: "1" },
},
{ label: "σ", token: "stdevp([" },
{ label: "s", token: "stdev([" },
{ label: "variance", token: "var([" },
{ label: "IQR", token: "iqr([" },
{ label: "sort", token: "sort([" },
{ label: "nCr", token: "ncr(" },
{ label: "nPr", token: "npr(" },
{ label: "gcd", token: "gcd(" },
{ label: "lcm", token: "lcm(" },
{ label: "corr", token: "corr([" },
]
const VARIABLE_KEYS: Array<ScientificKeyDefinition> = [
{ label: "a" },
{ label: "b" },
{ label: "c" },
{ label: "d" },
{ label: "x" },
{ label: "y" },
{ label: "n" },
{ label: "r" },
{ label: "t" },
{ label: "Ans", token: "Ans" },
{ label: "π", token: "π" },
{ label: "e", token: "e" },
{ label: "=", token: "=" },
{ label: "<", token: "<" },
{ label: ">", token: ">" },
{ label: "{ }", token: "{" },
{ label: ":", token: ":" },
{ label: "[ ]", token: "[" },
{ label: "(", token: "(" },
{ label: ")", token: ")" },
]
export const SCIENTIFIC_KEY_SETS = {
basic: BASIC_KEYS,
functions: FUNCTION_KEYS,
trig: TRIG_KEYS,
stats: STATS_KEYS,
variables: VARIABLE_KEYS,
}
/** Available scientific keypad categories. */
export type ScientificKeypadTab = keyof typeof SCIENTIFIC_KEY_SETS
/** Returns a label-length bucket used to fit keypad text within compact buttons. */
export function getScientificKeyLabelSize(label: string) {
const length = Array.from(label).length
if (length === 1) {
return "single"
}
if (length <= 3) {
return "short"
}
if (length <= 5) {
return "medium"
}
return "long"
}
export const SCIENTIFIC_EXAMPLES = [
{ expression: "(2÷3)+(1÷6)", label: "Add fractions" },
{ expression: "sin(30)", label: "sin(30°)" },
{ expression: "10ncr3", label: "10 choose 3" },
{ expression: "logb(2,32)", label: "log₂ 32" },
{ expression: "−3^2", label: "Order of operations" },
{ expression: "f(4)", label: "Use f(x)" },
]
../../packages/calculator-registry/src/components/mirai-calculator/modes/scientific-mode.tsx
import { useMemo, useRef, useState } from "react"
import { Redo2, Undo2 } from "lucide-react"
import { ScientificKeypad } from "@/components/mirai-calculator/modes/scientific-keypad"
import type {
ScientificKeyDefinition,
ScientificKeypadTab,
} from "@/components/mirai-calculator/modes/scientific-keypad-config"
import { ScientificSidebar } from "@/components/mirai-calculator/modes/scientific-sidebar"
import type { ScientificHistoryEntry } from "@/components/mirai-calculator/modes/scientific-sidebar"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { completeExpression, formatExpressionInput } from "@openmirai/calculator-core/engine"
import type { CalculatorEngine, NumberFormatOptions } from "@openmirai/calculator-core/engine"
import { cn } from "@/lib/utils"
export interface ScientificModeProps {
engine: CalculatorEngine
definitions: Array<string>
onDefinitionsChange: (definitions: Array<string>) => void
onAnsChange: (answer: number) => void
formatOptions: NumberFormatOptions
}
/** Renders the scientific expression workspace and calculator keypad. */
export function ScientificMode({
engine,
definitions,
onDefinitionsChange,
onAnsChange,
formatOptions,
}: ScientificModeProps) {
const [expression, setExpression] = useState("")
const [tab, setTab] = useState<ScientificKeypadTab>("basic")
const [history, setHistory] = useState<Array<ScientificHistoryEntry>>([])
const [fractionView, setFractionView] = useState(false)
const [undoStack, setUndoStack] = useState<Array<string>>([])
const [redoStack, setRedoStack] = useState<Array<string>>([])
const inputRef = useRef<HTMLInputElement>(null)
const nextHistoryIdRef = useRef(0)
const liveResult = useMemo(() => {
if (!expression.trim()) {
return null
}
try {
const value = engine.evaluate(completeExpression(expression))
const fraction = fractionView && typeof value === "number" ? engine.toFraction(value) : null
return {
value,
text: fraction ?? formatExpressionInput(engine.format(value, formatOptions)),
note: fraction ? "exact fraction" : "=",
error: false,
}
} catch (error) {
return {
value: undefined,
text: error instanceof Error ? error.message : "Invalid expression",
note: "",
error: true,
}
}
}, [engine, expression, formatOptions, fractionView])
const updateExpression = (next: string) => {
setUndoStack((stack) => [expression, ...stack].slice(0, 50))
setRedoStack([])
setExpression(next)
}
const updateTypedExpression = (next: string, selectionStart: number, selectionEnd: number) => {
const formatted = formatExpressionInput(next)
const formattedStart = formatExpressionInput(next.slice(0, selectionStart)).length
const formattedEnd = formatExpressionInput(next.slice(0, selectionEnd)).length
updateExpression(formatted)
requestAnimationFrame(() => {
inputRef.current?.setSelectionRange(formattedStart, formattedEnd)
})
}
const insert = (token: string) => {
const input = inputRef.current
const start = input?.selectionStart ?? expression.length
const end = input?.selectionEnd ?? start
const next = expression.slice(0, start) + token + expression.slice(end)
updateExpression(next)
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.setSelectionRange(start + token.length, start + token.length)
})
}
const removePreviousCharacter = () => {
const input = inputRef.current
const start = input?.selectionStart ?? expression.length
const end = input?.selectionEnd ?? start
if (start === 0 && end === 0) {
return
}
const next =
start === end
? expression.slice(0, start - 1) + expression.slice(end)
: expression.slice(0, start) + expression.slice(end)
updateExpression(next)
const cursor = start === end ? start - 1 : start
requestAnimationFrame(() => inputRef.current?.setSelectionRange(cursor, cursor))
}
const insertAbsoluteValue = () => {
const input = inputRef.current
const start = input?.selectionStart ?? expression.length
const end = input?.selectionEnd ?? start
const selected = expression.slice(start, end)
const wrapped = `|${selected}|`
const next = expression.slice(0, start) + wrapped + expression.slice(end)
updateExpression(next)
const cursor = selected ? start + wrapped.length : start + 1
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.setSelectionRange(cursor, cursor)
})
}
const commit = () => {
if (!expression.trim()) {
return
}
const completedExpression = completeExpression(expression)
if (completedExpression !== expression) {
setExpression(completedExpression)
}
try {
const value = engine.evaluate(completedExpression)
const result = formatExpressionInput(engine.format(value, formatOptions))
if (typeof value === "number") {
onAnsChange(value)
}
nextHistoryIdRef.current += 1
const id = nextHistoryIdRef.current
setHistory((entries) => {
const current = entries[0]
if (
current?.expression === completedExpression &&
current.result === result &&
!current.error
) {
return entries
}
return [
{
id,
expression: completedExpression,
result,
value: typeof value === "number" ? value : undefined,
},
...entries,
].slice(0, 40)
})
} catch (error) {
const result = error instanceof Error ? error.message : "Invalid expression"
nextHistoryIdRef.current += 1
const id = nextHistoryIdRef.current
setHistory((entries) => {
const current = entries[0]
if (
current?.expression === completedExpression &&
current.result === result &&
current.error
) {
return entries
}
return [
{
id,
expression: completedExpression,
result,
error: true,
},
...entries,
].slice(0, 40)
})
}
}
const pressKey = (key: ScientificKeyDefinition) => {
if (key.action === "absolute") {
return insertAbsoluteValue()
}
if (key.action === "backspace") {
return removePreviousCharacter()
}
if (key.action === "clear") {
return updateExpression("")
}
if (key.action === "evaluate") {
return commit()
}
insert(key.token ?? key.label)
}
const undo = () => {
const previous = undoStack[0]
if (previous === undefined) {
return
}
setRedoStack((stack) => [expression, ...stack])
setUndoStack((stack) => stack.slice(1))
setExpression(previous)
}
const redo = () => {
const next = redoStack[0]
if (next === undefined) {
return
}
setUndoStack((stack) => [expression, ...stack])
setRedoStack((stack) => stack.slice(1))
setExpression(next)
}
return (
<div className="mirai-scientific-layout grid min-h-0 flex-1 grid-cols-[minmax(0,1.65fr)_minmax(300px,0.85fr)] overflow-hidden @max-[699px]:grid-cols-1 @max-[699px]:overflow-y-auto">
<section className="mirai-scientific-primary flex min-h-0 min-w-0 flex-col overflow-hidden border-r @max-[699px]:min-h-112.5 @max-[699px]:border-r-0 @max-[699px]:border-b">
<div className="flex min-h-33 shrink-0 flex-col justify-center gap-2 px-6 py-4">
<label className="sr-only" htmlFor="mirai-expression">
Calculator expression
</label>
<Input
ref={inputRef}
id="mirai-expression"
value={expression}
onChange={(event) =>
updateTypedExpression(
event.target.value,
event.target.selectionStart ?? event.target.value.length,
event.target.selectionEnd ?? event.target.value.length
)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
commit()
}
}}
placeholder="Type or tap to start"
autoComplete="off"
spellCheck={false}
className="h-auto border-0 bg-transparent px-0 text-right font-[math,var(--font-sans,system-ui),sans-serif]! text-3xl! font-semibold tracking-[-0.02em] shadow-none placeholder:font-sans focus-visible:ring-0 dark:bg-transparent sm:text-4xl!"
/>
<div
aria-live="polite"
className={cn(
"flex min-h-9 items-center justify-end gap-2 text-right",
liveResult?.error && "text-destructive"
)}
>
{liveResult?.note && (
<span className="text-xs text-muted-foreground">{liveResult.note}</span>
)}
<span
className={cn(
"font-[math,var(--font-sans,system-ui),sans-serif]! text-2xl font-semibold tracking-[-0.015em] text-primary sm:text-3xl",
liveResult?.error && "max-w-full text-lg leading-tight text-destructive sm:text-xl"
)}
>
{liveResult?.text ?? " "}
</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
variant={fractionView ? "secondary" : "outline"}
size="sm"
onClick={() => setFractionView((visible) => !visible)}
>
{fractionView ? "Decimal" : "Fraction"}
</Button>
<Button variant="outline" size="sm" onClick={() => insert("Ans")}>
Use Ans
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={undo}
disabled={undoStack.length === 0}
aria-label="Undo expression edit"
>
<Undo2 />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={redo}
disabled={redoStack.length === 0}
aria-label="Redo expression edit"
>
<Redo2 />
</Button>
</div>
</div>
<ScientificKeypad tab={tab} onTabChange={setTab} onPressKey={pressKey} />
</section>
<ScientificSidebar
definitions={definitions}
onDefinitionsChange={onDefinitionsChange}
history={history}
onHistoryClear={() => setHistory([])}
onExpressionReuse={updateExpression}
/>
</div>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/scientific-sidebar.tsx
import { useRef } from "react"
import { X } from "lucide-react"
import { CalculatorEmptyStateAction } from "@/components/mirai-calculator/calculator-empty-state-action"
import { SCIENTIFIC_EXAMPLES } from "@/components/mirai-calculator/modes/scientific-keypad-config"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { formatExpressionInput } from "@openmirai/calculator-core/engine"
import { cn } from "@/lib/utils"
/** Completed scientific calculation shown in the local history panel. */
export interface ScientificHistoryEntry {
id: number
expression: string
result: string
value?: number
error?: boolean
}
interface ScientificSidebarProps {
definitions: Array<string>
onDefinitionsChange: (definitions: Array<string>) => void
history: Array<ScientificHistoryEntry>
onHistoryClear: () => void
onExpressionReuse: (expression: string) => void
}
/** Renders reusable definitions, calculation history, and scientific examples. */
export function ScientificSidebar({
definitions,
onDefinitionsChange,
history,
onHistoryClear,
onExpressionReuse,
}: ScientificSidebarProps) {
const definitionIdsRef = useRef<Array<number>>([])
const nextDefinitionIdRef = useRef(0)
while (definitionIdsRef.current.length < definitions.length) {
nextDefinitionIdRef.current += 1
definitionIdsRef.current.push(nextDefinitionIdRef.current)
}
definitionIdsRef.current.length = definitions.length
const addDefinition = () => {
nextDefinitionIdRef.current += 1
definitionIdsRef.current.push(nextDefinitionIdRef.current)
onDefinitionsChange([...definitions, ""])
}
const removeDefinition = (index: number) => {
definitionIdsRef.current.splice(index, 1)
onDefinitionsChange(definitions.filter((_, current) => current !== index))
}
const updateDefinition = (index: number, value: string) => {
onDefinitionsChange(
definitions.map((definition, current) => (current === index ? value : definition))
)
}
return (
<aside className="mirai-scientific-secondary flex min-h-0 flex-col bg-muted/25 @max-[699px]:min-h-75">
<ScrollArea className="min-h-0 flex-1">
<section className="border-b p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Definitions
</h3>
<Button
variant="ghost"
size="sm"
onClick={addDefinition}
className="h-6 px-1.5 text-xs text-primary"
>
Add
</Button>
</div>
<div className="space-y-2.5">
{definitions.map((definition, index) => (
<div className="space-y-0.5" key={definitionIdsRef.current[index]}>
<div className="flex items-center gap-1.5">
<Input
value={definition}
onChange={(event) => updateDefinition(index, event.target.value)}
placeholder="a = 5"
aria-label={`Definition ${index + 1}`}
className="h-8 rounded-md bg-background px-2.5 font-sans text-sm font-medium"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => removeDefinition(index)}
aria-label={`Remove definition ${index + 1}`}
className="text-muted-foreground hover:text-destructive"
>
<X />
</Button>
</div>
<p className="pl-0.5 text-xs text-muted-foreground">
Reusable in calculations and graphs
</p>
</div>
))}
{definitions.length === 0 && (
<p className="py-2 text-xs text-muted-foreground">
<CalculatorEmptyStateAction label="Add definition" onClick={addDefinition} /> a
variable or function definition.
</p>
)}
</div>
</section>
<section className="min-h-47.5 p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
History
</h3>
{history.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onHistoryClear}
className="h-6 px-1.5 text-xs text-primary"
>
Clear
</Button>
)}
</div>
<div className="space-y-2">
{history.map((entry) => (
<Button
key={entry.id}
variant="outline"
onClick={() => onExpressionReuse(entry.expression)}
className="h-auto w-full flex-col items-stretch gap-0.5 rounded-lg px-3 py-2.5 text-left"
>
<span className="break-all text-xs text-muted-foreground">
<span className="font-[math,var(--font-sans,system-ui),sans-serif]!">
{entry.expression}
</span>
</span>
<span
className={cn(
"font-[math,var(--font-sans,system-ui),sans-serif]! text-lg font-semibold",
entry.error && "text-destructive"
)}
>
{entry.result}
</span>
</Button>
))}
{history.length === 0 && (
<p className="px-3 py-5 text-center text-sm leading-5 text-muted-foreground">
Your calculations appear here. Tap one to reuse it.
</p>
)}
</div>
</section>
</ScrollArea>
<section className="shrink-0 border-t p-3">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Try this
</h3>
<div className="flex flex-wrap gap-1.5">
{SCIENTIFIC_EXAMPLES.map((example) => (
<Button
key={example.expression}
variant="outline"
size="sm"
onClick={() => onExpressionReuse(formatExpressionInput(example.expression))}
className="h-6.5 rounded-full px-2.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
>
{example.label}
</Button>
))}
</div>
</section>
<p className="shrink-0 border-t px-4 py-3 text-xs leading-5 text-muted-foreground">
Type with your keyboard too—<strong>sin</strong>, <strong>sqrt</strong>, <strong>^</strong>,
Enter to evaluate.
</p>
</aside>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/statistics-config.ts
import type { RegressionModel } from "@openmirai/calculator-core/statistics"
/** Chart variants available in the statistics workspace. */
export type StatisticsChartType = "scatter" | "histogram" | "box" | "dot" | "residual"
/** User-facing statistics chart options. */
export const STATISTICS_CHART_OPTIONS: Array<{
value: StatisticsChartType
label: string
}> = [
{ value: "scatter", label: "Scatter" },
{ value: "histogram", label: "Histogram" },
{ value: "box", label: "Box plot" },
{ value: "dot", label: "Dot plot" },
{ value: "residual", label: "Residuals" },
]
/** User-facing regression model options. */
export const STATISTICS_REGRESSION_OPTIONS: Array<{
value: RegressionModel
label: string
}> = [
{ value: "linear", label: "Linear" },
{ value: "quadratic", label: "Quadratic" },
{ value: "cubic", label: "Cubic" },
{ value: "exponential", label: "Exponential" },
{ value: "logarithmic", label: "Logarithmic" },
{ value: "power", label: "Power" },
]
../../packages/calculator-registry/src/components/mirai-calculator/modes/statistics-mode.tsx
import { useCallback, useMemo, useRef, useState } from "react"
import { BarChart3, LineChart } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { useStatisticsChart } from "@/components/mirai-calculator/modes/use-statistics-chart"
import {
STATISTICS_CHART_OPTIONS,
STATISTICS_REGRESSION_OPTIONS,
} from "@/components/mirai-calculator/modes/statistics-config"
import type { StatisticsChartType } from "@/components/mirai-calculator/modes/statistics-config"
import {
calculateStatistics,
correlation,
covariance,
fitRegression,
} from "@openmirai/calculator-core/statistics"
import type { RegressionModel } from "@openmirai/calculator-core/statistics"
import {
pairNumberSeries,
parseNumberList,
serializeNumberList,
} from "@openmirai/calculator-core/statistics-data"
import type { StatisticsInitialData } from "@openmirai/calculator-core/statistics-data"
import { cn } from "@/lib/utils"
export interface StatisticsModeProps {
formatNumber: (value: number) => string
colorScheme?: "light" | "dark"
defaultData?: StatisticsInitialData
}
/** Renders descriptive statistics, regression, and distribution tools. */
export function StatisticsMode({
formatNumber: formatValue,
colorScheme = "light",
defaultData,
}: StatisticsModeProps) {
const [xSource, setXSource] = useState(() => serializeNumberList(defaultData?.xValues))
const [ySource, setYSource] = useState(() => serializeNumberList(defaultData?.yValues))
const [chart, setChart] = useState<StatisticsChartType>("scatter")
const [model, setModel] = useState<RegressionModel>("linear")
const canvasRef = useRef<HTMLCanvasElement>(null)
const canvasHostRef = useRef<HTMLDivElement>(null)
const formatNumber = useCallback(
(value: number) => formatValue(Number.isFinite(value) ? Number(value.toPrecision(8)) : value),
[formatValue]
)
const xValues = useMemo(() => parseNumberList(xSource), [xSource])
const yValues = useMemo(() => parseNumberList(ySource), [ySource])
const statistics = useMemo(
() => (xValues.length ? calculateStatistics(xValues) : null),
[xValues]
)
const regression = useMemo(
() => fitRegression(xValues, yValues, model),
[model, xValues, yValues]
)
const pairedValues = useMemo(() => pairNumberSeries(xValues, yValues), [xValues, yValues])
const { correlationValue, covarianceValue } = useMemo(() => {
try {
return {
correlationValue: correlation(pairedValues.x, pairedValues.y),
covarianceValue: covariance(pairedValues.x, pairedValues.y),
}
} catch {
return { correlationValue: Number.NaN, covarianceValue: Number.NaN }
}
}, [pairedValues])
const residualRows = useMemo(() => {
const occurrences = new Map<string, number>()
return regression.residuals.slice(0, 12).map((residual, index) => {
const x = pairedValues.x[index]
const y = pairedValues.y[index]
const signature = `${x}:${y}:${residual}`
const occurrence = (occurrences.get(signature) ?? 0) + 1
occurrences.set(signature, occurrence)
return { key: `${signature}:${occurrence}`, residual, x }
})
}, [pairedValues, regression.residuals])
useStatisticsChart({
canvasRef,
canvasHostRef,
chart,
colorScheme,
formatNumber,
pairedValues,
regression,
statistics,
xValues,
})
const statRows = statistics
? [
["n", statistics.n],
["Sum", statistics.sum],
["Mean", statistics.mean],
["Median", statistics.median],
["Mode", statistics.mode],
["Min", statistics.min],
["Q1", statistics.q1],
["Q3", statistics.q3],
["Max", statistics.max],
["Range", statistics.range],
["IQR", statistics.iqr],
["σ (population)", statistics.populationStandardDeviation],
["s (sample)", statistics.sampleStandardDeviation],
["σ²", statistics.populationVariance],
["s²", statistics.sampleVariance],
]
: []
return (
<div className="mirai-statistics-layout grid min-h-0 flex-1 grid-cols-[340px_minmax(0,1fr)] overflow-hidden @max-[699px]:grid-cols-1 @max-[699px]:overflow-y-auto">
<aside className="mirai-statistics-sidebar min-h-0 border-r bg-muted/25 @max-[699px]:min-h-105 @max-[699px]:border-r-0 @max-[699px]:border-b">
<ScrollArea className="h-full">
<div className="space-y-5 p-5">
<div>
<h2 className="text-sm font-semibold">Data lists</h2>
<p className="text-xs text-muted-foreground">
Separate values with commas, spaces, or new lines.
</p>
</div>
<label
htmlFor="mirai-statistics-x-values"
className="block space-y-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
X values
<Textarea
id="mirai-statistics-x-values"
value={xSource}
onChange={(event) => setXSource(event.target.value)}
aria-label="X values"
className="min-h-24 resize-y bg-background font-mono text-sm text-foreground normal-case tracking-normal placeholder:text-muted-foreground"
/>
</label>
<label
htmlFor="mirai-statistics-y-values"
className="block space-y-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Y values
<Textarea
id="mirai-statistics-y-values"
value={ySource}
onChange={(event) => setYSource(event.target.value)}
aria-label="Y values"
className="min-h-24 resize-y bg-background font-mono text-sm text-foreground normal-case tracking-normal placeholder:text-muted-foreground"
/>
</label>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Chart
</h3>
<div className="flex flex-wrap gap-2">
{STATISTICS_CHART_OPTIONS.map((item) => (
<Button
key={item.value}
size="sm"
variant={chart === item.value ? "default" : "outline"}
onClick={() => setChart(item.value)}
>
{item.label}
</Button>
))}
</div>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Regression model
</h3>
<div className="flex flex-wrap gap-2">
{STATISTICS_REGRESSION_OPTIONS.map((item) => (
<Button
key={item.value}
size="sm"
variant={model === item.value ? "default" : "outline"}
onClick={() => setModel(item.value)}
>
{item.label}
</Button>
))}
</div>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm">
Model fit
<Badge variant={regression.ok ? "default" : "secondary"}>
{regression.label}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{regression.params.map((parameter) => (
<div key={parameter.label} className="flex justify-between text-sm">
<span className="text-muted-foreground">{parameter.label}</span>
<strong className="font-mono">{formatNumber(parameter.value)}</strong>
</div>
))}
<Separator />
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">R²</span>
<strong className="font-mono text-primary">
{regression.ok ? formatNumber(regression.r2) : "—"}
</strong>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">r</span>
<strong className="font-mono">{formatNumber(correlationValue)}</strong>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">covariance</span>
<strong className="font-mono">{formatNumber(covarianceValue)}</strong>
</div>
<p
className={cn(
"pt-1 text-xs text-muted-foreground",
!regression.ok && "text-destructive"
)}
>
{regression.message}
</p>
</CardContent>
</Card>
{regression.ok && (
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Residuals
</h3>
<div className="space-y-1">
{residualRows.map((row) => (
<div key={row.key} className="flex justify-between text-xs">
<span className="text-muted-foreground">x = {formatNumber(row.x)}</span>
<span className="font-mono">{formatNumber(row.residual)}</span>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</aside>
<section className="mirai-statistics-results grid min-h-0 grid-rows-[minmax(300px,1fr)_auto] @max-[699px]:min-h-max @max-[699px]:grid-rows-[minmax(260px,1fr)_auto]">
<div
ref={canvasHostRef}
className="mirai-statistics-chart relative min-h-75 @max-[699px]:min-h-65"
>
<canvas
ref={canvasRef}
className="absolute inset-0 size-full"
aria-label={`${chart} statistics chart`}
/>
<Badge className="absolute top-3 right-3 gap-1" variant="secondary">
{chart === "histogram" || chart === "box" || chart === "dot" ? (
<BarChart3 className="size-3" />
) : (
<LineChart className="size-3" />
)}
{STATISTICS_CHART_OPTIONS.find((item) => item.value === chart)?.label}
</Badge>
</div>
<div className="mirai-statistics-summary max-h-77.5 overflow-auto overscroll-contain border-t bg-muted/25 p-4 @max-[699px]:max-h-none @max-[699px]:overflow-visible">
<div className="mb-3">
<div>
<h3 className="text-sm font-semibold">One-variable statistics · x list</h3>
<p className="text-xs text-muted-foreground">{xValues.length} valid values parsed</p>
</div>
</div>
<div className="mirai-statistics-grid grid grid-cols-[repeat(auto-fit,minmax(min(165px,100%),1fr))] gap-2 @max-[699px]:grid-cols-2">
{statRows.map(([label, value]) => (
<Card key={String(label)} className="min-w-0 gap-1 overflow-hidden py-3">
<CardContent className="min-w-0 px-3">
<div className="min-h-4 wrap-break-word text-xs leading-4 text-muted-foreground">
{label}
</div>
<div
className="max-w-full overflow-hidden font-mono text-sm font-semibold text-ellipsis whitespace-nowrap"
title={value === null ? "none" : formatNumber(Number(value))}
>
{value === null ? "none" : formatNumber(Number(value))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
</div>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/tools-mode.tsx
import { useMemo, useState } from "react"
import { CirclePercent, DraftingCompass, MoveDiagonal2, Ratio } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
calculateCoordinates,
calculatePercent,
calculateRatio,
calculateShapes,
parseToolNumber,
serializeToolValue,
} from "@openmirai/calculator-core/tools"
import type { ToolsInitialData } from "@openmirai/calculator-core/tools"
export interface ToolsModeProps {
formatNumber: (value: number) => string
defaultData?: ToolsInitialData
}
function ResultRow({ label, value }: { label: string; value: string }) {
return (
<div className="mirai-tools-result flex items-center justify-between gap-4 text-sm @max-[699px]:flex-col @max-[699px]:items-start @max-[699px]:gap-0.5">
<span className="text-muted-foreground">{label}</span>
<strong className="min-w-0 max-w-full text-right font-mono wrap-break-word @max-[699px]:text-left">
{value}
</strong>
</div>
)
}
function NumberField({
label,
value,
onChange,
}: {
label: string
value: string
onChange: (value: string) => void
}) {
return (
<label className="min-w-0 space-y-1.5 text-xs text-muted-foreground">
{label}
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
inputMode="decimal"
className="bg-background font-mono text-sm text-foreground"
aria-label={label}
/>
</label>
)
}
/** Renders focused percent, ratio, coordinate, and geometry utilities. */
export function ToolsMode({ formatNumber: formatValue, defaultData }: ToolsModeProps) {
const [percent, setPercent] = useState(() => serializeToolValue(defaultData?.percent))
const [percentOf, setPercentOf] = useState(() => serializeToolValue(defaultData?.percentOf))
const [ratioLeft, setRatioLeft] = useState(() => serializeToolValue(defaultData?.ratioLeft))
const [ratioRight, setRatioRight] = useState(() => serializeToolValue(defaultData?.ratioRight))
const [ratioScale, setRatioScale] = useState(() => serializeToolValue(defaultData?.ratioScale))
const [x1, setX1] = useState(() => serializeToolValue(defaultData?.x1))
const [y1, setY1] = useState(() => serializeToolValue(defaultData?.y1))
const [x2, setX2] = useState(() => serializeToolValue(defaultData?.x2))
const [y2, setY2] = useState(() => serializeToolValue(defaultData?.y2))
const [radius, setRadius] = useState(() => serializeToolValue(defaultData?.radius))
const [base, setBase] = useState(() => serializeToolValue(defaultData?.base))
const [height, setHeight] = useState(() => serializeToolValue(defaultData?.height))
const [depth, setDepth] = useState(() => serializeToolValue(defaultData?.depth))
const formatNumber = (value: number) =>
formatValue(Number.isFinite(value) ? Number(value.toPrecision(8)) : value)
const percentResults = useMemo(
() => calculatePercent(parseToolNumber(percent), parseToolNumber(percentOf)),
[percent, percentOf]
)
const ratioResults = useMemo(
() =>
calculateRatio(
parseToolNumber(ratioLeft),
parseToolNumber(ratioRight),
parseToolNumber(ratioScale, 1)
),
[ratioLeft, ratioRight, ratioScale]
)
const coordinateResults = useMemo(
() =>
calculateCoordinates(
parseToolNumber(x1),
parseToolNumber(y1),
parseToolNumber(x2),
parseToolNumber(y2)
),
[x1, x2, y1, y2]
)
let lineThroughPoints: string
switch (coordinateResults.slope) {
case null:
lineThroughPoints = `x = ${formatNumber(parseToolNumber(x1))}`
break
default: {
const intercept = coordinateResults.intercept
lineThroughPoints =
intercept === null
? "undefined"
: `y = ${formatNumber(coordinateResults.slope)}x ${intercept < 0 ? "−" : "+"} ${formatNumber(Math.abs(intercept))}`
}
}
const shapeResults = useMemo(
() =>
calculateShapes(
parseToolNumber(radius),
parseToolNumber(base),
parseToolNumber(height),
parseToolNumber(depth)
),
[base, depth, height, radius]
)
return (
<div className="min-h-0 flex-1 overflow-auto bg-muted/25 p-4 sm:p-6">
<div className="mirai-tools-grid grid grid-cols-[repeat(auto-fit,minmax(min(280px,100%),1fr))] gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CirclePercent className="size-4 text-primary" />
Percent
</CardTitle>
<p className="text-xs text-muted-foreground">
Numbers in, numbers out—no worked steps.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="mirai-tools-fields mirai-tools-fields-two grid grid-cols-2 gap-3 @max-[699px]:grid-cols-1">
<NumberField label="Percent" value={percent} onChange={setPercent} />
<NumberField label="Of value" value={percentOf} onChange={setPercentOf} />
</div>
<div className="space-y-2 rounded-lg bg-muted p-3">
<ResultRow label="Percent of value" value={formatNumber(percentResults.portion)} />
<ResultRow label="Increased by" value={formatNumber(percentResults.increased)} />
<ResultRow label="Decreased by" value={formatNumber(percentResults.decreased)} />
<ResultRow
label="Original before increase"
value={formatNumber(percentResults.originalBeforeIncrease)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Ratio className="size-4 text-primary" />
Ratio and proportion
</CardTitle>
<p className="text-xs text-muted-foreground">Simplify, scale, and check equivalence.</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="mirai-tools-fields mirai-tools-fields-three grid grid-cols-3 gap-3 @max-[699px]:grid-cols-1">
<NumberField label="a" value={ratioLeft} onChange={setRatioLeft} />
<NumberField label="b" value={ratioRight} onChange={setRatioRight} />
<NumberField label="Scale" value={ratioScale} onChange={setRatioScale} />
</div>
<div className="space-y-2 rounded-lg bg-muted p-3">
<ResultRow
label="Simplified"
value={`${formatNumber(ratioResults.simplifiedLeft)} : ${formatNumber(ratioResults.simplifiedRight)}`}
/>
<ResultRow
label="Scaled"
value={`${formatNumber(ratioResults.scaledLeft)} : ${formatNumber(ratioResults.scaledRight)}`}
/>
<ResultRow label="As decimal" value={formatNumber(ratioResults.decimal)} />
<ResultRow
label="gcd · lcm"
value={`${formatNumber(ratioResults.gcd)} · ${formatNumber(ratioResults.lcm)}`}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MoveDiagonal2 className="size-4 text-primary" />
Coordinate geometry
</CardTitle>
<p className="text-xs text-muted-foreground">Two points in, distance and slope out.</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="mirai-tools-fields mirai-tools-fields-four grid grid-cols-4 gap-2 @max-[699px]:grid-cols-1">
<NumberField label="x₁" value={x1} onChange={setX1} />
<NumberField label="y₁" value={y1} onChange={setY1} />
<NumberField label="x₂" value={x2} onChange={setX2} />
<NumberField label="y₂" value={y2} onChange={setY2} />
</div>
<div className="space-y-2 rounded-lg bg-muted p-3">
<ResultRow label="Distance" value={formatNumber(coordinateResults.distance)} />
<ResultRow
label="Midpoint"
value={`(${formatNumber(coordinateResults.midpoint[0])}, ${formatNumber(coordinateResults.midpoint[1])})`}
/>
<ResultRow
label="Slope"
value={
coordinateResults.slope === null
? "undefined"
: formatNumber(coordinateResults.slope)
}
/>
<ResultRow label="Line through points" value={lineThroughPoints} />
</div>
</CardContent>
</Card>
<Card className="mirai-tools-wide col-span-full">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<DraftingCompass className="size-4 text-primary" />
Shape measures
</CardTitle>
<p className="text-xs text-muted-foreground">
Enter dimensions and the results update as you type.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="mirai-tools-fields mirai-tools-fields-four grid grid-cols-4 gap-3 @max-[699px]:grid-cols-1">
<NumberField label="Radius r" value={radius} onChange={setRadius} />
<NumberField label="Base b" value={base} onChange={setBase} />
<NumberField label="Height h" value={height} onChange={setHeight} />
<NumberField label="Depth d" value={depth} onChange={setDepth} />
</div>
<div className="mirai-tools-shape-results grid grid-cols-4 gap-3 @max-[699px]:grid-cols-1">
{[
["Circle area", shapeResults.circleArea],
["Circumference", shapeResults.circumference],
["Triangle area", shapeResults.triangleArea],
["Prism volume", shapeResults.prismVolume],
].map(([label, value]) => (
<div key={String(label)} className="rounded-lg bg-muted p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="font-mono text-base font-semibold">
{formatValue(
Number.isFinite(Number(value))
? Number(Number(value).toPrecision(6))
: Number(value)
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="mirai-tools-wide col-span-full border-primary/20 bg-primary/5">
<CardContent className="py-4">
<p className="text-sm font-medium text-primary">Practice tip</p>
<p className="mt-1 text-sm text-muted-foreground">
These helpers perform arithmetic only. They do not factor, solve, or explain a
question, keeping the workflow appropriate for timed practice.
</p>
</CardContent>
</Card>
</div>
</div>
)
}
../../packages/calculator-registry/src/components/mirai-calculator/modes/use-statistics-chart.ts
import { useEffect } from "react"
import { readCanvasColor, resolveCanvasColor } from "@/components/mirai-calculator/canvas-theme"
import { CALCULATOR_CHART_TOKENS } from "@/components/mirai-calculator/calculator-ui-config"
import type { StatisticsChartType } from "@/components/mirai-calculator/modes/statistics-config"
import type { DescriptiveStatistics, RegressionResult } from "@openmirai/calculator-core/statistics"
import {
buildHistogram,
calculateNumberExtent,
countNumberFrequencies,
sampleRegression,
} from "@openmirai/calculator-core/statistics-data"
import type { PairedNumberSeries } from "@openmirai/calculator-core/statistics-data"
interface UseStatisticsChartOptions {
canvasRef: React.RefObject<HTMLCanvasElement | null>
canvasHostRef: React.RefObject<HTMLDivElement | null>
chart: StatisticsChartType
colorScheme: "light" | "dark"
formatNumber: (value: number) => string
pairedValues: PairedNumberSeries
regression: RegressionResult
statistics: DescriptiveStatistics | null
xValues: Array<number>
}
/** Draws the selected statistics visualization and redraws it when its host resizes. */
export function useStatisticsChart({
canvasRef,
canvasHostRef,
chart,
colorScheme,
formatNumber,
pairedValues,
regression,
statistics,
xValues,
}: UseStatisticsChartOptions): void {
useEffect(() => {
const canvas = canvasRef.current
const host = canvasHostRef.current
if (!canvas || !host) {
return
}
const draw = () => {
const bounds = host.getBoundingClientRect()
const width = Math.max(1, Math.floor(bounds.width))
const height = Math.max(1, Math.floor(bounds.height))
const ratio = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = width * ratio
canvas.height = height * ratio
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const context = canvas.getContext("2d")
if (!context) {
return
}
context.setTransform(ratio, 0, 0, ratio, 0, 0)
const background = readCanvasColor(canvas, "--background")
const border = readCanvasColor(canvas, "--border")
const foreground = readCanvasColor(canvas, "--foreground")
const muted = readCanvasColor(canvas, "--muted-foreground")
const primary = resolveCanvasColor(canvas, CALCULATOR_CHART_TOKENS[0])
const secondary = resolveCanvasColor(canvas, CALCULATOR_CHART_TOKENS[1])
const padding = { top: 28, right: 28, bottom: 38, left: 48 }
const plotWidth = width - padding.left - padding.right
const plotHeight = height - padding.top - padding.bottom
context.clearRect(0, 0, width, height)
context.fillStyle = background
context.fillRect(0, 0, width, height)
context.font = "11px system-ui, -apple-system, sans-serif"
context.fillStyle = muted
context.strokeStyle = border
context.lineWidth = 1
const drawAxes = (xRange: [number, number], yRange: [number, number]) => {
const mapX = (value: number) =>
padding.left + ((value - xRange[0]) / (xRange[1] - xRange[0])) * plotWidth
const mapY = (value: number) =>
padding.top + (1 - (value - yRange[0]) / (yRange[1] - yRange[0])) * plotHeight
for (let index = 0; index <= 5; index += 1) {
const x = padding.left + (index / 5) * plotWidth
const y = padding.top + (index / 5) * plotHeight
context.strokeStyle = border
context.beginPath()
context.moveTo(x, padding.top)
context.lineTo(x, padding.top + plotHeight)
context.stroke()
context.beginPath()
context.moveTo(padding.left, y)
context.lineTo(padding.left + plotWidth, y)
context.stroke()
context.fillStyle = muted
const xValue = xRange[0] + (index / 5) * (xRange[1] - xRange[0])
const yValue = yRange[1] - (index / 5) * (yRange[1] - yRange[0])
context.textAlign = "center"
context.fillText(formatNumber(xValue), x, height - 14)
context.textAlign = "right"
context.fillText(formatNumber(yValue), padding.left - 8, y + 4)
}
return { mapX, mapY }
}
if (chart === "histogram" && xValues.length > 0) {
const bins = buildHistogram(xValues)
const firstBin = bins[0]
const lastBin = bins.at(-1)
if (!firstBin || !lastBin) {
return
}
const minimum = firstBin.minimum
const maximum = lastBin.maximum
const { mapX, mapY } = drawAxes(
[minimum, maximum],
[0, Math.max(...bins.map((bin) => bin.count), 1)]
)
context.fillStyle = primary
bins.forEach((bin) => {
const x0 = mapX(bin.minimum)
const x1 = mapX(bin.maximum)
const y = mapY(bin.count)
context.fillRect(x0 + 1, y, Math.max(1, x1 - x0 - 2), mapY(0) - y)
})
} else if (chart === "box" && statistics) {
const [minimum, maximum] = calculateNumberExtent(xValues)
const mapX = (value: number) =>
padding.left + ((value - minimum) / (maximum - minimum)) * plotWidth
const centerY = padding.top + plotHeight / 2
context.strokeStyle = foreground
context.lineWidth = 2
context.beginPath()
context.moveTo(mapX(statistics.min), centerY)
context.lineTo(mapX(statistics.q1), centerY)
context.moveTo(mapX(statistics.q3), centerY)
context.lineTo(mapX(statistics.max), centerY)
context.stroke()
context.fillStyle = `${primary}33`
context.fillRect(
mapX(statistics.q1),
centerY - 42,
mapX(statistics.q3) - mapX(statistics.q1),
84
)
context.strokeStyle = primary
context.strokeRect(
mapX(statistics.q1),
centerY - 42,
mapX(statistics.q3) - mapX(statistics.q1),
84
)
for (const value of [statistics.min, statistics.median, statistics.max]) {
context.beginPath()
context.moveTo(mapX(value), centerY - 48)
context.lineTo(mapX(value), centerY + 48)
context.stroke()
context.fillStyle = muted
context.fillText(formatNumber(value), mapX(value) - 10, centerY + 68)
}
} else if (chart === "dot" && xValues.length > 0) {
const [minimum, maximum] = calculateNumberExtent(xValues)
const frequencies = countNumberFrequencies(xValues)
const maxFrequency = Math.max(...frequencies.map(({ count }) => count))
const { mapX, mapY } = drawAxes([minimum, maximum], [0, maxFrequency + 1])
context.fillStyle = primary
for (const { value, count } of frequencies) {
for (let index = 1; index <= count; index += 1) {
context.beginPath()
context.arc(mapX(value), mapY(index), 5, 0, Math.PI * 2)
context.fill()
}
}
} else {
const yPlotValues =
chart === "residual" && regression.ok ? regression.residuals : pairedValues.y
const [xmin, xmax] = calculateNumberExtent(pairedValues.x)
const [ymin, ymax] = calculateNumberExtent(yPlotValues)
const { mapX, mapY } = drawAxes([xmin, xmax], [ymin, ymax])
if (chart === "residual") {
context.strokeStyle = muted
context.setLineDash([5, 4])
context.beginPath()
context.moveTo(mapX(xmin), mapY(0))
context.lineTo(mapX(xmax), mapY(0))
context.stroke()
context.setLineDash([])
} else if (regression.ok) {
context.strokeStyle = secondary
context.lineWidth = 2
context.beginPath()
sampleRegression(regression.predict, xmin, xmax).forEach(({ x, y }, index) => {
if (index === 0) {
context.moveTo(mapX(x), mapY(y))
} else {
context.lineTo(mapX(x), mapY(y))
}
})
context.stroke()
}
context.fillStyle = primary
pairedValues.x.forEach((x, index) => {
const y =
chart === "residual" && regression.ok
? regression.residuals[index]
: pairedValues.y[index]
if (!Number.isFinite(y)) {
return
}
context.beginPath()
context.arc(mapX(x), mapY(y), 4.5, 0, Math.PI * 2)
context.fill()
})
}
}
draw()
const observer = new ResizeObserver(draw)
observer.observe(host)
return () => observer.disconnect()
}, [
canvasHostRef,
canvasRef,
chart,
colorScheme,
formatNumber,
pairedValues,
regression,
statistics,
xValues,
])
}
../../packages/calculator-registry/src/components/mirai-calculator/use-system-dark-mode.ts
import { useSyncExternalStore } from "react"
import type { CalculatorTheme } from "@openmirai/calculator-core/configuration"
/** Subscribes to changes in the browser's preferred color scheme. */
function subscribeToSystemTheme(onStoreChange: () => void): () => void {
const query = window.matchMedia("(prefers-color-scheme: dark)")
query.addEventListener("change", onStoreChange)
return () => query.removeEventListener("change", onStoreChange)
}
/** Reads whether the browser currently prefers a dark color scheme. */
function getSystemThemeSnapshot(): boolean {
return window.matchMedia("(prefers-color-scheme: dark)").matches
}
/** Supplies the deterministic light-mode server snapshot. */
function getServerSystemThemeSnapshot(): boolean {
return false
}
/** Returns whether the host system currently prefers a dark color scheme. */
export function useSystemDarkMode(): boolean {
return useSyncExternalStore(
subscribeToSystemTheme,
getSystemThemeSnapshot,
getServerSystemThemeSnapshot
)
}
/** Resolves the configured calculator theme against the current system preference. */
export function resolveCalculatorTheme(
theme: CalculatorTheme,
systemDark: boolean
): "light" | "dark" {
switch (theme) {
case "system":
return systemDark ? "dark" : "light"
case "light":
case "dark":
return theme
}
}