|
|
@@ -1,27 +1,11 @@
|
|
|
/**
|
|
|
- * Lego 02 / Lego 11 / Lego 13 — Vision Tab (Scanner)
|
|
|
+ * ADR-024.3 — Vision Engine Core Reconstruction
|
|
|
*
|
|
|
- * Three-engine "Snap & Analyze" architecture. No continuous inference loops.
|
|
|
- *
|
|
|
- * Engines:
|
|
|
- * tflite — Browser TFLite WASM (local, file upload)
|
|
|
- * onnx — Browser ONNX Runtime (local, file upload)
|
|
|
- * socket — NestJS /vision socket (raw Base64 snap via vision:analyze)
|
|
|
- *
|
|
|
- * Snap workflow (socket engine):
|
|
|
- * 1. User starts webcam → live preview plays (no inference)
|
|
|
- * 2. User clicks "Snap & Analyze"
|
|
|
- * 3. Component captures the current video frame as 640×640 Base64
|
|
|
- * 4. VisionSocketService emits vision:analyze to NestJS
|
|
|
- * 5. NestJS returns vision:result → bounding boxes drawn on frozen snapshot
|
|
|
- *
|
|
|
- * Snap workflow (browser engines):
|
|
|
- * 1. User uploads an image file
|
|
|
- * 2. User clicks "Run Inference"
|
|
|
- * 3. InferenceService runs TFLite or ONNX locally
|
|
|
- * 4. Detections drawn on the canvas
|
|
|
- *
|
|
|
- * Preservation rule: TFLite/ONNX tensor math in LocalInferenceService is untouched.
|
|
|
+ * Extends BaseComponent for lifecycle-managed memory safety.
|
|
|
+ * All network frames route through RemoteInferenceService → DpService.stream()
|
|
|
+ * rather than raw socket instances.
|
|
|
+ * The `visionSocket` property is a local signal shim that preserves the
|
|
|
+ * existing template bindings without requiring HTML changes.
|
|
|
*/
|
|
|
|
|
|
import {
|
|
|
@@ -36,9 +20,16 @@ import {
|
|
|
} from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
+import { interval } from 'rxjs';
|
|
|
+import { Store } from '@ngxs/store';
|
|
|
+
|
|
|
+import { BaseComponent, untilDestroy } from 'angularlib/base.component';
|
|
|
+import { ComponentService } from 'angularlib/component.service';
|
|
|
+import { NgxSocketService } from 'dp-ui/socket/ngxSocket.service';
|
|
|
+
|
|
|
import { LocalHistoryService } from '../../services/local-history.service';
|
|
|
+import { RemoteInferenceService } from '../../services/remote-inference.service';
|
|
|
import { InferenceService, LocalEngine } from '../../core/services/inference.service';
|
|
|
-import { VisionSocketService } from '../../services/vision-socket.service';
|
|
|
import {
|
|
|
BatchResult,
|
|
|
FullSessionReport,
|
|
|
@@ -58,21 +49,16 @@ const GRADE_COLORS: Record<string, string> = {
|
|
|
templateUrl: './analyzer.component.html',
|
|
|
styleUrls: ['./analyzer.component.scss'],
|
|
|
})
|
|
|
-export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
+export class AnalyzerComponent extends BaseComponent implements OnInit, OnDestroy {
|
|
|
|
|
|
// ── Canvas refs ────────────────────────────────────────────────────────────
|
|
|
- /** Result canvas for browser-engine mode (drawn after inference) */
|
|
|
@ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
- /** Snapshot canvas for socket-engine mode (frozen frame + bounding boxes) */
|
|
|
@ViewChild('snapCanvas') snapCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
- /** Evidence canvas for batch audit drill-down (separate from snapCanvas) */
|
|
|
@ViewChild('evidenceCanvas') evidenceCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
- /** Live webcam video feed */
|
|
|
@ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
|
|
|
|
|
|
// ── Engine selection ───────────────────────────────────────────────────────
|
|
|
engine = signal<SnapEngine>('onnx');
|
|
|
- /** 'local' for browser engines, 'backend' for socket */
|
|
|
engineMode = computed<'local' | 'backend'>(() =>
|
|
|
this.engine() === 'socket' ? 'backend' : 'local'
|
|
|
);
|
|
|
@@ -84,7 +70,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
loading = false;
|
|
|
isDragging = false;
|
|
|
|
|
|
- // ── Batch ingestion state (backend/socket mode only) ───────────────────────
|
|
|
+ // ── Batch ingestion state ──────────────────────────────────────────────────
|
|
|
batchQueue = signal<File[]>([]);
|
|
|
currentBatchIndex = signal<number>(0);
|
|
|
private _batchRunning = signal<boolean>(false);
|
|
|
@@ -94,50 +80,65 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
completedReport = signal<FullSessionReport | null>(null);
|
|
|
selectedAuditEntry = signal<BatchResult | null>(null);
|
|
|
|
|
|
- /** Wall-clock timestamp of when sendBase64 was called for the current image */
|
|
|
private _pingTime = 0;
|
|
|
- /** Wall-clock timestamp of when startBatchProcessing was called */
|
|
|
private _batchStartTime = 0;
|
|
|
- /** Total files in the current batch run — stored separately so it survives queue drain */
|
|
|
private _totalBatchCount = 0;
|
|
|
get totalBatchCount(): number { return this._totalBatchCount; }
|
|
|
- /** Blob URL for the file currently being processed — stored so the pong effect can attach it to the record */
|
|
|
private _currentBlobUrl: string | null = null;
|
|
|
- /** UUID shared across all frames in a batch session — sent to the gateway so DB rows can be grouped in the Vault */
|
|
|
private _batchId: string = '';
|
|
|
|
|
|
// ── Socket-engine state ────────────────────────────────────────────────────
|
|
|
- /** 'webcam' = live camera snap | 'gallery' = file from device storage */
|
|
|
socketInputMode = signal<'webcam' | 'gallery'>('webcam');
|
|
|
- /** Base64 of the last snapped/gallery frame — displayed as frozen background */
|
|
|
snappedFrame: string | null = null;
|
|
|
- /** File selected in gallery mode */
|
|
|
socketGalleryFile: File | null = null;
|
|
|
private webcamStream: MediaStream | null = null;
|
|
|
|
|
|
+ // ── visionSocket shim ──────────────────────────────────────────────────────
|
|
|
+ // Preserves all template bindings (visionSocket.connected(), .nestStatus(), etc.)
|
|
|
+ // while routing all frames through DpService.stream() via RemoteInferenceService.
|
|
|
+ private _nestStatus = signal<'ONLINE' | 'OFFLINE'>('OFFLINE');
|
|
|
+ private _analyzing = signal<boolean>(false);
|
|
|
+ private _lastResult = signal<any>(null);
|
|
|
+ private _lastError = signal<string | null>(null);
|
|
|
+
|
|
|
+ readonly visionSocket = {
|
|
|
+ nestStatus: this._nestStatus,
|
|
|
+ connected: computed(() => this._nestStatus() === 'ONLINE'),
|
|
|
+ analyzing: this._analyzing,
|
|
|
+ lastResult: this._lastResult,
|
|
|
+ lastError: this._lastError,
|
|
|
+ clearResult: () => {
|
|
|
+ this._lastResult.set(null);
|
|
|
+ this._lastError.set(null);
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
constructor(
|
|
|
+ store: Store,
|
|
|
+ cs: ComponentService,
|
|
|
public inferenceService: InferenceService,
|
|
|
private localHistory: LocalHistoryService,
|
|
|
- public visionSocket: VisionSocketService,
|
|
|
+ private remoteInference: RemoteInferenceService,
|
|
|
+ private ngxSocket: NgxSocketService,
|
|
|
) {
|
|
|
- // When NestJS goes offline, force the engine back to a local mode
|
|
|
+ super(store, cs);
|
|
|
+
|
|
|
+ // Force back to local engine when backend connection is lost
|
|
|
effect(() => {
|
|
|
- if (visionSocket.nestStatus() === 'OFFLINE' && this.engine() === 'socket') {
|
|
|
+ if (this.visionSocket.nestStatus() === 'OFFLINE' && this.engine() === 'socket') {
|
|
|
this.engine.set('onnx');
|
|
|
this.stopWebcam();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- // Safety: switching to local while a batch is queued must clear the queue
|
|
|
- // immediately — local WASM cannot handle multi-file sequential load
|
|
|
+ // Local WASM cannot handle multi-file sequential load — abort any active batch
|
|
|
effect(() => {
|
|
|
if (this.engineMode() === 'local' && this.isBatchActive()) {
|
|
|
this.abortBatch();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- // "Pong" — fires on every lastResult change. Guards on non-null result AND
|
|
|
- // active batch so it never interferes with single-image gallery/snap flows.
|
|
|
+ // "Pong" — fires when a successful analysis result arrives during a batch run
|
|
|
effect(() => {
|
|
|
const res = this.visionSocket.lastResult();
|
|
|
if (!res || !this.isBatchActive()) return;
|
|
|
@@ -150,7 +151,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
image_id: queue[idx]?.name ?? `file_${idx}`,
|
|
|
timestamp: new Date().toISOString(),
|
|
|
status: 'ok',
|
|
|
- detections: res.detections.map(d => ({
|
|
|
+ detections: res.detections.map((d: any) => ({
|
|
|
bunch_id: d.bunch_id,
|
|
|
ripeness_class: d.class,
|
|
|
confidence_pct: Math.round(d.confidence * 10000) / 100,
|
|
|
@@ -187,7 +188,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- // "Error pong" — if a socket error fires mid-batch, log it and advance
|
|
|
+ // "Error pong" — socket error mid-batch: log it and advance to next file
|
|
|
effect(() => {
|
|
|
const err = this.visionSocket.lastError();
|
|
|
if (!err || !this.isBatchActive()) return;
|
|
|
@@ -226,18 +227,24 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- // Draw evidence canvas whenever the user selects an audit entry
|
|
|
+ // Redraw evidence canvas when the user selects an audit entry
|
|
|
effect(() => {
|
|
|
const entry = this.selectedAuditEntry();
|
|
|
if (!entry) return;
|
|
|
- // defer one tick so #evidenceCanvas is rendered in the DOM
|
|
|
setTimeout(() => this.drawEvidence(entry), 0);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- ngOnInit(): void {}
|
|
|
+ ngOnInit(): void {
|
|
|
+ // Poll NgxSocketService.status every second to keep visionSocket.nestStatus in sync
|
|
|
+ interval(1000).pipe(untilDestroy(this)).subscribe(() => {
|
|
|
+ const live = this.ngxSocket.status === 'online' ? 'ONLINE' : 'OFFLINE';
|
|
|
+ if (this._nestStatus() !== live) this._nestStatus.set(live);
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
- ngOnDestroy(): void {
|
|
|
+ override ngOnDestroy(): void {
|
|
|
+ super.ngOnDestroy(); // emits destroyed Subject → all untilDestroy subscriptions complete
|
|
|
this.stopWebcam();
|
|
|
}
|
|
|
|
|
|
@@ -292,12 +299,14 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
const file = this.batchQueue()[this.currentBatchIndex()];
|
|
|
if (!file) return;
|
|
|
|
|
|
- // Create a blob URL for the evidence canvas (revoked in finalizeBatch/abortBatch)
|
|
|
const blobUrl = URL.createObjectURL(file);
|
|
|
this._currentBlobUrl = blobUrl;
|
|
|
|
|
|
const img = new Image();
|
|
|
- img.onerror = () => { URL.revokeObjectURL(blobUrl); this.advanceBatchOnError(file.name, 'Image failed to decode'); };
|
|
|
+ img.onerror = () => {
|
|
|
+ URL.revokeObjectURL(blobUrl);
|
|
|
+ this.advanceBatchOnError(file.name, 'Image failed to decode');
|
|
|
+ };
|
|
|
img.onload = () => {
|
|
|
const offscreen = document.createElement('canvas');
|
|
|
offscreen.width = 640;
|
|
|
@@ -305,13 +314,11 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
|
|
|
const base64 = offscreen.toDataURL('image/jpeg');
|
|
|
this._pingTime = performance.now();
|
|
|
- this.visionSocket.sendBase64(base64, this._batchId);
|
|
|
+ this._sendBase64(base64, this._batchId);
|
|
|
};
|
|
|
img.src = blobUrl;
|
|
|
}
|
|
|
|
|
|
-
|
|
|
- /** Log a client-side read/decode error and advance to the next file */
|
|
|
private advanceBatchOnError(fileName: string, reason: string): void {
|
|
|
const record: BatchResult = {
|
|
|
image_id: fileName,
|
|
|
@@ -370,16 +377,13 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
|
|
|
abortBatch(): void {
|
|
|
- // Revoke any pending blob URL for the in-flight image
|
|
|
if (this._currentBlobUrl) { URL.revokeObjectURL(this._currentBlobUrl); this._currentBlobUrl = null; }
|
|
|
- // Revoke all stored evidence URLs
|
|
|
this.sessionManifest.forEach(r => { if (r.localBlobUrl) URL.revokeObjectURL(r.localBlobUrl); });
|
|
|
this.batchQueue.set([]);
|
|
|
this.currentBatchIndex.set(0);
|
|
|
this._batchRunning.set(false);
|
|
|
}
|
|
|
|
|
|
- /** Draw the source image + bounding boxes onto the dedicated evidence canvas */
|
|
|
private drawEvidence(entry: BatchResult): void {
|
|
|
const canvas = this.evidenceCanvasRef?.nativeElement;
|
|
|
if (!canvas || !entry.localBlobUrl) return;
|
|
|
@@ -445,28 +449,27 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
reader.readAsDataURL(file);
|
|
|
}
|
|
|
|
|
|
- /** Run local TFLite or ONNX inference on the uploaded file */
|
|
|
async runLocalInference(): Promise<void> {
|
|
|
if (!this.selectedFile || !this.previewUrl) return;
|
|
|
this.loading = true;
|
|
|
const start = performance.now();
|
|
|
|
|
|
- // Map engine toggle to InferenceService's LocalEngine type
|
|
|
const localEngine: LocalEngine = this.engine() === 'tflite' ? 'tflite' : 'onnx';
|
|
|
this.inferenceService.localEngine.set(localEngine);
|
|
|
this.inferenceService.mode.set('local');
|
|
|
|
|
|
try {
|
|
|
const img = await this.loadImageDimensions(this.selectedFile);
|
|
|
- this.inferenceService.analyze(this.previewUrl, img.width, img.height).subscribe({
|
|
|
- next: (detections) => {
|
|
|
- this.results = {
|
|
|
- industrial_summary: this.inferenceService.summary(),
|
|
|
- inference_ms: performance.now() - start,
|
|
|
- detections,
|
|
|
- original_dimensions: img,
|
|
|
- };
|
|
|
- if (true) { // always save local runs to history
|
|
|
+ this.inferenceService.analyze(this.previewUrl, img.width, img.height)
|
|
|
+ .pipe(untilDestroy(this))
|
|
|
+ .subscribe({
|
|
|
+ next: (detections) => {
|
|
|
+ this.results = {
|
|
|
+ industrial_summary: this.inferenceService.summary(),
|
|
|
+ inference_ms: performance.now() - start,
|
|
|
+ detections,
|
|
|
+ original_dimensions: img,
|
|
|
+ };
|
|
|
this.localHistory.saveRecord(
|
|
|
this.results,
|
|
|
this.selectedFile!.name,
|
|
|
@@ -474,22 +477,20 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
this.previewUrl!,
|
|
|
img,
|
|
|
);
|
|
|
- }
|
|
|
- this.loading = false;
|
|
|
- setTimeout(() => this.drawBrowserDetections(), 100);
|
|
|
- },
|
|
|
- error: (err) => {
|
|
|
- console.error('Local inference failed:', err);
|
|
|
- this.loading = false;
|
|
|
- },
|
|
|
- });
|
|
|
+ this.loading = false;
|
|
|
+ setTimeout(() => this.drawBrowserDetections(), 100);
|
|
|
+ },
|
|
|
+ error: (err) => {
|
|
|
+ console.error('Local inference failed:', err);
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ });
|
|
|
} catch (err) {
|
|
|
console.error('Local inference pipeline error:', err);
|
|
|
this.loading = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /** Draw detections onto the browser-mode result canvas */
|
|
|
private drawBrowserDetections(): void {
|
|
|
const detections = this.inferenceService.detections();
|
|
|
if (!detections || !this.resultCanvasRef || !this.previewUrl) return;
|
|
|
@@ -576,7 +577,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
this.socketGalleryFile = files[0];
|
|
|
this.batchQueue.set([]);
|
|
|
}
|
|
|
- // Reset input so the same files can be re-selected if needed
|
|
|
input.value = '';
|
|
|
}
|
|
|
|
|
|
@@ -610,10 +610,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
const base64 = e.target?.result as string;
|
|
|
if (!base64) return;
|
|
|
|
|
|
- // Rescale the gallery image to 640×640 before sending and storing as
|
|
|
- // snappedFrame. The backend always runs inference in 640×640 space, so
|
|
|
- // the canvas background must match that same square crop to keep bounding
|
|
|
- // boxes aligned with the displayed image.
|
|
|
const img = new Image();
|
|
|
img.onload = () => {
|
|
|
const offscreen = document.createElement('canvas');
|
|
|
@@ -622,7 +618,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
|
|
|
const scaled640 = offscreen.toDataURL('image/jpeg');
|
|
|
this.snappedFrame = scaled640;
|
|
|
- this.visionSocket.sendBase64(scaled640);
|
|
|
+ this._sendBase64(scaled640);
|
|
|
this.waitForSocketResult();
|
|
|
};
|
|
|
img.src = base64;
|
|
|
@@ -630,16 +626,10 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
reader.readAsDataURL(this.socketGalleryFile);
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Capture one frame from the live webcam, freeze it as a snapshot,
|
|
|
- * and send it to NestJS via vision:analyze.
|
|
|
- * The live video feed continues playing behind the scenes.
|
|
|
- */
|
|
|
snapAndAnalyze(): void {
|
|
|
const videoEl = this.videoElRef?.nativeElement;
|
|
|
if (!videoEl || videoEl.readyState < 2) return;
|
|
|
|
|
|
- // Capture current frame to an offscreen canvas → File → batchQueue of 1
|
|
|
const offscreen = document.createElement('canvas');
|
|
|
offscreen.width = 640;
|
|
|
offscreen.height = 640;
|
|
|
@@ -654,11 +644,37 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
}, 'image/jpeg');
|
|
|
}
|
|
|
|
|
|
+ // ── Private frame dispatch ─────────────────────────────────────────────────
|
|
|
+
|
|
|
/**
|
|
|
- * Polls the visionSocket.lastResult signal until a new result arrives,
|
|
|
- * then draws the bounding boxes on the frozen snapshot canvas.
|
|
|
- * Uses a single-fire check via requestAnimationFrame polling — no setInterval.
|
|
|
+ * Routes a Base64 frame through DpService.stream() via RemoteInferenceService.
|
|
|
+ * Updates visionSocket shim signals so existing template bindings and effects
|
|
|
+ * continue to work without modification.
|
|
|
*/
|
|
|
+ private _sendBase64(base64: string, batchId?: string): void {
|
|
|
+ this._lastResult.set(null);
|
|
|
+ this._lastError.set(null);
|
|
|
+ this._analyzing.set(true);
|
|
|
+
|
|
|
+ this.remoteInference.analyze(base64, batchId)
|
|
|
+ .pipe(untilDestroy(this))
|
|
|
+ .subscribe({
|
|
|
+ next: (res) => {
|
|
|
+ const data = res as any;
|
|
|
+ if (data?.error) {
|
|
|
+ this._lastError.set(data.error);
|
|
|
+ } else {
|
|
|
+ this._lastResult.set(data);
|
|
|
+ }
|
|
|
+ this._analyzing.set(false);
|
|
|
+ },
|
|
|
+ error: (err) => {
|
|
|
+ this._lastError.set(err?.message ?? String(err));
|
|
|
+ this._analyzing.set(false);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
private waitForSocketResult(): void {
|
|
|
const check = () => {
|
|
|
const result = this.visionSocket.lastResult();
|
|
|
@@ -673,7 +689,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
requestAnimationFrame(check);
|
|
|
}
|
|
|
|
|
|
- /** Draw detections on the frozen snapshot canvas (socket engine result) */
|
|
|
private drawSnapDetections(detections: any[]): void {
|
|
|
const canvas = this.snapCanvasRef?.nativeElement;
|
|
|
if (!canvas || !this.snappedFrame) return;
|
|
|
@@ -681,21 +696,14 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
const img = new Image();
|
|
|
img.src = this.snappedFrame;
|
|
|
img.onload = () => {
|
|
|
- // The canvas display width matches the container.
|
|
|
- // The canvas logical size is always set to 640×640 because the backend
|
|
|
- // always runs inference in 640×640 space — coords are always 640-relative
|
|
|
- // regardless of whether the source was a webcam snap (already 640×640) or
|
|
|
- // a gallery image (arbitrary size sent as-is; backend rescales internally).
|
|
|
const containerWidth = canvas.parentElement!.clientWidth || 640;
|
|
|
canvas.width = containerWidth;
|
|
|
- canvas.height = containerWidth; // square: 640px inference space
|
|
|
+ canvas.height = containerWidth;
|
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
if (!ctx) return;
|
|
|
- // Draw the source image stretched to fill the square canvas
|
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
- // Map 640-space coords → canvas pixels via percentage
|
|
|
const scaleX = canvas.width / 640;
|
|
|
const scaleY = canvas.height / 640;
|
|
|
|
|
|
@@ -746,7 +754,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
return Object.keys(this.inferenceService.summary() ?? {});
|
|
|
}
|
|
|
|
|
|
- /** Aggregate industrial_summary across all successful batch results */
|
|
|
getBatchDistribution(): { class: string; count: number; pct: number; color: string }[] {
|
|
|
const report = this.completedReport();
|
|
|
if (!report) return [];
|