diff --git a/README.md b/README.md index 3b00a30..4a36152 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,11 @@ For the automated deployment to work, add these secrets to your GitHub repositor - Functions update in real-time as you enter them - Click on points along the function graph to activate the point info panel with precise coordinates and calculations - Use left/right arrow navigation in the point info panel to move along the function curve with defined step size +- Configure formula parameters with custom ranges and step sizes: + - Set minimum and maximum values for each parameter + - Adjust step size for fine-grained control + - Use sliders for quick parameter adjustments + - Parameters are automatically detected from the formula expression ### Zoom Controls - Use the zoom buttons or keyboard shortcuts to zoom in/out diff --git a/docs/features/smart-function-input/README.md b/docs/features/smart-function-input/README.md new file mode 100644 index 0000000..5e0ca8f --- /dev/null +++ b/docs/features/smart-function-input/README.md @@ -0,0 +1,290 @@ +# Smart Function Input Feature + +## Overview + +The Smart Function Input feature enhances the function plotting capabilities by providing an intuitive interface for entering mathematical functions with automatic parameter detection. This allows users to easily create and manipulate mathematical functions with parameters. + +## User Stories + +### Intuitive Function Input +- As a user, I want to input mathematical functions in a natural way (e.g., "f(x) = ax^2 + bx + c") +- As a user, I want to use standard mathematical notation for functions (e.g., "f(x)", "y =", etc.) +- As a user, I want to easily input exponents using the ^ symbol or superscript +- As a user, I want to input common mathematical functions (sin, cos, sqrt, etc.) +- As a user, I want to see my input formatted in proper mathematical notation + +### Mathematical Input Interface +- As a user, I want to have a GeoGebra-like interface with buttons for mathematical symbols and functions +- As a user, I want to easily insert mathematical operators (+, -, ×, ÷, ^, √, etc.) +- As a user, I want to have quick access to common mathematical functions (sin, cos, tan, log, etc.) +- As a user, I want to be able to both click buttons and type directly into the input field +- As a user, I want the cursor position to be maintained when inserting symbols +- As a user, I want to have the input organized in tabs (Basic, Functions, Operators) for better organization + +### Parameter Detection +- As a user, I want the system to automatically detect parameters in my function (e.g., a, b, c in ax^2 + bx + c) +- As a user, I want to see a list of detected parameters with their current values +- As a user, I want to be able to adjust parameter values using interactive controls +- As a user, I want to see the graph update in real-time as I adjust parameters + +### Input Assistance +- As a user, I want to see suggestions for common mathematical functions as I type +- As a user, I want to see examples of how to input different types of functions +- As a user, I want to be able to use keyboard shortcuts for common mathematical operations +- As a user, I want to see immediate feedback if my input is invalid + +## Implementation Plan + +## Overview +This document outlines the implementation plan for enhancing the function input capabilities in the geometry playground. The goal is to make function input more intuitive and user-friendly while maintaining compatibility with the existing formula system. + +## Implementation Phases + +### Phase 0: Layout Restructuring +1. Move function controls to a dedicated sidebar + - [x] Create a new sidebar component for function controls + - [x] Position the sidebar next to the canvas + - [x] Move formula editor and controls from overlay to sidebar + - [x] Adjust canvas width to accommodate sidebar + - [x] Ensure responsive behavior for different screen sizes + - [x] Update layout to maintain proper spacing and alignment + +2. Update component hierarchy + - [x] Modify GeometryCanvas to accept sidebar as a prop + - [x] Update Index component to handle new layout structure + - [x] Ensure proper state management between components + - [x] Maintain existing functionality during transition + +3. Style and UI improvements + - [x] Design consistent sidebar styling + - [x] Add smooth transitions for sidebar open/close + - [x] Ensure proper z-indexing for all components + - [x] Add responsive breakpoints for mobile views + +### Phase 1: Parameter Detection and Dynamic Controls +1. Parameter Detection + - [x] Create parameter detection utility + - [x] Implement regex-based parameter extraction + - [x] Filter out mathematical function names (sqrt, sin, cos, etc.) + - [x] Handle nested functions and complex expressions + - [x] Add tests for parameter detection + - [x] Set default value of 1 for all detected parameters + +2. Dynamic Slider Creation + - [x] Create reusable slider component + - [x] Implement dynamic slider generation based on parameters + - [x] Add proper styling and layout for sliders + - [x] Ensure accessibility of dynamic controls + - [x] Add tests for slider component and generation + +3. Live Formula Updates + - [x] Implement parameter value state management + - [x] Create formula evaluation with parameter substitution + - [x] Add real-time graph updates when parameters change + - [x] Optimize performance for frequent updates + - [x] Add tests for live updates + +### Phase 2: Formula Management and Customization +1. Formula Options + - [ ] Add formula options button to each formula in the sidebar + - [ ] Create formula options popup dialog + - [ ] Implement parameter configuration UI + - [ ] Add formula naming functionality + - [ ] Store formula-specific settings + - [ ] Add tests for formula options + +2. Formula List Improvements + - [ ] Display formula names in the sidebar list + - [ ] Add formula visibility toggle + - [ ] Implement formula reordering + - [ ] Add formula search/filter + - [ ] Add tests for formula list features + +3. Formula Editor Enhancements + - [ ] Add formula options button to editor + - [ ] Implement formula templates + - [ ] Add formula validation feedback + - [ ] Improve formula input suggestions + - [ ] Add tests for editor features + +### Success Criteria +1. Parameter Detection ✅ + - Correctly identifies parameters in formulas + - Ignores mathematical function names + - Handles complex expressions + - Sets appropriate default values + +2. Dynamic Controls ✅ + - Sliders appear automatically for detected parameters + - Controls are properly styled and accessible + - Sliders have appropriate ranges and step sizes + - UI remains responsive with many parameters + +3. Live Updates ✅ + - Graph updates immediately when parameters change + - Performance remains smooth with multiple formulas + - No visual glitches during updates + - All changes are properly persisted + +4. Formula Management + - Users can name and customize their formulas + - Formula options are easily accessible + - Parameter settings are saved per formula + - Formula list is organized and searchable + - Editor provides helpful suggestions and feedback + +### Technical Considerations +1. Parameter Detection + - Use regex for initial parameter extraction + - Maintain list of mathematical function names to filter + - Consider using a proper math expression parser + - Handle edge cases (e.g., parameters in nested functions) + +2. Dynamic Controls + - Use Shadcn UI components for consistency + - Implement proper state management + - Consider mobile responsiveness + - Handle many parameters gracefully + +3. Performance + - Debounce parameter updates + - Optimize formula evaluation + - Consider using Web Workers for heavy computations + - Implement proper cleanup and memory management + +## Technical Considerations + +### State Management +- Use React Context for global state +- Implement proper state synchronization +- Handle formula updates efficiently +- Maintain undo/redo functionality + +### Performance +- Implement efficient formula evaluation +- Use Web Workers for heavy calculations +- Optimize rendering for large datasets +- Implement proper memoization + +### Accessibility +- Ensure keyboard navigation +- Add screen reader support +- Implement proper ARIA labels +- Support high contrast mode + +### Testing +- Add comprehensive unit tests +- Implement integration tests +- Add performance benchmarks +- Test edge cases and error conditions + +## Timeline +- Phase 0: 1-2 weeks +- Phase 1: 2-3 weeks +- Phase 2: 2-3 weeks +- Phase 3: 1-2 weeks + +Total estimated time: 6-10 weeks + +## Success Criteria +1. Users can input functions using standard mathematical notation +2. Functions are displayed accurately on the canvas +3. Multiple functions can be displayed simultaneously +4. Performance remains smooth with complex functions +5. The interface is intuitive and user-friendly +6. All features work responsively on different screen sizes +7. The system maintains compatibility with existing geometry features + +## Technical Details + +### Function Input +The system will support: +- Natural language input (e.g., "f(x) = ax^2 + bx + c") +- Standard mathematical notation +- Exponents using ^ or superscript +- Common mathematical functions (sin, cos, sqrt, etc.) +- Real-time formatting and validation + +### Parameter Detection +The system will identify parameters as: +- Single letters (a, b, c, etc.) +- Greek letters (α, β, γ, etc.) +- Custom parameter names (enclosed in curly braces) + +### Data Structures + +```typescript +// Parameter type for function parameters +interface Parameter { + name: string; + value: number; + min: number; + max: number; + step: number; +} + +// Updated Formula type +interface Formula { + id: string; + name: string; // User-defined name for the formula + expression: string; + substitutedExpression?: string; + parameters?: Parameter[]; + domain: [number, number]; + color: string; + visible: boolean; + createdAt: Date; + updatedAt: Date; + settings?: { + showParameters?: boolean; + parameterRanges?: Record; + customSettings?: Record; + }; +} +``` + +## Testing Plan + +### Unit Tests +- [x] Function input parsing tests +- [x] Parameter detection tests +- [x] Input validation tests +- [x] Formula evaluation tests with parameters +- [ ] Formula options tests +- [ ] Formula naming tests + +### Integration Tests +- [x] Input component integration tests +- [x] Parameter UI integration tests +- [x] Real-time update tests +- [ ] Formula options integration tests +- [ ] Formula list integration tests + +### E2E Tests +- [x] Complete function input workflow +- [x] Parameter adjustment workflow +- [x] Real-time graph update workflow +- [ ] Formula options workflow +- [ ] Formula naming workflow + +## Migration Plan + +1. Add new fields to existing formulas + - [x] Add parameters array (empty by default) + - [x] Add substitutedExpression field + - [ ] Add name field + - [ ] Add settings object + +2. Update formula validation + - [x] Add parameter validation + - [x] Add natural language input validation + - [ ] Add name validation + - [ ] Add settings validation + +3. Update UI components + - [x] Add enhanced formula input + - [x] Add parameter controls + - [x] Add input assistance features + - [ ] Add formula options UI + - [ ] Add formula naming UI + - [ ] Add formula list improvements \ No newline at end of file diff --git a/docs/features/smart-function-input/implementation-example-mathInput.md b/docs/features/smart-function-input/implementation-example-mathInput.md new file mode 100644 index 0000000..77b5fdf --- /dev/null +++ b/docs/features/smart-function-input/implementation-example-mathInput.md @@ -0,0 +1,318 @@ +# Implementation Example: Mathematical Expression Input Interface + +This document shows the implementation of a mathematical expression input interface that provides an intuitive way to input mathematical expressions using buttons and symbols, similar to GeoGebra's approach. + +## MathInput Component + +```typescript +// src/components/MathInput/MathInput.tsx + +import React, { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; + +interface MathInputProps { + value: string; + onChange: (value: string) => void; + onParameterDetected?: (parameters: string[]) => void; + className?: string; +} + +// Mathematical symbols and functions +const MATH_SYMBOLS = { + basic: [ + { label: '+', value: '+' }, + { label: '-', value: '-' }, + { label: '×', value: '*' }, + { label: '÷', value: '/' }, + { label: '=', value: '=' }, + { label: '(', value: '(' }, + { label: ')', value: ')' }, + { label: ',', value: ',' }, + { label: 'x', value: 'x' }, + { label: 'y', value: 'y' } + ], + functions: [ + { label: 'sin', value: 'sin(' }, + { label: 'cos', value: 'cos(' }, + { label: 'tan', value: 'tan(' }, + { label: '√', value: 'sqrt(' }, + { label: 'log', value: 'log(' }, + { label: 'ln', value: 'ln(' }, + { label: 'exp', value: 'exp(' }, + { label: 'abs', value: 'abs(' } + ], + operators: [ + { label: '^', value: '^' }, + { label: '√', value: 'sqrt(' }, + { label: 'π', value: 'pi' }, + { label: 'e', value: 'e' }, + { label: '∞', value: 'infinity' }, + { label: '±', value: '+-' }, + { label: '≤', value: '<=' }, + { label: '≥', value: '>=' } + ] +}; + +export const MathInput: React.FC = ({ + value, + onChange, + onParameterDetected, + className +}) => { + const [cursorPosition, setCursorPosition] = useState(0); + const inputRef = useRef(null); + + // Handle symbol button click + const handleSymbolClick = useCallback((symbol: string) => { + const newValue = value.slice(0, cursorPosition) + symbol + value.slice(cursorPosition); + onChange(newValue); + setCursorPosition(cursorPosition + symbol.length); + }, [value, cursorPosition, onChange]); + + // Handle input change + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + setCursorPosition(e.target.selectionStart || 0); + }, [onChange]); + + // Handle key press + const handleKeyPress = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + // Trigger parameter detection + const parameters = detectParameters(value); + onParameterDetected?.(parameters); + } + }, [value, onParameterDetected]); + + // Detect parameters in the expression + const detectParameters = useCallback((expression: string): string[] => { + const parameterRegex = /[a-zA-Z](?!\s*[=\(])/g; + const matches = expression.match(parameterRegex) || []; + return [...new Set(matches)].filter(param => param !== 'x' && param !== 'y'); + }, []); + + return ( + +
+ + + + + Basic + Functions + Operators + + + + {MATH_SYMBOLS.basic.map((symbol) => ( + + ))} + + + + {MATH_SYMBOLS.functions.map((symbol) => ( + + ))} + + + + {MATH_SYMBOLS.operators.map((symbol) => ( + + ))} + + +
+
+ ); +}; +``` + +## Integration with FormulaEditor + +```typescript +// src/components/FormulaEditor.tsx + +import { MathInput } from './MathInput/MathInput'; + +export const FormulaEditor: React.FC = ({ + formula, + onUpdate, + onDelete +}) => { + const [expression, setExpression] = useState(formula.expression); + const [parameters, setParameters] = useState([]); + + const handleExpressionChange = useCallback((newExpression: string) => { + setExpression(newExpression); + // Update formula with new expression + onUpdate({ + ...formula, + expression: newExpression + }); + }, [formula, onUpdate]); + + const handleParameterDetected = useCallback((detectedParameters: string[]) => { + setParameters(detectedParameters); + // Create parameter objects with default values + const newParameters = detectedParameters.map(param => ({ + name: param, + value: 1, + min: -10, + max: 10, + step: 0.1 + })); + // Update formula with new parameters + onUpdate({ + ...formula, + parameters: newParameters + }); + }, [formula, onUpdate]); + + return ( +
+ + + {parameters.length > 0 && ( +
+

Parameters

+
+ {parameters.map(param => ( +
+ {param} + { + // Update parameter value + }} + /> +
+ ))} +
+
+ )} +
+ ); +}; +``` + +## Testing Example + +```typescript +// src/components/MathInput/__tests__/MathInput.test.tsx + +import { render, screen, fireEvent } from '@testing-library/react'; +import { MathInput } from '../MathInput'; + +describe('MathInput', () => { + it('renders basic math symbols', () => { + render( {}} />); + + // Check if basic symbols are rendered + expect(screen.getByText('+')).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.getByText('×')).toBeInTheDocument(); + }); + + it('adds symbols to input when clicked', () => { + const onChange = jest.fn(); + render(); + + // Click some symbols + fireEvent.click(screen.getByText('+')); + fireEvent.click(screen.getByText('x')); + + expect(onChange).toHaveBeenCalledWith('+x'); + }); + + it('detects parameters in expression', () => { + const onParameterDetected = jest.fn(); + render( + {}} + onParameterDetected={onParameterDetected} + /> + ); + + // Press Enter to trigger parameter detection + fireEvent.keyPress(screen.getByPlaceholderText(/enter mathematical expression/i), { + key: 'Enter', + code: 13, + charCode: 13 + }); + + expect(onParameterDetected).toHaveBeenCalledWith(['a', 'b', 'c']); + }); + + it('maintains cursor position after symbol insertion', () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByPlaceholderText(/enter mathematical expression/i); + input.setSelectionRange(1, 1); + + fireEvent.click(screen.getByText('+')); + + expect(onChange).toHaveBeenCalledWith('x+'); + }); +}); +``` + +This implementation provides: +1. A tabbed interface for different types of mathematical symbols +2. Easy insertion of mathematical functions and operators +3. Automatic parameter detection +4. Cursor position maintenance +5. Real-time expression updates +6. Parameter controls +7. Comprehensive test coverage + +The next steps would be to: +1. Add support for more mathematical symbols and functions +2. Implement expression validation +3. Add support for custom functions +4. Add support for matrices and vectors +5. Implement expression simplification +6. Add support for units and constants +7. Add support for piecewise functions \ No newline at end of file diff --git a/docs/features/view-options.md b/docs/features/view-options.md new file mode 100644 index 0000000..ceacc56 --- /dev/null +++ b/docs/features/view-options.md @@ -0,0 +1,49 @@ +# View Options Feature + +## Overview +This feature allows the function plotter to adapt its display based on how it's being viewed (standalone, embedded, or fullscreen). + +## Requirements + +### View Modes +1. Standalone (default) + - Shows all UI elements + - Full functionality available + +2. Embedded (in iframe) + - Hides toolbar + - Hides function bar + - Focused on core plotting functionality + - Clean, minimal interface + +3. Fullscreen + - Shows function bar + - Hides toolbar + - Optimized for presentation/demonstration + +### Implementation Tasks +- [x] Add view mode detection +- [x] Create view mode context +- [x] Add test HTML for embedding +- [ ] Update toolbar visibility based on view mode +- [ ] Update function bar visibility based on view mode +- [ ] Add tests for view-specific UI elements +- [ ] Add documentation for embedding options +- [ ] Consider adding configuration options for embedded view + +### Technical Details +- View mode is detected using `window.self !== window.top` for iframe detection +- Fullscreen mode is detected using `document.fullscreenElement` +- View mode state is managed through React context +- UI components should check view mode before rendering + +### Testing +- [x] Unit tests for view detection +- [ ] Integration tests for view-specific UI +- [ ] Manual testing in different view modes +- [ ] Cross-browser testing for fullscreen support + +### Documentation +- [ ] Update embedding guide +- [ ] Add view mode configuration options +- [ ] Document view-specific features \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0aa472a..8556bf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "lint-staged": "^15.4.3", "nyc": "^17.1.0", "postcss": "^8.4.47", + "react-i18next": "^15.4.1", "tailwindcss": "^3.4.11", "ts-jest": "^29.2.6", "ts-node": "^10.9.2", @@ -96,6 +97,12 @@ "typescript-eslint": "^8.0.1", "vite": "^6.2.2", "vite-plugin-istanbul": "^7.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.9.1", + "@rollup/rollup-linux-x64-musl": "4.9.1", + "@swc/core-linux-x64-gnu": "1.3.107", + "@swc/core-linux-x64-musl": "1.3.107" } }, "node_modules/@adobe/css-tools": { @@ -3233,6 +3240,34 @@ "node": ">=14.0.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.36.0.tgz", + "integrity": "sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.36.0.tgz", + "integrity": "sha512-NyfuLvdPdNUfUNeYKUwPwKsE5SXa2J6bCt2LdB/N+AxShnkpiczi3tcLJrm5mA+eqpy0HmaIY9F6XCa32N5yzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.36.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.36.0.tgz", @@ -3247,6 +3282,228 @@ "darwin" ] }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.36.0.tgz", + "integrity": "sha512-6c6wMZa1lrtiRsbDziCmjE53YbTkxMYhhnWnSW8R/yqsM7a6mSJ3uAVT0t8Y/DGt7gxUWYuFM4bwWk9XCJrFKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.36.0.tgz", + "integrity": "sha512-KXVsijKeJXOl8QzXTsA+sHVDsFOmMCdBRgFmBb+mfEb/7geR7+C8ypAml4fquUt14ZyVXaw2o1FWhqAfOvA4sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.36.0.tgz", + "integrity": "sha512-dVeWq1ebbvByI+ndz4IJcD4a09RJgRYmLccwlQ8bPd4olz3Y213uf1iwvc7ZaxNn2ab7bjc08PrtBgMu6nb4pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.36.0.tgz", + "integrity": "sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.36.0.tgz", + "integrity": "sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.36.0.tgz", + "integrity": "sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.36.0.tgz", + "integrity": "sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.36.0.tgz", + "integrity": "sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.36.0.tgz", + "integrity": "sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.36.0.tgz", + "integrity": "sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.36.0.tgz", + "integrity": "sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.1.tgz", + "integrity": "sha512-kr8rEPQ6ns/Lmr/hiw8sEVj9aa07gh1/tQF2Y5HrNCCEPiCBGnBUt9tVusrcBBiJfIt1yNaXN6r1CCmpbFEDpg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.1.tgz", + "integrity": "sha512-t4QSR7gN+OEZLG0MiCgPqMWZGwmeHhsM4AkegJ0Kiy6TnJ9vZ8dEIwHw1LcZKhbHxTY32hp9eVCMdR3/I8MGRw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.36.0.tgz", + "integrity": "sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.36.0.tgz", + "integrity": "sha512-t+RY0JuRamIocMuQcfwYSOkmdX9dtkr1PbhKW42AMvaDQa+jOdpUYysroTF/nuPpAaQMWp7ye+ndlmmthieJrQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.36.0.tgz", + "integrity": "sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3330,6 +3587,191 @@ "node": ">=10" } }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.11.tgz", + "integrity": "sha512-/N4dGdqEYvD48mCF3QBSycAbbQd3yoZ2YHSzYesQf8usNc2YpIhYqEH3sql02UsxTjEFOJSf1bxZABDdhbSl6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.11.tgz", + "integrity": "sha512-hsBhKK+wVXdN3x9MrL5GW0yT8o9GxteE5zHAI2HJjRQel3HtW7m5Nvwaq+q8rwMf4YQRd8ydbvwl4iUOZx7i2Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.11.tgz", + "integrity": "sha512-YOCdxsqbnn/HMPCNM6nrXUpSndLXMUssGTtzT7ffXqr7WuzRg2e170FVDVQFIkb08E7Ku5uOnnUVAChAJQbMOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.11.tgz", + "integrity": "sha512-nR2tfdQRRzwqR2XYw9NnBk9Fdvff/b8IiJzDL28gRR2QiJWLaE8LsRovtWrzCOYq6o5Uu9cJ3WbabWthLo4jLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.107.tgz", + "integrity": "sha512-uBVNhIg0ip8rH9OnOsCARUFZ3Mq3tbPHxtmWk9uAa5u8jQwGWeBx5+nTHpDOVd3YxKb6+5xDEI/edeeLpha/9g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.107.tgz", + "integrity": "sha512-mvACkUvzSIB12q1H5JtabWATbk3AG+pQgXEN95AmEX2ZA5gbP9+B+mijsg7Sd/3tboHr7ZHLz/q3SHTvdFJrEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.11.tgz", + "integrity": "sha512-aZNZznem9WRnw2FbTqVpnclvl8Q2apOBW2B316gZK+qxbe+ktjOUnYaMhdCG3+BYggyIBDOnaJeQrXbKIMmNdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.11.tgz", + "integrity": "sha512-DjeJn/IfjgOddmJ8IBbWuDK53Fqw7UvOz7kyI/728CSdDYC3LXigzj3ZYs4VvyeOt+ZcQZUB2HA27edOifomGw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.11.tgz", + "integrity": "sha512-Gp/SLoeMtsU4n0uRoKDOlGrRC6wCfifq7bqLwSlAG8u8MyJYJCcwjg7ggm0rhLdC2vbiZ+lLVl3kkETp+JUvKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core/node_modules/@swc/core-linux-x64-gnu": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.11.tgz", + "integrity": "sha512-b4gBp5HA9xNWNC5gsYbdzGBJWx4vKSGybGMGOVWWuF+ynx10+0sA/o4XJGuNHm8TEDuNh9YLKf6QkIO8+GPJ1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core/node_modules/@swc/core-linux-x64-musl": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.11.tgz", + "integrity": "sha512-dEvqmQVswjNvMBwXNb8q5uSvhWrJLdttBSef3s6UC5oDSwOr00t3RQPzyS3n5qmGJ8UMTdPRmsopxmqaODISdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -7271,6 +7713,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -7326,6 +7778,39 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -11385,6 +11870,29 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", + "integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -11884,6 +12392,34 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.36.0.tgz", + "integrity": "sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.36.0.tgz", + "integrity": "sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13256,6 +13792,16 @@ "node": ">=18" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index 824c745..4413bca 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "lint-staged": "^15.4.3", "nyc": "^17.1.0", "postcss": "^8.4.47", + "react-i18next": "^15.4.1", "tailwindcss": "^3.4.11", "ts-jest": "^29.2.6", "ts-node": "^10.9.2", diff --git a/src/App.tsx b/src/App.tsx index da95e14..cf51656 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { ConfigProvider } from "./context/ConfigContext"; import { ServiceProvider } from "./providers/ServiceProvider"; +import { ViewModeProvider } from "./contexts/ViewModeContext"; import Index from "./pages/Index"; import NotFound from "./pages/NotFound"; import React from 'react'; @@ -17,15 +18,17 @@ const App: React.FC = () => { - - - - - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - + + + + + + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + + diff --git a/src/__tests__/components/Formula/FunctionSidebar.test.tsx b/src/__tests__/components/Formula/FunctionSidebar.test.tsx new file mode 100644 index 0000000..52bb60a --- /dev/null +++ b/src/__tests__/components/Formula/FunctionSidebar.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { useTranslation } from 'react-i18next'; +import FunctionSidebar from '@/components/Formula/FunctionSidebar'; +import { Formula } from '@/types/formula'; +import { MeasurementUnit } from '@/types/shapes'; +import { ConfigProvider } from '@/context/ConfigContext'; + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: jest.fn(), +})); + +// Mock ConfigContext +jest.mock('@/context/ConfigContext', () => ({ + ...jest.requireActual('@/context/ConfigContext'), + useConfig: () => ({ + language: 'en', + openaiApiKey: '', + loggingEnabled: false, + isGlobalConfigModalOpen: false, + isToolbarVisible: true, + pixelsPerUnit: 60, + measurementUnit: 'cm', + isComponentConfigModalOpen: false, + setLanguage: jest.fn(), + setOpenaiApiKey: jest.fn(), + setLoggingEnabled: jest.fn(), + setGlobalConfigModalOpen: jest.fn(), + setToolbarVisible: jest.fn(), + setPixelsPerUnit: jest.fn(), + setMeasurementUnit: jest.fn(), + setComponentConfigModalOpen: jest.fn(), + isConfigModalOpen: false, + setConfigModalOpen: jest.fn(), + }), +})); + +describe('FunctionSidebar', () => { + const mockFormula: Formula = { + id: 'test-formula', + type: 'function', + expression: 'x^2', + color: '#000000', + strokeWidth: 2, + xRange: [-10, 10], + samples: 100, + scaleFactor: 1, + }; + + const mockProps = { + formulas: [mockFormula], + selectedFormula: null, + onAddFormula: jest.fn(), + onDeleteFormula: jest.fn(), + onSelectFormula: jest.fn(), + onUpdateFormula: jest.fn(), + measurementUnit: 'cm' as MeasurementUnit, + }; + + beforeEach(() => { + (useTranslation as jest.Mock).mockReturnValue({ + t: (key: string) => key, + }); + }); + + it('renders without crashing', () => { + render(); + expect(screen.getByText('formula.title')).toBeInTheDocument(); + }); + + it('renders formula list', () => { + render(); + expect(screen.getByText('x^2')).toBeInTheDocument(); + }); + + it('calls onAddFormula when add button is clicked', () => { + render(); + const addButton = screen.getByTitle('formula.add'); + fireEvent.click(addButton); + expect(mockProps.onAddFormula).toHaveBeenCalled(); + }); + + it('calls onDeleteFormula when delete button is clicked', () => { + render(); + const deleteButton = screen.getByTitle('formula.delete'); + fireEvent.click(deleteButton); + expect(mockProps.onDeleteFormula).toHaveBeenCalledWith(mockFormula.id); + }); + + it('calls onSelectFormula when formula is clicked', () => { + render(); + const formulaButton = screen.getByText('x^2'); + fireEvent.click(formulaButton); + expect(mockProps.onSelectFormula).toHaveBeenCalledWith(mockFormula); + }); + + it('shows selected formula with different styling', () => { + render( + + ); + const formulaContainer = screen.getByText('x^2').closest('div').parentElement; + expect(formulaContainer).toHaveClass('bg-accent'); + expect(formulaContainer).toHaveClass('border-accent-foreground'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/components/Formula/ParameterSlider.test.tsx b/src/__tests__/components/Formula/ParameterSlider.test.tsx new file mode 100644 index 0000000..9105e98 --- /dev/null +++ b/src/__tests__/components/Formula/ParameterSlider.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { ParameterSlider } from "@/components/Formula/ParameterSlider"; + +describe("ParameterSlider", () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it("renders with default props", () => { + render( + + ); + + expect(screen.getByText("a")).toBeInTheDocument(); + expect(screen.getByText("1.0")).toBeInTheDocument(); + }); + + it("renders with custom min and max values", () => { + render( + + ); + + expect(screen.getByText("b")).toBeInTheDocument(); + expect(screen.getByText("2.0")).toBeInTheDocument(); + }); + + it("calls onChange when slider value changes", () => { + render( + + ); + + const slider = screen.getByRole("slider"); + fireEvent.keyDown(slider, { key: 'ArrowRight', code: 'ArrowRight' }); + + expect(mockOnChange).toHaveBeenCalledWith(0.1); + }); + + it("displays custom step value", () => { + render( + + ); + + expect(screen.getByText("1.0")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/utils/parameterDetection.test.ts b/src/__tests__/utils/parameterDetection.test.ts new file mode 100644 index 0000000..33dfac3 --- /dev/null +++ b/src/__tests__/utils/parameterDetection.test.ts @@ -0,0 +1,102 @@ +import { detectParameters, isValidParameterName } from '@/utils/parameterDetection'; + +describe('parameterDetection', () => { + describe('detectParameters', () => { + it('should detect single parameter in simple formula', () => { + const formula = 'ax^2'; + const result = detectParameters(formula); + expect(result).toEqual([ + { name: 'a', displayName: 'a', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 } + ]); + }); + + it('should detect multiple parameters in formula', () => { + const formula = 'ax^2 + bx + c'; + const result = detectParameters(formula); + expect(result).toEqual([ + { name: 'a', displayName: 'a', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 }, + { name: 'b', displayName: 'b', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 }, + { name: 'c', displayName: 'c', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 } + ]); + }); + + it('should not detect x as a parameter', () => { + const formula = 'ax^2 + bx + c'; + const result = detectParameters(formula); + expect(result).not.toContainEqual({ name: 'x', defaultValue: 1 }); + }); + + it('should not detect parameters in function names', () => { + const formula = 'sin(x) + a*cos(x)'; + const result = detectParameters(formula); + expect(result).toEqual([ + { name: 'a', displayName: 'a', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 } + ]); + }); + + it('should handle Math function names', () => { + const formula = 'Math.sin(x) + a*Math.cos(x)'; + const result = detectParameters(formula); + expect(result).toEqual([ + { name: 'a', displayName: 'a', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 } + ]); + }); + + it('should handle nested functions', () => { + const formula = 'a*sqrt(b*x^2)'; + const result = detectParameters(formula); + expect(result).toEqual([ + { name: 'a', displayName: 'a', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 }, + { name: 'b', displayName: 'b', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 } + ]); + }); + + it('should handle whitespace', () => { + const formula = 'a * x^2 + b * x + c'; + const result = detectParameters(formula); + expect(result).toEqual([ + { name: 'a', displayName: 'a', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 }, + { name: 'b', displayName: 'b', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 }, + { name: 'c', displayName: 'c', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 } + ]); + }); + + it('should handle case sensitivity', () => { + const formula = 'Ax^2 + Bx + C'; + const result = detectParameters(formula); + expect(result).toEqual([ + { name: 'a', displayName: 'a', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 }, + { name: 'b', displayName: 'b', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 }, + { name: 'c', displayName: 'c', defaultValue: 1, minValue: -10, maxValue: 10, step: 0.1 } + ]); + }); + }); + + describe('isValidParameterName', () => { + it('should accept single letters', () => { + expect(isValidParameterName('a')).toBe(true); + expect(isValidParameterName('b')).toBe(true); + expect(isValidParameterName('z')).toBe(true); + }); + + it('should not accept x', () => { + expect(isValidParameterName('x')).toBe(false); + }); + + it('should not accept multiple characters', () => { + expect(isValidParameterName('ab')).toBe(false); + expect(isValidParameterName('a1')).toBe(false); + }); + + it('should not accept numbers', () => { + expect(isValidParameterName('1')).toBe(false); + expect(isValidParameterName('2')).toBe(false); + }); + + it('should not accept function names', () => { + expect(isValidParameterName('sin')).toBe(false); + expect(isValidParameterName('cos')).toBe(false); + expect(isValidParameterName('sqrt')).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/utils/viewDetection.test.ts b/src/__tests__/utils/viewDetection.test.ts new file mode 100644 index 0000000..6ce04ed --- /dev/null +++ b/src/__tests__/utils/viewDetection.test.ts @@ -0,0 +1,143 @@ +import { detectViewMode, isInIframe, isFullscreen } from '@/utils/viewDetection'; + +describe('viewDetection', () => { + let originalWindow: Partial; + let originalDocument: Partial; + + beforeEach(() => { + originalWindow = { ...window }; + originalDocument = { ...document }; + + // Reset window and document to default state + Object.defineProperty(window, 'self', { + value: window, + writable: true + }); + Object.defineProperty(window, 'top', { + value: window, + writable: true + }); + Object.defineProperty(window, 'parent', { + value: window, + writable: true + }); + + // Create a new mock document + const mockDocument = { + fullscreenElement: null, + webkitFullscreenElement: null, + mozFullScreenElement: null, + msFullscreenElement: null + }; + Object.defineProperties(document, { + fullscreenElement: { + get: () => mockDocument.fullscreenElement, + configurable: true + }, + webkitFullscreenElement: { + get: () => mockDocument.webkitFullscreenElement, + configurable: true + }, + mozFullScreenElement: { + get: () => mockDocument.mozFullScreenElement, + configurable: true + }, + msFullscreenElement: { + get: () => mockDocument.msFullscreenElement, + configurable: true + } + }); + }); + + afterEach(() => { + // Restore original properties instead of reassigning globals + Object.defineProperty(window, 'self', { + value: originalWindow.self, + writable: true + }); + Object.defineProperty(window, 'top', { + value: originalWindow.top, + writable: true + }); + Object.defineProperty(window, 'parent', { + value: originalWindow.parent, + writable: true + }); + + // Restore document properties + if (originalDocument.fullscreenElement !== undefined) { + Object.defineProperty(document, 'fullscreenElement', { + value: originalDocument.fullscreenElement, + writable: true + }); + } + }); + + describe('isInIframe', () => { + it('should return false when not in an iframe', () => { + expect(isInIframe()).toBe(false); + }); + + it('should return true when in an iframe', () => { + Object.defineProperty(window, 'self', { + value: window + }); + Object.defineProperty(window, 'top', { + value: {} + }); + expect(isInIframe()).toBe(true); + }); + }); + + describe('isFullscreen', () => { + it('should return false when not in fullscreen', () => { + expect(isFullscreen()).toBe(false); + }); + + it('should return true when in fullscreen', () => { + Object.defineProperty(document, 'fullscreenElement', { + get: () => document.createElement('div'), + configurable: true + }); + expect(isFullscreen()).toBe(true); + }); + }); + + describe('detectViewMode', () => { + it('should return standalone when not in iframe or fullscreen', () => { + expect(detectViewMode()).toBe('standalone'); + }); + + it('should return embedded when in iframe', () => { + Object.defineProperty(window, 'self', { + value: window + }); + Object.defineProperty(window, 'top', { + value: {} + }); + expect(detectViewMode()).toBe('embedded'); + }); + + it('should return fullscreen when in fullscreen mode', () => { + Object.defineProperty(document, 'fullscreenElement', { + get: () => document.createElement('div'), + configurable: true + }); + expect(detectViewMode()).toBe('fullscreen'); + }); + + it('should prioritize fullscreen over embedded', () => { + Object.defineProperty(window, 'self', { + value: window + }); + Object.defineProperty(window, 'top', { + value: {} + }); + Object.defineProperty(document, 'fullscreenElement', { + get: () => document.createElement('div'), + configurable: true + }); + expect(detectViewMode()).toBe('fullscreen'); + }); + }); +}); \ No newline at end of file diff --git a/src/components/CanvasGrid/GridZoomControl.tsx b/src/components/CanvasGrid/GridZoomControl.tsx index 4f5be26..7a77ef8 100644 --- a/src/components/CanvasGrid/GridZoomControl.tsx +++ b/src/components/CanvasGrid/GridZoomControl.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { ZoomIn, ZoomOut } from 'lucide-react'; +import { ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'; import { useGridZoom } from '@/contexts/GridZoomContext/index'; import { useTranslate } from '@/hooks/useTranslate'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { useViewMode } from '@/contexts/ViewModeContext'; const GridZoomControl: React.FC = () => { const _t = useTranslate(); const { zoomFactor, setZoomFactor } = useGridZoom(); + const { isFullscreen, setIsFullscreen } = useViewMode(); const handleZoomIn = () => { const newZoom = Math.min(3, zoomFactor + 0.05); @@ -24,6 +26,10 @@ const GridZoomControl: React.FC = () => { setZoomFactor(1); }; + const handleToggleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + return (
{

Zoom In (Ctrl +)

+ + + + + + +

{isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}

+
+
); diff --git a/src/components/CanvasGrid/__tests__/GridZoomControl.test.tsx b/src/components/CanvasGrid/__tests__/GridZoomControl.test.tsx index 66b6536..1b1e1d9 100644 --- a/src/components/CanvasGrid/__tests__/GridZoomControl.test.tsx +++ b/src/components/CanvasGrid/__tests__/GridZoomControl.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent, screen } from '@testing-library/react'; import { GridZoomProvider } from '@/contexts/GridZoomContext/index'; +import { ViewModeProvider } from '@/contexts/ViewModeContext'; import GridZoomControl from '../GridZoomControl'; // Mock translations @@ -16,17 +17,24 @@ jest.mock('@/components/ui/tooltip', () => ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); -// Wrapper component to provide the GridZoomContext -const GridZoomControlWrapper = () => { - return ( - - - - ); -}; +// Mock fullscreen API +const mockRequestFullscreen = jest.fn().mockImplementation(() => Promise.resolve()); +const mockExitFullscreen = jest.fn().mockImplementation(() => Promise.resolve()); + +beforeAll(() => { + Object.defineProperty(document.documentElement, 'requestFullscreen', { + value: mockRequestFullscreen, + writable: true, + }); + Object.defineProperty(document, 'exitFullscreen', { + value: mockExitFullscreen, + writable: true, + }); +}); -// Mock localStorage for testing beforeEach(() => { + mockRequestFullscreen.mockClear(); + mockExitFullscreen.mockClear(); // Clear localStorage before each test localStorage.clear(); // Set initial zoom to 1 (100%) @@ -36,7 +44,13 @@ beforeEach(() => { describe('GridZoomControl', () => { // Helper function to render the component with a reset zoom level const renderComponent = () => { - return render(); + return render( + + + + + + ); }; it('should render zoom controls', () => { @@ -44,7 +58,7 @@ describe('GridZoomControl', () => { // Check for zoom buttons - using indices since buttons have icons, not text const buttons = screen.getAllByRole('button'); - expect(buttons.length).toBe(3); // Zoom out, percentage, zoom in + expect(buttons.length).toBe(4); // Zoom out, percentage, zoom in, fullscreen // Check for zoom factor display expect(screen.getByText('100%')).toBeInTheDocument(); @@ -168,4 +182,17 @@ describe('GridZoomControl', () => { // Maximum zoom is 300% expect(percentageButton).toHaveTextContent('300%'); }); + + it('should handle fullscreen toggle', () => { + renderComponent(); + + const buttons = screen.getAllByRole('button'); + const fullscreenButton = buttons[3]; // Fourth button is fullscreen + + // Click fullscreen button + fireEvent.click(fullscreenButton); + + // The button should now show the minimize icon + // We can't test the actual fullscreen state as it's not supported in jsdom + }); }); \ No newline at end of file diff --git a/src/components/Formula/FunctionSidebar.tsx b/src/components/Formula/FunctionSidebar.tsx new file mode 100644 index 0000000..fe56394 --- /dev/null +++ b/src/components/Formula/FunctionSidebar.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { Plus, Trash2, Maximize2, Minimize2 } from 'lucide-react'; +import { Formula } from '@/types/formula'; +import { MeasurementUnit } from '@/types/shapes'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +interface FunctionSidebarProps { + formulas: Formula[]; + selectedFormula: Formula | null; + onAddFormula: () => void; + onDeleteFormula: (id: string) => void; + onSelectFormula: (formula: Formula) => void; + onUpdateFormula: (id: string, updates: Partial) => void; + measurementUnit: MeasurementUnit; + className?: string; + isFullscreen?: boolean; + onToggleFullscreen?: () => void; +} + +export default function FunctionSidebar({ + formulas, + selectedFormula, + onAddFormula, + onDeleteFormula, + onSelectFormula, + onUpdateFormula, + measurementUnit, + className, + isFullscreen = false, + onToggleFullscreen +}: FunctionSidebarProps) { + const { t } = useTranslation(); + + return ( +
+
+

{t('formula.title')}

+
+ {onToggleFullscreen && ( + + + + + + +

{isFullscreen ? t('exitFullscreen') : t('enterFullscreen')}

+
+
+
+ )} + +
+
+ + +
+ {formulas.map((formula) => ( +
+
+ + +
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Formula/ParameterControls.tsx b/src/components/Formula/ParameterControls.tsx new file mode 100644 index 0000000..07d0c03 --- /dev/null +++ b/src/components/Formula/ParameterControls.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; +import { Formula } from '@/types/formula'; +import { ParameterSlider } from '@/components/Formula/ParameterSlider'; +import { detectParameters } from '@/utils/parameterDetection'; + +interface ParameterControlsProps { + selectedFormula: Formula | null; + onUpdateFormula: (id: string, updates: Partial) => void; +} + +export default function ParameterControls({ + selectedFormula, + onUpdateFormula, +}: ParameterControlsProps) { + const { t } = useTranslation(); + + const handleParameterChange = (parameterName: string, value: number) => { + if (!selectedFormula) return; + + const updatedParameters = { + ...selectedFormula.parameters, + [parameterName]: value, + }; + + onUpdateFormula(selectedFormula.id, { + parameters: updatedParameters, + }); + }; + + if (!selectedFormula) return null; + + const parameters = detectParameters(selectedFormula.expression); + if (parameters.length === 0) return null; + + return ( +
+
+

{t('formula.parameters')}

+
+ {parameters.map((param) => ( + handleParameterChange(param.name, value)} + parameters={selectedFormula.parameters} + /> + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Formula/ParameterSlider.tsx b/src/components/Formula/ParameterSlider.tsx new file mode 100644 index 0000000..2f6f290 --- /dev/null +++ b/src/components/Formula/ParameterSlider.tsx @@ -0,0 +1,72 @@ +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface ParameterSliderProps { + parameterName: string; + displayName?: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + className?: string; + parameters?: Record; +} + +export function ParameterSlider({ + parameterName, + displayName, + value, + onChange, + min = -3, + max = 3, + step = 0.1, + className, + parameters, +}: ParameterSliderProps) { + // Get the step size from parameters if available, otherwise use default + const stepSize = parameters?.[`${parameterName}_step`] ?? step; + + // Ensure the value is rounded to the step size + const roundedValue = Math.round(value / stepSize) * stepSize; + + return ( +
+
+ + + + + + +

Parameter: {parameterName}

+
+
+
+ {roundedValue.toFixed(1)} +
+ { + // Round the new value to the step size + const roundedNewValue = Math.round(newValue / stepSize) * stepSize; + onChange(roundedNewValue); + }} + min={min} + max={max} + step={stepSize} + className="w-full" + /> +
+ ); +} \ No newline at end of file diff --git a/src/components/FormulaEditor.tsx b/src/components/FormulaEditor.tsx index 197a0f9..3085392 100644 --- a/src/components/FormulaEditor.tsx +++ b/src/components/FormulaEditor.tsx @@ -4,7 +4,9 @@ import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { PlusCircle, Trash2, BookOpen, ZoomIn, ZoomOut, Sparkles, Loader2, AlertCircle, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { PlusCircle, Trash2, BookOpen, ZoomIn, ZoomOut, Sparkles, Loader2, AlertCircle, ChevronLeftIcon, ChevronRightIcon, Settings } from 'lucide-react'; import { MeasurementUnit } from '@/types/shapes'; import { getFormulaExamples, createDefaultFormula, validateFormula, convertToLatex } from '@/utils/formulaUtils'; import { useTranslate } from '@/utils/translate'; @@ -18,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import 'katex/dist/katex.min.css'; import { InlineMath } from 'react-katex'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { detectParameters } from '@/utils/parameterDetection'; interface FormulaEditorProps { formulas: Formula[]; @@ -48,6 +51,7 @@ const FormulaEditor: React.FC = ({ const [isProcessing, setIsProcessing] = useState(false); const [isNaturalLanguageOpen, setIsNaturalLanguageOpen] = useState(false); const [examplesOpen, setExamplesOpen] = useState(false); + const [isOptionsOpen, setIsOptionsOpen] = useState(false); const [validationErrors, setValidationErrors] = useState>({}); const _isMobile = useIsMobile(); const formulaInputRef = useRef(null); @@ -92,7 +96,7 @@ const FormulaEditor: React.FC = ({ }, []); // Update the formula being edited - const handleUpdateFormula = (key: keyof Formula, value: string | number | boolean | [number, number]) => { + const handleUpdateFormula = (key: keyof Formula, value: string | number | boolean | [number, number] | Record) => { if (!selectedFormulaId) return; // Update the formula @@ -315,19 +319,20 @@ const FormulaEditor: React.FC = ({ - - - - - + + -

{t('naturalLanguageTooltip')}

+

{t('naturalLanguageButton')}

@@ -354,6 +359,166 @@ const FormulaEditor: React.FC = ({
+ {/* Formula Options Button */} + + + + + + +

{t('formula.optionsTooltip')}

+
+
+
+ + {/* Formula Options Dialog */} + + + + {t('formula.options')} + + {t('formula.description')} + + + + + + {t('formula.tabs.general')} + {t('formula.tabs.parameters')} + + + {/* General Tab */} + +
+ + handleUpdateFormula('name', e.target.value)} + placeholder={t('formula.untitled')} + /> +
+
+ + {/* Parameters Tab */} + +
+ {detectParameters(findSelectedFormula()?.expression || '').map((param) => ( +
+
+
+ + handleUpdateFormula('parameters', { + ...(findSelectedFormula()?.parameters || {}), + [`${param.name}_displayName`]: e.target.value + } as Record)} + placeholder={t('formula.parameterName')} + /> +
+
+ handleUpdateFormula('parameters', { + ...(findSelectedFormula()?.parameters || {}), + [param.name]: e.target.value ? parseFloat(e.target.value) : param.defaultValue + } as Record)} + /> +
+
+
+ +
+
+ + { + const newMinValue = e.target.value ? parseFloat(e.target.value) : -10; + const updatedParams = detectParameters(findSelectedFormula()?.expression || '') + .map(p => p.name === param.name + ? { ...p, minValue: newMinValue } + : p + ); + // Update the parameter settings + handleUpdateFormula('parameters', { + ...(findSelectedFormula()?.parameters || {}), + [param.name]: Math.max(newMinValue, findSelectedFormula()?.parameters?.[param.name] ?? param.defaultValue) + } as Record); + }} + /> +
+
+ + { + const newMaxValue = e.target.value ? parseFloat(e.target.value) : 10; + const updatedParams = detectParameters(findSelectedFormula()?.expression || '') + .map(p => p.name === param.name + ? { ...p, maxValue: newMaxValue } + : p + ); + // Update the parameter settings + handleUpdateFormula('parameters', { + ...(findSelectedFormula()?.parameters || {}), + [param.name]: Math.min(newMaxValue, findSelectedFormula()?.parameters?.[param.name] ?? param.defaultValue) + } as Record); + }} + /> +
+
+
+ + { + const newStep = e.target.value ? parseFloat(e.target.value) : 0.1; + // Update the parameter settings with the new step size + handleUpdateFormula('parameters', { + ...(findSelectedFormula()?.parameters || {}), + [`${param.name}_step`]: newStep + } as Record); + }} + /> +
+
+
+ + handleUpdateFormula('parameters', { + ...(findSelectedFormula()?.parameters || {}), + [param.name]: value[0] + } as Record)} + /> +
+
+ ))} +
+
+
+
+
+ {/* Examples */} diff --git a/src/contexts/ViewModeContext.tsx b/src/contexts/ViewModeContext.tsx new file mode 100644 index 0000000..e5b28e2 --- /dev/null +++ b/src/contexts/ViewModeContext.tsx @@ -0,0 +1,71 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { ViewMode, detectViewMode } from '@/utils/viewDetection'; + +interface ViewModeContextType { + viewMode: ViewMode; + isEmbedded: boolean; + isFullscreen: boolean; + isStandalone: boolean; + setIsFullscreen: (isFullscreen: boolean) => void; +} + +const ViewModeContext = createContext(undefined); + +export function ViewModeProvider({ children }: { children: React.ReactNode }) { + const [viewMode, setViewMode] = useState(detectViewMode()); + + useEffect(() => { + const handleFullscreenChange = () => { + setViewMode(detectViewMode()); + }; + + // Listen for fullscreen changes + document.addEventListener('fullscreenchange', handleFullscreenChange); + document.addEventListener('webkitfullscreenchange', handleFullscreenChange); + document.addEventListener('mozfullscreenchange', handleFullscreenChange); + document.addEventListener('MSFullscreenChange', handleFullscreenChange); + + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + document.removeEventListener('webkitfullscreenchange', handleFullscreenChange); + document.removeEventListener('mozfullscreenchange', handleFullscreenChange); + document.removeEventListener('MSFullscreenChange', handleFullscreenChange); + }; + }, []); + + const setIsFullscreen = (isFullscreen: boolean) => { + if (isFullscreen) { + document.documentElement.requestFullscreen().catch(() => { + // Handle error if fullscreen is not supported + console.warn('Fullscreen not supported'); + }); + } else { + document.exitFullscreen().catch(() => { + // Handle error if fullscreen is not supported + console.warn('Fullscreen not supported'); + }); + } + }; + + const value = { + viewMode, + isEmbedded: viewMode === 'embedded', + isFullscreen: viewMode === 'fullscreen', + isStandalone: viewMode === 'standalone', + setIsFullscreen, + }; + + return ( + + {children} + + ); +} + +export function useViewMode() { + const context = useContext(ViewModeContext); + if (context === undefined) { + throw new Error('useViewMode must be used within a ViewModeProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index cea16da..393718f 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -134,6 +134,27 @@ export const translations = { applyButton: "Apply" } }, + formula: { + title: "Formula", + untitled: "Untitled", + delete: "Delete", + parameters: "Parameters", + options: "Formula Options", + optionsTooltip: "Configure formula settings", + description: "Configure formula settings and parameters", + name: "Name", + minValue: "Min Value", + maxValue: "Max Value", + step: "Step", + parameterRange: "Parameter Range", + quickAdjust: "Quick Adjust", + parameterName: "Display Name", + tabs: { + general: "General", + parameters: "Parameters" + }, + parametersDescription: "Configure formula parameters and their default values" + }, }, es: { formulaEditor: "Trazador de Fórmulas", @@ -257,7 +278,7 @@ export const translations = { calibration: { title: "Calibración", description: "Calibra tu pantalla para mediciones precisas", - instructions: "Para calibrar, mide una distancia conocida en tu pantalla", + instructions: "Para calibrar, mide una distancia connida en tu pantalla", lengthLabel: "Longitud de referencia", startButton: "Iniciar calibración", placeRuler: "Coloca una regla en tu pantalla", @@ -270,6 +291,27 @@ export const translations = { applyButton: "Aplicar" } }, + formula: { + title: "Fórmula", + untitled: "Sin título", + delete: "Eliminar", + parameters: "Parámetros", + options: "Opciones de fórmula", + optionsTooltip: "Configurar ajustes de fórmula", + description: "Configurar ajustes y parámetros de la fórmula", + name: "Nombre de la fórmula", + minValue: "Valor Mínimo", + maxValue: "Valor Máximo", + step: "Paso", + parameterRange: "Rango de Parámetro", + quickAdjust: "Ajustar Rápidamente", + parameterName: "Nombre de visualización", + tabs: { + general: "General", + parameters: "Parámetros" + }, + parametersDescription: "Configurar parámetros de la fórmula y sus valores predeterminados" + }, }, fr: { formulaEditor: "Traceur de Formules", @@ -408,6 +450,27 @@ export const translations = { zoomReset: 'Resetear Zoom', showToolbar: "Afficher la Barre d'Outils", hideToolbar: "Masquer la Barre d'Outils", + formula: { + title: "Formule", + untitled: "Sans titre", + delete: "Supprimer", + parameters: "Paramètres", + options: "Options de formule", + optionsTooltip: "Configurer les paramètres de la formule", + description: "Configurer les paramètres et options de la formule", + name: "Nom de la formule", + minValue: "Valeur Minimale", + maxValue: "Valeur Maximale", + step: "Étape", + parameterRange: "Plage de Paramètre", + quickAdjust: "Régler Rapidement", + parameterName: "Nom d'affichage", + tabs: { + general: "Général", + parameters: "Paramètres" + }, + parametersDescription: "Configurer les paramètres de la formule et leurs valeurs par défaut" + }, }, de: { formulaEditor: "Formelplotter", @@ -544,5 +607,26 @@ export const translations = { applyButton: "Anwenden" } }, + formula: { + title: "Formel", + untitled: "Unbenannt", + delete: "Löschen", + parameters: "Parameter", + options: "Formeloptionen", + optionsTooltip: "Formeleinstellungen konfigurieren", + description: "Formeleinstellungen und Parameter konfigurieren", + name: "Formelname", + minValue: "Minimalwert", + maxValue: "Maximalwert", + step: "Schritt", + parameterRange: "Parameterbereich", + quickAdjust: "Schnell einstellen", + parameterName: "Anzeigename", + tabs: { + general: "Allgemein", + parameters: "Parameter" + }, + parametersDescription: "Formelparameter und deren Standardwerte konfigurieren" + }, } }; diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 07882dc..938a3fc 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -7,6 +7,7 @@ import GeometryCanvas from '@/components/GeometryCanvas'; import Toolbar from '@/components/Toolbar'; import UnitSelector from '@/components/UnitSelector'; import FormulaEditor from '@/components/FormulaEditor'; +import FunctionSidebar from '@/components/Formula/FunctionSidebar'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useTranslate } from '@/utils/translate'; @@ -26,12 +27,16 @@ import { toast } from 'sonner'; import { useIsMobile } from '@/hooks/use-mobile'; import GlobalControls from '@/components/GlobalControls'; import _UnifiedInfoPanel from '@/components/UnifiedInfoPanel'; +import { useViewMode } from '@/contexts/ViewModeContext'; +import ParameterControls from '@/components/Formula/ParameterControls'; +import { cn } from '@/lib/utils'; const Index = () => { // Get the service factory const serviceFactory = useServiceFactory(); const { setComponentConfigModalOpen } = useComponentConfig(); const { isToolbarVisible, setToolbarVisible, defaultTool } = useGlobalConfig(); + const { isFullscreen, isEmbedded, setIsFullscreen } = useViewMode(); const isMobile = useIsMobile(); const { @@ -58,9 +63,8 @@ const Index = () => { shareCanvasUrl } = useShapeOperations(); - const [isFullscreen, setIsFullscreen] = useState(false); - const [formulas, setFormulas] = useState([]); const [isFormulaEditorOpen, setIsFormulaEditorOpen] = useState(false); + const [formulas, setFormulas] = useState([]); const [selectedFormulaId, setSelectedFormulaId] = useState(null); const [_pixelsPerUnit, setPixelsPerUnit] = useState(getStoredPixelsPerUnit(measurementUnit)); @@ -74,18 +78,6 @@ const Index = () => { setPixelsPerUnit(getStoredPixelsPerUnit(measurementUnit)); }, [measurementUnit]); - // Check fullscreen status - useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => { - document.removeEventListener('fullscreenchange', handleFullscreenChange); - }; - }, []); - // Function to request fullscreen with better mobile support const requestFullscreen = useCallback(() => { const elem = document.documentElement; @@ -257,7 +249,7 @@ const Index = () => { const newState = !prevState; // If opening the formula editor and there are no formulas, create a default one - if (newState && formulas.length === 0) { + if (newState && formulas.length === 0 && !hasLoadedFromUrl.current) { const newFormula = createDefaultFormula('function'); // Set a default expression of x^2 instead of empty newFormula.expression = "x*x"; @@ -272,7 +264,17 @@ const Index = () => { return newState; }); - }, [formulas, handleAddFormula, selectedFormulaId]); + }, [formulas, handleAddFormula, selectedFormulaId, hasLoadedFromUrl]); + + // Auto-open formula editor in embedded mode + useEffect(() => { + if (isEmbedded && !isFormulaEditorOpen && formulas.length === 0 && !hasLoadedFromUrl.current) { + const newFormula = createDefaultFormula('function'); + newFormula.expression = "x*x"; + handleAddFormula(newFormula); + setIsFormulaEditorOpen(true); + } + }, [isEmbedded, isFormulaEditorOpen, formulas.length, handleAddFormula]); // Open formula editor when a formula is selected (e.g., by clicking a point on the graph) useEffect(() => { @@ -354,143 +356,123 @@ const Index = () => { }, []); return ( -
-
- {/* Only show the header in the standard position when toolbar is visible */} - {isToolbarVisible && } - - {/* Include both modals */} - - - -
-
-
-
- - {isToolbarVisible ? ( -
- selectedShapeId && deleteShape(selectedShapeId)} - hasSelectedShape={!!selectedShapeId} - _canDelete={!!selectedShapeId} - onToggleFormulaEditor={toggleFormulaEditor} - isFormulaEditorOpen={isFormulaEditorOpen} - /> -
- ) : ( - /* Show header in the toolbar position when toolbar is hidden */ -
- -
- )} - -
- -
+
+ {/* Header */} + {!isEmbedded && ( + + )} + + {/* Main content */} +
+ {/* Toolbar */} + {!isEmbedded && isToolbarVisible && ( + selectedShapeId && deleteShape(selectedShapeId)} + hasSelectedShape={!!selectedShapeId} + _canDelete={!!selectedShapeId} + onToggleFormulaEditor={toggleFormulaEditor} + isFormulaEditorOpen={isFormulaEditorOpen} + /> + )} + + {/* Function Controls - Hide when embedded */} + {!isEmbedded && ( +
+
+
+ { + const newFormula = createDefaultFormula('function'); + newFormula.expression = "x*x"; + handleAddFormula(newFormula); + }} + />
- - {isFormulaEditorOpen && ( -
- { - const newFormula = createDefaultFormula('function'); - newFormula.expression = "x*x"; - handleAddFormula(newFormula); - }} - /> -
- )} - - - - - - - - -

{t('clearCanvas')}

-
-
- - - - - - -

{t('componentConfigModal.openButton')}

-
-
-
- - {/* Add UnitSelector here */} -
- -
-
- } - />
+ )} + + {/* Canvas and Sidebar */} +
+
+ +
+ + {/* Function Sidebar - Hide when embedded */} + {!isEmbedded && ( + f.id === selectedFormulaId) || null} + onAddFormula={() => { + const newFormula = createDefaultFormula('function'); + newFormula.expression = "x*x"; + handleAddFormula(newFormula); + }} + onDeleteFormula={handleDeleteFormula} + onSelectFormula={(formula) => setSelectedFormulaId(formula.id)} + onUpdateFormula={handleUpdateFormula} + measurementUnit={measurementUnit} + className={cn( + 'w-80', + isFormulaEditorOpen ? 'block' : 'hidden', + isMobile ? 'fixed inset-y-0 right-0 z-50' : '' + )} + isFullscreen={isFullscreen} + onToggleFullscreen={toggleFullscreen} + /> + )}
+ + {/* Parameter Controls */} + f.id === selectedFormulaId) || null} + onUpdateFormula={handleUpdateFormula} + />
+ + {/* Global Config Menu */} + {!isEmbedded && ( + + )} + + {/* Component Config Modal */} +
); }; diff --git a/src/types/formula.ts b/src/types/formula.ts index 646598e..e1d6056 100644 --- a/src/types/formula.ts +++ b/src/types/formula.ts @@ -5,13 +5,17 @@ export type FormulaType = 'function' | 'parametric' | 'polar'; export interface Formula { id: string; type: FormulaType; + name?: string; // Optional name for the formula expression: string; color: string; strokeWidth: number; - xRange: [number, number]; // For function and parametric - tRange?: [number, number]; // For parametric and polar - samples: number; // Number of points to sample - scaleFactor: number; // Scale factor to stretch or flatten the graph (1.0 is normal) + xRange: [number, number]; // The range of x values to plot + tRange?: [number, number]; // For parametric equations + samples: number; // Number of points to plot + scaleFactor: number; // Scale factor for the function + parameters?: Record; // Parameters for the function + minValue?: number; // Minimum value for the function + maxValue?: number; // Maximum value for the function } export interface FormulaPoint { diff --git a/src/utils/formulaUtils.ts b/src/utils/formulaUtils.ts index ce29764..a4a4ed3 100644 --- a/src/utils/formulaUtils.ts +++ b/src/utils/formulaUtils.ts @@ -1,5 +1,6 @@ import { Formula, FormulaPoint, FormulaExample, FormulaType } from "@/types/formula"; import { Point } from "@/types/shapes"; +import { detectParameters as detectFormulaParameters } from './parameterDetection'; // Constants const MAX_SAMPLES = 100000; @@ -55,28 +56,32 @@ const calculateVisibleXRange = ( ]; }; +const detectParameters = (expression: string): { name: string; defaultValue: number }[] => { + // Use our existing implementation + return detectFormulaParameters(expression); +}; + const createFunctionFromExpression = ( expression: string, - scaleFactor: number + scaleFactor: number, + parameters?: Record ): ((x: number) => number) => { if (!expression || expression.trim() === '') { return () => NaN; } - if (expression === 'Math.exp(x)') { - return (x: number) => Math.exp(x) * scaleFactor; - } - if (expression === '1 / (1 + Math.exp(-x))') { - return (x: number) => (1 / (1 + Math.exp(-x))) * scaleFactor; - } - if (expression === 'Math.sqrt(Math.abs(x))') { - return (x: number) => Math.sqrt(Math.abs(x)) * scaleFactor; - } - try { + // Detect parameters in the expression + const detectedParams = detectParameters(expression); + // Only wrap x in parentheses if it's not part of another identifier (like Math.exp) const scaledExpression = expression.replace(/(? p.name).join(','); + const paramDefaults = detectedParams.map(p => parameters?.[p.name] ?? p.defaultValue).join(','); + + return new Function('x', paramNames, ` try { const {sin, cos, tan, exp, log, sqrt, abs, pow, PI, E} = Math; return (${scaledExpression}) * ${scaleFactor}; @@ -84,7 +89,7 @@ const createFunctionFromExpression = ( console.error('Error in function evaluation:', e); return NaN; } - `) as (x: number) => number; + `) as (x: number, ...params: number[]) => number; } catch (e) { console.error('Error creating function from expression:', e); return () => NaN; @@ -379,140 +384,32 @@ const evaluatePoints = ( gridPosition: Point, pixelsPerUnit: number, xValues: number[], - fn: (x: number) => number + fn: (x: number, ...params: number[]) => number ): FormulaPoint[] => { const points: FormulaPoint[] = []; - const chars = detectFunctionCharacteristics(formula.expression); - const { isLogarithmic, allowsNegativeX, hasPow } = chars; - - let prevY: number | null = null; - let prevX: number | null = null; - - // Special case for complex formulas to detect and handle rapid changes - const isComplexFormula = formula.expression === 'Math.pow(x * 2, 2) + Math.pow((5 * Math.pow(x * 4, 2) - Math.sqrt(Math.abs(x))) * 2, 2) - 1'; - + const detectedParams = detectParameters(formula.expression); + const paramValues = detectedParams.map(p => formula.parameters?.[p.name] ?? p.defaultValue); + for (const x of xValues) { - let y: number; - let isValidDomain = true; - try { - // Special handling for logarithmic functions - if (isLogarithmic) { - if (Math.abs(x) < 1e-10) { - // Skip points too close to zero for log functions - y = NaN; - isValidDomain = false; - } else { - y = fn(x); - // Additional validation for logarithmic results - if (Math.abs(y) > 100) { - y = Math.sign(y) * 100; // Limit extreme values - } - } - } else { - y = fn(x); - } - - // Special handling for the complex formula - if (isComplexFormula) { - // Detect rapid changes around x=0 for the complex formula - if (Math.abs(x) < 0.01) { - // Extra validation for points very close to zero - if (Math.abs(y) > 1000) { - y = Math.sign(y) * 1000; // Limit extreme values - } - } - } - - // Skip points with extreme y values for non-logarithmic functions - if (!isLogarithmic && !isNaN(y) && Math.abs(y) > 100000) { - isValidDomain = false; - } - } catch (e) { - console.error(`Error evaluating function at x=${x}:`, e); - y = NaN; - isValidDomain = false; - } - - // Convert to canvas coordinates - const canvasX = gridPosition.x + x * pixelsPerUnit; - const canvasY = gridPosition.y - y * pixelsPerUnit; - - // Basic validity check for NaN and Infinity - const isBasicValid = !isNaN(y) && isFinite(y) && isValidDomain; - - // Additional validation for extreme changes - const isValidPoint = isBasicValid; - - if (isBasicValid && prevY !== null && prevX !== null) { - const MAX_DELTA_Y = isComplexFormula ? 200 : 100; // Allow larger jumps for complex formulas - const deltaY = Math.abs(canvasY - prevY); - const deltaX = Math.abs(canvasX - prevX); - - // If there's a very rapid change in y relative to x, and x values are close, - // this might be a discontinuity that we should render as separate segments - if (deltaX > 0 && deltaY / deltaX > 50) { - if (points.length > 0) { - // Create an invalid point to break the path - points.push({ - x: (canvasX + prevX) / 2, - y: (canvasY + prevY) / 2, - isValid: false - }); - } - } - } - - if (isComplexFormula && isBasicValid) { - // Special validation for our complex formula - // For the complex formula, detect rapid changes due to sqrt(abs(x)) - // which will cause sharp changes near x=0 - if (Math.abs(x) < 0.01) { - // For very small x, ensure we render a clean break at x=0 - if (prevX !== null && (Math.sign(x) !== Math.sign(prevX) || Math.abs(x) < 1e-6)) { - // Insert an invalid point precisely at x=0 to create a clean break - points.push({ - x: gridPosition.x, // x=0 in canvas coordinates - y: canvasY, - isValid: false - }); - } - - // Special case for extremely small x values in complex formula - // Add a visual connection between points that are very close to zero - if (Math.abs(x) < 1e-8 && prevX !== null && Math.abs(prevX) < 1e-8 && - Math.sign(x) !== Math.sign(prevX)) { - // Instead of a break, create a smooth connection by adding valid midpoint - points.push({ - x: gridPosition.x, // x=0 in canvas coordinates - y: (canvasY + prevY!) / 2, // Average of the y-values on both sides - isValid: true - }); - } - } - } - - // If the function evaluated successfully, add the point - if (isBasicValid) { - // Only update prev values for valid points - prevY = canvasY; - prevX = canvasX; + const y = fn(x, ...paramValues); + const { x: canvasX, y: canvasY } = toCanvasCoordinates(x, y, gridPosition, pixelsPerUnit); points.push({ x: canvasX, y: canvasY, - isValid: isValidPoint + isValid: !isNaN(y) && isFinite(y) }); - } else { - // For non-valid points, still add them but mark as invalid + } catch (e) { + console.error('Error evaluating point:', e); points.push({ - x: canvasX, - y: 0, // Placeholder y value + x: 0, + y: 0, isValid: false }); } } - + return points; }; @@ -601,118 +498,22 @@ export const evaluateFunction = ( pixelsPerUnit: number, overrideSamples?: number ): FormulaPoint[] => { - // Validate formula first - const validation = validateFormula(formula); - if (!validation.isValid) { - console.error(`Invalid function formula: ${validation.error}`); - return [{ x: 0, y: 0, isValid: false }]; - } - - try { - const actualSamples = overrideSamples || clampSamples(formula.samples); - const chars = detectFunctionCharacteristics(formula.expression); - const adjustedSamples = adjustSamples(actualSamples, chars, formula.expression); - - const fullRange = calculateVisibleXRange(gridPosition, pixelsPerUnit, adjustedSamples, chars.isLogarithmic); - let visibleXRange: [number, number] = [ - Math.max(fullRange[0], formula.xRange[0]), - Math.min(fullRange[1], formula.xRange[1]) - ]; - - let xValues: number[]; - - // Special case for our complex nested Math.pow formula - if (formula.expression === 'Math.pow(x * 2, 2) + Math.pow((5 * Math.pow(x * 4, 2) - Math.sqrt(Math.abs(x))) * 2, 2) - 1') { - // Use a combination of approaches for better resolution - - // First, get a standard set of points - const standardXValues = generateLinearXValues(visibleXRange, adjustedSamples); - - // Then, add more points around x=0 (where sqrt(abs(x)) creates interesting behavior) - const zeroRegionValues = []; - const zeroRegionDensity = 5000; // Significantly increased from 2000 - const zeroRegionWidth = 0.5; // Increased from 0.2 - - for (let i = 0; i < zeroRegionDensity; i++) { - // Generate more points near zero, both positive and negative - const t = i / zeroRegionDensity; - // Use quintic distribution for much denser sampling near zero - const tDist = t * t * t * t * t; - zeroRegionValues.push(-zeroRegionWidth * tDist); - zeroRegionValues.push(zeroRegionWidth * tDist); - } - - // Also add extra points for a wider region around zero - const widerRegionValues = []; - const widerRegionDensity = 2000; // Increased from 800 - const widerRegionWidth = 2.0; // Increased from 1.0 - - for (let i = 0; i < widerRegionDensity; i++) { - const t = i / widerRegionDensity; - const tCubed = t * t * t; - widerRegionValues.push(-widerRegionWidth * tCubed); - widerRegionValues.push(widerRegionWidth * tCubed); - } - - // Add even more points in very close proximity to zero - const microZeroValues = []; - for (let i = 1; i <= 1000; i++) { - const microValue = 0.0001 * i / 1000; - microZeroValues.push(-microValue); - microZeroValues.push(microValue); - } - - // Add ultra-dense points at practically zero (negative and positive sides) - const ultraZeroValues = []; - // Generate 500 extremely close points on each side of zero - for (let i = 1; i <= 500; i++) { - // Use exponential scaling to get extremely close to zero without reaching it - const ultraValue = 1e-10 * Math.pow(1.5, i); - ultraZeroValues.push(-ultraValue); - ultraZeroValues.push(ultraValue); - } - - // Combine and sort all x values - xValues = [ - ...standardXValues, - ...zeroRegionValues, - ...widerRegionValues, - ...microZeroValues, - ...ultraZeroValues - ].sort((a, b) => a - b); - - // Remove duplicates to avoid unnecessary computations - xValues = xValues.filter((value, index, self) => - index === 0 || Math.abs(value - self[index - 1]) > 0.00001 - ); - } - else if (chars.isLogarithmic) { - xValues = generateLogarithmicXValues(visibleXRange, adjustedSamples * 2); - } else if (chars.isTangent) { - visibleXRange = [ - Math.max(fullRange[0], -Math.PI/2 + 0.01), - Math.min(fullRange[1], Math.PI/2 - 0.01) - ]; - xValues = generateTangentXValues(visibleXRange, adjustedSamples * 10); - } else if (chars.hasSingularity) { - xValues = generateSingularityXValues(visibleXRange, adjustedSamples * 3); - } else if (chars.hasPowWithX) { - xValues = generateLinearXValues(visibleXRange, adjustedSamples * 1.5); - } else { - xValues = generateLinearXValues(visibleXRange, adjustedSamples); - } - - // Create the function from the expression - const fn = createFunctionFromExpression(formula.expression, formula.scaleFactor); - - // Evaluate the function at each x value - const points = evaluatePoints(formula, gridPosition, pixelsPerUnit, xValues, fn); - - return points; - } catch (error) { - console.error('Error evaluating function:', error); - return [{ x: 0, y: 0, isValid: false }]; - } + const chars = detectFunctionCharacteristics(formula.expression); + const baseSamples = overrideSamples ?? formula.samples; + const adjustedSamples = adjustSamples(baseSamples, chars, formula.expression); + const samples = clampSamples(adjustedSamples); + + const xRange = calculateVisibleXRange(gridPosition, pixelsPerUnit, samples, chars.isLogarithmic); + const xValues = chars.isLogarithmic + ? generateLogarithmicXValues(xRange, samples) + : chars.hasSingularity + ? generateSingularityXValues(xRange, samples) + : chars.isTangent + ? generateTangentXValues(xRange, samples) + : generateLinearXValues(xRange, samples); + + const fn = createFunctionFromExpression(formula.expression, formula.scaleFactor, formula.parameters); + return evaluatePoints(formula, gridPosition, pixelsPerUnit, xValues, fn); }; export const evaluateParametric = ( diff --git a/src/utils/parameterDetection.ts b/src/utils/parameterDetection.ts new file mode 100644 index 0000000..751d24a --- /dev/null +++ b/src/utils/parameterDetection.ts @@ -0,0 +1,82 @@ +/** + * List of mathematical function names to exclude from parameter detection + */ +const MATH_FUNCTIONS = [ + 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh', + 'log', 'ln', 'exp', 'sqrt', 'abs', 'floor', 'ceil', 'round', + 'Math.sin', 'Math.cos', 'Math.tan', 'Math.asin', 'Math.acos', 'Math.atan', + 'Math.sinh', 'Math.cosh', 'Math.tanh', 'Math.log', 'Math.exp', 'Math.sqrt', + 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round' +]; + +/** + * Regular expression to match single letters that could be parameters + * Excludes 'x' as it's the default variable + */ +const PARAMETER_REGEX = /[a-wyzA-WYZ]/g; + +/** + * Interface for detected parameters + */ +export interface DetectedParameter { + name: string; + displayName?: string; // Optional display name for the parameter + defaultValue: number; + minValue: number; + maxValue: number; + step: number; +} + +/** + * Removes function names from the formula + */ +function removeFunctionNames(formula: string): string { + let cleanedFormula = formula; + // Sort functions by length (longest first) to handle nested functions correctly + const sortedFunctions = [...MATH_FUNCTIONS].sort((a, b) => b.length - a.length); + + for (const func of sortedFunctions) { + // Replace function names with spaces to preserve formula structure + cleanedFormula = cleanedFormula.replace(new RegExp(func, 'g'), ' '.repeat(func.length)); + } + + return cleanedFormula; +} + +/** + * Detects parameters in a mathematical formula + * @param formula The mathematical formula to analyze + * @returns Array of detected parameters with default values + */ +export function detectParameters(formula: string): DetectedParameter[] { + // Remove whitespace for consistent matching + const normalizedFormula = formula.replace(/\s+/g, ''); + + // First remove all function names + const formulaWithoutFunctions = removeFunctionNames(normalizedFormula); + + // Find all potential parameter matches + const matches = formulaWithoutFunctions.match(PARAMETER_REGEX) || []; + + // Remove duplicates and create parameter objects + const uniqueParameters = [...new Set(matches)].map(name => ({ + name: name.toLowerCase(), // Convert to lowercase for consistency + displayName: name.toLowerCase(), // Default display name is the parameter name + defaultValue: 1, // Set default value to 1 as specified + minValue: -10, // Default min value + maxValue: 10, // Default max value + step: 0.1 // Default step value + })); + + return uniqueParameters; +} + +/** + * Tests if a string is a valid parameter name + * @param name The string to test + * @returns boolean indicating if the string is a valid parameter name + */ +export function isValidParameterName(name: string): boolean { + // Must be a single letter (excluding x) + return /^[a-wyzA-WYZ]$/.test(name); +} \ No newline at end of file diff --git a/src/utils/viewDetection.ts b/src/utils/viewDetection.ts new file mode 100644 index 0000000..5896af1 --- /dev/null +++ b/src/utils/viewDetection.ts @@ -0,0 +1,56 @@ +/** + * Type definition for different view modes + */ +export type ViewMode = 'standalone' | 'embedded' | 'fullscreen'; + +/** + * Extended Document interface to include vendor-specific fullscreen properties + */ +interface ExtendedDocument extends Document { + webkitFullscreenElement?: Element | null; + mozFullScreenElement?: Element | null; + msFullscreenElement?: Element | null; +} + +/** + * Checks if the application is running inside an iframe + */ +export function isInIframe(): boolean { + try { + return window.self !== window.top; + } catch (e) { + // If we can't access window.top due to same-origin policy, we're in an iframe + return true; + } +} + +/** + * Checks if the application is in fullscreen mode + */ +export function isFullscreen(): boolean { + const doc = document as ExtendedDocument; + return !!( + doc.fullscreenElement || + doc.webkitFullscreenElement || + doc.mozFullScreenElement || + doc.msFullscreenElement + ); +} + +/** + * Detects the current view mode of the application + */ +export function detectViewMode(): ViewMode { + // Check fullscreen first + if (isFullscreen()) { + return 'fullscreen'; + } + + // Then check if we're in an iframe + if (isInIframe()) { + return 'embedded'; + } + + // Default to standalone + return 'standalone'; +} \ No newline at end of file diff --git a/test/embedding/index.html b/test/embedding/index.html new file mode 100644 index 0000000..2c66a1c --- /dev/null +++ b/test/embedding/index.html @@ -0,0 +1,42 @@ + + + + + + Function Plotter Embedding Test + + + +
+ +
+ + \ No newline at end of file