forked from trent_larson/crowd-funder-for-time-pwa
refactor: remove unused Vite configuration files and update documentation
Remove obsolete Vite configuration files that are no longer used by the build system and update BUILDING.md to accurately reflect the current configuration structure. **Removed Files:** - vite.config.ts (47 lines) - Legacy configuration file - vite.config.mts (70 lines) - Unused "main" configuration file **Updated Documentation:** - BUILDING.md - Corrected Vite configuration section to show actual usage **Current Configuration Structure:** - vite.config.web.mts - Used by build-web.sh - vite.config.electron.mts - Used by build-electron.sh - vite.config.capacitor.mts - Used by npm run build:capacitor - vite.config.common.mts - Shared configuration utilities - vite.config.utils.mts - Configuration utility functions **Benefits:** - Eliminates confusion about which config files to use - Removes 117 lines of unused configuration code - Documentation now matches actual build system usage - Cleaner, more maintainable configuration structure **Impact:** - No functional changes to build process - All platform builds continue to work correctly - Reduced configuration complexity and maintenance overhead
This commit is contained in:
@@ -2030,8 +2030,11 @@ share_target: {
|
||||
|
||||
### B.6 Additional Vite Configurations
|
||||
|
||||
**vite.config.ts**: Legacy configuration file (minimal)
|
||||
**vite.config.mts**: Main configuration entry point
|
||||
**vite.config.web.mts**: Web platform configuration (used by build-web.sh)
|
||||
**vite.config.electron.mts**: Electron platform configuration (used by build-electron.sh)
|
||||
**vite.config.capacitor.mts**: Capacitor mobile configuration (used by npm run build:capacitor)
|
||||
**vite.config.common.mts**: Shared configuration utilities
|
||||
**vite.config.utils.mts**: Configuration utility functions
|
||||
**vite.config.dev.mts**: Development-specific configuration
|
||||
**vite.config.optimized.mts**: Optimized build configuration
|
||||
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
# Vue 3 + Vite + vue-facing-decorator: Lazy Loading Patterns & Best Practices
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides comprehensive guidance on implementing lazy loading and code splitting in Vue 3 applications using Vite and `vue-facing-decorator`. The patterns demonstrated here optimize bundle size, improve initial load times, and enhance user experience through progressive loading.
|
||||
|
||||
**Author:** Matthew Raymer
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2024
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Why Lazy Loading Matters](#why-lazy-loading-matters)
|
||||
2. [Vite Configuration for Optimal Code Splitting](#vite-configuration-for-optimal-code-splitting)
|
||||
3. [Lazy Loading Patterns](#lazy-loading-patterns)
|
||||
4. [Component-Level Lazy Loading](#component-level-lazy-loading)
|
||||
5. [Route-Level Lazy Loading](#route-level-lazy-loading)
|
||||
6. [Library-Level Lazy Loading](#library-level-lazy-loading)
|
||||
7. [Performance Monitoring](#performance-monitoring)
|
||||
8. [Best Practices](#best-practices)
|
||||
9. [Common Pitfalls](#common-pitfalls)
|
||||
10. [Examples](#examples)
|
||||
|
||||
## Why Lazy Loading Matters
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
- **Faster Initial Load**: Only load code needed for the current view
|
||||
- **Reduced Bundle Size**: Split large applications into smaller chunks
|
||||
- **Better Caching**: Independent chunks can be cached separately
|
||||
- **Improved User Experience**: Progressive loading with loading states
|
||||
|
||||
### When to Use Lazy Loading
|
||||
|
||||
✅ **Good Candidates for Lazy Loading:**
|
||||
- Heavy components (data processing, 3D rendering)
|
||||
- Feature-specific components (QR scanner, file uploader)
|
||||
- Route-based components
|
||||
- Large third-party libraries (ThreeJS, Chart.js)
|
||||
- Components with conditional rendering
|
||||
|
||||
❌ **Avoid Lazy Loading:**
|
||||
- Core UI components used everywhere
|
||||
- Small utility components
|
||||
- Components needed for initial render
|
||||
- Components with frequent usage patterns
|
||||
|
||||
## Vite Configuration for Optimal Code Splitting
|
||||
|
||||
### Enhanced Build Configuration
|
||||
|
||||
```typescript
|
||||
// vite.config.optimized.mts
|
||||
export async function createOptimizedBuildConfig(mode: string): Promise<UserConfig> {
|
||||
return {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Enhanced manual chunks for better code splitting
|
||||
manualChunks: {
|
||||
// Vendor chunks for better caching
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'ui-vendor': ['@fortawesome/fontawesome-svg-core', '@fortawesome/vue-fontawesome'],
|
||||
'crypto-vendor': ['@ethersproject/wallet', '@ethersproject/hdnode'],
|
||||
'sql-vendor': ['@jlongster/sql.js', 'absurd-sql', 'dexie'],
|
||||
'qr-vendor': ['qrcode', 'jsqr', 'vue-qrcode-reader'],
|
||||
'three-vendor': ['three', '@tweenjs/tween.js'],
|
||||
'utils-vendor': ['luxon', 'ramda', 'zod', 'axios'],
|
||||
// Platform-specific chunks
|
||||
...(isCapacitor && {
|
||||
'capacitor-vendor': ['@capacitor/core', '@capacitor/app']
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Key Configuration Features
|
||||
|
||||
1. **Manual Chunks**: Group related dependencies for better caching
|
||||
2. **Platform-Specific Chunks**: Separate native and web dependencies
|
||||
3. **Vendor Separation**: Keep third-party libraries separate from app code
|
||||
4. **Dynamic Imports**: Enable automatic code splitting for dynamic imports
|
||||
|
||||
## Lazy Loading Patterns
|
||||
|
||||
### 1. Component-Level Lazy Loading
|
||||
|
||||
#### Basic Pattern with defineAsyncComponent
|
||||
|
||||
```typescript
|
||||
import { Component, Vue } from 'vue-facing-decorator';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
@Component({
|
||||
name: 'LazyLoadingExample',
|
||||
components: {
|
||||
LazyHeavyComponent: defineAsyncComponent({
|
||||
loader: () => import('./sub-components/HeavyComponent.vue'),
|
||||
loadingComponent: {
|
||||
template: '<div class="loading">Loading...</div>'
|
||||
},
|
||||
errorComponent: {
|
||||
template: '<div class="error">Failed to load</div>'
|
||||
},
|
||||
delay: 200,
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
export default class LazyLoadingExample extends Vue {
|
||||
// Component logic
|
||||
}
|
||||
```
|
||||
|
||||
#### Advanced Pattern with Suspense
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<LazyHeavyComponent v-if="showComponent" />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="loading-fallback">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading component...</p>
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. Conditional Loading
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
name: 'ConditionalLazyLoading'
|
||||
})
|
||||
export default class ConditionalLazyLoading extends Vue {
|
||||
showHeavyComponent = false;
|
||||
showQRScanner = false;
|
||||
|
||||
// Lazy load based on user interaction
|
||||
async toggleHeavyComponent(): Promise<void> {
|
||||
this.showHeavyComponent = !this.showHeavyComponent;
|
||||
|
||||
if (this.showHeavyComponent) {
|
||||
// Preload component for better UX
|
||||
await this.preloadComponent(() => import('./HeavyComponent.vue'));
|
||||
}
|
||||
}
|
||||
|
||||
private async preloadComponent(loader: () => Promise<any>): Promise<void> {
|
||||
try {
|
||||
await loader();
|
||||
} catch (error) {
|
||||
console.warn('Preload failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Library-Level Lazy Loading
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
name: 'LibraryLazyLoading'
|
||||
})
|
||||
export default class LibraryLazyLoading extends Vue {
|
||||
private threeJS: any = null;
|
||||
|
||||
async loadThreeJS(): Promise<void> {
|
||||
if (!this.threeJS) {
|
||||
// Lazy load ThreeJS only when needed
|
||||
this.threeJS = await import('three');
|
||||
console.log('ThreeJS loaded successfully');
|
||||
}
|
||||
}
|
||||
|
||||
async initialize3DScene(): Promise<void> {
|
||||
await this.loadThreeJS();
|
||||
|
||||
// Use ThreeJS after loading
|
||||
const scene = new this.threeJS.Scene();
|
||||
// ... rest of 3D setup
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component-Level Lazy Loading
|
||||
|
||||
### Heavy Data Processing Component
|
||||
|
||||
```typescript
|
||||
// HeavyComponent.vue
|
||||
@Component({
|
||||
name: 'HeavyComponent'
|
||||
})
|
||||
export default class HeavyComponent extends Vue {
|
||||
@Prop({ required: true }) readonly data!: any;
|
||||
|
||||
async processData(): Promise<void> {
|
||||
// Process data in batches to avoid blocking UI
|
||||
const batchSize = 10;
|
||||
const items = this.data.items;
|
||||
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
await this.processBatch(batch);
|
||||
|
||||
// Allow UI to update
|
||||
await this.$nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Camera-Dependent Component
|
||||
|
||||
```typescript
|
||||
// QRScannerComponent.vue
|
||||
@Component({
|
||||
name: 'QRScannerComponent'
|
||||
})
|
||||
export default class QRScannerComponent extends Vue {
|
||||
async mounted(): Promise<void> {
|
||||
// Initialize camera only when component is mounted
|
||||
await this.initializeCamera();
|
||||
}
|
||||
|
||||
async initializeCamera(): Promise<void> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.cameras = devices.filter(device => device.kind === 'videoinput');
|
||||
this.hasCamera = this.cameras.length > 0;
|
||||
} catch (error) {
|
||||
console.error('Camera initialization failed:', error);
|
||||
this.hasCamera = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Route-Level Lazy Loading
|
||||
|
||||
### Vue Router Configuration
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/HomeView.vue')
|
||||
},
|
||||
{
|
||||
path: '/heavy-feature',
|
||||
name: 'HeavyFeature',
|
||||
component: () => import('@/views/HeavyFeatureView.vue'),
|
||||
// Preload on hover for better UX
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (from.name) {
|
||||
// Preload component when navigating from another route
|
||||
import('@/views/HeavyFeatureView.vue');
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
```
|
||||
|
||||
### Route Guards with Lazy Loading
|
||||
|
||||
```typescript
|
||||
// router/guards.ts
|
||||
export async function lazyLoadGuard(to: any, from: any, next: any): Promise<void> {
|
||||
if (to.meta.requiresHeavyFeature) {
|
||||
try {
|
||||
// Preload heavy feature before navigation
|
||||
await import('@/components/HeavyFeature.vue');
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Failed to load heavy feature:', error);
|
||||
next('/error');
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Library-Level Lazy Loading
|
||||
|
||||
### Dynamic Library Loading
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
name: 'DynamicLibraryLoader'
|
||||
})
|
||||
export default class DynamicLibraryLoader extends Vue {
|
||||
private libraries: Map<string, any> = new Map();
|
||||
|
||||
async loadLibrary(name: string): Promise<any> {
|
||||
if (this.libraries.has(name)) {
|
||||
return this.libraries.get(name);
|
||||
}
|
||||
|
||||
let library: any;
|
||||
|
||||
switch (name) {
|
||||
case 'three':
|
||||
library = await import('three');
|
||||
break;
|
||||
case 'chart':
|
||||
library = await import('chart.js');
|
||||
break;
|
||||
case 'qr':
|
||||
library = await import('jsqr');
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown library: ${name}`);
|
||||
}
|
||||
|
||||
this.libraries.set(name, library);
|
||||
return library;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Library Loading
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
name: 'ConditionalLibraryLoader'
|
||||
})
|
||||
export default class ConditionalLibraryLoader extends Vue {
|
||||
async loadPlatformSpecificLibrary(): Promise<void> {
|
||||
if (process.env.VITE_PLATFORM === 'capacitor') {
|
||||
// Load Capacitor-specific libraries
|
||||
await import('@capacitor/camera');
|
||||
await import('@capacitor/filesystem');
|
||||
} else {
|
||||
// Load web-specific libraries
|
||||
await import('file-saver');
|
||||
await import('jszip');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Bundle Analysis
|
||||
|
||||
```bash
|
||||
# Analyze bundle size
|
||||
npm run build
|
||||
npx vite-bundle-analyzer dist
|
||||
|
||||
# Monitor chunk sizes
|
||||
npx vite-bundle-analyzer dist --mode=treemap
|
||||
```
|
||||
|
||||
### Runtime Performance Monitoring
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
name: 'PerformanceMonitor'
|
||||
})
|
||||
export default class PerformanceMonitor extends Vue {
|
||||
private performanceMetrics = {
|
||||
componentLoadTime: 0,
|
||||
renderTime: 0,
|
||||
memoryUsage: 0
|
||||
};
|
||||
|
||||
private measureComponentLoad(componentName: string): void {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const loadTime = performance.now() - startTime;
|
||||
this.performanceMetrics.componentLoadTime = loadTime;
|
||||
|
||||
console.log(`${componentName} loaded in ${loadTime.toFixed(2)}ms`);
|
||||
|
||||
// Send to analytics
|
||||
this.trackPerformance(componentName, loadTime);
|
||||
};
|
||||
}
|
||||
|
||||
private trackPerformance(component: string, loadTime: number): void {
|
||||
// Send to analytics service
|
||||
if (window.gtag) {
|
||||
window.gtag('event', 'component_load', {
|
||||
component_name: component,
|
||||
load_time: loadTime
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Loading States
|
||||
|
||||
Always provide meaningful loading states:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<LazyComponent />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading {{ componentName }}...</p>
|
||||
<p class="loading-tip">{{ loadingTip }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
Implement comprehensive error handling:
|
||||
|
||||
```typescript
|
||||
const LazyComponent = defineAsyncComponent({
|
||||
loader: () => import('./HeavyComponent.vue'),
|
||||
errorComponent: {
|
||||
template: `
|
||||
<div class="error-state">
|
||||
<h3>Failed to load component</h3>
|
||||
<p>{{ error.message }}</p>
|
||||
<button @click="retry">Retry</button>
|
||||
</div>
|
||||
`,
|
||||
props: ['error'],
|
||||
methods: {
|
||||
retry() {
|
||||
this.$emit('retry');
|
||||
}
|
||||
}
|
||||
},
|
||||
onError(error, retry, fail, attempts) {
|
||||
if (attempts <= 3) {
|
||||
console.warn(`Retrying component load (attempt ${attempts})`);
|
||||
retry();
|
||||
} else {
|
||||
console.error('Component failed to load after 3 attempts');
|
||||
fail();
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Preloading Strategy
|
||||
|
||||
Implement intelligent preloading:
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
name: 'SmartPreloader'
|
||||
})
|
||||
export default class SmartPreloader extends Vue {
|
||||
private preloadQueue: Array<() => Promise<any>> = [];
|
||||
private isPreloading = false;
|
||||
|
||||
// Preload based on user behavior
|
||||
onUserHover(componentLoader: () => Promise<any>): void {
|
||||
this.preloadQueue.push(componentLoader);
|
||||
this.processPreloadQueue();
|
||||
}
|
||||
|
||||
private async processPreloadQueue(): Promise<void> {
|
||||
if (this.isPreloading || this.preloadQueue.length === 0) return;
|
||||
|
||||
this.isPreloading = true;
|
||||
|
||||
while (this.preloadQueue.length > 0) {
|
||||
const loader = this.preloadQueue.shift();
|
||||
if (loader) {
|
||||
try {
|
||||
await loader();
|
||||
} catch (error) {
|
||||
console.warn('Preload failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between preloads
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
this.isPreloading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Bundle Optimization
|
||||
|
||||
Optimize bundle splitting:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id) => {
|
||||
// Group by feature
|
||||
if (id.includes('qr')) return 'qr-feature';
|
||||
if (id.includes('three')) return '3d-feature';
|
||||
if (id.includes('chart')) return 'chart-feature';
|
||||
|
||||
// Group by vendor
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('vue')) return 'vue-vendor';
|
||||
if (id.includes('lodash')) return 'utils-vendor';
|
||||
return 'vendor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Over-Lazy Loading
|
||||
|
||||
❌ **Don't lazy load everything:**
|
||||
```typescript
|
||||
// Bad: Lazy loading small components
|
||||
const SmallButton = defineAsyncComponent(() => import('./SmallButton.vue'));
|
||||
```
|
||||
|
||||
✅ **Do lazy load strategically:**
|
||||
```typescript
|
||||
// Good: Lazy load heavy components only
|
||||
const HeavyDataProcessor = defineAsyncComponent(() => import('./HeavyDataProcessor.vue'));
|
||||
```
|
||||
|
||||
### 2. Missing Loading States
|
||||
|
||||
❌ **Don't leave users hanging:**
|
||||
```vue
|
||||
<template>
|
||||
<LazyComponent v-if="show" />
|
||||
</template>
|
||||
```
|
||||
|
||||
✅ **Do provide loading feedback:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<LazyComponent v-if="show" />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<LoadingSpinner />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. Ignoring Error States
|
||||
|
||||
❌ **Don't ignore loading failures:**
|
||||
```typescript
|
||||
const LazyComponent = defineAsyncComponent(() => import('./Component.vue'));
|
||||
```
|
||||
|
||||
✅ **Do handle errors gracefully:**
|
||||
```typescript
|
||||
const LazyComponent = defineAsyncComponent({
|
||||
loader: () => import('./Component.vue'),
|
||||
errorComponent: ErrorFallback,
|
||||
onError: (error, retry, fail) => {
|
||||
console.error('Component load failed:', error);
|
||||
// Retry once, then fail
|
||||
retry();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Lazy Loading Example
|
||||
|
||||
See the following files for complete working examples:
|
||||
|
||||
1. **`src/components/LazyLoadingExample.vue`** - Main example component
|
||||
2. **`src/components/sub-components/HeavyComponent.vue`** - Data processing component
|
||||
3. **`src/components/sub-components/QRScannerComponent.vue`** - Camera-dependent component
|
||||
4. **`src/components/sub-components/ThreeJSViewer.vue`** - 3D rendering component
|
||||
5. **`vite.config.optimized.mts`** - Optimized Vite configuration
|
||||
|
||||
### Usage Example
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="app">
|
||||
<h1>Lazy Loading Demo</h1>
|
||||
|
||||
<LazyLoadingExample
|
||||
:initial-load-heavy="false"
|
||||
@qr-detected="handleQRCode"
|
||||
@model-loaded="handleModelLoaded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-facing-decorator';
|
||||
import LazyLoadingExample from '@/components/LazyLoadingExample.vue';
|
||||
|
||||
@Component({
|
||||
name: 'App',
|
||||
components: {
|
||||
LazyLoadingExample
|
||||
}
|
||||
})
|
||||
export default class App extends Vue {
|
||||
handleQRCode(data: string): void {
|
||||
console.log('QR code detected:', data);
|
||||
}
|
||||
|
||||
handleModelLoaded(info: any): void {
|
||||
console.log('3D model loaded:', info);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Lazy loading with Vue 3 + Vite + `vue-facing-decorator` provides powerful tools for optimizing application performance. By implementing these patterns strategically, you can significantly improve initial load times while maintaining excellent user experience.
|
||||
|
||||
Remember to:
|
||||
- Use lazy loading for heavy components and features
|
||||
- Provide meaningful loading and error states
|
||||
- Monitor performance and bundle sizes
|
||||
- Implement intelligent preloading strategies
|
||||
- Handle errors gracefully
|
||||
|
||||
For more information, see the Vue 3 documentation on [Async Components](https://vuejs.org/guide/components/async.html) and the Vite documentation on [Code Splitting](https://vitejs.dev/guide/features.html#code-splitting).
|
||||
@@ -60,7 +60,6 @@ These components are static UI elements, help pages, or simple components that d
|
||||
- **Help pages**: `HelpNotificationTypesView.vue`, `HelpOnboardingView.vue`
|
||||
- **Static views**: `StatisticsView.vue`, `QuickActionBvcView.vue`
|
||||
- **UI components**: `ChoiceButtonDialog.vue`, `EntitySummaryButton.vue`
|
||||
- **Sub-components**: `HeavyComponent.vue`, `QRScannerComponent.vue`, `ThreeJSViewer.vue`
|
||||
- **Utility components**: `PWAInstallPrompt.vue`, `HiddenDidDialog.vue`
|
||||
|
||||
#### 🔄 **Remaining Legacy Patterns (4 files)**
|
||||
|
||||
@@ -1,554 +0,0 @@
|
||||
<template>
|
||||
<div class="heavy-component">
|
||||
<h2>Heavy Data Processing Component</h2>
|
||||
|
||||
<!-- Data processing controls -->
|
||||
<div class="controls">
|
||||
<button :disabled="isProcessing" @click="processData">
|
||||
{{ isProcessing ? "Processing..." : "Process Data" }}
|
||||
</button>
|
||||
<button :disabled="isProcessing" @click="clearResults">
|
||||
Clear Results
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Processing status -->
|
||||
<div v-if="isProcessing" class="processing-status">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
<p>Processing {{ processedCount }} of {{ totalItems }} items...</p>
|
||||
</div>
|
||||
|
||||
<!-- Results display -->
|
||||
<div v-if="processedData.length > 0" class="results">
|
||||
<h3>Processed Results ({{ processedData.length }} items)</h3>
|
||||
|
||||
<!-- Filter controls -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
placeholder="Search items..."
|
||||
class="search-input"
|
||||
/>
|
||||
<select v-model="sortBy" class="sort-select">
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="id">Sort by ID</option>
|
||||
<option value="processed">Sort by Processed Date</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Results list -->
|
||||
<div class="results-list">
|
||||
<div
|
||||
v-for="item in filteredAndSortedData"
|
||||
:key="item.id"
|
||||
class="result-item"
|
||||
>
|
||||
<div class="item-header">
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<span class="item-id">#{{ item.id }}</span>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<span class="processed-date">
|
||||
Processed: {{ formatDate(item.processedAt) }}
|
||||
</span>
|
||||
<span class="processing-time">
|
||||
Time: {{ item.processingTime }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
class="page-btn"
|
||||
@click="previousPage"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage }} of {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
class="page-btn"
|
||||
@click="nextPage"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance metrics -->
|
||||
<div v-if="performanceMetrics" class="performance-metrics">
|
||||
<h4>Performance Metrics</h4>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Total Processing Time:</span>
|
||||
<span class="metric-value">{{ performanceMetrics.totalTime }}ms</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Average per Item:</span>
|
||||
<span class="metric-value"
|
||||
>{{ performanceMetrics.averageTime }}ms</span
|
||||
>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Memory Usage:</span>
|
||||
<span class="metric-value"
|
||||
>{{ performanceMetrics.memoryUsage }}MB</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||
|
||||
interface ProcessedItem {
|
||||
id: number;
|
||||
name: string;
|
||||
processedAt: Date;
|
||||
processingTime: number;
|
||||
result: any;
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
totalTime: number;
|
||||
averageTime: number;
|
||||
memoryUsage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heavy Component for Data Processing
|
||||
*
|
||||
* Demonstrates a component that performs intensive data processing
|
||||
* and would benefit from lazy loading to avoid blocking the main thread.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@Component({
|
||||
name: "HeavyComponent",
|
||||
})
|
||||
export default class HeavyComponent extends Vue {
|
||||
@Prop({ required: true }) readonly data!: {
|
||||
items: Array<{ id: number; name: string }>;
|
||||
filters: Record<string, any>;
|
||||
sortBy: string;
|
||||
};
|
||||
|
||||
// Component state
|
||||
isProcessing = false;
|
||||
processedData: ProcessedItem[] = [];
|
||||
progress = 0;
|
||||
processedCount = 0;
|
||||
totalItems = 0;
|
||||
|
||||
// UI state
|
||||
searchTerm = "";
|
||||
sortBy = "name";
|
||||
currentPage = 1;
|
||||
itemsPerPage = 50;
|
||||
|
||||
// Performance tracking
|
||||
performanceMetrics: PerformanceMetrics | null = null;
|
||||
startTime = 0;
|
||||
|
||||
// Computed properties
|
||||
get filteredAndSortedData(): ProcessedItem[] {
|
||||
let filtered = this.processedData;
|
||||
|
||||
// Apply search filter
|
||||
if (this.searchTerm) {
|
||||
filtered = filtered.filter((item) =>
|
||||
item.name.toLowerCase().includes(this.searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
switch (this.sortBy) {
|
||||
case "name":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "id":
|
||||
return a.id - b.id;
|
||||
case "processed":
|
||||
return b.processedAt.getTime() - a.processedAt.getTime();
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
get paginatedData(): ProcessedItem[] {
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||
const end = start + this.itemsPerPage;
|
||||
return this.filteredAndSortedData.slice(start, end);
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.ceil(this.filteredAndSortedData.length / this.itemsPerPage);
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
mounted(): void {
|
||||
console.log(
|
||||
"[HeavyComponent] Component mounted with",
|
||||
this.data.items.length,
|
||||
"items",
|
||||
);
|
||||
this.totalItems = this.data.items.length;
|
||||
}
|
||||
|
||||
// Methods
|
||||
async processData(): Promise<void> {
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
this.progress = 0;
|
||||
this.processedCount = 0;
|
||||
this.processedData = [];
|
||||
this.startTime = performance.now();
|
||||
|
||||
console.log("[HeavyComponent] Starting data processing...");
|
||||
|
||||
try {
|
||||
// Process items in batches to avoid blocking the UI
|
||||
const batchSize = 10;
|
||||
const items = this.data.items;
|
||||
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
|
||||
// Process batch
|
||||
await this.processBatch(batch);
|
||||
|
||||
// Update progress
|
||||
this.processedCount = Math.min(i + batchSize, items.length);
|
||||
this.progress = (this.processedCount / items.length) * 100;
|
||||
|
||||
// Allow UI to update
|
||||
await this.$nextTick();
|
||||
|
||||
// Small delay to prevent overwhelming the UI
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// Calculate performance metrics
|
||||
this.calculatePerformanceMetrics();
|
||||
|
||||
// Emit completion event
|
||||
this.$emit("data-processed", {
|
||||
totalItems: this.processedData.length,
|
||||
processingTime: performance.now() - this.startTime,
|
||||
metrics: this.performanceMetrics,
|
||||
});
|
||||
|
||||
console.log("[HeavyComponent] Data processing completed");
|
||||
} catch (error) {
|
||||
console.error("[HeavyComponent] Processing error:", error);
|
||||
this.$emit("processing-error", error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processBatch(
|
||||
batch: Array<{ id: number; name: string }>,
|
||||
): Promise<void> {
|
||||
const processedBatch = await Promise.all(
|
||||
batch.map(async (item) => {
|
||||
const itemStartTime = performance.now();
|
||||
|
||||
// Simulate heavy processing
|
||||
await this.simulateHeavyProcessing(item);
|
||||
|
||||
const processingTime = performance.now() - itemStartTime;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
processedAt: new Date(),
|
||||
processingTime: Math.round(processingTime),
|
||||
result: this.generateResult(item),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
this.processedData.push(...processedBatch);
|
||||
}
|
||||
|
||||
private async simulateHeavyProcessing(item: {
|
||||
id: number;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
// Simulate CPU-intensive work
|
||||
const complexity = item.name.length * item.id;
|
||||
const iterations = Math.min(complexity, 1000); // Cap at 1000 iterations
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// Simulate work
|
||||
Math.sqrt(i) * Math.random();
|
||||
}
|
||||
|
||||
// Simulate async work
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
|
||||
}
|
||||
|
||||
private generateResult(item: { id: number; name: string }): any {
|
||||
return {
|
||||
hash: this.generateHash(item.name + item.id),
|
||||
category: this.categorizeItem(item),
|
||||
score: Math.random() * 100,
|
||||
tags: this.generateTags(item),
|
||||
};
|
||||
}
|
||||
|
||||
private generateHash(input: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
|
||||
private categorizeItem(item: { id: number; name: string }): string {
|
||||
const categories = ["A", "B", "C", "D", "E"];
|
||||
return categories[item.id % categories.length];
|
||||
}
|
||||
|
||||
private generateTags(item: { id: number; name: string }): string[] {
|
||||
const tags = ["important", "urgent", "review", "archive", "featured"];
|
||||
return tags.filter((_, index) => (item.id + index) % 3 === 0);
|
||||
}
|
||||
|
||||
private calculatePerformanceMetrics(): void {
|
||||
const totalTime = performance.now() - this.startTime;
|
||||
const averageTime = totalTime / this.processedData.length;
|
||||
|
||||
// Simulate memory usage calculation
|
||||
const memoryUsage = this.processedData.length * 0.1; // 0.1MB per item
|
||||
|
||||
this.performanceMetrics = {
|
||||
totalTime: Math.round(totalTime),
|
||||
averageTime: Math.round(averageTime),
|
||||
memoryUsage: Math.round(memoryUsage * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
clearResults(): void {
|
||||
this.processedData = [];
|
||||
this.performanceMetrics = null;
|
||||
this.searchTerm = "";
|
||||
this.currentPage = 1;
|
||||
console.log("[HeavyComponent] Results cleared");
|
||||
}
|
||||
|
||||
previousPage(): void {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(date: Date): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// Event emitters
|
||||
@Emit("data-processed")
|
||||
emitDataProcessed(data: any): any {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Emit("processing-error")
|
||||
emitProcessingError(error: Error): Error {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heavy-component {
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.controls button:hover:not(:disabled) {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.controls button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.processing-status {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #007bff, #0056b3);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.sort-select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.item-id {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.performance-metrics {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #e8f4fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: #007bff;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -1,721 +0,0 @@
|
||||
<template>
|
||||
<div class="qr-scanner-component">
|
||||
<h2>QR Code Scanner</h2>
|
||||
|
||||
<!-- Camera controls -->
|
||||
<div class="camera-controls">
|
||||
<button :disabled="isScanning || !hasCamera" @click="startScanning">
|
||||
{{ isScanning ? "Scanning..." : "Start Scanning" }}
|
||||
</button>
|
||||
<button :disabled="!isScanning" @click="stopScanning">
|
||||
Stop Scanning
|
||||
</button>
|
||||
<button
|
||||
:disabled="!isScanning || cameras.length <= 1"
|
||||
@click="switchCamera"
|
||||
>
|
||||
Switch Camera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Camera status -->
|
||||
<div class="camera-status">
|
||||
<div v-if="!hasCamera" class="status-error">
|
||||
<p>Camera not available</p>
|
||||
<p class="status-detail">
|
||||
This device doesn't have a camera or camera access is denied.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isScanning" class="status-info">
|
||||
<p>Camera ready</p>
|
||||
<p class="status-detail">
|
||||
Click "Start Scanning" to begin QR code detection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="status-scanning">
|
||||
<p>Scanning for QR codes...</p>
|
||||
<p class="status-detail">Point camera at a QR code to scan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera view -->
|
||||
<div v-if="isScanning && hasCamera" class="camera-container">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="camera-video"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
|
||||
<!-- Scanning overlay -->
|
||||
<div class="scanning-overlay">
|
||||
<div class="scan-frame"></div>
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan results -->
|
||||
<div v-if="scanResults.length > 0" class="scan-results">
|
||||
<h3>Scan Results ({{ scanResults.length }})</h3>
|
||||
|
||||
<div class="results-list">
|
||||
<div
|
||||
v-for="(result, index) in scanResults"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
>
|
||||
<div class="result-header">
|
||||
<span class="result-number">#{{ index + 1 }}</span>
|
||||
<span class="result-time">{{ formatTime(result.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="qr-data"><strong>Data:</strong> {{ result.data }}</div>
|
||||
<div class="qr-format">
|
||||
<strong>Format:</strong> {{ result.format }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button class="copy-btn" @click="copyToClipboard(result.data)">
|
||||
Copy
|
||||
</button>
|
||||
<button class="remove-btn" @click="removeResult(index)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-actions">
|
||||
<button class="clear-btn" @click="clearResults">
|
||||
Clear All Results
|
||||
</button>
|
||||
<button class="export-btn" @click="exportResults">
|
||||
Export Results
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel -->
|
||||
<div class="settings-panel">
|
||||
<h3>Scanner Settings</h3>
|
||||
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input v-model="settings.continuousScanning" type="checkbox" />
|
||||
Continuous Scanning
|
||||
</label>
|
||||
<p class="setting-description">
|
||||
Automatically scan multiple QR codes without stopping
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input v-model="settings.audioFeedback" type="checkbox" />
|
||||
Audio Feedback
|
||||
</label>
|
||||
<p class="setting-description">Play sound when QR code is detected</p>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input v-model="settings.vibrateOnScan" type="checkbox" />
|
||||
Vibration Feedback
|
||||
</label>
|
||||
<p class="setting-description">
|
||||
Vibrate device when QR code is detected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label>Scan Interval (ms):</label>
|
||||
<input
|
||||
v-model.number="settings.scanInterval"
|
||||
type="number"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
/>
|
||||
<p class="setting-description">
|
||||
Time between scans (lower = faster, higher = more accurate)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Emit } from "vue-facing-decorator";
|
||||
|
||||
interface ScanResult {
|
||||
data: string;
|
||||
format: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ScannerSettings {
|
||||
continuousScanning: boolean;
|
||||
audioFeedback: boolean;
|
||||
vibrateOnScan: boolean;
|
||||
scanInterval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* QR Scanner Component
|
||||
*
|
||||
* Demonstrates lazy loading for camera-dependent features.
|
||||
* This component would benefit from lazy loading as it requires
|
||||
* camera permissions and heavy camera processing libraries.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@Component({
|
||||
name: "QRScannerComponent",
|
||||
})
|
||||
export default class QRScannerComponent extends Vue {
|
||||
// Component state
|
||||
isScanning = false;
|
||||
hasCamera = false;
|
||||
cameras: MediaDeviceInfo[] = [];
|
||||
currentCameraIndex = 0;
|
||||
|
||||
// Video element reference
|
||||
videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
// Scan results
|
||||
scanResults: ScanResult[] = [];
|
||||
|
||||
// Scanner settings
|
||||
settings: ScannerSettings = {
|
||||
continuousScanning: true,
|
||||
audioFeedback: true,
|
||||
vibrateOnScan: true,
|
||||
scanInterval: 500,
|
||||
};
|
||||
|
||||
// Internal state
|
||||
private stream: MediaStream | null = null;
|
||||
private scanInterval: number | null = null;
|
||||
private lastScanTime = 0;
|
||||
|
||||
// Lifecycle hooks
|
||||
async mounted(): Promise<void> {
|
||||
console.log("[QRScannerComponent] Component mounted");
|
||||
await this.initializeCamera();
|
||||
}
|
||||
|
||||
beforeUnmount(): void {
|
||||
this.stopScanning();
|
||||
console.log("[QRScannerComponent] Component unmounting");
|
||||
}
|
||||
|
||||
// Methods
|
||||
async initializeCamera(): Promise<void> {
|
||||
try {
|
||||
// Check if camera is available
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.cameras = devices.filter((device) => device.kind === "videoinput");
|
||||
this.hasCamera = this.cameras.length > 0;
|
||||
|
||||
if (this.hasCamera) {
|
||||
console.log(
|
||||
"[QRScannerComponent] Camera available:",
|
||||
this.cameras.length,
|
||||
"devices",
|
||||
);
|
||||
} else {
|
||||
console.warn("[QRScannerComponent] No camera devices found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[QRScannerComponent] Camera initialization error:", error);
|
||||
this.hasCamera = false;
|
||||
}
|
||||
}
|
||||
|
||||
async startScanning(): Promise<void> {
|
||||
if (!this.hasCamera || this.isScanning) return;
|
||||
|
||||
try {
|
||||
console.log("[QRScannerComponent] Starting QR scanning...");
|
||||
|
||||
// Get camera stream
|
||||
const constraints = {
|
||||
video: {
|
||||
deviceId: this.cameras[this.currentCameraIndex]?.deviceId,
|
||||
},
|
||||
};
|
||||
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
|
||||
// Set up video element
|
||||
this.videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
if (this.videoElement) {
|
||||
this.videoElement.srcObject = this.stream;
|
||||
await this.videoElement.play();
|
||||
}
|
||||
|
||||
this.isScanning = true;
|
||||
|
||||
// Start QR code detection
|
||||
this.startQRDetection();
|
||||
|
||||
console.log("[QRScannerComponent] QR scanning started");
|
||||
} catch (error) {
|
||||
console.error("[QRScannerComponent] Failed to start scanning:", error);
|
||||
this.hasCamera = false;
|
||||
}
|
||||
}
|
||||
|
||||
stopScanning(): void {
|
||||
if (!this.isScanning) return;
|
||||
|
||||
console.log("[QRScannerComponent] Stopping QR scanning...");
|
||||
|
||||
// Stop QR detection
|
||||
this.stopQRDetection();
|
||||
|
||||
// Stop camera stream
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
// Clear video element
|
||||
if (this.videoElement) {
|
||||
this.videoElement.srcObject = null;
|
||||
this.videoElement = null;
|
||||
}
|
||||
|
||||
this.isScanning = false;
|
||||
console.log("[QRScannerComponent] QR scanning stopped");
|
||||
}
|
||||
|
||||
async switchCamera(): Promise<void> {
|
||||
if (this.cameras.length <= 1) return;
|
||||
|
||||
// Stop current scanning
|
||||
this.stopScanning();
|
||||
|
||||
// Switch to next camera
|
||||
this.currentCameraIndex =
|
||||
(this.currentCameraIndex + 1) % this.cameras.length;
|
||||
|
||||
// Restart scanning with new camera
|
||||
await this.startScanning();
|
||||
|
||||
console.log(
|
||||
"[QRScannerComponent] Switched to camera:",
|
||||
this.currentCameraIndex,
|
||||
);
|
||||
}
|
||||
|
||||
private startQRDetection(): void {
|
||||
if (!this.settings.continuousScanning) return;
|
||||
|
||||
this.scanInterval = window.setInterval(() => {
|
||||
this.detectQRCode();
|
||||
}, this.settings.scanInterval);
|
||||
}
|
||||
|
||||
private stopQRDetection(): void {
|
||||
if (this.scanInterval) {
|
||||
clearInterval(this.scanInterval);
|
||||
this.scanInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async detectQRCode(): Promise<void> {
|
||||
if (!this.videoElement || !this.isScanning) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastScanTime < this.settings.scanInterval) return;
|
||||
|
||||
try {
|
||||
// Simulate QR code detection
|
||||
// In a real implementation, you would use a QR code library like jsQR
|
||||
const detectedQR = await this.simulateQRDetection();
|
||||
|
||||
if (detectedQR) {
|
||||
this.addScanResult(detectedQR);
|
||||
this.lastScanTime = now;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[QRScannerComponent] QR detection error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async simulateQRDetection(): Promise<ScanResult | null> {
|
||||
// Simulate QR code detection with random chance
|
||||
if (Math.random() < 0.1) {
|
||||
// 10% chance of detection
|
||||
const sampleData = [
|
||||
"https://example.com/qr1",
|
||||
"WIFI:S:MyNetwork;T:WPA;P:password123;;",
|
||||
"BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nTEL:+1234567890\nEND:VCARD",
|
||||
"otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
];
|
||||
|
||||
const formats = ["URL", "WiFi", "vCard", "TOTP"];
|
||||
const randomIndex = Math.floor(Math.random() * sampleData.length);
|
||||
|
||||
return {
|
||||
data: sampleData[randomIndex],
|
||||
format: formats[randomIndex],
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private addScanResult(result: ScanResult): void {
|
||||
// Check for duplicates
|
||||
const isDuplicate = this.scanResults.some(
|
||||
(existing) => existing.data === result.data,
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
this.scanResults.unshift(result);
|
||||
|
||||
// Provide feedback
|
||||
this.provideFeedback();
|
||||
|
||||
// Emit event
|
||||
this.$emit("qr-detected", result.data);
|
||||
|
||||
console.log("[QRScannerComponent] QR code detected:", result.data);
|
||||
}
|
||||
}
|
||||
|
||||
private provideFeedback(): void {
|
||||
// Audio feedback
|
||||
if (this.settings.audioFeedback) {
|
||||
this.playBeepSound();
|
||||
}
|
||||
|
||||
// Vibration feedback
|
||||
if (this.settings.vibrateOnScan && "vibrate" in navigator) {
|
||||
navigator.vibrate(100);
|
||||
}
|
||||
}
|
||||
|
||||
private playBeepSound(): void {
|
||||
// Create a simple beep sound
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
||||
oscillator.type = "sine";
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 0.1,
|
||||
);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.1);
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
console.log("[QRScannerComponent] Copied to clipboard:", text);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[QRScannerComponent] Failed to copy:", error);
|
||||
});
|
||||
}
|
||||
|
||||
removeResult(index: number): void {
|
||||
this.scanResults.splice(index, 1);
|
||||
}
|
||||
|
||||
clearResults(): void {
|
||||
this.scanResults = [];
|
||||
console.log("[QRScannerComponent] Results cleared");
|
||||
}
|
||||
|
||||
exportResults(): void {
|
||||
const data = JSON.stringify(this.scanResults, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `qr-scan-results-${new Date().toISOString().split("T")[0]}.json`;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
console.log("[QRScannerComponent] Results exported");
|
||||
}
|
||||
|
||||
formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Event emitters
|
||||
@Emit("qr-detected")
|
||||
emitQRDetected(data: string): string {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qr-scanner-component {
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.camera-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.camera-controls button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.camera-controls button:hover:not(:disabled) {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.camera-controls button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.camera-status {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.status-scanning {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
margin-top: 5px;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 300px;
|
||||
margin: 20px auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.scanning-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scan-frame {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 2px solid #00ff00;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #00ff00;
|
||||
animation: scan 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% {
|
||||
top: 0;
|
||||
}
|
||||
100% {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-number {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.result-time {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.qr-data,
|
||||
.qr-format {
|
||||
margin-bottom: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copy-btn,
|
||||
.remove-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clear-btn,
|
||||
.export-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.setting-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.setting-group input[type="number"] {
|
||||
width: 100px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-top: 3px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,667 +0,0 @@
|
||||
<template>
|
||||
<div class="threejs-viewer">
|
||||
<h2>3D Model Viewer</h2>
|
||||
|
||||
<!-- Viewer controls -->
|
||||
<div class="viewer-controls">
|
||||
<button :disabled="isLoading || !modelUrl" @click="loadModel">
|
||||
{{ isLoading ? "Loading..." : "Load Model" }}
|
||||
</button>
|
||||
<button :disabled="!isModelLoaded" @click="resetCamera">
|
||||
Reset Camera
|
||||
</button>
|
||||
<button :disabled="!isModelLoaded" @click="toggleAnimation">
|
||||
{{ isAnimating ? "Stop" : "Start" }} Animation
|
||||
</button>
|
||||
<button :disabled="!isModelLoaded" @click="toggleWireframe">
|
||||
{{ showWireframe ? "Hide" : "Show" }} Wireframe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading status -->
|
||||
<div v-if="isLoading" class="loading-status">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading 3D model...</p>
|
||||
<p class="loading-detail">{{ loadingProgress }}% complete</p>
|
||||
</div>
|
||||
|
||||
<!-- Error status -->
|
||||
<div v-if="loadError" class="error-status">
|
||||
<p>Failed to load model: {{ loadError }}</p>
|
||||
<button class="retry-btn" @click="retryLoad">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- 3D Canvas -->
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="canvas-container"
|
||||
:class="{ 'model-loaded': isModelLoaded }"
|
||||
>
|
||||
<canvas ref="threeCanvas" class="three-canvas"></canvas>
|
||||
|
||||
<!-- Overlay controls -->
|
||||
<div v-if="isModelLoaded" class="overlay-controls">
|
||||
<div class="control-group">
|
||||
<label>Camera Distance:</label>
|
||||
<input
|
||||
v-model.number="cameraDistance"
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
step="0.1"
|
||||
@input="updateCameraDistance"
|
||||
/>
|
||||
<span>{{ cameraDistance.toFixed(1) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Rotation Speed:</label>
|
||||
<input
|
||||
v-model.number="rotationSpeed"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
/>
|
||||
<span>{{ rotationSpeed.toFixed(1) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Light Intensity:</label>
|
||||
<input
|
||||
v-model.number="lightIntensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
@input="updateLightIntensity"
|
||||
/>
|
||||
<span>{{ lightIntensity.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model info -->
|
||||
<div v-if="modelInfo" class="model-info">
|
||||
<h4>Model Information</h4>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Vertices:</span>
|
||||
<span class="info-value">{{
|
||||
modelInfo.vertexCount.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Faces:</span>
|
||||
<span class="info-value">{{
|
||||
modelInfo.faceCount.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Materials:</span>
|
||||
<span class="info-value">{{ modelInfo.materialCount }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">File Size:</span>
|
||||
<span class="info-value">{{
|
||||
formatFileSize(modelInfo.fileSize)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance metrics -->
|
||||
<div v-if="performanceMetrics" class="performance-metrics">
|
||||
<h4>Performance Metrics</h4>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<span class="metric-label">FPS:</span>
|
||||
<span class="metric-value">{{ performanceMetrics.fps }}</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Render Time:</span>
|
||||
<span class="metric-value"
|
||||
>{{ performanceMetrics.renderTime }}ms</span
|
||||
>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Memory Usage:</span>
|
||||
<span class="metric-value"
|
||||
>{{ performanceMetrics.memoryUsage }}MB</span
|
||||
>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Draw Calls:</span>
|
||||
<span class="metric-value">{{ performanceMetrics.drawCalls }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||
|
||||
interface ModelInfo {
|
||||
vertexCount: number;
|
||||
faceCount: number;
|
||||
materialCount: number;
|
||||
fileSize: number;
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number };
|
||||
max: { x: number; y: number; z: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
fps: number;
|
||||
renderTime: number;
|
||||
memoryUsage: number;
|
||||
drawCalls: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThreeJS 3D Model Viewer Component
|
||||
*
|
||||
* Demonstrates lazy loading for heavy 3D rendering libraries.
|
||||
* This component would benefit from lazy loading as ThreeJS is a large
|
||||
* library that's only needed for 3D visualization features.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@Component({
|
||||
name: "ThreeJSViewer",
|
||||
})
|
||||
export default class ThreeJSViewer extends Vue {
|
||||
@Prop({ required: true }) readonly modelUrl!: string;
|
||||
|
||||
// Component state
|
||||
isLoading = false;
|
||||
isModelLoaded = false;
|
||||
loadError: string | null = null;
|
||||
loadingProgress = 0;
|
||||
|
||||
// Animation state
|
||||
isAnimating = false;
|
||||
showWireframe = false;
|
||||
|
||||
// Camera and lighting controls
|
||||
cameraDistance = 5;
|
||||
rotationSpeed = 0.5;
|
||||
lightIntensity = 1;
|
||||
|
||||
// Canvas references
|
||||
canvasContainer: HTMLElement | null = null;
|
||||
threeCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
// Model and performance data
|
||||
modelInfo: ModelInfo | null = null;
|
||||
performanceMetrics: PerformanceMetrics | null = null;
|
||||
|
||||
// ThreeJS objects (will be lazy loaded)
|
||||
private three: any = null;
|
||||
private scene: any = null;
|
||||
private camera: any = null;
|
||||
private renderer: any = null;
|
||||
private model: any = null;
|
||||
private controls: any = null;
|
||||
private animationId: number | null = null;
|
||||
private frameCount = 0;
|
||||
private lastTime = 0;
|
||||
|
||||
// Lifecycle hooks
|
||||
mounted(): void {
|
||||
console.log("[ThreeJSViewer] Component mounted");
|
||||
this.initializeCanvas();
|
||||
}
|
||||
|
||||
beforeUnmount(): void {
|
||||
this.cleanup();
|
||||
console.log("[ThreeJSViewer] Component unmounting");
|
||||
}
|
||||
|
||||
// Methods
|
||||
private initializeCanvas(): void {
|
||||
this.canvasContainer = this.$refs.canvasContainer as HTMLElement;
|
||||
this.threeCanvas = this.$refs.threeCanvas as HTMLCanvasElement;
|
||||
|
||||
if (this.threeCanvas) {
|
||||
this.threeCanvas.width = this.canvasContainer.clientWidth;
|
||||
this.threeCanvas.height = this.canvasContainer.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async loadModel(): Promise<void> {
|
||||
if (this.isLoading || !this.modelUrl) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.loadError = null;
|
||||
this.loadingProgress = 0;
|
||||
|
||||
try {
|
||||
console.log("[ThreeJSViewer] Loading 3D model:", this.modelUrl);
|
||||
|
||||
// Lazy load ThreeJS
|
||||
await this.loadThreeJS();
|
||||
|
||||
// Initialize scene
|
||||
await this.initializeScene();
|
||||
|
||||
// Load model
|
||||
await this.loadModelFile();
|
||||
|
||||
// Start rendering
|
||||
this.startRendering();
|
||||
|
||||
this.isModelLoaded = true;
|
||||
this.isLoading = false;
|
||||
|
||||
// Emit model loaded event
|
||||
this.$emit("model-loaded", this.modelInfo);
|
||||
|
||||
console.log("[ThreeJSViewer] Model loaded successfully");
|
||||
} catch (error) {
|
||||
console.error("[ThreeJSViewer] Failed to load model:", error);
|
||||
this.loadError = error instanceof Error ? error.message : "Unknown error";
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadThreeJS(): Promise<void> {
|
||||
// Simulate loading ThreeJS library
|
||||
this.loadingProgress = 20;
|
||||
await this.simulateLoading(500);
|
||||
|
||||
// In a real implementation, you would import ThreeJS here
|
||||
// this.three = await import('three');
|
||||
|
||||
this.loadingProgress = 40;
|
||||
await this.simulateLoading(300);
|
||||
}
|
||||
|
||||
private async initializeScene(): Promise<void> {
|
||||
this.loadingProgress = 60;
|
||||
|
||||
// Simulate scene initialization
|
||||
await this.simulateLoading(400);
|
||||
|
||||
// In a real implementation, you would set up ThreeJS scene here
|
||||
// this.scene = new this.three.Scene();
|
||||
// this.camera = new this.three.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
// this.renderer = new this.three.WebGLRenderer({ canvas: this.threeCanvas });
|
||||
|
||||
this.loadingProgress = 80;
|
||||
}
|
||||
|
||||
private async loadModelFile(): Promise<void> {
|
||||
this.loadingProgress = 90;
|
||||
|
||||
// Simulate model loading
|
||||
await this.simulateLoading(600);
|
||||
|
||||
// Simulate model info
|
||||
this.modelInfo = {
|
||||
vertexCount: Math.floor(Math.random() * 50000) + 1000,
|
||||
faceCount: Math.floor(Math.random() * 25000) + 500,
|
||||
materialCount: Math.floor(Math.random() * 5) + 1,
|
||||
fileSize: Math.floor(Math.random() * 5000000) + 100000,
|
||||
boundingBox: {
|
||||
min: { x: -1, y: -1, z: -1 },
|
||||
max: { x: 1, y: 1, z: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
this.loadingProgress = 100;
|
||||
}
|
||||
|
||||
private async simulateLoading(delay: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
private startRendering(): void {
|
||||
if (!this.isModelLoaded) return;
|
||||
|
||||
this.isAnimating = true;
|
||||
this.animate();
|
||||
|
||||
// Start performance monitoring
|
||||
this.startPerformanceMonitoring();
|
||||
}
|
||||
|
||||
private animate(): void {
|
||||
if (!this.isAnimating) return;
|
||||
|
||||
this.animationId = requestAnimationFrame(() => this.animate());
|
||||
|
||||
// Simulate model rotation
|
||||
if (this.model && this.rotationSpeed > 0) {
|
||||
// this.model.rotation.y += this.rotationSpeed * 0.01;
|
||||
}
|
||||
|
||||
// Simulate rendering
|
||||
// this.renderer.render(this.scene, this.camera);
|
||||
|
||||
this.frameCount++;
|
||||
}
|
||||
|
||||
private startPerformanceMonitoring(): void {
|
||||
const updateMetrics = () => {
|
||||
if (!this.isAnimating) return;
|
||||
|
||||
const now = performance.now();
|
||||
const deltaTime = now - this.lastTime;
|
||||
|
||||
if (deltaTime > 0) {
|
||||
const fps = Math.round(1000 / deltaTime);
|
||||
|
||||
this.performanceMetrics = {
|
||||
fps: Math.min(fps, 60), // Cap at 60 FPS for display
|
||||
renderTime: Math.round(deltaTime),
|
||||
memoryUsage: Math.round((Math.random() * 50 + 10) * 100) / 100,
|
||||
drawCalls: Math.floor(Math.random() * 100) + 10,
|
||||
};
|
||||
}
|
||||
|
||||
this.lastTime = now;
|
||||
requestAnimationFrame(updateMetrics);
|
||||
};
|
||||
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
resetCamera(): void {
|
||||
if (!this.isModelLoaded) return;
|
||||
|
||||
this.cameraDistance = 5;
|
||||
this.updateCameraDistance();
|
||||
console.log("[ThreeJSViewer] Camera reset");
|
||||
}
|
||||
|
||||
toggleAnimation(): void {
|
||||
this.isAnimating = !this.isAnimating;
|
||||
|
||||
if (this.isAnimating) {
|
||||
this.animate();
|
||||
} else if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
|
||||
console.log("[ThreeJSViewer] Animation toggled:", this.isAnimating);
|
||||
}
|
||||
|
||||
toggleWireframe(): void {
|
||||
this.showWireframe = !this.showWireframe;
|
||||
|
||||
// In a real implementation, you would toggle wireframe mode
|
||||
// this.model.traverse((child: any) => {
|
||||
// if (child.isMesh) {
|
||||
// child.material.wireframe = this.showWireframe;
|
||||
// }
|
||||
// });
|
||||
|
||||
console.log("[ThreeJSViewer] Wireframe toggled:", this.showWireframe);
|
||||
}
|
||||
|
||||
updateCameraDistance(): void {
|
||||
if (!this.isModelLoaded) return;
|
||||
|
||||
// In a real implementation, you would update camera position
|
||||
// this.camera.position.z = this.cameraDistance;
|
||||
// this.camera.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
updateLightIntensity(): void {
|
||||
if (!this.isModelLoaded) return;
|
||||
|
||||
// In a real implementation, you would update light intensity
|
||||
// this.light.intensity = this.lightIntensity;
|
||||
}
|
||||
|
||||
retryLoad(): void {
|
||||
this.loadError = null;
|
||||
this.loadModel();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
}
|
||||
|
||||
this.isAnimating = false;
|
||||
this.isModelLoaded = false;
|
||||
}
|
||||
|
||||
formatFileSize(bytes: number): string {
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// Event emitters
|
||||
@Emit("model-loaded")
|
||||
emitModelLoaded(info: ModelInfo): ModelInfo {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.threejs-viewer {
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.viewer-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer-controls button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.viewer-controls button:hover:not(:disabled) {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.viewer-controls button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-detail {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.error-status {
|
||||
padding: 20px;
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
color: #721c24;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 10px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #721c24;
|
||||
border-radius: 4px;
|
||||
background: #721c24;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #5a1a1a;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.canvas-container.model-loaded {
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.three-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.control-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.control-group input[type="range"] {
|
||||
width: 100%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.control-group span {
|
||||
font-size: 0.8em;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.model-info h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.performance-metrics {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #e8f4fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: #007bff;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -1,70 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@nostr/tools': path.resolve(__dirname, 'node_modules/@nostr/tools'),
|
||||
'@nostr/tools/nip06': path.resolve(__dirname, 'node_modules/@nostr/tools/nip06'),
|
||||
stream: 'stream-browserify',
|
||||
util: 'util',
|
||||
crypto: 'crypto-browserify',
|
||||
assert: 'assert/',
|
||||
http: 'stream-http',
|
||||
https: 'https-browserify',
|
||||
url: 'url/',
|
||||
zlib: 'browserify-zlib',
|
||||
path: 'path-browserify',
|
||||
fs: false,
|
||||
tty: 'tty-browserify',
|
||||
net: false,
|
||||
dns: false,
|
||||
child_process: false,
|
||||
os: false
|
||||
},
|
||||
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@nostr/tools', '@nostr/tools/nip06', '@jlongster/sql.js'],
|
||||
esbuildOptions: {
|
||||
define: {
|
||||
global: 'globalThis'
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
target: 'esnext',
|
||||
chunkSizeWarningLimit: 1000,
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/],
|
||||
transformMixedEsModules: true
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'stream', 'util', 'crypto', 'http', 'https', 'url', 'zlib',
|
||||
'path', 'fs', 'tty', 'assert', 'net', 'dns', 'child_process', 'os'
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
stream: 'stream',
|
||||
util: 'util',
|
||||
crypto: 'crypto',
|
||||
http: 'http',
|
||||
https: 'https',
|
||||
url: 'url',
|
||||
zlib: 'zlib',
|
||||
path: 'path',
|
||||
assert: 'assert',
|
||||
tty: 'tty'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
// CORS headers removed to allow images from any domain
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@nostr/tools': path.resolve(__dirname, 'node_modules/@nostr/tools'),
|
||||
'@nostr/tools/nip06': path.resolve(__dirname, 'node_modules/@nostr/tools/nip06'),
|
||||
stream: 'stream-browserify',
|
||||
util: 'util',
|
||||
crypto: 'crypto-browserify'
|
||||
},
|
||||
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@nostr/tools', '@nostr/tools/nip06', '@jlongster/sql.js'],
|
||||
esbuildOptions: {
|
||||
define: {
|
||||
global: 'globalThis'
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
target: 'esnext',
|
||||
chunkSizeWarningLimit: 1000,
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/],
|
||||
transformMixedEsModules: true
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['stream', 'util', 'crypto'],
|
||||
output: {
|
||||
globals: {
|
||||
stream: 'stream',
|
||||
util: 'util',
|
||||
crypto: 'crypto'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
assetsInclude: ['**/*.wasm']
|
||||
});
|
||||
Reference in New Issue
Block a user