Просмотр исходного кода

fixed history vault and adjust for mobile view

Dr-Swopt 2 недель назад
Родитель
Сommit
3b1c3c9efc

+ 20 - 0
frontend/MOBILE_OPTIMIZATION.md

@@ -0,0 +1,20 @@
+# Phase 3: Mobile Web Optimization & Local Persistence
+
+## Responsive UI Stacking
+The interface has been upgraded to a **Mobile-First Responsive Layout**. Using CSS Media Queries, the "Scanner" and "Vault" views now transition from a dual-column desktop view to a single-column stacked view on devices with widths less than 768px.
+- **Touch Targets**: The "Upload Zone" has been optimized with increased padding and a larger click area to accommodate mobile touch gestures.
+- **Dynamic Scaling**: The detection overlay logic utilizes a relative scaling factor ($Scale = \frac{ContainerWidth}{ImageWidth}$) which recalculates on every scan to ensure bounding boxes perfectly align with the responsive canvas on any screen size.
+
+## Local Persistence (The Vault)
+The system now features a robust **Local Persistence Layer** via the `LocalHistoryService`. This replaces the previous FastAPI backend dependency for history tracking.
+- **LocalStorage Integration**: All scan results, including MPOB summary counts, latency metrics, and detection box coordinates, are serialized and stored in the browser's `localStorage`.
+- **Asset Archiving**: To support offline review, the system captures a Base64 string of the processed image, allowing users to review detection boxes in the "Vault" even after the original file is no longer available.
+- **Overflow Protection**: The local vault is capped at the 20 most recent records to prevent browser memory overflow while maintaining a functional audit trail for field harvesters.
+
+## Mobile Integration Checklist
+| Task | Status | Implementation Detail |
+| :--- | :--- | :--- |
+| **Viewport Scaling** | Configured | Meta tag set to `width=device-width`. |
+| **Column Stacking** | Active | Media queries applied to `Analyzer` and `History` rows. |
+| **Canvas Auto-Resize** | Active | Logic tied to `parentElement.clientWidth`. |
+| **Persistent Vault** | Active | Powered by `LocalHistoryService` and `localStorage`. |

+ 27 - 1
frontend/src/app/components/analyzer/analyzer.component.scss

@@ -5,6 +5,11 @@
 .row {
   display: flex;
   gap: 32px;
+  
+  // Mobile Stacking
+  @media (max-width: 768px) {
+    flex-direction: column;
+  }
 }
 
 .col-left {
@@ -12,10 +17,12 @@
   display: flex;
   flex-direction: column;
   gap: 20px;
+  width: 100%; // full width on mobile
 }
 
 .col-right {
   flex: 1;
+  width: 100%; // full width on mobile
 }
 
 .upload-zone {
@@ -29,6 +36,15 @@
   cursor: pointer;
   transition: all 0.3s ease;
   
+  @media (max-width: 768px) {
+    height: 200px; // Shorter on mobile
+  }
+
+  @media (max-width: 480px) {
+    padding: 1.5rem;
+    .upload-icon { font-size: 2.5rem; }
+  }
+  
   &:hover {
     border-color: var(--accent-green);
     background: rgba(0, 166, 81, 0.05);
@@ -128,6 +144,14 @@
   grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
   gap: 16px;
   margin-bottom: 32px;
+
+  @media (max-width: 768px) {
+    grid-template-columns: repeat(2, 1fr); // 2 cards per row on mobile
+  }
+
+  @media (max-width: 480px) {
+    grid-template-columns: 1fr; // Single column for very small phones
+  }
 }
 
 .card {
@@ -172,9 +196,11 @@
   overflow: hidden;
   border-radius: 12px;
   border: 1px solid var(--border-color);
+  width: 100%;
   
   canvas {
-    width: 100%;
+    width: 100% !important; // Force canvas to fit container width
+    height: auto !important;
     display: block;
   }
 }

+ 12 - 1
frontend/src/app/components/analyzer/analyzer.component.ts

@@ -2,6 +2,7 @@ import { Component, OnInit, ElementRef, ViewChild } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { ImageProcessorService } from '../../services/image-processor.service';
 import { LocalInferenceService } from '../../services/local-inference.service';
+import { LocalHistoryService } from '../../services/local-history.service';
 import { FormsModule } from '@angular/forms';
 
 @Component({
@@ -23,7 +24,8 @@ export class AnalyzerComponent implements OnInit {
 
   constructor(
     private imageProcessor: ImageProcessorService,
-    private localInference: LocalInferenceService
+    private localInference: LocalInferenceService,
+    private localHistory: LocalHistoryService
   ) {}
 
   ngOnInit(): void {
@@ -126,6 +128,15 @@ export class AnalyzerComponent implements OnInit {
         detections: detections
       };
 
+      // PERSIST TO LOCAL VAULT
+      this.localHistory.saveRecord(
+        this.results, 
+        this.selectedFile!.name, 
+        this.modelType,
+        this.previewUrl!,
+        img
+      );
+
       console.log('Backend-less PoC: Parsed Detections:', detections);
       
       this.loading = false;

+ 17 - 0
frontend/src/app/components/header/header.component.scss

@@ -16,6 +16,14 @@
   justify-content: space-between;
   align-items: center;
   width: 100%;
+
+  @media (max-width: 600px) {
+    flex-direction: column;
+    gap: 1rem;
+    
+    .logo { margin-bottom: 0.5rem; }
+    .nav { width: 100%; justify-content: center; gap: 16px; } // Reduced gap for small screens
+  }
 }
 
 .logo {
@@ -58,6 +66,11 @@
   font-size: 0.95rem;
   transition: color 0.2s ease;
   position: relative;
+
+  @media (max-width: 600px) {
+    padding: 0.5rem 0.8rem;
+    font-size: 0.9rem;
+  }
   
   &.active {
     color: var(--text-primary);
@@ -70,6 +83,10 @@
       width: 100%;
       height: 2px;
       background: var(--accent-green);
+
+      @media (max-width: 600px) {
+        bottom: -10px; // Adjust active indicator for stacked layout
+      }
     }
   }
   

+ 37 - 14
frontend/src/app/components/history/history.component.html

@@ -13,22 +13,45 @@
     </div>
 
     <div *ngIf="!loading && history.length > 0" class="history-grid">
-      <div *ngFor="let record of history" class="history-item">
-        <div class="record-info">
-          <span class="record-date">{{ record.timestamp }}</span>
-          <h3 class="record-filename">{{ record.filename }}</h3>
-          <div class="summary-badges">
-            <span *ngFor="let badge of getSummaryBadge(record.summary)" class="badge-item">{{ badge }}</span>
+      <div *ngFor="let record of history" 
+           class="history-item" 
+           [class.expanded]="isExpanded(record.timestamp, record.filename)">
+        <div class="item-header" (click)="toggleExpand(record.timestamp, record.filename)">
+          <div class="record-info">
+            <span class="record-date">{{ record.timestamp }}</span>
+            <h3 class="record-filename">{{ record.filename }}</h3>
+            <div class="summary-badges">
+              <span *ngFor="let badge of getSummaryBadge(record.summary)" class="badge-item">{{ badge }}</span>
+            </div>
           </div>
-        </div>
-        <div class="record-meta">
-          <div class="meta-box">
-            <span class="meta-label">Engine</span>
-            <span class="meta-value">{{ record.engine || 'onnx' }}</span>
+          <div class="record-meta">
+            <div class="meta-box">
+              <span class="meta-label">Engine</span>
+              <span class="meta-value">{{ record.engine || 'onnx' }}</span>
+            </div>
+            <div class="meta-box">
+              <span class="meta-label">Latency</span>
+              <span class="meta-value">{{ record.inference_ms.toFixed(1) }} ms</span>
+            </div>
+            <div class="expand-icon">
+              <i class="chevron"></i>
+            </div>
           </div>
-          <div class="meta-box">
-            <span class="meta-label">Latency</span>
-            <span class="meta-value">{{ record.inference_ms.toFixed(1) }} ms</span>
+        </div>
+
+        <!-- Expanded View: Result Visualization -->
+        <div *ngIf="isExpanded(record.timestamp, record.filename)" class="item-details">
+          <div class="result-visualization">
+            <div class="image-wrapper">
+              <img [src]="record.imageData" class="result-img" alt="Scan result">
+              <div *ngFor="let det of record.detections" 
+                   class="box-overlay"
+                   [ngStyle]="getBoxStyles(det.box, record.dimensions)"
+                   [class.ripe]="det.class === 'Ripe'"
+                   [class.alert]="det.is_health_alert">
+                <span class="box-label">{{ det.class }} {{ (det.confidence * 100).toFixed(0) }}%</span>
+              </div>
+            </div>
           </div>
         </div>
       </div>

+ 111 - 12
frontend/src/app/components/history/history.component.scss

@@ -1,9 +1,3 @@
-.panel-subtitle {
-  font-size: 0.85rem;
-  color: var(--text-secondary);
-  margin-bottom: 32px;
-}
-
 .history-grid {
   display: flex;
   flex-direction: column;
@@ -12,16 +6,36 @@
 
 .history-item {
   background: var(--input-bg);
-  padding: 24px;
   border-radius: 12px;
   border: 1px solid var(--border-color);
+  overflow: hidden;
+  transition: border-color 0.2s ease, box-shadow 0.3s ease;
+  
+  &:hover {
+    border-color: var(--accent-green);
+  }
+
+  &.expanded {
+    border-color: var(--accent-green);
+    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
+    
+    .expand-icon {
+      transform: rotate(180deg);
+    }
+  }
+}
+
+.item-header {
+  padding: 24px;
   display: flex;
   justify-content: space-between;
   align-items: center;
-  transition: border-color 0.2s ease, background 0.3s ease;
+  cursor: pointer;
+  background: transparent;
+  transition: background 0.2s ease;
   
   &:hover {
-    border-color: var(--accent-green);
+    background: rgba(255, 255, 255, 0.02);
   }
 }
 
@@ -43,28 +57,31 @@
 
 .summary-badges {
   display: flex;
+  flex-wrap: wrap;
   gap: 8px;
   margin-top: 8px;
 }
 
 .badge-item {
-  font-size: 0.7rem;
-  padding: 2px 8px;
+  font-size: 0.75rem;
+  padding: 4px 12px;
   border-radius: 99px;
   background: var(--panel-color);
   border: 1px solid var(--border-color);
   color: var(--text-secondary);
+  font-weight: 600;
 }
 
 .record-meta {
   display: flex;
   gap: 24px;
-  text-align: right;
+  align-items: center;
   
   .meta-box {
     display: flex;
     flex-direction: column;
     gap: 4px;
+    text-align: right;
     
     .meta-label {
       font-size: 0.65rem;
@@ -79,3 +96,85 @@
     }
   }
 }
+
+.expand-icon {
+  width: 32px;
+  height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.05);
+  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+  
+  .chevron {
+    width: 8px;
+    height: 8px;
+    border-right: 2px solid var(--text-secondary);
+    border-bottom: 2px solid var(--text-secondary);
+    transform: rotate(45deg);
+    margin-top: -4px;
+  }
+}
+
+.item-details {
+  background: var(--panel-color);
+  padding: 0 24px 24px 24px;
+  border-top: 1px solid var(--border-color);
+  animation: slideDown 0.3s ease-out;
+}
+
+.result-visualization {
+  margin-top: 24px;
+  border-radius: 8px;
+  overflow: hidden;
+  position: relative;
+  background: #000;
+  
+  .image-wrapper {
+    position: relative;
+    display: inline-block;
+    width: 100%;
+    
+    .result-img {
+      display: block;
+      width: 100%;
+      height: auto;
+      max-height: 500px;
+      object-fit: contain;
+    }
+  }
+}
+
+.box-overlay {
+  position: absolute;
+  border: 3px solid #ff0000;
+  pointer-events: none;
+  
+  &.ripe { border-color: #00ff00; }
+  &.alert { border-color: #ff4444; }
+
+  .box-label {
+    position: absolute;
+    top: -24px;
+    left: -3px;
+    background: inherit;
+    color: white;
+    padding: 2px 8px;
+    font-size: 0.75rem;
+    font-weight: 700;
+    white-space: nowrap;
+    border-radius: 3px 3px 0 0;
+    
+    // Fallback if background: inherit doesn't solve color
+    background-color: inherit;
+  }
+  
+  &.ripe { .box-label { background-color: #00ff00; color: #000; } }
+  &.alert { .box-label { background-color: #ff4444; color: #fff; } }
+}
+
+@keyframes slideDown {
+  from { opacity: 0; transform: translateY(-10px); }
+  to { opacity: 1; transform: translateY(0); }
+}

+ 32 - 12
frontend/src/app/components/history/history.component.ts

@@ -1,6 +1,6 @@
 import { Component, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
-import { ApiService } from '../../services/api.service';
+import { LocalHistoryService } from '../../services/local-history.service';
 
 @Component({
   selector: 'app-history',
@@ -12,20 +12,14 @@ import { ApiService } from '../../services/api.service';
 export class HistoryComponent implements OnInit {
   history: any[] = [];
   loading = true;
+  expandedId: string | null = null;
 
-  constructor(private apiService: ApiService) {}
+  constructor(private localHistory: LocalHistoryService) {}
 
   ngOnInit(): void {
-    this.apiService.getHistory().subscribe({
-      next: (res) => {
-        this.history = res.history;
-        this.loading = false;
-      },
-      error: (err) => {
-        console.error('Failed to load history', err);
-        this.loading = false;
-      }
-    });
+    // Synchronous load from LocalStorage
+    this.history = this.localHistory.getRecords();
+    this.loading = false;
   }
 
   parseJSON(jsonStr: string): any {
@@ -42,4 +36,30 @@ export class HistoryComponent implements OnInit {
       .filter(([_, count]) => (count as number) > 0)
       .map(([key, count]) => `${key}: ${count}`);
   }
+
+  toggleExpand(timestamp: string, filename: string): void {
+    const id = `${timestamp}-${filename}`;
+    this.expandedId = this.expandedId === id ? null : id;
+  }
+
+  isExpanded(timestamp: string, filename: string): boolean {
+    return this.expandedId === `${timestamp}-${filename}`;
+  }
+
+  getBoxStyles(box: number[], dimensions: {width: number, height: number}): any {
+    if (!dimensions) return {};
+    
+    // Convert absolute pixels to percentage for responsive overlay
+    const left = (box[0] / dimensions.width) * 100;
+    const top = (box[1] / dimensions.height) * 100;
+    const width = ((box[2] - box[0]) / dimensions.width) * 100;
+    const height = ((box[3] - box[1]) / dimensions.height) * 100;
+
+    return {
+      'left.%': left,
+      'top.%': top,
+      'width.%': width,
+      'height.%': height
+    };
+  }
 }

+ 33 - 0
frontend/src/app/services/local-history.service.ts

@@ -0,0 +1,33 @@
+import { Injectable } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+export class LocalHistoryService {
+  private readonly STORAGE_KEY = 'palm_oil_vault';
+
+  saveRecord(result: any, filename: string, engine: string, imageData: string, dimensions: {width: number, height: number}): void {
+    const history = this.getRecords();
+    
+    const newRecord = {
+      timestamp: new Date().toLocaleString(),
+      filename: filename,
+      summary: JSON.stringify(result.industrial_summary), // Matches HistoryComponent's expectations
+      inference_ms: result.inference_ms,
+      engine: engine,
+      detections: result.detections,
+      imageData: imageData,
+      dimensions: dimensions
+    };
+
+    history.unshift(newRecord); // Add newest to top
+    // Limit history to 20 records to avoid localStorage overflow
+    if (history.length > 20) {
+      history.pop();
+    }
+    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(history));
+  }
+
+  getRecords(): any[] {
+    const data = localStorage.getItem(this.STORAGE_KEY);
+    return data ? JSON.parse(data) : [];
+  }
+}