forked from jsnbuchanan/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:
@@ -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>
|
||||
Reference in New Issue
Block a user