docs: update build pattern conversion plan with consistent naming and mode handling

- Change build:* naming from hyphen to colon (build:web-dev → build:web:dev)
- Add missing build:web:test and build:web:prod scripts
- Update build:electron:dev to include electron startup (build + start)
- Remove hardcoded --mode electron to allow proper mode override
- Add comprehensive mode override behavior documentation
- Fix mode conflicts between hardcoded and passed --mode arguments

The plan now properly supports:
- Development builds with default --mode development
- Testing builds with explicit --mode test override
- Production builds with explicit --mode production override
- Consistent naming across all platforms (web, capacitor, electron)
This commit is contained in:
Matthew Raymer
2025-07-09 13:13:44 +00:00
parent 95b038c717
commit b35c1d693f
11 changed files with 4209 additions and 0 deletions

View File

@@ -0,0 +1,293 @@
<template>
<div class="lazy-loading-example">
<!-- Loading state with Suspense -->
<Suspense>
<template #default>
<!-- Main content with lazy-loaded components -->
<div class="content">
<h1>Lazy Loading Example</h1>
<!-- Lazy-loaded heavy component -->
<LazyHeavyComponent
v-if="showHeavyComponent"
:data="heavyComponentData"
@data-processed="handleDataProcessed"
/>
<!-- Conditionally loaded components -->
<LazyQRScanner
v-if="showQRScanner"
@qr-detected="handleQRDetected"
/>
<LazyThreeJSViewer
v-if="showThreeJS"
:model-url="threeJSModelUrl"
@model-loaded="handleModelLoaded"
/>
<!-- Route-based lazy loading -->
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</div>
</template>
<!-- Loading fallback -->
<template #fallback>
<div class="loading-fallback">
<div class="spinner"></div>
<p>Loading components...</p>
</div>
</template>
</Suspense>
<!-- Control buttons -->
<div class="controls">
<button @click="toggleHeavyComponent">
{{ showHeavyComponent ? 'Hide' : 'Show' }} Heavy Component
</button>
<button @click="toggleQRScanner">
{{ showQRScanner ? 'Hide' : 'Show' }} QR Scanner
</button>
<button @click="toggleThreeJS">
{{ showThreeJS ? 'Hide' : 'Show' }} 3D Viewer
</button>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-facing-decorator';
import { defineAsyncComponent } from 'vue';
/**
* Lazy Loading Example Component
*
* Demonstrates various lazy loading patterns with vue-facing-decorator:
* - defineAsyncComponent for heavy components
* - Conditional loading based on user interaction
* - Suspense for loading states
* - Route-based lazy loading
*
* @author Matthew Raymer
* @version 1.0.0
*/
@Component({
name: 'LazyLoadingExample',
components: {
// Lazy-loaded components with loading and error states
LazyHeavyComponent: defineAsyncComponent({
loader: () => import('./sub-components/HeavyComponent.vue'),
loadingComponent: {
template: '<div class="loading">Loading heavy component...</div>'
},
errorComponent: {
template: '<div class="error">Failed to load heavy component</div>'
},
delay: 200, // Show loading component after 200ms
timeout: 10000 // Timeout after 10 seconds
}),
LazyQRScanner: defineAsyncComponent({
loader: () => import('./sub-components/QRScannerComponent.vue'),
loadingComponent: {
template: '<div class="loading">Initializing QR scanner...</div>'
},
errorComponent: {
template: '<div class="error">QR scanner not available</div>'
}
}),
LazyThreeJSViewer: defineAsyncComponent({
loader: () => import('./sub-components/ThreeJSViewer.vue'),
loadingComponent: {
template: '<div class="loading">Loading 3D viewer...</div>'
},
errorComponent: {
template: '<div class="error">3D viewer failed to load</div>'
}
})
}
})
export default class LazyLoadingExample extends Vue {
// Component state
@Prop({ default: false }) readonly initialLoadHeavy!: boolean;
// Reactive properties
showHeavyComponent = false;
showQRScanner = false;
showThreeJS = false;
// Component data
heavyComponentData = {
items: Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Item ${i}` })),
filters: { category: 'all', status: 'active' },
sortBy: 'name'
};
threeJSModelUrl = '/models/lupine_plant/scene.gltf';
// Computed properties
get isLoadingAnyComponent(): boolean {
return this.showHeavyComponent || this.showQRScanner || this.showThreeJS;
}
get componentCount(): number {
let count = 0;
if (this.showHeavyComponent) count++;
if (this.showQRScanner) count++;
if (this.showThreeJS) count++;
return count;
}
// Lifecycle hooks
mounted(): void {
console.log('[LazyLoadingExample] Component mounted');
// Initialize based on props
if (this.initialLoadHeavy) {
this.showHeavyComponent = true;
}
// Preload critical components
this.preloadCriticalComponents();
}
// Methods
toggleHeavyComponent(): void {
this.showHeavyComponent = !this.showHeavyComponent;
console.log('[LazyLoadingExample] Heavy component toggled:', this.showHeavyComponent);
}
toggleQRScanner(): void {
this.showQRScanner = !this.showQRScanner;
console.log('[LazyLoadingExample] QR scanner toggled:', this.showQRScanner);
}
toggleThreeJS(): void {
this.showThreeJS = !this.showThreeJS;
console.log('[LazyLoadingExample] ThreeJS viewer toggled:', this.showThreeJS);
}
handleDataProcessed(data: any): void {
console.log('[LazyLoadingExample] Data processed:', data);
// Handle processed data from heavy component
}
handleQRDetected(qrData: string): void {
console.log('[LazyLoadingExample] QR code detected:', qrData);
// Handle QR code data
}
handleModelLoaded(modelInfo: any): void {
console.log('[LazyLoadingExample] 3D model loaded:', modelInfo);
// Handle 3D model loaded event
}
/**
* Preload critical components for better UX
*/
private preloadCriticalComponents(): void {
// Preload components that are likely to be used
if (process.env.NODE_ENV === 'production') {
// In production, preload based on user behavior patterns
this.preloadComponent(() => import('./sub-components/HeavyComponent.vue'));
}
}
/**
* Preload a component without rendering it
*/
private preloadComponent(componentLoader: () => Promise<any>): void {
componentLoader().catch(error => {
console.warn('[LazyLoadingExample] Preload failed:', error);
});
}
// Watchers
@Watch('showHeavyComponent')
onHeavyComponentToggle(newValue: boolean): void {
if (newValue) {
// Component is being shown - could trigger analytics
console.log('[LazyLoadingExample] Heavy component shown');
}
}
@Watch('componentCount')
onComponentCountChange(newCount: number): void {
console.log('[LazyLoadingExample] Active component count:', newCount);
}
}
</script>
<style scoped>
.lazy-loading-example {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.content {
margin-bottom: 20px;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
.controls button {
padding: 10px 20px;
border: 1px solid #ccc;
border-radius: 4px;
background: #f8f9fa;
cursor: pointer;
transition: background-color 0.2s;
}
.controls button:hover {
background: #e9ecef;
}
.loading-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
}
.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 {
padding: 20px;
text-align: center;
color: #666;
}
.error {
padding: 20px;
text-align: center;
color: #dc3545;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,542 @@
<template>
<div class="heavy-component">
<h2>Heavy Data Processing Component</h2>
<!-- Data processing controls -->
<div class="controls">
<button @click="processData" :disabled="isProcessing">
{{ isProcessing ? 'Processing...' : 'Process Data' }}
</button>
<button @click="clearResults" :disabled="isProcessing">
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
@click="previousPage"
:disabled="currentPage === 1"
class="page-btn"
>
Previous
</button>
<span class="page-info">
Page {{ currentPage }} of {{ totalPages }}
</span>
<button
@click="nextPage"
:disabled="currentPage === totalPages"
class="page-btn"
>
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>

View File

@@ -0,0 +1,708 @@
<template>
<div class="qr-scanner-component">
<h2>QR Code Scanner</h2>
<!-- Camera controls -->
<div class="camera-controls">
<button @click="startScanning" :disabled="isScanning || !hasCamera">
{{ isScanning ? 'Scanning...' : 'Start Scanning' }}
</button>
<button @click="stopScanning" :disabled="!isScanning">
Stop Scanning
</button>
<button @click="switchCamera" :disabled="!isScanning || cameras.length <= 1">
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 @click="copyToClipboard(result.data)" class="copy-btn">
Copy
</button>
<button @click="removeResult(index)" class="remove-btn">
Remove
</button>
</div>
</div>
</div>
<div class="results-actions">
<button @click="clearResults" class="clear-btn">
Clear All Results
</button>
<button @click="exportResults" class="export-btn">
Export Results
</button>
</div>
</div>
<!-- Settings panel -->
<div class="settings-panel">
<h3>Scanner Settings</h3>
<div class="setting-group">
<label>
<input
type="checkbox"
v-model="settings.continuousScanning"
/>
Continuous Scanning
</label>
<p class="setting-description">
Automatically scan multiple QR codes without stopping
</p>
</div>
<div class="setting-group">
<label>
<input
type="checkbox"
v-model="settings.audioFeedback"
/>
Audio Feedback
</label>
<p class="setting-description">
Play sound when QR code is detected
</p>
</div>
<div class="setting-group">
<label>
<input
type="checkbox"
v-model="settings.vibrateOnScan"
/>
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
type="number"
v-model.number="settings.scanInterval"
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>

View File

@@ -0,0 +1,657 @@
<template>
<div class="threejs-viewer">
<h2>3D Model Viewer</h2>
<!-- Viewer controls -->
<div class="viewer-controls">
<button @click="loadModel" :disabled="isLoading || !modelUrl">
{{ isLoading ? 'Loading...' : 'Load Model' }}
</button>
<button @click="resetCamera" :disabled="!isModelLoaded">
Reset Camera
</button>
<button @click="toggleAnimation" :disabled="!isModelLoaded">
{{ isAnimating ? 'Stop' : 'Start' }} Animation
</button>
<button @click="toggleWireframe" :disabled="!isModelLoaded">
{{ 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 @click="retryLoad" class="retry-btn">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
type="range"
v-model.number="cameraDistance"
min="1"
max="20"
step="0.1"
@input="updateCameraDistance"
/>
<span>{{ cameraDistance.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Rotation Speed:</label>
<input
type="range"
v-model.number="rotationSpeed"
min="0"
max="2"
step="0.1"
/>
<span>{{ rotationSpeed.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Light Intensity:</label>
<input
type="range"
v-model.number="lightIntensity"
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>