mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-05 02:41:26 +00:00
Merge 8211e70b8d into eafed9705c
This commit is contained in:
commit
fddfe53e64
28 changed files with 10102 additions and 0 deletions
39
apps/web-roo-code/src/components/excel-analyzer/CLAUDE.md
Normal file
39
apps/web-roo-code/src/components/excel-analyzer/CLAUDE.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# CLAUDE.md — Shared Brain / Lessons Learned
|
||||||
|
|
||||||
|
Durable project memory for architecture, structure, and workflow decisions.
|
||||||
|
|
||||||
|
## Current Snapshot
|
||||||
|
|
||||||
|
- Project: **Excel Analyzer**
|
||||||
|
- Runtime: Browser-only (client-side)
|
||||||
|
- Extensibility: `src/hooks/hook-engine.js`
|
||||||
|
- Operational memory:
|
||||||
|
- `.orchestration/active_intents.yaml`
|
||||||
|
- `.orchestration/agent_trace.jsonl`
|
||||||
|
- `.orchestration/intent_map.md`
|
||||||
|
|
||||||
|
## Documentation Map
|
||||||
|
|
||||||
|
- `README.md` (workspace root)
|
||||||
|
- `Excel-Based-Data-Analzer/README.md` (application-level guide)
|
||||||
|
- `specify/specification.md`
|
||||||
|
- `specify/memory/constitution.md`
|
||||||
|
- `specify/plan/plan.md`
|
||||||
|
- `specify/architecture/architecture.md`
|
||||||
|
- `specify/project_structure/project_structure.md`
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
1. Keep structure docs synchronized immediately after file moves.
|
||||||
|
2. Keep hook-specific logic isolated under `src/hooks/`.
|
||||||
|
3. Keep orchestration artifacts lightweight and plain-text for traceability.
|
||||||
|
4. Update both root and app README when structure changes.
|
||||||
|
|
||||||
|
## Working Agreements
|
||||||
|
|
||||||
|
- `.orchestration/active_intents.yaml` tracks active goals.
|
||||||
|
- `.orchestration/agent_trace.jsonl` logs key operations.
|
||||||
|
- `.orchestration/intent_map.md` maps goals to code areas.
|
||||||
|
- `specify/project_structure/project_structure.md` is the canonical structure reference.
|
||||||
|
|
||||||
|
|
||||||
72
apps/web-roo-code/src/components/excel-analyzer/README.md
Normal file
72
apps/web-roo-code/src/components/excel-analyzer/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Excel Analyzer Project
|
||||||
|
|
||||||
|
Web-based Excel analysis application with modular architecture, local-first processing, and extensibility via a dedicated Hook Engine.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
- Upload and parse Excel files (`.xlsx`, `.xls`)
|
||||||
|
- Preview tabular data with basic quality indicators
|
||||||
|
- Analyze/visualize data (Chart.js)
|
||||||
|
- View a compact dashboard summary
|
||||||
|
- Export results (PDF / Excel / CSV / JSON / PNG)
|
||||||
|
|
||||||
|
## Architecture Highlights
|
||||||
|
|
||||||
|
- **Client-side only** processing for privacy
|
||||||
|
- **Module-oriented JS structure** under `src/js/`
|
||||||
|
- **Hook Engine** under `src/hooks/hook-engine.js` for extensibility
|
||||||
|
- **Project memory and orchestration artifacts**:
|
||||||
|
- `CLAUDE.md`
|
||||||
|
- `.orchestration/active_intents.yaml`
|
||||||
|
- `.orchestration/agent_trace.jsonl`
|
||||||
|
- `.orchestration/intent_map.md`
|
||||||
|
|
||||||
|
## Current Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
excel-analyzer/
|
||||||
|
├── README.md
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── .orchestration/
|
||||||
|
│ ├── active_intents.yaml
|
||||||
|
│ ├── agent_trace.jsonl
|
||||||
|
│ └── intent_map.md
|
||||||
|
├── specify/
|
||||||
|
│ ├── specification.md
|
||||||
|
│ ├── memory/
|
||||||
|
│ │ └── constitution.md
|
||||||
|
│ ├── plan/
|
||||||
|
│ │ └── plan.md
|
||||||
|
│ ├── architecture/
|
||||||
|
│ │ └── architecture.md
|
||||||
|
│ └── project_structure/
|
||||||
|
│ └── project_structure.md
|
||||||
|
└── src/
|
||||||
|
├── index.html
|
||||||
|
├── css/
|
||||||
|
│ └── styles.css
|
||||||
|
├── hooks/
|
||||||
|
│ └── hook-engine.js
|
||||||
|
└── js/
|
||||||
|
├── main.js
|
||||||
|
├── config/
|
||||||
|
├── events/
|
||||||
|
├── modules/
|
||||||
|
└── utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Open in browser:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
start Excel-Based-Data-Analzer/src/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Docs
|
||||||
|
|
||||||
|
- Specification: `specify/specification.md`
|
||||||
|
- Constitution: `specify/memory/constitution.md`
|
||||||
|
- Plan: `specify/plan/plan.md`
|
||||||
|
- Architecture: `specify/architecture/architecture.md`
|
||||||
|
- Project Structure: `specify/project_structure/project_structure.md`
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
e# Excel Analyzer Architecture (Revised)
|
||||||
|
|
||||||
|
## 1) Architecture Style
|
||||||
|
|
||||||
|
The project uses a **client-side modular SPA-style architecture**:
|
||||||
|
|
||||||
|
- Runs fully in the browser (no backend required)
|
||||||
|
- Processes files locally for privacy
|
||||||
|
- Uses module separation for upload, processing, visualization, dashboard, export
|
||||||
|
- Supports extensibility through a dedicated **Hook Engine**
|
||||||
|
|
||||||
|
## 2) High-Level Layers
|
||||||
|
|
||||||
|
1. **Presentation Layer**
|
||||||
|
- `src/index.html`
|
||||||
|
- `src/css/styles.css`
|
||||||
|
|
||||||
|
2. **Application Orchestration Layer**
|
||||||
|
- `src/js/main.js`
|
||||||
|
- Coordinates upload → preview → analyze → dashboard → export flow
|
||||||
|
|
||||||
|
3. **Domain Modules Layer**
|
||||||
|
- `src/js/modules/*.js`
|
||||||
|
- Encapsulates feature logic (file upload, data processing, charts, dashboard, exports)
|
||||||
|
|
||||||
|
4. **Shared Utilities and Config Layer**
|
||||||
|
- `src/js/utils/*.js`
|
||||||
|
- `src/js/config/*.js`
|
||||||
|
- `src/js/events/*.js`
|
||||||
|
|
||||||
|
5. **Extensibility Layer (Hooks)**
|
||||||
|
- `src/hooks/hook-engine.js`
|
||||||
|
- Central hook registry/execution engine for plugging cross-cutting behavior
|
||||||
|
|
||||||
|
## 3) Hook Engine Design
|
||||||
|
|
||||||
|
Location: `src/hooks/hook-engine.js`
|
||||||
|
|
||||||
|
Core capabilities:
|
||||||
|
|
||||||
|
- `tap(hookName, handler, { priority, once })`
|
||||||
|
- `tapOnce(hookName, handler, options)`
|
||||||
|
- `untap(hookName, hookId)`
|
||||||
|
- `clear(hookName?)`
|
||||||
|
- `has(hookName)` / `list(hookName)`
|
||||||
|
- `run(hookName, context)` for async handler execution
|
||||||
|
- `runWaterfall(hookName, payload, context)` for payload transformation pipeline
|
||||||
|
|
||||||
|
Design goals:
|
||||||
|
|
||||||
|
- Keep hook logic isolated in `src/hooks/`
|
||||||
|
- Make extension points explicit and reusable
|
||||||
|
- Allow priority ordering and one-time hooks
|
||||||
|
|
||||||
|
## 4) Data Flow Layer
|
||||||
|
|
||||||
|
```text
|
||||||
|
User File Input
|
||||||
|
-> FileUploadManager
|
||||||
|
-> DataProcessor
|
||||||
|
-> Visualizer / DashboardManager
|
||||||
|
-> Exporter
|
||||||
|
-> Download Output
|
||||||
|
```
|
||||||
|
|
||||||
|
Potential hook interception points (recommended):
|
||||||
|
|
||||||
|
- `beforeFileParse`
|
||||||
|
- `afterFileParse`
|
||||||
|
- `beforeAnalysis`
|
||||||
|
- `afterAnalysis`
|
||||||
|
- `beforeExport`
|
||||||
|
- `afterExport`
|
||||||
|
|
||||||
|
## 5) Documentation and Operational Memory
|
||||||
|
|
||||||
|
Beyond source code, the project now includes lightweight operational artifacts:
|
||||||
|
|
||||||
|
- `CLAUDE.md` — shared brain / durable lessons learned
|
||||||
|
- `.orchestration/active_intents.yaml` — active priorities/intents
|
||||||
|
- `.orchestration/agent_trace.jsonl` — chronological action trace
|
||||||
|
- `.orchestration/intent_map.md` — mapping goals to code areas
|
||||||
|
|
||||||
|
## 6) Architectural Principles
|
||||||
|
|
||||||
|
- **Separation of concerns** between UI, orchestration, domain logic, utilities, and hooks
|
||||||
|
- **Local-first processing** for privacy and performance
|
||||||
|
- **Incremental extensibility** via Hook Engine rather than hardcoded branching
|
||||||
|
- **Documentation alignment**: structure docs must reflect actual repository state
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Excel Analyzer Project Constitution
|
||||||
|
|
||||||
|
## Preamble
|
||||||
|
|
||||||
|
We, the developers and contributors to the Excel Analyzer Project, recognize the importance of creating a robust, user-friendly, and accessible web-based tool for Excel data analysis. This constitution establishes the fundamental principles, values, and governance the structure that guide our development efforts and ensure the project's success and sustainability.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. User-Centric Design
|
||||||
|
- **1.1** The user experience SHALL be the primary consideration in all design decisions
|
||||||
|
- **1.2** The application SHALL be intuitive and accessible to users of all technical backgrounds
|
||||||
|
- **1.3** User feedback SHALL be actively sought and incorporated into development cycles
|
||||||
|
|
||||||
|
### 2. Data Privacy and Security
|
||||||
|
- **2.1** User data SHALL remain on the client-side and SHALL NOT be transmitted to external servers
|
||||||
|
- **2.2** No persistent storage of user files SHALL occur without explicit user consent
|
||||||
|
- **2.3** All data processing SHALL be transparent and auditable by the user
|
||||||
|
|
||||||
|
### 3. Open Source and Collaboration
|
||||||
|
- **3.1** The project SHALL remain open source under the MIT License
|
||||||
|
- **3.2** Contributions from the community SHALL be welcomed and encouraged
|
||||||
|
- **3.3** Development processes SHALL be transparent and documented
|
||||||
|
|
||||||
|
### 4. Technical Excellence
|
||||||
|
- **4.1** Code quality SHALL meet industry standards and best practices
|
||||||
|
- **4.2** Performance optimization SHALL be prioritized for user experience
|
||||||
|
- **4.3** Cross-browser compatibility SHALL be maintained and tested
|
||||||
|
|
||||||
|
### 5. Accessibility and Inclusion
|
||||||
|
- **5.1** The application SHALL comply with WCAG 2.1 accessibility guidelines
|
||||||
|
- **5.2** Support for assistive technologies SHALL be maintained
|
||||||
|
- **5.3** Internationalization SHALL be considered in design and implementation
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### 6. Code Standards
|
||||||
|
- **6.1** All code SHALL be well-documented with clear comments and documentation
|
||||||
|
- **6.2** Consistent coding style SHALL be enforced using automated tools
|
||||||
|
- **6.3** Code reviews SHALL be mandatory for all contributions
|
||||||
|
- **6.4** Unit tests SHALL be written for all new functionality
|
||||||
|
|
||||||
|
### 7. Quality Assurance
|
||||||
|
- **7.1** All features SHALL be thoroughly tested before release
|
||||||
|
- **7.2** Performance benchmarks SHALL be established and monitored
|
||||||
|
- **7.3** Security vulnerabilities SHALL be addressed promptly
|
||||||
|
- **7.4** User acceptance testing SHALL be conducted for major features
|
||||||
|
|
||||||
|
### 8. Version Control and Releases
|
||||||
|
- **8.1** Git SHALL be used for version control with a clear branching strategy
|
||||||
|
- **8.2** Semantic versioning SHALL be followed for releases
|
||||||
|
- **8.3** Release notes SHALL document all changes and improvements
|
||||||
|
- **8.4** Backward compatibility SHALL be maintained whenever possible
|
||||||
|
|
||||||
|
## Project Governance
|
||||||
|
|
||||||
|
### 9. Decision Making
|
||||||
|
- **9.1** Technical decisions SHALL be made through consensus among core contributors
|
||||||
|
- **9.2** Major architectural changes SHALL require community discussion
|
||||||
|
- **9.3** User-facing changes SHALL be validated through user testing
|
||||||
|
- **9.4** Emergency fixes MAY be implemented immediately with post-facto documentation
|
||||||
|
|
||||||
|
### 10. Contribution Process
|
||||||
|
- **10.1** All contributors SHALL adhere to the project's code of conduct
|
||||||
|
- **10.2** Pull requests SHALL include appropriate tests and documentation
|
||||||
|
- **10.3** Contributors SHALL be respectful and constructive in all interactions
|
||||||
|
- **10.4** Mentorship of new contributors SHALL be encouraged
|
||||||
|
|
||||||
|
### 11. Maintenance and Support
|
||||||
|
- **11.1** Regular maintenance updates SHALL be scheduled and published
|
||||||
|
- **11.2** Bug reports SHALL be acknowledged and addressed in a timely manner
|
||||||
|
- **11.3** Security issues SHALL be handled with appropriate urgency
|
||||||
|
- **11.4** Legacy support SHALL be maintained for reasonable time periods
|
||||||
|
|
||||||
|
## Ethical Guidelines
|
||||||
|
|
||||||
|
### 12. Data Ethics
|
||||||
|
- **12.1** The application SHALL NOT make assumptions or inferences about user data
|
||||||
|
- **12.2** Data analysis features SHALL be transparent about their methodologies
|
||||||
|
- **12.3** Users SHALL retain full control over their data and analysis results
|
||||||
|
- **12.4** The application SHALL NOT collect or analyze user behavior without consent
|
||||||
|
|
||||||
|
### 13. Responsible Development
|
||||||
|
- **13.1** Environmental impact of the application SHALL be considered
|
||||||
|
- **13.2** Resource efficiency SHALL be optimized to reduce computational waste
|
||||||
|
- **13.3** Dependencies SHALL be chosen with security and maintenance in mind
|
||||||
|
- **13.4** The project SHALL contribute positively to the open-source ecosystem
|
||||||
|
|
||||||
|
## Amendments
|
||||||
|
|
||||||
|
### 14. Constitution Updates
|
||||||
|
- **14.1** This constitution MAY be amended through community consensus
|
||||||
|
- **14.2** Proposed amendments SHALL be discussed publicly for a minimum of 30 days
|
||||||
|
- **14.3** Amendments SHALL require approval from the core development team
|
||||||
|
- **14.4** All amendments SHALL be documented with rationale and impact assessment
|
||||||
|
|
||||||
|
## Commitment
|
||||||
|
|
||||||
|
By contributing to this project, all participants agree to uphold these principles and work towards creating an Excel analysis tool that is not only technically excellent but also ethically sound, user-focused, and accessible to all.
|
||||||
|
|
||||||
|
**Adopted**: February 17, 2026
|
||||||
|
**Version**: 1.0
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
# Excel Analyzer Project Implementation Plan
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This document outlines the comprehensive implementation plan for the web-based Excel Analyzer project, detailing the development phases, milestones, and resource allocation required to deliver a complete solution.
|
||||||
|
|
||||||
|
## Project Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation Setup (Week 1)
|
||||||
|
**Duration**: 2 days
|
||||||
|
**Objective**: Establish project structure and basic infrastructure
|
||||||
|
|
||||||
|
#### 1.1 Project Initialization
|
||||||
|
- [ ] Create project directory structure
|
||||||
|
- [ ] Set up HTML, CSS, and JavaScript file organization
|
||||||
|
- [ ] Configure development environment
|
||||||
|
- [ ] Initialize documentation files
|
||||||
|
|
||||||
|
#### 1.2 Core Dependencies Setup
|
||||||
|
- [ ] Integrate SheetJS (xlsx.js) for Excel file processing
|
||||||
|
- [ ] Add Chart.js for data visualization
|
||||||
|
- [ ] Include jsPDF for PDF export functionality
|
||||||
|
- [ ] Set up CSS framework (Bootstrap or custom styles)
|
||||||
|
|
||||||
|
#### 1.3 Basic File Upload Interface
|
||||||
|
- [ ] Create main HTML structure
|
||||||
|
- [ ] Implement file input and drag-and-drop functionality
|
||||||
|
- [ ] Add file validation and error handling
|
||||||
|
- [ ] Create basic progress indicators
|
||||||
|
|
||||||
|
**Deliverable**: Basic file upload and validation system
|
||||||
|
|
||||||
|
### Phase 2: Data Processing Engine (Week 2)
|
||||||
|
**Duration**: 1 days
|
||||||
|
**Objective**: Implement core data analysis and preprocessing capabilities
|
||||||
|
|
||||||
|
#### 2.1 Data Parsing and Analysis
|
||||||
|
- [ ] Implement Excel file parsing using SheetJS
|
||||||
|
- [ ] Create data structure for internal data representation
|
||||||
|
- [ ] Develop data type detection algorithms
|
||||||
|
- [ ] Implement basic data statistics calculation
|
||||||
|
|
||||||
|
#### 2.2 Data Preprocessing Module
|
||||||
|
- [ ] Missing value detection and handling
|
||||||
|
- [ ] Outlier identification algorithms
|
||||||
|
- [ ] Data cleaning strategies implementation
|
||||||
|
- [ ] Data validation and quality checks
|
||||||
|
|
||||||
|
#### 2.3 Data Observation Interface
|
||||||
|
- [ ] Create data preview table component
|
||||||
|
- [ ] Implement data summary display
|
||||||
|
- [ ] Add data quality indicators
|
||||||
|
- [ ] Create interactive data exploration features
|
||||||
|
|
||||||
|
**Deliverable**: Complete data processing and observation system
|
||||||
|
|
||||||
|
### Phase 3: Visualization System (Week 3)
|
||||||
|
**Duration**: 1-2 days
|
||||||
|
**Objective**: Develop comprehensive data visualization capabilities
|
||||||
|
|
||||||
|
#### 3.1 Chart Generation Engine
|
||||||
|
- [ ] Implement automatic chart type selection based on data
|
||||||
|
- [ ] Create bar chart visualization
|
||||||
|
- [ ] Create line chart visualization
|
||||||
|
- [ ] Create pie chart visualization
|
||||||
|
- [ ] Create scatter plot visualization
|
||||||
|
|
||||||
|
#### 3.2 Interactive Visualization Features
|
||||||
|
- [ ] Add chart customization options
|
||||||
|
- [ ] Implement zoom and pan functionality
|
||||||
|
- [ ] Add tooltips and data point highlighting
|
||||||
|
- [ ] Create real-time chart updates
|
||||||
|
|
||||||
|
#### 3.3 Visualization Interface
|
||||||
|
- [ ] Create chart selection and configuration panel
|
||||||
|
- [ ] Implement chart arrangement and layout options
|
||||||
|
- [ ] Add chart export functionality
|
||||||
|
- [ ] Create visualization history and presets
|
||||||
|
|
||||||
|
**Deliverable**: Complete interactive visualization system
|
||||||
|
|
||||||
|
### Phase 4: Dashboard and User Interface (Week 4)
|
||||||
|
**Duration**: 1 day
|
||||||
|
**Objective**: Build comprehensive dashboard and enhance user experience
|
||||||
|
|
||||||
|
#### 4.1 Dashboard Framework
|
||||||
|
- [ ] Create dashboard layout system
|
||||||
|
- [ ] Implement widget-based dashboard components
|
||||||
|
- [ ] Add dashboard customization and arrangement
|
||||||
|
- [ ] Create dashboard state management
|
||||||
|
|
||||||
|
#### 4.2 Advanced User Interface
|
||||||
|
- [ ] Implement responsive design for mobile devices
|
||||||
|
- [ ] Add accessibility features (WCAG compliance)
|
||||||
|
- [ ] Create user preferences and settings
|
||||||
|
- [ ] Implement keyboard navigation
|
||||||
|
|
||||||
|
#### 4.3 Data Filtering and Interaction
|
||||||
|
- [ ] Add real-time data filtering capabilities
|
||||||
|
- [ ] Implement cross-chart data linking
|
||||||
|
- [ ] Create data drill-down features
|
||||||
|
- [ ] Add dashboard export and sharing options
|
||||||
|
|
||||||
|
**Deliverable**: Complete interactive dashboard system
|
||||||
|
|
||||||
|
### Phase 5: Export and Advanced Features (Week 5)
|
||||||
|
**Duration**: 1 day
|
||||||
|
**Objective**: Implement export functionality and advanced features
|
||||||
|
|
||||||
|
#### 5.1 Export Module
|
||||||
|
- [ ] Implement PDF export with charts and analysis
|
||||||
|
- [ ] Create Excel export for processed data
|
||||||
|
- [ ] Add export progress indicators
|
||||||
|
- [ ] Implement batch export functionality
|
||||||
|
|
||||||
|
#### 5.2 Advanced Analytics
|
||||||
|
- [ ] Add statistical analysis features
|
||||||
|
- [ ] Implement trend analysis
|
||||||
|
- [ ] Create data comparison tools
|
||||||
|
- [ ] Add custom calculation support
|
||||||
|
|
||||||
|
#### 5.3 Performance Optimization
|
||||||
|
- [ ] Optimize file processing for large datasets
|
||||||
|
- [ ] Implement lazy loading for charts
|
||||||
|
- [ ] Add memory management for large files
|
||||||
|
- [ ] Optimize export generation speed
|
||||||
|
|
||||||
|
**Deliverable**: Complete export system and advanced analytics
|
||||||
|
|
||||||
|
### Phase 6: Testing and Documentation (Week 6)
|
||||||
|
**Duration**: 1 days
|
||||||
|
**Objective**: Ensure quality and create comprehensive documentation
|
||||||
|
|
||||||
|
#### 6.1 Testing and Quality Assurance
|
||||||
|
- [ ] Create comprehensive test suite
|
||||||
|
- [ ] Perform cross-browser compatibility testing
|
||||||
|
- [ ] Conduct performance testing
|
||||||
|
- [ ] Implement user acceptance testing
|
||||||
|
|
||||||
|
#### 6.2 Documentation and Help System
|
||||||
|
- [ ] Create user guide and documentation
|
||||||
|
- [ ] Implement in-app help system
|
||||||
|
- [ ] Create API documentation
|
||||||
|
- [ ] Add tooltips and contextual help
|
||||||
|
|
||||||
|
#### 6.3 Final Polish and Optimization
|
||||||
|
- [ ] Address all identified bugs and issues
|
||||||
|
- [ ] Optimize code for production
|
||||||
|
- [ ] Create deployment package
|
||||||
|
- [ ] Final performance tuning
|
||||||
|
|
||||||
|
**Deliverable**: Production-ready application with complete documentation
|
||||||
|
|
||||||
|
## Resource Allocation
|
||||||
|
|
||||||
|
### Development Team
|
||||||
|
- **Project Manager**: 1 person (part-time)
|
||||||
|
- **Frontend Developer**: 1 person (full-time)
|
||||||
|
- **UI/UX Designer**: 1 person (part-time)
|
||||||
|
- **Quality Assurance**: 1 person (part-time)
|
||||||
|
|
||||||
|
### Technology Resources
|
||||||
|
- **Development Environment**: Modern web browser, code editor
|
||||||
|
- **Libraries**: SheetJS, Chart.js, jsPDF, Bootstrap
|
||||||
|
- **Testing Tools**: Browser developer tools, manual testing
|
||||||
|
- **Documentation**: Markdown, HTML documentation
|
||||||
|
|
||||||
|
### Time Allocation
|
||||||
|
- **Total Duration**: 1 weeks
|
||||||
|
- **Development Time**: 60-70 hours
|
||||||
|
- **Testing Time**: 10-12 hours
|
||||||
|
- **Documentation Time**: 2-3 hours
|
||||||
|
|
||||||
|
## Risk Management
|
||||||
|
|
||||||
|
### Technical Risks
|
||||||
|
- **Large File Processing**: Mitigation through chunking and optimization
|
||||||
|
- **Browser Compatibility**: Mitigation through comprehensive testing
|
||||||
|
- **Performance Issues**: Mitigation through optimization and best practices
|
||||||
|
|
||||||
|
### Project Risks
|
||||||
|
- **Scope Creep**: Mitigation through clear requirements and phase boundaries
|
||||||
|
- **Resource Constraints**: Mitigation through careful planning and prioritization
|
||||||
|
- **Timeline Delays**: Mitigation through buffer time and parallel development
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Functional Success
|
||||||
|
- [ ] All specified features implemented and working
|
||||||
|
- [ ] File processing handles up to 10,000 rows efficiently
|
||||||
|
- [ ] Export functionality produces valid files
|
||||||
|
- [ ] Dashboard provides interactive data exploration
|
||||||
|
|
||||||
|
### Quality Success
|
||||||
|
- [ ] Application works across all major browsers
|
||||||
|
- [ ] Performance meets specified benchmarks
|
||||||
|
- [ ] Code quality meets established standards
|
||||||
|
- [ ] User interface is intuitive and accessible
|
||||||
|
|
||||||
|
### User Success
|
||||||
|
- [ ] Users can complete analysis tasks within 15 minutes
|
||||||
|
- [ ] Application receives positive user feedback
|
||||||
|
- [ ] Documentation enables self-service usage
|
||||||
|
- [ ] Application meets accessibility standards
|
||||||
|
|
||||||
|
## Post-Implementation
|
||||||
|
|
||||||
|
### Maintenance Plan
|
||||||
|
- Regular security updates for dependencies
|
||||||
|
- Performance monitoring and optimization
|
||||||
|
- User feedback collection and implementation
|
||||||
|
- Bug fixes and minor feature enhancements
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
- Cloud storage integration
|
||||||
|
- Advanced machine learning features
|
||||||
|
- Collaboration and sharing capabilities
|
||||||
|
- Mobile app development
|
||||||
|
|
||||||
|
This implementation plan provides a structured approach to developing the Excel Analyzer project, ensuring all requirements are met while maintaining quality and user satisfaction.
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Excel Analyzer — Project Structure (Revised)
|
||||||
|
|
||||||
|
This document reflects the current repository layout after adding:
|
||||||
|
|
||||||
|
- `src/hooks/hook-engine.js`
|
||||||
|
- `.orchestration/*`
|
||||||
|
- `CLAUDE.md`
|
||||||
|
|
||||||
|
## Repository Tree
|
||||||
|
|
||||||
|
```text
|
||||||
|
Week1_TRP1_Intent_based_planning/
|
||||||
|
├── README.md
|
||||||
|
├── specify/
|
||||||
|
│ └── specification.md
|
||||||
|
└── Excel-Based-Data-Analzer/
|
||||||
|
├── README.md
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── .orchestration/
|
||||||
|
│ ├── active_intents.yaml
|
||||||
|
│ ├── agent_trace.jsonl
|
||||||
|
│ └── intent_map.md
|
||||||
|
├── specify/
|
||||||
|
│ ├── specification.md
|
||||||
|
│ ├── memory/
|
||||||
|
│ │ └── constitution.md
|
||||||
|
│ ├── plan/
|
||||||
|
│ │ └── plan.md
|
||||||
|
│ ├── architecture/
|
||||||
|
│ │ └── architecture.md
|
||||||
|
│ └── project_structure/
|
||||||
|
│ └── project_structure.md
|
||||||
|
└── src/
|
||||||
|
├── index.html
|
||||||
|
├── css/
|
||||||
|
│ └── styles.css
|
||||||
|
├── hooks/
|
||||||
|
│ └── hook-engine.js
|
||||||
|
└── js/
|
||||||
|
├── main.js
|
||||||
|
├── config/
|
||||||
|
│ ├── defaults.js
|
||||||
|
│ ├── settings.js
|
||||||
|
│ └── themes.js
|
||||||
|
├── events/
|
||||||
|
│ ├── event-handlers.js
|
||||||
|
│ └── event-system.js
|
||||||
|
├── modules/
|
||||||
|
│ ├── analytics.js
|
||||||
|
│ ├── dashboard.js
|
||||||
|
│ ├── data-processor.js
|
||||||
|
│ ├── exporter.js
|
||||||
|
│ ├── file-upload.js
|
||||||
|
│ ├── help.js
|
||||||
|
│ ├── notifications.js
|
||||||
|
│ ├── settings.js
|
||||||
|
│ └── visualizer.js
|
||||||
|
└── utils/
|
||||||
|
├── formatters.js
|
||||||
|
├── helpers.js
|
||||||
|
└── validators.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure Notes
|
||||||
|
|
||||||
|
- `src/hooks/` is reserved for hook-system code (currently `hook-engine.js`).
|
||||||
|
- `.orchestration/` stores operational memory and tracing artifacts.
|
||||||
|
- `CLAUDE.md` stores durable lessons and working agreements.
|
||||||
|
- `specify/` contains project governance/planning/specification documents.
|
||||||
|
|
||||||
|
## Maintenance Rule
|
||||||
|
|
||||||
|
Whenever files/folders are reorganized, update this
|
||||||
|
document and both README files in the same change.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
# Excel Analyzer Project Specification
|
||||||
|
|
||||||
|
## 1. Functional Requirements
|
||||||
|
|
||||||
|
### 1.1 File Upload Module
|
||||||
|
- **FR-001**: System SHALL accept Excel files (.xlsx, .xls .csv) from users
|
||||||
|
- **FR-002**: System SHALL validate file format and size (max 50MB)
|
||||||
|
- **FR-003**: System SHALL display file upload progress
|
||||||
|
- **FR-004**: System SHALL provide error messages for invalid files
|
||||||
|
- **FR-005**: System SHALL support drag-and-drop file upload
|
||||||
|
|
||||||
|
### 1.2 Data Observation Module
|
||||||
|
- **FR-006**: System SHALL display uploaded data in a tabular format
|
||||||
|
- **FR-007**: System SHALL show basic data statistics (rows, columns, data types)
|
||||||
|
- **FR-008**: System SHALL allow users to preview first 100 rows
|
||||||
|
- **FR-009**: System SHALL highlight missing values and data anomalies
|
||||||
|
- **FR-010**: System SHALL provide data summary information
|
||||||
|
|
||||||
|
### 1.3 Data Preprocessing Module
|
||||||
|
- **FR-011**: System SHALL detect and handle missing values
|
||||||
|
- **FR-012**: System SHALL identify and flag outliers
|
||||||
|
- **FR-013**: System SHALL provide options for data cleaning strategies
|
||||||
|
- **FR-014**: System SHALL allow users to apply preprocessing rules
|
||||||
|
- **FR-015**: System SHALL maintain data integrity during preprocessing
|
||||||
|
|
||||||
|
### 1.4 Visualization Module
|
||||||
|
- **FR-016**: System SHALL generate automatic charts based on data types
|
||||||
|
- **FR-017**: System SHALL support multiple chart types (bar, line, pie, scatter)
|
||||||
|
- **FR-018**: System SHALL allow users to customize chart appearance
|
||||||
|
- **FR-019**: System SHALL provide interactive chart features (zoom, pan, tooltips)
|
||||||
|
- **FR-020**: System SHALL support real-time chart updates
|
||||||
|
|
||||||
|
### 1.5 Dashboard Module
|
||||||
|
- **FR-021**: System SHALL provide an interactive dashboard interface
|
||||||
|
- **FR-022**: System SHALL allow users to arrange and customize dashboard widgets
|
||||||
|
- **FR-023**: System SHALL support multiple dashboard layouts
|
||||||
|
- **FR-024**: System SHALL provide real-time data filtering capabilities
|
||||||
|
- **FR-025**: System SHALL maintain dashboard state between sessions
|
||||||
|
|
||||||
|
### 1.6 Export Module
|
||||||
|
- **FR-026**: System SHALL export analysis results as PDF files
|
||||||
|
- **FR-027**: System SHALL export processed data as Excel files
|
||||||
|
- **FR-028**: System SHALL include charts and visualizations in exports
|
||||||
|
- **FR-029**: System SHALL provide export progress indication
|
||||||
|
- **FR-030**: System SHALL support batch export functionality
|
||||||
|
|
||||||
|
## 2. Non-Functional Requirements
|
||||||
|
|
||||||
|
### 2.1 Performance Requirements
|
||||||
|
- **NFR-001**: System SHALL load and process files up to 10,000 rows in under 10 seconds
|
||||||
|
- **NFR-002**: System SHALL handle concurrent user sessions (up to 100 users)
|
||||||
|
- **NFR-003**: Dashboard updates SHALL respond within 2 seconds
|
||||||
|
- **NFR-004**: Chart rendering SHALL complete within 3 seconds
|
||||||
|
|
||||||
|
### 2.2 Usability Requirements
|
||||||
|
- **NFR-005**: System SHALL be usable by users with basic computer skills
|
||||||
|
- **NFR-006**: Learning curve SHALL be under 15 minutes for basic functionality
|
||||||
|
- **NFR-007**: System SHALL provide clear error messages and help documentation
|
||||||
|
- **NFR-008**: Interface SHALL be responsive and work on various screen sizes
|
||||||
|
|
||||||
|
### 2.3 Security Requirements
|
||||||
|
- **NFR-009**: System SHALL not store uploaded files permanently
|
||||||
|
- **NFR-010**: Data processing SHALL occur client-side only
|
||||||
|
- **NFR-011**: System SHALL validate all user inputs
|
||||||
|
- **NFR-012**: No sensitive data SHALL be transmitted to external servers
|
||||||
|
|
||||||
|
### 2.4 Compatibility Requirements
|
||||||
|
- **NFR-013**: System SHALL work in modern web browsers (Chrome, Firefox, Safari, Edge)
|
||||||
|
- **NFR-014**: System SHALL support both Windows and macOS operating systems
|
||||||
|
- **NFR-015**: System SHALL be accessible on mobile devices with limited functionality
|
||||||
|
|
||||||
|
## 3. System Constraints
|
||||||
|
|
||||||
|
### 3.1 Technical Constraints
|
||||||
|
- **C-001**: Application MUST run entirely in the browser (no server required)
|
||||||
|
- **C-002**: File processing MUST use client-side JavaScript libraries
|
||||||
|
- **C-003**: Application MUST work offline after initial load
|
||||||
|
- **C-004**: Total application size MUST be under 5MB
|
||||||
|
|
||||||
|
### 3.2 Business Constraints
|
||||||
|
- **C-005**: Development MUST use open-source technologies
|
||||||
|
- **C-006**: Application MUST be free to use
|
||||||
|
- **C-007**: No user registration or authentication required
|
||||||
|
- **C-008**: Application MUST comply with data privacy regulations
|
||||||
|
|
||||||
|
## 4. User Interface Requirements
|
||||||
|
|
||||||
|
### 4.1 Layout Requirements
|
||||||
|
- **UI-001**: Application SHALL have a clean, intuitive interface
|
||||||
|
- **UI-002**: Main workspace SHALL be divided into logical sections
|
||||||
|
- **UI-003**: Navigation SHALL be consistent across all pages
|
||||||
|
- **UI-004**: Color scheme SHALL be professional and accessible
|
||||||
|
|
||||||
|
### 4.2 Interaction Requirements
|
||||||
|
- **UI-005**: All interactive elements SHALL provide visual feedback
|
||||||
|
- **UI-006**: Keyboard navigation SHALL be supported
|
||||||
|
- **UI-007**: Touch interactions SHALL work on mobile devices
|
||||||
|
- **UI-008**: Loading states SHALL be clearly indicated
|
||||||
|
|
||||||
|
## 5. Data Requirements
|
||||||
|
|
||||||
|
### 5.1 Input Data
|
||||||
|
- **D-001**: Excel files (.xlsx, .xls) with maximum 10,000 rows
|
||||||
|
- **D-002**: Data types: text, numbers, dates, boolean values
|
||||||
|
- **D-003**: Multiple worksheets support (primary focus on first sheet)
|
||||||
|
- **D-004**: File size limit: 50MB maximum
|
||||||
|
|
||||||
|
### 5.2 Output Data
|
||||||
|
- **D-005**: Processed data in JSON format for internal use
|
||||||
|
- **D-006**: PDF reports with charts and analysis summary
|
||||||
|
- **D-007**: Excel files with cleaned and processed data
|
||||||
|
- **D-008**: Chart configurations and dashboard layouts
|
||||||
|
|
||||||
|
## 6. Quality Attributes
|
||||||
|
|
||||||
|
### 6.1 Reliability
|
||||||
|
- **QA-001**: System SHALL handle file parsing errors gracefully
|
||||||
|
- **QA-002**: Data processing SHALL be consistent across browser sessions
|
||||||
|
- **QA-003**: System SHALL recover from JavaScript errors without crashing
|
||||||
|
- **QA-004**: Export functionality SHALL produce valid files
|
||||||
|
|
||||||
|
### 6.2 Maintainability
|
||||||
|
- **QA-005**: Code SHALL be well-documented and modular
|
||||||
|
- **QA-006**: System SHALL use established JavaScript libraries
|
||||||
|
- **QA-007**: Components SHALL be easily testable
|
||||||
|
- **QA-008**: Code SHALL follow consistent naming conventions
|
||||||
|
|
||||||
|
### 6.3 Scalability
|
||||||
|
- **QA-009**: System SHALL handle increasing data complexity
|
||||||
|
- **QA-010**: Visualization components SHALL be extensible
|
||||||
|
- **QA-011**: New chart types SHALL be easily added
|
||||||
|
- **QA-012**: Dashboard widgets SHALL be modular and reusable
|
||||||
|
|
@ -0,0 +1,737 @@
|
||||||
|
/* Excel Analyzer - Main Styles of Web Application */
|
||||||
|
|
||||||
|
/* CSS Custom Properties for Theming — Purple, Gold, Black & White Palette */
|
||||||
|
:root {
|
||||||
|
--primary-color: #6a0dad; /* Deep Royal Purple */
|
||||||
|
--secondary-color: #FFD700; /* Gold */
|
||||||
|
--success-color: #4caf50;
|
||||||
|
--danger-color: #e53935;
|
||||||
|
--warning-color: #FFD700; /* Gold as warning accent */
|
||||||
|
--info-color: #ab47bc; /* Light Purple */
|
||||||
|
--light-color: #ffffff;
|
||||||
|
--dark-color: #0d0d0d; /* Near Black */
|
||||||
|
|
||||||
|
--bg-primary: #ffffff; /* White background */
|
||||||
|
--bg-secondary: #f5f0ff; /* Very light purple tint */
|
||||||
|
--bg-accent: #ede7f6; /* Soft lavender accent */
|
||||||
|
|
||||||
|
--text-primary: #0d0d0d; /* Near Black text */
|
||||||
|
--text-secondary: #4a148c; /* Dark Purple text */
|
||||||
|
--text-muted: #7b5ea7; /* Muted purple */
|
||||||
|
|
||||||
|
--border-color: #ce93d8; /* Light purple border */
|
||||||
|
--shadow: 0 0.125rem 0.25rem rgba(106, 13, 173, 0.15);
|
||||||
|
--shadow-lg: 0 0.5rem 1rem rgba(106, 13, 173, 0.25);
|
||||||
|
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
--line-height-base: 1.5;
|
||||||
|
--border-radius: 0.375rem;
|
||||||
|
--border-radius-lg: 0.5rem;
|
||||||
|
--border-radius-sm: 0.25rem;
|
||||||
|
|
||||||
|
/* Gold accent for highlights */
|
||||||
|
--gold-color: #FFD700;
|
||||||
|
--gold-dark: #c9a800;
|
||||||
|
--purple-dark: #4a148c;
|
||||||
|
--purple-mid: #6a0dad;
|
||||||
|
--purple-light: #ab47bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Theme — Black, Purple & Gold */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--primary-color: #ce93d8; /* Light Purple on dark */
|
||||||
|
--secondary-color: #FFD700; /* Gold */
|
||||||
|
--success-color: #66bb6a;
|
||||||
|
--danger-color: #ef5350;
|
||||||
|
--warning-color: #FFD700;
|
||||||
|
--info-color: #ba68c8;
|
||||||
|
|
||||||
|
--bg-primary: #0d0d0d; /* True Black */
|
||||||
|
--bg-secondary: #1a0a2e; /* Very Dark Purple */
|
||||||
|
--bg-accent: #2d1b4e; /* Dark Purple accent */
|
||||||
|
|
||||||
|
--text-primary: #ffffff; /* White text */
|
||||||
|
--text-secondary: #FFD700; /* Gold text */
|
||||||
|
--text-muted: #ce93d8; /* Light purple muted */
|
||||||
|
|
||||||
|
--border-color: #4a148c; /* Dark purple border */
|
||||||
|
--shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.6);
|
||||||
|
--shadow-lg: 0 0.5rem 1rem rgba(206, 147, 216, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset and Base Styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--success-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: var(--danger-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--warning-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-info {
|
||||||
|
color: var(--info-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
word-wrap: break-word;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
background-clip: border-box;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
border-top-left-radius: calc(var(--border-radius) - 1px);
|
||||||
|
border-top-right-radius: calc(var(--border-radius) - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
border-bottom-right-radius: calc(var(--border-radius) - 1px);
|
||||||
|
border-bottom-left-radius: calc(var(--border-radius) - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--success-color);
|
||||||
|
border-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
border-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
color: #212529;
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
border-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--info-color);
|
||||||
|
border-color: var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-control {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
background-clip: padding-box;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 2.25rem 0.375rem 0.75rem;
|
||||||
|
-moz-padding-start: calc(0.75rem - 3px);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-size: 16px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bars */
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
height: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background-color: var(--bg-accent);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-accent-bg: var(--bg-secondary);
|
||||||
|
--bs-table-striped-color: var(--text-primary);
|
||||||
|
--bs-table-striped-bg: var(--bg-accent);
|
||||||
|
--bs-table-active-color: var(--text-primary);
|
||||||
|
--bs-table-active-bg: var(--bg-accent);
|
||||||
|
--bs-table-hover-color: var(--text-primary);
|
||||||
|
--bs-table-hover-bg: var(--bg-accent);
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
vertical-align: top;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table > :not(caption) > * > * {
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
background-color: var(--bs-table-bg);
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
box-shadow: inset 0 0 0 9999px var(--bs-table-accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-striped > tbody > tr:nth-of-type(odd) > * {
|
||||||
|
--bs-table-accent-bg: var(--bs-table-striped-bg);
|
||||||
|
color: var(--bs-table-striped-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover > tbody > tr:hover > * {
|
||||||
|
--bs-table-accent-bg: var(--bs-table-hover-bg);
|
||||||
|
color: var(--bs-table-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation — Purple & Gold */
|
||||||
|
.navbar {
|
||||||
|
background: linear-gradient(135deg, #4a148c 0%, #6a0dad 60%, #0d0d0d 100%) !important;
|
||||||
|
box-shadow: 0 2px 12px rgba(106, 13, 173, 0.4);
|
||||||
|
border-bottom: 3px solid #FFD700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: #FFD700 !important;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand:hover {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: rgba(255, 255, 255, 0.85) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: #FFD700 !important;
|
||||||
|
background-color: rgba(255, 215, 0, 0.1);
|
||||||
|
border-bottom-color: #FFD700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: #FFD700 !important;
|
||||||
|
background-color: rgba(255, 215, 0, 0.15);
|
||||||
|
border-bottom-color: #FFD700;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Headers — Purple accent */
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(90deg, var(--purple-dark) 0%, var(--purple-mid) 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: 2px solid var(--gold-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2,
|
||||||
|
.card-header h3,
|
||||||
|
.card-header h4,
|
||||||
|
.card-header h5,
|
||||||
|
.card-header h6 {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .btn-outline-primary {
|
||||||
|
color: #FFD700;
|
||||||
|
border-color: #FFD700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .btn-outline-primary:hover {
|
||||||
|
background-color: #FFD700;
|
||||||
|
color: #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .btn-outline-secondary {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .btn-outline-secondary:hover {
|
||||||
|
background-color: rgba(255,255,255,0.15);
|
||||||
|
color: #FFD700;
|
||||||
|
border-color: #FFD700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop Zone — Purple & Gold */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--purple-light) !important;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drop-zone.drag-over {
|
||||||
|
border-color: var(--gold-color) !important;
|
||||||
|
background-color: rgba(255, 215, 0, 0.05);
|
||||||
|
box-shadow: 0 0 20px rgba(255, 215, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Button — Purple with Gold hover */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6a0dad, #4a148c);
|
||||||
|
border-color: #6a0dad;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-primary:focus {
|
||||||
|
background: linear-gradient(135deg, #FFD700, #c9a800);
|
||||||
|
border-color: #FFD700;
|
||||||
|
color: #0d0d0d;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer — Black with Gold & Purple accents */
|
||||||
|
footer {
|
||||||
|
background: linear-gradient(135deg, #0d0d0d 0%, #1a0a2e 100%) !important;
|
||||||
|
border-top: 3px solid #FFD700;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer h5 {
|
||||||
|
color: #FFD700;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer .text-muted {
|
||||||
|
color: var(--purple-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Cards — Purple tinted */
|
||||||
|
.card.bg-light {
|
||||||
|
background-color: var(--bg-secondary) !important;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.bg-light .card-title {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar — Gold fill */
|
||||||
|
.progress-bar {
|
||||||
|
background: linear-gradient(90deg, #6a0dad, #FFD700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Header — Purple */
|
||||||
|
.table thead th {
|
||||||
|
background-color: var(--purple-dark);
|
||||||
|
color: #FFD700;
|
||||||
|
border-color: var(--purple-mid);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar — Purple & Gold */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--purple-mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--gold-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection highlight */
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(106, 13, 173, 0.25);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
.bi {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -.125em;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.text-center {
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-start {
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-end {
|
||||||
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-flex {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row {
|
||||||
|
flex-direction: row !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-content-center {
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-content-between {
|
||||||
|
justify-content: space-between !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-items-center {
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-3 {
|
||||||
|
margin-top: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border {
|
||||||
|
border: 1px solid var(--border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-dashed {
|
||||||
|
border-style: dashed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded {
|
||||||
|
border-radius: var(--border-radius) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow {
|
||||||
|
box-shadow: var(--shadow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading States */
|
||||||
|
.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-border {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
border: 0.25em solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
-webkit-animation: spinner-border .75s linear infinite;
|
||||||
|
animation: spinner-border .75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-border {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Indicators */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--bg-accent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
background-color: rgba(40, 167, 69, 0.1);
|
||||||
|
color: var(--success-color);
|
||||||
|
border: 1px solid rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
background-color: rgba(255, 193, 7, 0.1);
|
||||||
|
color: var(--warning-color);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-danger {
|
||||||
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
|
color: var(--danger-color);
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar,
|
||||||
|
.btn,
|
||||||
|
.progress,
|
||||||
|
.card-footer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
/**
|
||||||
|
* Hook Engine
|
||||||
|
*
|
||||||
|
* Lightweight hook system for registering and executing synchronous/asynchronous hooks
|
||||||
|
* with priority and one-time execution support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class HookEngine {
|
||||||
|
constructor() {
|
||||||
|
this.hooks = new Map();
|
||||||
|
this.sequence = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a hook handler.
|
||||||
|
* @param {string} hookName
|
||||||
|
* @param {Function} handler
|
||||||
|
* @param {{ priority?: number, once?: boolean }} options
|
||||||
|
* @returns {string} Hook id
|
||||||
|
*/
|
||||||
|
tap(hookName, handler, options = {}) {
|
||||||
|
if (!hookName || typeof hookName !== 'string') {
|
||||||
|
throw new Error('hookName must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof handler !== 'function') {
|
||||||
|
throw new Error('handler must be a function');
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = Number.isFinite(options.priority) ? options.priority : 0;
|
||||||
|
const once = Boolean(options.once);
|
||||||
|
const id = `${hookName}:${Date.now()}:${this.sequence++}`;
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
id,
|
||||||
|
hookName,
|
||||||
|
handler,
|
||||||
|
priority,
|
||||||
|
once,
|
||||||
|
order: this.sequence
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.hooks.has(hookName)) {
|
||||||
|
this.hooks.set(hookName, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this.hooks.get(hookName);
|
||||||
|
items.push(record);
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (b.priority !== a.priority) return b.priority - a.priority;
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a one-time hook handler.
|
||||||
|
*/
|
||||||
|
tapOnce(hookName, handler, options = {}) {
|
||||||
|
return this.tap(hookName, handler, { ...options, once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific hook by id.
|
||||||
|
*/
|
||||||
|
untap(hookName, hookId) {
|
||||||
|
const items = this.hooks.get(hookName);
|
||||||
|
if (!items || !items.length) return false;
|
||||||
|
|
||||||
|
const next = items.filter((entry) => entry.id !== hookId);
|
||||||
|
this.hooks.set(hookName, next);
|
||||||
|
return next.length !== items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all handlers for a hook, or all hooks when no name provided.
|
||||||
|
*/
|
||||||
|
clear(hookName = null) {
|
||||||
|
if (!hookName) {
|
||||||
|
this.hooks.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.hooks.delete(hookName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a hook has handlers.
|
||||||
|
*/
|
||||||
|
has(hookName) {
|
||||||
|
const items = this.hooks.get(hookName);
|
||||||
|
return Boolean(items && items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List registered handlers for a hook.
|
||||||
|
*/
|
||||||
|
list(hookName) {
|
||||||
|
return [...(this.hooks.get(hookName) || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute all handlers for a hook with a shared context object.
|
||||||
|
* Returns collected results and errors.
|
||||||
|
*/
|
||||||
|
async run(hookName, context = {}) {
|
||||||
|
const handlers = [...(this.hooks.get(hookName) || [])];
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const entry of handlers) {
|
||||||
|
try {
|
||||||
|
const value = await entry.handler(context);
|
||||||
|
results.push({ id: entry.id, value });
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({ id: entry.id, error });
|
||||||
|
} finally {
|
||||||
|
if (entry.once) {
|
||||||
|
this.untap(hookName, entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute hook handlers in waterfall mode.
|
||||||
|
* Each handler can transform the payload and pass to the next handler.
|
||||||
|
*/
|
||||||
|
async runWaterfall(hookName, payload, context = {}) {
|
||||||
|
const handlers = [...(this.hooks.get(hookName) || [])];
|
||||||
|
let current = payload;
|
||||||
|
|
||||||
|
for (const entry of handlers) {
|
||||||
|
try {
|
||||||
|
const next = await entry.handler(current, context);
|
||||||
|
if (typeof next !== 'undefined') {
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (entry.once) {
|
||||||
|
this.untap(hookName, entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = HookEngine;
|
||||||
|
} else {
|
||||||
|
window.HookEngine = HookEngine;
|
||||||
|
}
|
||||||
338
apps/web-roo-code/src/components/excel-analyzer/src/index.html
Normal file
338
apps/web-roo-code/src/components/excel-analyzer/src/index.html
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Excel Analyzer - Web-based Data Analysis Tool</title>
|
||||||
|
|
||||||
|
<!-- External Libraries -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Application Styles -->
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation Bar -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="#">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet me-2"></i>
|
||||||
|
Excel Analyzer
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="#upload" data-section="upload">Upload</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#preview" data-section="preview">Preview</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#analyze" data-section="analyze">Analyze</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#dashboard" data-section="dashboard">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#export" data-section="export">Export</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button id="themeToggle" class="btn btn-sm btn-outline-light" type="button" title="Toggle Theme">
|
||||||
|
<i class="bi bi-moon-stars"></i>
|
||||||
|
</button>
|
||||||
|
<span class="navbar-text d-none d-lg-inline">
|
||||||
|
Client-side processing - Your data stays private
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="container my-4">
|
||||||
|
<!-- File Upload Section -->
|
||||||
|
<section id="upload" class="upload-section mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
<i class="bi bi-upload"></i>
|
||||||
|
Upload Excel File
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="dropZone" class="drop-zone text-center p-4 border border-dashed rounded">
|
||||||
|
<div class="drop-zone-content">
|
||||||
|
<i class="bi bi-file-earmark-excel display-4 text-muted mb-3"></i>
|
||||||
|
<h5>Drag and drop your Excel file here</h5>
|
||||||
|
<p class="text-muted">or</p>
|
||||||
|
<input type="file" id="fileInput" accept=".xlsx,.xls" class="form-control" style="display: none;">
|
||||||
|
<button type="button" id="selectFileBtn" class="btn btn-primary">
|
||||||
|
Select File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fileInfo" class="mt-3" style="display: none;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>File:</strong> <span id="fileName"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Size:</strong> <span id="fileSize"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="uploadProgress" class="progress mt-3" style="display: none;">
|
||||||
|
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="uploadStatus" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Data Preview Section -->
|
||||||
|
<section id="preview" class="preview-section mb-4" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
<i class="bi bi-table"></i>
|
||||||
|
Data Preview
|
||||||
|
</h2>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="btnFirst100">First 100 Rows</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="btnAllData">All Data</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="card-title">Total Rows</h6>
|
||||||
|
<h3 class="text-primary" id="totalRows">0</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="card-title">Total Columns</h6>
|
||||||
|
<h3 class="text-primary" id="totalCols">0</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="card-title">Missing Values</h6>
|
||||||
|
<h3 class="text-warning" id="missingValues">0</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="card-title">Data Quality</h6>
|
||||||
|
<h3 class="text-success" id="dataQuality">100%</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover" id="dataTable">
|
||||||
|
<thead id="tableHeader">
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Analysis Section -->
|
||||||
|
<section id="analyze" class="analyze-section mb-4" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
<i class="bi bi-bar-chart"></i>
|
||||||
|
Data Analysis
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Column Analysis</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="columnAnalysis"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5>Chart Selection</h5>
|
||||||
|
<select id="chartTypeSelector" class="form-select" style="width: auto;">
|
||||||
|
<option value="auto">Auto Select</option>
|
||||||
|
<option value="bar">Bar Chart</option>
|
||||||
|
<option value="line">Line Chart</option>
|
||||||
|
<option value="pie">Pie Chart</option>
|
||||||
|
<option value="scatter">Scatter Plot</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="xAxisSelector" class="form-label">X-Axis</label>
|
||||||
|
<select id="xAxisSelector" class="form-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="yAxisSelector" class="form-label">Y-Axis</label>
|
||||||
|
<select id="yAxisSelector" class="form-select"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<canvas id="analysisChart" width="400" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Dashboard Section -->
|
||||||
|
<section id="dashboard" class="dashboard-section mb-4" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
<i class="bi bi-columns"></i>
|
||||||
|
Interactive Dashboard
|
||||||
|
</h2>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="layout1">Layout 1</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="layout2">Layout 2</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="layout3">Layout 3</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="dashboard-grid" id="dashboardGrid">
|
||||||
|
<!-- Dashboard widgets will be added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Export Section -->
|
||||||
|
<section id="export" class="export-section mb-4" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
Export Results
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-file-pdf display-4 text-danger mb-3"></i>
|
||||||
|
<h5>PDF Report</h5>
|
||||||
|
<p class="text-muted">Export analysis with charts</p>
|
||||||
|
<button class="btn btn-danger" id="exportPDF">
|
||||||
|
<i class="bi bi-download"></i> Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-file-earmark-excel display-4 text-success mb-3"></i>
|
||||||
|
<h5>Excel File</h5>
|
||||||
|
<p class="text-muted">Export processed data</p>
|
||||||
|
<button class="btn btn-success" id="exportExcel">
|
||||||
|
<i class="bi bi-download"></i> Download Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-image display-4 text-info mb-3"></i>
|
||||||
|
<h5>Chart Images</h5>
|
||||||
|
<p class="text-muted">Export individual charts</p>
|
||||||
|
<button class="btn btn-info" id="exportCharts">
|
||||||
|
<i class="bi bi-download"></i> Export Charts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="progress" id="exportProgress" style="display: none;">
|
||||||
|
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div id="exportStatus" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-dark text-light py-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Excel Analyzer</h5>
|
||||||
|
<p>A powerful web-based tool for Excel data analysis and visualization.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end">
|
||||||
|
<p>© 2026 Excel Analyzer. All rights reserved.</p>
|
||||||
|
<p class="small text-muted">Client-side processing ensures your data privacy</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- External Libraries -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Application Scripts -->
|
||||||
|
<script src="js/config/defaults.js"></script>
|
||||||
|
<script src="js/config/themes.js"></script>
|
||||||
|
<script src="js/config/settings.js"></script>
|
||||||
|
<script src="js/modules/file-upload.js"></script>
|
||||||
|
<script src="js/modules/data-processor.js"></script>
|
||||||
|
<script src="js/modules/visualizer.js"></script>
|
||||||
|
<script src="js/modules/dashboard.js"></script>
|
||||||
|
<script src="js/modules/exporter.js"></script>
|
||||||
|
<script src="js/modules/notifications.js"></script>
|
||||||
|
<script src="js/utils/helpers.js"></script>
|
||||||
|
<script src="js/utils/validators.js"></script>
|
||||||
|
<script src="js/utils/formatters.js"></script>
|
||||||
|
<script src="js/events/event-system.js"></script>
|
||||||
|
<script src="js/events/event-handlers.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,506 @@
|
||||||
|
/**
|
||||||
|
* Default Values of Web Application
|
||||||
|
*
|
||||||
|
* Default configuration values and presets for the Excel Analyzer application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DefaultValues {
|
||||||
|
constructor() {
|
||||||
|
this.defaults = {
|
||||||
|
// Application Defaults
|
||||||
|
app: {
|
||||||
|
name: 'Excel Analyzer',
|
||||||
|
version: '1.0.0',
|
||||||
|
theme: 'light',
|
||||||
|
language: 'en',
|
||||||
|
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||||
|
maxRows: 10000,
|
||||||
|
maxColumns: 100
|
||||||
|
},
|
||||||
|
|
||||||
|
// File Upload Defaults
|
||||||
|
fileUpload: {
|
||||||
|
allowedTypes: [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel'
|
||||||
|
],
|
||||||
|
maxFileSize: 50 * 1024 * 1024,
|
||||||
|
enableDragDrop: true,
|
||||||
|
enableMultiple: false,
|
||||||
|
previewRows: 100
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data Processing Defaults
|
||||||
|
dataProcessing: {
|
||||||
|
defaultMissingValueHandling: 'remove',
|
||||||
|
defaultOutlierHandling: 'remove',
|
||||||
|
outlierThreshold: 3,
|
||||||
|
autoDetectDataTypes: true,
|
||||||
|
enableDataValidation: true,
|
||||||
|
maxPreviewRows: 100,
|
||||||
|
enableCaching: true,
|
||||||
|
cacheTimeout: 300000 // 5 minutes
|
||||||
|
},
|
||||||
|
|
||||||
|
// Visualization Defaults
|
||||||
|
visualization: {
|
||||||
|
defaultChartType: 'auto',
|
||||||
|
chartColors: [
|
||||||
|
'#007bff', '#28a745', '#dc3545', '#ffc107', '#17a2b8',
|
||||||
|
'#6f42c1', '#20c997', '#fd7e14', '#e83e8c', '#6c757d'
|
||||||
|
],
|
||||||
|
chartAnimation: true,
|
||||||
|
chartResponsive: true,
|
||||||
|
chartLegendPosition: 'top',
|
||||||
|
chartTooltip: true,
|
||||||
|
chartHeight: 400,
|
||||||
|
chartWidth: 600,
|
||||||
|
enableZoom: true,
|
||||||
|
enablePan: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard Defaults
|
||||||
|
dashboard: {
|
||||||
|
defaultLayout: 'grid',
|
||||||
|
maxWidgets: 10,
|
||||||
|
enableWidgetResize: true,
|
||||||
|
enableWidgetDrag: true,
|
||||||
|
autoSaveDashboard: true,
|
||||||
|
defaultWidgetSize: {
|
||||||
|
width: 4,
|
||||||
|
height: 3
|
||||||
|
},
|
||||||
|
gridColumns: 12,
|
||||||
|
gridRows: 12,
|
||||||
|
widgetMargin: 10
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export Defaults
|
||||||
|
export: {
|
||||||
|
defaultFormat: 'pdf',
|
||||||
|
includeChartsInExport: true,
|
||||||
|
includeDataInExport: true,
|
||||||
|
includeSummaryInExport: true,
|
||||||
|
exportImageQuality: 'high',
|
||||||
|
pdfPageSize: 'A4',
|
||||||
|
pdfOrientation: 'portrait',
|
||||||
|
excelSheetName: 'Processed Data',
|
||||||
|
csvDelimiter: ',',
|
||||||
|
jsonIndent: 2
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI Defaults
|
||||||
|
ui: {
|
||||||
|
theme: 'light',
|
||||||
|
language: 'en',
|
||||||
|
enableTooltips: true,
|
||||||
|
enableAnimations: true,
|
||||||
|
fontSize: 'medium',
|
||||||
|
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
boxShadow: '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)',
|
||||||
|
transitionDuration: '0.15s'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Performance Defaults
|
||||||
|
performance: {
|
||||||
|
enableVirtualization: true,
|
||||||
|
maxPreviewRows: 100,
|
||||||
|
enableCaching: true,
|
||||||
|
cacheTimeout: 300000, // 5 minutes
|
||||||
|
debounceDelay: 300,
|
||||||
|
throttleDelay: 1000,
|
||||||
|
maxConcurrentOperations: 3
|
||||||
|
},
|
||||||
|
|
||||||
|
// Privacy Defaults
|
||||||
|
privacy: {
|
||||||
|
enableAnalytics: false,
|
||||||
|
saveUserPreferences: true,
|
||||||
|
clearDataOnExit: false,
|
||||||
|
storeFileData: false,
|
||||||
|
storeProcessingHistory: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default value by path
|
||||||
|
*/
|
||||||
|
get(path, defaultValue = null) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = this.defaults;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
current = current[key];
|
||||||
|
} else {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all defaults
|
||||||
|
*/
|
||||||
|
getAll() {
|
||||||
|
return { ...this.defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get application defaults
|
||||||
|
*/
|
||||||
|
getAppDefaults() {
|
||||||
|
return { ...this.defaults.app };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file upload defaults
|
||||||
|
*/
|
||||||
|
getFileUploadDefaults() {
|
||||||
|
return { ...this.defaults.fileUpload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data processing defaults
|
||||||
|
*/
|
||||||
|
getDataProcessingDefaults() {
|
||||||
|
return { ...this.defaults.dataProcessing };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get visualization defaults
|
||||||
|
*/
|
||||||
|
getVisualizationDefaults() {
|
||||||
|
return { ...this.defaults.visualization };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dashboard defaults
|
||||||
|
*/
|
||||||
|
getDashboardDefaults() {
|
||||||
|
return { ...this.defaults.dashboard };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get export defaults
|
||||||
|
*/
|
||||||
|
getExportDefaults() {
|
||||||
|
return { ...this.defaults.export };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UI defaults
|
||||||
|
*/
|
||||||
|
getUIDefaults() {
|
||||||
|
return { ...this.defaults.ui };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get performance defaults
|
||||||
|
*/
|
||||||
|
getPerformanceDefaults() {
|
||||||
|
return { ...this.defaults.performance };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get privacy defaults
|
||||||
|
*/
|
||||||
|
getPrivacyDefaults() {
|
||||||
|
return { ...this.defaults.privacy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart color presets
|
||||||
|
*/
|
||||||
|
getChartColorPresets() {
|
||||||
|
return {
|
||||||
|
primary: ['#007bff', '#28a745', '#dc3545', '#ffc107', '#17a2b8'],
|
||||||
|
pastel: ['#a8d8f0', '#b8e6b8', '#ffd1d1', '#fff7b3', '#b3e6ff'],
|
||||||
|
vibrant: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7'],
|
||||||
|
monochrome: ['#333333', '#666666', '#999999', '#cccccc', '#ffffff'],
|
||||||
|
autumn: ['#d35400', '#e67e22', '#f1c40f', '#f39c12', '#e74c3c']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart type presets
|
||||||
|
*/
|
||||||
|
getChartTypePresets() {
|
||||||
|
return {
|
||||||
|
auto: 'Automatic selection based on data',
|
||||||
|
bar: 'Bar chart for categorical data',
|
||||||
|
line: 'Line chart for time series data',
|
||||||
|
pie: 'Pie chart for proportional data',
|
||||||
|
scatter: 'Scatter plot for correlation analysis',
|
||||||
|
doughnut: 'Doughnut chart for proportional data',
|
||||||
|
area: 'Area chart for cumulative data',
|
||||||
|
radar: 'Radar chart for multi-dimensional data'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dashboard layout presets
|
||||||
|
*/
|
||||||
|
getDashboardLayoutPresets() {
|
||||||
|
return {
|
||||||
|
grid: {
|
||||||
|
name: 'Grid Layout',
|
||||||
|
description: 'Responsive grid-based layout',
|
||||||
|
columns: 12,
|
||||||
|
rows: 12,
|
||||||
|
margin: 10,
|
||||||
|
autoResize: true
|
||||||
|
},
|
||||||
|
vertical: {
|
||||||
|
name: 'Vertical Layout',
|
||||||
|
description: 'Stacked widgets vertically',
|
||||||
|
columns: 1,
|
||||||
|
rows: 20,
|
||||||
|
margin: 15,
|
||||||
|
autoResize: false
|
||||||
|
},
|
||||||
|
horizontal: {
|
||||||
|
name: 'Horizontal Layout',
|
||||||
|
description: 'Side-by-side widgets',
|
||||||
|
columns: 20,
|
||||||
|
rows: 6,
|
||||||
|
margin: 10,
|
||||||
|
autoResize: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get export format presets
|
||||||
|
*/
|
||||||
|
getExportFormatPresets() {
|
||||||
|
return {
|
||||||
|
pdf: {
|
||||||
|
name: 'PDF Report',
|
||||||
|
description: 'Professional PDF with charts and analysis',
|
||||||
|
extension: '.pdf',
|
||||||
|
includeCharts: true,
|
||||||
|
includeData: true,
|
||||||
|
includeSummary: true
|
||||||
|
},
|
||||||
|
excel: {
|
||||||
|
name: 'Excel File',
|
||||||
|
description: 'Processed data in Excel format',
|
||||||
|
extension: '.xlsx',
|
||||||
|
includeCharts: false,
|
||||||
|
includeData: true,
|
||||||
|
includeSummary: false
|
||||||
|
},
|
||||||
|
csv: {
|
||||||
|
name: 'CSV File',
|
||||||
|
description: 'Comma-separated values',
|
||||||
|
extension: '.csv',
|
||||||
|
includeCharts: false,
|
||||||
|
includeData: true,
|
||||||
|
includeSummary: false
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
name: 'JSON File',
|
||||||
|
description: 'Structured data in JSON format',
|
||||||
|
extension: '.json',
|
||||||
|
includeCharts: false,
|
||||||
|
includeData: true,
|
||||||
|
includeSummary: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme presets
|
||||||
|
*/
|
||||||
|
getThemePresets() {
|
||||||
|
return {
|
||||||
|
light: {
|
||||||
|
name: 'Light Theme',
|
||||||
|
description: 'Clean and bright interface',
|
||||||
|
colors: {
|
||||||
|
primary: '#007bff',
|
||||||
|
secondary: '#6c757d',
|
||||||
|
success: '#28a745',
|
||||||
|
danger: '#dc3545',
|
||||||
|
warning: '#ffc107',
|
||||||
|
info: '#17a2b8',
|
||||||
|
light: '#f8f9fa',
|
||||||
|
dark: '#343a40'
|
||||||
|
},
|
||||||
|
background: '#ffffff',
|
||||||
|
text: '#212529'
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
name: 'Dark Theme',
|
||||||
|
description: 'Dark interface for reduced eye strain',
|
||||||
|
colors: {
|
||||||
|
primary: '#4dabf7',
|
||||||
|
secondary: '#66d9ef',
|
||||||
|
success: '#51cf66',
|
||||||
|
danger: '#ff6b6b',
|
||||||
|
warning: '#ffd43b',
|
||||||
|
info: '#22d3ee',
|
||||||
|
light: '#121212',
|
||||||
|
dark: '#ffffff'
|
||||||
|
},
|
||||||
|
background: '#121212',
|
||||||
|
text: '#ffffff'
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
name: 'Blue Theme',
|
||||||
|
description: 'Professional blue color scheme',
|
||||||
|
colors: {
|
||||||
|
primary: '#1976d2',
|
||||||
|
secondary: '#424242',
|
||||||
|
success: '#2e7d32',
|
||||||
|
danger: '#c62828',
|
||||||
|
warning: '#ef6c00',
|
||||||
|
info: '#0277bd',
|
||||||
|
light: '#e3f2fd',
|
||||||
|
dark: '#212121'
|
||||||
|
},
|
||||||
|
background: '#ffffff',
|
||||||
|
text: '#212121'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data type detection rules
|
||||||
|
*/
|
||||||
|
getDataTypeDetectionRules() {
|
||||||
|
return {
|
||||||
|
date: {
|
||||||
|
patterns: [
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/,
|
||||||
|
/^\d{2}\/\d{2}\/\d{4}$/,
|
||||||
|
/^\d{2}-\d{2}-\d{4}$/,
|
||||||
|
/^\d{4}\/\d{2}\/\d{2}$/
|
||||||
|
],
|
||||||
|
validators: [
|
||||||
|
(value) => !isNaN(Date.parse(value))
|
||||||
|
]
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
patterns: [
|
||||||
|
/^\d{2}:\d{2}:\d{2}$/,
|
||||||
|
/^\d{2}:\d{2}$/
|
||||||
|
],
|
||||||
|
validators: [
|
||||||
|
(value) => {
|
||||||
|
const time = value.split(':');
|
||||||
|
return time.length >= 2 &&
|
||||||
|
parseInt(time[0]) >= 0 && parseInt(time[0]) <= 23 &&
|
||||||
|
parseInt(time[1]) >= 0 && parseInt(time[1]) <= 59;
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
numeric: {
|
||||||
|
patterns: [
|
||||||
|
/^-?\d+\.?\d*$/
|
||||||
|
],
|
||||||
|
validators: [
|
||||||
|
(value) => !isNaN(parseFloat(value)) && isFinite(value)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
boolean: {
|
||||||
|
patterns: [
|
||||||
|
/^(true|false|yes|no|1|0)$/i
|
||||||
|
],
|
||||||
|
validators: [
|
||||||
|
(value) => ['true', 'false', 'yes', 'no', '1', '0'].includes(value.toLowerCase())
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get processing strategies
|
||||||
|
*/
|
||||||
|
getProcessingStrategies() {
|
||||||
|
return {
|
||||||
|
missingValues: {
|
||||||
|
remove: 'Remove rows with missing values',
|
||||||
|
mean: 'Fill with mean (numeric only)',
|
||||||
|
median: 'Fill with median (numeric only)',
|
||||||
|
mode: 'Fill with most frequent value',
|
||||||
|
forwardFill: 'Forward fill from previous value',
|
||||||
|
backwardFill: 'Backward fill from next value',
|
||||||
|
custom: 'Use custom value'
|
||||||
|
},
|
||||||
|
outliers: {
|
||||||
|
remove: 'Remove outlier values',
|
||||||
|
cap: 'Cap to threshold values',
|
||||||
|
mean: 'Replace with mean',
|
||||||
|
median: 'Replace with median',
|
||||||
|
ignore: 'Keep outliers as-is'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get validation rules
|
||||||
|
*/
|
||||||
|
getValidationRules() {
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
maxSize: 50 * 1024 * 1024, // 50MB
|
||||||
|
allowedTypes: [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel'
|
||||||
|
],
|
||||||
|
maxRows: 10000,
|
||||||
|
maxColumns: 100
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
maxPreviewRows: 100,
|
||||||
|
maxCells: 1000000,
|
||||||
|
maxStringLength: 10000
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
maxDataPoints: 1000,
|
||||||
|
maxSeries: 10,
|
||||||
|
maxLabels: 50
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
maxWidgets: 20,
|
||||||
|
minWidgetSize: { width: 2, height: 2 },
|
||||||
|
maxWidgetSize: { width: 12, height: 12 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get performance thresholds
|
||||||
|
*/
|
||||||
|
getPerformanceThresholds() {
|
||||||
|
return {
|
||||||
|
fileProcessing: {
|
||||||
|
small: 1000, // rows
|
||||||
|
medium: 10000, // rows
|
||||||
|
large: 50000 // rows
|
||||||
|
},
|
||||||
|
chartRendering: {
|
||||||
|
fast: 100, // data points
|
||||||
|
medium: 1000, // data points
|
||||||
|
slow: 10000 // data points
|
||||||
|
},
|
||||||
|
memoryUsage: {
|
||||||
|
low: 50 * 1024 * 1024, // 50MB
|
||||||
|
medium: 100 * 1024 * 1024, // 100MB
|
||||||
|
high: 500 * 1024 * 1024 // 500MB
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = DefaultValues;
|
||||||
|
} else {
|
||||||
|
window.DefaultValues = DefaultValues;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* Settings Manager of Web Application
|
||||||
|
*
|
||||||
|
* Lightweight settings storage for the Excel Analyzer app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SettingsManager {
|
||||||
|
constructor() {
|
||||||
|
this.storageKey = 'excel-analyzer-settings';
|
||||||
|
this.defaultSettings = {
|
||||||
|
maxFileSize: 50 * 1024 * 1024,
|
||||||
|
maxRows: 10000,
|
||||||
|
previewRows: 100,
|
||||||
|
theme: 'light',
|
||||||
|
ui: {
|
||||||
|
theme: 'light'
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
layout: 'layout1'
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
includeCharts: true,
|
||||||
|
includeData: true,
|
||||||
|
includeSummary: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.settings = this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(this.storageKey);
|
||||||
|
if (!raw) return this.clone(this.defaultSettings);
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return this.merge(this.defaultSettings, parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load settings, using defaults.', error);
|
||||||
|
return this.clone(this.defaultSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.storageKey, JSON.stringify(this.settings));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save settings.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path, fallback = null) {
|
||||||
|
if (!path) return this.settings;
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = this.settings;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
current = current[key];
|
||||||
|
} else {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(path, value) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = this.settings;
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
if (!current[key] || typeof current[key] !== 'object') {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = value;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(values = {}) {
|
||||||
|
this.settings = this.merge(this.settings, values);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.settings = this.clone(this.defaultSettings);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(obj) {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
merge(base, extra) {
|
||||||
|
const merged = this.clone(base);
|
||||||
|
Object.keys(extra || {}).forEach((key) => {
|
||||||
|
const baseValue = merged[key];
|
||||||
|
const extraValue = extra[key];
|
||||||
|
|
||||||
|
if (
|
||||||
|
baseValue &&
|
||||||
|
typeof baseValue === 'object' &&
|
||||||
|
!Array.isArray(baseValue) &&
|
||||||
|
extraValue &&
|
||||||
|
typeof extraValue === 'object' &&
|
||||||
|
!Array.isArray(extraValue)
|
||||||
|
) {
|
||||||
|
merged[key] = this.merge(baseValue, extraValue);
|
||||||
|
} else {
|
||||||
|
merged[key] = extraValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = SettingsManager;
|
||||||
|
} else {
|
||||||
|
window.SettingsManager = SettingsManager;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,567 @@
|
||||||
|
/**
|
||||||
|
* Theme Manager of Web Application
|
||||||
|
*
|
||||||
|
* Manages theme switching and CSS custom properties for the Excel Analyzer application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
constructor() {
|
||||||
|
this.themes = {
|
||||||
|
royal: {
|
||||||
|
name: '👑 Royal',
|
||||||
|
description: 'Purple, Gold, Black & White — elegant royal palette',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': '#6a0dad',
|
||||||
|
'--secondary-color': '#FFD700',
|
||||||
|
'--success-color': '#4caf50',
|
||||||
|
'--danger-color': '#e53935',
|
||||||
|
'--warning-color': '#FFD700',
|
||||||
|
'--info-color': '#ab47bc',
|
||||||
|
'--light-color': '#ffffff',
|
||||||
|
'--dark-color': '#0d0d0d',
|
||||||
|
|
||||||
|
'--bg-primary': '#ffffff',
|
||||||
|
'--bg-secondary': '#f5f0ff',
|
||||||
|
'--bg-accent': '#ede7f6',
|
||||||
|
|
||||||
|
'--text-primary': '#0d0d0d',
|
||||||
|
'--text-secondary': '#4a148c',
|
||||||
|
'--text-muted': '#7b5ea7',
|
||||||
|
|
||||||
|
'--border-color': '#ce93d8',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(106, 13, 173, 0.15)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(106, 13, 173, 0.25)',
|
||||||
|
|
||||||
|
'--gold-color': '#FFD700',
|
||||||
|
'--gold-dark': '#c9a800',
|
||||||
|
'--purple-dark': '#4a148c',
|
||||||
|
'--purple-mid': '#6a0dad',
|
||||||
|
'--purple-light': '#ab47bc',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'royal-dark': {
|
||||||
|
name: '🌑 Royal Dark',
|
||||||
|
description: 'Black, Deep Purple & Gold — dark royal palette',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': '#ce93d8',
|
||||||
|
'--secondary-color': '#FFD700',
|
||||||
|
'--success-color': '#66bb6a',
|
||||||
|
'--danger-color': '#ef5350',
|
||||||
|
'--warning-color': '#FFD700',
|
||||||
|
'--info-color': '#ba68c8',
|
||||||
|
'--light-color': '#0d0d0d',
|
||||||
|
'--dark-color': '#ffffff',
|
||||||
|
|
||||||
|
'--bg-primary': '#0d0d0d',
|
||||||
|
'--bg-secondary': '#1a0a2e',
|
||||||
|
'--bg-accent': '#2d1b4e',
|
||||||
|
|
||||||
|
'--text-primary': '#ffffff',
|
||||||
|
'--text-secondary': '#FFD700',
|
||||||
|
'--text-muted': '#ce93d8',
|
||||||
|
|
||||||
|
'--border-color': '#4a148c',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.6)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(206, 147, 216, 0.2)',
|
||||||
|
|
||||||
|
'--gold-color': '#FFD700',
|
||||||
|
'--gold-dark': '#c9a800',
|
||||||
|
'--purple-dark': '#4a148c',
|
||||||
|
'--purple-mid': '#6a0dad',
|
||||||
|
'--purple-light': '#ce93d8',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
name: 'Light',
|
||||||
|
description: 'Clean and bright interface',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': '#007bff',
|
||||||
|
'--secondary-color': '#6c757d',
|
||||||
|
'--success-color': '#28a745',
|
||||||
|
'--danger-color': '#dc3545',
|
||||||
|
'--warning-color': '#ffc107',
|
||||||
|
'--info-color': '#17a2b8',
|
||||||
|
'--light-color': '#f8f9fa',
|
||||||
|
'--dark-color': '#343a40',
|
||||||
|
|
||||||
|
'--bg-primary': '#ffffff',
|
||||||
|
'--bg-secondary': '#f8f9fa',
|
||||||
|
'--bg-accent': '#e9ecef',
|
||||||
|
|
||||||
|
'--text-primary': '#212529',
|
||||||
|
'--text-secondary': '#6c757d',
|
||||||
|
'--text-muted': '#6c757d',
|
||||||
|
|
||||||
|
'--border-color': '#dee2e6',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(0, 0, 0, 0.15)',
|
||||||
|
|
||||||
|
'--gold-color': '#ffc107',
|
||||||
|
'--gold-dark': '#e0a800',
|
||||||
|
'--purple-dark': '#6f42c1',
|
||||||
|
'--purple-mid': '#7b1fa2',
|
||||||
|
'--purple-light': '#ab47bc',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
name: 'Dark',
|
||||||
|
description: 'Dark interface for reduced eye strain',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': '#4dabf7',
|
||||||
|
'--secondary-color': '#66d9ef',
|
||||||
|
'--success-color': '#51cf66',
|
||||||
|
'--danger-color': '#ff6b6b',
|
||||||
|
'--warning-color': '#ffd43b',
|
||||||
|
'--info-color': '#22d3ee',
|
||||||
|
'--light-color': '#121212',
|
||||||
|
'--dark-color': '#ffffff',
|
||||||
|
|
||||||
|
'--bg-primary': '#121212',
|
||||||
|
'--bg-secondary': '#1e1e1e',
|
||||||
|
'--bg-accent': '#2d2d2d',
|
||||||
|
|
||||||
|
'--text-primary': '#ffffff',
|
||||||
|
'--text-secondary': '#b0b0b0',
|
||||||
|
'--text-muted': '#8e8e8e',
|
||||||
|
|
||||||
|
'--border-color': '#3a3a3a',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.5)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(0, 0, 0, 0.3)',
|
||||||
|
|
||||||
|
'--gold-color': '#ffd43b',
|
||||||
|
'--gold-dark': '#e0a800',
|
||||||
|
'--purple-dark': '#6f42c1',
|
||||||
|
'--purple-mid': '#7b1fa2',
|
||||||
|
'--purple-light': '#ab47bc',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
name: 'Blue',
|
||||||
|
description: 'Professional blue color scheme',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': '#1976d2',
|
||||||
|
'--secondary-color': '#424242',
|
||||||
|
'--success-color': '#2e7d32',
|
||||||
|
'--danger-color': '#c62828',
|
||||||
|
'--warning-color': '#ef6c00',
|
||||||
|
'--info-color': '#0277bd',
|
||||||
|
'--light-color': '#e3f2fd',
|
||||||
|
'--dark-color': '#212121',
|
||||||
|
|
||||||
|
'--bg-primary': '#ffffff',
|
||||||
|
'--bg-secondary': '#f5f5f5',
|
||||||
|
'--bg-accent': '#e8eaf6',
|
||||||
|
|
||||||
|
'--text-primary': '#212121',
|
||||||
|
'--text-secondary': '#424242',
|
||||||
|
'--text-muted': '#757575',
|
||||||
|
|
||||||
|
'--border-color': '#e0e0e0',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(0, 0, 0, 0.15)',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
name: 'Green',
|
||||||
|
description: 'Fresh and modern green theme',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': '#2e7d32',
|
||||||
|
'--secondary-color': '#5d4037',
|
||||||
|
'--success-color': '#4caf50',
|
||||||
|
'--danger-color': '#d32f2f',
|
||||||
|
'--warning-color': '#f57c00',
|
||||||
|
'--info-color': '#00838f',
|
||||||
|
'--light-color': '#e8f5e9',
|
||||||
|
'--dark-color': '#1b5e20',
|
||||||
|
|
||||||
|
'--bg-primary': '#ffffff',
|
||||||
|
'--bg-secondary': '#f1f8e9',
|
||||||
|
'--bg-accent': '#e8f5e9',
|
||||||
|
|
||||||
|
'--text-primary': '#1b5e20',
|
||||||
|
'--text-secondary': '#4caf50',
|
||||||
|
'--text-muted': '#757575',
|
||||||
|
|
||||||
|
'--border-color': '#c8e6c9',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(0, 0, 0, 0.15)',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
name: 'Purple',
|
||||||
|
description: 'Elegant purple color scheme',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': '#7b1fa2',
|
||||||
|
'--secondary-color': '#4527a0',
|
||||||
|
'--success-color': '#6a1b9a',
|
||||||
|
'--danger-color': '#d81b60',
|
||||||
|
'--warning-color': '#ef6c00',
|
||||||
|
'--info-color': '#00acc1',
|
||||||
|
'--light-color': '#f3e5f5',
|
||||||
|
'--dark-color': '#4a148c',
|
||||||
|
|
||||||
|
'--bg-primary': '#ffffff',
|
||||||
|
'--bg-secondary': '#f3e5f5',
|
||||||
|
'--bg-accent': '#e1bee7',
|
||||||
|
|
||||||
|
'--text-primary': '#4a148c',
|
||||||
|
'--text-secondary': '#7b1fa2',
|
||||||
|
'--text-muted': '#757575',
|
||||||
|
|
||||||
|
'--border-color': '#ce93d8',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(0, 0, 0, 0.15)',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentTheme = 'royal';
|
||||||
|
this.element = document.documentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set theme by name
|
||||||
|
*/
|
||||||
|
setTheme(themeName) {
|
||||||
|
if (!this.themes[themeName]) {
|
||||||
|
console.warn(`Theme '${themeName}' not found. Using default theme.`);
|
||||||
|
themeName = 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentTheme = themeName;
|
||||||
|
this.applyTheme(themeName);
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('app-theme', themeName);
|
||||||
|
|
||||||
|
// Dispatch theme change event
|
||||||
|
this.dispatchThemeChange(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current theme
|
||||||
|
*/
|
||||||
|
getCurrentTheme() {
|
||||||
|
return this.currentTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme by name
|
||||||
|
*/
|
||||||
|
getTheme(themeName) {
|
||||||
|
return this.themes[themeName] || this.themes['light'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available themes
|
||||||
|
*/
|
||||||
|
getThemes() {
|
||||||
|
return { ...this.themes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply theme to document
|
||||||
|
*/
|
||||||
|
applyTheme(themeName) {
|
||||||
|
const theme = this.themes[themeName];
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
// Remove existing theme classes
|
||||||
|
Object.keys(this.themes).forEach(name => {
|
||||||
|
this.element.classList.remove(`theme-${name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add current theme class
|
||||||
|
this.element.classList.add(`theme-${themeName}`);
|
||||||
|
|
||||||
|
// Apply CSS custom properties
|
||||||
|
const root = document.documentElement;
|
||||||
|
Object.entries(theme.cssVariables).forEach(([property, value]) => {
|
||||||
|
root.style.setProperty(property, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update meta theme-color for mobile browsers
|
||||||
|
this.updateMetaThemeColor(theme.cssVariables['--primary-color']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update meta theme-color for mobile browsers
|
||||||
|
*/
|
||||||
|
updateMetaThemeColor(color) {
|
||||||
|
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (metaThemeColor) {
|
||||||
|
metaThemeColor.setAttribute('content', color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between Royal (light) and Royal Dark themes
|
||||||
|
*/
|
||||||
|
toggleTheme() {
|
||||||
|
const current = this.getCurrentTheme();
|
||||||
|
const newTheme = current === 'royal-dark' ? 'royal' : 'royal-dark';
|
||||||
|
this.setTheme(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch theme change event
|
||||||
|
*/
|
||||||
|
dispatchThemeChange(themeName) {
|
||||||
|
const event = new CustomEvent('theme:changed', {
|
||||||
|
detail: {
|
||||||
|
theme: themeName,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize theme from localStorage or system preference
|
||||||
|
* Defaults to the Royal (Purple/Gold/Black/White) theme
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Check localStorage first
|
||||||
|
const savedTheme = localStorage.getItem('app-theme');
|
||||||
|
if (savedTheme && this.themes[savedTheme]) {
|
||||||
|
this.setTheme(savedTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check system preference — map to Royal variants
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
this.setTheme('royal-dark');
|
||||||
|
} else {
|
||||||
|
this.setTheme('royal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create theme switcher component
|
||||||
|
*/
|
||||||
|
createThemeSwitcher(container) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const switcher = document.createElement('div');
|
||||||
|
switcher.className = 'theme-switcher';
|
||||||
|
switcher.innerHTML = `
|
||||||
|
<label class="theme-switcher-label">
|
||||||
|
<span class="theme-switcher-text">Theme:</span>
|
||||||
|
<select class="form-select theme-switcher-select" style="width: auto; display: inline-block;">
|
||||||
|
${Object.entries(this.themes).map(([name, theme]) =>
|
||||||
|
`<option value="${name}" ${name === this.currentTheme ? 'selected' : ''}>
|
||||||
|
${theme.name}
|
||||||
|
</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(switcher);
|
||||||
|
|
||||||
|
// Add event listener
|
||||||
|
const select = switcher.querySelector('.theme-switcher-select');
|
||||||
|
select.addEventListener('change', (e) => {
|
||||||
|
this.setTheme(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return switcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create theme preview component
|
||||||
|
*/
|
||||||
|
createThemePreview(container) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'theme-preview';
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
${Object.entries(this.themes).map(([name, theme]) => `
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="theme-card" data-theme="${name}" style="cursor: pointer; border: 2px solid transparent; border-radius: 8px; padding: 15px; transition: all 0.3s ease;">
|
||||||
|
<div style="height: 40px; background: ${theme.cssVariables['--bg-primary']}; border-radius: 4px; margin-bottom: 8px;"></div>
|
||||||
|
<div style="height: 20px; background: ${theme.cssVariables['--primary-color']}; border-radius: 4px; margin-bottom: 8px;"></div>
|
||||||
|
<div style="height: 15px; background: ${theme.cssVariables['--secondary-color']}; border-radius: 4px;"></div>
|
||||||
|
<div style="font-size: 12px; color: ${theme.cssVariables['--text-secondary']}; margin-top: 8px;">
|
||||||
|
${theme.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(preview);
|
||||||
|
|
||||||
|
// Add click listeners
|
||||||
|
const themeCards = preview.querySelectorAll('.theme-card');
|
||||||
|
themeCards.forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const themeName = card.getAttribute('data-theme');
|
||||||
|
this.setTheme(themeName);
|
||||||
|
|
||||||
|
// Update selection indicators
|
||||||
|
themeCards.forEach(c => c.style.borderColor = 'transparent');
|
||||||
|
card.style.borderColor = this.themes[themeName].cssVariables['--primary-color'];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export current theme
|
||||||
|
*/
|
||||||
|
exportTheme() {
|
||||||
|
const theme = this.getTheme(this.currentTheme);
|
||||||
|
return {
|
||||||
|
name: theme.name,
|
||||||
|
description: theme.description,
|
||||||
|
cssVariables: { ...theme.cssVariables },
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import custom theme
|
||||||
|
*/
|
||||||
|
importTheme(themeData) {
|
||||||
|
if (!themeData || !themeData.cssVariables) {
|
||||||
|
throw new Error('Invalid theme data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeName = themeData.name || 'custom';
|
||||||
|
this.themes[themeName] = {
|
||||||
|
name: themeData.name || 'Custom Theme',
|
||||||
|
description: themeData.description || 'Custom imported theme',
|
||||||
|
cssVariables: { ...themeData.cssVariables }
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setTheme(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create custom theme from colors
|
||||||
|
*/
|
||||||
|
createCustomTheme(colors) {
|
||||||
|
const customTheme = {
|
||||||
|
name: 'Custom',
|
||||||
|
description: 'User-defined theme',
|
||||||
|
cssVariables: {
|
||||||
|
'--primary-color': colors.primary || '#007bff',
|
||||||
|
'--secondary-color': colors.secondary || '#6c757d',
|
||||||
|
'--success-color': colors.success || '#28a745',
|
||||||
|
'--danger-color': colors.danger || '#dc3545',
|
||||||
|
'--warning-color': colors.warning || '#ffc107',
|
||||||
|
'--info-color': colors.info || '#17a2b8',
|
||||||
|
'--light-color': colors.light || '#f8f9fa',
|
||||||
|
'--dark-color': colors.dark || '#343a40',
|
||||||
|
|
||||||
|
'--bg-primary': colors.bgPrimary || '#ffffff',
|
||||||
|
'--bg-secondary': colors.bgSecondary || '#f8f9fa',
|
||||||
|
'--bg-accent': colors.bgAccent || '#e9ecef',
|
||||||
|
|
||||||
|
'--text-primary': colors.textPrimary || '#212529',
|
||||||
|
'--text-secondary': colors.textSecondary || '#6c757d',
|
||||||
|
'--text-muted': colors.textMuted || '#6c757d',
|
||||||
|
|
||||||
|
'--border-color': colors.borderColor || '#dee2e6',
|
||||||
|
'--shadow': '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)',
|
||||||
|
'--shadow-lg': '0 0.5rem 1rem rgba(0, 0, 0, 0.15)',
|
||||||
|
|
||||||
|
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'--font-size-base': '1rem',
|
||||||
|
'--line-height-base': '1.5',
|
||||||
|
'--border-radius': '0.375rem',
|
||||||
|
'--border-radius-lg': '0.5rem',
|
||||||
|
'--border-radius-sm': '0.25rem'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.themes.custom = customTheme;
|
||||||
|
this.setTheme('custom');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset to default theme
|
||||||
|
*/
|
||||||
|
resetTheme() {
|
||||||
|
this.setTheme('light');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if theme exists
|
||||||
|
*/
|
||||||
|
hasTheme(themeName) {
|
||||||
|
return !!this.themes[themeName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove theme
|
||||||
|
*/
|
||||||
|
removeTheme(themeName) {
|
||||||
|
if (themeName === this.currentTheme) {
|
||||||
|
this.setTheme('light');
|
||||||
|
}
|
||||||
|
delete this.themes[themeName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = ThemeManager;
|
||||||
|
} else {
|
||||||
|
window.ThemeManager = ThemeManager;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,729 @@
|
||||||
|
/**
|
||||||
|
* Event Handlers of Web Application
|
||||||
|
*
|
||||||
|
* Centralized event handling for the Excel Analyzer application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class EventHandlers {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.appState = options.appState || {};
|
||||||
|
this.modules = options.modules || {};
|
||||||
|
this.utils = options.utils || {};
|
||||||
|
this.config = options.config || {};
|
||||||
|
this.events = options.events || {};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.setupGlobalEventListeners();
|
||||||
|
this.setupModuleEventListeners();
|
||||||
|
this.setupUIEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup global event listeners
|
||||||
|
*/
|
||||||
|
setupGlobalEventListeners() {
|
||||||
|
// Window resize events
|
||||||
|
window.addEventListener('resize', this.handleWindowResize.bind(this));
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
window.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||||
|
|
||||||
|
// Before unload confirmation
|
||||||
|
window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this));
|
||||||
|
|
||||||
|
// Theme change events
|
||||||
|
window.addEventListener('theme:changed', this.handleThemeChange.bind(this));
|
||||||
|
|
||||||
|
// App initialization events
|
||||||
|
this.events.on('app:initialized', this.handleAppInitialized.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup module-specific event listeners
|
||||||
|
*/
|
||||||
|
setupModuleEventListeners() {
|
||||||
|
// File upload events
|
||||||
|
if (this.modules.fileUpload) {
|
||||||
|
this.modules.fileUpload.onFileSelected = this.handleFileSelected.bind(this);
|
||||||
|
this.modules.fileUpload.onFileParsed = this.handleFileParsed.bind(this);
|
||||||
|
this.modules.fileUpload.onError = this.handleModuleError.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data processing events
|
||||||
|
if (this.modules.dataProcessor) {
|
||||||
|
this.modules.dataProcessor.onDataAnalyzed = this.handleDataAnalyzed.bind(this);
|
||||||
|
this.modules.dataProcessor.onDataProcessed = this.handleDataProcessed.bind(this);
|
||||||
|
this.modules.dataProcessor.onError = this.handleModuleError.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visualization events
|
||||||
|
if (this.modules.visualizer) {
|
||||||
|
this.modules.visualizer.onChartRendered = this.handleChartRendered.bind(this);
|
||||||
|
this.modules.visualizer.onChartUpdated = this.handleChartUpdated.bind(this);
|
||||||
|
this.modules.visualizer.onError = this.handleModuleError.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard events
|
||||||
|
if (this.modules.dashboard) {
|
||||||
|
this.modules.dashboard.onDashboardUpdated = this.handleDashboardUpdated.bind(this);
|
||||||
|
this.modules.dashboard.onWidgetAdded = this.handleWidgetAdded.bind(this);
|
||||||
|
this.modules.dashboard.onError = this.handleModuleError.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export events
|
||||||
|
if (this.modules.exporter) {
|
||||||
|
this.modules.exporter.onExportComplete = this.handleExportComplete.bind(this);
|
||||||
|
this.modules.exporter.onExportProgress = this.handleExportProgress.bind(this);
|
||||||
|
this.modules.exporter.onError = this.handleModuleError.bind(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup UI event listeners
|
||||||
|
*/
|
||||||
|
setupUIEventListeners() {
|
||||||
|
// Navigation events
|
||||||
|
this.setupNavigationEvents();
|
||||||
|
|
||||||
|
// Chart selection events
|
||||||
|
this.setupChartSelectionEvents();
|
||||||
|
|
||||||
|
// Dashboard layout events
|
||||||
|
this.setupDashboardLayoutEvents();
|
||||||
|
|
||||||
|
// Export events
|
||||||
|
this.setupExportEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle window resize
|
||||||
|
*/
|
||||||
|
handleWindowResize() {
|
||||||
|
// Resize charts if visualizer is available
|
||||||
|
if (this.modules.visualizer && this.modules.visualizer.resizeCharts) {
|
||||||
|
this.modules.visualizer.resizeCharts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update dashboard layout if needed
|
||||||
|
if (this.modules.dashboard && this.modules.dashboard.handleResize) {
|
||||||
|
this.modules.dashboard.handleResize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard shortcuts
|
||||||
|
*/
|
||||||
|
handleKeyDown(event) {
|
||||||
|
// Ctrl/Cmd + S: Save dashboard
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleSaveDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + E: Export
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'e') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleExport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + T: Toggle theme
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 't') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleThemeToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape: Close modals
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
this.handleEscapeKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle before unload
|
||||||
|
*/
|
||||||
|
handleBeforeUnload(event) {
|
||||||
|
if (this.appState.rawData && this.appState.rawData.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle theme change
|
||||||
|
*/
|
||||||
|
handleThemeChange(event) {
|
||||||
|
const theme = event.detail.theme;
|
||||||
|
console.log(`Theme changed to: ${theme}`);
|
||||||
|
|
||||||
|
// Update any theme-dependent components
|
||||||
|
if (this.modules.visualizer) {
|
||||||
|
this.modules.visualizer.updateTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.modules.dashboard) {
|
||||||
|
this.modules.dashboard.updateTheme(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle app initialization
|
||||||
|
*/
|
||||||
|
handleAppInitialized(event) {
|
||||||
|
console.log('Application initialized successfully');
|
||||||
|
|
||||||
|
// Setup any post-initialization tasks
|
||||||
|
this.setupPostInitialization();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle file selected
|
||||||
|
*/
|
||||||
|
handleFileSelected(file) {
|
||||||
|
console.log('File selected:', file.name);
|
||||||
|
|
||||||
|
// Update app state
|
||||||
|
this.appState.currentFile = file;
|
||||||
|
this.appState.rawData = [];
|
||||||
|
this.appState.processedData = [];
|
||||||
|
this.appState.dataStructure = null;
|
||||||
|
this.appState.visualizations = [];
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
this.updateFileInfo(file);
|
||||||
|
this.showSection('preview');
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle file parsed
|
||||||
|
*/
|
||||||
|
handleFileParsed(data) {
|
||||||
|
console.log('File parsed successfully');
|
||||||
|
|
||||||
|
this.appState.rawData = data;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
this.updateDataPreview(data);
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle data analyzed
|
||||||
|
*/
|
||||||
|
handleDataAnalyzed(structure) {
|
||||||
|
console.log('Data analyzed');
|
||||||
|
|
||||||
|
this.appState.dataStructure = structure;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
this.updateColumnAnalysis(structure);
|
||||||
|
this.showSection('analyze');
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle data processed
|
||||||
|
*/
|
||||||
|
handleDataProcessed(processedData) {
|
||||||
|
console.log('Data processed');
|
||||||
|
|
||||||
|
this.appState.processedData = processedData;
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle chart rendered
|
||||||
|
*/
|
||||||
|
handleChartRendered(chart) {
|
||||||
|
console.log('Chart rendered');
|
||||||
|
|
||||||
|
this.appState.visualizations.push(chart);
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle chart updated
|
||||||
|
*/
|
||||||
|
handleChartUpdated(chart) {
|
||||||
|
console.log('Chart updated');
|
||||||
|
|
||||||
|
const index = this.appState.visualizations.findIndex(c => c.id === chart.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.appState.visualizations[index] = chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle dashboard updated
|
||||||
|
*/
|
||||||
|
handleDashboardUpdated(dashboard) {
|
||||||
|
console.log('Dashboard updated');
|
||||||
|
|
||||||
|
this.appState.dashboard = dashboard;
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle widget added
|
||||||
|
*/
|
||||||
|
handleWidgetAdded(widget) {
|
||||||
|
console.log('Widget added to dashboard');
|
||||||
|
|
||||||
|
// Dashboard manager handles this internally
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
this.saveAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle export complete
|
||||||
|
*/
|
||||||
|
handleExportComplete(result) {
|
||||||
|
console.log('Export completed:', result);
|
||||||
|
|
||||||
|
this.showSuccessMessage(`Export completed successfully: ${result.filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle export progress
|
||||||
|
*/
|
||||||
|
handleExportProgress(progress) {
|
||||||
|
console.log('Export progress:', progress);
|
||||||
|
|
||||||
|
this.updateExportProgress(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle module error
|
||||||
|
*/
|
||||||
|
handleModuleError(error) {
|
||||||
|
console.error('Module error:', error);
|
||||||
|
|
||||||
|
this.showErrorMessage(error.message || 'An error occurred. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup navigation events
|
||||||
|
*/
|
||||||
|
setupNavigationEvents() {
|
||||||
|
const links = document.querySelectorAll('.nav-link[data-section]');
|
||||||
|
links.forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sectionId = link.getAttribute('data-section');
|
||||||
|
this.showSection(sectionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup chart selection events
|
||||||
|
*/
|
||||||
|
setupChartSelectionEvents() {
|
||||||
|
const chartTypeSelector = document.getElementById('chartTypeSelector');
|
||||||
|
const xAxisSelector = document.getElementById('xAxisSelector');
|
||||||
|
const yAxisSelector = document.getElementById('yAxisSelector');
|
||||||
|
|
||||||
|
if (chartTypeSelector) {
|
||||||
|
chartTypeSelector.addEventListener('change', (e) => {
|
||||||
|
this.handleChartTypeChange(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xAxisSelector) {
|
||||||
|
xAxisSelector.addEventListener('change', (e) => {
|
||||||
|
this.handleAxisChange('x', e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yAxisSelector) {
|
||||||
|
yAxisSelector.addEventListener('change', (e) => {
|
||||||
|
this.handleAxisChange('y', e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup dashboard layout events
|
||||||
|
*/
|
||||||
|
setupDashboardLayoutEvents() {
|
||||||
|
const layoutButtons = document.querySelectorAll('.btn[data-layout]');
|
||||||
|
layoutButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const layout = e.target.getAttribute('data-layout');
|
||||||
|
this.handleLayoutChange(layout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup export events
|
||||||
|
*/
|
||||||
|
setupExportEvents() {
|
||||||
|
const exportButtons = document.querySelectorAll('[data-export-format]');
|
||||||
|
exportButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const format = e.target.getAttribute('data-export-format');
|
||||||
|
this.handleExport(format);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle chart type change
|
||||||
|
*/
|
||||||
|
handleChartTypeChange(chartType) {
|
||||||
|
console.log('Chart type changed to:', chartType);
|
||||||
|
|
||||||
|
// Update chart configuration
|
||||||
|
if (this.modules.visualizer) {
|
||||||
|
this.modules.visualizer.updateChartType(chartType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle axis change
|
||||||
|
*/
|
||||||
|
handleAxisChange(axis, value) {
|
||||||
|
console.log(`${axis} axis changed to:`, value);
|
||||||
|
|
||||||
|
// Update chart configuration
|
||||||
|
if (this.modules.visualizer) {
|
||||||
|
this.modules.visualizer.updateAxis(axis, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle layout change
|
||||||
|
*/
|
||||||
|
handleLayoutChange(layout) {
|
||||||
|
console.log('Layout changed to:', layout);
|
||||||
|
|
||||||
|
// Update dashboard layout
|
||||||
|
if (this.modules.dashboard) {
|
||||||
|
this.modules.dashboard.setLayout(layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle export
|
||||||
|
*/
|
||||||
|
handleExport(format = 'pdf') {
|
||||||
|
console.log('Export requested:', format);
|
||||||
|
|
||||||
|
if (this.modules.exporter) {
|
||||||
|
this.modules.exporter.export(format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle save dashboard
|
||||||
|
*/
|
||||||
|
handleSaveDashboard() {
|
||||||
|
console.log('Save dashboard requested');
|
||||||
|
|
||||||
|
if (this.modules.dashboard) {
|
||||||
|
this.modules.dashboard.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle theme toggle
|
||||||
|
*/
|
||||||
|
handleThemeToggle() {
|
||||||
|
console.log('Theme toggle requested');
|
||||||
|
|
||||||
|
if (this.config.themes) {
|
||||||
|
this.config.themes.toggleTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle escape key
|
||||||
|
*/
|
||||||
|
handleEscapeKey() {
|
||||||
|
console.log('Escape key pressed');
|
||||||
|
|
||||||
|
// Close any open modals or dropdowns
|
||||||
|
const modals = document.querySelectorAll('.modal.show');
|
||||||
|
modals.forEach(modal => {
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(modal);
|
||||||
|
if (modalInstance) {
|
||||||
|
modalInstance.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropdowns = document.querySelectorAll('.dropdown.show');
|
||||||
|
dropdowns.forEach(dropdown => {
|
||||||
|
const dropdownInstance = bootstrap.Dropdown.getInstance(dropdown);
|
||||||
|
if (dropdownInstance) {
|
||||||
|
dropdownInstance.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup post-initialization tasks
|
||||||
|
*/
|
||||||
|
setupPostInitialization() {
|
||||||
|
// Load saved state if available
|
||||||
|
this.loadAppState();
|
||||||
|
|
||||||
|
// Initialize tooltips
|
||||||
|
this.initializeTooltips();
|
||||||
|
|
||||||
|
// Setup theme switcher if available
|
||||||
|
this.setupThemeSwitcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update file info display
|
||||||
|
*/
|
||||||
|
updateFileInfo(file) {
|
||||||
|
const fileNameEl = document.getElementById('fileName');
|
||||||
|
const fileSizeEl = document.getElementById('fileSize');
|
||||||
|
|
||||||
|
if (fileNameEl) fileNameEl.textContent = file.name;
|
||||||
|
if (fileSizeEl) fileSizeEl.textContent = this.utils.formatters.formatFileSize(file.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update data preview
|
||||||
|
*/
|
||||||
|
updateDataPreview(data) {
|
||||||
|
// Update statistics
|
||||||
|
const totalRowsEl = document.getElementById('totalRows');
|
||||||
|
const totalColsEl = document.getElementById('totalCols');
|
||||||
|
const missingValuesEl = document.getElementById('missingValues');
|
||||||
|
const dataQualityEl = document.getElementById('dataQuality');
|
||||||
|
|
||||||
|
if (totalRowsEl) totalRowsEl.textContent = data.length;
|
||||||
|
if (totalColsEl && data.length > 0) totalColsEl.textContent = Object.keys(data[0]).length;
|
||||||
|
|
||||||
|
// Calculate missing values and data quality
|
||||||
|
const stats = this.calculateDataStats(data);
|
||||||
|
if (missingValuesEl) missingValuesEl.textContent = stats.missingCount;
|
||||||
|
if (dataQualityEl) dataQualityEl.textContent = `${stats.qualityPercentage}%`;
|
||||||
|
|
||||||
|
// Update table
|
||||||
|
this.renderDataTable(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update column analysis
|
||||||
|
*/
|
||||||
|
updateColumnAnalysis(structure) {
|
||||||
|
const container = document.getElementById('columnAnalysis');
|
||||||
|
if (container && structure) {
|
||||||
|
container.innerHTML = this.generateColumnAnalysisHTML(structure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show section
|
||||||
|
*/
|
||||||
|
showSection(sectionId) {
|
||||||
|
const sections = ['upload', 'preview', 'analyze', 'dashboard', 'export'];
|
||||||
|
sections.forEach(section => {
|
||||||
|
const element = document.getElementById(section);
|
||||||
|
if (element) {
|
||||||
|
element.style.display = section === sectionId ? 'block' : 'none';
|
||||||
|
if (section === sectionId) {
|
||||||
|
element.classList.add('fade-in');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update navigation
|
||||||
|
this.updateNavigationActive(sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update navigation active state
|
||||||
|
*/
|
||||||
|
updateNavigationActive(sectionId) {
|
||||||
|
const links = document.querySelectorAll('.nav-link');
|
||||||
|
links.forEach(link => {
|
||||||
|
if (link.getAttribute('data-section') === sectionId) {
|
||||||
|
link.classList.add('active');
|
||||||
|
} else {
|
||||||
|
link.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show success message
|
||||||
|
*/
|
||||||
|
showSuccessMessage(message) {
|
||||||
|
const statusEl = document.getElementById('exportStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `<div class="alert alert-success">${message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error message
|
||||||
|
*/
|
||||||
|
showErrorMessage(message) {
|
||||||
|
const statusEl = document.getElementById('uploadStatus') || document.getElementById('exportStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `<div class="alert alert-danger">${message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update export progress
|
||||||
|
*/
|
||||||
|
updateExportProgress(progress) {
|
||||||
|
const progressEl = document.getElementById('exportProgress');
|
||||||
|
const progressBarEl = progressEl ? progressEl.querySelector('.progress-bar') : null;
|
||||||
|
|
||||||
|
if (progressEl) {
|
||||||
|
progressEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBarEl) {
|
||||||
|
progressBarEl.style.width = `${progress.percentage}%`;
|
||||||
|
progressBarEl.textContent = `${progress.percentage}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render data table
|
||||||
|
*/
|
||||||
|
renderDataTable(data) {
|
||||||
|
const tableBody = document.getElementById('tableBody');
|
||||||
|
const tableHeader = document.getElementById('tableHeader');
|
||||||
|
|
||||||
|
if (!tableBody || !tableHeader || data.length === 0) return;
|
||||||
|
|
||||||
|
// Generate headers
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
tableHeader.innerHTML = '<tr>' + headers.map(header => `<th>${header}</th>`).join('') + '</tr>';
|
||||||
|
|
||||||
|
// Generate rows (limit to first 100 for performance)
|
||||||
|
const displayData = data.slice(0, 100);
|
||||||
|
tableBody.innerHTML = displayData.map(row => {
|
||||||
|
return '<tr>' + headers.map(header => `<td>${row[header] || ''}</td>`).join('') + '</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate data statistics
|
||||||
|
*/
|
||||||
|
calculateDataStats(data) {
|
||||||
|
if (data.length === 0) return { missingCount: 0, qualityPercentage: 100 };
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
let missingCount = 0;
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
data.forEach(row => {
|
||||||
|
headers.forEach(header => {
|
||||||
|
totalCount++;
|
||||||
|
if (row[header] === null || row[header] === undefined || row[header] === '') {
|
||||||
|
missingCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const qualityPercentage = Math.round(((totalCount - missingCount) / totalCount) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingCount,
|
||||||
|
qualityPercentage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate column analysis HTML
|
||||||
|
*/
|
||||||
|
generateColumnAnalysisHTML(structure) {
|
||||||
|
if (!structure || !structure.columns) return '';
|
||||||
|
|
||||||
|
return structure.columns.map(col => `
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">${col.name}</h6>
|
||||||
|
<p class="card-text">
|
||||||
|
<small class="text-muted">Type: ${col.type}</small><br>
|
||||||
|
<small class="text-muted">Unique Values: ${col.uniqueValues}</small><br>
|
||||||
|
<small class="text-muted">Missing: ${col.missingCount}</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize tooltips
|
||||||
|
*/
|
||||||
|
initializeTooltips() {
|
||||||
|
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
||||||
|
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup theme switcher
|
||||||
|
*/
|
||||||
|
setupThemeSwitcher() {
|
||||||
|
const themeContainer = document.getElementById('theme-switcher-container');
|
||||||
|
if (themeContainer && this.config.themes) {
|
||||||
|
this.config.themes.createThemeSwitcher(themeContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save application state
|
||||||
|
*/
|
||||||
|
saveAppState() {
|
||||||
|
localStorage.setItem('excel-analyzer-state', JSON.stringify(this.appState));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load application state
|
||||||
|
*/
|
||||||
|
loadAppState() {
|
||||||
|
const savedState = localStorage.getItem('excel-analyzer-state');
|
||||||
|
if (savedState) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(savedState);
|
||||||
|
Object.assign(this.appState, state);
|
||||||
|
console.log('Application state loaded from localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load saved state:', error);
|
||||||
|
localStorage.removeItem('excel-analyzer-state');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = EventHandlers;
|
||||||
|
} else {
|
||||||
|
window.EventHandlers = EventHandlers;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
/**
|
||||||
|
* Event System of Web Application
|
||||||
|
*
|
||||||
|
* Custom event management system for the Excel Analyzer application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class EventSystem {
|
||||||
|
constructor() {
|
||||||
|
this.events = new Map();
|
||||||
|
this.maxListeners = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add event listener
|
||||||
|
*/
|
||||||
|
on(event, callback, context = null) {
|
||||||
|
if (typeof callback !== 'function') {
|
||||||
|
throw new Error('Callback must be a function');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.events.has(event)) {
|
||||||
|
this.events.set(event, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = this.events.get(event);
|
||||||
|
|
||||||
|
// Check max listeners limit
|
||||||
|
if (listeners.size >= this.maxListeners) {
|
||||||
|
console.warn(`Maximum number of listeners (${this.maxListeners}) reached for event: ${event}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners.add({ callback, context });
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one-time event listener
|
||||||
|
*/
|
||||||
|
once(event, callback, context = null) {
|
||||||
|
const onceCallback = (...args) => {
|
||||||
|
this.off(event, onceCallback);
|
||||||
|
callback.apply(context, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.on(event, onceCallback, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove event listener
|
||||||
|
*/
|
||||||
|
off(event, callback) {
|
||||||
|
if (!this.events.has(event)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = this.events.get(event);
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
// Remove specific callback
|
||||||
|
for (const listener of listeners) {
|
||||||
|
if (listener.callback === callback) {
|
||||||
|
listeners.delete(listener);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove all listeners for this event
|
||||||
|
listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty event sets
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
this.events.delete(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit event
|
||||||
|
*/
|
||||||
|
emit(event, data = null) {
|
||||||
|
if (!this.events.has(event)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = this.events.get(event);
|
||||||
|
let hasListeners = false;
|
||||||
|
|
||||||
|
for (const listener of listeners) {
|
||||||
|
hasListeners = true;
|
||||||
|
try {
|
||||||
|
listener.callback.call(listener.context, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in event listener for '${event}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasListeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all listeners for an event
|
||||||
|
*/
|
||||||
|
listeners(event) {
|
||||||
|
if (!this.events.has(event)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(this.events.get(event)).map(listener => listener.callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event has listeners
|
||||||
|
*/
|
||||||
|
hasListeners(event) {
|
||||||
|
return this.events.has(event) && this.events.get(event).size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all listeners for all events
|
||||||
|
*/
|
||||||
|
removeAllListeners() {
|
||||||
|
this.events.clear();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all listeners for specific event
|
||||||
|
*/
|
||||||
|
removeAllListenersForEvent(event) {
|
||||||
|
if (this.events.has(event)) {
|
||||||
|
this.events.get(event).clear();
|
||||||
|
this.events.delete(event);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set max listeners limit
|
||||||
|
*/
|
||||||
|
setMaxListeners(max) {
|
||||||
|
if (typeof max !== 'number' || max < 0) {
|
||||||
|
throw new Error('Max listeners must be a non-negative number');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.maxListeners = max;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event names
|
||||||
|
*/
|
||||||
|
eventNames() {
|
||||||
|
return Array.from(this.events.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get listener count for event
|
||||||
|
*/
|
||||||
|
listenerCount(event) {
|
||||||
|
return this.events.has(event) ? this.events.get(event).size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create namespaced event
|
||||||
|
*/
|
||||||
|
namespace(namespace) {
|
||||||
|
return {
|
||||||
|
on: (event, callback, context) => this.on(`${namespace}:${event}`, callback, context),
|
||||||
|
once: (event, callback, context) => this.once(`${namespace}:${event}`, callback, context),
|
||||||
|
off: (event, callback) => this.off(`${namespace}:${event}`, callback),
|
||||||
|
emit: (event, data) => this.emit(`${namespace}:${event}`, data),
|
||||||
|
removeAllListeners: () => {
|
||||||
|
const eventsToRemove = [];
|
||||||
|
for (const eventName of this.eventNames()) {
|
||||||
|
if (eventName.startsWith(`${namespace}:`)) {
|
||||||
|
eventsToRemove.push(eventName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventsToRemove.forEach(event => this.removeAllListenersForEvent(event));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create event chain
|
||||||
|
*/
|
||||||
|
chain(events) {
|
||||||
|
if (!Array.isArray(events) || events.length < 2) {
|
||||||
|
throw new Error('Event chain requires at least 2 events');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: (initialData) => {
|
||||||
|
this.emit(events[0], initialData);
|
||||||
|
},
|
||||||
|
link: (event, callback) => {
|
||||||
|
this.on(event, callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced event emitter
|
||||||
|
*/
|
||||||
|
debouncedEmit(event, data, delay = 300) {
|
||||||
|
if (!this._debounceTimers) {
|
||||||
|
this._debounceTimers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._debounceTimers.has(event)) {
|
||||||
|
clearTimeout(this._debounceTimers.get(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.emit(event, data);
|
||||||
|
this._debounceTimers.delete(event);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
this._debounceTimers.set(event, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttled event emitter
|
||||||
|
*/
|
||||||
|
throttledEmit(event, data, limit = 1000) {
|
||||||
|
if (!this._throttleTimers) {
|
||||||
|
this._throttleTimers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEmit = this._throttleTimers.get(event);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!lastEmit || (now - lastEmit) >= limit) {
|
||||||
|
this.emit(event, data);
|
||||||
|
this._throttleTimers.set(event, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event logger
|
||||||
|
*/
|
||||||
|
enableLogging(enabled = true) {
|
||||||
|
if (enabled) {
|
||||||
|
this._originalEmit = this.emit;
|
||||||
|
this.emit = (event, data) => {
|
||||||
|
console.log(`Event emitted: ${event}`, data);
|
||||||
|
return this._originalEmit.call(this, event, data);
|
||||||
|
};
|
||||||
|
} else if (this._originalEmit) {
|
||||||
|
this.emit = this._originalEmit;
|
||||||
|
delete this._originalEmit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event history
|
||||||
|
*/
|
||||||
|
enableHistory(enabled = true, maxSize = 100) {
|
||||||
|
if (enabled) {
|
||||||
|
this._eventHistory = [];
|
||||||
|
this._historyMaxSize = maxSize;
|
||||||
|
|
||||||
|
this._originalEmit = this.emit;
|
||||||
|
this.emit = (event, data) => {
|
||||||
|
const result = this._originalEmit.call(this, event, data);
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
this._eventHistory.push({
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit history size
|
||||||
|
if (this._eventHistory.length > this._historyMaxSize) {
|
||||||
|
this._eventHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
} else if (this._originalEmit) {
|
||||||
|
this.emit = this._originalEmit;
|
||||||
|
delete this._originalEmit;
|
||||||
|
delete this._eventHistory;
|
||||||
|
delete this._historyMaxSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event history
|
||||||
|
*/
|
||||||
|
getHistory() {
|
||||||
|
return this._eventHistory || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear event history
|
||||||
|
*/
|
||||||
|
clearHistory() {
|
||||||
|
if (this._eventHistory) {
|
||||||
|
this._eventHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event filter
|
||||||
|
*/
|
||||||
|
filter(event, filterFn) {
|
||||||
|
if (typeof filterFn !== 'function') {
|
||||||
|
throw new Error('Filter function must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalEmit = this.emit;
|
||||||
|
this.emit = (eventName, data) => {
|
||||||
|
if (eventName === event && !filterFn(data)) {
|
||||||
|
return false; // Don't emit if filter returns false
|
||||||
|
}
|
||||||
|
return originalEmit.call(this, eventName, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event transformer
|
||||||
|
*/
|
||||||
|
transform(event, transformFn) {
|
||||||
|
if (typeof transformFn !== 'function') {
|
||||||
|
throw new Error('Transform function must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalEmit = this.emit;
|
||||||
|
this.emit = (eventName, data) => {
|
||||||
|
if (eventName === event) {
|
||||||
|
data = transformFn(data);
|
||||||
|
}
|
||||||
|
return originalEmit.call(this, eventName, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = EventSystem;
|
||||||
|
} else {
|
||||||
|
window.EventSystem = EventSystem;
|
||||||
|
}
|
||||||
467
apps/web-roo-code/src/components/excel-analyzer/src/js/main.js
Normal file
467
apps/web-roo-code/src/components/excel-analyzer/src/js/main.js
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
/**
|
||||||
|
* Excel Analyzer - Main Application Entry Point
|
||||||
|
*
|
||||||
|
* Provides an end-to-end browser workflow:
|
||||||
|
* upload -> preview -> analysis/chart -> dashboard -> export in PDF, Excel or chart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const appState = {
|
||||||
|
currentFile: null,
|
||||||
|
rawData: [],
|
||||||
|
processedData: [],
|
||||||
|
dataStructure: null,
|
||||||
|
currentChart: null,
|
||||||
|
previewLimit: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const modules = {
|
||||||
|
fileUpload: null,
|
||||||
|
dataProcessor: null,
|
||||||
|
visualizer: null,
|
||||||
|
dashboard: null,
|
||||||
|
exporter: null,
|
||||||
|
notifications: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
settings: null,
|
||||||
|
themes: null,
|
||||||
|
defaults: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
try {
|
||||||
|
initConfig();
|
||||||
|
initModules();
|
||||||
|
initUI();
|
||||||
|
showInlineStatus('uploadStatus', 'success', 'Application ready. Upload an Excel file to begin.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Initialization failed:', error);
|
||||||
|
showInlineStatus('uploadStatus', 'danger', `Initialization failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initConfig() {
|
||||||
|
config.settings = window.SettingsManager ? new SettingsManager() : null;
|
||||||
|
config.themes = window.ThemeManager ? new ThemeManager() : null;
|
||||||
|
config.defaults = window.DefaultValues ? new DefaultValues() : null;
|
||||||
|
|
||||||
|
const savedTheme =
|
||||||
|
(config.settings && config.settings.get('ui.theme')) ||
|
||||||
|
localStorage.getItem('app-theme') ||
|
||||||
|
'light';
|
||||||
|
|
||||||
|
if (config.themes) {
|
||||||
|
config.themes.setTheme(savedTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initModules() {
|
||||||
|
modules.notifications = window.Notifications ? new Notifications() : null;
|
||||||
|
|
||||||
|
modules.fileUpload = new FileUploadManager({
|
||||||
|
onFileSelected: handleFileSelected,
|
||||||
|
onFileParsed: handleFileParsed,
|
||||||
|
onError: handleModuleError
|
||||||
|
});
|
||||||
|
|
||||||
|
modules.dataProcessor = new DataProcessor({ onError: handleModuleError });
|
||||||
|
|
||||||
|
modules.visualizer = new Visualizer({
|
||||||
|
onChartRendered: (chart) => {
|
||||||
|
appState.currentChart = chart;
|
||||||
|
updateDashboard();
|
||||||
|
},
|
||||||
|
onError: handleModuleError
|
||||||
|
});
|
||||||
|
|
||||||
|
modules.dashboard = new DashboardManager({
|
||||||
|
containerId: 'dashboardGrid',
|
||||||
|
onDashboardUpdated: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
modules.exporter = new Exporter({
|
||||||
|
onExportProgress: (progress) => updateExportProgress(progress),
|
||||||
|
onExportComplete: ({ filename }) => {
|
||||||
|
showInlineStatus('exportStatus', 'success', `Export completed: ${filename}`);
|
||||||
|
notify('success', `Export completed: ${filename}`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showInlineStatus('exportStatus', 'danger', error.message || 'Export failed');
|
||||||
|
notify('error', error.message || 'Export failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initUI() {
|
||||||
|
hideAllSections();
|
||||||
|
showSection('upload');
|
||||||
|
|
||||||
|
setupNavigation();
|
||||||
|
setupPreviewControls();
|
||||||
|
setupAnalysisControls();
|
||||||
|
setupDashboardControls();
|
||||||
|
setupExportControls();
|
||||||
|
setupThemeToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupNavigation() {
|
||||||
|
document.querySelectorAll('.nav-link[data-section]').forEach((link) => {
|
||||||
|
link.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sectionId = link.getAttribute('data-section');
|
||||||
|
if (sectionId !== 'upload' && !hasData()) {
|
||||||
|
showInlineStatus('uploadStatus', 'warning', 'Please upload and parse a file first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showSection(sectionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPreviewControls() {
|
||||||
|
const btnFirst100 = document.getElementById('btnFirst100');
|
||||||
|
const btnAllData = document.getElementById('btnAllData');
|
||||||
|
|
||||||
|
if (btnFirst100) {
|
||||||
|
btnFirst100.addEventListener('click', () => {
|
||||||
|
appState.previewLimit = 100;
|
||||||
|
renderDataTable(appState.processedData, appState.previewLimit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnAllData) {
|
||||||
|
btnAllData.addEventListener('click', () => {
|
||||||
|
appState.previewLimit = Number.MAX_SAFE_INTEGER;
|
||||||
|
renderDataTable(appState.processedData, appState.previewLimit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAnalysisControls() {
|
||||||
|
const chartTypeSelector = document.getElementById('chartTypeSelector');
|
||||||
|
const xAxisSelector = document.getElementById('xAxisSelector');
|
||||||
|
const yAxisSelector = document.getElementById('yAxisSelector');
|
||||||
|
|
||||||
|
if (chartTypeSelector) chartTypeSelector.addEventListener('change', renderSelectedChart);
|
||||||
|
if (xAxisSelector) xAxisSelector.addEventListener('change', renderSelectedChart);
|
||||||
|
if (yAxisSelector) yAxisSelector.addEventListener('change', renderSelectedChart);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDashboardControls() {
|
||||||
|
const layouts = {
|
||||||
|
layout1: 'layout1',
|
||||||
|
layout2: 'layout2',
|
||||||
|
layout3: 'layout3'
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(layouts).forEach((id) => {
|
||||||
|
const button = document.getElementById(id);
|
||||||
|
if (!button) return;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
modules.dashboard.setLayout(layouts[id]);
|
||||||
|
updateDashboard();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupExportControls() {
|
||||||
|
const exportPDF = document.getElementById('exportPDF');
|
||||||
|
const exportExcel = document.getElementById('exportExcel');
|
||||||
|
const exportCharts = document.getElementById('exportCharts');
|
||||||
|
|
||||||
|
if (exportPDF) exportPDF.addEventListener('click', () => exportData('pdf'));
|
||||||
|
if (exportExcel) exportExcel.addEventListener('click', () => exportData('excel'));
|
||||||
|
if (exportCharts) exportCharts.addEventListener('click', () => exportData('png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupThemeToggle() {
|
||||||
|
const toggle = document.getElementById('themeToggle');
|
||||||
|
if (!toggle || !config.themes) return;
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const current = config.themes.getCurrentTheme();
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
config.themes.setTheme(next);
|
||||||
|
if (config.settings) {
|
||||||
|
config.settings.set('ui.theme', next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelected(file) {
|
||||||
|
appState.currentFile = file;
|
||||||
|
appState.rawData = [];
|
||||||
|
appState.processedData = [];
|
||||||
|
appState.dataStructure = null;
|
||||||
|
appState.currentChart = null;
|
||||||
|
|
||||||
|
updateFileInfo(file);
|
||||||
|
showInlineStatus('uploadStatus', 'info', `Selected file: ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileParsed(data) {
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
showInlineStatus('uploadStatus', 'warning', 'File parsed, but no usable rows found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.rawData = data;
|
||||||
|
appState.processedData = data;
|
||||||
|
appState.dataStructure = modules.dataProcessor.analyzeDataStructure(data);
|
||||||
|
|
||||||
|
updateDataPreview(appState.processedData);
|
||||||
|
updateColumnAnalysis(appState.dataStructure);
|
||||||
|
populateAxisSelectors(appState.processedData);
|
||||||
|
renderSelectedChart();
|
||||||
|
updateDashboard();
|
||||||
|
|
||||||
|
showSection('preview');
|
||||||
|
notify('success', `Parsed successfully: ${data.length} rows.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModuleError(error) {
|
||||||
|
console.error(error);
|
||||||
|
const message = error?.message || 'An unexpected error occurred.';
|
||||||
|
showInlineStatus('uploadStatus', 'danger', message);
|
||||||
|
notify('error', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasData() {
|
||||||
|
return Array.isArray(appState.processedData) && appState.processedData.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAllSections() {
|
||||||
|
['upload', 'preview', 'analyze', 'dashboard', 'export'].forEach((id) => {
|
||||||
|
const section = document.getElementById(id);
|
||||||
|
if (section) section.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSection(sectionId) {
|
||||||
|
hideAllSections();
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
if (section) {
|
||||||
|
section.style.display = 'block';
|
||||||
|
section.classList.add('fade-in');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.nav-link[data-section]').forEach((link) => {
|
||||||
|
link.classList.toggle('active', link.getAttribute('data-section') === sectionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileInfo(file) {
|
||||||
|
const info = document.getElementById('fileInfo');
|
||||||
|
const fileName = document.getElementById('fileName');
|
||||||
|
const fileSize = document.getElementById('fileSize');
|
||||||
|
|
||||||
|
if (info) info.style.display = 'block';
|
||||||
|
if (fileName) fileName.textContent = file.name;
|
||||||
|
if (fileSize) fileSize.textContent = formatFileSize(file.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDataPreview(data) {
|
||||||
|
const stats = calculateDataStats(data);
|
||||||
|
|
||||||
|
const totalRows = document.getElementById('totalRows');
|
||||||
|
const totalCols = document.getElementById('totalCols');
|
||||||
|
const missingValues = document.getElementById('missingValues');
|
||||||
|
const dataQuality = document.getElementById('dataQuality');
|
||||||
|
|
||||||
|
if (totalRows) totalRows.textContent = data.length;
|
||||||
|
if (totalCols) totalCols.textContent = data.length ? Object.keys(data[0]).length : 0;
|
||||||
|
if (missingValues) missingValues.textContent = stats.missingCount;
|
||||||
|
if (dataQuality) dataQuality.textContent = `${stats.qualityPercentage}%`;
|
||||||
|
|
||||||
|
renderDataTable(data, appState.previewLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDataTable(data, limit = 100) {
|
||||||
|
const tableHeader = document.getElementById('tableHeader');
|
||||||
|
const tableBody = document.getElementById('tableBody');
|
||||||
|
if (!tableHeader || !tableBody) return;
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
tableHeader.innerHTML = '';
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
tableHeader.innerHTML = `<tr>${headers.map((h) => `<th>${h}</th>`).join('')}</tr>`;
|
||||||
|
|
||||||
|
const displayRows = data.slice(0, Math.min(limit, data.length));
|
||||||
|
tableBody.innerHTML = displayRows
|
||||||
|
.map((row) => {
|
||||||
|
const cells = headers
|
||||||
|
.map((header) => {
|
||||||
|
const value = row[header];
|
||||||
|
const missing = value === null || value === undefined || value === '';
|
||||||
|
return `<td class="${missing ? 'table-warning' : ''}">${missing ? '' : String(value)}</td>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDataStats(data) {
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return { missingCount: 0, qualityPercentage: 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
let missingCount = 0;
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
data.forEach((row) => {
|
||||||
|
headers.forEach((header) => {
|
||||||
|
totalCount += 1;
|
||||||
|
const value = row[header];
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
missingCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const qualityPercentage = totalCount ? Math.round(((totalCount - missingCount) / totalCount) * 100) : 100;
|
||||||
|
return { missingCount, qualityPercentage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColumnAnalysis(structure) {
|
||||||
|
const container = document.getElementById('columnAnalysis');
|
||||||
|
if (!container || !structure || !Array.isArray(structure.columns)) return;
|
||||||
|
|
||||||
|
container.innerHTML = structure.columns
|
||||||
|
.map((col) => {
|
||||||
|
const missing = col.missingCount ?? col.nullCount ?? 0;
|
||||||
|
return `
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<h6 class="mb-1">${col.name}</h6>
|
||||||
|
<small class="text-muted">Type: ${col.type}</small><br>
|
||||||
|
<small class="text-muted">Unique: ${col.uniqueValues ?? 0}</small><br>
|
||||||
|
<small class="text-muted">Missing: ${missing}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateAxisSelectors(data) {
|
||||||
|
const xAxisSelector = document.getElementById('xAxisSelector');
|
||||||
|
const yAxisSelector = document.getElementById('yAxisSelector');
|
||||||
|
if (!xAxisSelector || !yAxisSelector || !data || !data.length) return;
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const numericHeaders = headers.filter((header) =>
|
||||||
|
data.every((row) => row[header] === null || row[header] === '' || !Number.isNaN(Number(row[header])))
|
||||||
|
);
|
||||||
|
|
||||||
|
xAxisSelector.innerHTML = headers.map((header) => `<option value="${header}">${header}</option>`).join('');
|
||||||
|
yAxisSelector.innerHTML = headers.map((header) => `<option value="${header}">${header}</option>`).join('');
|
||||||
|
|
||||||
|
xAxisSelector.value = headers[0];
|
||||||
|
yAxisSelector.value = numericHeaders[0] || headers[Math.min(1, headers.length - 1)] || headers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSelectedChart() {
|
||||||
|
if (!hasData()) return;
|
||||||
|
|
||||||
|
const chartTypeSelector = document.getElementById('chartTypeSelector');
|
||||||
|
const xAxisSelector = document.getElementById('xAxisSelector');
|
||||||
|
const yAxisSelector = document.getElementById('yAxisSelector');
|
||||||
|
|
||||||
|
const type = chartTypeSelector ? chartTypeSelector.value : 'auto';
|
||||||
|
const xAxis = xAxisSelector ? xAxisSelector.value : undefined;
|
||||||
|
const yAxis = yAxisSelector ? yAxisSelector.value : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chart = await modules.visualizer.renderChart(appState.processedData.slice(0, 1000), {
|
||||||
|
type,
|
||||||
|
xAxis,
|
||||||
|
yAxis
|
||||||
|
});
|
||||||
|
appState.currentChart = chart;
|
||||||
|
updateDashboard();
|
||||||
|
} catch (error) {
|
||||||
|
handleModuleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboard() {
|
||||||
|
if (!modules.dashboard) return;
|
||||||
|
|
||||||
|
const chartDataUrl =
|
||||||
|
appState.currentChart && typeof appState.currentChart.toBase64Image === 'function'
|
||||||
|
? appState.currentChart.toBase64Image('image/png', 1)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
modules.dashboard.render({
|
||||||
|
data: appState.processedData,
|
||||||
|
structure: appState.dataStructure,
|
||||||
|
chartDataUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportData(format) {
|
||||||
|
if (!hasData()) {
|
||||||
|
showInlineStatus('exportStatus', 'warning', 'No data available to export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = (appState.currentFile?.name || 'excel-analyzer-data').replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
|
showInlineStatus('exportStatus', 'info', `Preparing ${format.toUpperCase()} export...`);
|
||||||
|
|
||||||
|
await modules.exporter.export(format, {
|
||||||
|
filenameBase: baseName,
|
||||||
|
title: `Excel Analyzer Report - ${baseName}`,
|
||||||
|
rawData: appState.rawData,
|
||||||
|
processedData: appState.processedData,
|
||||||
|
dataStructure: appState.dataStructure,
|
||||||
|
currentChart: appState.currentChart
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExportProgress(progress) {
|
||||||
|
const wrapper = document.getElementById('exportProgress');
|
||||||
|
const bar = wrapper ? wrapper.querySelector('.progress-bar') : null;
|
||||||
|
if (!wrapper || !bar) return;
|
||||||
|
|
||||||
|
wrapper.style.display = 'block';
|
||||||
|
const value = Math.max(0, Math.min(100, Number(progress?.percentage || 0)));
|
||||||
|
bar.style.width = `${value}%`;
|
||||||
|
bar.textContent = `${value}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInlineStatus(elementId, type, message) {
|
||||||
|
const container = document.getElementById(elementId);
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = `<div class="alert alert-${type} mb-0">${message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(level, message) {
|
||||||
|
if (!modules.notifications) return;
|
||||||
|
const fn = modules.notifications[level] || modules.notifications.info;
|
||||||
|
fn.call(modules.notifications, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (!bytes) return '0 Bytes';
|
||||||
|
const units = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = bytes / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
window.ExcelAnalyzer = {
|
||||||
|
appState,
|
||||||
|
modules,
|
||||||
|
config,
|
||||||
|
init
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
/**
|
||||||
|
* Analytics Module of Web Application
|
||||||
|
*
|
||||||
|
* Handles data analysis, statistical calculations, and insights generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Analytics {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.analysisCache = new Map();
|
||||||
|
this.maxCacheSize = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze data structure
|
||||||
|
*/
|
||||||
|
analyzeDataStructure(data) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return {
|
||||||
|
columns: [],
|
||||||
|
rowCount: 0,
|
||||||
|
columnCount: 0,
|
||||||
|
dataTypes: {},
|
||||||
|
missingValues: {},
|
||||||
|
uniqueValues: {},
|
||||||
|
statistics: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const rowCount = data.length;
|
||||||
|
const columnCount = headers.length;
|
||||||
|
|
||||||
|
// Analyze each column
|
||||||
|
const columns = headers.map(header => {
|
||||||
|
const values = data.map(row => row[header]);
|
||||||
|
const dataType = this.detectDataType(values);
|
||||||
|
const missingCount = this.countMissingValues(values);
|
||||||
|
const uniqueCount = this.countUniqueValues(values);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: header,
|
||||||
|
type: dataType,
|
||||||
|
missingCount: missingCount,
|
||||||
|
uniqueValues: uniqueCount,
|
||||||
|
sampleValues: values.slice(0, 5)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate statistics for numeric columns
|
||||||
|
const statistics = this.calculateStatistics(data, headers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: columns,
|
||||||
|
rowCount: rowCount,
|
||||||
|
columnCount: columnCount,
|
||||||
|
dataTypes: this.getColumnDataTypes(columns),
|
||||||
|
missingValues: this.getMissingValuesSummary(columns),
|
||||||
|
uniqueValues: this.getUniqueValuesSummary(columns),
|
||||||
|
statistics: statistics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect data type for a column
|
||||||
|
*/
|
||||||
|
detectDataType(values) {
|
||||||
|
if (values.length === 0) return 'empty';
|
||||||
|
|
||||||
|
// Remove null/undefined values for analysis
|
||||||
|
const cleanValues = values.filter(v => v !== null && v !== undefined && v !== '');
|
||||||
|
|
||||||
|
if (cleanValues.length === 0) return 'empty';
|
||||||
|
|
||||||
|
// Check for date patterns
|
||||||
|
const datePatterns = [
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/,
|
||||||
|
/^\d{2}\/\d{2}\/\d{4}$/,
|
||||||
|
/^\d{2}-\d{2}-\d{4}$/,
|
||||||
|
/^\d{4}\/\d{2}\/\d{2}$/
|
||||||
|
];
|
||||||
|
|
||||||
|
let dateCount = 0;
|
||||||
|
let numericCount = 0;
|
||||||
|
let booleanCount = 0;
|
||||||
|
|
||||||
|
for (const value of cleanValues) {
|
||||||
|
const strValue = String(value).trim();
|
||||||
|
|
||||||
|
// Check for date patterns
|
||||||
|
if (datePatterns.some(pattern => pattern.test(strValue))) {
|
||||||
|
if (!isNaN(Date.parse(strValue))) {
|
||||||
|
dateCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for numeric values
|
||||||
|
if (!isNaN(parseFloat(strValue)) && isFinite(strValue)) {
|
||||||
|
numericCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for boolean values
|
||||||
|
if (['true', 'false', 'yes', 'no', '1', '0'].includes(strValue.toLowerCase())) {
|
||||||
|
booleanCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine type based on majority
|
||||||
|
const total = cleanValues.length;
|
||||||
|
const dateRatio = dateCount / total;
|
||||||
|
const numericRatio = numericCount / total;
|
||||||
|
const booleanRatio = booleanCount / total;
|
||||||
|
|
||||||
|
if (dateRatio > 0.8) return 'date';
|
||||||
|
if (numericRatio > 0.8) return 'numeric';
|
||||||
|
if (booleanRatio > 0.8) return 'boolean';
|
||||||
|
|
||||||
|
return 'categorical';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count missing values
|
||||||
|
*/
|
||||||
|
countMissingValues(values) {
|
||||||
|
return values.filter(v => v === null || v === undefined || v === '').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count unique values
|
||||||
|
*/
|
||||||
|
countUniqueValues(values) {
|
||||||
|
const unique = new Set(values.map(v => String(v)));
|
||||||
|
return unique.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate statistics for numeric columns
|
||||||
|
*/
|
||||||
|
calculateStatistics(data, headers) {
|
||||||
|
const stats = {};
|
||||||
|
|
||||||
|
headers.forEach(header => {
|
||||||
|
const values = data.map(row => parseFloat(row[header])).filter(v => !isNaN(v));
|
||||||
|
|
||||||
|
if (values.length > 0) {
|
||||||
|
stats[header] = {
|
||||||
|
min: Math.min(...values),
|
||||||
|
max: Math.max(...values),
|
||||||
|
mean: this.calculateMean(values),
|
||||||
|
median: this.calculateMedian(values),
|
||||||
|
stdDev: this.calculateStandardDeviation(values),
|
||||||
|
variance: this.calculateVariance(values),
|
||||||
|
sum: this.calculateSum(values),
|
||||||
|
count: values.length,
|
||||||
|
quartiles: this.calculateQuartiles(values)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate mean
|
||||||
|
*/
|
||||||
|
calculateMean(values) {
|
||||||
|
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate median
|
||||||
|
*/
|
||||||
|
calculateMedian(values) {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
|
||||||
|
if (sorted.length % 2 === 0) {
|
||||||
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
} else {
|
||||||
|
return sorted[mid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate standard deviation
|
||||||
|
*/
|
||||||
|
calculateStandardDeviation(values) {
|
||||||
|
const mean = this.calculateMean(values);
|
||||||
|
const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate variance
|
||||||
|
*/
|
||||||
|
calculateVariance(values) {
|
||||||
|
const mean = this.calculateMean(values);
|
||||||
|
return values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate sum
|
||||||
|
*/
|
||||||
|
calculateSum(values) {
|
||||||
|
return values.reduce((sum, val) => sum + val, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate quartiles
|
||||||
|
*/
|
||||||
|
calculateQuartiles(values) {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const q1 = this.calculatePercentile(sorted, 25);
|
||||||
|
const q2 = this.calculatePercentile(sorted, 50); // median
|
||||||
|
const q3 = this.calculatePercentile(sorted, 75);
|
||||||
|
|
||||||
|
return {
|
||||||
|
q1: q1,
|
||||||
|
q2: q2,
|
||||||
|
q3: q3,
|
||||||
|
iqr: q3 - q1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate percentile
|
||||||
|
*/
|
||||||
|
calculatePercentile(sortedValues, percentile) {
|
||||||
|
const index = (percentile / 100) * (sortedValues.length - 1);
|
||||||
|
const lower = Math.floor(index);
|
||||||
|
const upper = Math.ceil(index);
|
||||||
|
const weight = index % 1;
|
||||||
|
|
||||||
|
if (upper >= sortedValues.length) return sortedValues[sortedValues.length - 1];
|
||||||
|
|
||||||
|
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get column data types summary
|
||||||
|
*/
|
||||||
|
getColumnDataTypes(columns) {
|
||||||
|
const types = {};
|
||||||
|
columns.forEach(col => {
|
||||||
|
types[col.name] = col.type;
|
||||||
|
});
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get missing values summary
|
||||||
|
*/
|
||||||
|
getMissingValuesSummary(columns) {
|
||||||
|
const summary = {};
|
||||||
|
columns.forEach(col => {
|
||||||
|
summary[col.name] = col.missingCount;
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique values summary
|
||||||
|
*/
|
||||||
|
getUniqueValuesSummary(columns) {
|
||||||
|
const summary = {};
|
||||||
|
columns.forEach(col => {
|
||||||
|
summary[col.name] = col.uniqueValues;
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect outliers using IQR method
|
||||||
|
*/
|
||||||
|
detectOutliers(data, columnName, method = 'iqr') {
|
||||||
|
const values = data.map(row => parseFloat(row[columnName])).filter(v => !isNaN(v));
|
||||||
|
|
||||||
|
if (values.length === 0) return { outliers: [], bounds: null };
|
||||||
|
|
||||||
|
if (method === 'iqr') {
|
||||||
|
return this.detectOutliersIQR(values);
|
||||||
|
} else if (method === 'zscore') {
|
||||||
|
return this.detectOutliersZScore(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { outliers: [], bounds: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect outliers using IQR method
|
||||||
|
*/
|
||||||
|
detectOutliersIQR(values) {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const q1 = this.calculatePercentile(sorted, 25);
|
||||||
|
const q3 = this.calculatePercentile(sorted, 75);
|
||||||
|
const iqr = q3 - q1;
|
||||||
|
|
||||||
|
const lowerBound = q1 - 1.5 * iqr;
|
||||||
|
const upperBound = q3 + 1.5 * iqr;
|
||||||
|
|
||||||
|
const outliers = values.filter(v => v < lowerBound || v > upperBound);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outliers: outliers,
|
||||||
|
bounds: { lower: lowerBound, upper: upperBound },
|
||||||
|
count: outliers.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect outliers using Z-score method
|
||||||
|
*/
|
||||||
|
detectOutliersZScore(values, threshold = 3) {
|
||||||
|
const mean = this.calculateMean(values);
|
||||||
|
const stdDev = this.calculateStandardDeviation(values);
|
||||||
|
|
||||||
|
const outliers = values.filter(v => Math.abs((v - mean) / stdDev) > threshold);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outliers: outliers,
|
||||||
|
bounds: { mean: mean, stdDev: stdDev, threshold: threshold },
|
||||||
|
count: outliers.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate correlation between two columns
|
||||||
|
*/
|
||||||
|
calculateCorrelation(data, column1, column2) {
|
||||||
|
const x = data.map(row => parseFloat(row[column1])).filter(v => !isNaN(v));
|
||||||
|
const y = data.map(row => parseFloat(row[column2])).filter(v => !isNaN(v));
|
||||||
|
|
||||||
|
if (x.length !== y.length || x.length === 0) {
|
||||||
|
return { correlation: 0, strength: 'none', direction: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = x.length;
|
||||||
|
const sumX = x.reduce((a, b) => a + b, 0);
|
||||||
|
const sumY = y.reduce((a, b) => a + b, 0);
|
||||||
|
const sumXY = x.reduce((sum, val, i) => sum + val * y[i], 0);
|
||||||
|
const sumX2 = x.reduce((sum, val) => sum + val * val, 0);
|
||||||
|
const sumY2 = y.reduce((sum, val) => sum + val * val, 0);
|
||||||
|
|
||||||
|
const numerator = n * sumXY - sumX * sumY;
|
||||||
|
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||||
|
|
||||||
|
if (denominator === 0) {
|
||||||
|
return { correlation: 0, strength: 'none', direction: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const correlation = numerator / denominator;
|
||||||
|
|
||||||
|
return {
|
||||||
|
correlation: correlation,
|
||||||
|
strength: this.getCorrelationStrength(Math.abs(correlation)),
|
||||||
|
direction: correlation > 0 ? 'positive' : 'negative'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get correlation strength description
|
||||||
|
*/
|
||||||
|
getCorrelationStrength(value) {
|
||||||
|
if (value < 0.3) return 'weak';
|
||||||
|
if (value < 0.7) return 'moderate';
|
||||||
|
return 'strong';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate insights
|
||||||
|
*/
|
||||||
|
generateInsights(dataStructure) {
|
||||||
|
const insights = [];
|
||||||
|
|
||||||
|
// Data quality insights
|
||||||
|
const totalMissing = Object.values(dataStructure.missingValues).reduce((sum, val) => sum + val, 0);
|
||||||
|
const totalCells = dataStructure.rowCount * dataStructure.columnCount;
|
||||||
|
const dataQuality = ((totalCells - totalMissing) / totalCells) * 100;
|
||||||
|
|
||||||
|
if (dataQuality < 80) {
|
||||||
|
insights.push({
|
||||||
|
type: 'data_quality',
|
||||||
|
severity: 'warning',
|
||||||
|
message: `Data quality is ${dataQuality.toFixed(1)}%. Consider cleaning missing values.`,
|
||||||
|
details: { quality: dataQuality, missing: totalMissing, total: totalCells }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column type insights
|
||||||
|
const numericColumns = dataStructure.columns.filter(col => col.type === 'numeric');
|
||||||
|
const categoricalColumns = dataStructure.columns.filter(col => col.type === 'categorical');
|
||||||
|
|
||||||
|
if (numericColumns.length > 1) {
|
||||||
|
insights.push({
|
||||||
|
type: 'analysis_opportunity',
|
||||||
|
severity: 'info',
|
||||||
|
message: `Found ${numericColumns.length} numeric columns. Consider correlation analysis.`,
|
||||||
|
details: { columns: numericColumns.map(c => c.name) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoricalColumns.length > 0) {
|
||||||
|
insights.push({
|
||||||
|
type: 'visualization_opportunity',
|
||||||
|
severity: 'info',
|
||||||
|
message: `Found ${categoricalColumns.length} categorical columns. Good for bar charts and pie charts.`,
|
||||||
|
details: { columns: categoricalColumns.map(c => c.name) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outlier insights
|
||||||
|
numericColumns.forEach(col => {
|
||||||
|
const stats = dataStructure.statistics[col.name];
|
||||||
|
if (stats) {
|
||||||
|
const outliers = this.detectOutliers(dataStructure.rawData, col.name);
|
||||||
|
if (outliers.count > 0) {
|
||||||
|
insights.push({
|
||||||
|
type: 'outliers',
|
||||||
|
severity: 'warning',
|
||||||
|
message: `Column "${col.name}" has ${outliers.count} outliers.`,
|
||||||
|
details: { column: col.name, count: outliers.count, bounds: outliers.bounds }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return insights;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform trend analysis
|
||||||
|
*/
|
||||||
|
analyzeTrends(data, timeColumn, valueColumn) {
|
||||||
|
const timeValues = data.map(row => row[timeColumn]);
|
||||||
|
const numericValues = data.map(row => parseFloat(row[valueColumn])).filter(v => !isNaN(v));
|
||||||
|
|
||||||
|
if (timeValues.length === 0 || numericValues.length === 0) {
|
||||||
|
return { trend: 'none', slope: 0, rSquared: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple linear regression
|
||||||
|
const n = numericValues.length;
|
||||||
|
const sumX = timeValues.reduce((sum, val, i) => sum + i, 0);
|
||||||
|
const sumY = numericValues.reduce((sum, val) => sum + val, 0);
|
||||||
|
const sumXY = timeValues.reduce((sum, val, i) => sum + i * numericValues[i], 0);
|
||||||
|
const sumX2 = timeValues.reduce((sum, val, i) => sum + i * i, 0);
|
||||||
|
|
||||||
|
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||||
|
const intercept = (sumY - slope * sumX) / n;
|
||||||
|
|
||||||
|
// Calculate R-squared
|
||||||
|
const yMean = sumY / n;
|
||||||
|
const ssRes = numericValues.reduce((sum, val, i) => {
|
||||||
|
const predicted = slope * i + intercept;
|
||||||
|
return sum + Math.pow(val - predicted, 2);
|
||||||
|
}, 0);
|
||||||
|
const ssTot = numericValues.reduce((sum, val) => sum + Math.pow(val - yMean, 2), 0);
|
||||||
|
const rSquared = 1 - (ssRes / ssTot);
|
||||||
|
|
||||||
|
let trend = 'none';
|
||||||
|
if (Math.abs(slope) > 0.1) {
|
||||||
|
trend = slope > 0 ? 'increasing' : 'decreasing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
trend: trend,
|
||||||
|
slope: slope,
|
||||||
|
rSquared: rSquared,
|
||||||
|
intercept: intercept,
|
||||||
|
equation: `y = ${slope.toFixed(4)}x + ${intercept.toFixed(4)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache analysis results
|
||||||
|
*/
|
||||||
|
cacheAnalysis(key, result) {
|
||||||
|
if (this.analysisCache.size >= this.maxCacheSize) {
|
||||||
|
// Remove oldest entry
|
||||||
|
const oldestKey = this.analysisCache.keys().next().value;
|
||||||
|
this.analysisCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.analysisCache.set(key, {
|
||||||
|
result: result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
size: JSON.stringify(result).length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached analysis
|
||||||
|
*/
|
||||||
|
getCachedAnalysis(key) {
|
||||||
|
const cached = this.analysisCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
// Check if cache is still valid (10 minutes)
|
||||||
|
if (Date.now() - cached.timestamp < 600000) {
|
||||||
|
return cached.result;
|
||||||
|
} else {
|
||||||
|
this.analysisCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.analysisCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
getCacheStats() {
|
||||||
|
return {
|
||||||
|
size: this.analysisCache.size,
|
||||||
|
maxSize: this.maxCacheSize,
|
||||||
|
entries: Array.from(this.analysisCache.keys()),
|
||||||
|
totalSize: Array.from(this.analysisCache.values()).reduce((sum, entry) => sum + entry.size, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform comprehensive analysis
|
||||||
|
*/
|
||||||
|
async performAnalysis(data, options = {}) {
|
||||||
|
const cacheKey = this.generateCacheKey(data, options);
|
||||||
|
const cached = this.getCachedAnalysis(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Perform analysis
|
||||||
|
const structure = this.analyzeDataStructure(data);
|
||||||
|
const insights = this.generateInsights(structure);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
structure: structure,
|
||||||
|
insights: insights,
|
||||||
|
analysisTime: Date.now() - startTime,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
options: options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
this.cacheAnalysis(cacheKey, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key
|
||||||
|
*/
|
||||||
|
generateCacheKey(data, options) {
|
||||||
|
const dataHash = this.hashData(data);
|
||||||
|
const optionsHash = JSON.stringify(options);
|
||||||
|
return `${dataHash}-${optionsHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple data hash function
|
||||||
|
*/
|
||||||
|
hashData(data) {
|
||||||
|
const str = JSON.stringify(data.slice(0, 100)); // Hash only first 100 rows
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32-bit integer
|
||||||
|
}
|
||||||
|
return hash.toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export analysis report
|
||||||
|
*/
|
||||||
|
exportAnalysisReport(analysisResult) {
|
||||||
|
const report = {
|
||||||
|
metadata: {
|
||||||
|
title: 'Data Analysis Report',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
rowCount: analysisResult.structure.rowCount,
|
||||||
|
columnCount: analysisResult.structure.columnCount,
|
||||||
|
analysisTime: analysisResult.analysisTime
|
||||||
|
},
|
||||||
|
dataStructure: analysisResult.structure,
|
||||||
|
insights: analysisResult.insights,
|
||||||
|
recommendations: this.generateRecommendations(analysisResult)
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(report, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate recommendations based on analysis
|
||||||
|
*/
|
||||||
|
generateRecommendations(analysisResult) {
|
||||||
|
const recommendations = [];
|
||||||
|
|
||||||
|
// Data quality recommendations
|
||||||
|
const dataQuality = analysisResult.insights.find(i => i.type === 'data_quality');
|
||||||
|
if (dataQuality && dataQuality.severity === 'warning') {
|
||||||
|
recommendations.push({
|
||||||
|
category: 'Data Quality',
|
||||||
|
priority: 'high',
|
||||||
|
action: 'Clean missing values',
|
||||||
|
description: 'Consider filling or removing missing data to improve analysis quality.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visualization recommendations
|
||||||
|
const vizOpportunities = analysisResult.insights.filter(i => i.type === 'visualization_opportunity');
|
||||||
|
if (vizOpportunities.length > 0) {
|
||||||
|
recommendations.push({
|
||||||
|
category: 'Visualization',
|
||||||
|
priority: 'medium',
|
||||||
|
action: 'Create charts',
|
||||||
|
description: 'Generate visualizations for categorical data to identify patterns.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analysis recommendations
|
||||||
|
const analysisOpportunities = analysisResult.insights.filter(i => i.type === 'analysis_opportunity');
|
||||||
|
if (analysisOpportunities.length > 0) {
|
||||||
|
recommendations.push({
|
||||||
|
category: 'Analysis',
|
||||||
|
priority: 'medium',
|
||||||
|
action: 'Perform correlation analysis',
|
||||||
|
description: 'Analyze relationships between numeric variables.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Analytics;
|
||||||
|
} else {
|
||||||
|
window.Analytics = Analytics;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* Dashboard Module of Web Application
|
||||||
|
*
|
||||||
|
* Renders lightweight KPI and chart widgets into the dashboard section.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DashboardManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.containerId = options.containerId || 'dashboardGrid';
|
||||||
|
this.container = document.getElementById(this.containerId);
|
||||||
|
this.layout = 'layout1';
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayout(layout) {
|
||||||
|
this.layout = layout || 'layout1';
|
||||||
|
if (this.container) {
|
||||||
|
this.container.dataset.layout = this.layout;
|
||||||
|
}
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ data = [], structure = null, chartDataUrl = null } = {}) {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const stats = this.calculateStats(data, structure);
|
||||||
|
const chartCard = chartDataUrl
|
||||||
|
? `
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header"><strong>Latest Chart Snapshot</strong></div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<img src="${chartDataUrl}" alt="Chart snapshot" style="max-width:100%;height:auto;border:1px solid var(--border-color);border-radius:6px;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header"><strong>Latest Chart Snapshot</strong></div>
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-center text-muted">
|
||||||
|
No chart rendered yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const columns = (structure?.columns || [])
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((c) => `<li class="list-group-item d-flex justify-content-between"><span>${c.name}</span><small class="text-muted">${c.type}</small></li>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header"><strong>Dataset Summary</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-1"><strong>Rows:</strong> ${stats.rows}</p>
|
||||||
|
<p class="mb-1"><strong>Columns:</strong> ${stats.columns}</p>
|
||||||
|
<p class="mb-1"><strong>Missing:</strong> ${stats.missing}</p>
|
||||||
|
<p class="mb-0"><strong>Quality:</strong> ${stats.quality}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header"><strong>Columns</strong></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
${columns || '<li class="list-group-item text-muted">No column info available.</li>'}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
${chartCard}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateStats(data, structure) {
|
||||||
|
const rows = Array.isArray(data) ? data.length : 0;
|
||||||
|
const columns = structure?.columnCount || (rows ? Object.keys(data[0]).length : 0);
|
||||||
|
|
||||||
|
let missing = 0;
|
||||||
|
if (rows && columns) {
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
data.forEach((row) => {
|
||||||
|
headers.forEach((h) => {
|
||||||
|
const v = row[h];
|
||||||
|
if (v === null || v === undefined || v === '') missing += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCells = rows * columns;
|
||||||
|
const quality = totalCells ? Math.round(((totalCells - missing) / totalCells) * 100) : 100;
|
||||||
|
|
||||||
|
return { rows, columns, missing, quality };
|
||||||
|
}
|
||||||
|
|
||||||
|
exportState() {
|
||||||
|
return {
|
||||||
|
layout: this.layout,
|
||||||
|
html: this.container ? this.container.innerHTML : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
emitUpdate() {
|
||||||
|
if (typeof this.options.onDashboardUpdated === 'function') {
|
||||||
|
this.options.onDashboardUpdated(this.exportState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = DashboardManager;
|
||||||
|
} else {
|
||||||
|
window.DashboardManager = DashboardManager;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
/**
|
||||||
|
* Data Processing Module of Web Application
|
||||||
|
*
|
||||||
|
* Handles data analysis, preprocessing, missing value detection,
|
||||||
|
* outlier identification, and data cleaning.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DataProcessor {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze data structure and characteristics
|
||||||
|
*/
|
||||||
|
analyzeDataStructure(data) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
throw new Error('No data to analyze');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const rowCount = data.length;
|
||||||
|
|
||||||
|
const columns = headers.map(header => {
|
||||||
|
const values = data.map(row => row[header]);
|
||||||
|
return this.analyzeColumn(header, values);
|
||||||
|
});
|
||||||
|
|
||||||
|
const structure = {
|
||||||
|
rowCount,
|
||||||
|
columnCount: headers.length,
|
||||||
|
columns,
|
||||||
|
totalCells: rowCount * headers.length,
|
||||||
|
missingCells: this.countMissingValues(data),
|
||||||
|
dataTypes: this.detectDataTypes(data)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.options.onDataAnalyzed) {
|
||||||
|
this.options.onDataAnalyzed(structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze individual column characteristics
|
||||||
|
*/
|
||||||
|
analyzeColumn(name, values) {
|
||||||
|
const nonNullValues = values.filter(v => v !== null && v !== undefined && v !== '');
|
||||||
|
const nullCount = values.length - nonNullValues.length;
|
||||||
|
const uniqueValues = new Set(nonNullValues.map(v => v.toString())).size;
|
||||||
|
|
||||||
|
const dataType = this.detectColumnType(nonNullValues);
|
||||||
|
const statistics = this.calculateStatistics(nonNullValues, dataType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: dataType,
|
||||||
|
uniqueValues,
|
||||||
|
nullCount,
|
||||||
|
nullPercentage: Math.round((nullCount / values.length) * 100),
|
||||||
|
statistics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect column data type
|
||||||
|
*/
|
||||||
|
detectColumnType(values) {
|
||||||
|
if (values.length === 0) return 'unknown';
|
||||||
|
|
||||||
|
// Check for date/time
|
||||||
|
const datePattern = /^\d{4}-\d{2}-\d{2}/;
|
||||||
|
const timePattern = /^\d{2}:\d{2}:\d{2}/;
|
||||||
|
|
||||||
|
for (let value of values) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (datePattern.test(value) || timePattern.test(value)) {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
if (!isNaN(Date.parse(value))) {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for numeric
|
||||||
|
const numericValues = values.filter(v => !isNaN(v) && v !== '');
|
||||||
|
if (numericValues.length / values.length > 0.8) {
|
||||||
|
return 'numeric';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for boolean
|
||||||
|
const booleanValues = values.filter(v =>
|
||||||
|
v === true || v === false ||
|
||||||
|
v === 'true' || v === 'false' ||
|
||||||
|
v === 'TRUE' || v === 'FALSE' ||
|
||||||
|
v === 0 || v === 1
|
||||||
|
);
|
||||||
|
if (booleanValues.length / values.length > 0.9) {
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to text
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate column statistics
|
||||||
|
*/
|
||||||
|
calculateStatistics(values, dataType) {
|
||||||
|
if (dataType !== 'numeric' || values.length === 0) {
|
||||||
|
return {
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
mean: null,
|
||||||
|
median: null,
|
||||||
|
stdDev: null,
|
||||||
|
outliers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValues = values.map(v => parseFloat(v)).filter(v => !isNaN(v));
|
||||||
|
|
||||||
|
if (numericValues.length === 0) {
|
||||||
|
return { min: null, max: null, mean: null, median: null, stdDev: null, outliers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
numericValues.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const min = Math.min(...numericValues);
|
||||||
|
const max = Math.max(...numericValues);
|
||||||
|
const mean = this.calculateMean(numericValues);
|
||||||
|
const median = this.calculateMedian(numericValues);
|
||||||
|
const stdDev = this.calculateStdDev(numericValues, mean);
|
||||||
|
const outliers = this.detectOutliers(numericValues, mean, stdDev);
|
||||||
|
|
||||||
|
return {
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
mean,
|
||||||
|
median,
|
||||||
|
stdDev,
|
||||||
|
outliers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate mean
|
||||||
|
*/
|
||||||
|
calculateMean(values) {
|
||||||
|
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate median
|
||||||
|
*/
|
||||||
|
calculateMedian(values) {
|
||||||
|
const mid = Math.floor(values.length / 2);
|
||||||
|
return values.length % 2 !== 0
|
||||||
|
? values[mid]
|
||||||
|
: (values[mid - 1] + values[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate standard deviation
|
||||||
|
*/
|
||||||
|
calculateStdDev(values, mean) {
|
||||||
|
const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect outliers using IQR method
|
||||||
|
*/
|
||||||
|
detectOutliers(values, mean, stdDev) {
|
||||||
|
// Using Z-score method for outlier detection
|
||||||
|
const threshold = 3;
|
||||||
|
const outliers = [];
|
||||||
|
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
const zScore = Math.abs((value - mean) / stdDev);
|
||||||
|
if (zScore > threshold) {
|
||||||
|
outliers.push({
|
||||||
|
index,
|
||||||
|
value,
|
||||||
|
zScore
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return outliers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count missing values in dataset
|
||||||
|
*/
|
||||||
|
countMissingValues(data) {
|
||||||
|
let count = 0;
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
|
||||||
|
data.forEach(row => {
|
||||||
|
headers.forEach(header => {
|
||||||
|
if (row[header] === null || row[header] === undefined || row[header] === '') {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect data types for all columns
|
||||||
|
*/
|
||||||
|
detectDataTypes(data) {
|
||||||
|
if (data.length === 0) return {};
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const types = {};
|
||||||
|
|
||||||
|
headers.forEach(header => {
|
||||||
|
const values = data.map(row => row[header]);
|
||||||
|
types[header] = this.detectColumnType(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean data by handling missing values and outliers
|
||||||
|
*/
|
||||||
|
cleanData(data, rules = {}) {
|
||||||
|
if (!data || data.length === 0) return data;
|
||||||
|
|
||||||
|
const cleanedData = JSON.parse(JSON.stringify(data)); // Deep copy
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
|
||||||
|
headers.forEach(header => {
|
||||||
|
const columnRules = rules[header] || {};
|
||||||
|
const values = cleanedData.map(row => row[header]);
|
||||||
|
const dataType = this.detectColumnType(values);
|
||||||
|
|
||||||
|
// Handle missing values
|
||||||
|
if (columnRules.handleMissing) {
|
||||||
|
cleanedData.forEach(row => {
|
||||||
|
if (row[header] === null || row[header] === undefined || row[header] === '') {
|
||||||
|
row[header] = this.handleMissingValue(row[header], dataType, columnRules);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle outliers
|
||||||
|
if (columnRules.handleOutliers && dataType === 'numeric') {
|
||||||
|
const numericValues = values
|
||||||
|
.filter(v => !isNaN(v) && v !== '')
|
||||||
|
.map(v => parseFloat(v));
|
||||||
|
|
||||||
|
if (numericValues.length > 0) {
|
||||||
|
const mean = this.calculateMean(numericValues);
|
||||||
|
const stdDev = this.calculateStdDev(numericValues, mean);
|
||||||
|
|
||||||
|
cleanedData.forEach(row => {
|
||||||
|
const value = parseFloat(row[header]);
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
const zScore = Math.abs((value - mean) / stdDev);
|
||||||
|
if (zScore > 3) { // Outlier threshold
|
||||||
|
row[header] = this.handleOutlier(value, mean, stdDev, columnRules);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.options.onDataProcessed) {
|
||||||
|
this.options.onDataProcessed(cleanedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle missing values based on strategy
|
||||||
|
*/
|
||||||
|
handleMissingValue(value, dataType, rules) {
|
||||||
|
const strategy = rules.missingStrategy || 'remove';
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case 'mean':
|
||||||
|
return dataType === 'numeric' ? 0 : '';
|
||||||
|
case 'median':
|
||||||
|
return dataType === 'numeric' ? 0 : '';
|
||||||
|
case 'mode':
|
||||||
|
return '';
|
||||||
|
case 'forward_fill':
|
||||||
|
return value; // Would need previous value
|
||||||
|
case 'backward_fill':
|
||||||
|
return value; // Would need next value
|
||||||
|
case 'remove':
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle outliers based on strategy
|
||||||
|
*/
|
||||||
|
handleOutlier(value, mean, stdDev, rules) {
|
||||||
|
const strategy = rules.outlierStrategy || 'remove';
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case 'cap':
|
||||||
|
return Math.sign(value) * 3 * stdDev + mean;
|
||||||
|
case 'mean':
|
||||||
|
return mean;
|
||||||
|
case 'remove':
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data quality report
|
||||||
|
*/
|
||||||
|
getDataQualityReport(data) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return { quality: 0, issues: ['No data available'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const totalCells = data.length * headers.length;
|
||||||
|
const missingCells = this.countMissingValues(data);
|
||||||
|
const missingPercentage = (missingCells / totalCells) * 100;
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
let quality = 100;
|
||||||
|
|
||||||
|
if (missingPercentage > 10) {
|
||||||
|
issues.push(`High missing value rate: ${missingPercentage.toFixed(1)}%`);
|
||||||
|
quality -= 20;
|
||||||
|
} else if (missingPercentage > 5) {
|
||||||
|
issues.push(`Moderate missing value rate: ${missingPercentage.toFixed(1)}%`);
|
||||||
|
quality -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data type inconsistencies
|
||||||
|
headers.forEach(header => {
|
||||||
|
const values = data.map(row => row[header]);
|
||||||
|
const dataType = this.detectColumnType(values);
|
||||||
|
|
||||||
|
if (dataType === 'unknown') {
|
||||||
|
issues.push(`Column "${header}" has inconsistent data types`);
|
||||||
|
quality -= 5;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
quality: Math.max(0, quality),
|
||||||
|
missingPercentage,
|
||||||
|
issues,
|
||||||
|
totalRecords: data.length,
|
||||||
|
totalFields: headers.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = DataProcessor;
|
||||||
|
} else {
|
||||||
|
window.DataProcessor = DataProcessor;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
/**
|
||||||
|
* Export Module of Web Application
|
||||||
|
*
|
||||||
|
* Handles export to PDF, Excel, CSV, JSON and PNG chart image.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Exporter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(format, options = {}) {
|
||||||
|
const normalizedFormat = String(format || '').toLowerCase();
|
||||||
|
const payload = this.preparePayload(options);
|
||||||
|
|
||||||
|
this.emitProgress(15, 'Preparing export data...');
|
||||||
|
|
||||||
|
let result;
|
||||||
|
switch (normalizedFormat) {
|
||||||
|
case 'pdf':
|
||||||
|
result = await this.exportPDF(payload, options);
|
||||||
|
break;
|
||||||
|
case 'excel':
|
||||||
|
case 'xlsx':
|
||||||
|
result = await this.exportExcel(payload, options);
|
||||||
|
break;
|
||||||
|
case 'csv':
|
||||||
|
result = await this.exportCSV(payload, options);
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
result = await this.exportJSON(payload, options);
|
||||||
|
break;
|
||||||
|
case 'png':
|
||||||
|
result = await this.exportPNG(payload, options);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported export format: ${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitProgress(90, 'Downloading file...');
|
||||||
|
this.downloadBlob(result.blob, result.filename);
|
||||||
|
this.emitProgress(100, 'Export complete.');
|
||||||
|
|
||||||
|
if (typeof this.options.onExportComplete === 'function') {
|
||||||
|
this.options.onExportComplete({
|
||||||
|
format: normalizedFormat,
|
||||||
|
filename: result.filename
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
preparePayload(options = {}) {
|
||||||
|
return {
|
||||||
|
rawData: Array.isArray(options.rawData) ? options.rawData : [],
|
||||||
|
processedData: Array.isArray(options.processedData) ? options.processedData : [],
|
||||||
|
dataStructure: options.dataStructure || null,
|
||||||
|
title: options.title || 'Excel Analyzer Report',
|
||||||
|
chart: options.currentChart || null,
|
||||||
|
exportedAt: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileBase(options = {}) {
|
||||||
|
const rawBase = options.filenameBase || 'excel-analyzer-export';
|
||||||
|
return rawBase.replace(/[<>:"/\\|?*]+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimestamp() {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPDF(payload, options = {}) {
|
||||||
|
if (!window.jspdf || !window.jspdf.jsPDF) {
|
||||||
|
throw new Error('jsPDF is not available in this page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new window.jspdf.jsPDF();
|
||||||
|
const lineHeight = 7;
|
||||||
|
let y = 15;
|
||||||
|
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.text(payload.title, 14, y);
|
||||||
|
y += 10;
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text(`Exported: ${payload.exportedAt.toLocaleString()}`, 14, y);
|
||||||
|
y += lineHeight;
|
||||||
|
doc.text(`Rows: ${payload.processedData.length}`, 14, y);
|
||||||
|
y += lineHeight;
|
||||||
|
doc.text(`Columns: ${payload.processedData.length ? Object.keys(payload.processedData[0]).length : 0}`, 14, y);
|
||||||
|
y += lineHeight * 2;
|
||||||
|
|
||||||
|
const previewRows = payload.processedData.slice(0, 20);
|
||||||
|
if (previewRows.length > 0) {
|
||||||
|
const headers = Object.keys(previewRows[0]);
|
||||||
|
doc.setFont(undefined, 'bold');
|
||||||
|
doc.text(headers.join(' | ').slice(0, 180), 14, y);
|
||||||
|
y += lineHeight;
|
||||||
|
doc.setFont(undefined, 'normal');
|
||||||
|
|
||||||
|
previewRows.forEach((row) => {
|
||||||
|
if (y > 280) {
|
||||||
|
doc.addPage();
|
||||||
|
y = 15;
|
||||||
|
}
|
||||||
|
const line = headers.map((h) => (row[h] ?? '')).join(' | ');
|
||||||
|
doc.text(String(line).slice(0, 180), 14, y);
|
||||||
|
y += lineHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.chart && typeof payload.chart.toBase64Image === 'function') {
|
||||||
|
try {
|
||||||
|
const img = payload.chart.toBase64Image('image/png', 1);
|
||||||
|
doc.addPage();
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.text('Chart Snapshot', 14, 15);
|
||||||
|
doc.addImage(img, 'PNG', 14, 24, 180, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not embed chart image in PDF.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = doc.output('blob');
|
||||||
|
const filename = `${this.getFileBase(options)}-${this.getTimestamp()}.pdf`;
|
||||||
|
return { blob, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportExcel(payload, options = {}) {
|
||||||
|
if (!window.XLSX) {
|
||||||
|
throw new Error('SheetJS (XLSX) is not available in this page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = window.XLSX.utils.book_new();
|
||||||
|
const data = payload.processedData.length ? payload.processedData : payload.rawData;
|
||||||
|
|
||||||
|
const dataSheet = window.XLSX.utils.json_to_sheet(data);
|
||||||
|
window.XLSX.utils.book_append_sheet(workbook, dataSheet, 'Processed Data');
|
||||||
|
|
||||||
|
const summaryRows = [
|
||||||
|
{ Metric: 'Title', Value: payload.title },
|
||||||
|
{ Metric: 'Exported At', Value: payload.exportedAt.toLocaleString() },
|
||||||
|
{ Metric: 'Total Rows', Value: data.length },
|
||||||
|
{ Metric: 'Total Columns', Value: data.length ? Object.keys(data[0]).length : 0 }
|
||||||
|
];
|
||||||
|
const summarySheet = window.XLSX.utils.json_to_sheet(summaryRows);
|
||||||
|
window.XLSX.utils.book_append_sheet(workbook, summarySheet, 'Summary');
|
||||||
|
|
||||||
|
const arrayBuffer = window.XLSX.write(workbook, {
|
||||||
|
bookType: 'xlsx',
|
||||||
|
type: 'array'
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([arrayBuffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const filename = `${this.getFileBase(options)}-${this.getTimestamp()}.xlsx`;
|
||||||
|
return { blob, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportCSV(payload, options = {}) {
|
||||||
|
const data = payload.processedData.length ? payload.processedData : payload.rawData;
|
||||||
|
const csv = this.toCSV(data);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const filename = `${this.getFileBase(options)}-${this.getTimestamp()}.csv`;
|
||||||
|
return { blob, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportJSON(payload, options = {}) {
|
||||||
|
const content = JSON.stringify(
|
||||||
|
{
|
||||||
|
title: payload.title,
|
||||||
|
exportedAt: payload.exportedAt.toISOString(),
|
||||||
|
dataStructure: payload.dataStructure,
|
||||||
|
data: payload.processedData.length ? payload.processedData : payload.rawData
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
|
||||||
|
const filename = `${this.getFileBase(options)}-${this.getTimestamp()}.json`;
|
||||||
|
return { blob, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPNG(payload, options = {}) {
|
||||||
|
if (!payload.chart || typeof payload.chart.toBase64Image !== 'function') {
|
||||||
|
throw new Error('No chart is available yet for PNG export. Render a chart first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = payload.chart.toBase64Image('image/png', 1);
|
||||||
|
const blob = await (await fetch(dataUrl)).blob();
|
||||||
|
const filename = `${this.getFileBase(options)}-${this.getTimestamp()}.png`;
|
||||||
|
return { blob, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
toCSV(rows) {
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(rows[0]);
|
||||||
|
const csvRows = [headers.join(',')];
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const line = headers
|
||||||
|
.map((header) => {
|
||||||
|
const value = row[header] ?? '';
|
||||||
|
const text = String(value);
|
||||||
|
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
})
|
||||||
|
.join(',');
|
||||||
|
csvRows.push(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
return csvRows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitProgress(percentage, message) {
|
||||||
|
if (typeof this.options.onExportProgress === 'function') {
|
||||||
|
this.options.onExportProgress({ percentage, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Exporter;
|
||||||
|
} else {
|
||||||
|
window.Exporter = Exporter;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
/**
|
||||||
|
* File Upload Module of Web Application
|
||||||
|
*
|
||||||
|
* Handles file input, drag-and-drop functionality, file validation,
|
||||||
|
* and Excel file parsing using SheetJS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FileUploadManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.dropZone = null;
|
||||||
|
this.fileInput = null;
|
||||||
|
this.selectFileBtn = null;
|
||||||
|
this.isProcessing = false;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.dropZone = document.getElementById('dropZone');
|
||||||
|
this.fileInput = document.getElementById('fileInput');
|
||||||
|
this.selectFileBtn = document.getElementById('selectFileBtn');
|
||||||
|
|
||||||
|
if (!this.dropZone || !this.fileInput) {
|
||||||
|
throw new Error('File upload elements not found in DOM');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
const openFileDialog = () => {
|
||||||
|
if (this.isProcessing) return;
|
||||||
|
// Reset so selecting the same file again still triggers `change`.
|
||||||
|
this.fileInput.value = '';
|
||||||
|
this.fileInput.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// File input change event
|
||||||
|
this.fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
this.handleFile(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop events
|
||||||
|
this.dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dropZone.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dropZone.addEventListener('dragleave', () => {
|
||||||
|
this.dropZone.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dropZone.classList.remove('drag-over');
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
this.handleFile(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dedicated select button (prevents duplicate bubbling click behavior)
|
||||||
|
if (this.selectFileBtn) {
|
||||||
|
this.selectFileBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openFileDialog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click anywhere on drop zone to upload (except button which handles itself)
|
||||||
|
this.dropZone.addEventListener('click', (e) => {
|
||||||
|
if (e.target && e.target.closest && e.target.closest('#selectFileBtn')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openFileDialog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFile(file) {
|
||||||
|
if (this.isProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isProcessing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate file
|
||||||
|
const validation = this.validateFile(file);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
this.showError(validation.error);
|
||||||
|
this.isProcessing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify file selected
|
||||||
|
if (this.options.onFileSelected) {
|
||||||
|
this.options.onFileSelected(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show progress
|
||||||
|
this.showProgress(0);
|
||||||
|
|
||||||
|
// Parse file
|
||||||
|
const data = await this.parseExcelFile(file);
|
||||||
|
|
||||||
|
// Notify file parsed
|
||||||
|
if (this.options.onFileParsed) {
|
||||||
|
this.options.onFileParsed(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showProgress(100);
|
||||||
|
this.hideProgress();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(error.message);
|
||||||
|
if (this.options.onError) {
|
||||||
|
this.options.onError(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.fileInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateFile(file) {
|
||||||
|
// Check file type (MIME + extension fallback)
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel'
|
||||||
|
];
|
||||||
|
const lowerName = (file.name || '').toLowerCase();
|
||||||
|
const hasValidExtension = lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls');
|
||||||
|
const hasValidMime = allowedTypes.includes(file.type);
|
||||||
|
|
||||||
|
if (!hasValidMime && !hasValidExtension) {
|
||||||
|
return { isValid: false, error: 'Invalid file type. Please upload an Excel file (.xlsx or .xls)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (max 50MB)
|
||||||
|
const maxSize = 50 * 1024 * 1024; // 50MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return { isValid: false, error: 'File too large. Maximum file size is 50MB' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseExcelFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(e.target.result);
|
||||||
|
const workbook = XLSX.read(data, { type: 'array' });
|
||||||
|
|
||||||
|
// Get first worksheet
|
||||||
|
const firstSheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[firstSheetName];
|
||||||
|
|
||||||
|
// Convert to JSON
|
||||||
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
|
||||||
|
header: 1,
|
||||||
|
defval: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array of objects
|
||||||
|
const result = this.convertToObjects(jsonData);
|
||||||
|
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error('Failed to parse Excel file: ' + error.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToObjects(jsonData) {
|
||||||
|
if (jsonData.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// First row contains headers
|
||||||
|
const headers = jsonData[0];
|
||||||
|
const rows = jsonData.slice(1);
|
||||||
|
|
||||||
|
// Clean headers (remove empty headers and create unique names)
|
||||||
|
const cleanHeaders = headers.map((header, index) => {
|
||||||
|
if (!header || header === '') {
|
||||||
|
return `Column_${index + 1}`;
|
||||||
|
}
|
||||||
|
return header.toString().trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create unique column names if duplicates exist
|
||||||
|
const uniqueHeaders = this.makeUniqueHeaders(cleanHeaders);
|
||||||
|
|
||||||
|
// Convert rows to objects
|
||||||
|
return rows.map(row => {
|
||||||
|
const obj = {};
|
||||||
|
uniqueHeaders.forEach((header, index) => {
|
||||||
|
obj[header] = row[index];
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
makeUniqueHeaders(headers) {
|
||||||
|
const seen = {};
|
||||||
|
return headers.map(header => {
|
||||||
|
if (seen[header]) {
|
||||||
|
const newHeader = `${header}_${seen[header]}`;
|
||||||
|
seen[header]++;
|
||||||
|
return newHeader;
|
||||||
|
} else {
|
||||||
|
seen[header] = 1;
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showProgress(percentage) {
|
||||||
|
const progressEl = document.getElementById('uploadProgress');
|
||||||
|
const progressBarEl = progressEl ? progressEl.querySelector('.progress-bar') : null;
|
||||||
|
|
||||||
|
if (progressEl) {
|
||||||
|
progressEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBarEl) {
|
||||||
|
progressBarEl.style.width = `${percentage}%`;
|
||||||
|
progressBarEl.textContent = `${percentage}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideProgress() {
|
||||||
|
const progressEl = document.getElementById('uploadProgress');
|
||||||
|
if (progressEl) {
|
||||||
|
progressEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const statusEl = document.getElementById('uploadStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<strong>Error:</strong> ${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError() {
|
||||||
|
const statusEl = document.getElementById('uploadStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = FileUploadManager;
|
||||||
|
} else {
|
||||||
|
window.FileUploadManager = FileUploadManager;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,773 @@
|
||||||
|
/**
|
||||||
|
* Notifications Module
|
||||||
|
*
|
||||||
|
* Handles user notifications, alerts, and feedback messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Notifications {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.notifications = [];
|
||||||
|
this.maxNotifications = 50;
|
||||||
|
this.container = null;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createContainer();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notifications container
|
||||||
|
*/
|
||||||
|
createContainer() {
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.className = 'notifications-container';
|
||||||
|
this.container.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 400px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// Auto-dismiss notifications after timeout
|
||||||
|
setInterval(() => {
|
||||||
|
this.notifications.forEach(notification => {
|
||||||
|
if (notification.autoDismiss && Date.now() - notification.timestamp > notification.timeout) {
|
||||||
|
this.removeNotification(notification.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show success notification
|
||||||
|
*/
|
||||||
|
success(message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'success',
|
||||||
|
message: message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error notification
|
||||||
|
*/
|
||||||
|
error(message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'error',
|
||||||
|
message: message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show warning notification
|
||||||
|
*/
|
||||||
|
warning(message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'warning',
|
||||||
|
message: message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show info notification
|
||||||
|
*/
|
||||||
|
info(message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'info',
|
||||||
|
message: message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show notification
|
||||||
|
*/
|
||||||
|
show(notificationData) {
|
||||||
|
const notification = {
|
||||||
|
id: this.generateId(),
|
||||||
|
type: notificationData.type || 'info',
|
||||||
|
message: notificationData.message || '',
|
||||||
|
title: notificationData.title || this.getNotificationTitle(notificationData.type),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
autoDismiss: notificationData.autoDismiss !== false,
|
||||||
|
timeout: notificationData.timeout || this.getTimeoutForType(notificationData.type),
|
||||||
|
persistent: notificationData.persistent || false,
|
||||||
|
actions: notificationData.actions || [],
|
||||||
|
onDismiss: notificationData.onDismiss || null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifications.push(notification);
|
||||||
|
this.renderNotification(notification);
|
||||||
|
|
||||||
|
// Limit notifications
|
||||||
|
if (this.notifications.length > this.maxNotifications) {
|
||||||
|
const oldest = this.notifications.shift();
|
||||||
|
this.removeNotification(oldest.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render notification element
|
||||||
|
*/
|
||||||
|
renderNotification(notification) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = `notification notification-${notification.type}`;
|
||||||
|
element.dataset.notificationId = notification.id;
|
||||||
|
element.style.cssText = `
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 15px;
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 400px;
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'notification-icon';
|
||||||
|
icon.innerHTML = this.getNotificationIcon(notification.type);
|
||||||
|
icon.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'notification-content';
|
||||||
|
content.style.cssText = `
|
||||||
|
margin-left: 35px;
|
||||||
|
min-height: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'notification-title';
|
||||||
|
title.textContent = notification.title;
|
||||||
|
title.style.cssText = `
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.className = 'notification-message';
|
||||||
|
message.textContent = notification.message;
|
||||||
|
message.style.cssText = `
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
content.appendChild(title);
|
||||||
|
content.appendChild(message);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
if (notification.actions.length > 0) {
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'notification-actions';
|
||||||
|
actions.style.cssText = `
|
||||||
|
margin-left: 35px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
notification.actions.forEach(action => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'btn btn-sm btn-outline-secondary';
|
||||||
|
button.textContent = action.label;
|
||||||
|
button.style.cssText = `
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (action.onClick) {
|
||||||
|
action.onClick(notification);
|
||||||
|
}
|
||||||
|
this.removeNotification(notification.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
content.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss button
|
||||||
|
const dismissBtn = document.createElement('button');
|
||||||
|
dismissBtn.className = 'notification-dismiss';
|
||||||
|
dismissBtn.innerHTML = '×';
|
||||||
|
dismissBtn.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
dismissBtn.addEventListener('mouseenter', () => {
|
||||||
|
dismissBtn.style.backgroundColor = 'var(--bg-secondary)';
|
||||||
|
});
|
||||||
|
|
||||||
|
dismissBtn.addEventListener('mouseleave', () => {
|
||||||
|
dismissBtn.style.backgroundColor = 'transparent';
|
||||||
|
});
|
||||||
|
|
||||||
|
dismissBtn.addEventListener('click', () => {
|
||||||
|
this.removeNotification(notification.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
element.appendChild(icon);
|
||||||
|
element.appendChild(content);
|
||||||
|
element.appendChild(dismissBtn);
|
||||||
|
|
||||||
|
this.container.appendChild(element);
|
||||||
|
|
||||||
|
// Trigger animation
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.animation = 'none';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove notification
|
||||||
|
*/
|
||||||
|
removeNotification(id) {
|
||||||
|
const index = this.notifications.findIndex(n => n.id === id);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const notification = this.notifications[index];
|
||||||
|
const element = this.container.querySelector(`[data-notification-id="${id}"]`);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.style.animation = 'slideOutRight 0.3s ease-out';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (element.parentNode) {
|
||||||
|
element.parentNode.removeChild(element);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifications.splice(index, 1);
|
||||||
|
|
||||||
|
// Call dismiss callback
|
||||||
|
if (notification.onDismiss) {
|
||||||
|
notification.onDismiss(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all notifications
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.notifications.forEach(notification => {
|
||||||
|
this.removeNotification(notification.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification title
|
||||||
|
*/
|
||||||
|
getNotificationTitle(type) {
|
||||||
|
const titles = {
|
||||||
|
success: 'Success',
|
||||||
|
error: 'Error',
|
||||||
|
warning: 'Warning',
|
||||||
|
info: 'Information'
|
||||||
|
};
|
||||||
|
return titles[type] || 'Notification';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification icon
|
||||||
|
*/
|
||||||
|
getNotificationIcon(type) {
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✗',
|
||||||
|
warning: '⚠',
|
||||||
|
info: 'ℹ'
|
||||||
|
};
|
||||||
|
return icons[type] || 'ℹ';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timeout for notification type
|
||||||
|
*/
|
||||||
|
getTimeoutForType(type) {
|
||||||
|
const timeouts = {
|
||||||
|
success: 3000,
|
||||||
|
error: 5000,
|
||||||
|
warning: 4000,
|
||||||
|
info: 3000
|
||||||
|
};
|
||||||
|
return timeouts[type] || 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique ID
|
||||||
|
*/
|
||||||
|
generateId() {
|
||||||
|
return 'notification-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show loading notification
|
||||||
|
*/
|
||||||
|
loading(message, options = {}) {
|
||||||
|
const notification = {
|
||||||
|
id: this.generateId(),
|
||||||
|
type: 'loading',
|
||||||
|
message: message,
|
||||||
|
title: 'Processing',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
autoDismiss: false,
|
||||||
|
persistent: true,
|
||||||
|
actions: [],
|
||||||
|
onDismiss: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifications.push(notification);
|
||||||
|
this.renderLoadingNotification(notification);
|
||||||
|
|
||||||
|
return notification.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render loading notification
|
||||||
|
*/
|
||||||
|
renderLoadingNotification(notification) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = `notification notification-loading`;
|
||||||
|
element.dataset.notificationId = notification.id;
|
||||||
|
element.style.cssText = `
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 15px;
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 400px;
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Spinner
|
||||||
|
const spinner = document.createElement('div');
|
||||||
|
spinner.className = 'notification-spinner';
|
||||||
|
spinner.style.cssText = `
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top: 2px solid var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'notification-content';
|
||||||
|
content.style.cssText = `
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
content.textContent = notification.message;
|
||||||
|
|
||||||
|
element.appendChild(spinner);
|
||||||
|
element.appendChild(content);
|
||||||
|
|
||||||
|
this.container.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update loading notification
|
||||||
|
*/
|
||||||
|
updateLoading(id, message) {
|
||||||
|
const element = this.container.querySelector(`[data-notification-id="${id}"]`);
|
||||||
|
if (element) {
|
||||||
|
const content = element.querySelector('.notification-content');
|
||||||
|
if (content) {
|
||||||
|
content.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete loading notification
|
||||||
|
*/
|
||||||
|
completeLoading(id, success = true, message = null) {
|
||||||
|
const element = this.container.querySelector(`[data-notification-id="${id}"]`);
|
||||||
|
if (element) {
|
||||||
|
const type = success ? 'success' : 'error';
|
||||||
|
const title = success ? 'Completed' : 'Failed';
|
||||||
|
const icon = success ? '✓' : '✗';
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
const content = element.querySelector('.notification-content');
|
||||||
|
if (content) {
|
||||||
|
content.textContent = message || (success ? 'Operation completed successfully' : 'Operation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update styles
|
||||||
|
element.className = `notification notification-${type}`;
|
||||||
|
element.style.borderColor = getComputedStyle(document.documentElement).getPropertyValue(`--${type}-color`);
|
||||||
|
|
||||||
|
// Update spinner to icon
|
||||||
|
const spinner = element.querySelector('.notification-spinner');
|
||||||
|
if (spinner) {
|
||||||
|
spinner.outerHTML = `<div class="notification-icon" style="position: relative; font-size: 18px; color: var(--${type}-color)">${icon}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.removeNotification(id);
|
||||||
|
}, success ? 2000 : 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show confirmation dialog
|
||||||
|
*/
|
||||||
|
confirm(message, options = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const notification = {
|
||||||
|
id: this.generateId(),
|
||||||
|
type: 'confirm',
|
||||||
|
message: message,
|
||||||
|
title: options.title || 'Confirm Action',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
autoDismiss: false,
|
||||||
|
persistent: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: options.cancelLabel || 'Cancel',
|
||||||
|
onClick: () => resolve(false)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: options.confirmLabel || 'Confirm',
|
||||||
|
onClick: () => resolve(true)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onDismiss: () => resolve(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifications.push(notification);
|
||||||
|
this.renderNotification(notification);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show prompt dialog
|
||||||
|
*/
|
||||||
|
prompt(message, options = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const notification = {
|
||||||
|
id: this.generateId(),
|
||||||
|
type: 'prompt',
|
||||||
|
message: message,
|
||||||
|
title: options.title || 'Input Required',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
autoDismiss: false,
|
||||||
|
persistent: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: options.cancelLabel || 'Cancel',
|
||||||
|
onClick: () => resolve(null)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: options.confirmLabel || 'Submit',
|
||||||
|
onClick: (notification) => {
|
||||||
|
const input = document.querySelector(`[data-notification-id="${notification.id}"] input`);
|
||||||
|
resolve(input ? input.value : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onDismiss: () => resolve(null)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifications.push(notification);
|
||||||
|
this.renderPromptNotification(notification);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render prompt notification
|
||||||
|
*/
|
||||||
|
renderPromptNotification(notification) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = `notification notification-prompt`;
|
||||||
|
element.dataset.notificationId = notification.id;
|
||||||
|
element.style.cssText = `
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 15px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 400px;
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'notification-title';
|
||||||
|
title.textContent = notification.title;
|
||||||
|
title.style.cssText = `
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Message
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.className = 'notification-message';
|
||||||
|
message.textContent = notification.message;
|
||||||
|
message.style.cssText = `
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Input
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.placeholder = 'Enter your response...';
|
||||||
|
input.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'notification-actions';
|
||||||
|
actions.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`;
|
||||||
|
|
||||||
|
notification.actions.forEach(action => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'btn btn-sm';
|
||||||
|
button.textContent = action.label;
|
||||||
|
button.style.cssText = `
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: ${action.label.toLowerCase().includes('cancel') ? 'transparent' : 'var(--primary-color)'};
|
||||||
|
color: ${action.label.toLowerCase().includes('cancel') ? 'var(--text-secondary)' : 'white'};
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (action.onClick) {
|
||||||
|
action.onClick(notification);
|
||||||
|
}
|
||||||
|
this.removeNotification(notification.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
element.appendChild(title);
|
||||||
|
element.appendChild(message);
|
||||||
|
element.appendChild(input);
|
||||||
|
element.appendChild(actions);
|
||||||
|
|
||||||
|
this.container.appendChild(element);
|
||||||
|
|
||||||
|
// Focus input
|
||||||
|
setTimeout(() => input.focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification statistics
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
const stats = {
|
||||||
|
total: this.notifications.length,
|
||||||
|
byType: {},
|
||||||
|
oldest: null,
|
||||||
|
newest: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifications.forEach(notification => {
|
||||||
|
stats.byType[notification.type] = (stats.byType[notification.type] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.notifications.length > 0) {
|
||||||
|
stats.oldest = this.notifications[0];
|
||||||
|
stats.newest = this.notifications[this.notifications.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export notifications
|
||||||
|
*/
|
||||||
|
exportNotifications() {
|
||||||
|
return {
|
||||||
|
notifications: this.notifications,
|
||||||
|
stats: this.getStats(),
|
||||||
|
exportedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSS animations
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-container {
|
||||||
|
--primary-color: #007bff;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--success-color: #28a745;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--info-color: #17a2b8;
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8f9fa;
|
||||||
|
--text-primary: #212529;
|
||||||
|
--text-secondary: #6c757d;
|
||||||
|
--text-muted: #6c757d;
|
||||||
|
--border-color: #dee2e6;
|
||||||
|
--shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
--border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.notification-success {
|
||||||
|
border-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.notification-error {
|
||||||
|
border-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.notification-warning {
|
||||||
|
border-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.notification-info {
|
||||||
|
border-color: var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.notification-loading {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.notification-confirm,
|
||||||
|
.notification.notification-prompt {
|
||||||
|
border-color: var(--info-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Notifications;
|
||||||
|
} else {
|
||||||
|
window.Notifications = Notifications;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,971 @@
|
||||||
|
/**
|
||||||
|
* Settings Module
|
||||||
|
*
|
||||||
|
* Manages application settings, user preferences, and configuration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Settings {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.settings = {};
|
||||||
|
this.defaultSettings = {
|
||||||
|
// File Upload Settings
|
||||||
|
fileUpload: {
|
||||||
|
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||||
|
enableDragDrop: true,
|
||||||
|
enableMultiple: false,
|
||||||
|
previewRows: 100
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data Processing Settings
|
||||||
|
dataProcessing: {
|
||||||
|
defaultMissingValueHandling: 'remove',
|
||||||
|
defaultOutlierHandling: 'remove',
|
||||||
|
outlierThreshold: 3,
|
||||||
|
autoDetectDataTypes: true,
|
||||||
|
enableDataValidation: true,
|
||||||
|
maxPreviewRows: 100,
|
||||||
|
enableCaching: true,
|
||||||
|
cacheTimeout: 300000 // 5 minutes
|
||||||
|
},
|
||||||
|
|
||||||
|
// Visualization Settings
|
||||||
|
visualization: {
|
||||||
|
defaultChartType: 'auto',
|
||||||
|
chartColors: [
|
||||||
|
'#007bff', '#28a745', '#dc3545', '#ffc107', '#17a2b8',
|
||||||
|
'#6f42c1', '#20c997', '#fd7e14', '#e83e8c', '#6c757d'
|
||||||
|
],
|
||||||
|
chartAnimation: true,
|
||||||
|
chartResponsive: true,
|
||||||
|
chartLegendPosition: 'top',
|
||||||
|
chartTooltip: true,
|
||||||
|
chartHeight: 400,
|
||||||
|
chartWidth: 600,
|
||||||
|
enableZoom: true,
|
||||||
|
enablePan: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard Settings
|
||||||
|
dashboard: {
|
||||||
|
defaultLayout: 'grid',
|
||||||
|
maxWidgets: 10,
|
||||||
|
enableWidgetResize: true,
|
||||||
|
enableWidgetDrag: true,
|
||||||
|
autoSaveDashboard: true,
|
||||||
|
defaultWidgetSize: {
|
||||||
|
width: 4,
|
||||||
|
height: 3
|
||||||
|
},
|
||||||
|
gridColumns: 12,
|
||||||
|
gridRows: 12,
|
||||||
|
widgetMargin: 10
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export Settings
|
||||||
|
export: {
|
||||||
|
defaultFormat: 'pdf',
|
||||||
|
includeChartsInExport: true,
|
||||||
|
includeDataInExport: true,
|
||||||
|
includeSummaryInExport: true,
|
||||||
|
exportImageQuality: 'high',
|
||||||
|
pdfPageSize: 'A4',
|
||||||
|
pdfOrientation: 'portrait',
|
||||||
|
excelSheetName: 'Processed Data',
|
||||||
|
csvDelimiter: ',',
|
||||||
|
jsonIndent: 2
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI Settings
|
||||||
|
ui: {
|
||||||
|
theme: 'light',
|
||||||
|
language: 'en',
|
||||||
|
enableTooltips: true,
|
||||||
|
enableAnimations: true,
|
||||||
|
fontSize: 'medium',
|
||||||
|
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
boxShadow: '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)',
|
||||||
|
transitionDuration: '0.15s'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Performance Settings
|
||||||
|
performance: {
|
||||||
|
enableVirtualization: true,
|
||||||
|
maxPreviewRows: 100,
|
||||||
|
enableCaching: true,
|
||||||
|
cacheTimeout: 300000, // 5 minutes
|
||||||
|
debounceDelay: 300,
|
||||||
|
throttleDelay: 1000,
|
||||||
|
maxConcurrentOperations: 3
|
||||||
|
},
|
||||||
|
|
||||||
|
// Privacy Settings
|
||||||
|
privacy: {
|
||||||
|
enableAnalytics: false,
|
||||||
|
saveUserPreferences: true,
|
||||||
|
clearDataOnExit: false,
|
||||||
|
storeFileData: false,
|
||||||
|
storeProcessingHistory: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadSettings();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load settings from localStorage
|
||||||
|
*/
|
||||||
|
loadSettings() {
|
||||||
|
try {
|
||||||
|
const savedSettings = localStorage.getItem('excel-analyzer-settings');
|
||||||
|
if (savedSettings) {
|
||||||
|
const parsed = JSON.parse(savedSettings);
|
||||||
|
this.settings = this.mergeSettings(this.defaultSettings, parsed);
|
||||||
|
} else {
|
||||||
|
this.settings = { ...this.defaultSettings };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load settings, using defaults:', error);
|
||||||
|
this.settings = { ...this.defaultSettings };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save settings to localStorage
|
||||||
|
*/
|
||||||
|
saveSettings() {
|
||||||
|
if (this.settings.privacy.saveUserPreferences) {
|
||||||
|
localStorage.setItem('excel-analyzer-settings', JSON.stringify(this.settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get setting value
|
||||||
|
*/
|
||||||
|
get(path, defaultValue = null) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = this.settings;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
current = current[key];
|
||||||
|
} else {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set setting value
|
||||||
|
*/
|
||||||
|
set(path, value) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = this.settings;
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
if (!(key in current)) {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = value;
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
this.saveSettings();
|
||||||
|
|
||||||
|
// Notify change
|
||||||
|
this.notifyChange(path, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset settings to defaults
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.settings = { ...this.defaultSettings };
|
||||||
|
this.saveSettings();
|
||||||
|
|
||||||
|
// Notify reset
|
||||||
|
this.notifyReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all settings
|
||||||
|
*/
|
||||||
|
getAll() {
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings section
|
||||||
|
*/
|
||||||
|
getSection(section) {
|
||||||
|
return this.settings[section] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings section
|
||||||
|
*/
|
||||||
|
updateSection(section, values) {
|
||||||
|
if (!this.settings[section]) {
|
||||||
|
this.settings[section] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings[section] = { ...this.settings[section], ...values };
|
||||||
|
this.saveSettings();
|
||||||
|
|
||||||
|
// Notify change
|
||||||
|
this.notifyChange(section, this.settings[section]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge settings with defaults
|
||||||
|
*/
|
||||||
|
mergeSettings(defaults, userSettings) {
|
||||||
|
const result = { ...defaults };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(userSettings)) {
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
|
result[key] = this.mergeSettings(result[key] || {}, value);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// Listen for theme changes
|
||||||
|
window.addEventListener('theme:changed', (event) => {
|
||||||
|
this.set('ui.theme', event.detail.theme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify setting change
|
||||||
|
*/
|
||||||
|
notifyChange(path, value) {
|
||||||
|
const event = new CustomEvent('settings:changed', {
|
||||||
|
detail: {
|
||||||
|
path: path,
|
||||||
|
value: value,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Call callback if provided
|
||||||
|
if (this.options.onSettingChanged) {
|
||||||
|
this.options.onSettingChanged(path, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify settings reset
|
||||||
|
*/
|
||||||
|
notifyReset() {
|
||||||
|
const event = new CustomEvent('settings:reset', {
|
||||||
|
detail: {
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Call callback if provided
|
||||||
|
if (this.options.onSettingsReset) {
|
||||||
|
this.options.onSettingsReset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create settings UI
|
||||||
|
*/
|
||||||
|
createSettingsUI(container) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const settingsHTML = `
|
||||||
|
<div class="settings-container">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h4>Application Settings</h4>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="resetSettingsBtn">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Reset to Defaults
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="saveSettingsBtn">
|
||||||
|
<i class="bi bi-check"></i> Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-tabs">
|
||||||
|
<ul class="nav nav-tabs" id="settingsTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="general-tab" data-bs-toggle="tab" data-bs-target="#general" type="button">General</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="upload-tab" data-bs-toggle="tab" data-bs-target="#upload" type="button">Upload</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="processing-tab" data-bs-toggle="tab" data-bs-target="#processing" type="button">Processing</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="visualization-tab" data-bs-toggle="tab" data-bs-target="#visualization" type="button">Visualization</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button">Export</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="privacy-tab" data-bs-toggle="tab" data-bs-target="#privacy" type="button">Privacy</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="settingsTabContent">
|
||||||
|
${this.generateGeneralTab()}
|
||||||
|
${this.generateUploadTab()}
|
||||||
|
${this.generateProcessingTab()}
|
||||||
|
${this.generateVisualizationTab()}
|
||||||
|
${this.generateExportTab()}
|
||||||
|
${this.generatePrivacyTab()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = settingsHTML;
|
||||||
|
|
||||||
|
// Setup tab interactions
|
||||||
|
this.setupSettingsTabs();
|
||||||
|
|
||||||
|
// Setup form interactions
|
||||||
|
this.setupSettingsForms();
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate general settings tab
|
||||||
|
*/
|
||||||
|
generateGeneralTab() {
|
||||||
|
const theme = this.get('ui.theme');
|
||||||
|
const language = this.get('ui.language');
|
||||||
|
const fontSize = this.get('ui.fontSize');
|
||||||
|
const enableTooltips = this.get('ui.enableTooltips');
|
||||||
|
const enableAnimations = this.get('ui.enableAnimations');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tab-pane fade show active" id="general" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Theme</label>
|
||||||
|
<select class="form-select" id="themeSelect">
|
||||||
|
<option value="light" ${theme === 'light' ? 'selected' : ''}>Light</option>
|
||||||
|
<option value="dark" ${theme === 'dark' ? 'selected' : ''}>Dark</option>
|
||||||
|
<option value="blue" ${theme === 'blue' ? 'selected' : ''}>Blue</option>
|
||||||
|
<option value="green" ${theme === 'green' ? 'selected' : ''}>Green</option>
|
||||||
|
<option value="purple" ${theme === 'purple' ? 'selected' : ''}>Purple</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Language</label>
|
||||||
|
<select class="form-select" id="languageSelect">
|
||||||
|
<option value="en" ${language === 'en' ? 'selected' : ''}>English</option>
|
||||||
|
<option value="es" ${language === 'es' ? 'selected' : ''}>Español</option>
|
||||||
|
<option value="fr" ${language === 'fr' ? 'selected' : ''}>Français</option>
|
||||||
|
<option value="de" ${language === 'de' ? 'selected' : ''}>Deutsch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Font Size</label>
|
||||||
|
<select class="form-select" id="fontSizeSelect">
|
||||||
|
<option value="small" ${fontSize === 'small' ? 'selected' : ''}>Small</option>
|
||||||
|
<option value="medium" ${fontSize === 'medium' ? 'selected' : ''}>Medium</option>
|
||||||
|
<option value="large" ${fontSize === 'large' ? 'selected' : ''}>Large</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableTooltips" ${enableTooltips ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableTooltips">
|
||||||
|
Enable Tooltips
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableAnimations" ${enableAnimations ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableAnimations">
|
||||||
|
Enable Animations
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="saveUserPreferences" ${this.get('privacy.saveUserPreferences') ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="saveUserPreferences">
|
||||||
|
Save User Preferences
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="clearDataOnExit" ${this.get('privacy.clearDataOnExit') ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="clearDataOnExit">
|
||||||
|
Clear Data on Exit
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate upload settings tab
|
||||||
|
*/
|
||||||
|
generateUploadTab() {
|
||||||
|
const maxFileSize = this.get('fileUpload.maxFileSize');
|
||||||
|
const enableDragDrop = this.get('fileUpload.enableDragDrop');
|
||||||
|
const enableMultiple = this.get('fileUpload.enableMultiple');
|
||||||
|
const previewRows = this.get('fileUpload.previewRows');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tab-pane fade" id="upload" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Max File Size (MB)</label>
|
||||||
|
<input type="number" class="form-control" id="maxFileSize" value="${maxFileSize / (1024 * 1024)}" min="1" max="100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Preview Rows</label>
|
||||||
|
<input type="number" class="form-control" id="previewRows" value="${previewRows}" min="10" max="1000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableDragDrop" ${enableDragDrop ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableDragDrop">
|
||||||
|
Enable Drag & Drop
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableMultiple" ${enableMultiple ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableMultiple">
|
||||||
|
Enable Multiple Files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate processing settings tab
|
||||||
|
*/
|
||||||
|
generateProcessingTab() {
|
||||||
|
const autoDetectDataTypes = this.get('dataProcessing.autoDetectDataTypes');
|
||||||
|
const enableDataValidation = this.get('dataProcessing.enableDataValidation');
|
||||||
|
const enableCaching = this.get('dataProcessing.enableCaching');
|
||||||
|
const maxPreviewRows = this.get('dataProcessing.maxPreviewRows');
|
||||||
|
const outlierThreshold = this.get('dataProcessing.outlierThreshold');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tab-pane fade" id="processing" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Outlier Threshold</label>
|
||||||
|
<input type="number" class="form-control" id="outlierThreshold" value="${outlierThreshold}" min="1" max="10">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Max Preview Rows</label>
|
||||||
|
<input type="number" class="form-control" id="maxPreviewRows" value="${maxPreviewRows}" min="10" max="1000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="autoDetectDataTypes" ${autoDetectDataTypes ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="autoDetectDataTypes">
|
||||||
|
Auto-detect Data Types
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableDataValidation" ${enableDataValidation ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableDataValidation">
|
||||||
|
Enable Data Validation
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableCaching" ${enableCaching ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableCaching">
|
||||||
|
Enable Caching
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate visualization settings tab
|
||||||
|
*/
|
||||||
|
generateVisualizationTab() {
|
||||||
|
const defaultChartType = this.get('visualization.defaultChartType');
|
||||||
|
const chartAnimation = this.get('visualization.chartAnimation');
|
||||||
|
const chartResponsive = this.get('visualization.chartResponsive');
|
||||||
|
const chartTooltip = this.get('visualization.chartTooltip');
|
||||||
|
const enableZoom = this.get('visualization.enableZoom');
|
||||||
|
const enablePan = this.get('visualization.enablePan');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tab-pane fade" id="visualization" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Default Chart Type</label>
|
||||||
|
<select class="form-select" id="defaultChartType">
|
||||||
|
<option value="auto" ${defaultChartType === 'auto' ? 'selected' : ''}>Auto</option>
|
||||||
|
<option value="bar" ${defaultChartType === 'bar' ? 'selected' : ''}>Bar</option>
|
||||||
|
<option value="line" ${defaultChartType === 'line' ? 'selected' : ''}>Line</option>
|
||||||
|
<option value="pie" ${defaultChartType === 'pie' ? 'selected' : ''}>Pie</option>
|
||||||
|
<option value="scatter" ${defaultChartType === 'scatter' ? 'selected' : ''}>Scatter</option>
|
||||||
|
<option value="doughnut" ${defaultChartType === 'doughnut' ? 'selected' : ''}>Doughnut</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Chart Height</label>
|
||||||
|
<input type="number" class="form-control" id="chartHeight" value="${this.get('visualization.chartHeight')}" min="200" max="800">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Chart Width</label>
|
||||||
|
<input type="number" class="form-control" id="chartWidth" value="${this.get('visualization.chartWidth')}" min="400" max="1200">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="chartAnimation" ${chartAnimation ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="chartAnimation">
|
||||||
|
Enable Chart Animation
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="chartResponsive" ${chartResponsive ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="chartResponsive">
|
||||||
|
Enable Responsive Charts
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="chartTooltip" ${chartTooltip ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="chartTooltip">
|
||||||
|
Enable Chart Tooltips
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableZoom" ${enableZoom ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableZoom">
|
||||||
|
Enable Chart Zoom
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enablePan" ${enablePan ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enablePan">
|
||||||
|
Enable Chart Pan
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate export settings tab
|
||||||
|
*/
|
||||||
|
generateExportTab() {
|
||||||
|
const defaultFormat = this.get('export.defaultFormat');
|
||||||
|
const includeChartsInExport = this.get('export.includeChartsInExport');
|
||||||
|
const includeDataInExport = this.get('export.includeDataInExport');
|
||||||
|
const includeSummaryInExport = this.get('export.includeSummaryInExport');
|
||||||
|
const exportImageQuality = this.get('export.exportImageQuality');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tab-pane fade" id="export" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Default Export Format</label>
|
||||||
|
<select class="form-select" id="defaultExportFormat">
|
||||||
|
<option value="pdf" ${defaultFormat === 'pdf' ? 'selected' : ''}>PDF</option>
|
||||||
|
<option value="excel" ${defaultFormat === 'excel' ? 'selected' : ''}>Excel</option>
|
||||||
|
<option value="csv" ${defaultFormat === 'csv' ? 'selected' : ''}>CSV</option>
|
||||||
|
<option value="json" ${defaultFormat === 'json' ? 'selected' : ''}>JSON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Export Image Quality</label>
|
||||||
|
<select class="form-select" id="exportImageQuality">
|
||||||
|
<option value="low" ${exportImageQuality === 'low' ? 'selected' : ''}>Low</option>
|
||||||
|
<option value="medium" ${exportImageQuality === 'medium' ? 'selected' : ''}>Medium</option>
|
||||||
|
<option value="high" ${exportImageQuality === 'high' ? 'selected' : ''}>High</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="includeChartsInExport" ${includeChartsInExport ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="includeChartsInExport">
|
||||||
|
Include Charts in Export
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="includeDataInExport" ${includeDataInExport ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="includeDataInExport">
|
||||||
|
Include Data in Export
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="includeSummaryInExport" ${includeSummaryInExport ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="includeSummaryInExport">
|
||||||
|
Include Summary in Export
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate privacy settings tab
|
||||||
|
*/
|
||||||
|
generatePrivacyTab() {
|
||||||
|
const enableAnalytics = this.get('privacy.enableAnalytics');
|
||||||
|
const saveUserPreferences = this.get('privacy.saveUserPreferences');
|
||||||
|
const clearDataOnExit = this.get('privacy.clearDataOnExit');
|
||||||
|
const storeFileData = this.get('privacy.storeFileData');
|
||||||
|
const storeProcessingHistory = this.get('privacy.storeProcessingHistory');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tab-pane fade" id="privacy" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enableAnalytics" ${enableAnalytics ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="enableAnalytics">
|
||||||
|
Enable Analytics
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="saveUserPreferences" ${saveUserPreferences ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="saveUserPreferences">
|
||||||
|
Save User Preferences
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="clearDataOnExit" ${clearDataOnExit ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="clearDataOnExit">
|
||||||
|
Clear Data on Exit
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="storeFileData" ${storeFileData ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="storeFileData">
|
||||||
|
Store File Data
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="storeProcessingHistory" ${storeProcessingHistory ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="storeProcessingHistory">
|
||||||
|
Store Processing History
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h6>Privacy Notice</h6>
|
||||||
|
<p>These settings control how your data is stored and processed. Disabling certain features may affect application functionality.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup settings tabs
|
||||||
|
*/
|
||||||
|
setupSettingsTabs() {
|
||||||
|
const tabs = document.querySelectorAll('.nav-link');
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('shown.bs.tab', (e) => {
|
||||||
|
// Handle tab-specific logic if needed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup settings forms
|
||||||
|
*/
|
||||||
|
setupSettingsForms() {
|
||||||
|
// General settings
|
||||||
|
const themeSelect = document.getElementById('themeSelect');
|
||||||
|
if (themeSelect) {
|
||||||
|
themeSelect.addEventListener('change', (e) => {
|
||||||
|
this.set('ui.theme', e.target.value);
|
||||||
|
if (this.options.themeManager) {
|
||||||
|
this.options.themeManager.setTheme(e.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageSelect = document.getElementById('languageSelect');
|
||||||
|
if (languageSelect) {
|
||||||
|
languageSelect.addEventListener('change', (e) => {
|
||||||
|
this.set('ui.language', e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontSizeSelect = document.getElementById('fontSizeSelect');
|
||||||
|
if (fontSizeSelect) {
|
||||||
|
fontSizeSelect.addEventListener('change', (e) => {
|
||||||
|
this.set('ui.fontSize', e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean settings
|
||||||
|
const booleanSettings = [
|
||||||
|
{ id: 'enableTooltips', path: 'ui.enableTooltips' },
|
||||||
|
{ id: 'enableAnimations', path: 'ui.enableAnimations' },
|
||||||
|
{ id: 'enableDragDrop', path: 'fileUpload.enableDragDrop' },
|
||||||
|
{ id: 'enableMultiple', path: 'fileUpload.enableMultiple' },
|
||||||
|
{ id: 'autoDetectDataTypes', path: 'dataProcessing.autoDetectDataTypes' },
|
||||||
|
{ id: 'enableDataValidation', path: 'dataProcessing.enableDataValidation' },
|
||||||
|
{ id: 'enableCaching', path: 'dataProcessing.enableCaching' },
|
||||||
|
{ id: 'chartAnimation', path: 'visualization.chartAnimation' },
|
||||||
|
{ id: 'chartResponsive', path: 'visualization.chartResponsive' },
|
||||||
|
{ id: 'chartTooltip', path: 'visualization.chartTooltip' },
|
||||||
|
{ id: 'enableZoom', path: 'visualization.enableZoom' },
|
||||||
|
{ id: 'enablePan', path: 'visualization.enablePan' },
|
||||||
|
{ id: 'includeChartsInExport', path: 'export.includeChartsInExport' },
|
||||||
|
{ id: 'includeDataInExport', path: 'export.includeDataInExport' },
|
||||||
|
{ id: 'includeSummaryInExport', path: 'export.includeSummaryInExport' },
|
||||||
|
{ id: 'enableAnalytics', path: 'privacy.enableAnalytics' },
|
||||||
|
{ id: 'saveUserPreferences', path: 'privacy.saveUserPreferences' },
|
||||||
|
{ id: 'clearDataOnExit', path: 'privacy.clearDataOnExit' },
|
||||||
|
{ id: 'storeFileData', path: 'privacy.storeFileData' },
|
||||||
|
{ id: 'storeProcessingHistory', path: 'privacy.storeProcessingHistory' }
|
||||||
|
];
|
||||||
|
|
||||||
|
booleanSettings.forEach(setting => {
|
||||||
|
const element = document.getElementById(setting.id);
|
||||||
|
if (element) {
|
||||||
|
element.addEventListener('change', (e) => {
|
||||||
|
this.set(setting.path, e.target.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Number settings
|
||||||
|
const numberSettings = [
|
||||||
|
{ id: 'maxFileSize', path: 'fileUpload.maxFileSize', multiplier: 1024 * 1024 },
|
||||||
|
{ id: 'previewRows', path: 'fileUpload.previewRows' },
|
||||||
|
{ id: 'outlierThreshold', path: 'dataProcessing.outlierThreshold' },
|
||||||
|
{ id: 'maxPreviewRows', path: 'dataProcessing.maxPreviewRows' },
|
||||||
|
{ id: 'chartHeight', path: 'visualization.chartHeight' },
|
||||||
|
{ id: 'chartWidth', path: 'visualization.chartWidth' }
|
||||||
|
];
|
||||||
|
|
||||||
|
numberSettings.forEach(setting => {
|
||||||
|
const element = document.getElementById(setting.id);
|
||||||
|
if (element) {
|
||||||
|
element.addEventListener('change', (e) => {
|
||||||
|
let value = parseInt(e.target.value);
|
||||||
|
if (setting.multiplier) {
|
||||||
|
value = value * setting.multiplier;
|
||||||
|
}
|
||||||
|
this.set(setting.path, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select settings
|
||||||
|
const selectSettings = [
|
||||||
|
{ id: 'defaultChartType', path: 'visualization.defaultChartType' },
|
||||||
|
{ id: 'defaultExportFormat', path: 'export.defaultFormat' },
|
||||||
|
{ id: 'exportImageQuality', path: 'export.exportImageQuality' }
|
||||||
|
];
|
||||||
|
|
||||||
|
selectSettings.forEach(setting => {
|
||||||
|
const element = document.getElementById(setting.id);
|
||||||
|
if (element) {
|
||||||
|
element.addEventListener('change', (e) => {
|
||||||
|
this.set(setting.path, e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset and save buttons
|
||||||
|
const resetBtn = document.getElementById('resetSettingsBtn');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
if (confirm('Are you sure you want to reset all settings to defaults?')) {
|
||||||
|
this.reset();
|
||||||
|
this.updateSettingsUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('saveSettingsBtn');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
this.saveSettings();
|
||||||
|
alert('Settings saved successfully!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings UI with current values
|
||||||
|
*/
|
||||||
|
updateSettingsUI() {
|
||||||
|
// This would update all form elements with current setting values
|
||||||
|
// Implementation would depend on the specific UI structure
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export settings
|
||||||
|
*/
|
||||||
|
exportSettings() {
|
||||||
|
const settingsData = {
|
||||||
|
settings: this.getAll(),
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(settingsData, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import settings
|
||||||
|
*/
|
||||||
|
importSettings(settingsData) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof settingsData === 'string' ? JSON.parse(settingsData) : settingsData;
|
||||||
|
if (parsed.settings) {
|
||||||
|
this.settings = this.mergeSettings(this.defaultSettings, parsed.settings);
|
||||||
|
this.saveSettings();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import settings:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings statistics
|
||||||
|
*/
|
||||||
|
getSettingsStats() {
|
||||||
|
return {
|
||||||
|
totalSettings: Object.keys(this.settings).length,
|
||||||
|
sections: Object.keys(this.settings),
|
||||||
|
lastModified: localStorage.getItem('excel-analyzer-settings') ?
|
||||||
|
new Date(JSON.parse(localStorage.getItem('excel-analyzer-settings')).timestamp || Date.now()).toISOString() :
|
||||||
|
'Never'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Settings;
|
||||||
|
} else {
|
||||||
|
window.Settings = Settings;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,591 @@
|
||||||
|
/**
|
||||||
|
* Visualization Module
|
||||||
|
*
|
||||||
|
* Handles chart rendering, interactive visualizations, and chart management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Visualizer {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.charts = new Map();
|
||||||
|
this.currentChart = null;
|
||||||
|
this.chartContainer = null;
|
||||||
|
this.chartCanvas = null;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.chartContainer = document.getElementById('analysisChart');
|
||||||
|
if (!this.chartContainer) {
|
||||||
|
throw new Error('Chart container not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupChartContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupChartContainer() {
|
||||||
|
// Ensure canvas is properly sized
|
||||||
|
this.chartContainer.style.width = '100%';
|
||||||
|
this.chartContainer.style.height = '400px';
|
||||||
|
|
||||||
|
// Setup responsive behavior
|
||||||
|
this.setupResponsiveChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render chart
|
||||||
|
*/
|
||||||
|
async renderChart(data, config = {}) {
|
||||||
|
try {
|
||||||
|
// Determine chart type
|
||||||
|
const chartType = this.selectChartType(data, config);
|
||||||
|
|
||||||
|
// Prepare chart data
|
||||||
|
const chartData = this.prepareChartData(data, chartType, config);
|
||||||
|
|
||||||
|
// Prepare chart options
|
||||||
|
const chartOptions = this.prepareChartOptions(chartType, config);
|
||||||
|
|
||||||
|
// Create chart
|
||||||
|
const chart = await this.createChart(chartType, chartData, chartOptions);
|
||||||
|
|
||||||
|
// Store chart
|
||||||
|
this.charts.set(chart.id, chart);
|
||||||
|
this.currentChart = chart;
|
||||||
|
|
||||||
|
// Notify completion
|
||||||
|
if (this.options.onChartRendered) {
|
||||||
|
this.options.onChartRendered(chart);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chart;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chart rendering failed:', error);
|
||||||
|
if (this.options.onError) {
|
||||||
|
this.options.onError(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing chart
|
||||||
|
*/
|
||||||
|
async updateChart(chartId, newData, config = {}) {
|
||||||
|
try {
|
||||||
|
const chart = this.charts.get(chartId);
|
||||||
|
if (!chart) {
|
||||||
|
throw new Error('Chart not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart data
|
||||||
|
const chartData = this.prepareChartData(newData, chart.config.type, config);
|
||||||
|
chart.data = chartData;
|
||||||
|
|
||||||
|
// Update chart options if provided
|
||||||
|
if (Object.keys(config).length > 0) {
|
||||||
|
const chartOptions = this.prepareChartOptions(chart.config.type, config);
|
||||||
|
chart.options = { ...chart.options, ...chartOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart
|
||||||
|
chart.update();
|
||||||
|
|
||||||
|
// Notify update
|
||||||
|
if (this.options.onChartUpdated) {
|
||||||
|
this.options.onChartUpdated(chart);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chart;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chart update failed:', error);
|
||||||
|
if (this.options.onError) {
|
||||||
|
this.options.onError(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select chart type based on data
|
||||||
|
*/
|
||||||
|
selectChartType(data, config = {}) {
|
||||||
|
// If explicitly specified, use that
|
||||||
|
if (config.type && config.type !== 'auto') {
|
||||||
|
return config.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return 'bar';
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const numericColumns = this.getNumericColumns(data);
|
||||||
|
const categoricalColumns = this.getCategoricalColumns(data);
|
||||||
|
|
||||||
|
// Auto-select based on data characteristics
|
||||||
|
if (numericColumns.length >= 2 && categoricalColumns.length === 0) {
|
||||||
|
// Multiple numeric columns - scatter plot or line chart
|
||||||
|
return 'scatter';
|
||||||
|
} else if (numericColumns.length === 1 && categoricalColumns.length >= 1) {
|
||||||
|
// One numeric, multiple categorical - bar chart
|
||||||
|
return 'bar';
|
||||||
|
} else if (numericColumns.length === 1 && categoricalColumns.length === 0) {
|
||||||
|
// Single numeric - pie chart for proportions
|
||||||
|
return 'pie';
|
||||||
|
} else {
|
||||||
|
// Default to bar chart
|
||||||
|
return 'bar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare chart data
|
||||||
|
*/
|
||||||
|
prepareChartData(data, chartType, config = {}) {
|
||||||
|
const xColumn = config.xAxis || this.getDefaultXAxis(data, chartType);
|
||||||
|
const yColumn = config.yAxis || this.getDefaultYAxis(data, chartType);
|
||||||
|
|
||||||
|
if (chartType === 'pie' || chartType === 'doughnut') {
|
||||||
|
return this.preparePieChartData(data, yColumn);
|
||||||
|
} else if (chartType === 'scatter') {
|
||||||
|
return this.prepareScatterChartData(data, xColumn, yColumn);
|
||||||
|
} else {
|
||||||
|
return this.prepareStandardChartData(data, xColumn, yColumn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare pie chart data
|
||||||
|
*/
|
||||||
|
preparePieChartData(data, valueColumn) {
|
||||||
|
const labels = data.map(row => row[valueColumn]);
|
||||||
|
const values = data.map(row => parseFloat(row[valueColumn]) || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: this.generateColors(values.length),
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare scatter chart data
|
||||||
|
*/
|
||||||
|
prepareScatterChartData(data, xColumn, yColumn) {
|
||||||
|
const points = data.map(row => ({
|
||||||
|
x: parseFloat(row[xColumn]) || 0,
|
||||||
|
y: parseFloat(row[yColumn]) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasets: [{
|
||||||
|
label: `${xColumn} vs ${yColumn}`,
|
||||||
|
data: points,
|
||||||
|
backgroundColor: this.generateColors(points.length),
|
||||||
|
pointRadius: 5,
|
||||||
|
pointHoverRadius: 7
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare standard chart data (bar, line, etc.)
|
||||||
|
*/
|
||||||
|
prepareStandardChartData(data, xColumn, yColumn) {
|
||||||
|
const labels = data.map(row => row[xColumn]);
|
||||||
|
const values = data.map(row => parseFloat(row[yColumn]) || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: yColumn,
|
||||||
|
data: values,
|
||||||
|
backgroundColor: this.generateColors(values.length),
|
||||||
|
borderColor: this.generateColors(values.length, 0.8),
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: false
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare chart options
|
||||||
|
*/
|
||||||
|
prepareChartOptions(chartType, config = {}) {
|
||||||
|
const theme = this.getCurrentTheme();
|
||||||
|
const colors = this.getThemeColors(theme);
|
||||||
|
|
||||||
|
const baseOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: 1000,
|
||||||
|
easing: 'easeInOutQuart'
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: config.legendPosition || 'top',
|
||||||
|
labels: {
|
||||||
|
color: colors.text,
|
||||||
|
font: {
|
||||||
|
size: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
titleColor: colors.text,
|
||||||
|
bodyColor: colors.text,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: colors.border
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
color: colors.border
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart-specific options
|
||||||
|
switch (chartType) {
|
||||||
|
case 'bar':
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
indexAxis: config.horizontal ? 'y' : 'x',
|
||||||
|
scales: {
|
||||||
|
...baseOptions.scales,
|
||||||
|
x: {
|
||||||
|
...baseOptions.scales.x,
|
||||||
|
beginAtZero: true
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
...baseOptions.scales.y,
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'line':
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'pie':
|
||||||
|
case 'doughnut':
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
cutout: chartType === 'doughnut' ? '50%' : '0%',
|
||||||
|
plugins: {
|
||||||
|
...baseOptions.plugins,
|
||||||
|
legend: {
|
||||||
|
...baseOptions.plugins.legend,
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'scatter':
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'bottom',
|
||||||
|
grid: {
|
||||||
|
color: colors.border
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
grid: {
|
||||||
|
color: colors.border
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return baseOptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create chart instance
|
||||||
|
*/
|
||||||
|
async createChart(chartType, data, options) {
|
||||||
|
// Destroy existing chart if any
|
||||||
|
if (this.currentChart) {
|
||||||
|
this.currentChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new chart
|
||||||
|
const chart = new Chart(this.chartContainer, {
|
||||||
|
type: chartType,
|
||||||
|
data: data,
|
||||||
|
options: options
|
||||||
|
});
|
||||||
|
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize charts for responsiveness
|
||||||
|
*/
|
||||||
|
resizeCharts() {
|
||||||
|
this.charts.forEach(chart => {
|
||||||
|
if (chart && chart.resize) {
|
||||||
|
chart.resize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update chart theme
|
||||||
|
*/
|
||||||
|
updateTheme(theme) {
|
||||||
|
this.charts.forEach(chart => {
|
||||||
|
if (chart) {
|
||||||
|
const colors = this.getThemeColors(theme);
|
||||||
|
chart.options.plugins.legend.labels.color = colors.text;
|
||||||
|
chart.options.plugins.tooltip.backgroundColor = colors.bgSecondary;
|
||||||
|
chart.options.plugins.tooltip.titleColor = colors.text;
|
||||||
|
chart.options.plugins.tooltip.bodyColor = colors.text;
|
||||||
|
chart.options.plugins.tooltip.borderColor = colors.border;
|
||||||
|
chart.options.scales.x.grid.color = colors.border;
|
||||||
|
chart.options.scales.x.ticks.color = colors.text;
|
||||||
|
chart.options.scales.y.grid.color = colors.border;
|
||||||
|
chart.options.scales.y.ticks.color = colors.text;
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export chart as image
|
||||||
|
*/
|
||||||
|
exportChart(chartId, format = 'png') {
|
||||||
|
const chart = this.charts.get(chartId);
|
||||||
|
if (!chart) {
|
||||||
|
throw new Error('Chart not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataURL = chart.toBase64Image(format, 1.0);
|
||||||
|
return dataURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart statistics
|
||||||
|
*/
|
||||||
|
getChartStatistics(chartId) {
|
||||||
|
const chart = this.charts.get(chartId);
|
||||||
|
if (!chart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = chart.data.datasets[0].data;
|
||||||
|
const stats = {
|
||||||
|
min: Math.min(...data),
|
||||||
|
max: Math.max(...data),
|
||||||
|
mean: data.reduce((sum, val) => sum + val, 0) / data.length,
|
||||||
|
count: data.length
|
||||||
|
};
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get numeric columns from data
|
||||||
|
*/
|
||||||
|
getNumericColumns(data) {
|
||||||
|
if (!data || data.length === 0) return [];
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
return headers.filter(header => {
|
||||||
|
const values = data.map(row => row[header]);
|
||||||
|
return values.every(val => !isNaN(parseFloat(val)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categorical columns from data
|
||||||
|
*/
|
||||||
|
getCategoricalColumns(data) {
|
||||||
|
if (!data || data.length === 0) return [];
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
return headers.filter(header => {
|
||||||
|
const values = data.map(row => row[header]);
|
||||||
|
return values.some(val => isNaN(parseFloat(val)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default X axis for chart type
|
||||||
|
*/
|
||||||
|
getDefaultXAxis(data, chartType) {
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
|
||||||
|
// For scatter plots, prefer numeric columns
|
||||||
|
if (chartType === 'scatter') {
|
||||||
|
const numericColumns = this.getNumericColumns(data);
|
||||||
|
return numericColumns.length > 0 ? numericColumns[0] : headers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other charts, prefer categorical columns
|
||||||
|
const categoricalColumns = this.getCategoricalColumns(data);
|
||||||
|
return categoricalColumns.length > 0 ? categoricalColumns[0] : headers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default Y axis for chart type
|
||||||
|
*/
|
||||||
|
getDefaultYAxis(data, chartType) {
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
|
||||||
|
// Prefer numeric columns for Y axis
|
||||||
|
const numericColumns = this.getNumericColumns(data);
|
||||||
|
return numericColumns.length > 0 ? numericColumns[0] : headers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate colors for chart
|
||||||
|
*/
|
||||||
|
generateColors(count, alpha = 1.0) {
|
||||||
|
const colors = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const hue = (i * 137.508) % 360; // Golden angle
|
||||||
|
colors.push(this.hslToHex(hue, 70, 50));
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HSL to Hex
|
||||||
|
*/
|
||||||
|
hslToHex(h, s, l) {
|
||||||
|
l /= 100;
|
||||||
|
const a = s * Math.min(l, 1 - l) / 100;
|
||||||
|
const f = n => {
|
||||||
|
const k = (n + h / 30) % 12;
|
||||||
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
|
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||||
|
};
|
||||||
|
return `#${f(0)}${f(8)}${f(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme colors
|
||||||
|
*/
|
||||||
|
getThemeColors(theme) {
|
||||||
|
// Get colors from CSS custom properties
|
||||||
|
const root = document.documentElement;
|
||||||
|
return {
|
||||||
|
primary: getComputedStyle(root).getPropertyValue('--primary-color').trim(),
|
||||||
|
secondary: getComputedStyle(root).getPropertyValue('--secondary-color').trim(),
|
||||||
|
success: getComputedStyle(root).getPropertyValue('--success-color').trim(),
|
||||||
|
danger: getComputedStyle(root).getPropertyValue('--danger-color').trim(),
|
||||||
|
warning: getComputedStyle(root).getPropertyValue('--warning-color').trim(),
|
||||||
|
info: getComputedStyle(root).getPropertyValue('--info-color').trim(),
|
||||||
|
text: getComputedStyle(root).getPropertyValue('--text-primary').trim(),
|
||||||
|
bgSecondary: getComputedStyle(root).getPropertyValue('--bg-secondary').trim(),
|
||||||
|
border: getComputedStyle(root).getPropertyValue('--border-color').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current theme
|
||||||
|
*/
|
||||||
|
getCurrentTheme() {
|
||||||
|
return document.documentElement.getAttribute('data-theme') || 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all charts
|
||||||
|
*/
|
||||||
|
clearCharts() {
|
||||||
|
this.charts.forEach(chart => {
|
||||||
|
if (chart && chart.destroy) {
|
||||||
|
chart.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.charts.clear();
|
||||||
|
this.currentChart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart by ID
|
||||||
|
*/
|
||||||
|
getChart(chartId) {
|
||||||
|
return this.charts.get(chartId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all charts
|
||||||
|
*/
|
||||||
|
getAllCharts() {
|
||||||
|
return Array.from(this.charts.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove chart
|
||||||
|
*/
|
||||||
|
removeChart(chartId) {
|
||||||
|
const chart = this.charts.get(chartId);
|
||||||
|
if (chart) {
|
||||||
|
chart.destroy();
|
||||||
|
this.charts.delete(chartId);
|
||||||
|
if (this.currentChart && this.currentChart.id === chartId) {
|
||||||
|
this.currentChart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup responsive chart behavior
|
||||||
|
*/
|
||||||
|
setupResponsiveChart() {
|
||||||
|
// Chart.js handles responsiveness automatically with responsive: true
|
||||||
|
// But we can add additional responsive behavior here if needed
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
this.resizeCharts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Visualizer;
|
||||||
|
} else {
|
||||||
|
window.Visualizer = Visualizer;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,552 @@
|
||||||
|
/**
|
||||||
|
* Formatting Utilities
|
||||||
|
*
|
||||||
|
* Data formatting functions for the Excel Analyzer Web application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Formatters {
|
||||||
|
/**
|
||||||
|
* Format number with locale
|
||||||
|
*/
|
||||||
|
formatNumber(num, options = {}) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return options.defaultValue || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
useGrouping: true,
|
||||||
|
locale: 'en-US'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
return Number(num).toLocaleString(config.locale, {
|
||||||
|
minimumFractionDigits: config.minimumFractionDigits,
|
||||||
|
maximumFractionDigits: config.maximumFractionDigits,
|
||||||
|
useGrouping: config.useGrouping
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency
|
||||||
|
*/
|
||||||
|
formatCurrency(num, options = {}) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return options.defaultValue || '$0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
locale: 'en-US'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
return Number(num).toLocaleString(config.locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: config.currency,
|
||||||
|
minimumFractionDigits: config.minimumFractionDigits,
|
||||||
|
maximumFractionDigits: config.maximumFractionDigits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage
|
||||||
|
*/
|
||||||
|
formatPercentage(num, options = {}) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return options.defaultValue || '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
locale: 'en-US'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
return Number(num).toLocaleString(config.locale, {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits: config.minimumFractionDigits,
|
||||||
|
maximumFractionDigits: config.maximumFractionDigits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date
|
||||||
|
*/
|
||||||
|
formatDate(date, options = {}) {
|
||||||
|
if (!date) {
|
||||||
|
return options.defaultValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
locale: 'en-US'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return options.defaultValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.toLocaleDateString(config.locale, {
|
||||||
|
year: config.year,
|
||||||
|
month: config.month,
|
||||||
|
day: config.day,
|
||||||
|
hour: config.hour,
|
||||||
|
minute: config.minute,
|
||||||
|
second: config.second
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return options.defaultValue || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time
|
||||||
|
*/
|
||||||
|
formatTime(date, options = {}) {
|
||||||
|
if (!date) {
|
||||||
|
return options.defaultValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
locale: 'en-US'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return options.defaultValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.toLocaleTimeString(config.locale, {
|
||||||
|
hour: config.hour,
|
||||||
|
minute: config.minute,
|
||||||
|
second: config.second
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return options.defaultValue || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size
|
||||||
|
*/
|
||||||
|
formatFileSize(bytes, options = {}) {
|
||||||
|
if (bytes === 0) {
|
||||||
|
return options.defaultValue || '0 Bytes';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
decimals: 2,
|
||||||
|
units: ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
if (bytes === 0) return '0 ' + config.units[0];
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const dm = config.decimals < 0 ? 0 : config.decimals;
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + config.units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration
|
||||||
|
*/
|
||||||
|
formatDuration(milliseconds, options = {}) {
|
||||||
|
if (!milliseconds || milliseconds <= 0) {
|
||||||
|
return options.defaultValue || '0s';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
showDays: true,
|
||||||
|
showHours: true,
|
||||||
|
showMinutes: true,
|
||||||
|
showSeconds: true,
|
||||||
|
showMilliseconds: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
const ms = milliseconds % 1000;
|
||||||
|
const seconds = Math.floor((milliseconds / 1000) % 60);
|
||||||
|
const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
|
||||||
|
const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
|
||||||
|
const days = Math.floor(milliseconds / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (config.showDays && days > 0) {
|
||||||
|
parts.push(`${days}d`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.showHours && (hours > 0 || parts.length > 0)) {
|
||||||
|
parts.push(`${hours}h`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.showMinutes && (minutes > 0 || parts.length > 0)) {
|
||||||
|
parts.push(`${minutes}m`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.showSeconds && (seconds > 0 || parts.length > 0)) {
|
||||||
|
parts.push(`${seconds}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.showMilliseconds && ms > 0) {
|
||||||
|
parts.push(`${ms}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ') || '0s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format large numbers with abbreviations
|
||||||
|
*/
|
||||||
|
formatLargeNumber(num, options = {}) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return options.defaultValue || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
decimals: 1,
|
||||||
|
abbreviations: {
|
||||||
|
1e3: 'K',
|
||||||
|
1e6: 'M',
|
||||||
|
1e9: 'B',
|
||||||
|
1e12: 'T'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
const absNum = Math.abs(num);
|
||||||
|
|
||||||
|
for (const [threshold, abbr] of Object.entries(config.abbreviations).sort((a, b) => b[0] - a[0])) {
|
||||||
|
if (absNum >= threshold) {
|
||||||
|
const value = num / threshold;
|
||||||
|
const decimals = absNum >= threshold * 10 ? 0 : config.decimals;
|
||||||
|
return value.toFixed(decimals) + abbr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatNumber(num, { maximumFractionDigits: config.decimals });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format text case
|
||||||
|
*/
|
||||||
|
formatTextCase(text, format = 'title') {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
switch (format.toLowerCase()) {
|
||||||
|
case 'upper':
|
||||||
|
return text.toUpperCase();
|
||||||
|
case 'lower':
|
||||||
|
return text.toLowerCase();
|
||||||
|
case 'title':
|
||||||
|
return text.replace(/\w\S*/g, (txt) => {
|
||||||
|
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
|
||||||
|
});
|
||||||
|
case 'sentence':
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||||
|
case 'camel':
|
||||||
|
return text.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
|
||||||
|
return index === 0 ? word.toLowerCase() : word.toUpperCase();
|
||||||
|
}).replace(/\s+/g, '');
|
||||||
|
case 'pascal':
|
||||||
|
return text.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => {
|
||||||
|
return word.toUpperCase();
|
||||||
|
}).replace(/\s+/g, '');
|
||||||
|
case 'kebab':
|
||||||
|
return text.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase();
|
||||||
|
case 'snake':
|
||||||
|
return text.replace(/([a-z])([A-Z])/g, '$1_$2').replace(/\s+/g, '_').toLowerCase();
|
||||||
|
default:
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format phone number
|
||||||
|
*/
|
||||||
|
formatPhoneNumber(phone, options = {}) {
|
||||||
|
if (!phone) return '';
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
format: 'US', // US, International, etc.
|
||||||
|
separator: '-'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
// Remove all non-digit characters
|
||||||
|
const digits = phone.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (config.format === 'US') {
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return digits.replace(/(\d{3})(\d{3})(\d{4})/, `($1) $2${config.separator}$3`);
|
||||||
|
} else if (digits.length === 11 && digits[0] === '1') {
|
||||||
|
return digits.replace(/(\d{1})(\d{3})(\d{3})(\d{4})/, `+$1 ($2) $3${config.separator}$4`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default format
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format postal code
|
||||||
|
*/
|
||||||
|
formatPostalCode(code, options = {}) {
|
||||||
|
if (!code) return '';
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
format: 'US' // US, Canada, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
const cleanCode = code.replace(/\s+/g, '').toUpperCase();
|
||||||
|
|
||||||
|
if (config.format === 'US') {
|
||||||
|
// US ZIP code format: 12345 or 12345-6789
|
||||||
|
if (cleanCode.length === 5) {
|
||||||
|
return cleanCode;
|
||||||
|
} else if (cleanCode.length === 9) {
|
||||||
|
return cleanCode.replace(/(\d{5})(\d{4})/, '$1-$2');
|
||||||
|
}
|
||||||
|
} else if (config.format === 'Canada') {
|
||||||
|
// Canadian postal code format: A1A 1A1
|
||||||
|
if (cleanCode.length === 6) {
|
||||||
|
return cleanCode.replace(/(.{3})(.{3})/, '$1 $2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage change
|
||||||
|
*/
|
||||||
|
formatPercentageChange(oldValue, newValue, options = {}) {
|
||||||
|
if (oldValue === null || newValue === null || oldValue === 0) {
|
||||||
|
return options.defaultValue || '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = ((newValue - oldValue) / Math.abs(oldValue)) * 100;
|
||||||
|
const formattedChange = this.formatPercentage(Math.abs(change) / 100, options);
|
||||||
|
|
||||||
|
if (newValue > oldValue) {
|
||||||
|
return `+${formattedChange}`;
|
||||||
|
} else if (newValue < oldValue) {
|
||||||
|
return `-${formattedChange}`;
|
||||||
|
} else {
|
||||||
|
return '0%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format data size
|
||||||
|
*/
|
||||||
|
formatDataSize(bytes, options = {}) {
|
||||||
|
return this.formatFileSize(bytes, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format memory size
|
||||||
|
*/
|
||||||
|
formatMemorySize(bytes, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
units: ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.formatFileSize(bytes, { ...defaultOptions, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format speed
|
||||||
|
*/
|
||||||
|
formatSpeed(bytesPerSecond, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
timeUnit: 's' // s, m, h
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
let unit = '/s';
|
||||||
|
let speed = bytesPerSecond;
|
||||||
|
|
||||||
|
switch (config.timeUnit) {
|
||||||
|
case 'm':
|
||||||
|
speed = bytesPerSecond * 60;
|
||||||
|
unit = '/m';
|
||||||
|
break;
|
||||||
|
case 'h':
|
||||||
|
speed = bytesPerSecond * 3600;
|
||||||
|
unit = '/h';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatDataSize(speed, { ...config, defaultValue: '0 B' }) + unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format ratio
|
||||||
|
*/
|
||||||
|
formatRatio(numerator, denominator, options = {}) {
|
||||||
|
if (denominator === 0) {
|
||||||
|
return options.defaultValue || '0:1';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
decimals: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
const ratio = numerator / denominator;
|
||||||
|
return this.formatNumber(ratio, { ...config, defaultValue: '0' }) + ':1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format score
|
||||||
|
*/
|
||||||
|
formatScore(score, maxScore = 100, options = {}) {
|
||||||
|
if (score === null || score === undefined) {
|
||||||
|
return options.defaultValue || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
showPercentage: true,
|
||||||
|
showFraction: false,
|
||||||
|
maxDecimals: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
if (config.showFraction) {
|
||||||
|
result += `${score}/${maxScore}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.showPercentage) {
|
||||||
|
const percentage = (score / maxScore) * 100;
|
||||||
|
const formattedPercentage = this.formatNumber(percentage, {
|
||||||
|
maximumFractionDigits: config.maxDecimals
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
result += ' ';
|
||||||
|
}
|
||||||
|
result += `(${formattedPercentage}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result || this.formatNumber(score, { maximumFractionDigits: config.maxDecimals });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format progress
|
||||||
|
*/
|
||||||
|
formatProgress(current, total, options = {}) {
|
||||||
|
if (total === 0) {
|
||||||
|
return options.defaultValue || '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = (current / total) * 100;
|
||||||
|
return this.formatPercentage(percentage / 100, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format version
|
||||||
|
*/
|
||||||
|
formatVersion(version, options = {}) {
|
||||||
|
if (!version) return '';
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
padZeros: true,
|
||||||
|
maxParts: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
// Remove all non-numeric and non-dot characters except hyphens
|
||||||
|
const cleanVersion = version.replace(/[^\d.-]/g, '');
|
||||||
|
|
||||||
|
// Split by dots and hyphens
|
||||||
|
const parts = cleanVersion.split(/[.-]/).filter(part => part.length > 0);
|
||||||
|
|
||||||
|
if (config.padZeros) {
|
||||||
|
// Pad each part to at least 2 digits
|
||||||
|
const paddedParts = parts.map(part => part.padStart(2, '0'));
|
||||||
|
return paddedParts.slice(0, config.maxParts).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.slice(0, config.maxParts).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format scientific notation
|
||||||
|
*/
|
||||||
|
formatScientific(num, options = {}) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return options.defaultValue || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
precision: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
return Number(num).toExponential(config.precision);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format boolean
|
||||||
|
*/
|
||||||
|
formatBoolean(value, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
trueLabel: 'Yes',
|
||||||
|
falseLabel: 'No',
|
||||||
|
nullLabel: 'N/A'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
if (value === true || value === 'true' || value === 1) {
|
||||||
|
return config.trueLabel;
|
||||||
|
} else if (value === false || value === 'false' || value === 0) {
|
||||||
|
return config.falseLabel;
|
||||||
|
} else {
|
||||||
|
return config.nullLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Formatters;
|
||||||
|
} else {
|
||||||
|
window.Formatters = Formatters;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,391 @@
|
||||||
|
/**
|
||||||
|
* Helper Utilities
|
||||||
|
*
|
||||||
|
* Common utility functions for the Excel Analyzer Web application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Helpers {
|
||||||
|
/**
|
||||||
|
* Deep clone an object
|
||||||
|
*/
|
||||||
|
deepClone(obj) {
|
||||||
|
if (obj === null || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj instanceof Date) {
|
||||||
|
return new Date(obj.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj instanceof Array) {
|
||||||
|
return obj.map(item => this.deepClone(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
const clonedObj = {};
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
clonedObj[key] = this.deepClone(obj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clonedObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique ID
|
||||||
|
*/
|
||||||
|
generateId(prefix = 'id') {
|
||||||
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with commas and decimal places
|
||||||
|
*/
|
||||||
|
formatNumber(num, decimals = 2) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(num).toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency
|
||||||
|
*/
|
||||||
|
formatCurrency(num, currency = 'USD', decimals = 2) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return '$0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(num).toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage
|
||||||
|
*/
|
||||||
|
formatPercentage(num, decimals = 2) {
|
||||||
|
if (num === null || num === undefined || isNaN(num)) {
|
||||||
|
return '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(num).toLocaleString('en-US', {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date
|
||||||
|
*/
|
||||||
|
formatDate(date, format = 'YYYY-MM-DD') {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
const d = new Date(date);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return format
|
||||||
|
.replace('YYYY', year)
|
||||||
|
.replace('MM', month)
|
||||||
|
.replace('DD', day)
|
||||||
|
.replace('HH', hours)
|
||||||
|
.replace('mm', minutes)
|
||||||
|
.replace('ss', seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function
|
||||||
|
*/
|
||||||
|
debounce(func, wait, immediate = false) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
if (!immediate) func.apply(this, args);
|
||||||
|
};
|
||||||
|
const callNow = immediate && !timeout;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
if (callNow) func.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle function
|
||||||
|
*/
|
||||||
|
throttle(func, limit) {
|
||||||
|
let inThrottle;
|
||||||
|
return function(...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(this, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => inThrottle = false, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if value is empty
|
||||||
|
*/
|
||||||
|
isEmpty(value) {
|
||||||
|
if (value === null || value === undefined) return true;
|
||||||
|
if (typeof value === 'string') return value.trim() === '';
|
||||||
|
if (Array.isArray(value)) return value.length === 0;
|
||||||
|
if (typeof value === 'object') return Object.keys(value).length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get object size in bytes
|
||||||
|
*/
|
||||||
|
getObjectSize(obj) {
|
||||||
|
const objStr = JSON.stringify(obj);
|
||||||
|
return new Blob([objStr]).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize first letter
|
||||||
|
*/
|
||||||
|
capitalize(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate string
|
||||||
|
*/
|
||||||
|
truncate(str, maxLength = 50, suffix = '...') {
|
||||||
|
if (!str || str.length <= maxLength) return str;
|
||||||
|
return str.substring(0, maxLength - suffix.length) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension
|
||||||
|
*/
|
||||||
|
getFileExtension(filename) {
|
||||||
|
if (!filename) return '';
|
||||||
|
return filename.split('.').pop().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is image
|
||||||
|
*/
|
||||||
|
isImageFile(filename) {
|
||||||
|
const ext = this.getFileExtension(filename);
|
||||||
|
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is Excel
|
||||||
|
*/
|
||||||
|
isExcelFile(filename) {
|
||||||
|
const ext = this.getFileExtension(filename);
|
||||||
|
return ['xlsx', 'xls'].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get random color
|
||||||
|
*/
|
||||||
|
getRandomColor() {
|
||||||
|
const letters = '0123456789ABCDEF';
|
||||||
|
let color = '#';
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
color += letters[Math.floor(Math.random() * 16)];
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate color palette
|
||||||
|
*/
|
||||||
|
generateColorPalette(count = 5) {
|
||||||
|
const colors = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const hue = (i * 137.508) % 360; // Golden angle
|
||||||
|
colors.push(this.hslToHex(hue, 70, 50));
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HSL to Hex
|
||||||
|
*/
|
||||||
|
hslToHex(h, s, l) {
|
||||||
|
l /= 100;
|
||||||
|
const a = s * Math.min(l, 1 - l) / 100;
|
||||||
|
const f = n => {
|
||||||
|
const k = (n + h / 30) % 12;
|
||||||
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
|
return Math.round(255 * color).toString(16).padStart(2, '0'); // convert to Hex and prefix "0" if needed
|
||||||
|
};
|
||||||
|
return `#${f(0)}${f(8)}${f(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffle array
|
||||||
|
*/
|
||||||
|
shuffleArray(array) {
|
||||||
|
const arr = [...array];
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique values from array
|
||||||
|
*/
|
||||||
|
getUniqueValues(array) {
|
||||||
|
return [...new Set(array)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group array by property
|
||||||
|
*/
|
||||||
|
groupBy(array, key) {
|
||||||
|
return array.reduce((groups, item) => {
|
||||||
|
const groupKey = item[key];
|
||||||
|
groups[groupKey] = groups[groupKey] || [];
|
||||||
|
groups[groupKey].push(item);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort array by property
|
||||||
|
*/
|
||||||
|
sortBy(array, key, direction = 'asc') {
|
||||||
|
return [...array].sort((a, b) => {
|
||||||
|
const aVal = a[key];
|
||||||
|
const bVal = b[key];
|
||||||
|
|
||||||
|
if (aVal < bVal) return direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search in array of objects
|
||||||
|
*/
|
||||||
|
searchInArray(array, searchTerm, keys) {
|
||||||
|
if (!searchTerm) return array;
|
||||||
|
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return array.filter(item => {
|
||||||
|
return keys.some(key => {
|
||||||
|
const value = item[key];
|
||||||
|
return value && value.toString().toLowerCase().includes(term);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file
|
||||||
|
*/
|
||||||
|
downloadFile(content, filename, mimeType = 'text/plain') {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard
|
||||||
|
*/
|
||||||
|
copyToClipboard(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email
|
||||||
|
*/
|
||||||
|
isValidEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URL
|
||||||
|
*/
|
||||||
|
isValidUrl(string) {
|
||||||
|
try {
|
||||||
|
new URL(string);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get URL parameters
|
||||||
|
*/
|
||||||
|
getUrlParams(url = window.location.href) {
|
||||||
|
const params = {};
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
urlObj.searchParams.forEach((value, key) => {
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set URL parameters
|
||||||
|
*/
|
||||||
|
setUrlParams(params) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
Object.keys(params).forEach(key => {
|
||||||
|
if (params[key] !== null && params[key] !== undefined) {
|
||||||
|
url.searchParams.set(key, params[key]);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Helpers;
|
||||||
|
} else {
|
||||||
|
window.Helpers = Helpers;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,444 @@
|
||||||
|
/**
|
||||||
|
* Validation Utilities
|
||||||
|
*
|
||||||
|
* Input validation functions for the Excel Analyzer Web application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Validators {
|
||||||
|
/**
|
||||||
|
* Validate file format
|
||||||
|
*/
|
||||||
|
validateFileFormat(file) {
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return { isValid: false, error: 'No file selected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'Invalid file type. Please upload an Excel file (.xlsx or .xls)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file size
|
||||||
|
*/
|
||||||
|
validateFileSize(file, maxSize = 50 * 1024 * 1024) {
|
||||||
|
if (!file) {
|
||||||
|
return { isValid: false, error: 'No file selected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const maxSizeMB = Math.round(maxSize / (1024 * 1024));
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `File too large. Maximum file size is ${maxSizeMB}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email address
|
||||||
|
*/
|
||||||
|
validateEmail(email) {
|
||||||
|
if (!email) {
|
||||||
|
return { isValid: false, error: 'Email is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return { isValid: false, error: 'Invalid email format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URL
|
||||||
|
*/
|
||||||
|
validateUrl(url) {
|
||||||
|
if (!url) {
|
||||||
|
return { isValid: false, error: 'URL is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return { isValid: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { isValid: false, error: 'Invalid URL format' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate number range
|
||||||
|
*/
|
||||||
|
validateNumberRange(value, min = null, max = null) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return { isValid: false, error: 'Number is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return { isValid: false, error: 'Value must be a number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min !== null && num < min) {
|
||||||
|
return { isValid: false, error: `Value must be at least ${min}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max !== null && num > max) {
|
||||||
|
return { isValid: false, error: `Value must be at most ${max}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate string length
|
||||||
|
*/
|
||||||
|
validateStringLength(str, minLength = 1, maxLength = null) {
|
||||||
|
if (!str) {
|
||||||
|
return { isValid: false, error: 'String is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str.length < minLength) {
|
||||||
|
return { isValid: false, error: `String must be at least ${minLength} characters long` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLength && str.length > maxLength) {
|
||||||
|
return { isValid: false, error: `String must be at most ${maxLength} characters long` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate data structure
|
||||||
|
*/
|
||||||
|
validateDataStructure(data) {
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
return { isValid: false, error: 'Invalid data structure' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return { isValid: false, error: 'No data available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
if (headers.length === 0) {
|
||||||
|
return { isValid: false, error: 'No columns found in data' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for consistent structure
|
||||||
|
for (let i = 1; i < data.length; i++) {
|
||||||
|
const rowHeaders = Object.keys(data[i]);
|
||||||
|
if (rowHeaders.length !== headers.length) {
|
||||||
|
return { isValid: false, error: `Inconsistent data structure at row ${i + 1}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate chart configuration
|
||||||
|
*/
|
||||||
|
validateChartConfig(config) {
|
||||||
|
const requiredFields = ['type', 'data'];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!config[field]) {
|
||||||
|
errors.push(`Missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type && !['bar', 'line', 'pie', 'scatter', 'doughnut'].includes(config.type)) {
|
||||||
|
errors.push('Invalid chart type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.data && !Array.isArray(config.data)) {
|
||||||
|
errors.push('Chart data must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { isValid: false, error: errors.join(', ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate export settings
|
||||||
|
*/
|
||||||
|
validateExportSettings(settings) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return { isValid: false, error: 'Export settings are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFormats = ['pdf', 'excel', 'csv', 'json'];
|
||||||
|
if (settings.format && !validFormats.includes(settings.format)) {
|
||||||
|
errors.push('Invalid export format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.includeCharts !== undefined && typeof settings.includeCharts !== 'boolean') {
|
||||||
|
errors.push('includeCharts must be boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.includeData !== undefined && typeof settings.includeData !== 'boolean') {
|
||||||
|
errors.push('includeData must be boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { isValid: false, error: errors.join(', ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate dashboard configuration
|
||||||
|
*/
|
||||||
|
validateDashboardConfig(config) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return { isValid: false, error: 'Dashboard configuration is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.widgets && !Array.isArray(config.widgets)) {
|
||||||
|
errors.push('Widgets must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.layout && typeof config.layout !== 'string') {
|
||||||
|
errors.push('Layout must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { isValid: false, error: errors.join(', ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate settings object
|
||||||
|
*/
|
||||||
|
validateSettings(settings) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!settings || typeof settings !== 'object') {
|
||||||
|
return { isValid: false, error: 'Settings must be an object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate specific settings
|
||||||
|
if (settings.maxFileSize && !this.validateNumberRange(settings.maxFileSize, 1024).isValid) {
|
||||||
|
errors.push('maxFileSize must be at least 1024 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.maxRows && !this.validateNumberRange(settings.maxRows, 1, 100000).isValid) {
|
||||||
|
errors.push('maxRows must be between 1 and 100000');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.maxColumns && !this.validateNumberRange(settings.maxColumns, 1, 1000).isValid) {
|
||||||
|
errors.push('maxColumns must be between 1 and 1000');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.outlierThreshold && !this.validateNumberRange(settings.outlierThreshold, 1).isValid) {
|
||||||
|
errors.push('outlierThreshold must be at least 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.theme && !['light', 'dark'].includes(settings.theme)) {
|
||||||
|
errors.push('theme must be either "light" or "dark"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { isValid: false, error: errors.join(', ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate color value
|
||||||
|
*/
|
||||||
|
validateColor(color) {
|
||||||
|
if (!color) {
|
||||||
|
return { isValid: false, error: 'Color is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for hex color
|
||||||
|
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
|
||||||
|
if (hexRegex.test(color)) {
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for RGB color
|
||||||
|
const rgbRegex = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/;
|
||||||
|
if (rgbRegex.test(color)) {
|
||||||
|
const matches = color.match(rgbRegex);
|
||||||
|
const r = parseInt(matches[1]);
|
||||||
|
const g = parseInt(matches[2]);
|
||||||
|
const b = parseInt(matches[3]);
|
||||||
|
|
||||||
|
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for named colors
|
||||||
|
const namedColors = [
|
||||||
|
'red', 'blue', 'green', 'yellow', 'orange', 'purple', 'pink', 'brown',
|
||||||
|
'black', 'white', 'gray', 'grey', 'cyan', 'magenta', 'lime', 'navy',
|
||||||
|
'maroon', 'olive', 'teal', 'silver', 'aqua', 'fuchsia'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (namedColors.includes(color.toLowerCase())) {
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: false, error: 'Invalid color format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate date range
|
||||||
|
*/
|
||||||
|
validateDateRange(startDate, endDate) {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return { isValid: false, error: 'Both start and end dates are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
return { isValid: false, error: 'Invalid date format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > end) {
|
||||||
|
return { isValid: false, error: 'Start date must be before end date' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate array of items
|
||||||
|
*/
|
||||||
|
validateArray(items, validatorFn, minItems = 0, maxItems = null) {
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return { isValid: false, error: 'Must be an array' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length < minItems) {
|
||||||
|
return { isValid: false, error: `Must have at least ${minItems} items` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxItems && items.length > maxItems) {
|
||||||
|
return { isValid: false, error: `Must have at most ${maxItems} items` };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const result = validatorFn(items[i]);
|
||||||
|
if (!result.isValid) {
|
||||||
|
return { isValid: false, error: `Item ${i + 1}: ${result.error}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate object properties
|
||||||
|
*/
|
||||||
|
validateObject(obj, rules) {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return { isValid: false, error: 'Must be an object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, validator] of Object.entries(rules)) {
|
||||||
|
if (obj[key] !== undefined) {
|
||||||
|
const result = validator(obj[key]);
|
||||||
|
if (!result.isValid) {
|
||||||
|
return { isValid: false, error: `${key}: ${result.error}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate required field
|
||||||
|
*/
|
||||||
|
validateRequired(value, fieldName = 'Field') {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return { isValid: false, error: `${fieldName} is required` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate against custom function
|
||||||
|
*/
|
||||||
|
validateCustom(value, validatorFn, errorMessage = 'Validation failed') {
|
||||||
|
try {
|
||||||
|
const result = validatorFn(value);
|
||||||
|
if (result === true) {
|
||||||
|
return { isValid: true };
|
||||||
|
} else if (typeof result === 'string') {
|
||||||
|
return { isValid: false, error: result };
|
||||||
|
} else {
|
||||||
|
return { isValid: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { isValid: false, error: error.message || errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch validation
|
||||||
|
*/
|
||||||
|
validateBatch(validations) {
|
||||||
|
const results = [];
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
for (const validation of validations) {
|
||||||
|
const result = validation.validator(validation.value, ...validation.args);
|
||||||
|
results.push({
|
||||||
|
...result,
|
||||||
|
field: validation.field
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.isValid) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid,
|
||||||
|
results,
|
||||||
|
errors: results.filter(r => !r.isValid).map(r => `${r.field}: ${r.error}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in main.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Validators;
|
||||||
|
} else {
|
||||||
|
window.Validators = Validators;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue