nvti commited on
Commit
db14c26
·
1 Parent(s): a2c8ece

feat(report): show analysis progress timeline

Browse files
Files changed (2) hide show
  1. web/app.js +135 -0
  2. web/styles.css +60 -0
web/app.js CHANGED
@@ -1,6 +1,7 @@
1
  import React, {
2
  useEffect,
3
  useMemo,
 
4
  useState,
5
  } from "https://esm.sh/react@18.2.0";
6
  import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
@@ -62,6 +63,103 @@ function percent(value) {
62
  return typeof value === "number" ? `${Math.round(value * 100)}%` : "n/a";
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  function SummaryTab({ result }) {
66
  if (!result) {
67
  return h(
@@ -536,6 +634,8 @@ function App() {
536
  const [activeTab, setActiveTab] = useState("summary");
537
  const [status, setStatus] = useState("idle");
538
  const [error, setError] = useState("");
 
 
539
 
540
  const previewUrl = useMemo(
541
  () => (file ? URL.createObjectURL(file) : ""),
@@ -555,6 +655,29 @@ function App() {
555
  };
556
  }, [previewUrl]);
557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  function toggleLimitation(value) {
559
  setLimitations((current) =>
560
  current.includes(value)
@@ -568,6 +691,7 @@ function App() {
568
  setError("");
569
  setStatus("running");
570
  setResult(null);
 
571
 
572
  const payload = new FormData();
573
  if (file) payload.append("video", file);
@@ -585,10 +709,20 @@ function App() {
585
  });
586
  const body = await response.json();
587
  if (!response.ok) throw new Error(body.detail || "Analysis failed.");
 
588
  setResult(body);
 
589
  setStatus("complete");
590
  } catch (caught) {
 
591
  setError(caught.message || "Analysis failed.");
 
 
 
 
 
 
 
592
  setStatus("idle");
593
  }
594
  }
@@ -741,6 +875,7 @@ function App() {
741
  status === "running" ? "Analyzing..." : "Analyze Form",
742
  ),
743
  error ? h("div", { className: "error" }, error) : null,
 
744
  ),
745
  h(
746
  "section",
 
1
  import React, {
2
  useEffect,
3
  useMemo,
4
+ useRef,
5
  useState,
6
  } from "https://esm.sh/react@18.2.0";
7
  import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
 
63
  return typeof value === "number" ? `${Math.round(value * 100)}%` : "n/a";
64
  }
65
 
66
+ const runningProgressSteps = [
67
+ {
68
+ id: "quality",
69
+ text: "Tôi đang kiểm tra chất lượng video của bạn.",
70
+ delayMs: 0,
71
+ },
72
+ {
73
+ id: "pose",
74
+ text: "Tôi đang tiến hành phân tích dáng tập của bạn.",
75
+ delayMs: 900,
76
+ },
77
+ {
78
+ id: "exercise",
79
+ text: "Tôi đang nhận diện bài tập bạn đang thực hiện.",
80
+ delayMs: 1800,
81
+ },
82
+ {
83
+ id: "reps",
84
+ text: "Tôi đang đếm số reps trong video.",
85
+ delayMs: 2800,
86
+ },
87
+ {
88
+ id: "issues",
89
+ text: "Tôi đang phân tích các lỗi sai trong khi tập của bạn.",
90
+ delayMs: 3900,
91
+ },
92
+ ];
93
+
94
+ function runningProgressState(activeIndex = 0) {
95
+ return runningProgressSteps.map((step, index) => ({
96
+ id: step.id,
97
+ text: step.text,
98
+ status:
99
+ index < activeIndex ? "done" : index === activeIndex ? "active" : "pending",
100
+ }));
101
+ }
102
+
103
+ function finalProgressState(result) {
104
+ const report = result.report;
105
+ const warnings = report.video_manifest?.quality_warnings || [];
106
+ const exercise = label(report.exercise?.exercise || "movement");
107
+ const repCount = report.reps?.reps?.length || 0;
108
+ const issues = report.issue_markers?.issues || [];
109
+ return [
110
+ {
111
+ id: "quality",
112
+ status: "done",
113
+ text: warnings.length
114
+ ? `Video có một vài điểm cần lưu ý: ${warnings.map(label).join(", ")}.`
115
+ : "Ồ, video của bạn có chất lượng quay tốt đó.",
116
+ },
117
+ {
118
+ id: "pose",
119
+ status: "done",
120
+ text: "Tôi đã phân tích xong dáng tập và các điểm landmark chính.",
121
+ },
122
+ {
123
+ id: "exercise",
124
+ status: "done",
125
+ text: `Chúng tôi nhận thấy bạn đang thực hiện ${exercise}.`,
126
+ },
127
+ {
128
+ id: "reps",
129
+ status: "done",
130
+ text: `Tôi đếm được bạn đã thực hiện ${repCount} reps ${exercise}.`,
131
+ },
132
+ {
133
+ id: "issues",
134
+ status: "done",
135
+ text: issues.length
136
+ ? `Tôi tìm thấy ${issues.length} điểm cần chú ý trong khi tập của bạn.`
137
+ : "Tôi không thấy lỗi kỹ thuật rõ ràng trong bài tập này.",
138
+ },
139
+ ];
140
+ }
141
+
142
+ function ProgressPanel({ steps }) {
143
+ if (!steps.length) return null;
144
+ return h(
145
+ "section",
146
+ { className: "progress-panel", "aria-live": "polite" },
147
+ h("h3", null, "Analysis progress"),
148
+ h(
149
+ "ol",
150
+ { className: "progress-list" },
151
+ steps.map((step) =>
152
+ h(
153
+ "li",
154
+ { className: `progress-step ${step.status}`, key: step.id },
155
+ h("span", { className: "progress-dot", "aria-hidden": "true" }),
156
+ h("span", null, step.text),
157
+ ),
158
+ ),
159
+ ),
160
+ );
161
+ }
162
+
163
  function SummaryTab({ result }) {
164
  if (!result) {
165
  return h(
 
634
  const [activeTab, setActiveTab] = useState("summary");
635
  const [status, setStatus] = useState("idle");
636
  const [error, setError] = useState("");
637
+ const [progressSteps, setProgressSteps] = useState([]);
638
+ const progressTimers = useRef([]);
639
 
640
  const previewUrl = useMemo(
641
  () => (file ? URL.createObjectURL(file) : ""),
 
655
  };
656
  }, [previewUrl]);
657
 
658
+ useEffect(() => {
659
+ return () => {
660
+ progressTimers.current.forEach((timer) => clearTimeout(timer));
661
+ };
662
+ }, []);
663
+
664
+ function clearProgressTimers() {
665
+ progressTimers.current.forEach((timer) => clearTimeout(timer));
666
+ progressTimers.current = [];
667
+ }
668
+
669
+ function startProgressTimeline() {
670
+ clearProgressTimers();
671
+ setProgressSteps(runningProgressState(0));
672
+ progressTimers.current = runningProgressSteps
673
+ .slice(1)
674
+ .map((step, index) =>
675
+ setTimeout(() => {
676
+ setProgressSteps(runningProgressState(index + 1));
677
+ }, step.delayMs),
678
+ );
679
+ }
680
+
681
  function toggleLimitation(value) {
682
  setLimitations((current) =>
683
  current.includes(value)
 
691
  setError("");
692
  setStatus("running");
693
  setResult(null);
694
+ startProgressTimeline();
695
 
696
  const payload = new FormData();
697
  if (file) payload.append("video", file);
 
709
  });
710
  const body = await response.json();
711
  if (!response.ok) throw new Error(body.detail || "Analysis failed.");
712
+ clearProgressTimers();
713
  setResult(body);
714
+ setProgressSteps(finalProgressState(body));
715
  setStatus("complete");
716
  } catch (caught) {
717
+ clearProgressTimers();
718
  setError(caught.message || "Analysis failed.");
719
+ setProgressSteps([
720
+ {
721
+ id: "error",
722
+ status: "active",
723
+ text: "Phân tích chưa hoàn tất. Vui lòng thử lại với video khác hoặc kiểm tra kết nối.",
724
+ },
725
+ ]);
726
  setStatus("idle");
727
  }
728
  }
 
875
  status === "running" ? "Analyzing..." : "Analyze Form",
876
  ),
877
  error ? h("div", { className: "error" }, error) : null,
878
+ h(ProgressPanel, { steps: progressSteps }),
879
  ),
880
  h(
881
  "section",
web/styles.css CHANGED
@@ -339,6 +339,66 @@ input[type="text"]:focus,
339
  opacity: 0.72;
340
  }
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  .result-stage {
343
  display: grid;
344
  min-height: 470px;
 
339
  opacity: 0.72;
340
  }
341
 
342
+ .progress-panel {
343
+ border: 1px solid rgba(37, 44, 31, 0.12);
344
+ border-radius: 8px;
345
+ margin-top: 14px;
346
+ padding: 14px;
347
+ background: rgba(255, 253, 247, 0.66);
348
+ }
349
+
350
+ .progress-panel h3 {
351
+ margin: 0 0 12px;
352
+ color: var(--forest);
353
+ font-size: 0.96rem;
354
+ }
355
+
356
+ .progress-list {
357
+ display: grid;
358
+ gap: 10px;
359
+ margin: 0;
360
+ padding: 0;
361
+ list-style: none;
362
+ }
363
+
364
+ .progress-step {
365
+ display: grid;
366
+ grid-template-columns: 18px minmax(0, 1fr);
367
+ gap: 10px;
368
+ align-items: start;
369
+ color: var(--muted);
370
+ line-height: 1.35;
371
+ }
372
+
373
+ .progress-step.done {
374
+ color: #304133;
375
+ }
376
+
377
+ .progress-step.active {
378
+ color: var(--forest-deep);
379
+ font-weight: 600;
380
+ }
381
+
382
+ .progress-dot {
383
+ width: 10px;
384
+ height: 10px;
385
+ border: 2px solid rgba(33, 79, 58, 0.3);
386
+ border-radius: 999px;
387
+ margin-top: 4px;
388
+ background: rgba(255, 253, 247, 0.9);
389
+ }
390
+
391
+ .progress-step.done .progress-dot {
392
+ border-color: var(--forest);
393
+ background: var(--forest);
394
+ }
395
+
396
+ .progress-step.active .progress-dot {
397
+ border-color: var(--clay);
398
+ background: var(--clay);
399
+ box-shadow: 0 0 0 5px rgba(190, 104, 75, 0.14);
400
+ }
401
+
402
  .result-stage {
403
  display: grid;
404
  min-height: 470px;