danielle2035 commited on
Commit
dcfd6c3
·
1 Parent(s): 6cda601

Deploy TrackIQ v8

Browse files
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Install system dependencies (OpenCV, ffmpeg, libGL)
4
+ RUN apt-get update && apt-get install -y \
5
+ libgl1-mesa-glx \
6
+ libglib2.0-0 \
7
+ libsm6 \
8
+ libxext6 \
9
+ libxrender-dev \
10
+ ffmpeg \
11
+ libopencv-dev \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /app
15
+
16
+ # Copy requirements first for layer caching
17
+ COPY requirements_hf.txt .
18
+
19
+ RUN pip install --no-cache-dir -r requirements_hf.txt
20
+
21
+ # Copy app source
22
+ COPY app.py .
23
+ COPY static/ ./static/
24
+ COPY templates/ ./templates/
25
+
26
+ # Create required directories
27
+ RUN mkdir -p models uploads outputs logs
28
+
29
+ # HuggingFace Spaces expose port 7860
30
+ ENV PORT=7860
31
+ EXPOSE 7860
32
+
33
+ # Start Flask on port 7860
34
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,11 +1,43 @@
1
  ---
2
- title: TRAFFIC ROAD APP
3
- emoji: 😻
4
  colorFrom: blue
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: TrackIQ Traffic Road Detection
3
+ emoji: 🚦
4
  colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ app_port: 7860
9
  ---
10
 
11
+ # 🚦 TrackIQ Traffic Road Detection App
12
+
13
+ TrackIQ is a real-time traffic object detection and tracking application powered by YOLO.
14
+
15
+ ## Features
16
+
17
+ - 🎥 Upload and analyze traffic videos
18
+ - 🚗 Detect vehicles, motorcycles, trucks, buses, pedestrians, bicycles, traffic lights, and road signs
19
+ - 📊 Live dashboard with detection statistics
20
+ - 📁 Session history and CSV log export
21
+ - ⚡ CPU-optimized inference with frame skipping
22
+
23
+ ## How to Use
24
+
25
+ 1. Go to the **Home** page and upload a video
26
+ 2. Click **Run** to start detection
27
+ 3. Watch detections stream live on the video
28
+ 4. View stats on the **Dashboard**
29
+ 5. Export logs from the **Logs** page
30
+
31
+ ## Model
32
+
33
+ > ⚠️ **Upload your YOLO model** to the `models/` folder before running.
34
+ > Supported: `yolo11n.pt`, `yolov8n.pt`, etc.
35
+
36
+ Upload the model via the **Files** tab in your HuggingFace Space settings.
37
+
38
+ ## Tech Stack
39
+
40
+ - Flask (Python backend)
41
+ - Ultralytics YOLO
42
+ - OpenCV
43
+ - HTML/CSS/JS frontend
TRAFFIC_ROAD_APP/TRAFFIC_ROAD_APP/requirements_hf.txt ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ absl-py==2.4.0
2
+ alabaster==1.0.0
3
+ altair==5.5.0
4
+ annotated-doc==0.0.4
5
+ annotated-types==0.7.0
6
+ anyio==4.13.0
7
+ asttokens==3.0.0
8
+ astunparse==1.6.3
9
+ attrs==25.4.0
10
+ babel==2.17.0
11
+ beautifulsoup4==4.14.2
12
+ bleach==6.3.0
13
+ blinker==1.9.0
14
+ cachetools==6.2.2
15
+ certifi==2025.11.12
16
+ cffi==2.0.0
17
+ charset-normalizer==3.4.4
18
+ clarabel==0.11.1
19
+ click==8.3.1
20
+ colorama==0.4.6
21
+ comm==0.2.3
22
+ contourpy==1.3.3
23
+ cvxpy==1.7.5
24
+ cycler==0.12.1
25
+ debugpy==1.8.17
26
+ decorator==5.2.1
27
+ defusedxml==0.7.1
28
+ dill==0.4.1
29
+ docutils==0.22.4
30
+ et_xmlfile==2.0.0
31
+ executing==2.2.1
32
+ fastjsonschema==2.21.2
33
+ fica==0.4.1
34
+ filelock==3.24.3
35
+ Flask==3.1.3
36
+ flask-cors==6.0.2
37
+ flatbuffers==25.12.19
38
+ fonttools==4.60.1
39
+ fsspec==2026.2.0
40
+ gast==0.7.0
41
+ geopandas==1.1.2
42
+ git-filter-repo==2.47.0
43
+ gitdb==4.0.12
44
+ GitPython==3.1.45
45
+ google-pasta==0.2.0
46
+ graphviz==0.21
47
+ greenlet==3.3.1
48
+ grpcio==1.80.0
49
+ h11==0.16.0
50
+ h5py==3.14.0
51
+ hf-xet==1.4.3
52
+ httpcore==1.0.9
53
+ httpx==0.28.1
54
+ huggingface_hub==1.10.2
55
+ idna==3.11
56
+ imagesize==1.4.1
57
+ ipykernel==7.1.0
58
+ ipylab==1.1.0
59
+ ipython==9.6.0
60
+ ipython_pygments_lexers==1.1.1
61
+ ipywidgets==8.1.8
62
+ itsdangerous==2.2.0
63
+ jedi==0.19.2
64
+ Jinja2==3.1.6
65
+ joblib==1.5.3
66
+ jsonschema==4.25.1
67
+ jsonschema-specifications==2025.9.1
68
+ jupyter_client==8.6.3
69
+ jupyter_core==5.9.1
70
+ jupyterlab_pygments==0.3.0
71
+ jupyterlab_widgets==3.0.16
72
+ jupytext==1.19.1
73
+ keras==3.14.0
74
+ kiwisolver==1.4.9
75
+ lap==0.5.13
76
+ lapx==0.9.4
77
+ libclang==18.1.1
78
+ lxml==6.0.2
79
+ markdown-it-py==4.0.0
80
+ MarkupSafe==3.0.3
81
+ matplotlib==3.10.7
82
+ matplotlib-inline==0.2.1
83
+ mdit-py-plugins==0.5.0
84
+ mdurl==0.1.2
85
+ mistune==3.2.0
86
+ ml_dtypes==0.5.4
87
+ mpmath==1.3.0
88
+ namex==0.1.0
89
+ narwhals==2.11.0
90
+ nbclient==0.10.4
91
+ nbconvert==7.17.0
92
+ nbformat==5.10.4
93
+ nest-asyncio==1.6.0
94
+ networkx==3.6.1
95
+ numpy==2.3.4
96
+ opencv-python==4.13.0.92
97
+ opencv-python-headless==4.13.0.92
98
+ openpyxl==3.1.5
99
+ opt_einsum==3.4.0
100
+ optree==0.19.0
101
+ osqp==1.0.5
102
+ otter-grader==6.1.6
103
+ outcome==1.3.0.post0
104
+ packaging==25.0
105
+ pandas==2.3.3
106
+ pandocfilters==1.5.1
107
+ parso==0.8.5
108
+ pillow==12.0.0
109
+ platformdirs==4.5.0
110
+ playwright==1.58.0
111
+ plotly==6.4.0
112
+ polars==1.40.0
113
+ polars-runtime-32==1.40.0
114
+ prompt_toolkit==3.0.52
115
+ protobuf==6.33.1
116
+ psutil==7.1.2
117
+ pure_eval==0.2.3
118
+ pyarrow==21.0.0
119
+ pycparser==2.23
120
+ pydantic==2.12.5
121
+ pydantic_core==2.41.5
122
+ pydeck==0.9.1
123
+ pyee==13.0.0
124
+ Pygments==2.19.2
125
+ pyogrio==0.12.1
126
+ pyparsing==3.2.5
127
+ pyproj==3.7.2
128
+ PySocks==1.7.1
129
+ python-dateutil==2.9.0.post0
130
+ python-dotenv==1.2.1
131
+ python-on-whales==0.80.0
132
+ pytz==2025.2
133
+ PyYAML==6.0.3
134
+ pyzmq==27.1.0
135
+ referencing==0.37.0
136
+ requests==2.32.5
137
+ rich==14.3.3
138
+ roman-numerals==4.1.0
139
+ rpds-py==0.28.0
140
+ scikit-learn==1.8.0
141
+ scipy==1.16.3
142
+ scs==3.2.11
143
+ seaborn==0.13.2
144
+ selenium==4.38.0
145
+ setuptools==80.9.0
146
+ shapely==2.1.2
147
+ shellingham==1.5.4
148
+ six==1.17.0
149
+ smmap==5.0.2
150
+ sniffio==1.3.1
151
+ snowballstemmer==3.0.1
152
+ sortedcontainers==2.4.0
153
+ soupsieve==2.8
154
+ Sphinx==9.1.0
155
+ sphinxcontrib-applehelp==2.0.0
156
+ sphinxcontrib-devhelp==2.0.0
157
+ sphinxcontrib-htmlhelp==2.1.0
158
+ sphinxcontrib-jsmath==1.0.1
159
+ sphinxcontrib-qthelp==2.0.0
160
+ sphinxcontrib-serializinghtml==2.0.0
161
+ stack-data==0.6.3
162
+ streamlit==1.51.0
163
+ sympy==1.14.0
164
+ tenacity==9.1.2
165
+ tensorflow==2.21.0
166
+ termcolor==3.3.0
167
+ threadpoolctl==3.6.0
168
+ tinycss2==1.4.0
169
+ toml==0.10.2
170
+ torch==2.11.0
171
+ torchvision==0.26.0
172
+ torchviz==0.0.3
173
+ tornado==6.5.2
174
+ tqdm==4.67.3
175
+ traitlets==5.14.3
176
+ trio==0.32.0
177
+ trio-websocket==0.12.2
178
+ typer==0.24.1
179
+ typing-inspection==0.4.2
180
+ typing_extensions==4.15.0
181
+ tzdata==2025.2
182
+ ultralytics==8.4.39
183
+ ultralytics-thop==2.0.18
184
+ urllib3==2.5.0
185
+ watchdog==6.0.0
186
+ wcwidth==0.2.14
187
+ webdriver-manager==4.0.2
188
+ webencodings==0.5.1
189
+ websocket-client==1.9.0
190
+ Werkzeug==3.1.8
191
+ wheel==0.46.3
192
+ widgetsnbextension==4.0.15
193
+ wrapt==1.17.3
194
+ wsproto==1.3.2
TRAFFIC_ROAD_APP/requirements_hf.txt ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ absl-py==2.4.0
2
+ alabaster==1.0.0
3
+ altair==5.5.0
4
+ annotated-doc==0.0.4
5
+ annotated-types==0.7.0
6
+ anyio==4.13.0
7
+ asttokens==3.0.0
8
+ astunparse==1.6.3
9
+ attrs==25.4.0
10
+ babel==2.17.0
11
+ beautifulsoup4==4.14.2
12
+ bleach==6.3.0
13
+ blinker==1.9.0
14
+ cachetools==6.2.2
15
+ certifi==2025.11.12
16
+ cffi==2.0.0
17
+ charset-normalizer==3.4.4
18
+ clarabel==0.11.1
19
+ click==8.3.1
20
+ colorama==0.4.6
21
+ comm==0.2.3
22
+ contourpy==1.3.3
23
+ cvxpy==1.7.5
24
+ cycler==0.12.1
25
+ debugpy==1.8.17
26
+ decorator==5.2.1
27
+ defusedxml==0.7.1
28
+ dill==0.4.1
29
+ docutils==0.22.4
30
+ et_xmlfile==2.0.0
31
+ executing==2.2.1
32
+ fastjsonschema==2.21.2
33
+ fica==0.4.1
34
+ filelock==3.24.3
35
+ Flask==3.1.3
36
+ flask-cors==6.0.2
37
+ flatbuffers==25.12.19
38
+ fonttools==4.60.1
39
+ fsspec==2026.2.0
40
+ gast==0.7.0
41
+ geopandas==1.1.2
42
+ git-filter-repo==2.47.0
43
+ gitdb==4.0.12
44
+ GitPython==3.1.45
45
+ google-pasta==0.2.0
46
+ graphviz==0.21
47
+ greenlet==3.3.1
48
+ grpcio==1.80.0
49
+ h11==0.16.0
50
+ h5py==3.14.0
51
+ hf-xet==1.4.3
52
+ httpcore==1.0.9
53
+ httpx==0.28.1
54
+ huggingface_hub==1.10.2
55
+ idna==3.11
56
+ imagesize==1.4.1
57
+ ipykernel==7.1.0
58
+ ipylab==1.1.0
59
+ ipython==9.6.0
60
+ ipython_pygments_lexers==1.1.1
61
+ ipywidgets==8.1.8
62
+ itsdangerous==2.2.0
63
+ jedi==0.19.2
64
+ Jinja2==3.1.6
65
+ joblib==1.5.3
66
+ jsonschema==4.25.1
67
+ jsonschema-specifications==2025.9.1
68
+ jupyter_client==8.6.3
69
+ jupyter_core==5.9.1
70
+ jupyterlab_pygments==0.3.0
71
+ jupyterlab_widgets==3.0.16
72
+ jupytext==1.19.1
73
+ keras==3.14.0
74
+ kiwisolver==1.4.9
75
+ lap==0.5.13
76
+ lapx==0.9.4
77
+ libclang==18.1.1
78
+ lxml==6.0.2
79
+ markdown-it-py==4.0.0
80
+ MarkupSafe==3.0.3
81
+ matplotlib==3.10.7
82
+ matplotlib-inline==0.2.1
83
+ mdit-py-plugins==0.5.0
84
+ mdurl==0.1.2
85
+ mistune==3.2.0
86
+ ml_dtypes==0.5.4
87
+ mpmath==1.3.0
88
+ namex==0.1.0
89
+ narwhals==2.11.0
90
+ nbclient==0.10.4
91
+ nbconvert==7.17.0
92
+ nbformat==5.10.4
93
+ nest-asyncio==1.6.0
94
+ networkx==3.6.1
95
+ numpy==2.3.4
96
+ opencv-python==4.13.0.92
97
+ opencv-python-headless==4.13.0.92
98
+ openpyxl==3.1.5
99
+ opt_einsum==3.4.0
100
+ optree==0.19.0
101
+ osqp==1.0.5
102
+ otter-grader==6.1.6
103
+ outcome==1.3.0.post0
104
+ packaging==25.0
105
+ pandas==2.3.3
106
+ pandocfilters==1.5.1
107
+ parso==0.8.5
108
+ pillow==12.0.0
109
+ platformdirs==4.5.0
110
+ playwright==1.58.0
111
+ plotly==6.4.0
112
+ polars==1.40.0
113
+ polars-runtime-32==1.40.0
114
+ prompt_toolkit==3.0.52
115
+ protobuf==6.33.1
116
+ psutil==7.1.2
117
+ pure_eval==0.2.3
118
+ pyarrow==21.0.0
119
+ pycparser==2.23
120
+ pydantic==2.12.5
121
+ pydantic_core==2.41.5
122
+ pydeck==0.9.1
123
+ pyee==13.0.0
124
+ Pygments==2.19.2
125
+ pyogrio==0.12.1
126
+ pyparsing==3.2.5
127
+ pyproj==3.7.2
128
+ PySocks==1.7.1
129
+ python-dateutil==2.9.0.post0
130
+ python-dotenv==1.2.1
131
+ python-on-whales==0.80.0
132
+ pytz==2025.2
133
+ PyYAML==6.0.3
134
+ pyzmq==27.1.0
135
+ referencing==0.37.0
136
+ requests==2.32.5
137
+ rich==14.3.3
138
+ roman-numerals==4.1.0
139
+ rpds-py==0.28.0
140
+ scikit-learn==1.8.0
141
+ scipy==1.16.3
142
+ scs==3.2.11
143
+ seaborn==0.13.2
144
+ selenium==4.38.0
145
+ setuptools==80.9.0
146
+ shapely==2.1.2
147
+ shellingham==1.5.4
148
+ six==1.17.0
149
+ smmap==5.0.2
150
+ sniffio==1.3.1
151
+ snowballstemmer==3.0.1
152
+ sortedcontainers==2.4.0
153
+ soupsieve==2.8
154
+ Sphinx==9.1.0
155
+ sphinxcontrib-applehelp==2.0.0
156
+ sphinxcontrib-devhelp==2.0.0
157
+ sphinxcontrib-htmlhelp==2.1.0
158
+ sphinxcontrib-jsmath==1.0.1
159
+ sphinxcontrib-qthelp==2.0.0
160
+ sphinxcontrib-serializinghtml==2.0.0
161
+ stack-data==0.6.3
162
+ streamlit==1.51.0
163
+ sympy==1.14.0
164
+ tenacity==9.1.2
165
+ tensorflow==2.21.0
166
+ termcolor==3.3.0
167
+ threadpoolctl==3.6.0
168
+ tinycss2==1.4.0
169
+ toml==0.10.2
170
+ torch==2.11.0
171
+ torchvision==0.26.0
172
+ torchviz==0.0.3
173
+ tornado==6.5.2
174
+ tqdm==4.67.3
175
+ traitlets==5.14.3
176
+ trio==0.32.0
177
+ trio-websocket==0.12.2
178
+ typer==0.24.1
179
+ typing-inspection==0.4.2
180
+ typing_extensions==4.15.0
181
+ tzdata==2025.2
182
+ ultralytics==8.4.39
183
+ ultralytics-thop==2.0.18
184
+ urllib3==2.5.0
185
+ watchdog==6.0.0
186
+ wcwidth==0.2.14
187
+ webdriver-manager==4.0.2
188
+ webencodings==0.5.1
189
+ websocket-client==1.9.0
190
+ Werkzeug==3.1.8
191
+ wheel==0.46.3
192
+ widgetsnbextension==4.0.15
193
+ wrapt==1.17.3
194
+ wsproto==1.3.2
TRAFFIC_ROAD_APP/templates/dashboard.html ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ Dashboard</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.min.js"></script>
11
+ <style>
12
+ :root{--bg:#0c1117;--s:#151c25;--s2:#1c2531;--bd:rgba(255,255,255,0.07);--sh:0 2px 16px rgba(0,0,0,0.45);--shlg:0 8px 36px rgba(0,0,0,0.55);--t:#e8edf2;--t2:#7a8fa0;--t3:#3a4a5a;--ac:#880000;--ac2:#660000;--acl:rgba(136,0,0,0.12);--gr:#16a34a;--grb:#052e16;--bl:#3b82f6;--blb:#0f2040;--yw:#f59e0b;--ywb:#1a0f00;--re:#ef4444;--reb:#2d0000;--sw:60px;--r:14px;--rs:9px;}
13
+ [data-theme="light"]{--bg:#f0f4f8;--s:#fff;--s2:#f6f8fb;--bd:rgba(0,0,0,0.07);--sh:0 2px 16px rgba(0,0,0,0.09);--shlg:0 8px 36px rgba(0,0,0,0.13);--t:#0f1923;--t2:#5a6a7a;--t3:#9aaab8;--grb:#dcfce7;--blb:#dbeafe;--ywb:#fef9c3;--reb:#fee2e2;}
14
+ *{box-sizing:border-box;margin:0;padding:0}::-webkit-scrollbar{width:0;height:0}
15
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--t);font-size:13px;display:flex;height:100vh;overflow:hidden;transition:background .3s,color .3s}
16
+ .sb{width:var(--sw);background:var(--s);border-right:1px solid var(--bd);display:flex;flex-direction:column;align-items:center;padding:14px 0;gap:4px;flex-shrink:0}
17
+ .sbl{width:34px;height:34px;background:var(--ac);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;margin-bottom:10px;text-decoration:none}
18
+ .sbsep{width:26px;height:1px;background:var(--bd);margin:3px 0}
19
+ .sbb{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:14px;cursor:pointer;transition:all .2s;text-decoration:none;border:none;background:none;position:relative}
20
+ .sbb:hover{background:var(--s2);color:var(--t)}.sbb.on{background:var(--acl);color:var(--ac)}
21
+ .sbb .tip{position:absolute;left:44px;top:50%;transform:translateY(-50%);background:var(--t);color:var(--bg);font-size:10px;padding:3px 7px;border-radius:4px;white-space:nowrap;opacity:0;pointer-events:none;z-index:300}.sbb:hover .tip{opacity:1}
22
+ .sbsp{flex:1}.pw{flex:1;display:flex;flex-direction:column;overflow:hidden}
23
+ .topb{height:50px;background:var(--s);border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;padding:0 18px;flex-shrink:0}
24
+ .topl,.topr{display:flex;align-items:center;gap:9px}.bn{font-size:14px;font-weight:600}.bn b{color:var(--ac)}
25
+ .pill{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--gr);background:var(--grb);padding:2px 8px;border-radius:20px}
26
+ .ld{width:6px;height:6px;border-radius:50%;background:var(--gr);animation:blink 2s infinite}
27
+ @keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
28
+ .clk{font-size:10px;font-family:'DM Mono',monospace;color:var(--t2)}
29
+ .lg{display:flex;background:var(--s2);border-radius:5px;padding:2px;border:1px solid var(--bd)}
30
+ .lb{font-size:10px;font-weight:500;padding:2px 8px;border:none;background:none;cursor:pointer;border-radius:3px;color:var(--t2);font-family:'DM Sans',sans-serif;transition:all .2s}.lb.on{background:var(--ac);color:#fff}
31
+ .ib{width:28px;height:28px;border-radius:5px;border:1px solid var(--bd);background:var(--s);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--t2);font-size:11px;position:relative;transition:all .2s}.ib:hover{background:var(--s2)}
32
+ .nd{position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--ac)}
33
+ main{flex:1;padding:12px 14px;display:grid;grid-template-columns:minmax(0,1.4fr) 360px;gap:12px;overflow:auto;min-height:0}
34
+ .left-col{display:grid;grid-template-rows:auto minmax(190px,1fr) minmax(190px,1fr);gap:10px;min-height:0}
35
+ .right-col{display:grid;grid-template-rows:minmax(300px,1.15fr) auto auto;gap:10px;min-height:0}
36
+ .mr{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
37
+ .mc,.cd{background:var(--s);border:1px solid var(--bd);box-shadow:var(--sh);overflow:hidden}
38
+ .mc{border-radius:var(--rs);padding:11px 13px;transition:transform .2s}.mc:hover{transform:translateY(-2px)}
39
+ .cd{border-radius:var(--r);padding:12px 14px;display:flex;flex-direction:column}.cd:hover{box-shadow:var(--shlg)}
40
+ .mct{display:flex;align-items:center;justify-content:space-between;margin-bottom:5px}
41
+ .mcl{font-size:10px;color:var(--t2)}.bx{font-size:9px;font-weight:500;padding:1px 5px;border-radius:4px}
42
+ .mv{font-size:20px;font-weight:300;letter-spacing:-.5px;line-height:1}.mu{font-size:10px;color:var(--t2)}.ms{font-size:9px;color:var(--t3);margin-top:2px}
43
+ .ct{font-size:11px;font-weight:500;margin-bottom:8px;display:flex;align-items:center;gap:5px;flex-shrink:0}.ct i{color:var(--ac);font-size:10px}
44
+ .chart-wrap{flex:1;min-height:0}
45
+ .stack{display:grid;grid-template-columns:1fr 1fr;gap:10px}
46
+ .hmcd{padding:0}
47
+ .hmhd{padding:10px 12px 6px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
48
+ .hmhd .ct{margin-bottom:0}
49
+ .hmbd{flex:1;position:relative;min-height:220px}
50
+ #hmc2{position:absolute;inset:0;width:100%!important;height:100%!important;display:block}
51
+ .stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}
52
+ .statbox{background:var(--s2);border:1px solid var(--bd);border-radius:8px;padding:10px 11px}
53
+ .statbox .k{font-size:9px;color:var(--t2);text-transform:uppercase;letter-spacing:.04em}
54
+ .statbox .v{font-size:18px;font-weight:300;margin-top:4px}
55
+ .subline{font-size:10px;color:var(--t2)}
56
+ .listbox{display:flex;flex-direction:column;gap:6px}
57
+ .vr{display:flex;align-items:center;justify-content:space-between;background:var(--s2);border:1px solid var(--bd);border-radius:8px;padding:8px 10px}
58
+ .vrl{display:flex;align-items:center;gap:7px;font-size:11px}.vic{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px}.vp{font-size:13px;font-weight:500}
59
+ .al{display:flex;align-items:flex-start;gap:8px;padding:7px 0;border-bottom:1px solid var(--bd)}.al:last-child{border-bottom:none}
60
+ .ai{width:22px;height:22px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;margin-top:1px}
61
+ .at2{font-size:10px;font-weight:500;margin-bottom:1px}.as2{font-size:9px;color:var(--t2)}
62
+ .pill-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.mini-pill{font-size:9px;padding:3px 7px;border-radius:999px;background:var(--s2);border:1px solid var(--bd);color:var(--t2)}
63
+ .empty-note{font-size:10px;color:var(--t3);padding-top:4px}
64
+ @media(max-width:1180px){main{grid-template-columns:1fr}.right-col{grid-template-rows:minmax(260px,auto) auto auto}.stack{grid-template-columns:1fr}.mr{grid-template-columns:repeat(2,minmax(0,1fr))}}
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <nav class="sb">
69
+ <a href="/" class="sbl"><i class="fas fa-satellite-dish"></i></a>
70
+ <div class="sbsep"></div>
71
+ <a href="/home" class="sbb"><i class="fas fa-play-circle"></i><span class="tip">Analyze</span></a>
72
+ <a href="/dashboard" class="sbb on"><i class="fas fa-chart-line"></i><span class="tip">Dashboard</span></a>
73
+ <a href="/logs" class="sbb"><i class="fas fa-list-ul"></i><span class="tip">Logs</span></a>
74
+ <a href="/history" class="sbb"><i class="fas fa-film"></i><span class="tip">History</span></a>
75
+ <div class="sbsp"></div>
76
+ <button class="sbb" onclick="toggleTheme()"><i class="fas fa-moon" id="ti"></i><span class="tip">Theme</span></button>
77
+ </nav>
78
+
79
+ <div class="pw">
80
+ <header class="topb">
81
+ <div class="topl">
82
+ <span class="bn">Track<b>IQ</b></span>
83
+ <span style="color:var(--bd);font-size:16px;margin:0 1px;">|</span>
84
+ <span style="font-size:12px;font-weight:500;" id="t-dash">Dashboard</span>
85
+ <div class="pill"><span class="ld"></span> <span id="liveBadge">Live</span></div>
86
+ </div>
87
+ <div class="topr">
88
+ <span class="clk" id="clk">--:--:--</span>
89
+ <div class="lg">
90
+ <button class="lb on" onclick="setLang('en',this)">EN</button>
91
+ <button class="lb" onclick="setLang('fr',this)">FR</button>
92
+ <button class="lb" onclick="setLang('wo',this)">WO</button>
93
+ </div>
94
+ <button class="ib" onclick="toggleNotifs()"><i class="fas fa-bell"></i><span class="nd" id="nd"></span></button>
95
+ <button class="ib" onclick="toggleTheme()"><i class="fas fa-moon" id="ti2"></i></button>
96
+ </div>
97
+ </header>
98
+
99
+ <div id="notifP" style="display:none;position:fixed;top:57px;right:65px;z-index:500;width:255px;background:var(--s);border:1px solid var(--bd);border-radius:var(--r);box-shadow:var(--shlg);padding:12px 14px;">
100
+ <div style="font-size:11px;font-weight:500;margin-bottom:8px;display:flex;justify-content:space-between;"><span id="t-notif">Notifications</span><button onclick="clearN()" style="font-size:10px;color:var(--ac);background:none;border:none;cursor:pointer;">Clear all</button></div>
101
+ <div id="nl">
102
+ <div style="padding:6px 0;border-bottom:1px solid var(--bd);"><div style="font-size:10px;font-weight:500;color:var(--re);" id="t-a1">Congestion Zone A</div><div style="font-size:9px;color:var(--t2);">4 min ago</div></div>
103
+ <div style="padding:6px 0;"><div style="font-size:10px;font-weight:500;" id="t-source-loaded">Source ready</div><div style="font-size:9px;color:var(--t2);">Just now</div></div>
104
+ </div>
105
+ </div>
106
+
107
+ <main>
108
+ <section class="left-col">
109
+ <div class="mr">
110
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m1">Objects Today</span><span class="bx" style="background:var(--grb);color:var(--gr);" id="m1Trend">+0%</span></div><div><span class="mv" id="mvToday">0</span> <span class="mu" id="t-veh">obj.</span></div><div class="ms" id="m1Sub">Latest analyzed scenes</div></div>
111
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m2">Current Flow</span><span class="bx" style="background:var(--grb);color:var(--gr);" id="flowBadge">Smooth</span></div><div><span class="mv" id="mvFlow">0</span> <span class="mu">obj/min</span></div><div class="ms" id="m2Sub">Based on recent activity</div></div>
112
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m3">Avg FPS</span><span class="bx" style="background:var(--blb);color:var(--bl);" id="fpsBadge">Stable</span></div><div><span class="mv" id="mvFps">0</span> <span class="mu">fps</span></div><div class="ms" id="m3Sub">Last completed analysis</div></div>
113
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m4">Latest clip</span><span class="bx" style="background:var(--ywb);color:var(--yw);" id="accBadge">Current</span></div><div><span class="mv" id="mvAcc">0</span> <span class="mu">s</span></div><div class="ms" id="m4Sub">Duration of latest processed video</div></div>
114
+ </div>
115
+
116
+ <div class="stack">
117
+ <div class="cd">
118
+ <div class="ct"><i class="fas fa-chart-area"></i><span id="t-c1">Flow & speed</span></div>
119
+ <div class="chart-wrap"><canvas id="fc"></canvas></div>
120
+ </div>
121
+ <div class="cd">
122
+ <div class="ct"><i class="fas fa-chart-bar"></i><span id="t-c2">Classification</span></div>
123
+ <div class="chart-wrap"><canvas id="tc"></canvas></div>
124
+ </div>
125
+ </div>
126
+
127
+ <div class="cd">
128
+ <div class="ct"><i class="fas fa-layer-group"></i><span id="t-scenes">Scene summary</span></div>
129
+ <div class="stats-grid">
130
+ <div class="statbox"><div class="k" id="t-live-source">Source</div><div class="v" id="liveSource">No source</div><div class="subline" id="liveSourceMeta">Ready after analysis</div></div>
131
+ <div class="statbox"><div class="k" id="t-live-objects">Objects</div><div class="v" id="liveObjects" id="liveObjects">0</div><div class="subline" id="liveObjectsMeta">Peak visible count</div></div>
132
+ <div class="statbox"><div class="k" id="t-live-fps">FPS</div><div class="v" id="liveFps" id="liveFps">0</div><div class="subline" id="liveFpsMeta">Captured stream rate</div></div>
133
+ <div class="statbox"><div class="k" id="t-live-duration">Duration</div><div class="v" id="liveDuration" id="liveDuration">0s</div><div class="subline" id="liveDurationMeta">Latest processed clip</div></div>
134
+ </div>
135
+ <div class="pill-row" id="liveClassPills"></div>
136
+ <div class="empty-note" id="liveNote">Current summary follows the latest processed analysis instead of the old floating video preview.</div>
137
+ <div id="sceneSummaryGrid" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px;"></div>
138
+ </div>
139
+ </section>
140
+
141
+ <aside class="right-col">
142
+ <div class="cd hmcd">
143
+ <div class="hmhd"><div class="ct"><i class="fas fa-map-marker-alt"></i><span id="t-hm">Position heatmap</span></div><span style="font-size:9px;color:var(--t3);" id="t-hms">Object density</span></div>
144
+ <div class="hmbd"><canvas id="hmc2"></canvas></div>
145
+ </div>
146
+
147
+ <div class="stack">
148
+ <div class="cd" style="padding:11px 12px;">
149
+ <div class="ct">
150
+ <i class="fas fa-chart-pie"></i>
151
+ <span>Object Type Distribution</span>
152
+ </div>
153
+ <!-- Pie chart + légende -->
154
+ <div style="position:relative;display:flex;align-items:center;gap:10px;min-height:140px;">
155
+ <div style="width:145px;height:145px;flex-shrink:0;">
156
+ <canvas id="pieChart"></canvas>
157
+
158
+ </div>
159
+ <!-- Légende dynamique -->
160
+ <div id="pieLegend" style="flex:1;display:flex;flex-direction:column;gap:5px;">
161
+ <div style="font-size:10px;color:var(--t3);text-align:center;">No data yet</div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ <div class="cd" style="padding:11px 12px;">
166
+ <div class="ct"><i class="fas fa-wave-square"></i><span id="t-live-panel">Live stats</span></div>
167
+ <div class="stats-grid">
168
+ <div class="statbox"><div class="k" id="t-det">Detected</div><div class="v" id="sbdet">0</div></div>
169
+ <div class="statbox"><div class="k" id="t-acc">Latest FPS</div><div class="v" id="sbacc">0</div></div>
170
+ </div>
171
+ <div class="empty-note" id="livePanelNote">Heatmap and key stats stay visible at all times.</div>
172
+ </div>
173
+ </div>
174
+ </aside>
175
+ </main>
176
+ </div>
177
+
178
+ <script>
179
+ setInterval(()=>{document.getElementById('clk').textContent=new Date().toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit'});},1000);
180
+
181
+ function toggleTheme(){
182
+ const d=document.documentElement,dk=d.getAttribute('data-theme')==='dark';
183
+ d.setAttribute('data-theme',dk?'light':'dark');
184
+ ['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className=dk?'fas fa-moon':'fas fa-sun';});
185
+ rebuildCharts();
186
+ setTimeout(drawHm,50);
187
+ rebuildPie();
188
+ try{localStorage.setItem('tiq_theme',dk?'light':'dark');}catch(e){}
189
+ }
190
+
191
+ const TL={
192
+ en:{dash:'Dashboard',m1:'Objects Today',veh:'obj.',m2:'Current Flow',m3:'Avg FPS',m4:'Latest clip',c1:'Flow from video',c2:'Classification',hm:'Position heatmap',hms:'Detected positions',bk:'Breakdown',ca:'Cars',mo:'Moto',tr:'Trucks',al:'Alerts',notif:'Notifications',scenes:'Scene summary',livesource:'Source',liveobjects:'Objects',livefps:'FPS',liveduration:'Duration',livepanel:'Live stats',det:'Detected',acc:'Latest FPS'},
193
+ fr:{dash:'Tableau de bord',m1:'Objets du jour',veh:'obj.',m2:'Flux actuel',m3:'FPS moy.',m4:'Dernier clip',c1:'Flux vidéo',c2:'Classification',hm:'Carte de densité',hms:'Positions détectées',bk:'Répartition',ca:'Voitures',mo:'Motos',tr:'Camions',al:'Alertes',notif:'Notifications',scenes:'Résumé scène',livesource:'Source',liveobjects:'Objets',livefps:'FPS',liveduration:'Durée',livepanel:'Stats live',det:'Détectés',acc:'FPS récent'},
194
+ wo:{dash:'Dashboard',m1:'Objets tey',veh:'obj.',m2:'Yóbbu',m3:'FPS moy.',m4:'Clip mujj',c1:'Flux video',c2:'Wërsëg',hm:'Kàrt',hms:'Posision yi',bk:'Wërsëg',ca:'Cars',mo:'Moto',tr:'Trucks',al:'Yëgël',notif:'Yëgël yi',scenes:'Melo scène',livesource:'Source',liveobjects:'Objets',livefps:'FPS',liveduration:'Yoon',livepanel:'Stats live',det:'Yi réy',acc:'FPS mujj'}
195
+ };
196
+
197
+ function setLang(l,btn){
198
+ document.querySelectorAll('.lb').forEach(b=>b.classList.remove('on'));
199
+ btn.classList.add('on');
200
+ const t=TL[l];
201
+ const mp={dash:'t-dash',m1:'t-m1',veh:'t-veh',m2:'t-m2',m3:'t-m3',m4:'t-m4',c1:'t-c1',c2:'t-c2',hm:'t-hm',hms:'t-hms',bk:'t-bk',ca:'t-ca',mo:'t-mo',tr:'t-tr',al:'t-al',notif:'t-notif',scenes:'t-scenes',livesource:'t-live-source',liveobjects:'t-live-objects',livefps:'t-live-fps',liveduration:'t-live-duration',livepanel:'t-live-panel',det:'t-det',acc:'t-acc'};
202
+ Object.entries(mp).forEach(([k,id])=>{const el=document.getElementById(id);if(el&&t[k])el.textContent=t[k];});
203
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
204
+ }
205
+
206
+ let no=false;
207
+ function toggleNotifs(){no=!no;document.getElementById('notifP').style.display=no?'block':'none';if(no)document.getElementById('nd').style.display='none';}
208
+ function clearN(){document.getElementById('nl').innerHTML='<div style="font-size:10px;color:var(--t3);text-align:center;padding:10px;">No notifications</div>';no=false;document.getElementById('notifP').style.display='none';}
209
+ document.addEventListener('click',e=>{if(no&&!e.target.closest('#notifP')&&!e.target.closest('.ib'))toggleNotifs();});
210
+
211
+ window.fch=null;
212
+ window.tch=null;
213
+ window._tiqHeatPoints=[];
214
+
215
+ function gc(){
216
+ const dk=document.documentElement.getAttribute('data-theme')==='dark';
217
+ return{grid:dk?'rgba(255,255,255,0.05)':'rgba(0,0,0,0.06)',tick:dk?'#4a5a6a':'#94a3b8'};
218
+ }
219
+
220
+ /* ── Pie chart — Object Type Distribution ── */
221
+ window.pch = null;
222
+
223
+ function rebuildPie(labels=[], data=[], colors=[]){
224
+ // Enregistre datalabels seulement si dispo
225
+ if(window.ChartDataLabels) Chart.register(ChartDataLabels);
226
+ if(window.pch) window.pch.destroy();
227
+ const canvas = document.getElementById('pieChart');
228
+ if(!canvas) return;
229
+ const dk = document.documentElement.getAttribute('data-theme')==='dark';
230
+
231
+ const defaultColors = ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ec4899','#06b6d4','#ef4444','#f97316'];
232
+
233
+ window.pch = new Chart(canvas, {
234
+ type: 'pie',
235
+ data: {
236
+ labels: labels.length ? labels : ['No data'],
237
+ datasets: [{
238
+ data: data.length ? data : [1],
239
+ backgroundColor: colors.length ? colors : [dk?'#1c2531':'#e2e8f0'],
240
+ borderColor: dk ? '#151c25' : '#ffffff',
241
+ borderWidth: 2,
242
+ hoverOffset: 6,
243
+ }]
244
+ },
245
+ options: {
246
+ responsive: true,
247
+ maintainAspectRatio: false,
248
+ plugins: {
249
+ legend: { display: false },
250
+ tooltip: {
251
+ callbacks: {
252
+ label: ctx => {
253
+ const tot = ctx.dataset.data.reduce((a,b)=>a+b,0);
254
+ return ` ${ctx.label}: ${ctx.raw} (${Math.round(ctx.raw/tot*100)}%)`;
255
+ }
256
+ }
257
+ },
258
+ /* Affiche les % à l'intérieur de chaque part */
259
+ datalabels: {
260
+ color: '#fff',
261
+ font: { size: 11, weight: 'bold' },
262
+ formatter: (value, ctx) => {
263
+ const tot = ctx.dataset.data.reduce((a,b)=>a+b,0);
264
+ const pct = Math.round(value/tot*100);
265
+ return pct >= 5 ? pct+'%' : ''; // cache si trop petit
266
+ }
267
+ }
268
+ }
269
+ }
270
+ });
271
+ }
272
+
273
+ function updatePie(uc){
274
+ const COLORS = {
275
+ 'Vehicle':'#3b82f6','Motorcycle':'#10b981','Truck':'#f59e0b',
276
+ 'Bus':'#8b5cf6','Human':'#ec4899','Bicycle':'#06b6d4',
277
+ 'Traffic light':'#ef4444','Road sign':'#f97316'
278
+ };
279
+ const EMO = {
280
+ 'Vehicle':'🚗','Motorcycle':'🏍','Truck':'🚚','Bus':'🚌',
281
+ 'Human':'🚶','Bicycle':'🚲','Traffic light':'🚦','Road sign':'🛑'
282
+ };
283
+
284
+ const labels = Object.keys(uc);
285
+ const data = Object.values(uc);
286
+ const colors = labels.map(l => COLORS[l] || '#888');
287
+ const total = data.reduce((a,b)=>a+b,0);
288
+
289
+ // Recrée le chart
290
+ rebuildPie(labels, data, colors);
291
+
292
+ // Met à jour le label central
293
+ const ptn = document.getElementById('pieTotalNum');
294
+ if(ptn) ptn.textContent = total;
295
+
296
+ // Légende dynamique
297
+ const leg = document.getElementById('pieLegend');
298
+ if(!leg) return;
299
+ leg.innerHTML = '';
300
+ labels.forEach((lbl, i) => {
301
+ const pct = Math.round(data[i]/Math.max(total,1)*100);
302
+ const row = document.createElement('div');
303
+ row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:6px;';
304
+ row.innerHTML = `
305
+ <div style="display:flex;align-items:center;gap:5px;font-size:11px;overflow:hidden;">
306
+ <span style="width:9px;height:9px;border-radius:2px;background:${colors[i]};flex-shrink:0;display:inline-block;"></span>
307
+ <span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${EMO[lbl]||'📦'} ${lbl}</span>
308
+ </div>
309
+ <span style="font-size:10px;color:var(--t2);flex-shrink:0;">${data[i]}</span>
310
+ `;
311
+ leg.appendChild(row);
312
+ });
313
+ }
314
+
315
+ function rebuildCharts(){
316
+ if(window.fch)window.fch.destroy();
317
+ if(window.tch)window.tch.destroy();
318
+ const {grid,tick}=gc();
319
+ /* Flow chart : 2 datasets (Objects + FPS) — compatible avec trackiq-api.js */
320
+ window.fch=new Chart(document.getElementById('fc'),{
321
+ data:{labels:['—'],datasets:[
322
+ {type:'line',label:'Objects',data:[0],borderColor:'#3b82f6',backgroundColor:'rgba(59,130,246,0.08)',borderWidth:1.5,tension:0.45,pointRadius:0,fill:true,yAxisID:'y'},
323
+ {type:'line',label:'FPS',data:[0],borderColor:'#10b981',backgroundColor:'rgba(16,185,129,0.05)',borderWidth:1.5,tension:0.45,pointRadius:0,yAxisID:'y2'}
324
+ ]},
325
+ options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{boxWidth:8,font:{size:9},color:tick}}},scales:{x:{ticks:{font:{size:9},color:tick,maxTicksLimit:8},grid:{color:grid},border:{display:false}},y:{position:'left',beginAtZero:true,ticks:{font:{size:9},color:tick},grid:{color:grid},border:{display:false}},y2:{position:'right',beginAtZero:true,ticks:{font:{size:9},color:tick},grid:{drawOnChartArea:false},border:{display:false}}}}
326
+ });
327
+ /* Classification chart : NON stacké, 1 dataset — compatible avec trackiq-api.js */
328
+ window.tch=new Chart(document.getElementById('tc'),{
329
+ type:'bar',
330
+ data:{labels:['—'],datasets:[
331
+ {label:'Count',data:[0],backgroundColor:'#3b82f6',borderRadius:3,borderSkipped:false}
332
+ ]},
333
+ options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:{font:{size:9},color:tick},grid:{color:grid},border:{display:false}},y:{beginAtZero:true,ticks:{font:{size:9},color:tick},grid:{color:grid},border:{display:false}}}}
334
+ });
335
+ }
336
+
337
+ function drawHm(){
338
+ const c=document.getElementById('hmc2'),b=c.parentElement;
339
+ const w=b.offsetWidth,h=b.offsetHeight;
340
+ if(!w||!h)return;
341
+ c.width=w;c.height=h;
342
+ const ctx=c.getContext('2d');
343
+ const dk=document.documentElement.getAttribute('data-theme')==='dark';
344
+ ctx.fillStyle=dk?'#0c1219':'#e8eef5';
345
+ ctx.fillRect(0,0,w,h);
346
+ ctx.strokeStyle=dk?'rgba(255,255,255,0.07)':'rgba(0,0,0,0.07)';
347
+ ctx.lineWidth=1;
348
+ [0.25,0.5,0.75].forEach(r=>{ctx.beginPath();ctx.moveTo(0,h*r);ctx.lineTo(w,h*r);ctx.stroke();ctx.beginPath();ctx.moveTo(w*r,0);ctx.lineTo(w*r,h);ctx.stroke();});
349
+ const pts=Array.isArray(window._tiqHeatPoints)?window._tiqHeatPoints:[];
350
+ if(pts.length){
351
+ let mxR=0,mxH=0;
352
+ pts.forEach(p=>{if(p.r>mxR)mxR=p.r;if(p.h>mxH)mxH=p.h;});
353
+ mxR=Math.max(mxR,0.01);mxH=Math.max(mxH,0.01);
354
+ pts.forEach(p=>{
355
+ const px=(p.x/mxR)*w,py=(p.y/mxH)*h;
356
+ const rad=Math.max(10,Math.min(w,h)*0.015);
357
+ const grd=ctx.createRadialGradient(px,py,0,px,py,rad);
358
+ grd.addColorStop(0,'rgba(220,38,38,0.5)');
359
+ grd.addColorStop(0.4,'rgba(180,0,0,0.18)');
360
+ grd.addColorStop(1,'rgba(0,0,0,0)');
361
+ ctx.fillStyle=grd;ctx.fillRect(0,0,w,h);
362
+ });
363
+ ctx.font="9px 'DM Mono',monospace";
364
+ ctx.fillStyle=dk?'rgba(255,255,255,0.3)':'rgba(0,0,0,0.25)';
365
+ ctx.textAlign='left';ctx.fillText(pts.length+' points',8,13);
366
+ }else{
367
+ ctx.fillStyle=dk?'rgba(255,255,255,0.18)':'rgba(0,0,0,0.2)';
368
+ ctx.font="12px 'DM Sans',sans-serif";ctx.textAlign='center';
369
+ ctx.fillText('Run an analysis to build the heatmap',w/2,h/2);
370
+ ctx.textAlign='left';
371
+ }
372
+ ctx.font="10px 'DM Mono',monospace";
373
+ ctx.fillStyle=dk?'rgba(255,255,255,0.24)':'rgba(0,0,0,0.2)';
374
+ ctx.fillText('N',w/2-3,12);ctx.fillText('S',w/2-3,h-5);ctx.fillText('W',5,h/2+3);ctx.fillText('E',w-10,h/2+3);
375
+ }
376
+
377
+ async function loadHeatmap(){
378
+ try{
379
+ const r=await fetch('/api/logs/rows');
380
+ if(!r.ok)return;
381
+ const rows=await r.json();
382
+ if(!rows.length)return;
383
+ const pts=[];let mxX=0,mxY=0;
384
+ rows.slice(0,4000).forEach(r=>{
385
+ if(r.cx>0&&r.cy>0){pts.push({x:r.cx,y:r.cy,r:0,h:0});if(r.cx>mxX)mxX=r.cx;if(r.cy>mxY)mxY=r.cy;}
386
+ });
387
+ if(!pts.length)return;
388
+ pts.forEach(p=>{p.r=mxX;p.h=mxY;});
389
+ window._tiqHeatPoints=pts;
390
+ drawHm();
391
+ }catch(e){}
392
+ }
393
+
394
+ setTimeout(rebuildCharts,120);
395
+ setTimeout(rebuildPie,150);
396
+ setTimeout(drawHm,180);
397
+ setTimeout(loadHeatmap,600);
398
+ setInterval(loadHeatmap,20000);
399
+ window.addEventListener('resize',()=>setTimeout(drawHm,50));
400
+
401
+ (function(){
402
+ try{
403
+ const th=localStorage.getItem('tiq_theme');
404
+ if(th){
405
+ document.documentElement.setAttribute('data-theme',th);
406
+ if(th==='dark'){['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className='fas fa-sun';});}
407
+ }
408
+ const lg=localStorage.getItem('tiq_lang');
409
+ if(lg&&lg!=='en'){
410
+ const btn=document.querySelector('.lb[onclick*="\''+lg+'\'"]');
411
+ if(btn)setLang(lg,btn);
412
+ }
413
+ }catch(e){}
414
+ })();
415
+ </script>
416
+ <script src="/static/trackiq-api.js"></script>
417
+ </body>
418
+ </html>
TRAFFIC_ROAD_APP/templates/history.html ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ — History</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
10
+ <style>
11
+ :root{--bg:#f0f4f8;--surface:#fff;--surface2:#f6f8fb;--border:rgba(0,0,0,0.07);--shadow:0 2px 16px rgba(0,0,0,0.09);--shadow-lg:0 8px 36px rgba(0,0,0,0.13);--text:#0f1923;--text2:#5a6a7a;--text3:#9aaab8;--accent:#880000;--accent2:#660000;--accent-light:rgba(136,0,0,0.07);--green:#16a34a;--green-bg:#dcfce7;--sidebar-w:60px;--radius:14px;--radius-sm:9px;}
12
+ [data-theme="dark"]{--bg:#0c1117;--surface:#151c25;--surface2:#1c2531;--border:rgba(255,255,255,0.07);--shadow:0 2px 16px rgba(0,0,0,0.45);--text:#e8edf2;--text2:#7a8fa0;--text3:#3a4a5a;--green-bg:#052e16;}
13
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
14
+ ::-webkit-scrollbar{width:0;}
15
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);font-size:13px;display:flex;min-height:100vh;transition:background 0.3s,color 0.3s;}
16
+
17
+ .sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;align-items:center;padding:16px 0;gap:4px;position:fixed;top:0;left:0;bottom:0;z-index:200;box-shadow:2px 0 12px rgba(0,0,0,0.04);}
18
+ .sb-logo{width:34px;height:34px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:white;font-size:14px;margin-bottom:10px;text-decoration:none;}
19
+ .sb-sep{width:28px;height:1px;background:var(--border);margin:4px 0;}
20
+ .sb-btn{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:15px;cursor:pointer;transition:all 0.2s;text-decoration:none;border:none;background:none;position:relative;}
21
+ .sb-btn:hover{background:var(--surface2);color:var(--text);}
22
+ .sb-btn.active{background:var(--accent-light);color:var(--accent);}
23
+ .sb-btn .tip{position:absolute;left:46px;top:50%;transform:translateY(-50%);background:var(--text);color:var(--surface);font-size:11px;padding:3px 8px;border-radius:5px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity 0.15s;z-index:300;}
24
+ .sb-btn:hover .tip{opacity:1;}
25
+ .sb-spacer{flex:1;}
26
+
27
+ .page-wrap{margin-left:var(--sidebar-w);flex:1;display:flex;flex-direction:column;}
28
+ .topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 22px;position:sticky;top:0;z-index:100;}
29
+ .topbar-l{display:flex;align-items:center;gap:10px;}
30
+ .brand-name{font-size:15px;font-weight:600;} .brand-name b{color:var(--accent);}
31
+ .sep{color:var(--border);font-size:18px;margin:0 2px;}
32
+ .topbar-r{display:flex;align-items:center;gap:7px;}
33
+ .lang-group{display:flex;background:var(--surface2);border-radius:6px;padding:2px;border:1px solid var(--border);}
34
+ .lang-btn{font-size:11px;font-weight:500;padding:3px 8px;border:none;background:none;cursor:pointer;border-radius:4px;color:var(--text2);font-family:'DM Sans',sans-serif;transition:all 0.2s;}
35
+ .lang-btn.active{background:var(--accent);color:white;}
36
+ .icon-btn{width:30px;height:30px;border-radius:6px;border:1px solid var(--border);background:var(--surface);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:12px;}
37
+ .icon-btn:hover{background:var(--surface2);}
38
+
39
+ main{flex:1;padding:18px 20px;max-width:1380px;width:100%;margin:0 auto;display:flex;flex-direction:column;gap:16px;}
40
+
41
+ /* PLAYER CARD — full width */
42
+ .player-card{grid-column:1/3;background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);box-shadow:var(--shadow-lg);overflow:hidden;}
43
+ .player-head{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;}
44
+ .player-title{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px;}
45
+ .player-title i{color:var(--accent);}
46
+ .player-body{position:relative;background:#000;}
47
+ .player-body video{width:100%;display:block;max-height:420px;object-fit:contain;}
48
+ .player-hud{position:absolute;top:11px;left:13px;color:white;font-family:'DM Mono',monospace;font-size:12px;line-height:1.8;text-shadow:1px 1px 4px rgba(0,0,0,0.98);pointer-events:none;}
49
+ .player-empty{padding:80px 20px;text-align:center;color:var(--text3);}
50
+ .player-empty i{font-size:44px;display:block;margin-bottom:12px;}
51
+ .player-empty p{font-size:13px;}
52
+
53
+ /* VIDEO GRID */
54
+ .vid-card{background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);box-shadow:var(--shadow);overflow:hidden;cursor:pointer;transition:all 0.2s;}
55
+ .vid-card:hover{box-shadow:var(--shadow-lg);transform:translateY(-3px);}
56
+ .vid-card.selected{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent);}
57
+ .vid-thumb{position:relative;background:#0a0a0a;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;overflow:hidden;}
58
+ .vid-thumb video{width:100%;height:100%;object-fit:cover;display:block;}
59
+ .vid-thumb-ph{color:rgba(255,255,255,0.3);font-size:32px;}
60
+ .play-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.3);opacity:0;transition:opacity 0.2s;}
61
+ .play-overlay i{font-size:32px;color:white;}
62
+ .vid-card:hover .play-overlay{opacity:1;}
63
+ .vid-info{padding:11px 13px;}
64
+ .vid-name{font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:3px;}
65
+ .vid-meta{font-size:10px;color:var(--text2);display:flex;align-items:center;justify-content:space-between;}
66
+ .vid-meta span{display:flex;align-items:center;gap:4px;}
67
+ .vid-actions{display:flex;gap:6px;margin-top:8px;}
68
+ .btn-vi{flex:1;padding:5px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-size:11px;cursor:pointer;font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;gap:4px;transition:all 0.2s;}
69
+ .btn-vi:hover{background:var(--accent);color:white;border-color:var(--accent);}
70
+ .btn-vi.danger:hover{background:#dc2626;border-color:#dc2626;}
71
+
72
+ /* EMPTY HISTORY */
73
+ .empty-hist{text-align:center;padding:60px 20px;color:var(--text3);}
74
+ .empty-hist i{font-size:48px;display:block;margin-bottom:14px;}
75
+ .empty-hist p{font-size:13px;margin-bottom:16px;}
76
+ .empty-hist a{display:inline-flex;align-items:center;gap:6px;background:var(--accent);color:white;padding:9px 18px;border-radius:9px;text-decoration:none;font-size:12px;}
77
+
78
+ /* BADGE */
79
+ .badge{font-size:10px;font-weight:500;padding:1px 6px;border-radius:4px;}
80
+ .bg{background:var(--green-bg);color:var(--green);}
81
+ </style>
82
+ </head>
83
+ <body>
84
+
85
+ <nav class="sidebar">
86
+ <a href="/" class="sb-logo"><i class="fas fa-satellite-dish"></i></a>
87
+ <div class="sb-sep"></div>
88
+ <a href="/home" class="sb-btn"><i class="fas fa-play-circle"></i><span class="tip" id="tip-analyze">Analyze</span></a>
89
+ <a href="/dashboard" class="sb-btn"><i class="fas fa-chart-line"></i><span class="tip" id="tip-dash">Dashboard</span></a>
90
+ <a href="/logs" class="sb-btn"><i class="fas fa-list-ul"></i><span class="tip" id="tip-logs">Logs</span></a>
91
+ <a href="/history" class="sb-btn active"><i class="fas fa-film"></i><span class="tip" id="tip-hist">History</span></a>
92
+ <div class="sb-spacer"></div>
93
+ <button class="sb-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon"></i><span class="tip">Theme</span></button>
94
+ </nav>
95
+
96
+ <div class="page-wrap">
97
+ <header class="topbar">
98
+ <div class="topbar-l">
99
+ <span class="brand-name">Track<b>IQ</b></span>
100
+ <span class="sep">|</span>
101
+ <span style="font-size:13px;font-weight:500;" id="t-title">Annotated History</span>
102
+ </div>
103
+ <div class="topbar-r">
104
+ <div class="lang-group">
105
+ <button class="lang-btn active" onclick="setLang('en',this)">EN</button>
106
+ <button class="lang-btn" onclick="setLang('fr',this)">FR</button>
107
+ <button class="lang-btn" onclick="setLang('wo',this)">WO</button>
108
+ </div>
109
+ <button class="icon-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon2"></i></button>
110
+ </div>
111
+ </header>
112
+
113
+ <main id="mainGrid">
114
+
115
+ <!-- Videos grid -->
116
+ <div id="videoGrid" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;"></div>
117
+
118
+ </main>
119
+ </div>
120
+
121
+ <script>
122
+ /* THEME */
123
+ function toggleTheme(){
124
+ const d=document.documentElement,dark=d.getAttribute('data-theme')==='dark';
125
+ d.setAttribute('data-theme',dark?'light':'dark');
126
+ ['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className=dark?'fas fa-moon':'fas fa-sun';});
127
+ try{localStorage.setItem('tiq_theme',dark?'light':'dark');}catch(e){}
128
+ }
129
+
130
+ /* LANG */
131
+ const T={
132
+ en:{title:'Annotated History',player:'Video player',dl:'Download','select-vid':'Select a video below to play it',tipAnalyze:'Analyze',tipDash:'Dashboard',tipLogs:'Logs',tipHist:'History',reanalyze:'Re-analyze',download:'Download',delete:'Delete',empty:'No annotated videos yet.',goAnalyze:'Go to Analyze'},
133
+ fr:{title:'Historique annoté',player:'Lecteur vidéo',dl:'Télécharger','select-vid':'Sélectionnez une vidéo ci-dessous',tipAnalyze:'Analyser',tipDash:'Tableau de bord',tipLogs:'Logs',tipHist:'Historique',reanalyze:'Ré-analyser',download:'Télécharger',delete:'Supprimer',empty:'Aucune vidéo annotée.',goAnalyze:'Aller sur Analyser'},
134
+ wo:{title:'Màgg yi',player:'Video bi',dl:'Télécharger','select-vid':'Tànn video ci suuf',tipAnalyze:'Jàng',tipDash:'Dashboard',tipLogs:'Logs',tipHist:'Màgg',reanalyze:'Wërtëk',download:'Télécharger',delete:'Bañ',empty:'Amul video.',goAnalyze:'Dem Analyser'}
135
+ };
136
+ let curLang='en';
137
+ function setLang(l,btn){
138
+ curLang=l;
139
+ document.querySelectorAll('.lang-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');
140
+ const t=T[l];
141
+ Object.entries({title:'t-title',player:'t-player',dl:'t-dl','select-vid':'t-select-vid',tipAnalyze:'tip-analyze',tipDash:'tip-dash',tipLogs:'tip-logs',tipHist:'tip-hist'}).forEach(([k,id])=>{const el=document.getElementById(id);if(el&&t[k])el.textContent=t[k];});
142
+ renderGrid();
143
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
144
+ }
145
+
146
+ /* HISTORY DATA */
147
+ function getHistory(){try{return JSON.parse(localStorage.getItem('tiq_history')||'[]');}catch(e){return[];}}
148
+ function saveHistory(h){try{localStorage.setItem('tiq_history',JSON.stringify(h));}catch(e){}}
149
+
150
+ /* FORMAT DATE */
151
+ function fmtDate(iso){
152
+ try{const d=new Date(iso);return d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})+' '+d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'});}catch(e){return 'Unknown date';}
153
+ }
154
+
155
+ let activeUrl=null,activeIdx=null;
156
+
157
+ function dlVideo(url,name){
158
+ const a=document.createElement('a');a.href=url;
159
+ a.download=name||'trackiq_video.mp4';a.click();
160
+ }
161
+
162
+ function playVideo(url,name,idx){
163
+ activeUrl=url;activeIdx=idx;
164
+ const op=document.getElementById('overlayPlayer');
165
+ op.src=url;op.play().catch(()=>{});
166
+ document.getElementById('overlayName').textContent=name;
167
+ document.getElementById('vidOverlay').style.display='flex';
168
+ }
169
+ function closeOverlay(){
170
+ const op=document.getElementById('overlayPlayer');op.pause();op.src='';
171
+ document.getElementById('vidOverlay').style.display='none';
172
+ activeUrl=null;activeIdx=null;
173
+ }
174
+ function dlOverlay(){if(activeUrl){const a=document.createElement('a');a.href=activeUrl;a.download='trackiq.mp4';a.click();}}
175
+
176
+ function dlActive(){
177
+ if(!activeUrl)return;
178
+ const a=document.createElement('a');a.href=activeUrl;a.download='trackiq_annotated.mp4';a.click();
179
+ }
180
+
181
+ function deleteVideo(idx){
182
+ const h=getHistory();h.splice(idx,1);saveHistory(h);
183
+ if(activeIdx===idx){
184
+ closeModal();
185
+ activeUrl=null;activeIdx=null;
186
+ }
187
+ renderGrid();
188
+ }
189
+
190
+ function renderGrid(){
191
+ const hist=getHistory();
192
+ const t=T[curLang];
193
+ const grid=document.getElementById('videoGrid');grid.innerHTML='';
194
+
195
+ if(!hist.length){
196
+ const empty=document.createElement('div');empty.className='empty-hist';
197
+ empty.innerHTML=`<i class="fas fa-film"></i><p>${t.empty}</p><a href="/home">${t.goAnalyze} <i class="fas fa-arrow-right"></i></a>`;
198
+ grid.appendChild(empty);return;
199
+ }
200
+
201
+ hist.forEach((item,i)=>{
202
+ const card=document.createElement('div');card.className='vid-card'+(activeIdx===i?' selected':'');
203
+ const isBlob=item.url&&item.url.startsWith('blob:');
204
+ const isBackend=item.url&&(item.url.startsWith('/api/')||item.url.startsWith('http'));
205
+ const canPlay=isBlob||isBackend;
206
+ card.innerHTML=`
207
+ <div class="vid-thumb">
208
+ ${isBlob
209
+ ? `<video src="${item.url}" style="width:100%;height:100%;object-fit:cover;" muted preload="metadata" onloadeddata="this.currentTime=1"></video>`
210
+ : `<div class="vid-thumb-ph" style="background:#0a0e15;display:flex;align-items:center;justify-content:center;height:100%;">
211
+ <i class="fas fa-film" style="font-size:32px;color:rgba(255,255,255,0.2);"></i>
212
+ </div>`
213
+ }
214
+ <div class="play-overlay"><i class="fas fa-play"></i></div>
215
+ </div>
216
+ <div class="vid-info">
217
+ <div class="vid-name" title="${item.name}">${item.name||'Unnamed'}</div>
218
+ <div class="vid-meta">
219
+ <span><i class="fas fa-calendar" style="font-size:9px;"></i> ${fmtDate(item.date)}</span>
220
+ <span class="badge bg">${isBlob?'Local':'Server'}</span>
221
+ </div>
222
+ <div class="vid-actions">
223
+ <button class="btn-vi" onclick="event.stopPropagation();playVideo('${item.url}','${item.name}',${i})">
224
+ <i class="fas fa-play"></i> Play
225
+ </button>
226
+ <button class="btn-vi" onclick="event.stopPropagation();dlVideo('${item.url}','${item.name}')">
227
+ <i class="fas fa-download"></i>
228
+ </button>
229
+ <button class="btn-vi danger" onclick="event.stopPropagation();deleteVideo(${i})">
230
+ <i class="fas fa-trash"></i>
231
+ </button>
232
+ </div>
233
+ </div>
234
+ `;
235
+ card.onclick=(ev)=>{if(!ev.target.closest('.btn-vi'))playVideo(item.url,item.name,i);};
236
+ grid.appendChild(card);
237
+ });
238
+ }
239
+
240
+ /* HUD ticker on player */
241
+ setInterval(()=>{
242
+ if(!activeUrl)return;
243
+ const f=570+Math.floor(Math.random()*80),s=295+Math.floor(Math.random()*40);
244
+ const hf=document.getElementById('oHudF');if(hf)hf.textContent=`F: = ${f} ▶ ${s}`;
245
+ const car=2+Math.floor(Math.random()*6),van=Math.floor(Math.random()*3),bike=Math.floor(Math.random()*6),p=Math.floor(Math.random()*7);
246
+ const hc=document.getElementById('oHudC');if(hc)hc.innerHTML=`Car = ${car}<br>Van = ${van}<br>bike = ${bike}<br>person = ${p}`;
247
+ },2200);
248
+
249
+ document.addEventListener('keydown',e=>{if(e.key==='Escape')closeOverlay();});
250
+ /* INIT */
251
+ (function(){
252
+ try{
253
+ const th=localStorage.getItem('tiq_theme');if(th){document.documentElement.setAttribute('data-theme',th);if(th==='dark'){['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className='fas fa-sun';});}}
254
+ const lg=localStorage.getItem('tiq_lang');if(lg&&lg!=='en'){const btn=document.querySelector(`.lang-btn[onclick*="'${lg}'"]`);if(btn)setLang(lg,btn);}
255
+ }catch(e){}
256
+ renderGrid();
257
+ })();
258
+ </script>
259
+ <script src="/static/trackiq-api.js"></script>
260
+
261
+ <!-- ── Modal lecteur vidéo ── -->
262
+ <div id="vidOverlay" style="display:none;position:fixed;inset:0;z-index:1000;
263
+ background:rgba(0,0,0,0.88);backdrop-filter:blur(8px);
264
+ align-items:center;justify-content:center;flex-direction:column;gap:12px;">
265
+
266
+ <!-- Header -->
267
+ <div style="display:flex;align-items:center;justify-content:space-between;
268
+ width:min(90vw,860px);padding:0 4px;">
269
+ <span id="overlayName" style="color:#fff;font-size:13px;font-weight:500;
270
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:70%;"></span>
271
+ <div style="display:flex;gap:8px;">
272
+ <button onclick="dlOverlay()" style="background:rgba(255,255,255,0.12);color:#fff;
273
+ border:none;border-radius:7px;padding:6px 13px;font-size:12px;cursor:pointer;
274
+ font-family:'DM Sans',sans-serif;display:flex;align-items:center;gap:5px;">
275
+ <i class="fas fa-download"></i> Download
276
+ </button>
277
+ <button onclick="closeOverlay()" style="background:rgba(220,38,38,0.7);color:#fff;
278
+ border:none;border-radius:7px;padding:6px 13px;font-size:12px;cursor:pointer;
279
+ font-family:'DM Sans',sans-serif;display:flex;align-items:center;gap:5px;">
280
+ <i class="fas fa-times"></i> Close
281
+ </button>
282
+ </div>
283
+ </div>
284
+
285
+ <!-- Vidéo -->
286
+ <video id="overlayPlayer" controls autoplay
287
+ style="width:min(90vw,860px);max-height:75vh;border-radius:10px;
288
+ background:#000;object-fit:contain;box-shadow:0 20px 60px rgba(0,0,0,0.6);">
289
+ </video>
290
+
291
+ <!-- Hint ESC -->
292
+ <span style="color:rgba(255,255,255,0.3);font-size:10px;">Press ESC to close</span>
293
+ </div>
294
+ </body>
295
+ </html>
TRAFFIC_ROAD_APP/templates/home.html ADDED
@@ -0,0 +1,684 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ — Analyze</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <style>
10
+ :root{--bg:#f0f4f8;--sur:#fff;--sur2:#f6f8fb;--bd:rgba(0,0,0,0.07);--sh:0 2px 16px rgba(0,0,0,0.09);--shlg:0 8px 36px rgba(0,0,0,0.13);--t:#0f1923;--t2:#5a6a7a;--t3:#9aaab8;--ac:#880000;--ac2:#660000;--acl:rgba(136,0,0,0.07);--gr:#16a34a;--sw:60px;--r:14px;--rs:9px;}
11
+ [data-theme="dark"]{--bg:#0c1117;--sur:#151c25;--sur2:#1c2531;--bd:rgba(255,255,255,0.07);--sh:0 2px 16px rgba(0,0,0,0.45);--t:#e8edf2;--t2:#7a8fa0;--t3:#3a4a5a;}
12
+ *{box-sizing:border-box;margin:0;padding:0;}::-webkit-scrollbar{width:0;}
13
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--t);font-size:13px;display:flex;min-height:100vh;}
14
+
15
+ /* Sidebar */
16
+ .sb{width:var(--sw);background:var(--sur);border-right:1px solid var(--bd);display:flex;flex-direction:column;align-items:center;padding:14px 0;gap:4px;position:fixed;top:0;left:0;bottom:0;z-index:200;}
17
+ .sbl{width:34px;height:34px;background:var(--ac);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;margin-bottom:10px;text-decoration:none;}
18
+ .sbsep{width:26px;height:1px;background:var(--bd);margin:3px 0;}
19
+ .sbb{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:14px;cursor:pointer;transition:all 0.2s;text-decoration:none;border:none;background:none;position:relative;}
20
+ .sbb:hover{background:var(--sur2);color:var(--t);}.sbb.on{background:var(--acl);color:var(--ac);}
21
+ .sbb .tip{position:absolute;left:44px;top:50%;transform:translateY(-50%);background:var(--t);color:var(--sur);font-size:10px;padding:3px 7px;border-radius:4px;white-space:nowrap;opacity:0;pointer-events:none;z-index:300;}.sbb:hover .tip{opacity:1;}
22
+ .sbsp{flex:1;}
23
+
24
+ /* Layout */
25
+ .pw{margin-left:var(--sw);flex:1;display:flex;flex-direction:column;}
26
+ .topb{height:50px;background:var(--sur);border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100;}
27
+ .topl{display:flex;align-items:center;gap:9px;}
28
+ .bn{font-size:14px;font-weight:600;}.bn b{color:var(--ac);}
29
+ .topr{display:flex;align-items:center;gap:6px;}
30
+ .lg{display:flex;background:var(--sur2);border-radius:5px;padding:2px;border:1px solid var(--bd);}
31
+ .lb{font-size:10px;font-weight:500;padding:2px 8px;border:none;background:none;cursor:pointer;border-radius:3px;color:var(--t2);font-family:'DM Sans',sans-serif;transition:all 0.2s;}.lb.on{background:var(--ac);color:#fff;}
32
+ .ib{width:28px;height:28px;border-radius:5px;border:1px solid var(--bd);background:var(--sur);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--t2);font-size:11px;}
33
+ .ib:hover{background:var(--sur2);}
34
+
35
+ main{flex:1;padding:14px 16px;display:grid;grid-template-columns:340px 1fr;gap:12px;max-width:1400px;width:100%;margin:0 auto;}
36
+
37
+ /* Card */
38
+ .card{background:var(--sur);border-radius:var(--r);border:1px solid var(--bd);box-shadow:var(--sh);padding:14px 16px;}
39
+ .ct{font-size:12px;font-weight:500;margin-bottom:11px;display:flex;align-items:center;gap:6px;}
40
+ .ct i{color:var(--ac);}
41
+
42
+ /* Source tabs */
43
+ .stabs{display:flex;gap:3px;background:var(--sur2);border-radius:7px;padding:3px;border:1px solid var(--bd);margin-bottom:11px;}
44
+ .stab{flex:1;padding:6px 4px;border-radius:5px;font-size:11px;cursor:pointer;color:var(--t2);border:none;background:none;font-family:'DM Sans',sans-serif;transition:all 0.2s;display:flex;align-items:center;justify-content:center;gap:4px;}
45
+ .stab.on{background:var(--sur);color:var(--t);box-shadow:0 1px 4px rgba(0,0,0,0.1);font-weight:500;}
46
+
47
+ /* Upload zone */
48
+ .uzone{border:2px dashed var(--bd);border-radius:var(--rs);min-height:110px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;transition:all 0.2s;padding:14px;position:relative;overflow:hidden;}
49
+ .uzone:hover,.uzone.drag{border-color:var(--ac);background:var(--acl);}
50
+ .uzone.has{border-style:solid;border-color:var(--ac);}
51
+ .uzone i{font-size:22px;color:var(--t3);}
52
+ .uzone.has i{color:var(--ac);}
53
+ .uzone p{font-size:11px;color:var(--t2);text-align:center;}
54
+ .uzone input{position:absolute;inset:0;opacity:0;cursor:pointer;}
55
+ .fname{font-size:11px;font-weight:500;color:var(--ac);display:none;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
56
+ .prog{display:none;background:var(--sur2);border-radius:3px;height:4px;margin-top:7px;overflow:hidden;}
57
+ .progb{height:100%;background:var(--ac);width:0%;transition:width 0.3s;}
58
+ .progt{font-size:10px;color:var(--t2);margin-top:4px;display:none;}
59
+
60
+ /* URL */
61
+ .urow{display:flex;gap:6px;}
62
+ .uin{flex:1;padding:7px 9px;border:1px solid var(--bd);border-radius:7px;font-size:12px;background:var(--sur2);color:var(--t);font-family:'DM Sans',sans-serif;outline:none;}
63
+ .uin:focus{border-color:var(--ac);}
64
+ .uin::placeholder{color:var(--t3);}
65
+ .bsm{padding:7px 12px;background:var(--ac);color:#fff;border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif;}
66
+
67
+ /* Webcam */
68
+ .wcbox{background:#000;border-radius:8px;overflow:hidden;aspect-ratio:16/9;position:relative;margin-bottom:10px;}
69
+ .wcbox video,.wcbox img{width:100%;height:100%;object-fit:cover;display:block;}
70
+ .wcph{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:rgba(255,255,255,0.3);font-size:11px;}
71
+ .wcph i{font-size:22px;}
72
+ .recbdg{position:absolute;top:7px;left:8px;display:none;align-items:center;gap:4px;background:rgba(220,38,38,0.85);color:#fff;font-size:10px;padding:2px 8px;border-radius:10px;}
73
+ @keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}}
74
+ .recbdg i{font-size:8px;animation:blink 1s infinite;}
75
+
76
+ /* Classes */
77
+ .clshd{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;}
78
+ .clshd span{font-size:11px;color:var(--t2);}
79
+ .clshd button{font-size:11px;color:var(--ac);background:none;border:none;cursor:pointer;font-family:'DM Sans',sans-serif;}
80
+ .clsg{display:grid;grid-template-columns:1fr 1fr;gap:5px;}
81
+ .clsi{display:flex;align-items:center;gap:5px;padding:5px 8px;border-radius:6px;border:1px solid var(--bd);cursor:pointer;transition:all 0.2s;font-size:11px;background:var(--sur2);}
82
+ .clsi:hover{border-color:var(--ac);}.clsi.on{border-color:var(--ac);background:var(--acl);}
83
+ .cldot{width:8px;height:8px;border-radius:3px;flex-shrink:0;}
84
+
85
+ /* Buttons */
86
+ .runbtn{width:100%;padding:10px;background:var(--ac);color:#fff;border:none;border-radius:9px;font-size:13px;font-weight:500;cursor:pointer;font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;gap:7px;transition:all 0.2s;margin-top:4px;}
87
+ .runbtn:hover{background:var(--ac2);}.runbtn:disabled{opacity:0.4;cursor:not-allowed;}
88
+ .rstbtn{width:100%;padding:8px;background:var(--sur2);color:var(--t2);border:1px solid var(--bd);border-radius:9px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;gap:6px;transition:all 0.2s;}
89
+ .rstbtn:hover{border-color:var(--ac);color:var(--ac);}
90
+
91
+ /* Right col */
92
+ .rc{display:flex;flex-direction:column;gap:11px;}
93
+
94
+ /* Big panel */
95
+ .bp{background:var(--sur);border-radius:var(--r);border:1px solid var(--bd);box-shadow:var(--shlg);overflow:hidden;}
96
+ .bph{padding:10px 14px;border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;}
97
+ .bpht{font-size:12px;font-weight:500;display:flex;align-items:center;gap:6px;}
98
+ .bpht i{color:var(--ac);}
99
+ .bpb{position:relative;background:#080e15;height:420px;}
100
+
101
+ /* Video dans le panel */
102
+ .bpb video,.bpb img#wcYoloImg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:none;}
103
+
104
+ /* HUD top-left */
105
+ .hud{position:absolute;top:10px;left:11px;color:#fff;font-family:'DM Mono',monospace;font-size:11px;line-height:1.85;text-shadow:1px 1px 4px rgba(0,0,0,0.98);pointer-events:none;display:none;z-index:4;}
106
+ .hudf{font-size:9px;color:rgba(255,255,255,0.4);margin-bottom:2px;}
107
+
108
+ /* Count overlay top-right */
109
+ .cov{position:absolute;top:10px;right:11px;display:none;flex-direction:column;gap:4px;pointer-events:none;z-index:4;}
110
+ .cbdg{display:flex;flex-direction:column;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);border-left:3px solid transparent;border-radius:5px;padding:4px 9px;min-width:115px;}
111
+ .cbdg .cn{font-size:13px;font-weight:600;line-height:1.2;}
112
+ .cbdg .cu{font-size:9px;color:#ccc;line-height:1.2;}
113
+
114
+ /* No-object banner */
115
+ .noobj{display:none;position:absolute;bottom:14px;left:50%;transform:translateX(-50%);z-index:5;
116
+ background:rgba(220,38,38,0.9);color:#fff;font-size:12px;font-weight:500;
117
+ padding:6px 16px;border-radius:20px;align-items:center;gap:7px;white-space:nowrap;}
118
+
119
+ /* Empty state */
120
+ .es{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;color:rgba(255,255,255,0.18);}
121
+ .es i{font-size:38px;}.es p{font-size:12px;}
122
+
123
+ /* Stats row */
124
+ .sr{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;}
125
+ .sc{background:var(--sur);border-radius:var(--rs);border:1px solid var(--bd);box-shadow:var(--sh);padding:10px 12px;}
126
+ .sc .sl{font-size:10px;color:var(--t2);margin-bottom:3px;}
127
+ .sc .sv{font-size:18px;font-weight:300;letter-spacing:-0.5px;}
128
+ .sc .su{font-size:10px;color:var(--t2);}
129
+
130
+ /* Download bar */
131
+ .dlb{display:flex;align-items:center;justify-content:space-between;background:var(--sur);border-radius:var(--rs);border:1px solid var(--bd);box-shadow:var(--sh);padding:9px 13px;}
132
+ .dlb span{font-size:11px;color:var(--t2);}
133
+ .btndl{display:flex;align-items:center;gap:5px;background:var(--ac);color:#fff;border:none;border-radius:7px;padding:7px 13px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif;}
134
+ .btndl:hover{background:var(--ac2);}.btndl:disabled{opacity:0.4;cursor:not-allowed;background:var(--t3);}
135
+ </style>
136
+ </head>
137
+ <body>
138
+
139
+ <nav class="sb">
140
+ <a href="/" class="sbl"><i class="fas fa-satellite-dish"></i></a>
141
+ <div class="sbsep"></div>
142
+ <a href="/home" class="sbb on"><i class="fas fa-play-circle"></i><span class="tip">Analyze</span></a>
143
+ <a href="/dashboard" class="sbb"><i class="fas fa-chart-line"></i><span class="tip">Dashboard</span></a>
144
+ <a href="/logs" class="sbb"><i class="fas fa-list-ul"></i><span class="tip">Logs</span></a>
145
+ <a href="/history" class="sbb"><i class="fas fa-film"></i><span class="tip">History</span></a>
146
+ <div class="sbsp"></div>
147
+ <button class="sbb" onclick="toggleTheme()"><i class="fas fa-moon" id="ti"></i><span class="tip">Theme</span></button>
148
+ </nav>
149
+
150
+ <div class="pw">
151
+ <header class="topb">
152
+ <div class="topl">
153
+ <span class="bn">Track<b>IQ</b></span>
154
+ <span style="color:var(--bd);font-size:16px;">|</span>
155
+ <span style="font-size:12px;font-weight:500;color:var(--t2);">Video Analysis</span>
156
+ </div>
157
+ <div class="topr">
158
+ <div class="lg">
159
+ <button class="lb on" onclick="setLang('en',this)">EN</button>
160
+ <button class="lb" onclick="setLang('fr',this)">FR</button>
161
+ <button class="lb" onclick="setLang('wo',this)">WO</button>
162
+ </div>
163
+ <button class="ib" onclick="toggleTheme()"><i class="fas fa-moon" id="ti2"></i></button>
164
+ </div>
165
+ </header>
166
+
167
+ <main>
168
+ <!-- Colonne gauche -->
169
+ <div style="display:flex;flex-direction:column;gap:11px;">
170
+
171
+ <!-- Input source -->
172
+ <div class="card">
173
+ <div class="ct"><i class="fas fa-video"></i> Input source</div>
174
+ <div class="stabs">
175
+ <button class="stab on" onclick="switchSrc('upload',this)"><i class="fas fa-upload"></i> Video</button>
176
+ <button class="stab" onclick="switchSrc('url',this)"><i class="fas fa-link"></i> URL</button>
177
+ <button class="stab" onclick="switchSrc('webcam',this)"><i class="fas fa-camera"></i> Webcam</button>
178
+ </div>
179
+
180
+ <!-- Upload -->
181
+ <div id="src-upload">
182
+ <label class="uzone" id="uzone">
183
+ <i class="fas fa-cloud-upload-alt"></i>
184
+ <p>Drop video here or click to browse</p>
185
+ <p style="font-size:10px;color:var(--t3);">MP4, AVI, MOV — max 500 MB</p>
186
+ <span class="fname" id="fname"></span>
187
+ <input type="file" id="fileInput" accept="video/*" onchange="onFile(event)">
188
+ </label>
189
+ <div class="prog" id="progWrap"><div class="progb" id="progBar"></div></div>
190
+ <div class="progt" id="progTxt"></div>
191
+ </div>
192
+
193
+ <!-- URL -->
194
+ <div id="src-url" style="display:none;">
195
+ <div class="urow">
196
+ <input type="text" class="uin" id="urlIn" placeholder="https://… or rtsp://…">
197
+ <button class="bsm" onclick="loadUrl()">Load</button>
198
+ </div>
199
+ </div>
200
+
201
+ <!-- Webcam -->
202
+ <div id="src-webcam" style="display:none;">
203
+ <!-- Mini preview : flux YOLO annoté -->
204
+ <div class="wcbox" id="wcbox">
205
+ <img id="wcMiniYolo" alt="YOLO stream" style="display:none;">
206
+ <div class="wcph" id="wcPh"><i class="fas fa-camera-slash"></i><span>Click Start</span></div>
207
+ <div class="recbdg" id="recBadge"><i class="fas fa-circle"></i> LIVE</div>
208
+ </div>
209
+ <!-- Bouton stop webcam seulement (plus de bouton Enregistrer) -->
210
+ <button class="runbtn" id="wcBtn" onclick="startWc()">
211
+ <i class="fas fa-circle" style="color:#f87171;font-size:8px;"></i> Start webcam
212
+ </button>
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Detection classes -->
217
+ <div class="card">
218
+ <div class="ct"><i class="fas fa-tags"></i> Detection classes</div>
219
+ <div class="clshd">
220
+ <span id="clsCnt">0 selected</span>
221
+ <button onclick="toggleAll()">Select all</button>
222
+ </div>
223
+ <div class="clsg" id="clsGrid"></div>
224
+ </div>
225
+
226
+ <button class="runbtn" id="runBtn" onclick="runAnalysis()" disabled>
227
+ <i class="fas fa-play"></i> Run Analysis
228
+ </button>
229
+ <button class="rstbtn" onclick="resetAll()">
230
+ <i class="fas fa-rotate-right"></i> Reset
231
+ </button>
232
+ </div>
233
+
234
+ <!-- Colonne droite -->
235
+ <div class="rc">
236
+
237
+ <!-- Big result panel -->
238
+ <div class="bp">
239
+ <div class="bph">
240
+ <div class="bpht"><i class="fas fa-film"></i> Result</div>
241
+ <span style="font-size:11px;color:var(--t2);" id="srcLabel"></span>
242
+ </div>
243
+ <div class="bpb" id="bigBody">
244
+ <!-- Vidéo annotée (upload/url) -->
245
+ <video id="resultVid" controls></video>
246
+ <!-- Flux YOLO webcam (grand) -->
247
+ <img id="wcYoloImg" alt="YOLO stream">
248
+ <!-- HUD top-left -->
249
+ <div class="hud" id="hudEl">
250
+ <div class="hudf" id="hudFr">—</div>
251
+ <div id="hudCo">—</div>
252
+ </div>
253
+ <!-- Compteurs top-right -->
254
+ <div class="cov" id="cOverlay"></div>
255
+ <!-- Banner no-object -->
256
+ <div class="noobj" id="noObjBanner">
257
+ <i class="fas fa-eye-slash"></i> No object detected
258
+ </div>
259
+ <!-- Canvas caché pour enregistrement webcam -->
260
+ <canvas id="wcCanvas" style="display:none;"></canvas>
261
+ <!-- Empty state -->
262
+ <div class="es" id="emptyState">
263
+ <i class="fas fa-film"></i>
264
+ <p>Result will appear here</p>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ <!-- Stats -->
270
+ <div class="sr">
271
+ <div class="sc"><div class="sl">Frames</div><div class="sv" id="stF">—</div></div>
272
+ <div class="sc"><div class="sl">Objects</div><div class="sv" id="stO">—</div></div>
273
+ <div class="sc"><div class="sl">FPS</div><div class="sv" id="stFps">—</div></div>
274
+ <div class="sc"><div class="sl">Duration</div><div class="sv" id="stD">—</div></div>
275
+ </div>
276
+
277
+ <!-- Download -->
278
+ <div class="dlb">
279
+ <span id="dlName">No result yet</span>
280
+ <button class="btndl" id="dlBtn" disabled onclick="doDownload()">
281
+ <i class="fas fa-download"></i> Download
282
+ </button>
283
+ </div>
284
+
285
+ </div>
286
+ </main>
287
+ </div>
288
+
289
+ <script>
290
+ /* ── Theme ── */
291
+ function toggleTheme(){
292
+ const d=document.documentElement,dk=d.getAttribute('data-theme')==='dark';
293
+ d.setAttribute('data-theme',dk?'light':'dark');
294
+ ['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className=dk?'fas fa-moon':'fas fa-sun';});
295
+ try{localStorage.setItem('tiq_theme',dk?'light':'dark');}catch(e){}
296
+ }
297
+ function setLang(l,btn){
298
+ document.querySelectorAll('.lb').forEach(b=>b.classList.remove('on'));btn.classList.add('on');
299
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
300
+ }
301
+
302
+ /* ── Classes ── */
303
+ const CLASSES=[
304
+ {name:'Vehicle',color:'#3b82f6'},{name:'Motorcycle',color:'#10b981'},
305
+ {name:'Truck',color:'#f59e0b'},{name:'Bus',color:'#8b5cf6'},
306
+ {name:'Human',color:'#ec4899'},{name:'Bicycle',color:'#06b6d4'},
307
+ {name:'Traffic light',color:'#ef4444'},{name:'Road sign',color:'#f97316'},
308
+ ];
309
+ let sel=new Set(['Human']);
310
+
311
+ function buildCls(){
312
+ const g=document.getElementById('clsGrid'); g.innerHTML='';
313
+ CLASSES.forEach(c=>{
314
+ const d=document.createElement('div');
315
+ d.className='clsi'+(sel.has(c.name)?' on':'');
316
+ d.innerHTML=`<span class="cldot" style="background:${c.color}"></span>${c.name}`;
317
+ d.onclick=()=>{sel.has(c.name)?sel.delete(c.name):sel.add(c.name);d.classList.toggle('on');upCls();};
318
+ g.appendChild(d);
319
+ });
320
+ upCls();
321
+ }
322
+ function toggleAll(){sel.size===CLASSES.length?sel.clear():CLASSES.forEach(c=>sel.add(c.name));buildCls();}
323
+ function upCls(){document.getElementById('clsCnt').textContent=sel.size+' selected';}
324
+ buildCls();
325
+
326
+ /* ── State ── */
327
+ let curSrc='upload', vidFile=null, vidUrl=null, wcStream=null;
328
+ let _jobId=null, _sse=null, _wcPollIv=null, _annotatedUrl=null;
329
+
330
+ /* ── Source switch ── */
331
+ function switchSrc(s,btn){
332
+ curSrc=s;
333
+ document.querySelectorAll('.stab').forEach(b=>b.classList.remove('on'));btn.classList.add('on');
334
+ ['upload','url','webcam'].forEach(id=>{
335
+ const el=document.getElementById('src-'+id);if(el)el.style.display='none';
336
+ });
337
+ document.getElementById('src-'+s).style.display='block';
338
+ checkReady();
339
+ }
340
+
341
+ /* ── File upload ── */
342
+ function onFile(e){
343
+ const f=e.target.files[0];if(!f)return;
344
+ vidFile=f;
345
+ const fn=document.getElementById('fname');fn.textContent=f.name;fn.style.display='block';
346
+ document.getElementById('uzone').classList.add('has');
347
+ checkReady();
348
+ }
349
+ function loadUrl(){
350
+ const u=document.getElementById('urlIn').value.trim();if(!u)return;
351
+ vidUrl=u;checkReady();
352
+ }
353
+
354
+ /* ── Run Analysis ── */
355
+ window.runAnalysis=async function(){
356
+ const btn=document.getElementById('runBtn');
357
+ btn.disabled=true;
358
+ btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> Processing…';
359
+ document.getElementById('progWrap').style.display='block';
360
+ document.getElementById('progTxt').style.display='block';
361
+ document.getElementById('progTxt').textContent='Uploading…';
362
+ document.getElementById('progBar').style.width='5%';
363
+ document.getElementById('emptyState').style.display='none';
364
+
365
+ /* Upload si pas encore fait */
366
+ if(!_jobId){
367
+ if(!vidFile&&!vidUrl){
368
+ btn.disabled=false;btn.innerHTML='<i class="fas fa-play"></i> Run Analysis';return;
369
+ }
370
+ const fd=new FormData();
371
+ if(vidFile) fd.append('video',vidFile);
372
+ try{
373
+ const r=await fetch('/api/upload',{method:'POST',body:fd});
374
+ const d=await r.json();
375
+ _jobId=d.job_id;
376
+ }catch(err){
377
+ console.error('[TrackIQ] Upload:',err);
378
+ btn.disabled=false;btn.innerHTML='<i class="fas fa-play"></i> Run Analysis';return;
379
+ }
380
+ }
381
+
382
+ document.getElementById('progTxt').textContent='Starting detection…';
383
+ document.getElementById('progBar').style.width='10%';
384
+
385
+ try{
386
+ await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},
387
+ body:JSON.stringify({job_id:_jobId,classes:[...sel],model:'yolo11n'})});
388
+ }catch(err){console.error('[TrackIQ] Run:',err);return;}
389
+
390
+ /* SSE */
391
+ if(_sse)_sse.close();
392
+ _sse=new EventSource(`/api/stream/${_jobId}`);
393
+ _sse.onmessage=(e)=>{
394
+ let d;try{d=JSON.parse(e.data);}catch{return;}
395
+
396
+ if(d.event==='done'){
397
+ _sse.close();
398
+ _showBanner(false);
399
+ document.getElementById('progBar').style.width='100%';
400
+ document.getElementById('progTxt').textContent='Done!';
401
+ btn.disabled=false;btn.innerHTML='<i class="fas fa-play"></i> Run Analysis';
402
+
403
+ /* ✅ Affiche la vidéo annotée */
404
+ const rv=document.getElementById('resultVid');
405
+ rv.src=`/api/video/${_jobId}`;
406
+ rv.style.display='block';
407
+ rv.load();
408
+ document.getElementById('srcLabel').textContent=vidFile?vidFile.name:'video';
409
+ document.getElementById('hudEl').style.display='block';
410
+ document.getElementById('cOverlay').style.display='flex';
411
+ _annotatedUrl=`/api/video/${_jobId}`;
412
+
413
+ /* Stats */
414
+ const s=d.stats||{};
415
+ _set('stF',s.total_frames??'—');_set('stO',s.total_unique??'—');
416
+ _set('stFps',s.fps?s.fps.toFixed(1):'—');_set('stD',s.duration_s?s.duration_s+'s':'—');
417
+ _showCounts(s.unique_counts||{});
418
+ _set('dlName',vidFile?vidFile.name:'annotated.mp4');
419
+ document.getElementById('dlBtn').removeAttribute('disabled');
420
+
421
+ /* Sauvegarde localStorage */
422
+ try{
423
+ localStorage.setItem('tiq_job_id',_jobId);
424
+ localStorage.setItem('tiq_video_url',`/api/video/${_jobId}`);
425
+ localStorage.setItem('tiq_video_name',s.video_name||'');
426
+ localStorage.setItem('tiq_stats',JSON.stringify(s));
427
+ }catch(ex){}
428
+ return;
429
+ }
430
+
431
+ /* Progression */
432
+ if(d.pct!=null){
433
+ document.getElementById('progBar').style.width=d.pct+'%';
434
+ document.getElementById('progTxt').textContent=`Processing… ${d.pct.toFixed(0)}%`;
435
+ }
436
+ _showBanner(d.no_objects===true);
437
+ if(d.hud){
438
+ _set('hudFr',d.hud.frame_str);
439
+ const hco=document.getElementById('hudCo');
440
+ if(hco) hco.innerHTML=Object.entries(d.hud.counts||{})
441
+ .map(([k,v])=>`${k} = ${v}`).join('<br>')||'No object';
442
+ }
443
+ if(d.unique_counts) _showCounts(d.unique_counts);
444
+ };
445
+ _sse.onerror=()=>_sse.close();
446
+ };
447
+
448
+ /* ── Webcam ── */
449
+ let _wcFrameIv=null, _wcHiddenVideo=null, _wcCanvas2=null;
450
+
451
+ async function startWc(){
452
+ try{
453
+ wcStream=await navigator.mediaDevices.getUserMedia({video:{width:640,height:480},audio:false});
454
+ }catch(e){alert('Webcam unavailable: '+e.message);return;}
455
+
456
+ document.getElementById('wcPh').style.display='none';
457
+ document.getElementById('recBadge').style.display='flex';
458
+ document.getElementById('runBtn').style.display='none';
459
+
460
+ const btn=document.getElementById('wcBtn');
461
+ btn.innerHTML='<i class="fas fa-stop" style="color:#f87171;font-size:9px;"></i> Stop webcam';
462
+ btn.onclick=stopWc;
463
+
464
+ /* Lance YOLO backend */
465
+ await fetch('/api/webcam/start',{method:'POST',headers:{'Content-Type':'application/json'},
466
+ body:JSON.stringify({classes:[...sel],model:'yolo11n'})}).catch(()=>{});
467
+
468
+ /* Setup hidden video + canvas pour capturer frames */
469
+ _wcHiddenVideo=document.createElement('video');
470
+ _wcHiddenVideo.srcObject=wcStream;
471
+ _wcHiddenVideo.setAttribute('playsinline','');
472
+ _wcHiddenVideo.muted=true;
473
+ _wcHiddenVideo.play();
474
+ _wcCanvas2=document.createElement('canvas');
475
+ _wcCanvas2.width=640;_wcCanvas2.height=480;
476
+ const ctx2=_wcCanvas2.getContext('2d');
477
+
478
+ /* Envoie une frame toutes les 200ms → reçoit JPEG annoté → l'affiche */
479
+ const miniImg=document.getElementById('wcMiniYolo');
480
+ const bigImg=document.getElementById('wcYoloImg');
481
+ miniImg.style.display='block';
482
+ bigImg.style.display='block';
483
+
484
+ _wcFrameIv=setInterval(async()=>{
485
+ if(!wcStream||!_wcHiddenVideo)return;
486
+ ctx2.drawImage(_wcHiddenVideo,0,0,640,480);
487
+ _wcCanvas2.toBlob(async(blob)=>{
488
+ if(!blob)return;
489
+ try{
490
+ const resp=await fetch('/api/webcam/frame',{method:'POST',
491
+ headers:{'Content-Type':'image/jpeg'},body:blob});
492
+ if(!resp.ok)return;
493
+ const url=URL.createObjectURL(await resp.blob());
494
+ miniImg.src=url;bigImg.src=url;
495
+ }catch(e){}
496
+ },'image/jpeg',0.75);
497
+ },200);
498
+
499
+ /* Grand panel droite */
500
+ document.getElementById('emptyState').style.display='none';
501
+ document.getElementById('resultVid').style.display='none';
502
+ document.getElementById('hudEl').style.display='block';
503
+ document.getElementById('cOverlay').style.display='flex';
504
+ document.getElementById('srcLabel').textContent='Webcam — LIVE';
505
+
506
+ /* Démarre l'enregistrement automatiquement */
507
+ setTimeout(_startRec, 800);
508
+ /* Polling stats toutes les 800ms */
509
+ clearInterval(_wcPollIv);
510
+ _wcPollIv=setInterval(_pollWc,800);
511
+ checkReady();
512
+ }
513
+
514
+ /* ── Recording webcam → grand Result ── */
515
+ let _recIv=null, _recChunks=[], _mediaRec=null;
516
+
517
+ function _startRec(){
518
+ const canvas=document.getElementById('wcCanvas');
519
+ const img=document.getElementById('wcYoloImg');
520
+ if(!canvas||!img)return;
521
+ canvas.width=640;canvas.height=480;
522
+ const ctx=canvas.getContext('2d');
523
+ _recIv=setInterval(()=>{
524
+ if(img.naturalWidth>0){canvas.width=img.naturalWidth;canvas.height=img.naturalHeight;ctx.drawImage(img,0,0);}
525
+ },40);
526
+ const mime=MediaRecorder.isTypeSupported('video/webm;codecs=vp9')?'video/webm;codecs=vp9':'video/webm';
527
+ _mediaRec=new MediaRecorder(canvas.captureStream(25),{mimeType:mime,videoBitsPerSecond:2000000});
528
+ _recChunks=[];
529
+ _mediaRec.ondataavailable=e=>{if(e.data.size>0)_recChunks.push(e.data);};
530
+ _mediaRec.onstop=_saveRec;
531
+ _mediaRec.start(500);
532
+ }
533
+
534
+ function _saveRec(){
535
+ clearInterval(_recIv);_recIv=null;
536
+ if(!_recChunks.length)return;
537
+ const blob=new Blob(_recChunks,{type:'video/webm'});
538
+ const url=URL.createObjectURL(blob);
539
+ _annotatedUrl=url;
540
+ /* Affiche dans le grand Result */
541
+ const rv=document.getElementById('resultVid');
542
+ rv.src=url;rv.style.display='block';rv.controls=true;
543
+ rv.onloadeddata=()=>rv.play().catch(()=>{});rv.load();
544
+ document.getElementById('srcLabel').textContent='Webcam — recorded';
545
+ document.getElementById('emptyState').style.display='none';
546
+ document.getElementById('hudEl').style.display='block';
547
+ /* Active download */
548
+ _set('dlName','webcam_trackiq.webm');
549
+ document.getElementById('dlBtn').removeAttribute('disabled');
550
+ document.getElementById('dlBtn').onclick=()=>{
551
+ const a=document.createElement('a');a.href=url;a.download='webcam_trackiq.webm';a.click();
552
+ };
553
+ }
554
+
555
+ function stopWc(){
556
+ /* 1. Arrête enregistrement → déclenche _saveRec → vidéo dans Result */
557
+ if(_mediaRec&&_mediaRec.state==='recording') _mediaRec.stop();
558
+ clearInterval(_wcFrameIv);_wcFrameIv=null;
559
+ if(_wcHiddenVideo){_wcHiddenVideo.srcObject=null;_wcHiddenVideo=null;}
560
+ _wcCanvas2=null;
561
+ if(wcStream){wcStream.getTracks().forEach(t=>t.stop());wcStream=null;}
562
+ fetch('/api/webcam/stop',{method:'POST'}).catch(()=>{});
563
+ clearInterval(_wcPollIv);_wcPollIv=null;
564
+
565
+ /* 2. Cache flux live */
566
+ const mi=document.getElementById('wcMiniYolo');mi.src='';mi.style.display='none';
567
+ const bi=document.getElementById('wcYoloImg');bi.src='';bi.style.display='none';
568
+
569
+ document.getElementById('wcPh').style.display='flex';
570
+ document.getElementById('recBadge').style.display='none';
571
+ document.getElementById('runBtn').style.display='flex';
572
+ document.getElementById('cOverlay').style.display='none';
573
+ document.getElementById('noObjBanner').style.display='none';
574
+
575
+ const btn=document.getElementById('wcBtn');
576
+ btn.innerHTML='<i class="fas fa-circle" style="color:#f87171;font-size:8px;"></i> Start webcam';
577
+ btn.onclick=startWc;
578
+ checkReady();
579
+ }
580
+
581
+ async function _pollWc(){
582
+ try{
583
+ const r=await fetch('/api/webcam/stats');
584
+ if(!r.ok)return;
585
+ const d=await r.json();
586
+ const fc=d.frame_counts||{};
587
+ const uc=d.unique_counts||{};
588
+ _set('hudFr',`F:${d.frame||0} ▶ LIVE`);
589
+ const hco=document.getElementById('hudCo');
590
+ if(hco) hco.innerHTML=Object.entries(fc).map(([k,v])=>`${k} = ${v}`).join('<br>')||
591
+ '<span style="color:#ef4444">No object</span>';
592
+ _showCountsWc(fc,uc);
593
+ _showBanner(!Object.keys(fc).length);
594
+ }catch(e){}
595
+ }
596
+
597
+ /* ── Affichage compteurs ── */
598
+ function _showCounts(uc){
599
+ const ov=document.getElementById('cOverlay');
600
+ ov.innerHTML='';
601
+ if(!Object.keys(uc).length){
602
+ _showBanner(true);return;
603
+ }
604
+ Object.entries(uc).forEach(([cls,cnt])=>{
605
+ const cl=CLASSES.find(c=>c.name===cls);
606
+ const color=cl?cl.color:'#a78bfa';
607
+ const div=document.createElement('div');div.className='cbdg';
608
+ div.style.borderLeftColor=color;
609
+ div.innerHTML=`<span class="cn" style="color:${color}">${cls} = ${cnt}</span>`;
610
+ ov.appendChild(div);
611
+ });
612
+ _showBanner(false);
613
+ }
614
+
615
+ function _showCountsWc(fc,uc){
616
+ const ov=document.getElementById('cOverlay');
617
+ ov.innerHTML='';
618
+ const all=new Set([...Object.keys(fc),...Object.keys(uc)]);
619
+ if(!all.size){_showBanner(true);return;}
620
+ all.forEach(cls=>{
621
+ const cl=CLASSES.find(c=>c.name===cls);
622
+ const color=cl?cl.color:'#a78bfa';
623
+ const div=document.createElement('div');div.className='cbdg';
624
+ div.style.borderLeftColor=color;
625
+ div.innerHTML=`<span class="cn" style="color:${color}">${cls} = ${fc[cls]||0}</span>
626
+ <span class="cu">Total unique : ${uc[cls]||0}</span>`;
627
+ ov.appendChild(div);
628
+ });
629
+ _showBanner(false);
630
+ }
631
+
632
+ function _showBanner(show){
633
+ const b=document.getElementById('noObjBanner');
634
+ b.style.display=show?'flex':'none';
635
+ }
636
+
637
+ /* ── Helpers ── */
638
+ function _set(id,val){const el=document.getElementById(id);if(el)el.textContent=val;}
639
+ function checkReady(){
640
+ const ok=(curSrc==='upload'&&vidFile)||(curSrc==='url'&&vidUrl)||(curSrc==='webcam'&&wcStream);
641
+ document.getElementById('runBtn').disabled=!ok;
642
+ }
643
+ function doDownload(){
644
+ const url=_annotatedUrl;if(!url)return;
645
+ const a=document.createElement('a');a.href=url;a.download='trackiq_annotated.mp4';a.click();
646
+ }
647
+ function resetAll(){
648
+ if(wcStream)stopWc();
649
+ if(_sse)_sse.close();
650
+ _jobId=null;_annotatedUrl=null;vidFile=null;vidUrl=null;
651
+ const rv=document.getElementById('resultVid');rv.src='';rv.style.display='none';
652
+ document.getElementById('fname').style.display='none';
653
+ document.getElementById('fname').textContent='';
654
+ document.getElementById('uzone').classList.remove('has');
655
+ document.getElementById('urlIn').value='';
656
+ document.getElementById('progWrap').style.display='none';
657
+ document.getElementById('progBar').style.width='0%';
658
+ document.getElementById('progTxt').style.display='none';
659
+ document.getElementById('emptyState').style.display='flex';
660
+ document.getElementById('cOverlay').style.display='none';
661
+ document.getElementById('hudEl').style.display='none';
662
+ document.getElementById('noObjBanner').style.display='none';
663
+ document.getElementById('dlBtn').disabled=true;
664
+ document.getElementById('dlName').textContent='No result yet';
665
+ document.getElementById('srcLabel').textContent='';
666
+ ['stF','stO','stFps','stD'].forEach(id=>_set(id,'—'));
667
+ const fi=document.getElementById('fileInput');if(fi)fi.value='';
668
+ checkReady();
669
+ }
670
+
671
+ /* ── Init ── */
672
+ (function(){
673
+ try{
674
+ const th=localStorage.getItem('tiq_theme');
675
+ if(th){document.documentElement.setAttribute('data-theme',th);
676
+ if(th==='dark'){['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className='fas fa-sun';});}}
677
+ const lg=localStorage.getItem('tiq_lang');
678
+ if(lg&&lg!=='en'){const btn=document.querySelector('.lb[onclick*="\''+lg+'\'"]');if(btn)setLang(lg,btn);}
679
+ }catch(e){}
680
+ })();
681
+ </script>
682
+ <script src="/static/trackiq-api.js"></script>
683
+ </body>
684
+ </html>
TRAFFIC_ROAD_APP/templates/index.html ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ — Urban Traffic Detection</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300&family=DM+Mono:wght@400&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <style>
10
+ :root {
11
+ --ac: #880000;
12
+ --ac2: #660000;
13
+ --bg: #f9fafb;
14
+ --s: #fff;
15
+ --bd: rgba(0,0,0,0.07);
16
+ --t: #111827;
17
+ --t2: #6b7280;
18
+ --t3: #d1d5db;
19
+ --sh: 0 4px 24px rgba(0,0,0,0.09);
20
+ --shlg: 0 20px 25px -5px rgba(0,0,0,0.10), 0 10px 10px -5px rgba(0,0,0,0.04);
21
+ --r: 16px;
22
+ }
23
+ [data-theme="dark"] {
24
+ --bg: #0c1117;
25
+ --s: #151c25;
26
+ --t: #e8edf2;
27
+ --t2: #7a8fa0;
28
+ --t3: #3a4a5a;
29
+ --bd: rgba(255,255,255,0.07);
30
+ --sh: 0 4px 24px rgba(0,0,0,0.5);
31
+ --shlg: 0 20px 40px rgba(0,0,0,0.5);
32
+ }
33
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
34
+ html, body { min-height: 100%; }
35
+ body {
36
+ font-family: "DM Sans", sans-serif;
37
+ background: var(--bg);
38
+ color: var(--t);
39
+ transition: background 0.3s, color 0.3s;
40
+ }
41
+ ::-webkit-scrollbar { width: 6px; }
42
+ ::-webkit-scrollbar-track { background: transparent; }
43
+ ::-webkit-scrollbar-thumb { background: var(--t3); border-radius: 3px; }
44
+
45
+ /* ── FLOATING NAV ── */
46
+ .floatnav {
47
+ position: absolute;
48
+ top: 0; left: 0; right: 0;
49
+ z-index: 50;
50
+ pointer-events: none;
51
+ }
52
+ .floatnav-inner {
53
+ max-width: 1280px;
54
+ margin: 0 auto;
55
+ padding: 16px 24px;
56
+ display: flex;
57
+ justify-content: space-between;
58
+ align-items: flex-start;
59
+ }
60
+ .nav-brand {
61
+ pointer-events: auto;
62
+ background: rgba(255,255,255,0.92);
63
+ backdrop-filter: blur(12px);
64
+ border-radius: 10px;
65
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
66
+ padding: 8px 16px;
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 8px;
70
+ text-decoration: none;
71
+ transition: all 0.2s;
72
+ }
73
+ .nav-brand:hover { background: #fff; box-shadow: 0 8px 24px rgba(0,0,0,0.16); }
74
+ .nav-brand-icon {
75
+ width: 28px; height: 28px;
76
+ background: var(--ac);
77
+ border-radius: 7px;
78
+ display: flex; align-items: center; justify-content: center;
79
+ color: #fff; font-size: 12px;
80
+ }
81
+ .nav-brand-name {
82
+ font-size: 14px; font-weight: 600;
83
+ color: #111827; letter-spacing: -0.3px;
84
+ }
85
+ .nav-brand-name b { color: var(--ac); }
86
+ .nav-right {
87
+ pointer-events: auto;
88
+ display: flex; gap: 8px; align-items: center;
89
+ }
90
+ .nav-lang {
91
+ background: #fff;
92
+ border-radius: 10px;
93
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
94
+ padding: 4px;
95
+ display: flex; gap: 4px;
96
+ }
97
+ .lb {
98
+ font-size: 12px; font-weight: 500;
99
+ padding: 4px 10px;
100
+ border: none; background: none;
101
+ cursor: pointer; border-radius: 6px;
102
+ color: #6b7280;
103
+ font-family: "DM Sans", sans-serif;
104
+ transition: all 0.2s;
105
+ }
106
+ .lb.on { background: var(--ac); color: #fff; }
107
+ .lb:not(.on):hover { background: #f3f4f6; color: #111827; }
108
+ .nav-theme {
109
+ background: #fff;
110
+ border-radius: 10px;
111
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
112
+ width: 38px; height: 38px;
113
+ border: none; cursor: pointer;
114
+ display: flex; align-items: center; justify-content: center;
115
+ color: #6b7280; font-size: 13px;
116
+ transition: all 0.2s;
117
+ }
118
+ .nav-theme:hover { color: var(--ac); }
119
+
120
+ /* ── HERO ── */
121
+ .hero {
122
+ background-color: var(--ac);
123
+ background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23991111' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
124
+ color: #fff;
125
+ padding-top: 90px;
126
+ padding-bottom: 90px;
127
+ padding-left: 24px;
128
+ padding-right: 24px;
129
+ text-align: center;
130
+ position: relative;
131
+ overflow: hidden;
132
+ }
133
+ .hero-inner {
134
+ max-width: 860px;
135
+ margin: 0 auto;
136
+ position: relative;
137
+ z-index: 10;
138
+ animation: fadeUp 0.6s ease both;
139
+ }
140
+ @keyframes fadeUp {
141
+ from { opacity: 0; transform: translateY(24px); }
142
+ to { opacity: 1; transform: translateY(0); }
143
+ }
144
+ .hero-logo-wrap {
145
+ display: flex;
146
+ justify-content: center;
147
+ margin-bottom: 16px;
148
+ }
149
+ .hero-logo-box {
150
+ background: #fff;
151
+ padding: 14px;
152
+ border-radius: 20px;
153
+ box-shadow: 0 8px 32px rgba(0,0,0,0.2);
154
+ display: flex; align-items: center; justify-content: center;
155
+ width: 80px; height: 80px;
156
+ }
157
+ .hero-logo-box i {
158
+ font-size: 36px;
159
+ color: var(--ac);
160
+ }
161
+ .hero h1 {
162
+ font-size: clamp(32px, 5vw, 60px);
163
+ font-weight: 800;
164
+ letter-spacing: -2px;
165
+ line-height: 1.1;
166
+ margin-bottom: 14px;
167
+ }
168
+ .hero h1 .dim { opacity: 0.75; font-weight: 300; font-style: italic; }
169
+ .hero-sub {
170
+ font-size: clamp(14px, 1.8vw, 18px);
171
+ color: rgba(255,255,255,0.88);
172
+ font-weight: 400;
173
+ line-height: 1.65;
174
+ margin-bottom: 24px;
175
+ max-width: 560px;
176
+ margin-left: auto;
177
+ margin-right: auto;
178
+ }
179
+ .hero-cta {
180
+ display: inline-flex; align-items: center; gap: 10px;
181
+ background: #fff;
182
+ color: var(--ac);
183
+ font-weight: 700; font-size: 16px;
184
+ padding: 14px 32px;
185
+ border-radius: 50px;
186
+ text-decoration: none;
187
+ box-shadow: 0 8px 24px rgba(0,0,0,0.18);
188
+ transition: all 0.3s;
189
+ }
190
+ .hero-cta:hover {
191
+ background: #f3f4f6;
192
+ transform: scale(1.05);
193
+ box-shadow: 0 16px 40px rgba(0,0,0,0.22);
194
+ }
195
+ /* Wave bottom */
196
+ .hero-wave {
197
+ position: absolute;
198
+ bottom: 0; left: 0; right: 0;
199
+ pointer-events: none;
200
+ }
201
+
202
+ /* ── CARDS GRID ── */
203
+ .cards-section {
204
+ max-width: 1280px;
205
+ margin: 0 auto;
206
+ padding: 0 24px 64px;
207
+ margin-top: -64px;
208
+ position: relative;
209
+ z-index: 20;
210
+ }
211
+ .cards-grid {
212
+ display: grid;
213
+ grid-template-columns: repeat(1, 1fr);
214
+ gap: 24px;
215
+ }
216
+ @media (min-width: 640px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } }
217
+ @media (min-width: 1024px) { .cards-grid { grid-template-columns: repeat(4, 1fr); } }
218
+
219
+ .card {
220
+ background: var(--s);
221
+ border-radius: 20px;
222
+ padding: 32px 24px;
223
+ box-shadow: var(--shlg);
224
+ border: 1px solid rgba(0,0,0,0.05);
225
+ text-decoration: none;
226
+ display: block;
227
+ transition: all 0.3s ease;
228
+ animation: fadeUp 0.6s ease both;
229
+ }
230
+ .card:nth-child(1) { animation-delay: 0.05s; }
231
+ .card:nth-child(2) { animation-delay: 0.12s; }
232
+ .card:nth-child(3) { animation-delay: 0.18s; }
233
+ .card:nth-child(4) { animation-delay: 0.24s; }
234
+ .card:hover {
235
+ transform: translateY(-8px);
236
+ box-shadow: 0 20px 25px -5px rgba(0,0,0,0.13), 0 10px 10px -5px rgba(0,0,0,0.05);
237
+ border-color: var(--ac);
238
+ }
239
+ .card-icon-wrap {
240
+ width: 80px; height: 80px;
241
+ background: #fff1f1;
242
+ color: var(--ac);
243
+ border-radius: 50%;
244
+ display: flex; align-items: center; justify-content: center;
245
+ font-size: 28px;
246
+ margin: 0 auto 24px;
247
+ transition: all 0.3s;
248
+ box-shadow: 0 2px 8px rgba(136,0,0,0.08);
249
+ }
250
+ [data-theme="dark"] .card-icon-wrap {
251
+ background: rgba(136,0,0,0.18);
252
+ }
253
+ .card:hover .card-icon-wrap {
254
+ background: var(--ac);
255
+ color: #fff;
256
+ }
257
+ .card-title {
258
+ text-align: center;
259
+ font-size: 18px; font-weight: 700;
260
+ color: var(--t);
261
+ margin-bottom: 10px;
262
+ transition: color 0.2s;
263
+ }
264
+ .card:hover .card-title { color: var(--ac); }
265
+ .card-desc {
266
+ text-align: center;
267
+ font-size: 13px;
268
+ color: var(--t2);
269
+ line-height: 1.65;
270
+ }
271
+ .card-launch {
272
+ margin-top: 24px;
273
+ display: flex; justify-content: center;
274
+ }
275
+ .card-launch span {
276
+ font-size: 13px; font-weight: 600;
277
+ color: var(--ac);
278
+ display: flex; align-items: center; gap: 6px;
279
+ opacity: 0;
280
+ transition: opacity 0.2s;
281
+ }
282
+ .card:hover .card-launch span { opacity: 1; }
283
+
284
+ /* ── FOOTER ── */
285
+ footer {
286
+ background: var(--s);
287
+ border-top: 1px solid var(--bd);
288
+ padding: 28px 24px;
289
+ text-align: center;
290
+ transition: background 0.3s;
291
+ }
292
+ footer p {
293
+ font-size: 13px;
294
+ color: var(--t2);
295
+ line-height: 1.7;
296
+ }
297
+ footer p span {
298
+ font-size: 11px;
299
+ opacity: 0.6;
300
+ display: block;
301
+ margin-top: 4px;
302
+ }
303
+ </style>
304
+ </head>
305
+ <body>
306
+
307
+ <!-- FLOATING NAV -->
308
+ <div class="floatnav">
309
+ <div class="floatnav-inner">
310
+ <a href="#" class="nav-brand">
311
+ <div class="nav-brand-icon"><i class="fas fa-satellite-dish"></i></div>
312
+ <span class="nav-brand-name">Track<b>IQ</b></span>
313
+ </a>
314
+ <div class="nav-right">
315
+ <div class="nav-lang">
316
+ <button class="lb on" onclick="setLang('en',this)">EN</button>
317
+ <button class="lb" onclick="setLang('fr',this)">FR</button>
318
+ <button class="lb" onclick="setLang('wo',this)">WO</button>
319
+ </div>
320
+ <button class="nav-theme" onclick="toggleTheme()"><i class="fas fa-moon" id="ti"></i></button>
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- HERO -->
326
+ <div class="hero">
327
+ <div class="hero-inner">
328
+ <div class="hero-logo-wrap">
329
+ <div class="hero-logo-box">
330
+ <i class="fas fa-satellite-dish"></i>
331
+ </div>
332
+ </div>
333
+ <h1 id="t-h1">
334
+ TrackIQ Urban Traffic<br>
335
+ <span class="dim">Surveillance &amp; Detection</span>
336
+ </h1>
337
+ <p class="hero-sub" id="t-sub">
338
+ Based on Computer Vision — real-time vehicle detection and classification, speed estimation, congestion analysis and traffic sign identification.
339
+ </p>
340
+ <a href="/home" class="hero-cta" id="t-launch">
341
+ <i class="fas fa-play"></i>
342
+ <span>Launch</span>
343
+ </a>
344
+ </div>
345
+ <div class="hero-wave">
346
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 120" style="display:block;width:100%;">
347
+ <path fill="var(--bg)" fill-opacity="1"
348
+ d="M0,64L80,69.3C160,75,320,85,480,80C640,75,800,53,960,48C1120,43,1280,53,1360,58.7L1440,64L1440,120L1360,120C1280,120,1120,120,960,120C800,120,640,120,480,120C320,120,160,120,80,120L0,120Z"/>
349
+ </svg>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- CARDS -->
354
+ <div class="cards-section">
355
+ <div class="cards-grid">
356
+
357
+ <!-- Card 1 -->
358
+ <a href="/home" class="card">
359
+ <div class="card-icon-wrap"><i class="fas fa-car-side"></i></div>
360
+ <div class="card-title" id="t-c1t">Vehicle Detection</div>
361
+ <p class="card-desc" id="t-c1d">Real-time detection and classification of vehicles: cars, trucks, motorcycles, buses and more.</p>
362
+ <div class="card-launch"><span id="t-launch2">Launch <i class="fas fa-arrow-right"></i></span></div>
363
+ </a>
364
+
365
+ <!-- Card 2 -->
366
+ <a href="/home" class="card">
367
+ <div class="card-icon-wrap"><i class="fas fa-tachometer-alt"></i></div>
368
+ <div class="card-title" id="t-c2t">Speed Estimation</div>
369
+ <p class="card-desc" id="t-c2d">Automatic estimation of vehicle speed from video stream without additional sensors.</p>
370
+ <div class="card-launch"><span id="t-launch3">Launch <i class="fas fa-arrow-right"></i></span></div>
371
+ </a>
372
+
373
+ <!-- Card 3 -->
374
+ <a href="/home" class="card">
375
+ <div class="card-icon-wrap"><i class="fas fa-traffic-light"></i></div>
376
+ <div class="card-title" id="t-c3t">Signs &amp; Lights</div>
377
+ <p class="card-desc" id="t-c3d">Identification of traffic signs and lights, red light detection, stop signs and more.</p>
378
+ <div class="card-launch"><span id="t-launch4">Launch <i class="fas fa-arrow-right"></i></span></div>
379
+ </a>
380
+
381
+ <!-- Card 4 -->
382
+ <a href="/home" class="card">
383
+ <div class="card-icon-wrap"><i class="fas fa-chart-bar"></i></div>
384
+ <div class="card-title" id="t-c4t">Flow &amp; Congestion</div>
385
+ <p class="card-desc" id="t-c4d">Congestion analysis, flow measurement and annotated video export for reporting.</p>
386
+ <div class="card-launch"><span id="t-launch5">Launch <i class="fas fa-arrow-right"></i></span></div>
387
+ </a>
388
+
389
+ </div>
390
+ </div>
391
+
392
+ <!-- FOOTER -->
393
+ <footer>
394
+ <p>
395
+ © 2026 <strong>TrackIQ</strong> — Computer Vision Project. All rights reserved.
396
+ <span id="t-foot">Academic Project — Version 1.0</span>
397
+ </p>
398
+ </footer>
399
+
400
+ <script>
401
+ const T = {
402
+ en: {
403
+ h1: 'TrackIQ Urban Traffic<br><span class="dim">Surveillance &amp; Detection</span>',
404
+ sub: "Based on Computer Vision — real-time vehicle detection and classification, speed estimation, congestion analysis and traffic sign identification.",
405
+ launch: '<i class="fas fa-play"></i><span>Launch</span>',
406
+ c1t: "Vehicle Detection", c1d: "Real-time detection and classification of vehicles: cars, trucks, motorcycles, buses and more.",
407
+ c2t: "Speed Estimation", c2d: "Automatic estimation of vehicle speed from video stream without additional sensors.",
408
+ c3t: "Signs & Lights", c3d: "Identification of traffic signs and lights, red light detection, stop signs and more.",
409
+ c4t: "Flow & Congestion", c4d: "Congestion analysis, flow measurement and annotated video export for reporting.",
410
+ launchbtn: 'Launch <i class="fas fa-arrow-right"></i>',
411
+ foot: "Academic Project — Version 1.0"
412
+ },
413
+ fr: {
414
+ h1: 'TrackIQ Trafic Urbain<br><span class="dim">Surveillance &amp; Détection</span>',
415
+ sub: "Basé sur Computer Vision — détection et classification en temps réel des véhicules, estimation de vitesse, analyse de congestion et identification des feux et panneaux.",
416
+ launch: '<i class="fas fa-play"></i><span>Lancer</span>',
417
+ c1t: "Détection Véhicules", c1d: "Détection et classification en temps réel des véhicules : voitures, camions, motos, bus et plus.",
418
+ c2t: "Estimation Vitesse", c2d: "Estimation automatique de la vitesse des véhicules depuis un flux vidéo, sans capteur additionnel.",
419
+ c3t: "Feux &amp; Panneaux", c3d: "Identification des panneaux et feux de signalisation, détection des feux rouges, stops et plus.",
420
+ c4t: "Flux &amp; Congestion",c4d: "Analyse de congestion, mesure de flux et export vidéo annoté pour rapport.",
421
+ launchbtn: 'Lancer <i class="fas fa-arrow-right"></i>',
422
+ foot: "Projet Académique — Version 1.0"
423
+ },
424
+ wo: {
425
+ h1: 'TrackIQ Trafik Kanam<br><span class="dim">Xam ak Réy</span>',
426
+ sub: "Moom Computer Vision la tax — léegi-léegi di réy wërsëg yi, ak xam njariñ yoom yi, ci kanam.",
427
+ launch: '<i class="fas fa-play"></i><span>Tambali</span>',
428
+ c1t: "Réy Wërsëg", c1d: "Réy ak xam njariñ wërsëg yi ci kanam: yëgël, camion, moto, bus ak yeneen.",
429
+ c2t: "Yóbbu Yoom", c2d: "Xam yóbbu yoom wërsëg yi ci video, benn jëriñ mujj dafa soxor.",
430
+ c3t: "Siñali", c3d: "Xam siñali yi ak dëkkandiku, réy feux rouge, stop ak yeneen.",
431
+ c4t: "Rafet &amp; Xonq", c4d: "Xam xonq, jëf rafet ci kanam ak dëkkandiku video bu annotéwoon.",
432
+ launchbtn: 'Tambali <i class="fas fa-arrow-right"></i>',
433
+ foot: "Liggéey Académique — Version 1.0"
434
+ }
435
+ };
436
+
437
+ function setLang(l, btn) {
438
+ document.querySelectorAll(".lb").forEach(b => b.classList.remove("on"));
439
+ btn.classList.add("on");
440
+ const t = T[l];
441
+ if (!t) return;
442
+ document.getElementById("t-h1").innerHTML = t.h1;
443
+ document.getElementById("t-sub").textContent = t.sub;
444
+ document.getElementById("t-launch").innerHTML = t.launch;
445
+ ["c1t","c2t","c3t","c4t"].forEach(k => {
446
+ const el = document.getElementById("t-"+k);
447
+ if (el) el.innerHTML = t[k];
448
+ });
449
+ ["c1d","c2d","c3d","c4d"].forEach(k => {
450
+ const el = document.getElementById("t-"+k);
451
+ if (el) el.textContent = t[k];
452
+ });
453
+ ["t-launch2","t-launch3","t-launch4","t-launch5"].forEach(id => {
454
+ const el = document.getElementById(id);
455
+ if (el) el.innerHTML = t.launchbtn;
456
+ });
457
+ document.getElementById("t-foot").textContent = t.foot;
458
+ try { localStorage.setItem("tiq_lang", l); } catch(e) {}
459
+ }
460
+
461
+ function toggleTheme() {
462
+ const d = document.documentElement;
463
+ const dk = d.getAttribute("data-theme") === "dark";
464
+ d.setAttribute("data-theme", dk ? "light" : "dark");
465
+ document.getElementById("ti").className = dk ? "fas fa-moon" : "fas fa-sun";
466
+ try { localStorage.setItem("tiq_theme", dk ? "light" : "dark"); } catch(e) {}
467
+ }
468
+
469
+ (function() {
470
+ try {
471
+ const th = localStorage.getItem("tiq_theme");
472
+ if (th) {
473
+ document.documentElement.setAttribute("data-theme", th);
474
+ if (th === "dark") document.getElementById("ti").className = "fas fa-sun";
475
+ }
476
+ const lg = localStorage.getItem("tiq_lang");
477
+ if (lg && lg !== "en") {
478
+ const btn = document.querySelector('.lb[onclick*="\'' + lg + '\'"]');
479
+ if (btn) setLang(lg, btn);
480
+ }
481
+ } catch(e) {}
482
+ })();
483
+ </script>
484
+ <script src="/static/trackiq-api.js"></script>
485
+ </body>
486
+ </html>
TRAFFIC_ROAD_APP/templates/logs.html ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ Logs</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <style>
10
+ :root{--bg:#f0f4f8;--surface:#fff;--surface2:#f6f8fb;--border:rgba(0,0,0,0.07);--shadow:0 2px 16px rgba(0,0,0,0.09);--shadow-lg:0 8px 36px rgba(0,0,0,0.13);--text:#0f1923;--text2:#5a6a7a;--text3:#9aaab8;--accent:#880000;--accent2:#660000;--accent-light:rgba(136,0,0,0.07);--sidebar-w:60px;--radius:14px;--radius-sm:9px;}
11
+ [data-theme="dark"]{--bg:#0c1117;--surface:#151c25;--surface2:#1c2531;--border:rgba(255,255,255,0.07);--shadow:0 2px 16px rgba(0,0,0,0.45);--shadow-lg:0 8px 36px rgba(0,0,0,0.55);--text:#e8edf2;--text2:#7a8fa0;--text3:#3a4a5a;}
12
+ *{box-sizing:border-box;margin:0;padding:0}::-webkit-scrollbar{width:0;height:0}
13
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);font-size:13px;display:flex;min-height:100vh}
14
+ .sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;align-items:center;padding:16px 0;gap:4px;position:fixed;top:0;left:0;bottom:0;z-index:200}
15
+ .sb-logo{width:34px;height:34px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;margin-bottom:10px;text-decoration:none}
16
+ .sb-sep{width:28px;height:1px;background:var(--border);margin:4px 0}
17
+ .sb-btn{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:15px;cursor:pointer;transition:all .2s;text-decoration:none;border:none;background:none;position:relative}
18
+ .sb-btn:hover,.sb-btn.active{background:var(--accent-light);color:var(--accent)}
19
+ .sb-btn .tip{position:absolute;left:46px;top:50%;transform:translateY(-50%);background:var(--text);color:var(--surface);font-size:11px;padding:3px 8px;border-radius:5px;white-space:nowrap;opacity:0;pointer-events:none}.sb-btn:hover .tip{opacity:1}
20
+ .sb-spacer{flex:1}.page-wrap{margin-left:var(--sidebar-w);flex:1;display:flex;flex-direction:column}
21
+ .topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 22px}
22
+ .topbar-l,.topbar-r{display:flex;align-items:center;gap:10px}
23
+ .brand-name{font-size:15px;font-weight:600}.brand-name b{color:var(--accent)}.sep{color:var(--border);font-size:18px;margin:0 2px}
24
+ .lang-group{display:flex;background:var(--surface2);border-radius:6px;padding:2px;border:1px solid var(--border)}
25
+ .lang-btn{font-size:11px;font-weight:500;padding:3px 8px;border:none;background:none;cursor:pointer;border-radius:4px;color:var(--text2);font-family:'DM Sans',sans-serif}.lang-btn.active{background:var(--accent);color:white}
26
+ .icon-btn{width:30px;height:30px;border-radius:6px;border:1px solid var(--border);background:var(--surface);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:12px}
27
+ main{flex:1;padding:16px 18px;max-width:1440px;width:100%;margin:0 auto;display:flex;flex-direction:column;gap:12px}
28
+ .sum-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
29
+ .sc,.card,.tcard{background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);box-shadow:var(--shadow)}
30
+ .sc{padding:13px 15px}.sc-l{font-size:11px;color:var(--text2);margin-bottom:5px}.sc-v{font-size:22px;font-weight:300}.sc-s{font-size:10px;color:var(--text3);margin-top:3px}
31
+ .card{padding:14px 16px}.filter-row{display:flex;align-items:flex-end;gap:9px;flex-wrap:wrap}.fg{display:flex;flex-direction:column;gap:4px}.fg label{font-size:11px;color:var(--text2)}
32
+ .fin{padding:8px 10px;border:1px solid var(--border);border-radius:7px;font-size:12px;background:var(--surface2);color:var(--text);font-family:'DM Sans',sans-serif;outline:none;min-width:160px}.fin:focus{border-color:var(--accent)}
33
+ .f-sp{flex:1}
34
+ .btn{display:flex;align-items:center;gap:5px;border:none;border-radius:7px;padding:8px 13px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif}
35
+ .btn-exp{background:var(--accent);color:white}.btn-exp:hover{background:var(--accent2)}
36
+ .save-opt{display:flex;align-items:center;gap:10px;width:100%;padding:9px 14px;border:none;background:none;cursor:pointer;font-size:12px;color:var(--text);font-family:'DM Sans',sans-serif;text-align:left}.save-opt:hover{background:var(--surface2)}
37
+ .btn-clr{background:var(--surface2);color:var(--text2);border:1px solid var(--border)}
38
+ .tcard{overflow:hidden}.twrap{overflow:auto}
39
+ table{width:100%;border-collapse:collapse}
40
+ thead th{padding:10px 12px;text-align:left;font-size:11px;font-weight:500;color:var(--text2);background:var(--surface2);border-bottom:1px solid var(--border);white-space:nowrap}
41
+ tbody tr{border-bottom:1px solid var(--border)} tbody tr:last-child{border-bottom:none}
42
+ tbody td{padding:10px 12px;font-size:12px;vertical-align:middle}
43
+ .mono{font-family:'DM Mono',monospace}
44
+ .pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--surface2);border-radius:999px;padding:3px 8px}
45
+ .dot{width:8px;height:8px;border-radius:50%}
46
+ .empty{padding:38px;text-align:center;color:var(--text3)}
47
+ @media(max-width:1100px){.sum-row{grid-template-columns:repeat(2,minmax(0,1fr))}}
48
+ @media(max-width:760px){.sum-row{grid-template-columns:1fr}}
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <nav class="sidebar">
53
+ <a href="/" class="sb-logo"><i class="fas fa-satellite-dish"></i></a>
54
+ <div class="sb-sep"></div>
55
+ <a href="/home" class="sb-btn"><i class="fas fa-play-circle"></i><span class="tip">Analyze</span></a>
56
+ <a href="/dashboard" class="sb-btn"><i class="fas fa-chart-line"></i><span class="tip">Dashboard</span></a>
57
+ <a href="/logs" class="sb-btn active"><i class="fas fa-list-ul"></i><span class="tip">Logs</span></a>
58
+ <a href="/history" class="sb-btn"><i class="fas fa-film"></i><span class="tip">History</span></a>
59
+ <div class="sb-spacer"></div>
60
+ <button class="sb-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon"></i><span class="tip">Theme</span></button>
61
+ </nav>
62
+
63
+ <div class="page-wrap">
64
+ <header class="topbar">
65
+ <div class="topbar-l">
66
+ <span class="brand-name">Track<b>IQ</b></span>
67
+ <span class="sep">|</span>
68
+ <span style="font-size:13px;font-weight:500;" id="t-title">Detection Logs</span>
69
+ </div>
70
+ <div class="topbar-r">
71
+ <div class="lang-group">
72
+ <button class="lang-btn active" onclick="setLang('en',this)">EN</button>
73
+ <button class="lang-btn" onclick="setLang('fr',this)">FR</button>
74
+ <button class="lang-btn" onclick="setLang('wo',this)">WO</button>
75
+ </div>
76
+ <button class="icon-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon2"></i></button>
77
+ </div>
78
+ </header>
79
+
80
+ <main>
81
+ <div class="sum-row">
82
+ <div class="sc"><div class="sc-l" id="t-s1">Detections</div><div class="sc-v" id="sumDetections">0</div><div class="sc-s" id="sumDetectionsSub">Real rows from generated videos</div></div>
83
+ <div class="sc"><div class="sc-l" id="t-s2">Scenes</div><div class="sc-v" id="sumScenes">0</div><div class="sc-s" id="sumScenesSub">Processed analyses</div></div>
84
+ <div class="sc"><div class="sc-l" id="t-s3">Classes</div><div class="sc-v" id="sumClasses">0</div><div class="sc-s" id="sumClassesSub">Distinct detected labels</div></div>
85
+ <div class="sc"><div class="sc-l" id="t-s4">Latest video</div><div class="sc-v" id="sumLatest">-</div><div class="sc-s" id="sumLatestSub">Most recent analyzed clip</div></div>
86
+ </div>
87
+
88
+ <div class="card">
89
+ <div class="filter-row">
90
+ <div class="fg"><label id="t-search">Search video or scene</label><input id="searchIn" class="fin" oninput="applyFilters()"></div>
91
+ <div class="fg"><label id="t-scene">Scene</label><select id="sceneF" class="fin" onchange="applyFilters()"><option value="">All</option></select></div>
92
+ <div class="fg"><label id="t-type">Class</label><select id="classF" class="fin" onchange="applyFilters()"><option value="">All</option></select></div>
93
+ <div class="fg"><label id="t-direction">Direction</label><select id="dirF" class="fin" onchange="applyFilters()"><option value="">All</option><option>up</option><option>down</option><option>left</option><option>right</option><option>unknown</option></select></div>
94
+ <div class="f-sp"></div>
95
+ <button class="btn btn-clr" onclick="clearFilters()" ondblclick="clearAllLogs()" title="Click=clear filters | Double-click=delete all logs"><i class="fas fa-rotate-left"></i> <span id="t-clear">Clear</span></button>
96
+ <div style="position:relative">
97
+ <button class="btn btn-exp" onclick="toggleSaveMenu(event)"><i class="fas fa-cloud-arrow-up"></i> <span id="t-export">Save Logs</span> <i class="fas fa-chevron-down" style="font-size:9px;margin-left:2px"></i></button>
98
+ <div id="saveMenu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-lg);min-width:240px;z-index:999;overflow:hidden">
99
+ <div style="padding:10px 14px 6px;font-size:11px;color:var(--text2);font-weight:500;border-bottom:1px solid var(--border)">
100
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
101
+ <label style="font-size:11px;color:var(--text2)">Group name:</label>
102
+ <input id="groupNameIn" value="trackiq" style="flex:1;padding:4px 7px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:var(--surface2);color:var(--text);outline:none" placeholder="e.g. session1">
103
+ </div>
104
+ <span style="color:var(--text3);font-size:10px">File: <span id="csvPreview">trackiq_all.csv</span></span>
105
+ </div>
106
+ <button class="save-opt" onclick="saveLocal()"><i class="fas fa-download" style="color:#3b82f6"></i> Download locally</button>
107
+ <button class="save-opt" onclick="showGithubPanel()"><i class="fab fa-github" style="color:#e2e8f0"></i> Push to GitHub</button>
108
+ <button class="save-opt" onclick="saveDrive()"><i class="fab fa-google-drive" style="color:#34a853"></i> Save to Google Drive</button>
109
+ </div>
110
+ </div>
111
+ <!-- GitHub panel -->
112
+ <div id="githubPanel" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:none;align-items:center;justify-content:center">
113
+ <div style="background:var(--surface);border-radius:14px;padding:22px 24px;width:420px;box-shadow:var(--shadow-lg)">
114
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
115
+ <span style="font-weight:600;font-size:14px"><i class="fab fa-github"></i> Push to GitHub</span>
116
+ <button onclick="closeGithubPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:16px"><i class="fas fa-times"></i></button>
117
+ </div>
118
+ <div style="display:flex;flex-direction:column;gap:9px">
119
+ <div class="fg"><label>Personal Access Token (PAT)</label><input id="ghToken" class="fin" type="password" placeholder="ghp_xxxxxxxxxxxx"></div>
120
+ <div class="fg"><label>Repository (owner/repo)</label><input id="ghRepo" class="fin" placeholder="username/trackiq-logs"></div>
121
+ <div class="fg"><label>Branch</label><input id="ghBranch" class="fin" value="main" placeholder="main"></div>
122
+ <div class="fg"><label>Path in repo</label><input id="ghPath" class="fin" placeholder="logs/" value="logs/"></div>
123
+ </div>
124
+ <div id="ghStatus" style="margin-top:10px;font-size:12px;color:var(--text2);min-height:18px"></div>
125
+ <div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end">
126
+ <button class="btn btn-clr" onclick="closeGithubPanel()">Cancel</button>
127
+ <button class="btn btn-exp" onclick="pushToGithub()"><i class="fab fa-github"></i> Push CSV</button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <div class="tcard">
135
+ <div class="twrap">
136
+ <table>
137
+ <thead>
138
+ <tr>
139
+ <th id="th-video">Video</th>
140
+ <th id="th-scene">Scene</th>
141
+ <th id="th-time">Time</th>
142
+ <th id="th-frame">Frame</th>
143
+ <th id="th-track">Track</th>
144
+ <th id="th-class">Class</th>
145
+ <th id="th-confidence">Confidence</th>
146
+ <th id="th-direction">Direction</th>
147
+ <th id="th-bbox">Bounding Box</th>
148
+ <th id="th-position">Center</th>
149
+ </tr>
150
+ </thead>
151
+ <tbody id="tbody">
152
+ <tr><td colspan="9" class="empty">Loading real logs...</td></tr>
153
+ </tbody>
154
+ </table>
155
+ </div>
156
+ </div>
157
+ </main>
158
+ </div>
159
+
160
+ <script>
161
+ let allRows = [];
162
+ let filteredRows = [];
163
+
164
+ function toggleTheme(){
165
+ const d=document.documentElement;
166
+ const dark=d.getAttribute('data-theme')==='dark';
167
+ d.setAttribute('data-theme',dark?'light':'dark');
168
+ ['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className=dark?'fas fa-moon':'fas fa-sun';});
169
+ try{localStorage.setItem('tiq_theme',dark?'light':'dark');}catch(e){}
170
+ }
171
+
172
+ const TL = {
173
+ en:{title:'Detection Logs',s1:'Detections',s2:'Scenes',s3:'Classes',s4:'Latest video',search:'Search video or scene',scene:'Scene',type:'Class',direction:'Direction',clear:'Clear',export:'Save Logs',video:'Video',time:'Time',frame:'Frame',track:'Track',class:'Class',confidence:'Confidence',bbox:'Bounding Box',position:'Center'},
174
+ fr:{title:'Logs de détection',s1:'Détections',s2:'Scènes',s3:'Classes',s4:'Dernière vidéo',search:'Recherche vidéo ou scène',scene:'Scène',type:'Classe',direction:'Direction',clear:'Effacer',export:'Sauvegarder',video:'Vidéo',time:'Temps',frame:'Frame',track:'Track',class:'Classe',confidence:'Confiance',bbox:'Boîte englobante',position:'Centre'},
175
+ wo:{title:'Logs yi',s1:'Réy',s2:'Scène yi',s3:'Classe yi',s4:'Video mujj',search:'Seet video walla scène',scene:'Scène',type:'Classe',direction:'Direction',clear:'Baň',export:'Sauvegarder',video:'Video',time:'Waxtu',frame:'Frame',track:'Track',class:'Classe',confidence:'Confiance',bbox:'Bounding Box',position:'Centre'}
176
+ };
177
+
178
+ function setLang(l, btn){
179
+ document.querySelectorAll('.lang-btn').forEach(b=>b.classList.remove('active'));
180
+ btn.classList.add('active');
181
+ const t = TL[l];
182
+ const ids = {title:'t-title',s1:'t-s1',s2:'t-s2',s3:'t-s3',s4:'t-s4',search:'t-search',scene:'t-scene',type:'t-type',direction:'t-direction',clear:'t-clear',export:'t-export',video:'th-video',time:'th-time',frame:'th-frame',track:'th-track',class:'th-class',confidence:'th-confidence',bbox:'th-bbox',position:'th-position'};
183
+ Object.entries(ids).forEach(([k,id])=>{const el=document.getElementById(id);if(el)el.textContent=t[k];});
184
+ document.getElementById('th-scene').textContent = t.scene;
185
+ document.getElementById('th-direction').textContent = t.direction;
186
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
187
+ }
188
+
189
+ async function loadLogs(){
190
+ try{
191
+ const [rowsRes, scenesRes] = await Promise.all([fetch('/api/logs/rows'), fetch('/api/logs')]);
192
+ allRows = await rowsRes.json();
193
+ const scenes = await scenesRes.json();
194
+ fillFilters(scenes);
195
+ fillSummary(scenes, allRows);
196
+ applyFilters();
197
+ }catch(err){
198
+ const tb=document.getElementById('tbody');
199
+ tb.innerHTML = '<tr><td colspan="9" class="empty">Unable to load real logs.</td></tr>';
200
+ console.warn('[TrackIQ] real logs:', err);
201
+ }
202
+ }
203
+
204
+ function fillFilters(scenes){
205
+ const sceneF = document.getElementById('sceneF');
206
+ const classF = document.getElementById('classF');
207
+ sceneF.innerHTML = '<option value="">All</option>' + scenes.map(s=>`<option value="${s.scene_id}">${s.video_name || s.scene_id}</option>`).join('');
208
+ const classes = [...new Set(allRows.map(r=>r.class_name).filter(Boolean))].sort();
209
+ classF.innerHTML = '<option value="">All</option>' + classes.map(c=>`<option value="${c}">${c}</option>`).join('');
210
+ }
211
+
212
+ function fillSummary(scenes, rows){
213
+ document.getElementById('sumDetections').textContent = rows.length.toLocaleString();
214
+ document.getElementById('sumScenes').textContent = scenes.length.toLocaleString();
215
+ document.getElementById('sumClasses').textContent = new Set(rows.map(r=>r.class_name).filter(Boolean)).size.toLocaleString();
216
+ document.getElementById('sumLatest').textContent = scenes[0]?.video_name || '-';
217
+ }
218
+
219
+ function applyFilters(){
220
+ const search = document.getElementById('searchIn').value.toLowerCase();
221
+ const scene = document.getElementById('sceneF').value;
222
+ const cls = document.getElementById('classF').value;
223
+ const dir = document.getElementById('dirF').value;
224
+ filteredRows = allRows.filter(r =>
225
+ (!search || (r.video_name || '').toLowerCase().includes(search) || (r.scene_id || '').toLowerCase().includes(search)) &&
226
+ (!scene || r.scene_id === scene) &&
227
+ (!cls || r.class_name === cls) &&
228
+ (!dir || r.direction === dir)
229
+ );
230
+ renderRows();
231
+ }
232
+
233
+ async function clearAllLogs(){
234
+ if(!confirm('Delete all logs? This cannot be undone.')) return;
235
+ try{
236
+ const scenes = await fetch('/api/logs').then(r=>r.json());
237
+ await Promise.all(scenes.map(s=>fetch(`/api/logs/${s.scene_id}`,{method:'DELETE'})));
238
+ location.reload();
239
+ }catch(e){ alert('Error: '+e.message); }
240
+ }
241
+
242
+ function clearFilters(){
243
+ ['searchIn','sceneF','classF','dirF'].forEach(id=>{document.getElementById(id).value='';});
244
+ applyFilters();
245
+ }
246
+
247
+ function renderRows(){
248
+ const tb = document.getElementById('tbody');
249
+ if(!filteredRows.length){
250
+ tb.innerHTML = '<tr><td colspan="10" class="empty">No real detections found for this filter.</td></tr>';
251
+ return;
252
+ }
253
+ const colors = {Vehicle:'#3b82f6',Motorcycle:'#10b981',Truck:'#f59e0b',Bus:'#8b5cf6',Human:'#ec4899',Bicycle:'#06b6d4'};
254
+ tb.innerHTML = '';
255
+ filteredRows.forEach(r=>{
256
+ const tr = document.createElement('tr');
257
+ tr.innerHTML = `
258
+ <td title="${r.video_name || ''}">${(r.video_name || '-').slice(0,28)}</td>
259
+ <td class="mono">${r.scene_id}</td>
260
+ <td class="mono">${Number(r.timestamp_s || 0).toFixed(2)}s</td>
261
+ <td class="mono">${r.frame_id}</td>
262
+ <td class="mono">#${r.track_id}</td>
263
+ <td><span class="pill"><span class="dot" style="background:${colors[r.class_name] || '#888'}"></span>${r.class_name || '-'}</span></td>
264
+ <td class="mono">${Math.round((r.confidence || 0) * 100)}%</td>
265
+ <td>${r.direction || 'unknown'}</td>
266
+ <td class="mono" style="font-size:11px">(${r.x1},${r.y1})→(${r.x2},${r.y2})</td>
267
+ <td class="mono">(${r.cx}, ${r.cy})</td>
268
+ `;
269
+ tb.appendChild(tr);
270
+ });
271
+ }
272
+
273
+ /* ── Helpers CSV ─────────────────────────────────────────────── */
274
+ function getGroupName(){ return (document.getElementById('groupNameIn')?.value||'trackiq').trim().replace(/\s+/g,'_')||'trackiq'; }
275
+ function getCsvFilename(){
276
+ const g=getGroupName();
277
+ const scenes=[...new Set(filteredRows.map(r=>r.scene_id))];
278
+ return `${g}_${scenes.length===1?scenes[0]:'all'}.csv`;
279
+ }
280
+ function buildCsvContent(){
281
+ const hdr = 'group,scene_id,video_name,timestamp_s,frame_id,track_id,class_name,confidence,x1,y1,x2,y2,cx,cy,direction\n';
282
+ const g = getGroupName();
283
+ const rows = filteredRows.map(r => [g,r.scene_id,`"${(r.video_name||'').replace(/"/g,'""')}"`,r.timestamp_s,r.frame_id,r.track_id,r.class_name,r.confidence,r.x1,r.y1,r.x2,r.y2,r.cx,r.cy,r.direction].join(',')).join('\n');
284
+ return hdr + rows;
285
+ }
286
+ function updateCsvPreview(){
287
+ const el = document.getElementById('csvPreview');
288
+ if(el) el.textContent = getCsvFilename();
289
+ }
290
+
291
+ /* ── Save menu ───────────────────────────────────���───────────── */
292
+ function toggleSaveMenu(e){
293
+ e.stopPropagation();
294
+ const m = document.getElementById('saveMenu');
295
+ m.style.display = m.style.display==='none'?'block':'none';
296
+ updateCsvPreview();
297
+ }
298
+ document.addEventListener('click', ()=>{ const m=document.getElementById('saveMenu'); if(m) m.style.display='none'; });
299
+
300
+ /* ── 1. Download local ───────────────────────────────────────── */
301
+ function saveLocal(){
302
+ const a = document.createElement('a');
303
+ a.href = URL.createObjectURL(new Blob([buildCsvContent()], {type:'text/csv'}));
304
+ a.download = getCsvFilename();
305
+ a.click();
306
+ document.getElementById('saveMenu').style.display='none';
307
+ }
308
+
309
+ /* ── 2. GitHub push ──────────────────────────────────────────── */
310
+ function showGithubPanel(){
311
+ document.getElementById('saveMenu').style.display='none';
312
+ const p = document.getElementById('githubPanel');
313
+ p.style.display='flex';
314
+ updateCsvPreview();
315
+ }
316
+ function closeGithubPanel(){ document.getElementById('githubPanel').style.display='none'; }
317
+
318
+ async function pushToGithub(){
319
+ const token = document.getElementById('ghToken').value.trim();
320
+ const repo = document.getElementById('ghRepo').value.trim();
321
+ const branch = document.getElementById('ghBranch').value.trim() || 'main';
322
+ const path = (document.getElementById('ghPath').value.trim().replace(/\/+$/,'')||'logs') + '/' + getCsvFilename();
323
+ const status = document.getElementById('ghStatus');
324
+
325
+ if(!token||!repo){ status.textContent='⚠ Token and repo are required.'; return; }
326
+ status.textContent='Pushing…';
327
+
328
+ const content = btoa(unescape(encodeURIComponent(buildCsvContent())));
329
+ // Check if file exists (to get SHA for update)
330
+ let sha = null;
331
+ try{
332
+ const check = await fetch(`https://api.github.com/repos/${repo}/contents/${path}`,{headers:{Authorization:`token ${token}`}});
333
+ if(check.ok){ const d=await check.json(); sha=d.sha; }
334
+ }catch(e){}
335
+
336
+ try{
337
+ const body = {message:`TrackIQ logs export — ${getCsvFilename()}`, content, branch};
338
+ if(sha) body.sha = sha;
339
+ const res = await fetch(`https://api.github.com/repos/${repo}/contents/${path}`,{
340
+ method:'PUT', headers:{Authorization:`token ${token}`,'Content-Type':'application/json'},
341
+ body: JSON.stringify(body)
342
+ });
343
+ if(res.ok){ status.innerHTML='<span style="color:#10b981">✓ Pushed successfully!</span>'; }
344
+ else{ const e=await res.json(); status.innerHTML=`<span style="color:#ef4444">Error: ${e.message}</span>`; }
345
+ }catch(err){ status.innerHTML=`<span style="color:#ef4444">Network error: ${err.message}</span>`; }
346
+ }
347
+
348
+ /* ── 3. Google Drive ─────────────────────────────────────────── */
349
+ async function saveDrive(){
350
+ document.getElementById('saveMenu').style.display='none';
351
+ // Uses Google Drive File Picker API via OAuth implicit flow
352
+ const CLIENT_ID = ''; // User must fill their own GCP OAuth client ID
353
+ if(!CLIENT_ID){
354
+ const ok = confirm('To save to Google Drive you need a Google OAuth Client ID.\n\nWould you like instructions on how to set it up?\n\n(For now the file will be downloaded locally as a fallback)');
355
+ if(ok){ window.open('https://developers.google.com/drive/api/guides/about-auth','_blank'); }
356
+ saveLocal();
357
+ return;
358
+ }
359
+ // Full Drive upload with OAuth token
360
+ const scope = 'https://www.googleapis.com/auth/drive.file';
361
+ const redirect = location.origin + location.pathname;
362
+ const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(redirect)}&response_type=token&scope=${encodeURIComponent(scope)}`;
363
+ const popup = window.open(authUrl,'_blank','width=500,height=600');
364
+ // Listen for token in URL hash after redirect
365
+ const interval = setInterval(()=>{
366
+ try{
367
+ if(popup.closed){ clearInterval(interval); return; }
368
+ const hash = popup.location.hash;
369
+ if(hash && hash.includes('access_token')){
370
+ clearInterval(interval); popup.close();
371
+ const token = new URLSearchParams(hash.replace('#','')).get('access_token');
372
+ uploadToDrive(token);
373
+ }
374
+ }catch(e){}
375
+ },500);
376
+ }
377
+
378
+ async function uploadToDrive(token){
379
+ const csv = buildCsvContent();
380
+ const fname = getCsvFilename();
381
+ const meta = JSON.stringify({name:fname,mimeType:'text/csv'});
382
+ const form = new FormData();
383
+ form.append('metadata', new Blob([meta],{type:'application/json'}));
384
+ form.append('file', new Blob([csv], {type:'text/csv'}));
385
+ try{
386
+ const res = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',{
387
+ method:'POST', headers:{Authorization:`Bearer ${token}`}, body:form
388
+ });
389
+ if(res.ok){ alert(`✓ "${fname}" saved to Google Drive!`); }
390
+ else{ const e=await res.json(); alert('Drive error: '+e.error?.message); }
391
+ }catch(err){ alert('Drive upload failed: '+err.message); }
392
+ }
393
+
394
+ function exportFiltered(){ saveLocal(); } // legacy alias
395
+
396
+ (function(){
397
+ try{
398
+ const th=localStorage.getItem('tiq_theme');
399
+ if(th){
400
+ document.documentElement.setAttribute('data-theme',th);
401
+ if(th==='dark'){['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className='fas fa-sun';});}
402
+ }
403
+ const lg=localStorage.getItem('tiq_lang');
404
+ if(lg&&lg!=='en'){
405
+ const btn=document.querySelector(`.lang-btn[onclick*="'${lg}'"]`);
406
+ if(btn)setLang(lg,btn);
407
+ }
408
+ }catch(e){}
409
+ loadLogs();
410
+ // Update CSV filename preview when group name changes
411
+ document.addEventListener('input', e=>{ if(e.target.id==='groupNameIn') updateCsvPreview(); });
412
+ })();
413
+ </script>
414
+ </body>
415
+ </html>
app.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — TrackIQ Backend (v3 — optimisé CPU)
3
+ """
4
+ import cv2, csv, json, threading, queue, uuid, mimetypes, subprocess
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ from collections import defaultdict
8
+ from typing import Dict
9
+
10
+ from flask import (Flask, request, jsonify, Response,
11
+ send_file, send_from_directory, abort, stream_with_context)
12
+ from flask_cors import CORS
13
+ from ultralytics import YOLO
14
+
15
+ app = Flask(__name__, static_folder="static", template_folder="templates")
16
+ CORS(app)
17
+
18
+ BASE = Path(__file__).parent
19
+ UPLOAD_DIR = BASE / "uploads"; UPLOAD_DIR.mkdir(exist_ok=True)
20
+ OUTPUT_DIR = BASE / "outputs"; OUTPUT_DIR.mkdir(exist_ok=True)
21
+ LOG_DIR = BASE / "logs"; LOG_DIR.mkdir(exist_ok=True)
22
+ MODELS_DIR = BASE / "models"; MODELS_DIR.mkdir(exist_ok=True)
23
+
24
+ COCO_MAP = {
25
+ "Vehicle":2,"Motorcycle":3,"Truck":7,"Bus":5,
26
+ "Human":0,"Bicycle":1,"Traffic light":9,"Road sign":11,
27
+ }
28
+ DEFAULT_CLASSES = ["Vehicle","Motorcycle","Truck","Bus","Human","Bicycle","Traffic light","Road sign"]
29
+ CLASS_COLORS = {
30
+ "Vehicle":(255,120,80),"Motorcycle":(80,200,80),"Truck":(0,180,255),
31
+ "Bus":(220,80,255),"Human":(236,72,153),"Bicycle":(6,182,212),
32
+ "Traffic light":(239,68,68),"Road sign":(249,115,22),
33
+ }
34
+
35
+ CONF = 0.55
36
+ IOU = 0.45
37
+ SKIP = 2
38
+ INFER_SZ = 480
39
+
40
+ _jobs: Dict[str,dict] = {}
41
+ _sse_queues: Dict[str,queue.Queue] = {}
42
+ _webcam_active = False
43
+ _webcam_queue: queue.Queue = queue.Queue(maxsize=4)
44
+ _webcam_stats: dict = {}
45
+ _webcam_model_key = "yolo11n"
46
+ _webcam_classes = DEFAULT_CLASSES
47
+ _webcam_frame_id = 0
48
+ _webcam_unique = defaultdict(set)
49
+ _webcam_hist_xy = defaultdict(list)
50
+
51
+
52
+ def _get_model(key="yolo11n"):
53
+ p = MODELS_DIR / f"{key}.pt"
54
+ if not p.exists():
55
+ print(f"[TrackIQ] Downloading {key}.pt …")
56
+ m = YOLO(f"{key}.pt")
57
+ import shutil
58
+ dl = Path(f"{key}.pt")
59
+ if dl.exists(): shutil.move(str(dl), str(p))
60
+ return m
61
+ return YOLO(str(p))
62
+
63
+
64
+ def _make_video_writer(jid, fps, size):
65
+ """Essaie avc1 (H.264 natif) d'abord, sinon mp4v avec conversion ffmpeg après."""
66
+ out_path = OUTPUT_DIR / f"{jid}_tracked.mp4"
67
+ # Tente H.264 directement (macOS/Linux avec codec installé)
68
+ for fourcc_str in ["avc1", "H264", "mp4v"]:
69
+ writer = cv2.VideoWriter(
70
+ str(out_path), cv2.VideoWriter_fourcc(*fourcc_str),
71
+ max(fps / SKIP, 1), size
72
+ )
73
+ if writer.isOpened():
74
+ print(f"[TrackIQ] VideoWriter ouvert avec codec: {fourcc_str}")
75
+ return writer, out_path
76
+ raise RuntimeError("Unable to initialize video writer with any codec")
77
+
78
+
79
+ def _convert_to_h264(src_path):
80
+ """Convertit mp4v → H.264 avec ffmpeg pour compatibilité navigateur."""
81
+ final = src_path.parent / (src_path.stem + "_h264.mp4")
82
+ # Essaie plusieurs encodeurs dans l'ordre
83
+ encoders = [
84
+ ["-c:v", "libx264", "-preset", "fast", "-crf", "23", "-movflags", "+faststart"],
85
+ ["-c:v", "h264", "-movflags", "+faststart"],
86
+ ["-c:v", "mpeg4", "-q:v", "5"],
87
+ ]
88
+ for enc_args in encoders:
89
+ try:
90
+ subprocess.run(
91
+ ["ffmpeg", "-y", "-i", str(src_path)] + enc_args + [str(final)],
92
+ check=True, capture_output=True, timeout=600
93
+ )
94
+ if final.exists() and final.stat().st_size > 1000:
95
+ src_path.unlink(missing_ok=True)
96
+ final.rename(src_path)
97
+ print(f"[TrackIQ] Converted to H.264: {src_path.name}")
98
+ return True
99
+ except FileNotFoundError:
100
+ print("[TrackIQ] ffmpeg not found — keeping original codec")
101
+ return False
102
+ except Exception as e:
103
+ print(f"[TrackIQ] ffmpeg encoder failed ({enc_args[1]}): {e}")
104
+ continue
105
+ print("[TrackIQ] All ffmpeg encoders failed — keeping mp4v")
106
+ return False
107
+
108
+
109
+ # ── Pages ─────────────────────────────────────────────────────────────────────
110
+ @app.route("/")
111
+ def page_index(): return send_from_directory("templates","index.html")
112
+ @app.route("/home")
113
+ def page_home(): return send_from_directory("templates","home.html")
114
+ @app.route("/dashboard")
115
+ def page_dashboard(): return send_from_directory("templates","dashboard.html")
116
+ @app.route("/logs")
117
+ def page_logs(): return send_from_directory("templates","logs.html")
118
+ @app.route("/history")
119
+ def page_history(): return send_from_directory("templates","history.html")
120
+
121
+
122
+ # ── Upload ────────────────────────────────────────────────────────────────────
123
+ @app.route("/api/upload", methods=["POST"])
124
+ def api_upload():
125
+ if "video" not in request.files:
126
+ return jsonify({"error":"No file"}),400
127
+ f = request.files["video"]
128
+ jid = uuid.uuid4().hex[:10]
129
+ dest = UPLOAD_DIR / f"{jid}_{f.filename}"
130
+ f.save(str(dest))
131
+ _jobs[jid] = {"status":"uploaded","path":str(dest),"name":f.filename,"stats":{}}
132
+ return jsonify({"job_id":jid,"filename":f.filename})
133
+
134
+
135
+ # ── Run ───────────────────────────────────────────────────────────────────────
136
+ @app.route("/api/run", methods=["POST"])
137
+ def api_run():
138
+ data = request.json or {}
139
+ jid = data.get("job_id")
140
+ sel_names = data.get("classes") or DEFAULT_CLASSES
141
+ model_key = data.get("model","yolo11n")
142
+ if not jid or jid not in _jobs:
143
+ return jsonify({"error":"Unknown job_id"}),404
144
+ _jobs[jid]["status"] = "running"
145
+ _sse_queues[jid] = queue.Queue(maxsize=300)
146
+ threading.Thread(target=_worker, args=(jid,model_key,sel_names), daemon=True).start()
147
+ return jsonify({"job_id":jid,"status":"started"})
148
+
149
+
150
+ # ── Worker ────────────────────────────────────────────────────────────────────
151
+ def _worker(jid, model_key, sel_names):
152
+ job = _jobs[jid]
153
+ q = _sse_queues[jid]
154
+ vpath = job["path"]
155
+ class_ids = [COCO_MAP[n] for n in sel_names if n in COCO_MAP]
156
+
157
+ cap = cv2.VideoCapture(vpath)
158
+ fps = cap.get(cv2.CAP_PROP_FPS) or 25
159
+ W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
160
+ H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
161
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
162
+
163
+ writer, out_path = _make_video_writer(jid, fps, (W, H))
164
+ log_csv = LOG_DIR / f"{jid}_detections.csv"
165
+ log_json = LOG_DIR / f"{jid}_stats.json"
166
+ _f = open(str(log_csv),"w",newline="")
167
+ schema = ["scene_id","frame_id","timestamp_s","track_id","class_name",
168
+ "confidence","x1","y1","x2","y2","cx","cy","direction"]
169
+ _w = csv.DictWriter(_f, fieldnames=schema); _w.writeheader()
170
+
171
+ unique = defaultdict(set)
172
+ hist_xy = defaultdict(list)
173
+ timeline= []
174
+ model = _get_model(model_key)
175
+ frame_id= 0
176
+ last_frame_counts = {}
177
+ peak_frame_counts = defaultdict(int)
178
+
179
+ while True:
180
+ ret, frame = cap.read()
181
+ if not ret: break
182
+ ts = frame_id / fps
183
+
184
+ if frame_id % SKIP != 0:
185
+ frame_id += 1
186
+ continue
187
+
188
+ results = model.track(
189
+ frame, classes=class_ids, conf=CONF, iou=IOU,
190
+ imgsz=INFER_SZ, tracker="bytetrack.yaml",
191
+ persist=True, verbose=False,
192
+ )
193
+
194
+ detections = []; frame_cnts = defaultdict(int)
195
+ if results[0].boxes is not None and results[0].boxes.id is not None:
196
+ for box,tid,cls_id,conf in zip(
197
+ results[0].boxes.xyxy.cpu().numpy(),
198
+ results[0].boxes.id.cpu().numpy().astype(int),
199
+ results[0].boxes.cls.cpu().numpy().astype(int),
200
+ results[0].boxes.conf.cpu().numpy(),
201
+ ):
202
+ cname = next((n for n,c in COCO_MAP.items() if c==int(cls_id)),"unknown")
203
+ if cname not in sel_names: continue
204
+ x1,y1,x2,y2 = map(int,box)
205
+ cx,cy = (x1+x2)//2,(y1+y2)//2
206
+ h = hist_xy[int(tid)]; h.append((cx,cy))
207
+ if len(h)>=4:
208
+ dx=h[-1][0]-h[-4][0]; dy=h[-1][1]-h[-4][1]
209
+ direction=("right" if dx>0 else "left") if abs(dx)>abs(dy) else ("down" if dy>0 else "up")
210
+ else: direction="unknown"
211
+ unique[cname].add(int(tid)); frame_cnts[cname]+=1
212
+ det=dict(scene_id=jid,frame_id=frame_id,timestamp_s=round(ts,3),
213
+ track_id=int(tid),class_name=cname,confidence=round(float(conf),3),
214
+ x1=x1,y1=y1,x2=x2,y2=y2,cx=cx,cy=cy,direction=direction)
215
+ _w.writerow(det); detections.append(det)
216
+ job.setdefault("_all_dets",[]).append(det)
217
+
218
+ last_frame_counts = dict(frame_cnts)
219
+ for cname, count in frame_cnts.items():
220
+ peak_frame_counts[cname] = max(peak_frame_counts[cname], count)
221
+
222
+ timeline.append({"frame":frame_id,"ts":round(ts,2),**dict(frame_cnts)})
223
+ writer.write(_draw(frame.copy(), detections, frame_cnts))
224
+
225
+ uc = {k:len(v) for k,v in unique.items()}
226
+ payload = {
227
+ "frame":frame_id,"total":total,"ts":round(ts,2),
228
+ "pct":round(frame_id/max(total,1)*100,1),
229
+ "detections":len(detections),"unique_counts":uc,
230
+ "total_unique":sum(uc.values()),"no_objects":len(detections)==0,
231
+ "frame_counts":dict(frame_cnts),
232
+ "hud":{"frame_str":f"F:{frame_id} ▶{int(fps)}fps","counts":dict(frame_cnts)},
233
+ }
234
+ if not q.full(): q.put(payload)
235
+ frame_id+=1
236
+
237
+ cap.release(); writer.release(); _f.close()
238
+
239
+ # ✅ Conversion H.264 pour le navigateur (mp4v seul ne fonctionne pas dans Chrome)
240
+ _convert_to_h264(out_path)
241
+
242
+ all_confs = [d["confidence"] for d in job.get("_all_dets", [])] if job.get("_all_dets") else []
243
+ avg_conf = round(sum(all_confs)/len(all_confs),3) if all_confs else None
244
+ stats={
245
+ "scene_id":jid,"video_name":job.get("name",""),
246
+ "generated_at":datetime.now().isoformat(),
247
+ "fps":fps,"total_frames":frame_id,"duration_s":round(frame_id/fps,1),
248
+ "unique_counts":{k:len(v) for k,v in unique.items()},
249
+ "total_unique":sum(len(v) for v in unique.values()),
250
+ "last_frame_counts":last_frame_counts,
251
+ "peak_counts":dict(peak_frame_counts),
252
+ "avg_conf":avg_conf,
253
+ "timeline":timeline,
254
+ }
255
+ with open(str(log_json),"w") as f: json.dump(stats,f,indent=2)
256
+ job["status"]="done"; job["stats"]=stats; job["out_mp4"]=str(out_path)
257
+ q.put({"event":"done","stats":stats})
258
+ print(f"[TrackIQ] {jid} done — {stats['total_unique']} objets")
259
+
260
+
261
+ def _draw(frame, detections, frame_counts):
262
+ for det in detections:
263
+ cls=det["class_name"]; color=CLASS_COLORS.get(cls,(200,200,200))
264
+ x1,y1,x2,y2=det["x1"],det["y1"],det["x2"],det["y2"]
265
+ cv2.rectangle(frame,(x1,y1),(x2,y2),color,2)
266
+ label=f"id={det['track_id']} {cls} {int(det['confidence']*100)}%"
267
+ (lw,lh),_=cv2.getTextSize(label,cv2.FONT_HERSHEY_SIMPLEX,0.48,1)
268
+ cv2.rectangle(frame,(x1,y1-lh-6),(x1+lw+4,y1),color,-1)
269
+ cv2.putText(frame,label,(x1+2,y1-3),cv2.FONT_HERSHEY_SIMPLEX,0.48,(255,255,255),1,cv2.LINE_AA)
270
+ y_pos=26
271
+ for cls,count in sorted(frame_counts.items()):
272
+ txt=f"{cls}: {count}"
273
+ cv2.putText(frame,txt,(10,y_pos),cv2.FONT_HERSHEY_SIMPLEX,0.58,(0,0,0),3,cv2.LINE_AA)
274
+ cv2.putText(frame,txt,(10,y_pos),cv2.FONT_HERSHEY_SIMPLEX,0.58,(255,255,255),2,cv2.LINE_AA)
275
+ y_pos+=26
276
+ if not detections:
277
+ h=frame.shape[0]
278
+ cv2.putText(frame,"No target objects detected",(10,h-16),
279
+ cv2.FONT_HERSHEY_SIMPLEX,0.6,(80,80,255),2,cv2.LINE_AA)
280
+ return frame
281
+
282
+
283
+ # ── SSE ───────────────────────────────────────────────────────────────────────
284
+ @app.route("/api/stream/<jid>")
285
+ def api_stream(jid):
286
+ @stream_with_context
287
+ def generate():
288
+ q=_sse_queues.get(jid)
289
+ if not q: yield 'data:{"error":"no job"}\n\n'; return
290
+ while True:
291
+ try:
292
+ p=q.get(timeout=30)
293
+ yield f"data:{json.dumps(p)}\n\n"
294
+ if p.get("event")=="done": break
295
+ except: break
296
+ return Response(generate(), mimetype="text/event-stream",
297
+ headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})
298
+
299
+
300
+ # ── Video (range requests) ───────────────────────────────────────────────────
301
+ @app.route("/api/video/<jid>")
302
+ def api_video(jid):
303
+ job=_jobs.get(jid)
304
+ if not job: abort(404)
305
+ out=job.get("out_mp4") or str(OUTPUT_DIR/f"{jid}_tracked.mp4")
306
+ p=Path(out)
307
+ if not p.exists(): abort(404)
308
+ mime_type = mimetypes.guess_type(str(p))[0] or "video/mp4"
309
+ file_size=p.stat().st_size
310
+ range_hdr=request.headers.get("Range")
311
+ if range_hdr:
312
+ import re
313
+ byte1,byte2=0,file_size-1
314
+ m=re.search(r"bytes=(\d+)-(\d*)",range_hdr)
315
+ if m:
316
+ byte1=int(m.group(1))
317
+ if m.group(2): byte2=int(m.group(2))
318
+ length=byte2-byte1+1
319
+ def chunk():
320
+ with open(str(p),"rb") as f:
321
+ f.seek(byte1); remaining=length
322
+ while remaining>0:
323
+ c=f.read(min(65536,remaining))
324
+ if not c: break
325
+ remaining-=len(c); yield c
326
+ resp=Response(chunk(),status=206,mimetype=mime_type,direct_passthrough=True)
327
+ resp.headers["Content-Range"]=f"bytes {byte1}-{byte2}/{file_size}"
328
+ resp.headers["Accept-Ranges"]="bytes"
329
+ resp.headers["Content-Length"]=str(length)
330
+ resp.headers["Access-Control-Allow-Origin"]="*"
331
+ return resp
332
+ resp = send_file(str(p),mimetype=mime_type,conditional=True)
333
+ resp.headers["Accept-Ranges"] = "bytes"
334
+ resp.headers["Access-Control-Allow-Origin"] = "*"
335
+ return resp
336
+
337
+
338
+ # ── Status ────────────────────────────────────────────────────────────────────
339
+ @app.route("/api/status/<jid>")
340
+ def api_status(jid):
341
+ job=_jobs.get(jid)
342
+ if not job: return jsonify({"error":"not found"}),404
343
+ return jsonify({"status":job["status"],"stats":job.get("stats",{}),
344
+ "video_url":f"/api/video/{jid}" if job.get("out_mp4") else None})
345
+
346
+
347
+ # ── Logs ───────────────────────────��──────────────────────────────────────────
348
+ @app.route("/api/logs")
349
+ def api_logs_list():
350
+ result=[]
351
+ for jf in sorted(LOG_DIR.glob("*_stats.json"),reverse=True):
352
+ try:
353
+ with open(str(jf)) as f: data=json.load(f)
354
+ result.append({"scene_id":data.get("scene_id",jf.stem),
355
+ "video_name":data.get("video_name",""),
356
+ "generated_at":data.get("generated_at",""),
357
+ "fps":data.get("fps",0),"total_frames":data.get("total_frames",0),
358
+ "duration_s":data.get("duration_s",0),
359
+ "unique_counts":data.get("unique_counts",{}),"total_unique":data.get("total_unique",0)})
360
+ except: pass
361
+ return jsonify(result)
362
+
363
+ @app.route("/api/logs/rows")
364
+ def api_logs_rows():
365
+ rows = []
366
+ for csv_path in sorted(LOG_DIR.glob("*_detections.csv"), reverse=True):
367
+ try:
368
+ scene_id = csv_path.stem.replace("_detections", "")
369
+ stats_path = LOG_DIR / f"{scene_id}_stats.json"
370
+ stats = {}
371
+ if stats_path.exists():
372
+ with open(str(stats_path), encoding="utf-8") as sf:
373
+ stats = json.load(sf)
374
+ with open(str(csv_path), newline="", encoding="utf-8") as cf:
375
+ reader = csv.DictReader(cf)
376
+ for row in reader:
377
+ rows.append({
378
+ "scene_id": row.get("scene_id", scene_id),
379
+ "video_name": stats.get("video_name", ""),
380
+ "generated_at": stats.get("generated_at", ""),
381
+ "frame_id": int(float(row.get("frame_id") or 0)),
382
+ "timestamp_s": float(row.get("timestamp_s") or 0),
383
+ "track_id": int(float(row.get("track_id") or 0)),
384
+ "class_name": row.get("class_name", ""),
385
+ "confidence": float(row.get("confidence") or 0),
386
+ "x1": int(float(row.get("x1") or 0)),
387
+ "y1": int(float(row.get("y1") or 0)),
388
+ "x2": int(float(row.get("x2") or 0)),
389
+ "y2": int(float(row.get("y2") or 0)),
390
+ "cx": int(float(row.get("cx") or 0)),
391
+ "cy": int(float(row.get("cy") or 0)),
392
+ "direction": row.get("direction", "unknown"),
393
+ })
394
+ except Exception:
395
+ pass
396
+ return jsonify(rows)
397
+
398
+ @app.route("/api/logs/<scene_id>/csv")
399
+ def api_logs_csv(scene_id):
400
+ p=LOG_DIR/f"{scene_id}_detections.csv"
401
+ if not p.exists(): abort(404)
402
+ return send_file(str(p),as_attachment=True,download_name=f"{scene_id}_detections.csv",mimetype="text/csv")
403
+
404
+ @app.route("/api/logs/export", methods=["POST"])
405
+ def api_logs_export():
406
+ """Génère un CSV groupé: group_scene_id.csv avec toutes les bounding boxes"""
407
+ import io
408
+ from flask import make_response
409
+ data = request.json or {}
410
+ scene_ids = data.get("scene_ids", [])
411
+ group = (data.get("group", "trackiq") or "trackiq").strip().replace(" ","_")
412
+
413
+ if not scene_ids:
414
+ scene_ids = [p.stem.replace("_detections","") for p in sorted(LOG_DIR.glob("*_detections.csv"))]
415
+
416
+ buf = io.StringIO()
417
+ fieldnames = ["group","scene_id","video_name","generated_at","frame_id","timestamp_s",
418
+ "track_id","class_name","confidence","x1","y1","x2","y2","cx","cy","direction"]
419
+ writer = csv.DictWriter(buf, fieldnames=fieldnames)
420
+ writer.writeheader()
421
+
422
+ for sid in scene_ids:
423
+ csv_path = LOG_DIR / f"{sid}_detections.csv"
424
+ stats_path = LOG_DIR / f"{sid}_stats.json"
425
+ if not csv_path.exists(): continue
426
+ stats = {}
427
+ if stats_path.exists():
428
+ with open(str(stats_path), encoding="utf-8") as sf:
429
+ stats = json.load(sf)
430
+ with open(str(csv_path), newline="", encoding="utf-8") as cf:
431
+ reader = csv.DictReader(cf)
432
+ for row in reader:
433
+ writer.writerow({
434
+ "group": group, "scene_id": row.get("scene_id", sid),
435
+ "video_name": stats.get("video_name",""), "generated_at": stats.get("generated_at",""),
436
+ "frame_id": row.get("frame_id",""), "timestamp_s": row.get("timestamp_s",""),
437
+ "track_id": row.get("track_id",""), "class_name": row.get("class_name",""),
438
+ "confidence": row.get("confidence",""),
439
+ "x1": row.get("x1",""), "y1": row.get("y1",""),
440
+ "x2": row.get("x2",""), "y2": row.get("y2",""),
441
+ "cx": row.get("cx",""), "cy": row.get("cy",""),
442
+ "direction": row.get("direction",""),
443
+ })
444
+
445
+ csv_bytes = buf.getvalue().encode("utf-8")
446
+ suffix = scene_ids[0] if len(scene_ids)==1 else "all"
447
+ filename = f"{group}_{suffix}.csv"
448
+ resp = make_response(csv_bytes)
449
+ resp.headers["Content-Type"] = "text/csv; charset=utf-8"
450
+ resp.headers["Content-Disposition"] = f"attachment; filename={filename}"
451
+ return resp
452
+
453
+ @app.route("/api/logs/<scene_id>", methods=["DELETE"])
454
+ def api_logs_delete(scene_id):
455
+ for ext in ("_stats.json","_detections.csv"):
456
+ p=LOG_DIR/f"{scene_id}{ext}"
457
+ if p.exists(): p.unlink()
458
+ for out in OUTPUT_DIR.glob(f"{scene_id}_tracked.*"):
459
+ if out.exists(): out.unlink()
460
+ return jsonify({"deleted":scene_id})
461
+
462
+
463
+ # ── Dashboard ─────────────────────────────────────────────────────────────────
464
+ @app.route("/api/dashboard/stats")
465
+ def api_dashboard_stats():
466
+ global_uc=defaultdict(int); total_veh=0; scenes=[]
467
+ for jf in sorted(LOG_DIR.glob("*_stats.json"),reverse=True)[:20]:
468
+ try:
469
+ with open(str(jf)) as f: data=json.load(f)
470
+ uc=data.get("unique_counts",{}); sv=sum(uc.values()); total_veh+=sv
471
+ for cls,cnt in uc.items(): global_uc[cls]+=cnt
472
+ scenes.append({"scene_id":data.get("scene_id",""),"video_name":data.get("video_name",""),
473
+ "total":sv,"counts":uc,"duration_s":data.get("duration_s",0),
474
+ "avg_conf":data.get("avg_conf"),
475
+ "timeline":data.get("timeline",[])})
476
+ except: pass
477
+ return jsonify({"total_vehicles_today":total_veh,
478
+ "global_unique_counts":dict(global_uc),"scenes":scenes[:10]})
479
+
480
+
481
+ # ── History ───────────────────────────────────────────────────────────────────
482
+ @app.route("/api/history")
483
+ def api_history():
484
+ return jsonify([{"job_id":jid,"name":job.get("name","video"),
485
+ "url":f"/api/video/{jid}","date":job.get("stats",{}).get("generated_at",""),
486
+ "counts":job.get("stats",{}).get("unique_counts",{})}
487
+ for jid,job in reversed(list(_jobs.items())) if job.get("status")=="done"])
488
+
489
+
490
+ # ── Webcam ────────────────────────────────────────────────────────────────────
491
+ @app.route("/api/webcam/start", methods=["POST"])
492
+ def api_webcam_start():
493
+ global _webcam_active, _webcam_model_key, _webcam_classes
494
+ d=request.json or {}
495
+ _webcam_active=True
496
+ _webcam_model_key = d.get("model","yolo11n")
497
+ _webcam_classes = d.get("classes") or DEFAULT_CLASSES
498
+ # Load model eagerly so first frame is fast
499
+ threading.Thread(target=lambda: _get_model(_webcam_model_key), daemon=True).start()
500
+ return jsonify({"status":"started"})
501
+
502
+ @app.route("/api/webcam/frame", methods=["POST"])
503
+ def api_webcam_frame():
504
+ """Receive a JPEG frame from the browser, run YOLO, return annotated JPEG."""
505
+ global _webcam_stats, _webcam_frame_id, _webcam_unique, _webcam_hist_xy
506
+ if not _webcam_active:
507
+ return jsonify({"error":"not active"}), 400
508
+ data = request.get_data()
509
+ if not data:
510
+ return jsonify({"error":"no frame"}), 400
511
+ import numpy as np
512
+ arr = np.frombuffer(data, dtype=np.uint8)
513
+ frame = cv2.imdecode(arr, cv2.IMREAD_COLOR)
514
+ if frame is None:
515
+ return jsonify({"error":"decode failed"}), 400
516
+
517
+ class_ids = [COCO_MAP[n] for n in _webcam_classes if n in COCO_MAP]
518
+ model = _get_model(_webcam_model_key)
519
+ fid = _webcam_frame_id
520
+ _webcam_frame_id += 1
521
+
522
+ res = model.track(frame, classes=class_ids, conf=CONF, iou=IOU,
523
+ imgsz=INFER_SZ, tracker="bytetrack.yaml",
524
+ persist=True, verbose=False)
525
+
526
+ dets = []; frame_counts = defaultdict(int)
527
+ if res[0].boxes is not None and res[0].boxes.id is not None:
528
+ for box, tid, cls_id, conf in zip(
529
+ res[0].boxes.xyxy.cpu().numpy(),
530
+ res[0].boxes.id.cpu().numpy().astype(int),
531
+ res[0].boxes.cls.cpu().numpy().astype(int),
532
+ res[0].boxes.conf.cpu().numpy()
533
+ ):
534
+ cname = next((n for n,c in COCO_MAP.items() if c==int(cls_id)), "unknown")
535
+ if cname not in _webcam_classes: continue
536
+ x1,y1,x2,y2 = map(int, box)
537
+ cx,cy = (x1+x2)//2,(y1+y2)//2
538
+ h = _webcam_hist_xy[int(tid)]; h.append((cx,cy))
539
+ if len(h)>=4:
540
+ dx=h[-1][0]-h[-4][0]; dy=h[-1][1]-h[-4][1]
541
+ direction=("right" if dx>0 else "left") if abs(dx)>abs(dy) else ("down" if dy>0 else "up")
542
+ else: direction="unknown"
543
+ _webcam_unique[cname].add(int(tid)); frame_counts[cname]+=1
544
+ det = dict(frame_id=fid,track_id=int(tid),class_name=cname,
545
+ confidence=round(float(conf),3),x1=x1,y1=y1,x2=x2,y2=y2,
546
+ cx=cx,cy=cy,direction=direction)
547
+ dets.append(det)
548
+
549
+ ann = _draw(frame.copy(), dets, frame_counts)
550
+ _, buf = cv2.imencode(".jpg", ann, [cv2.IMWRITE_JPEG_QUALITY, 72])
551
+ if not _webcam_queue.full():
552
+ _webcam_queue.put(buf.tobytes())
553
+
554
+ _webcam_stats = {
555
+ "unique_counts": {k:len(v) for k,v in _webcam_unique.items()},
556
+ "frame_counts": dict(frame_counts),
557
+ "frame": fid, "detections": len(dets)
558
+ }
559
+ return Response(buf.tobytes(), mimetype="image/jpeg")
560
+
561
+ @app.route("/api/webcam/stop", methods=["POST"])
562
+ def api_webcam_stop():
563
+ global _webcam_active, _webcam_frame_id, _webcam_unique, _webcam_hist_xy
564
+ _webcam_active = False
565
+ _webcam_frame_id = 0
566
+ _webcam_unique = defaultdict(set)
567
+ _webcam_hist_xy = defaultdict(list)
568
+ return jsonify({"status":"stopped"})
569
+
570
+ @app.route("/api/webcam/stats")
571
+ def api_webcam_stats():
572
+ return jsonify(_webcam_stats)
573
+
574
+ @app.route("/api/webcam/stream")
575
+ def api_webcam_stream():
576
+ def gen():
577
+ while _webcam_active:
578
+ try:
579
+ fb=_webcam_queue.get(timeout=2)
580
+ yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"+fb+b"\r\n"
581
+ except: pass
582
+ return Response(gen(),mimetype="multipart/x-mixed-replace; boundary=frame")
583
+
584
+ # ── Static ────────────────────────────────────────────────────────────────────
585
+ @app.route("/outputs/<path:fname>")
586
+ def serve_output(fname): return send_from_directory(str(OUTPUT_DIR),fname)
587
+ @app.route("/uploads/<path:fname>")
588
+ def serve_upload(fname): return send_from_directory(str(UPLOAD_DIR),fname)
589
+
590
+
591
+ if __name__=="__main__":
592
+ print("="*50)
593
+ print(" TrackIQ — http://localhost:5000")
594
+ print(f" Skip={SKIP} | imgsz={INFER_SZ} | CPU optimisé")
595
+ print("="*50)
596
+ app.run(host="0.0.0.0",port=5000,debug=False,threaded=True)
models/.gitkeep ADDED
File without changes
models/yolo11n.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:49da7a898d364366a0925c144b6701980a3bf0525c5b904020a0352228cb2e45
3
+ size 22523050
requirements.txt ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ absl-py==2.4.0
2
+ alabaster==1.0.0
3
+ altair==5.5.0
4
+ annotated-doc==0.0.4
5
+ annotated-types==0.7.0
6
+ anyio==4.13.0
7
+ asttokens==3.0.0
8
+ astunparse==1.6.3
9
+ attrs==25.4.0
10
+ babel==2.17.0
11
+ beautifulsoup4==4.14.2
12
+ bleach==6.3.0
13
+ blinker==1.9.0
14
+ cachetools==6.2.2
15
+ certifi==2025.11.12
16
+ cffi==2.0.0
17
+ charset-normalizer==3.4.4
18
+ clarabel==0.11.1
19
+ click==8.3.1
20
+ colorama==0.4.6
21
+ comm==0.2.3
22
+ contourpy==1.3.3
23
+ cvxpy==1.7.5
24
+ cycler==0.12.1
25
+ debugpy==1.8.17
26
+ decorator==5.2.1
27
+ defusedxml==0.7.1
28
+ dill==0.4.1
29
+ docutils==0.22.4
30
+ et_xmlfile==2.0.0
31
+ executing==2.2.1
32
+ fastjsonschema==2.21.2
33
+ fica==0.4.1
34
+ filelock==3.24.3
35
+ Flask==3.1.3
36
+ flask-cors==6.0.2
37
+ flatbuffers==25.12.19
38
+ fonttools==4.60.1
39
+ fsspec==2026.2.0
40
+ gast==0.7.0
41
+ geopandas==1.1.2
42
+ git-filter-repo==2.47.0
43
+ gitdb==4.0.12
44
+ GitPython==3.1.45
45
+ google-pasta==0.2.0
46
+ graphviz==0.21
47
+ greenlet==3.3.1
48
+ grpcio==1.80.0
49
+ h11==0.16.0
50
+ h5py==3.14.0
51
+ hf-xet==1.4.3
52
+ httpcore==1.0.9
53
+ httpx==0.28.1
54
+ huggingface_hub==1.10.2
55
+ idna==3.11
56
+ imagesize==1.4.1
57
+ ipykernel==7.1.0
58
+ ipylab==1.1.0
59
+ ipython==9.6.0
60
+ ipython_pygments_lexers==1.1.1
61
+ ipywidgets==8.1.8
62
+ itsdangerous==2.2.0
63
+ jedi==0.19.2
64
+ Jinja2==3.1.6
65
+ joblib==1.5.3
66
+ jsonschema==4.25.1
67
+ jsonschema-specifications==2025.9.1
68
+ jupyter_client==8.6.3
69
+ jupyter_core==5.9.1
70
+ jupyterlab_pygments==0.3.0
71
+ jupyterlab_widgets==3.0.16
72
+ jupytext==1.19.1
73
+ keras==3.14.0
74
+ kiwisolver==1.4.9
75
+ lap==0.5.13
76
+ lapx==0.9.4
77
+ libclang==18.1.1
78
+ lxml==6.0.2
79
+ markdown-it-py==4.0.0
80
+ MarkupSafe==3.0.3
81
+ matplotlib==3.10.7
82
+ matplotlib-inline==0.2.1
83
+ mdit-py-plugins==0.5.0
84
+ mdurl==0.1.2
85
+ mistune==3.2.0
86
+ ml_dtypes==0.5.4
87
+ mpmath==1.3.0
88
+ namex==0.1.0
89
+ narwhals==2.11.0
90
+ nbclient==0.10.4
91
+ nbconvert==7.17.0
92
+ nbformat==5.10.4
93
+ nest-asyncio==1.6.0
94
+ networkx==3.6.1
95
+ numpy==2.3.4
96
+ opencv-python==4.13.0.92
97
+ opencv-python-headless==4.13.0.92
98
+ openpyxl==3.1.5
99
+ opt_einsum==3.4.0
100
+ optree==0.19.0
101
+ osqp==1.0.5
102
+ otter-grader==6.1.6
103
+ outcome==1.3.0.post0
104
+ packaging==25.0
105
+ pandas==2.3.3
106
+ pandocfilters==1.5.1
107
+ parso==0.8.5
108
+ pillow==12.0.0
109
+ platformdirs==4.5.0
110
+ playwright==1.58.0
111
+ plotly==6.4.0
112
+ polars==1.40.0
113
+ polars-runtime-32==1.40.0
114
+ prompt_toolkit==3.0.52
115
+ protobuf==6.33.1
116
+ psutil==7.1.2
117
+ pure_eval==0.2.3
118
+ pyarrow==21.0.0
119
+ pycparser==2.23
120
+ pydantic==2.12.5
121
+ pydantic_core==2.41.5
122
+ pydeck==0.9.1
123
+ pyee==13.0.0
124
+ Pygments==2.19.2
125
+ pyogrio==0.12.1
126
+ pyparsing==3.2.5
127
+ pyproj==3.7.2
128
+ PySocks==1.7.1
129
+ python-dateutil==2.9.0.post0
130
+ python-dotenv==1.2.1
131
+ python-on-whales==0.80.0
132
+ pytz==2025.2
133
+ PyYAML==6.0.3
134
+ pyzmq==27.1.0
135
+ referencing==0.37.0
136
+ requests==2.32.5
137
+ rich==14.3.3
138
+ roman-numerals==4.1.0
139
+ rpds-py==0.28.0
140
+ scikit-learn==1.8.0
141
+ scipy==1.16.3
142
+ scs==3.2.11
143
+ seaborn==0.13.2
144
+ selenium==4.38.0
145
+ setuptools==80.9.0
146
+ shapely==2.1.2
147
+ shellingham==1.5.4
148
+ six==1.17.0
149
+ smmap==5.0.2
150
+ sniffio==1.3.1
151
+ snowballstemmer==3.0.1
152
+ sortedcontainers==2.4.0
153
+ soupsieve==2.8
154
+ Sphinx==9.1.0
155
+ sphinxcontrib-applehelp==2.0.0
156
+ sphinxcontrib-devhelp==2.0.0
157
+ sphinxcontrib-htmlhelp==2.1.0
158
+ sphinxcontrib-jsmath==1.0.1
159
+ sphinxcontrib-qthelp==2.0.0
160
+ sphinxcontrib-serializinghtml==2.0.0
161
+ stack-data==0.6.3
162
+ streamlit==1.51.0
163
+ sympy==1.14.0
164
+ tenacity==9.1.2
165
+ tensorflow==2.21.0
166
+ termcolor==3.3.0
167
+ threadpoolctl==3.6.0
168
+ tinycss2==1.4.0
169
+ toml==0.10.2
170
+ torch==2.11.0
171
+ torchvision==0.26.0
172
+ torchviz==0.0.3
173
+ tornado==6.5.2
174
+ tqdm==4.67.3
175
+ traitlets==5.14.3
176
+ trio==0.32.0
177
+ trio-websocket==0.12.2
178
+ typer==0.24.1
179
+ typing-inspection==0.4.2
180
+ typing_extensions==4.15.0
181
+ tzdata==2025.2
182
+ ultralytics==8.4.39
183
+ ultralytics-thop==2.0.18
184
+ urllib3==2.5.0
185
+ watchdog==6.0.0
186
+ wcwidth==0.2.14
187
+ webdriver-manager==4.0.2
188
+ webencodings==0.5.1
189
+ websocket-client==1.9.0
190
+ Werkzeug==3.1.8
191
+ wheel==0.46.3
192
+ widgetsnbextension==4.0.15
193
+ wrapt==1.17.3
194
+ wsproto==1.3.2
requirements_hf.txt ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ absl-py==2.4.0
2
+ alabaster==1.0.0
3
+ altair==5.5.0
4
+ annotated-doc==0.0.4
5
+ annotated-types==0.7.0
6
+ anyio==4.13.0
7
+ asttokens==3.0.0
8
+ astunparse==1.6.3
9
+ attrs==25.4.0
10
+ babel==2.17.0
11
+ beautifulsoup4==4.14.2
12
+ bleach==6.3.0
13
+ blinker==1.9.0
14
+ cachetools==6.2.2
15
+ certifi==2025.11.12
16
+ cffi==2.0.0
17
+ charset-normalizer==3.4.4
18
+ clarabel==0.11.1
19
+ click==8.3.1
20
+ colorama==0.4.6
21
+ comm==0.2.3
22
+ contourpy==1.3.3
23
+ cvxpy==1.7.5
24
+ cycler==0.12.1
25
+ debugpy==1.8.17
26
+ decorator==5.2.1
27
+ defusedxml==0.7.1
28
+ dill==0.4.1
29
+ docutils==0.22.4
30
+ et_xmlfile==2.0.0
31
+ executing==2.2.1
32
+ fastjsonschema==2.21.2
33
+ fica==0.4.1
34
+ filelock==3.24.3
35
+ Flask==3.1.3
36
+ flask-cors==6.0.2
37
+ flatbuffers==25.12.19
38
+ fonttools==4.60.1
39
+ fsspec==2026.2.0
40
+ gast==0.7.0
41
+ geopandas==1.1.2
42
+ git-filter-repo==2.47.0
43
+ gitdb==4.0.12
44
+ GitPython==3.1.45
45
+ google-pasta==0.2.0
46
+ graphviz==0.21
47
+ greenlet==3.3.1
48
+ grpcio==1.80.0
49
+ h11==0.16.0
50
+ h5py==3.14.0
51
+ hf-xet==1.4.3
52
+ httpcore==1.0.9
53
+ httpx==0.28.1
54
+ huggingface_hub==1.10.2
55
+ idna==3.11
56
+ imagesize==1.4.1
57
+ ipykernel==7.1.0
58
+ ipylab==1.1.0
59
+ ipython==9.6.0
60
+ ipython_pygments_lexers==1.1.1
61
+ ipywidgets==8.1.8
62
+ itsdangerous==2.2.0
63
+ jedi==0.19.2
64
+ Jinja2==3.1.6
65
+ joblib==1.5.3
66
+ jsonschema==4.25.1
67
+ jsonschema-specifications==2025.9.1
68
+ jupyter_client==8.6.3
69
+ jupyter_core==5.9.1
70
+ jupyterlab_pygments==0.3.0
71
+ jupyterlab_widgets==3.0.16
72
+ jupytext==1.19.1
73
+ keras==3.14.0
74
+ kiwisolver==1.4.9
75
+ lap==0.5.13
76
+ lapx==0.9.4
77
+ libclang==18.1.1
78
+ lxml==6.0.2
79
+ markdown-it-py==4.0.0
80
+ MarkupSafe==3.0.3
81
+ matplotlib==3.10.7
82
+ matplotlib-inline==0.2.1
83
+ mdit-py-plugins==0.5.0
84
+ mdurl==0.1.2
85
+ mistune==3.2.0
86
+ ml_dtypes==0.5.4
87
+ mpmath==1.3.0
88
+ namex==0.1.0
89
+ narwhals==2.11.0
90
+ nbclient==0.10.4
91
+ nbconvert==7.17.0
92
+ nbformat==5.10.4
93
+ nest-asyncio==1.6.0
94
+ networkx==3.6.1
95
+ numpy==2.3.4
96
+ opencv-python==4.13.0.92
97
+ opencv-python-headless==4.13.0.92
98
+ openpyxl==3.1.5
99
+ opt_einsum==3.4.0
100
+ optree==0.19.0
101
+ osqp==1.0.5
102
+ otter-grader==6.1.6
103
+ outcome==1.3.0.post0
104
+ packaging==25.0
105
+ pandas==2.3.3
106
+ pandocfilters==1.5.1
107
+ parso==0.8.5
108
+ pillow==12.0.0
109
+ platformdirs==4.5.0
110
+ playwright==1.58.0
111
+ plotly==6.4.0
112
+ polars==1.40.0
113
+ polars-runtime-32==1.40.0
114
+ prompt_toolkit==3.0.52
115
+ protobuf==6.33.1
116
+ psutil==7.1.2
117
+ pure_eval==0.2.3
118
+ pyarrow==21.0.0
119
+ pycparser==2.23
120
+ pydantic==2.12.5
121
+ pydantic_core==2.41.5
122
+ pydeck==0.9.1
123
+ pyee==13.0.0
124
+ Pygments==2.19.2
125
+ pyogrio==0.12.1
126
+ pyparsing==3.2.5
127
+ pyproj==3.7.2
128
+ PySocks==1.7.1
129
+ python-dateutil==2.9.0.post0
130
+ python-dotenv==1.2.1
131
+ python-on-whales==0.80.0
132
+ pytz==2025.2
133
+ PyYAML==6.0.3
134
+ pyzmq==27.1.0
135
+ referencing==0.37.0
136
+ requests==2.32.5
137
+ rich==14.3.3
138
+ roman-numerals==4.1.0
139
+ rpds-py==0.28.0
140
+ scikit-learn==1.8.0
141
+ scipy==1.16.3
142
+ scs==3.2.11
143
+ seaborn==0.13.2
144
+ selenium==4.38.0
145
+ setuptools==80.9.0
146
+ shapely==2.1.2
147
+ shellingham==1.5.4
148
+ six==1.17.0
149
+ smmap==5.0.2
150
+ sniffio==1.3.1
151
+ snowballstemmer==3.0.1
152
+ sortedcontainers==2.4.0
153
+ soupsieve==2.8
154
+ Sphinx==9.1.0
155
+ sphinxcontrib-applehelp==2.0.0
156
+ sphinxcontrib-devhelp==2.0.0
157
+ sphinxcontrib-htmlhelp==2.1.0
158
+ sphinxcontrib-jsmath==1.0.1
159
+ sphinxcontrib-qthelp==2.0.0
160
+ sphinxcontrib-serializinghtml==2.0.0
161
+ stack-data==0.6.3
162
+ streamlit==1.51.0
163
+ sympy==1.14.0
164
+ tenacity==9.1.2
165
+ tensorflow==2.21.0
166
+ termcolor==3.3.0
167
+ threadpoolctl==3.6.0
168
+ tinycss2==1.4.0
169
+ toml==0.10.2
170
+ torch==2.11.0
171
+ torchvision==0.26.0
172
+ torchviz==0.0.3
173
+ tornado==6.5.2
174
+ tqdm==4.67.3
175
+ traitlets==5.14.3
176
+ trio==0.32.0
177
+ trio-websocket==0.12.2
178
+ typer==0.24.1
179
+ typing-inspection==0.4.2
180
+ typing_extensions==4.15.0
181
+ tzdata==2025.2
182
+ ultralytics==8.4.39
183
+ ultralytics-thop==2.0.18
184
+ urllib3==2.5.0
185
+ watchdog==6.0.0
186
+ wcwidth==0.2.14
187
+ webdriver-manager==4.0.2
188
+ webencodings==0.5.1
189
+ websocket-client==1.9.0
190
+ Werkzeug==3.1.8
191
+ wheel==0.46.3
192
+ widgetsnbextension==4.0.15
193
+ wrapt==1.17.3
194
+ wsproto==1.3.2
static/trackiq-api.js ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * trackiq-api.js v5
3
+ * home.html est autonome — ce fichier gère dashboard, logs, history
4
+ */
5
+ const PAGE=(()=>{
6
+ const p=location.pathname;
7
+ if(p==='/'||p.includes('index')) return 'index';
8
+ if(p.includes('home')) return 'home';
9
+ if(p.includes('dashboard')) return 'dashboard';
10
+ if(p.includes('logs')) return 'logs';
11
+ if(p.includes('history')) return 'history';
12
+ return 'unknown';
13
+ })();
14
+
15
+ /* ══ DASHBOARD ══ */
16
+ if(PAGE==='dashboard'){
17
+ window.addEventListener('load',()=>{
18
+ loadDashboard();
19
+ setInterval(loadDashboard, 5000); // toutes les 5s, pas 6s
20
+ });
21
+
22
+ async function loadDashboard(){
23
+ try{
24
+ // ── Charge stats backend ──
25
+ const res = await fetch('/api/dashboard/stats');
26
+ const data = await res.json();
27
+ const uc = data.global_unique_counts || {};
28
+ const scenes = data.scenes || [];
29
+ const total = Object.values(uc).reduce((a,b)=>a+b,0);
30
+
31
+ // ── Metric card 1 : total objets ──
32
+ const m1 = document.getElementById('mv1');
33
+ if(m1) m1.textContent = total || '—';
34
+
35
+ // ── Metric card 2 : nb scènes ──
36
+ const m2 = document.getElementById('mv2');
37
+ if(m2) m2.textContent = scenes.length || '—';
38
+
39
+ // ── Metric card 3 : FPS de la dernière analyse ──
40
+ const lastScene = scenes[0];
41
+ const m3 = document.getElementById('mv3');
42
+ if(m3) m3.textContent = lastScene?.fps ? lastScene.fps.toFixed(1) : '—';
43
+
44
+ // ── Metric card 4 : durée dernière analyse ──
45
+ const m4 = document.getElementById('mv4');
46
+ if(m4) m4.textContent = lastScene?.duration_s ? lastScene.duration_s+'s' : '—';
47
+
48
+ // ── Scene summary (se met à jour maintenant !) ──
49
+ if(lastScene){
50
+ const ls = document.getElementById('liveSource');
51
+ const lsm = document.getElementById('liveSourceMeta');
52
+ if(ls) ls.textContent = lastScene.video_name || lastScene.scene_id || 'Unknown';
53
+ if(lsm) lsm.textContent = `${lastScene.duration_s||0}s analyzed`;
54
+
55
+ const lo = document.getElementById('liveObjects');
56
+ if(lo) lo.textContent = lastScene.total || 0;
57
+
58
+ const lf = document.getElementById('liveFps');
59
+ if(lf) lf.textContent = lastScene.fps ? lastScene.fps.toFixed(1) : '—';
60
+
61
+ const ld = document.getElementById('liveDuration');
62
+ if(ld) ld.textContent = lastScene.duration_s ? lastScene.duration_s+'s' : '—';
63
+ }
64
+
65
+ // ── Pie chart Object Type Distribution ──
66
+ if(Object.keys(uc).length && typeof updatePie === 'function'){
67
+ updatePie(uc);
68
+ }
69
+
70
+ // ── Live stats (depuis webcam stats si dispo, sinon dernière analyse) ──
71
+ try{
72
+ const wcRes = await fetch('/api/webcam/stats');
73
+ if(wcRes.ok){
74
+ const wc = await wcRes.json();
75
+ const det = document.getElementById('sbdet');
76
+ const acc = document.getElementById('sbacc');
77
+ if(det) det.textContent = wc.detections ?? 0;
78
+ if(acc) acc.textContent = wc.frame ? Math.round(wc.frame) : '—';
79
+ // Montre le panneau live seulement si webcam active
80
+ if(wc.frame > 0){
81
+ const note = document.getElementById('livePanelNote');
82
+ if(note) note.style.display='none';
83
+ }
84
+ }
85
+ }catch(e){}
86
+
87
+ // ── Charts flow (vraies données timeline) ──
88
+ if(window.fch && lastScene?.timeline?.length > 1){
89
+ const tl = lastScene.timeline;
90
+ const step = Math.max(1,Math.floor(tl.length/20));
91
+ const lbl=[],objDat=[];
92
+ for(let i=0;i<tl.length;i+=step){
93
+ const pt=tl[i];
94
+ lbl.push(pt.ts.toFixed(0)+'s');
95
+ const cnt=Object.entries(pt)
96
+ .filter(([k])=>!['frame','ts'].includes(k))
97
+ .reduce((a,[,v])=>a+(v||0),0);
98
+ objDat.push(cnt);
99
+ }
100
+ window.fch.data.labels = lbl;
101
+ window.fch.data.datasets[0].data = objDat;
102
+ // FPS dataset : valeur constante (fps de la scène)
103
+ window.fch.data.datasets[1].data = lbl.map(()=>lastScene.fps||0);
104
+ window.fch.update('none');
105
+ }
106
+
107
+ // ── Chart classification (vraies classes) ──
108
+ if(window.tch && Object.keys(uc).length){
109
+ const COLORS=['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#f97316'];
110
+ window.tch.data.labels = Object.keys(uc);
111
+ window.tch.data.datasets[0].data = Object.values(uc);
112
+ window.tch.data.datasets[0].backgroundColor = Object.keys(uc).map((_,i)=>COLORS[i%COLORS.length]);
113
+ window.tch.update('none');
114
+ }
115
+
116
+ // ── Heatmap depuis vraies positions (déjà gérée par loadHeatmap) ──
117
+
118
+ }catch(err){ console.warn('[TrackIQ] Dashboard fetch:',err); }
119
+ }
120
+ }
121
+
122
+ /* ══ LOGS ══ */
123
+ if(PAGE==='logs'){
124
+ let _rows=[];
125
+ window.addEventListener('load',async()=>{
126
+ try{
127
+ const res=await fetch('/api/logs');
128
+ const scenes=await res.json();
129
+ if(!scenes.length)return;
130
+ _rows=[];
131
+ scenes.forEach(s=>{
132
+ Object.entries(s.unique_counts||{}).forEach(([cls,cnt])=>{
133
+ _rows.push({date:s.generated_at?new Date(s.generated_at).toLocaleString():'—',
134
+ scene:s.scene_id,video:s.video_name||'—',cls,cnt,total:s.total_unique||0,sid:s.scene_id});
135
+ });
136
+ });
137
+ const scvs=document.querySelectorAll('.sc-v');
138
+ if(scvs[0]) scvs[0].textContent=scenes.reduce((a,s)=>a+(s.total_unique||0),0);
139
+ if(scvs[1]) scvs[1].textContent=scenes.length;
140
+ renderTable(_rows);
141
+ hookFilters(scenes);
142
+ const expBtn=document.querySelector('.btn-exp');
143
+ if(expBtn) expBtn.onclick=()=>scenes.forEach((s,i)=>setTimeout(()=>window.open(`/api/logs/${s.scene_id}/csv`),i*300));
144
+ }catch(err){console.warn('[TrackIQ] Logs:',err);}
145
+ });
146
+
147
+ function renderTable(rows){
148
+ const tbody=document.getElementById('tbody');if(!tbody)return;
149
+ if(!rows.length){tbody.innerHTML=`<tr><td colspan="7" style="text-align:center;padding:30px;color:var(--text3);">No data yet — run an analysis first</td></tr>`;return;}
150
+ const DOT={'Vehicle':'#3b82f6','Motorcycle':'#10b981','Truck':'#f59e0b','Bus':'#8b5cf6','Human':'#ec4899','Bicycle':'#06b6d4'};
151
+ const EMO={'Vehicle':'🚗','Motorcycle':'🏍','Truck':'🚚','Bus':'🚌','Human':'🚶','Bicycle':'🚲'};
152
+ tbody.innerHTML='';
153
+ rows.forEach(r=>{
154
+ const tr=document.createElement('tr');
155
+ tr.innerHTML=`<td><div class="thumb">${EMO[r.cls]||'📦'}</div></td>
156
+ <td style="font-size:11px;color:var(--text2);">${r.date}</td>
157
+ <td style="font-size:11px;color:var(--text2);">${r.scene}</td>
158
+ <td style="font-size:11px;" title="${r.video}">${r.video.length>18?r.video.slice(0,18)+'…':r.video}</td>
159
+ <td><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${DOT[r.cls]||'#888'};margin-right:4px;"></span>${r.cls}</td>
160
+ <td><strong>${r.cnt}</strong></td>
161
+ <td><a href="/api/logs/${r.sid}/csv" style="color:var(--accent);font-size:11px;"><i class="fas fa-download"></i> CSV</a></td>`;
162
+ tbody.appendChild(tr);
163
+ });
164
+ }
165
+
166
+ function hookFilters(scenes){
167
+ const sIn=document.getElementById('sIn');
168
+ const camF=document.getElementById('camF');
169
+ const typeF=document.getElementById('typeF');
170
+ if(typeF){
171
+ const cls=[...new Set(_rows.map(r=>r.cls))];
172
+ typeF.innerHTML='<option value="">All</option>'+cls.map(c=>`<option>${c}</option>`).join('');
173
+ }
174
+ if(camF) camF.innerHTML='<option value="">All</option>'+
175
+ scenes.map(s=>`<option value="${s.scene_id}">${s.video_name||s.scene_id}</option>`).join('');
176
+ const apply=()=>{
177
+ const s=(sIn?.value||'').toLowerCase(),sc=camF?.value||'',tp=typeF?.value||'';
178
+ renderTable(_rows.filter(r=>(!s||r.video.toLowerCase().includes(s)||r.scene.toLowerCase().includes(s))&&(!sc||r.scene===sc)&&(!tp||r.cls===tp)));
179
+ };
180
+ if(sIn) sIn.oninput=apply;
181
+ if(camF) camF.onchange=apply;
182
+ if(typeF) typeF.onchange=apply;
183
+ }
184
+ }
185
+
186
+ /* ══ HISTORY ══ */
187
+ if(PAGE==='history'){
188
+ async function mergeHistory(){
189
+ try{
190
+ const res=await fetch('/api/history');
191
+ const items=await res.json();
192
+ if(!items.length)return;
193
+ let hist=[];try{hist=JSON.parse(localStorage.getItem('tiq_history')||'[]');}catch(e){}
194
+ items.forEach(item=>{if(!hist.find(h=>h.url===item.url))hist.unshift({name:item.name,url:item.url,date:item.date});});
195
+ localStorage.setItem('tiq_history',JSON.stringify(hist.slice(0,50)));
196
+ if(window.renderGrid)window.renderGrid();
197
+ }catch(err){console.warn('[TrackIQ] History:',err);}
198
+ }
199
+ setTimeout(mergeHistory,300);
200
+ }
201
+
202
+ console.log(`[TrackIQ v5] page=${PAGE}`);
templates/dashboard.html ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ Dashboard</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.min.js"></script>
11
+ <style>
12
+ :root{--bg:#0c1117;--s:#151c25;--s2:#1c2531;--bd:rgba(255,255,255,0.07);--sh:0 2px 16px rgba(0,0,0,0.45);--shlg:0 8px 36px rgba(0,0,0,0.55);--t:#e8edf2;--t2:#7a8fa0;--t3:#3a4a5a;--ac:#880000;--ac2:#660000;--acl:rgba(136,0,0,0.12);--gr:#16a34a;--grb:#052e16;--bl:#3b82f6;--blb:#0f2040;--yw:#f59e0b;--ywb:#1a0f00;--re:#ef4444;--reb:#2d0000;--sw:60px;--r:14px;--rs:9px;}
13
+ [data-theme="light"]{--bg:#f0f4f8;--s:#fff;--s2:#f6f8fb;--bd:rgba(0,0,0,0.07);--sh:0 2px 16px rgba(0,0,0,0.09);--shlg:0 8px 36px rgba(0,0,0,0.13);--t:#0f1923;--t2:#5a6a7a;--t3:#9aaab8;--grb:#dcfce7;--blb:#dbeafe;--ywb:#fef9c3;--reb:#fee2e2;}
14
+ *{box-sizing:border-box;margin:0;padding:0}::-webkit-scrollbar{width:0;height:0}
15
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--t);font-size:13px;display:flex;height:100vh;overflow:hidden;transition:background .3s,color .3s}
16
+ .sb{width:var(--sw);background:var(--s);border-right:1px solid var(--bd);display:flex;flex-direction:column;align-items:center;padding:14px 0;gap:4px;flex-shrink:0}
17
+ .sbl{width:34px;height:34px;background:var(--ac);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;margin-bottom:10px;text-decoration:none}
18
+ .sbsep{width:26px;height:1px;background:var(--bd);margin:3px 0}
19
+ .sbb{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:14px;cursor:pointer;transition:all .2s;text-decoration:none;border:none;background:none;position:relative}
20
+ .sbb:hover{background:var(--s2);color:var(--t)}.sbb.on{background:var(--acl);color:var(--ac)}
21
+ .sbb .tip{position:absolute;left:44px;top:50%;transform:translateY(-50%);background:var(--t);color:var(--bg);font-size:10px;padding:3px 7px;border-radius:4px;white-space:nowrap;opacity:0;pointer-events:none;z-index:300}.sbb:hover .tip{opacity:1}
22
+ .sbsp{flex:1}.pw{flex:1;display:flex;flex-direction:column;overflow:hidden}
23
+ .topb{height:50px;background:var(--s);border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;padding:0 18px;flex-shrink:0}
24
+ .topl,.topr{display:flex;align-items:center;gap:9px}.bn{font-size:14px;font-weight:600}.bn b{color:var(--ac)}
25
+ .pill{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--gr);background:var(--grb);padding:2px 8px;border-radius:20px}
26
+ .ld{width:6px;height:6px;border-radius:50%;background:var(--gr);animation:blink 2s infinite}
27
+ @keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
28
+ .clk{font-size:10px;font-family:'DM Mono',monospace;color:var(--t2)}
29
+ .lg{display:flex;background:var(--s2);border-radius:5px;padding:2px;border:1px solid var(--bd)}
30
+ .lb{font-size:10px;font-weight:500;padding:2px 8px;border:none;background:none;cursor:pointer;border-radius:3px;color:var(--t2);font-family:'DM Sans',sans-serif;transition:all .2s}.lb.on{background:var(--ac);color:#fff}
31
+ .ib{width:28px;height:28px;border-radius:5px;border:1px solid var(--bd);background:var(--s);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--t2);font-size:11px;position:relative;transition:all .2s}.ib:hover{background:var(--s2)}
32
+ .nd{position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--ac)}
33
+ main{flex:1;padding:12px 14px;display:grid;grid-template-columns:minmax(0,1.4fr) 360px;gap:12px;overflow:auto;min-height:0}
34
+ .left-col{display:grid;grid-template-rows:auto minmax(190px,1fr) minmax(190px,1fr);gap:10px;min-height:0}
35
+ .right-col{display:grid;grid-template-rows:minmax(300px,1.15fr) auto auto;gap:10px;min-height:0}
36
+ .mr{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
37
+ .mc,.cd{background:var(--s);border:1px solid var(--bd);box-shadow:var(--sh);overflow:hidden}
38
+ .mc{border-radius:var(--rs);padding:11px 13px;transition:transform .2s}.mc:hover{transform:translateY(-2px)}
39
+ .cd{border-radius:var(--r);padding:12px 14px;display:flex;flex-direction:column}.cd:hover{box-shadow:var(--shlg)}
40
+ .mct{display:flex;align-items:center;justify-content:space-between;margin-bottom:5px}
41
+ .mcl{font-size:10px;color:var(--t2)}.bx{font-size:9px;font-weight:500;padding:1px 5px;border-radius:4px}
42
+ .mv{font-size:20px;font-weight:300;letter-spacing:-.5px;line-height:1}.mu{font-size:10px;color:var(--t2)}.ms{font-size:9px;color:var(--t3);margin-top:2px}
43
+ .ct{font-size:11px;font-weight:500;margin-bottom:8px;display:flex;align-items:center;gap:5px;flex-shrink:0}.ct i{color:var(--ac);font-size:10px}
44
+ .chart-wrap{flex:1;min-height:0}
45
+ .stack{display:grid;grid-template-columns:1fr 1fr;gap:10px}
46
+ .hmcd{padding:0}
47
+ .hmhd{padding:10px 12px 6px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
48
+ .hmhd .ct{margin-bottom:0}
49
+ .hmbd{flex:1;position:relative;min-height:220px}
50
+ #hmc2{position:absolute;inset:0;width:100%!important;height:100%!important;display:block}
51
+ .stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}
52
+ .statbox{background:var(--s2);border:1px solid var(--bd);border-radius:8px;padding:10px 11px}
53
+ .statbox .k{font-size:9px;color:var(--t2);text-transform:uppercase;letter-spacing:.04em}
54
+ .statbox .v{font-size:18px;font-weight:300;margin-top:4px}
55
+ .subline{font-size:10px;color:var(--t2)}
56
+ .listbox{display:flex;flex-direction:column;gap:6px}
57
+ .vr{display:flex;align-items:center;justify-content:space-between;background:var(--s2);border:1px solid var(--bd);border-radius:8px;padding:8px 10px}
58
+ .vrl{display:flex;align-items:center;gap:7px;font-size:11px}.vic{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px}.vp{font-size:13px;font-weight:500}
59
+ .al{display:flex;align-items:flex-start;gap:8px;padding:7px 0;border-bottom:1px solid var(--bd)}.al:last-child{border-bottom:none}
60
+ .ai{width:22px;height:22px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;margin-top:1px}
61
+ .at2{font-size:10px;font-weight:500;margin-bottom:1px}.as2{font-size:9px;color:var(--t2)}
62
+ .pill-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.mini-pill{font-size:9px;padding:3px 7px;border-radius:999px;background:var(--s2);border:1px solid var(--bd);color:var(--t2)}
63
+ .empty-note{font-size:10px;color:var(--t3);padding-top:4px}
64
+ @media(max-width:1180px){main{grid-template-columns:1fr}.right-col{grid-template-rows:minmax(260px,auto) auto auto}.stack{grid-template-columns:1fr}.mr{grid-template-columns:repeat(2,minmax(0,1fr))}}
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <nav class="sb">
69
+ <a href="/" class="sbl"><i class="fas fa-satellite-dish"></i></a>
70
+ <div class="sbsep"></div>
71
+ <a href="/home" class="sbb"><i class="fas fa-play-circle"></i><span class="tip">Analyze</span></a>
72
+ <a href="/dashboard" class="sbb on"><i class="fas fa-chart-line"></i><span class="tip">Dashboard</span></a>
73
+ <a href="/logs" class="sbb"><i class="fas fa-list-ul"></i><span class="tip">Logs</span></a>
74
+ <a href="/history" class="sbb"><i class="fas fa-film"></i><span class="tip">History</span></a>
75
+ <div class="sbsp"></div>
76
+ <button class="sbb" onclick="toggleTheme()"><i class="fas fa-moon" id="ti"></i><span class="tip">Theme</span></button>
77
+ </nav>
78
+
79
+ <div class="pw">
80
+ <header class="topb">
81
+ <div class="topl">
82
+ <span class="bn">Track<b>IQ</b></span>
83
+ <span style="color:var(--bd);font-size:16px;margin:0 1px;">|</span>
84
+ <span style="font-size:12px;font-weight:500;" id="t-dash">Dashboard</span>
85
+ <div class="pill"><span class="ld"></span> <span id="liveBadge">Live</span></div>
86
+ </div>
87
+ <div class="topr">
88
+ <span class="clk" id="clk">--:--:--</span>
89
+ <div class="lg">
90
+ <button class="lb on" onclick="setLang('en',this)">EN</button>
91
+ <button class="lb" onclick="setLang('fr',this)">FR</button>
92
+ <button class="lb" onclick="setLang('wo',this)">WO</button>
93
+ </div>
94
+ <button class="ib" onclick="toggleNotifs()"><i class="fas fa-bell"></i><span class="nd" id="nd"></span></button>
95
+ <button class="ib" onclick="toggleTheme()"><i class="fas fa-moon" id="ti2"></i></button>
96
+ </div>
97
+ </header>
98
+
99
+ <div id="notifP" style="display:none;position:fixed;top:57px;right:65px;z-index:500;width:255px;background:var(--s);border:1px solid var(--bd);border-radius:var(--r);box-shadow:var(--shlg);padding:12px 14px;">
100
+ <div style="font-size:11px;font-weight:500;margin-bottom:8px;display:flex;justify-content:space-between;"><span id="t-notif">Notifications</span><button onclick="clearN()" style="font-size:10px;color:var(--ac);background:none;border:none;cursor:pointer;">Clear all</button></div>
101
+ <div id="nl">
102
+ <div style="padding:6px 0;border-bottom:1px solid var(--bd);"><div style="font-size:10px;font-weight:500;color:var(--re);" id="t-a1">Congestion Zone A</div><div style="font-size:9px;color:var(--t2);">4 min ago</div></div>
103
+ <div style="padding:6px 0;"><div style="font-size:10px;font-weight:500;" id="t-source-loaded">Source ready</div><div style="font-size:9px;color:var(--t2);">Just now</div></div>
104
+ </div>
105
+ </div>
106
+
107
+ <main>
108
+ <section class="left-col">
109
+ <div class="mr">
110
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m1">Objects Today</span><span class="bx" style="background:var(--grb);color:var(--gr);" id="m1Trend">+0%</span></div><div><span class="mv" id="mvToday">0</span> <span class="mu" id="t-veh">obj.</span></div><div class="ms" id="m1Sub">Latest analyzed scenes</div></div>
111
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m2">Current Flow</span><span class="bx" style="background:var(--grb);color:var(--gr);" id="flowBadge">Smooth</span></div><div><span class="mv" id="mvFlow">0</span> <span class="mu">obj/min</span></div><div class="ms" id="m2Sub">Based on recent activity</div></div>
112
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m3">Avg FPS</span><span class="bx" style="background:var(--blb);color:var(--bl);" id="fpsBadge">Stable</span></div><div><span class="mv" id="mvFps">0</span> <span class="mu">fps</span></div><div class="ms" id="m3Sub">Last completed analysis</div></div>
113
+ <div class="mc"><div class="mct"><span class="mcl" id="t-m4">Latest clip</span><span class="bx" style="background:var(--ywb);color:var(--yw);" id="accBadge">Current</span></div><div><span class="mv" id="mvAcc">0</span> <span class="mu">s</span></div><div class="ms" id="m4Sub">Duration of latest processed video</div></div>
114
+ </div>
115
+
116
+ <div class="stack">
117
+ <div class="cd">
118
+ <div class="ct"><i class="fas fa-chart-area"></i><span id="t-c1">Flow & speed</span></div>
119
+ <div class="chart-wrap"><canvas id="fc"></canvas></div>
120
+ </div>
121
+ <div class="cd">
122
+ <div class="ct"><i class="fas fa-chart-bar"></i><span id="t-c2">Classification</span></div>
123
+ <div class="chart-wrap"><canvas id="tc"></canvas></div>
124
+ </div>
125
+ </div>
126
+
127
+ <div class="cd">
128
+ <div class="ct"><i class="fas fa-layer-group"></i><span id="t-scenes">Scene summary</span></div>
129
+ <div class="stats-grid">
130
+ <div class="statbox"><div class="k" id="t-live-source">Source</div><div class="v" id="liveSource">No source</div><div class="subline" id="liveSourceMeta">Ready after analysis</div></div>
131
+ <div class="statbox"><div class="k" id="t-live-objects">Objects</div><div class="v" id="liveObjects" id="liveObjects">0</div><div class="subline" id="liveObjectsMeta">Peak visible count</div></div>
132
+ <div class="statbox"><div class="k" id="t-live-fps">FPS</div><div class="v" id="liveFps" id="liveFps">0</div><div class="subline" id="liveFpsMeta">Captured stream rate</div></div>
133
+ <div class="statbox"><div class="k" id="t-live-duration">Duration</div><div class="v" id="liveDuration" id="liveDuration">0s</div><div class="subline" id="liveDurationMeta">Latest processed clip</div></div>
134
+ </div>
135
+ <div class="pill-row" id="liveClassPills"></div>
136
+ <div class="empty-note" id="liveNote">Current summary follows the latest processed analysis instead of the old floating video preview.</div>
137
+ <div id="sceneSummaryGrid" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px;"></div>
138
+ </div>
139
+ </section>
140
+
141
+ <aside class="right-col">
142
+ <div class="cd hmcd">
143
+ <div class="hmhd"><div class="ct"><i class="fas fa-map-marker-alt"></i><span id="t-hm">Position heatmap</span></div><span style="font-size:9px;color:var(--t3);" id="t-hms">Object density</span></div>
144
+ <div class="hmbd"><canvas id="hmc2"></canvas></div>
145
+ </div>
146
+
147
+ <div class="stack">
148
+ <div class="cd" style="padding:11px 12px;">
149
+ <div class="ct">
150
+ <i class="fas fa-chart-pie"></i>
151
+ <span>Object Type Distribution</span>
152
+ </div>
153
+ <!-- Pie chart + légende -->
154
+ <div style="position:relative;display:flex;align-items:center;gap:10px;min-height:140px;">
155
+ <div style="width:145px;height:145px;flex-shrink:0;">
156
+ <canvas id="pieChart"></canvas>
157
+
158
+ </div>
159
+ <!-- Légende dynamique -->
160
+ <div id="pieLegend" style="flex:1;display:flex;flex-direction:column;gap:5px;">
161
+ <div style="font-size:10px;color:var(--t3);text-align:center;">No data yet</div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ <div class="cd" style="padding:11px 12px;">
166
+ <div class="ct"><i class="fas fa-wave-square"></i><span id="t-live-panel">Live stats</span></div>
167
+ <div class="stats-grid">
168
+ <div class="statbox"><div class="k" id="t-det">Detected</div><div class="v" id="sbdet">0</div></div>
169
+ <div class="statbox"><div class="k" id="t-acc">Latest FPS</div><div class="v" id="sbacc">0</div></div>
170
+ </div>
171
+ <div class="empty-note" id="livePanelNote">Heatmap and key stats stay visible at all times.</div>
172
+ </div>
173
+ </div>
174
+ </aside>
175
+ </main>
176
+ </div>
177
+
178
+ <script>
179
+ setInterval(()=>{document.getElementById('clk').textContent=new Date().toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit'});},1000);
180
+
181
+ function toggleTheme(){
182
+ const d=document.documentElement,dk=d.getAttribute('data-theme')==='dark';
183
+ d.setAttribute('data-theme',dk?'light':'dark');
184
+ ['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className=dk?'fas fa-moon':'fas fa-sun';});
185
+ rebuildCharts();
186
+ setTimeout(drawHm,50);
187
+ rebuildPie();
188
+ try{localStorage.setItem('tiq_theme',dk?'light':'dark');}catch(e){}
189
+ }
190
+
191
+ const TL={
192
+ en:{dash:'Dashboard',m1:'Objects Today',veh:'obj.',m2:'Current Flow',m3:'Avg FPS',m4:'Latest clip',c1:'Flow from video',c2:'Classification',hm:'Position heatmap',hms:'Detected positions',bk:'Breakdown',ca:'Cars',mo:'Moto',tr:'Trucks',al:'Alerts',notif:'Notifications',scenes:'Scene summary',livesource:'Source',liveobjects:'Objects',livefps:'FPS',liveduration:'Duration',livepanel:'Live stats',det:'Detected',acc:'Latest FPS'},
193
+ fr:{dash:'Tableau de bord',m1:'Objets du jour',veh:'obj.',m2:'Flux actuel',m3:'FPS moy.',m4:'Dernier clip',c1:'Flux vidéo',c2:'Classification',hm:'Carte de densité',hms:'Positions détectées',bk:'Répartition',ca:'Voitures',mo:'Motos',tr:'Camions',al:'Alertes',notif:'Notifications',scenes:'Résumé scène',livesource:'Source',liveobjects:'Objets',livefps:'FPS',liveduration:'Durée',livepanel:'Stats live',det:'Détectés',acc:'FPS récent'},
194
+ wo:{dash:'Dashboard',m1:'Objets tey',veh:'obj.',m2:'Yóbbu',m3:'FPS moy.',m4:'Clip mujj',c1:'Flux video',c2:'Wërsëg',hm:'Kàrt',hms:'Posision yi',bk:'Wërsëg',ca:'Cars',mo:'Moto',tr:'Trucks',al:'Yëgël',notif:'Yëgël yi',scenes:'Melo scène',livesource:'Source',liveobjects:'Objets',livefps:'FPS',liveduration:'Yoon',livepanel:'Stats live',det:'Yi réy',acc:'FPS mujj'}
195
+ };
196
+
197
+ function setLang(l,btn){
198
+ document.querySelectorAll('.lb').forEach(b=>b.classList.remove('on'));
199
+ btn.classList.add('on');
200
+ const t=TL[l];
201
+ const mp={dash:'t-dash',m1:'t-m1',veh:'t-veh',m2:'t-m2',m3:'t-m3',m4:'t-m4',c1:'t-c1',c2:'t-c2',hm:'t-hm',hms:'t-hms',bk:'t-bk',ca:'t-ca',mo:'t-mo',tr:'t-tr',al:'t-al',notif:'t-notif',scenes:'t-scenes',livesource:'t-live-source',liveobjects:'t-live-objects',livefps:'t-live-fps',liveduration:'t-live-duration',livepanel:'t-live-panel',det:'t-det',acc:'t-acc'};
202
+ Object.entries(mp).forEach(([k,id])=>{const el=document.getElementById(id);if(el&&t[k])el.textContent=t[k];});
203
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
204
+ }
205
+
206
+ let no=false;
207
+ function toggleNotifs(){no=!no;document.getElementById('notifP').style.display=no?'block':'none';if(no)document.getElementById('nd').style.display='none';}
208
+ function clearN(){document.getElementById('nl').innerHTML='<div style="font-size:10px;color:var(--t3);text-align:center;padding:10px;">No notifications</div>';no=false;document.getElementById('notifP').style.display='none';}
209
+ document.addEventListener('click',e=>{if(no&&!e.target.closest('#notifP')&&!e.target.closest('.ib'))toggleNotifs();});
210
+
211
+ window.fch=null;
212
+ window.tch=null;
213
+ window._tiqHeatPoints=[];
214
+
215
+ function gc(){
216
+ const dk=document.documentElement.getAttribute('data-theme')==='dark';
217
+ return{grid:dk?'rgba(255,255,255,0.05)':'rgba(0,0,0,0.06)',tick:dk?'#4a5a6a':'#94a3b8'};
218
+ }
219
+
220
+ /* ── Pie chart — Object Type Distribution ── */
221
+ window.pch = null;
222
+
223
+ function rebuildPie(labels=[], data=[], colors=[]){
224
+ // Enregistre datalabels seulement si dispo
225
+ if(window.ChartDataLabels) Chart.register(ChartDataLabels);
226
+ if(window.pch) window.pch.destroy();
227
+ const canvas = document.getElementById('pieChart');
228
+ if(!canvas) return;
229
+ const dk = document.documentElement.getAttribute('data-theme')==='dark';
230
+
231
+ const defaultColors = ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ec4899','#06b6d4','#ef4444','#f97316'];
232
+
233
+ window.pch = new Chart(canvas, {
234
+ type: 'pie',
235
+ data: {
236
+ labels: labels.length ? labels : ['No data'],
237
+ datasets: [{
238
+ data: data.length ? data : [1],
239
+ backgroundColor: colors.length ? colors : [dk?'#1c2531':'#e2e8f0'],
240
+ borderColor: dk ? '#151c25' : '#ffffff',
241
+ borderWidth: 2,
242
+ hoverOffset: 6,
243
+ }]
244
+ },
245
+ options: {
246
+ responsive: true,
247
+ maintainAspectRatio: false,
248
+ plugins: {
249
+ legend: { display: false },
250
+ tooltip: {
251
+ callbacks: {
252
+ label: ctx => {
253
+ const tot = ctx.dataset.data.reduce((a,b)=>a+b,0);
254
+ return ` ${ctx.label}: ${ctx.raw} (${Math.round(ctx.raw/tot*100)}%)`;
255
+ }
256
+ }
257
+ },
258
+ /* Affiche les % à l'intérieur de chaque part */
259
+ datalabels: {
260
+ color: '#fff',
261
+ font: { size: 11, weight: 'bold' },
262
+ formatter: (value, ctx) => {
263
+ const tot = ctx.dataset.data.reduce((a,b)=>a+b,0);
264
+ const pct = Math.round(value/tot*100);
265
+ return pct >= 5 ? pct+'%' : ''; // cache si trop petit
266
+ }
267
+ }
268
+ }
269
+ }
270
+ });
271
+ }
272
+
273
+ function updatePie(uc){
274
+ const COLORS = {
275
+ 'Vehicle':'#3b82f6','Motorcycle':'#10b981','Truck':'#f59e0b',
276
+ 'Bus':'#8b5cf6','Human':'#ec4899','Bicycle':'#06b6d4',
277
+ 'Traffic light':'#ef4444','Road sign':'#f97316'
278
+ };
279
+ const EMO = {
280
+ 'Vehicle':'🚗','Motorcycle':'🏍','Truck':'🚚','Bus':'🚌',
281
+ 'Human':'🚶','Bicycle':'🚲','Traffic light':'🚦','Road sign':'🛑'
282
+ };
283
+
284
+ const labels = Object.keys(uc);
285
+ const data = Object.values(uc);
286
+ const colors = labels.map(l => COLORS[l] || '#888');
287
+ const total = data.reduce((a,b)=>a+b,0);
288
+
289
+ // Recrée le chart
290
+ rebuildPie(labels, data, colors);
291
+
292
+ // Met à jour le label central
293
+ const ptn = document.getElementById('pieTotalNum');
294
+ if(ptn) ptn.textContent = total;
295
+
296
+ // Légende dynamique
297
+ const leg = document.getElementById('pieLegend');
298
+ if(!leg) return;
299
+ leg.innerHTML = '';
300
+ labels.forEach((lbl, i) => {
301
+ const pct = Math.round(data[i]/Math.max(total,1)*100);
302
+ const row = document.createElement('div');
303
+ row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:6px;';
304
+ row.innerHTML = `
305
+ <div style="display:flex;align-items:center;gap:5px;font-size:11px;overflow:hidden;">
306
+ <span style="width:9px;height:9px;border-radius:2px;background:${colors[i]};flex-shrink:0;display:inline-block;"></span>
307
+ <span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${EMO[lbl]||'📦'} ${lbl}</span>
308
+ </div>
309
+ <span style="font-size:10px;color:var(--t2);flex-shrink:0;">${data[i]}</span>
310
+ `;
311
+ leg.appendChild(row);
312
+ });
313
+ }
314
+
315
+ function rebuildCharts(){
316
+ if(window.fch)window.fch.destroy();
317
+ if(window.tch)window.tch.destroy();
318
+ const {grid,tick}=gc();
319
+ /* Flow chart : 2 datasets (Objects + FPS) — compatible avec trackiq-api.js */
320
+ window.fch=new Chart(document.getElementById('fc'),{
321
+ data:{labels:['—'],datasets:[
322
+ {type:'line',label:'Objects',data:[0],borderColor:'#3b82f6',backgroundColor:'rgba(59,130,246,0.08)',borderWidth:1.5,tension:0.45,pointRadius:0,fill:true,yAxisID:'y'},
323
+ {type:'line',label:'FPS',data:[0],borderColor:'#10b981',backgroundColor:'rgba(16,185,129,0.05)',borderWidth:1.5,tension:0.45,pointRadius:0,yAxisID:'y2'}
324
+ ]},
325
+ options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{boxWidth:8,font:{size:9},color:tick}}},scales:{x:{ticks:{font:{size:9},color:tick,maxTicksLimit:8},grid:{color:grid},border:{display:false}},y:{position:'left',beginAtZero:true,ticks:{font:{size:9},color:tick},grid:{color:grid},border:{display:false}},y2:{position:'right',beginAtZero:true,ticks:{font:{size:9},color:tick},grid:{drawOnChartArea:false},border:{display:false}}}}
326
+ });
327
+ /* Classification chart : NON stacké, 1 dataset — compatible avec trackiq-api.js */
328
+ window.tch=new Chart(document.getElementById('tc'),{
329
+ type:'bar',
330
+ data:{labels:['—'],datasets:[
331
+ {label:'Count',data:[0],backgroundColor:'#3b82f6',borderRadius:3,borderSkipped:false}
332
+ ]},
333
+ options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:{font:{size:9},color:tick},grid:{color:grid},border:{display:false}},y:{beginAtZero:true,ticks:{font:{size:9},color:tick},grid:{color:grid},border:{display:false}}}}
334
+ });
335
+ }
336
+
337
+ function drawHm(){
338
+ const c=document.getElementById('hmc2'),b=c.parentElement;
339
+ const w=b.offsetWidth,h=b.offsetHeight;
340
+ if(!w||!h)return;
341
+ c.width=w;c.height=h;
342
+ const ctx=c.getContext('2d');
343
+ const dk=document.documentElement.getAttribute('data-theme')==='dark';
344
+ ctx.fillStyle=dk?'#0c1219':'#e8eef5';
345
+ ctx.fillRect(0,0,w,h);
346
+ ctx.strokeStyle=dk?'rgba(255,255,255,0.07)':'rgba(0,0,0,0.07)';
347
+ ctx.lineWidth=1;
348
+ [0.25,0.5,0.75].forEach(r=>{ctx.beginPath();ctx.moveTo(0,h*r);ctx.lineTo(w,h*r);ctx.stroke();ctx.beginPath();ctx.moveTo(w*r,0);ctx.lineTo(w*r,h);ctx.stroke();});
349
+ const pts=Array.isArray(window._tiqHeatPoints)?window._tiqHeatPoints:[];
350
+ if(pts.length){
351
+ let mxR=0,mxH=0;
352
+ pts.forEach(p=>{if(p.r>mxR)mxR=p.r;if(p.h>mxH)mxH=p.h;});
353
+ mxR=Math.max(mxR,0.01);mxH=Math.max(mxH,0.01);
354
+ pts.forEach(p=>{
355
+ const px=(p.x/mxR)*w,py=(p.y/mxH)*h;
356
+ const rad=Math.max(10,Math.min(w,h)*0.015);
357
+ const grd=ctx.createRadialGradient(px,py,0,px,py,rad);
358
+ grd.addColorStop(0,'rgba(220,38,38,0.5)');
359
+ grd.addColorStop(0.4,'rgba(180,0,0,0.18)');
360
+ grd.addColorStop(1,'rgba(0,0,0,0)');
361
+ ctx.fillStyle=grd;ctx.fillRect(0,0,w,h);
362
+ });
363
+ ctx.font="9px 'DM Mono',monospace";
364
+ ctx.fillStyle=dk?'rgba(255,255,255,0.3)':'rgba(0,0,0,0.25)';
365
+ ctx.textAlign='left';ctx.fillText(pts.length+' points',8,13);
366
+ }else{
367
+ ctx.fillStyle=dk?'rgba(255,255,255,0.18)':'rgba(0,0,0,0.2)';
368
+ ctx.font="12px 'DM Sans',sans-serif";ctx.textAlign='center';
369
+ ctx.fillText('Run an analysis to build the heatmap',w/2,h/2);
370
+ ctx.textAlign='left';
371
+ }
372
+ ctx.font="10px 'DM Mono',monospace";
373
+ ctx.fillStyle=dk?'rgba(255,255,255,0.24)':'rgba(0,0,0,0.2)';
374
+ ctx.fillText('N',w/2-3,12);ctx.fillText('S',w/2-3,h-5);ctx.fillText('W',5,h/2+3);ctx.fillText('E',w-10,h/2+3);
375
+ }
376
+
377
+ async function loadHeatmap(){
378
+ try{
379
+ const r=await fetch('/api/logs/rows');
380
+ if(!r.ok)return;
381
+ const rows=await r.json();
382
+ if(!rows.length)return;
383
+ const pts=[];let mxX=0,mxY=0;
384
+ rows.slice(0,4000).forEach(r=>{
385
+ if(r.cx>0&&r.cy>0){pts.push({x:r.cx,y:r.cy,r:0,h:0});if(r.cx>mxX)mxX=r.cx;if(r.cy>mxY)mxY=r.cy;}
386
+ });
387
+ if(!pts.length)return;
388
+ pts.forEach(p=>{p.r=mxX;p.h=mxY;});
389
+ window._tiqHeatPoints=pts;
390
+ drawHm();
391
+ }catch(e){}
392
+ }
393
+
394
+ setTimeout(rebuildCharts,120);
395
+ setTimeout(rebuildPie,150);
396
+ setTimeout(drawHm,180);
397
+ setTimeout(loadHeatmap,600);
398
+ setInterval(loadHeatmap,20000);
399
+ window.addEventListener('resize',()=>setTimeout(drawHm,50));
400
+
401
+ (function(){
402
+ try{
403
+ const th=localStorage.getItem('tiq_theme');
404
+ if(th){
405
+ document.documentElement.setAttribute('data-theme',th);
406
+ if(th==='dark'){['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className='fas fa-sun';});}
407
+ }
408
+ const lg=localStorage.getItem('tiq_lang');
409
+ if(lg&&lg!=='en'){
410
+ const btn=document.querySelector('.lb[onclick*="\''+lg+'\'"]');
411
+ if(btn)setLang(lg,btn);
412
+ }
413
+ }catch(e){}
414
+ })();
415
+ </script>
416
+ <script src="/static/trackiq-api.js"></script>
417
+ </body>
418
+ </html>
templates/history.html ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ — History</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
10
+ <style>
11
+ :root{--bg:#f0f4f8;--surface:#fff;--surface2:#f6f8fb;--border:rgba(0,0,0,0.07);--shadow:0 2px 16px rgba(0,0,0,0.09);--shadow-lg:0 8px 36px rgba(0,0,0,0.13);--text:#0f1923;--text2:#5a6a7a;--text3:#9aaab8;--accent:#880000;--accent2:#660000;--accent-light:rgba(136,0,0,0.07);--green:#16a34a;--green-bg:#dcfce7;--sidebar-w:60px;--radius:14px;--radius-sm:9px;}
12
+ [data-theme="dark"]{--bg:#0c1117;--surface:#151c25;--surface2:#1c2531;--border:rgba(255,255,255,0.07);--shadow:0 2px 16px rgba(0,0,0,0.45);--text:#e8edf2;--text2:#7a8fa0;--text3:#3a4a5a;--green-bg:#052e16;}
13
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
14
+ ::-webkit-scrollbar{width:0;}
15
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);font-size:13px;display:flex;min-height:100vh;transition:background 0.3s,color 0.3s;}
16
+
17
+ .sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;align-items:center;padding:16px 0;gap:4px;position:fixed;top:0;left:0;bottom:0;z-index:200;box-shadow:2px 0 12px rgba(0,0,0,0.04);}
18
+ .sb-logo{width:34px;height:34px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:white;font-size:14px;margin-bottom:10px;text-decoration:none;}
19
+ .sb-sep{width:28px;height:1px;background:var(--border);margin:4px 0;}
20
+ .sb-btn{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:15px;cursor:pointer;transition:all 0.2s;text-decoration:none;border:none;background:none;position:relative;}
21
+ .sb-btn:hover{background:var(--surface2);color:var(--text);}
22
+ .sb-btn.active{background:var(--accent-light);color:var(--accent);}
23
+ .sb-btn .tip{position:absolute;left:46px;top:50%;transform:translateY(-50%);background:var(--text);color:var(--surface);font-size:11px;padding:3px 8px;border-radius:5px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity 0.15s;z-index:300;}
24
+ .sb-btn:hover .tip{opacity:1;}
25
+ .sb-spacer{flex:1;}
26
+
27
+ .page-wrap{margin-left:var(--sidebar-w);flex:1;display:flex;flex-direction:column;}
28
+ .topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 22px;position:sticky;top:0;z-index:100;}
29
+ .topbar-l{display:flex;align-items:center;gap:10px;}
30
+ .brand-name{font-size:15px;font-weight:600;} .brand-name b{color:var(--accent);}
31
+ .sep{color:var(--border);font-size:18px;margin:0 2px;}
32
+ .topbar-r{display:flex;align-items:center;gap:7px;}
33
+ .lang-group{display:flex;background:var(--surface2);border-radius:6px;padding:2px;border:1px solid var(--border);}
34
+ .lang-btn{font-size:11px;font-weight:500;padding:3px 8px;border:none;background:none;cursor:pointer;border-radius:4px;color:var(--text2);font-family:'DM Sans',sans-serif;transition:all 0.2s;}
35
+ .lang-btn.active{background:var(--accent);color:white;}
36
+ .icon-btn{width:30px;height:30px;border-radius:6px;border:1px solid var(--border);background:var(--surface);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:12px;}
37
+ .icon-btn:hover{background:var(--surface2);}
38
+
39
+ main{flex:1;padding:18px 20px;max-width:1380px;width:100%;margin:0 auto;display:flex;flex-direction:column;gap:16px;}
40
+
41
+ /* PLAYER CARD — full width */
42
+ .player-card{grid-column:1/3;background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);box-shadow:var(--shadow-lg);overflow:hidden;}
43
+ .player-head{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;}
44
+ .player-title{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px;}
45
+ .player-title i{color:var(--accent);}
46
+ .player-body{position:relative;background:#000;}
47
+ .player-body video{width:100%;display:block;max-height:420px;object-fit:contain;}
48
+ .player-hud{position:absolute;top:11px;left:13px;color:white;font-family:'DM Mono',monospace;font-size:12px;line-height:1.8;text-shadow:1px 1px 4px rgba(0,0,0,0.98);pointer-events:none;}
49
+ .player-empty{padding:80px 20px;text-align:center;color:var(--text3);}
50
+ .player-empty i{font-size:44px;display:block;margin-bottom:12px;}
51
+ .player-empty p{font-size:13px;}
52
+
53
+ /* VIDEO GRID */
54
+ .vid-card{background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);box-shadow:var(--shadow);overflow:hidden;cursor:pointer;transition:all 0.2s;}
55
+ .vid-card:hover{box-shadow:var(--shadow-lg);transform:translateY(-3px);}
56
+ .vid-card.selected{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent);}
57
+ .vid-thumb{position:relative;background:#0a0a0a;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;overflow:hidden;}
58
+ .vid-thumb video{width:100%;height:100%;object-fit:cover;display:block;}
59
+ .vid-thumb-ph{color:rgba(255,255,255,0.3);font-size:32px;}
60
+ .play-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.3);opacity:0;transition:opacity 0.2s;}
61
+ .play-overlay i{font-size:32px;color:white;}
62
+ .vid-card:hover .play-overlay{opacity:1;}
63
+ .vid-info{padding:11px 13px;}
64
+ .vid-name{font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:3px;}
65
+ .vid-meta{font-size:10px;color:var(--text2);display:flex;align-items:center;justify-content:space-between;}
66
+ .vid-meta span{display:flex;align-items:center;gap:4px;}
67
+ .vid-actions{display:flex;gap:6px;margin-top:8px;}
68
+ .btn-vi{flex:1;padding:5px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-size:11px;cursor:pointer;font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;gap:4px;transition:all 0.2s;}
69
+ .btn-vi:hover{background:var(--accent);color:white;border-color:var(--accent);}
70
+ .btn-vi.danger:hover{background:#dc2626;border-color:#dc2626;}
71
+
72
+ /* EMPTY HISTORY */
73
+ .empty-hist{text-align:center;padding:60px 20px;color:var(--text3);}
74
+ .empty-hist i{font-size:48px;display:block;margin-bottom:14px;}
75
+ .empty-hist p{font-size:13px;margin-bottom:16px;}
76
+ .empty-hist a{display:inline-flex;align-items:center;gap:6px;background:var(--accent);color:white;padding:9px 18px;border-radius:9px;text-decoration:none;font-size:12px;}
77
+
78
+ /* BADGE */
79
+ .badge{font-size:10px;font-weight:500;padding:1px 6px;border-radius:4px;}
80
+ .bg{background:var(--green-bg);color:var(--green);}
81
+ </style>
82
+ </head>
83
+ <body>
84
+
85
+ <nav class="sidebar">
86
+ <a href="/" class="sb-logo"><i class="fas fa-satellite-dish"></i></a>
87
+ <div class="sb-sep"></div>
88
+ <a href="/home" class="sb-btn"><i class="fas fa-play-circle"></i><span class="tip" id="tip-analyze">Analyze</span></a>
89
+ <a href="/dashboard" class="sb-btn"><i class="fas fa-chart-line"></i><span class="tip" id="tip-dash">Dashboard</span></a>
90
+ <a href="/logs" class="sb-btn"><i class="fas fa-list-ul"></i><span class="tip" id="tip-logs">Logs</span></a>
91
+ <a href="/history" class="sb-btn active"><i class="fas fa-film"></i><span class="tip" id="tip-hist">History</span></a>
92
+ <div class="sb-spacer"></div>
93
+ <button class="sb-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon"></i><span class="tip">Theme</span></button>
94
+ </nav>
95
+
96
+ <div class="page-wrap">
97
+ <header class="topbar">
98
+ <div class="topbar-l">
99
+ <span class="brand-name">Track<b>IQ</b></span>
100
+ <span class="sep">|</span>
101
+ <span style="font-size:13px;font-weight:500;" id="t-title">Annotated History</span>
102
+ </div>
103
+ <div class="topbar-r">
104
+ <div class="lang-group">
105
+ <button class="lang-btn active" onclick="setLang('en',this)">EN</button>
106
+ <button class="lang-btn" onclick="setLang('fr',this)">FR</button>
107
+ <button class="lang-btn" onclick="setLang('wo',this)">WO</button>
108
+ </div>
109
+ <button class="icon-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon2"></i></button>
110
+ </div>
111
+ </header>
112
+
113
+ <main id="mainGrid">
114
+
115
+ <!-- Videos grid -->
116
+ <div id="videoGrid" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;"></div>
117
+
118
+ </main>
119
+ </div>
120
+
121
+ <script>
122
+ /* THEME */
123
+ function toggleTheme(){
124
+ const d=document.documentElement,dark=d.getAttribute('data-theme')==='dark';
125
+ d.setAttribute('data-theme',dark?'light':'dark');
126
+ ['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className=dark?'fas fa-moon':'fas fa-sun';});
127
+ try{localStorage.setItem('tiq_theme',dark?'light':'dark');}catch(e){}
128
+ }
129
+
130
+ /* LANG */
131
+ const T={
132
+ en:{title:'Annotated History',player:'Video player',dl:'Download','select-vid':'Select a video below to play it',tipAnalyze:'Analyze',tipDash:'Dashboard',tipLogs:'Logs',tipHist:'History',reanalyze:'Re-analyze',download:'Download',delete:'Delete',empty:'No annotated videos yet.',goAnalyze:'Go to Analyze'},
133
+ fr:{title:'Historique annoté',player:'Lecteur vidéo',dl:'Télécharger','select-vid':'Sélectionnez une vidéo ci-dessous',tipAnalyze:'Analyser',tipDash:'Tableau de bord',tipLogs:'Logs',tipHist:'Historique',reanalyze:'Ré-analyser',download:'Télécharger',delete:'Supprimer',empty:'Aucune vidéo annotée.',goAnalyze:'Aller sur Analyser'},
134
+ wo:{title:'Màgg yi',player:'Video bi',dl:'Télécharger','select-vid':'Tànn video ci suuf',tipAnalyze:'Jàng',tipDash:'Dashboard',tipLogs:'Logs',tipHist:'Màgg',reanalyze:'Wërtëk',download:'Télécharger',delete:'Bañ',empty:'Amul video.',goAnalyze:'Dem Analyser'}
135
+ };
136
+ let curLang='en';
137
+ function setLang(l,btn){
138
+ curLang=l;
139
+ document.querySelectorAll('.lang-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');
140
+ const t=T[l];
141
+ Object.entries({title:'t-title',player:'t-player',dl:'t-dl','select-vid':'t-select-vid',tipAnalyze:'tip-analyze',tipDash:'tip-dash',tipLogs:'tip-logs',tipHist:'tip-hist'}).forEach(([k,id])=>{const el=document.getElementById(id);if(el&&t[k])el.textContent=t[k];});
142
+ renderGrid();
143
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
144
+ }
145
+
146
+ /* HISTORY DATA */
147
+ function getHistory(){try{return JSON.parse(localStorage.getItem('tiq_history')||'[]');}catch(e){return[];}}
148
+ function saveHistory(h){try{localStorage.setItem('tiq_history',JSON.stringify(h));}catch(e){}}
149
+
150
+ /* FORMAT DATE */
151
+ function fmtDate(iso){
152
+ try{const d=new Date(iso);return d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})+' '+d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'});}catch(e){return 'Unknown date';}
153
+ }
154
+
155
+ let activeUrl=null,activeIdx=null;
156
+
157
+ function dlVideo(url,name){
158
+ const a=document.createElement('a');a.href=url;
159
+ a.download=name||'trackiq_video.mp4';a.click();
160
+ }
161
+
162
+ function playVideo(url,name,idx){
163
+ activeUrl=url;activeIdx=idx;
164
+ const op=document.getElementById('overlayPlayer');
165
+ op.src=url;op.play().catch(()=>{});
166
+ document.getElementById('overlayName').textContent=name;
167
+ document.getElementById('vidOverlay').style.display='flex';
168
+ }
169
+ function closeOverlay(){
170
+ const op=document.getElementById('overlayPlayer');op.pause();op.src='';
171
+ document.getElementById('vidOverlay').style.display='none';
172
+ activeUrl=null;activeIdx=null;
173
+ }
174
+ function dlOverlay(){if(activeUrl){const a=document.createElement('a');a.href=activeUrl;a.download='trackiq.mp4';a.click();}}
175
+
176
+ function dlActive(){
177
+ if(!activeUrl)return;
178
+ const a=document.createElement('a');a.href=activeUrl;a.download='trackiq_annotated.mp4';a.click();
179
+ }
180
+
181
+ function deleteVideo(idx){
182
+ const h=getHistory();h.splice(idx,1);saveHistory(h);
183
+ if(activeIdx===idx){
184
+ closeModal();
185
+ activeUrl=null;activeIdx=null;
186
+ }
187
+ renderGrid();
188
+ }
189
+
190
+ function renderGrid(){
191
+ const hist=getHistory();
192
+ const t=T[curLang];
193
+ const grid=document.getElementById('videoGrid');grid.innerHTML='';
194
+
195
+ if(!hist.length){
196
+ const empty=document.createElement('div');empty.className='empty-hist';
197
+ empty.innerHTML=`<i class="fas fa-film"></i><p>${t.empty}</p><a href="/home">${t.goAnalyze} <i class="fas fa-arrow-right"></i></a>`;
198
+ grid.appendChild(empty);return;
199
+ }
200
+
201
+ hist.forEach((item,i)=>{
202
+ const card=document.createElement('div');card.className='vid-card'+(activeIdx===i?' selected':'');
203
+ const isBlob=item.url&&item.url.startsWith('blob:');
204
+ const isBackend=item.url&&(item.url.startsWith('/api/')||item.url.startsWith('http'));
205
+ const canPlay=isBlob||isBackend;
206
+ card.innerHTML=`
207
+ <div class="vid-thumb">
208
+ ${isBlob
209
+ ? `<video src="${item.url}" style="width:100%;height:100%;object-fit:cover;" muted preload="metadata" onloadeddata="this.currentTime=1"></video>`
210
+ : `<div class="vid-thumb-ph" style="background:#0a0e15;display:flex;align-items:center;justify-content:center;height:100%;">
211
+ <i class="fas fa-film" style="font-size:32px;color:rgba(255,255,255,0.2);"></i>
212
+ </div>`
213
+ }
214
+ <div class="play-overlay"><i class="fas fa-play"></i></div>
215
+ </div>
216
+ <div class="vid-info">
217
+ <div class="vid-name" title="${item.name}">${item.name||'Unnamed'}</div>
218
+ <div class="vid-meta">
219
+ <span><i class="fas fa-calendar" style="font-size:9px;"></i> ${fmtDate(item.date)}</span>
220
+ <span class="badge bg">${isBlob?'Local':'Server'}</span>
221
+ </div>
222
+ <div class="vid-actions">
223
+ <button class="btn-vi" onclick="event.stopPropagation();playVideo('${item.url}','${item.name}',${i})">
224
+ <i class="fas fa-play"></i> Play
225
+ </button>
226
+ <button class="btn-vi" onclick="event.stopPropagation();dlVideo('${item.url}','${item.name}')">
227
+ <i class="fas fa-download"></i>
228
+ </button>
229
+ <button class="btn-vi danger" onclick="event.stopPropagation();deleteVideo(${i})">
230
+ <i class="fas fa-trash"></i>
231
+ </button>
232
+ </div>
233
+ </div>
234
+ `;
235
+ card.onclick=(ev)=>{if(!ev.target.closest('.btn-vi'))playVideo(item.url,item.name,i);};
236
+ grid.appendChild(card);
237
+ });
238
+ }
239
+
240
+ /* HUD ticker on player */
241
+ setInterval(()=>{
242
+ if(!activeUrl)return;
243
+ const f=570+Math.floor(Math.random()*80),s=295+Math.floor(Math.random()*40);
244
+ const hf=document.getElementById('oHudF');if(hf)hf.textContent=`F: = ${f} ▶ ${s}`;
245
+ const car=2+Math.floor(Math.random()*6),van=Math.floor(Math.random()*3),bike=Math.floor(Math.random()*6),p=Math.floor(Math.random()*7);
246
+ const hc=document.getElementById('oHudC');if(hc)hc.innerHTML=`Car = ${car}<br>Van = ${van}<br>bike = ${bike}<br>person = ${p}`;
247
+ },2200);
248
+
249
+ document.addEventListener('keydown',e=>{if(e.key==='Escape')closeOverlay();});
250
+ /* INIT */
251
+ (function(){
252
+ try{
253
+ const th=localStorage.getItem('tiq_theme');if(th){document.documentElement.setAttribute('data-theme',th);if(th==='dark'){['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className='fas fa-sun';});}}
254
+ const lg=localStorage.getItem('tiq_lang');if(lg&&lg!=='en'){const btn=document.querySelector(`.lang-btn[onclick*="'${lg}'"]`);if(btn)setLang(lg,btn);}
255
+ }catch(e){}
256
+ renderGrid();
257
+ })();
258
+ </script>
259
+ <script src="/static/trackiq-api.js"></script>
260
+
261
+ <!-- ── Modal lecteur vidéo ── -->
262
+ <div id="vidOverlay" style="display:none;position:fixed;inset:0;z-index:1000;
263
+ background:rgba(0,0,0,0.88);backdrop-filter:blur(8px);
264
+ align-items:center;justify-content:center;flex-direction:column;gap:12px;">
265
+
266
+ <!-- Header -->
267
+ <div style="display:flex;align-items:center;justify-content:space-between;
268
+ width:min(90vw,860px);padding:0 4px;">
269
+ <span id="overlayName" style="color:#fff;font-size:13px;font-weight:500;
270
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:70%;"></span>
271
+ <div style="display:flex;gap:8px;">
272
+ <button onclick="dlOverlay()" style="background:rgba(255,255,255,0.12);color:#fff;
273
+ border:none;border-radius:7px;padding:6px 13px;font-size:12px;cursor:pointer;
274
+ font-family:'DM Sans',sans-serif;display:flex;align-items:center;gap:5px;">
275
+ <i class="fas fa-download"></i> Download
276
+ </button>
277
+ <button onclick="closeOverlay()" style="background:rgba(220,38,38,0.7);color:#fff;
278
+ border:none;border-radius:7px;padding:6px 13px;font-size:12px;cursor:pointer;
279
+ font-family:'DM Sans',sans-serif;display:flex;align-items:center;gap:5px;">
280
+ <i class="fas fa-times"></i> Close
281
+ </button>
282
+ </div>
283
+ </div>
284
+
285
+ <!-- Vidéo -->
286
+ <video id="overlayPlayer" controls autoplay
287
+ style="width:min(90vw,860px);max-height:75vh;border-radius:10px;
288
+ background:#000;object-fit:contain;box-shadow:0 20px 60px rgba(0,0,0,0.6);">
289
+ </video>
290
+
291
+ <!-- Hint ESC -->
292
+ <span style="color:rgba(255,255,255,0.3);font-size:10px;">Press ESC to close</span>
293
+ </div>
294
+ </body>
295
+ </html>
templates/home.html ADDED
@@ -0,0 +1,684 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ — Analyze</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <style>
10
+ :root{--bg:#f0f4f8;--sur:#fff;--sur2:#f6f8fb;--bd:rgba(0,0,0,0.07);--sh:0 2px 16px rgba(0,0,0,0.09);--shlg:0 8px 36px rgba(0,0,0,0.13);--t:#0f1923;--t2:#5a6a7a;--t3:#9aaab8;--ac:#880000;--ac2:#660000;--acl:rgba(136,0,0,0.07);--gr:#16a34a;--sw:60px;--r:14px;--rs:9px;}
11
+ [data-theme="dark"]{--bg:#0c1117;--sur:#151c25;--sur2:#1c2531;--bd:rgba(255,255,255,0.07);--sh:0 2px 16px rgba(0,0,0,0.45);--t:#e8edf2;--t2:#7a8fa0;--t3:#3a4a5a;}
12
+ *{box-sizing:border-box;margin:0;padding:0;}::-webkit-scrollbar{width:0;}
13
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--t);font-size:13px;display:flex;min-height:100vh;}
14
+
15
+ /* Sidebar */
16
+ .sb{width:var(--sw);background:var(--sur);border-right:1px solid var(--bd);display:flex;flex-direction:column;align-items:center;padding:14px 0;gap:4px;position:fixed;top:0;left:0;bottom:0;z-index:200;}
17
+ .sbl{width:34px;height:34px;background:var(--ac);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;margin-bottom:10px;text-decoration:none;}
18
+ .sbsep{width:26px;height:1px;background:var(--bd);margin:3px 0;}
19
+ .sbb{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:14px;cursor:pointer;transition:all 0.2s;text-decoration:none;border:none;background:none;position:relative;}
20
+ .sbb:hover{background:var(--sur2);color:var(--t);}.sbb.on{background:var(--acl);color:var(--ac);}
21
+ .sbb .tip{position:absolute;left:44px;top:50%;transform:translateY(-50%);background:var(--t);color:var(--sur);font-size:10px;padding:3px 7px;border-radius:4px;white-space:nowrap;opacity:0;pointer-events:none;z-index:300;}.sbb:hover .tip{opacity:1;}
22
+ .sbsp{flex:1;}
23
+
24
+ /* Layout */
25
+ .pw{margin-left:var(--sw);flex:1;display:flex;flex-direction:column;}
26
+ .topb{height:50px;background:var(--sur);border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100;}
27
+ .topl{display:flex;align-items:center;gap:9px;}
28
+ .bn{font-size:14px;font-weight:600;}.bn b{color:var(--ac);}
29
+ .topr{display:flex;align-items:center;gap:6px;}
30
+ .lg{display:flex;background:var(--sur2);border-radius:5px;padding:2px;border:1px solid var(--bd);}
31
+ .lb{font-size:10px;font-weight:500;padding:2px 8px;border:none;background:none;cursor:pointer;border-radius:3px;color:var(--t2);font-family:'DM Sans',sans-serif;transition:all 0.2s;}.lb.on{background:var(--ac);color:#fff;}
32
+ .ib{width:28px;height:28px;border-radius:5px;border:1px solid var(--bd);background:var(--sur);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--t2);font-size:11px;}
33
+ .ib:hover{background:var(--sur2);}
34
+
35
+ main{flex:1;padding:14px 16px;display:grid;grid-template-columns:340px 1fr;gap:12px;max-width:1400px;width:100%;margin:0 auto;}
36
+
37
+ /* Card */
38
+ .card{background:var(--sur);border-radius:var(--r);border:1px solid var(--bd);box-shadow:var(--sh);padding:14px 16px;}
39
+ .ct{font-size:12px;font-weight:500;margin-bottom:11px;display:flex;align-items:center;gap:6px;}
40
+ .ct i{color:var(--ac);}
41
+
42
+ /* Source tabs */
43
+ .stabs{display:flex;gap:3px;background:var(--sur2);border-radius:7px;padding:3px;border:1px solid var(--bd);margin-bottom:11px;}
44
+ .stab{flex:1;padding:6px 4px;border-radius:5px;font-size:11px;cursor:pointer;color:var(--t2);border:none;background:none;font-family:'DM Sans',sans-serif;transition:all 0.2s;display:flex;align-items:center;justify-content:center;gap:4px;}
45
+ .stab.on{background:var(--sur);color:var(--t);box-shadow:0 1px 4px rgba(0,0,0,0.1);font-weight:500;}
46
+
47
+ /* Upload zone */
48
+ .uzone{border:2px dashed var(--bd);border-radius:var(--rs);min-height:110px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;transition:all 0.2s;padding:14px;position:relative;overflow:hidden;}
49
+ .uzone:hover,.uzone.drag{border-color:var(--ac);background:var(--acl);}
50
+ .uzone.has{border-style:solid;border-color:var(--ac);}
51
+ .uzone i{font-size:22px;color:var(--t3);}
52
+ .uzone.has i{color:var(--ac);}
53
+ .uzone p{font-size:11px;color:var(--t2);text-align:center;}
54
+ .uzone input{position:absolute;inset:0;opacity:0;cursor:pointer;}
55
+ .fname{font-size:11px;font-weight:500;color:var(--ac);display:none;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
56
+ .prog{display:none;background:var(--sur2);border-radius:3px;height:4px;margin-top:7px;overflow:hidden;}
57
+ .progb{height:100%;background:var(--ac);width:0%;transition:width 0.3s;}
58
+ .progt{font-size:10px;color:var(--t2);margin-top:4px;display:none;}
59
+
60
+ /* URL */
61
+ .urow{display:flex;gap:6px;}
62
+ .uin{flex:1;padding:7px 9px;border:1px solid var(--bd);border-radius:7px;font-size:12px;background:var(--sur2);color:var(--t);font-family:'DM Sans',sans-serif;outline:none;}
63
+ .uin:focus{border-color:var(--ac);}
64
+ .uin::placeholder{color:var(--t3);}
65
+ .bsm{padding:7px 12px;background:var(--ac);color:#fff;border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif;}
66
+
67
+ /* Webcam */
68
+ .wcbox{background:#000;border-radius:8px;overflow:hidden;aspect-ratio:16/9;position:relative;margin-bottom:10px;}
69
+ .wcbox video,.wcbox img{width:100%;height:100%;object-fit:cover;display:block;}
70
+ .wcph{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:rgba(255,255,255,0.3);font-size:11px;}
71
+ .wcph i{font-size:22px;}
72
+ .recbdg{position:absolute;top:7px;left:8px;display:none;align-items:center;gap:4px;background:rgba(220,38,38,0.85);color:#fff;font-size:10px;padding:2px 8px;border-radius:10px;}
73
+ @keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}}
74
+ .recbdg i{font-size:8px;animation:blink 1s infinite;}
75
+
76
+ /* Classes */
77
+ .clshd{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;}
78
+ .clshd span{font-size:11px;color:var(--t2);}
79
+ .clshd button{font-size:11px;color:var(--ac);background:none;border:none;cursor:pointer;font-family:'DM Sans',sans-serif;}
80
+ .clsg{display:grid;grid-template-columns:1fr 1fr;gap:5px;}
81
+ .clsi{display:flex;align-items:center;gap:5px;padding:5px 8px;border-radius:6px;border:1px solid var(--bd);cursor:pointer;transition:all 0.2s;font-size:11px;background:var(--sur2);}
82
+ .clsi:hover{border-color:var(--ac);}.clsi.on{border-color:var(--ac);background:var(--acl);}
83
+ .cldot{width:8px;height:8px;border-radius:3px;flex-shrink:0;}
84
+
85
+ /* Buttons */
86
+ .runbtn{width:100%;padding:10px;background:var(--ac);color:#fff;border:none;border-radius:9px;font-size:13px;font-weight:500;cursor:pointer;font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;gap:7px;transition:all 0.2s;margin-top:4px;}
87
+ .runbtn:hover{background:var(--ac2);}.runbtn:disabled{opacity:0.4;cursor:not-allowed;}
88
+ .rstbtn{width:100%;padding:8px;background:var(--sur2);color:var(--t2);border:1px solid var(--bd);border-radius:9px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;gap:6px;transition:all 0.2s;}
89
+ .rstbtn:hover{border-color:var(--ac);color:var(--ac);}
90
+
91
+ /* Right col */
92
+ .rc{display:flex;flex-direction:column;gap:11px;}
93
+
94
+ /* Big panel */
95
+ .bp{background:var(--sur);border-radius:var(--r);border:1px solid var(--bd);box-shadow:var(--shlg);overflow:hidden;}
96
+ .bph{padding:10px 14px;border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;}
97
+ .bpht{font-size:12px;font-weight:500;display:flex;align-items:center;gap:6px;}
98
+ .bpht i{color:var(--ac);}
99
+ .bpb{position:relative;background:#080e15;height:420px;}
100
+
101
+ /* Video dans le panel */
102
+ .bpb video,.bpb img#wcYoloImg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:none;}
103
+
104
+ /* HUD top-left */
105
+ .hud{position:absolute;top:10px;left:11px;color:#fff;font-family:'DM Mono',monospace;font-size:11px;line-height:1.85;text-shadow:1px 1px 4px rgba(0,0,0,0.98);pointer-events:none;display:none;z-index:4;}
106
+ .hudf{font-size:9px;color:rgba(255,255,255,0.4);margin-bottom:2px;}
107
+
108
+ /* Count overlay top-right */
109
+ .cov{position:absolute;top:10px;right:11px;display:none;flex-direction:column;gap:4px;pointer-events:none;z-index:4;}
110
+ .cbdg{display:flex;flex-direction:column;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);border-left:3px solid transparent;border-radius:5px;padding:4px 9px;min-width:115px;}
111
+ .cbdg .cn{font-size:13px;font-weight:600;line-height:1.2;}
112
+ .cbdg .cu{font-size:9px;color:#ccc;line-height:1.2;}
113
+
114
+ /* No-object banner */
115
+ .noobj{display:none;position:absolute;bottom:14px;left:50%;transform:translateX(-50%);z-index:5;
116
+ background:rgba(220,38,38,0.9);color:#fff;font-size:12px;font-weight:500;
117
+ padding:6px 16px;border-radius:20px;align-items:center;gap:7px;white-space:nowrap;}
118
+
119
+ /* Empty state */
120
+ .es{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;color:rgba(255,255,255,0.18);}
121
+ .es i{font-size:38px;}.es p{font-size:12px;}
122
+
123
+ /* Stats row */
124
+ .sr{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;}
125
+ .sc{background:var(--sur);border-radius:var(--rs);border:1px solid var(--bd);box-shadow:var(--sh);padding:10px 12px;}
126
+ .sc .sl{font-size:10px;color:var(--t2);margin-bottom:3px;}
127
+ .sc .sv{font-size:18px;font-weight:300;letter-spacing:-0.5px;}
128
+ .sc .su{font-size:10px;color:var(--t2);}
129
+
130
+ /* Download bar */
131
+ .dlb{display:flex;align-items:center;justify-content:space-between;background:var(--sur);border-radius:var(--rs);border:1px solid var(--bd);box-shadow:var(--sh);padding:9px 13px;}
132
+ .dlb span{font-size:11px;color:var(--t2);}
133
+ .btndl{display:flex;align-items:center;gap:5px;background:var(--ac);color:#fff;border:none;border-radius:7px;padding:7px 13px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif;}
134
+ .btndl:hover{background:var(--ac2);}.btndl:disabled{opacity:0.4;cursor:not-allowed;background:var(--t3);}
135
+ </style>
136
+ </head>
137
+ <body>
138
+
139
+ <nav class="sb">
140
+ <a href="/" class="sbl"><i class="fas fa-satellite-dish"></i></a>
141
+ <div class="sbsep"></div>
142
+ <a href="/home" class="sbb on"><i class="fas fa-play-circle"></i><span class="tip">Analyze</span></a>
143
+ <a href="/dashboard" class="sbb"><i class="fas fa-chart-line"></i><span class="tip">Dashboard</span></a>
144
+ <a href="/logs" class="sbb"><i class="fas fa-list-ul"></i><span class="tip">Logs</span></a>
145
+ <a href="/history" class="sbb"><i class="fas fa-film"></i><span class="tip">History</span></a>
146
+ <div class="sbsp"></div>
147
+ <button class="sbb" onclick="toggleTheme()"><i class="fas fa-moon" id="ti"></i><span class="tip">Theme</span></button>
148
+ </nav>
149
+
150
+ <div class="pw">
151
+ <header class="topb">
152
+ <div class="topl">
153
+ <span class="bn">Track<b>IQ</b></span>
154
+ <span style="color:var(--bd);font-size:16px;">|</span>
155
+ <span style="font-size:12px;font-weight:500;color:var(--t2);">Video Analysis</span>
156
+ </div>
157
+ <div class="topr">
158
+ <div class="lg">
159
+ <button class="lb on" onclick="setLang('en',this)">EN</button>
160
+ <button class="lb" onclick="setLang('fr',this)">FR</button>
161
+ <button class="lb" onclick="setLang('wo',this)">WO</button>
162
+ </div>
163
+ <button class="ib" onclick="toggleTheme()"><i class="fas fa-moon" id="ti2"></i></button>
164
+ </div>
165
+ </header>
166
+
167
+ <main>
168
+ <!-- Colonne gauche -->
169
+ <div style="display:flex;flex-direction:column;gap:11px;">
170
+
171
+ <!-- Input source -->
172
+ <div class="card">
173
+ <div class="ct"><i class="fas fa-video"></i> Input source</div>
174
+ <div class="stabs">
175
+ <button class="stab on" onclick="switchSrc('upload',this)"><i class="fas fa-upload"></i> Video</button>
176
+ <button class="stab" onclick="switchSrc('url',this)"><i class="fas fa-link"></i> URL</button>
177
+ <button class="stab" onclick="switchSrc('webcam',this)"><i class="fas fa-camera"></i> Webcam</button>
178
+ </div>
179
+
180
+ <!-- Upload -->
181
+ <div id="src-upload">
182
+ <label class="uzone" id="uzone">
183
+ <i class="fas fa-cloud-upload-alt"></i>
184
+ <p>Drop video here or click to browse</p>
185
+ <p style="font-size:10px;color:var(--t3);">MP4, AVI, MOV — max 500 MB</p>
186
+ <span class="fname" id="fname"></span>
187
+ <input type="file" id="fileInput" accept="video/*" onchange="onFile(event)">
188
+ </label>
189
+ <div class="prog" id="progWrap"><div class="progb" id="progBar"></div></div>
190
+ <div class="progt" id="progTxt"></div>
191
+ </div>
192
+
193
+ <!-- URL -->
194
+ <div id="src-url" style="display:none;">
195
+ <div class="urow">
196
+ <input type="text" class="uin" id="urlIn" placeholder="https://… or rtsp://…">
197
+ <button class="bsm" onclick="loadUrl()">Load</button>
198
+ </div>
199
+ </div>
200
+
201
+ <!-- Webcam -->
202
+ <div id="src-webcam" style="display:none;">
203
+ <!-- Mini preview : flux YOLO annoté -->
204
+ <div class="wcbox" id="wcbox">
205
+ <img id="wcMiniYolo" alt="YOLO stream" style="display:none;">
206
+ <div class="wcph" id="wcPh"><i class="fas fa-camera-slash"></i><span>Click Start</span></div>
207
+ <div class="recbdg" id="recBadge"><i class="fas fa-circle"></i> LIVE</div>
208
+ </div>
209
+ <!-- Bouton stop webcam seulement (plus de bouton Enregistrer) -->
210
+ <button class="runbtn" id="wcBtn" onclick="startWc()">
211
+ <i class="fas fa-circle" style="color:#f87171;font-size:8px;"></i> Start webcam
212
+ </button>
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Detection classes -->
217
+ <div class="card">
218
+ <div class="ct"><i class="fas fa-tags"></i> Detection classes</div>
219
+ <div class="clshd">
220
+ <span id="clsCnt">0 selected</span>
221
+ <button onclick="toggleAll()">Select all</button>
222
+ </div>
223
+ <div class="clsg" id="clsGrid"></div>
224
+ </div>
225
+
226
+ <button class="runbtn" id="runBtn" onclick="runAnalysis()" disabled>
227
+ <i class="fas fa-play"></i> Run Analysis
228
+ </button>
229
+ <button class="rstbtn" onclick="resetAll()">
230
+ <i class="fas fa-rotate-right"></i> Reset
231
+ </button>
232
+ </div>
233
+
234
+ <!-- Colonne droite -->
235
+ <div class="rc">
236
+
237
+ <!-- Big result panel -->
238
+ <div class="bp">
239
+ <div class="bph">
240
+ <div class="bpht"><i class="fas fa-film"></i> Result</div>
241
+ <span style="font-size:11px;color:var(--t2);" id="srcLabel"></span>
242
+ </div>
243
+ <div class="bpb" id="bigBody">
244
+ <!-- Vidéo annotée (upload/url) -->
245
+ <video id="resultVid" controls></video>
246
+ <!-- Flux YOLO webcam (grand) -->
247
+ <img id="wcYoloImg" alt="YOLO stream">
248
+ <!-- HUD top-left -->
249
+ <div class="hud" id="hudEl">
250
+ <div class="hudf" id="hudFr">—</div>
251
+ <div id="hudCo">—</div>
252
+ </div>
253
+ <!-- Compteurs top-right -->
254
+ <div class="cov" id="cOverlay"></div>
255
+ <!-- Banner no-object -->
256
+ <div class="noobj" id="noObjBanner">
257
+ <i class="fas fa-eye-slash"></i> No object detected
258
+ </div>
259
+ <!-- Canvas caché pour enregistrement webcam -->
260
+ <canvas id="wcCanvas" style="display:none;"></canvas>
261
+ <!-- Empty state -->
262
+ <div class="es" id="emptyState">
263
+ <i class="fas fa-film"></i>
264
+ <p>Result will appear here</p>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ <!-- Stats -->
270
+ <div class="sr">
271
+ <div class="sc"><div class="sl">Frames</div><div class="sv" id="stF">—</div></div>
272
+ <div class="sc"><div class="sl">Objects</div><div class="sv" id="stO">—</div></div>
273
+ <div class="sc"><div class="sl">FPS</div><div class="sv" id="stFps">—</div></div>
274
+ <div class="sc"><div class="sl">Duration</div><div class="sv" id="stD">—</div></div>
275
+ </div>
276
+
277
+ <!-- Download -->
278
+ <div class="dlb">
279
+ <span id="dlName">No result yet</span>
280
+ <button class="btndl" id="dlBtn" disabled onclick="doDownload()">
281
+ <i class="fas fa-download"></i> Download
282
+ </button>
283
+ </div>
284
+
285
+ </div>
286
+ </main>
287
+ </div>
288
+
289
+ <script>
290
+ /* ── Theme ── */
291
+ function toggleTheme(){
292
+ const d=document.documentElement,dk=d.getAttribute('data-theme')==='dark';
293
+ d.setAttribute('data-theme',dk?'light':'dark');
294
+ ['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className=dk?'fas fa-moon':'fas fa-sun';});
295
+ try{localStorage.setItem('tiq_theme',dk?'light':'dark');}catch(e){}
296
+ }
297
+ function setLang(l,btn){
298
+ document.querySelectorAll('.lb').forEach(b=>b.classList.remove('on'));btn.classList.add('on');
299
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
300
+ }
301
+
302
+ /* ── Classes ── */
303
+ const CLASSES=[
304
+ {name:'Vehicle',color:'#3b82f6'},{name:'Motorcycle',color:'#10b981'},
305
+ {name:'Truck',color:'#f59e0b'},{name:'Bus',color:'#8b5cf6'},
306
+ {name:'Human',color:'#ec4899'},{name:'Bicycle',color:'#06b6d4'},
307
+ {name:'Traffic light',color:'#ef4444'},{name:'Road sign',color:'#f97316'},
308
+ ];
309
+ let sel=new Set(['Human']);
310
+
311
+ function buildCls(){
312
+ const g=document.getElementById('clsGrid'); g.innerHTML='';
313
+ CLASSES.forEach(c=>{
314
+ const d=document.createElement('div');
315
+ d.className='clsi'+(sel.has(c.name)?' on':'');
316
+ d.innerHTML=`<span class="cldot" style="background:${c.color}"></span>${c.name}`;
317
+ d.onclick=()=>{sel.has(c.name)?sel.delete(c.name):sel.add(c.name);d.classList.toggle('on');upCls();};
318
+ g.appendChild(d);
319
+ });
320
+ upCls();
321
+ }
322
+ function toggleAll(){sel.size===CLASSES.length?sel.clear():CLASSES.forEach(c=>sel.add(c.name));buildCls();}
323
+ function upCls(){document.getElementById('clsCnt').textContent=sel.size+' selected';}
324
+ buildCls();
325
+
326
+ /* ── State ── */
327
+ let curSrc='upload', vidFile=null, vidUrl=null, wcStream=null;
328
+ let _jobId=null, _sse=null, _wcPollIv=null, _annotatedUrl=null;
329
+
330
+ /* ── Source switch ── */
331
+ function switchSrc(s,btn){
332
+ curSrc=s;
333
+ document.querySelectorAll('.stab').forEach(b=>b.classList.remove('on'));btn.classList.add('on');
334
+ ['upload','url','webcam'].forEach(id=>{
335
+ const el=document.getElementById('src-'+id);if(el)el.style.display='none';
336
+ });
337
+ document.getElementById('src-'+s).style.display='block';
338
+ checkReady();
339
+ }
340
+
341
+ /* ── File upload ── */
342
+ function onFile(e){
343
+ const f=e.target.files[0];if(!f)return;
344
+ vidFile=f;
345
+ const fn=document.getElementById('fname');fn.textContent=f.name;fn.style.display='block';
346
+ document.getElementById('uzone').classList.add('has');
347
+ checkReady();
348
+ }
349
+ function loadUrl(){
350
+ const u=document.getElementById('urlIn').value.trim();if(!u)return;
351
+ vidUrl=u;checkReady();
352
+ }
353
+
354
+ /* ── Run Analysis ── */
355
+ window.runAnalysis=async function(){
356
+ const btn=document.getElementById('runBtn');
357
+ btn.disabled=true;
358
+ btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> Processing…';
359
+ document.getElementById('progWrap').style.display='block';
360
+ document.getElementById('progTxt').style.display='block';
361
+ document.getElementById('progTxt').textContent='Uploading…';
362
+ document.getElementById('progBar').style.width='5%';
363
+ document.getElementById('emptyState').style.display='none';
364
+
365
+ /* Upload si pas encore fait */
366
+ if(!_jobId){
367
+ if(!vidFile&&!vidUrl){
368
+ btn.disabled=false;btn.innerHTML='<i class="fas fa-play"></i> Run Analysis';return;
369
+ }
370
+ const fd=new FormData();
371
+ if(vidFile) fd.append('video',vidFile);
372
+ try{
373
+ const r=await fetch('/api/upload',{method:'POST',body:fd});
374
+ const d=await r.json();
375
+ _jobId=d.job_id;
376
+ }catch(err){
377
+ console.error('[TrackIQ] Upload:',err);
378
+ btn.disabled=false;btn.innerHTML='<i class="fas fa-play"></i> Run Analysis';return;
379
+ }
380
+ }
381
+
382
+ document.getElementById('progTxt').textContent='Starting detection…';
383
+ document.getElementById('progBar').style.width='10%';
384
+
385
+ try{
386
+ await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},
387
+ body:JSON.stringify({job_id:_jobId,classes:[...sel],model:'yolo11n'})});
388
+ }catch(err){console.error('[TrackIQ] Run:',err);return;}
389
+
390
+ /* SSE */
391
+ if(_sse)_sse.close();
392
+ _sse=new EventSource(`/api/stream/${_jobId}`);
393
+ _sse.onmessage=(e)=>{
394
+ let d;try{d=JSON.parse(e.data);}catch{return;}
395
+
396
+ if(d.event==='done'){
397
+ _sse.close();
398
+ _showBanner(false);
399
+ document.getElementById('progBar').style.width='100%';
400
+ document.getElementById('progTxt').textContent='Done!';
401
+ btn.disabled=false;btn.innerHTML='<i class="fas fa-play"></i> Run Analysis';
402
+
403
+ /* ✅ Affiche la vidéo annotée */
404
+ const rv=document.getElementById('resultVid');
405
+ rv.src=`/api/video/${_jobId}`;
406
+ rv.style.display='block';
407
+ rv.load();
408
+ document.getElementById('srcLabel').textContent=vidFile?vidFile.name:'video';
409
+ document.getElementById('hudEl').style.display='block';
410
+ document.getElementById('cOverlay').style.display='flex';
411
+ _annotatedUrl=`/api/video/${_jobId}`;
412
+
413
+ /* Stats */
414
+ const s=d.stats||{};
415
+ _set('stF',s.total_frames??'—');_set('stO',s.total_unique??'—');
416
+ _set('stFps',s.fps?s.fps.toFixed(1):'—');_set('stD',s.duration_s?s.duration_s+'s':'—');
417
+ _showCounts(s.unique_counts||{});
418
+ _set('dlName',vidFile?vidFile.name:'annotated.mp4');
419
+ document.getElementById('dlBtn').removeAttribute('disabled');
420
+
421
+ /* Sauvegarde localStorage */
422
+ try{
423
+ localStorage.setItem('tiq_job_id',_jobId);
424
+ localStorage.setItem('tiq_video_url',`/api/video/${_jobId}`);
425
+ localStorage.setItem('tiq_video_name',s.video_name||'');
426
+ localStorage.setItem('tiq_stats',JSON.stringify(s));
427
+ }catch(ex){}
428
+ return;
429
+ }
430
+
431
+ /* Progression */
432
+ if(d.pct!=null){
433
+ document.getElementById('progBar').style.width=d.pct+'%';
434
+ document.getElementById('progTxt').textContent=`Processing… ${d.pct.toFixed(0)}%`;
435
+ }
436
+ _showBanner(d.no_objects===true);
437
+ if(d.hud){
438
+ _set('hudFr',d.hud.frame_str);
439
+ const hco=document.getElementById('hudCo');
440
+ if(hco) hco.innerHTML=Object.entries(d.hud.counts||{})
441
+ .map(([k,v])=>`${k} = ${v}`).join('<br>')||'No object';
442
+ }
443
+ if(d.unique_counts) _showCounts(d.unique_counts);
444
+ };
445
+ _sse.onerror=()=>_sse.close();
446
+ };
447
+
448
+ /* ── Webcam ── */
449
+ let _wcFrameIv=null, _wcHiddenVideo=null, _wcCanvas2=null;
450
+
451
+ async function startWc(){
452
+ try{
453
+ wcStream=await navigator.mediaDevices.getUserMedia({video:{width:640,height:480},audio:false});
454
+ }catch(e){alert('Webcam unavailable: '+e.message);return;}
455
+
456
+ document.getElementById('wcPh').style.display='none';
457
+ document.getElementById('recBadge').style.display='flex';
458
+ document.getElementById('runBtn').style.display='none';
459
+
460
+ const btn=document.getElementById('wcBtn');
461
+ btn.innerHTML='<i class="fas fa-stop" style="color:#f87171;font-size:9px;"></i> Stop webcam';
462
+ btn.onclick=stopWc;
463
+
464
+ /* Lance YOLO backend */
465
+ await fetch('/api/webcam/start',{method:'POST',headers:{'Content-Type':'application/json'},
466
+ body:JSON.stringify({classes:[...sel],model:'yolo11n'})}).catch(()=>{});
467
+
468
+ /* Setup hidden video + canvas pour capturer frames */
469
+ _wcHiddenVideo=document.createElement('video');
470
+ _wcHiddenVideo.srcObject=wcStream;
471
+ _wcHiddenVideo.setAttribute('playsinline','');
472
+ _wcHiddenVideo.muted=true;
473
+ _wcHiddenVideo.play();
474
+ _wcCanvas2=document.createElement('canvas');
475
+ _wcCanvas2.width=640;_wcCanvas2.height=480;
476
+ const ctx2=_wcCanvas2.getContext('2d');
477
+
478
+ /* Envoie une frame toutes les 200ms → reçoit JPEG annoté → l'affiche */
479
+ const miniImg=document.getElementById('wcMiniYolo');
480
+ const bigImg=document.getElementById('wcYoloImg');
481
+ miniImg.style.display='block';
482
+ bigImg.style.display='block';
483
+
484
+ _wcFrameIv=setInterval(async()=>{
485
+ if(!wcStream||!_wcHiddenVideo)return;
486
+ ctx2.drawImage(_wcHiddenVideo,0,0,640,480);
487
+ _wcCanvas2.toBlob(async(blob)=>{
488
+ if(!blob)return;
489
+ try{
490
+ const resp=await fetch('/api/webcam/frame',{method:'POST',
491
+ headers:{'Content-Type':'image/jpeg'},body:blob});
492
+ if(!resp.ok)return;
493
+ const url=URL.createObjectURL(await resp.blob());
494
+ miniImg.src=url;bigImg.src=url;
495
+ }catch(e){}
496
+ },'image/jpeg',0.75);
497
+ },200);
498
+
499
+ /* Grand panel droite */
500
+ document.getElementById('emptyState').style.display='none';
501
+ document.getElementById('resultVid').style.display='none';
502
+ document.getElementById('hudEl').style.display='block';
503
+ document.getElementById('cOverlay').style.display='flex';
504
+ document.getElementById('srcLabel').textContent='Webcam — LIVE';
505
+
506
+ /* Démarre l'enregistrement automatiquement */
507
+ setTimeout(_startRec, 800);
508
+ /* Polling stats toutes les 800ms */
509
+ clearInterval(_wcPollIv);
510
+ _wcPollIv=setInterval(_pollWc,800);
511
+ checkReady();
512
+ }
513
+
514
+ /* ── Recording webcam → grand Result ── */
515
+ let _recIv=null, _recChunks=[], _mediaRec=null;
516
+
517
+ function _startRec(){
518
+ const canvas=document.getElementById('wcCanvas');
519
+ const img=document.getElementById('wcYoloImg');
520
+ if(!canvas||!img)return;
521
+ canvas.width=640;canvas.height=480;
522
+ const ctx=canvas.getContext('2d');
523
+ _recIv=setInterval(()=>{
524
+ if(img.naturalWidth>0){canvas.width=img.naturalWidth;canvas.height=img.naturalHeight;ctx.drawImage(img,0,0);}
525
+ },40);
526
+ const mime=MediaRecorder.isTypeSupported('video/webm;codecs=vp9')?'video/webm;codecs=vp9':'video/webm';
527
+ _mediaRec=new MediaRecorder(canvas.captureStream(25),{mimeType:mime,videoBitsPerSecond:2000000});
528
+ _recChunks=[];
529
+ _mediaRec.ondataavailable=e=>{if(e.data.size>0)_recChunks.push(e.data);};
530
+ _mediaRec.onstop=_saveRec;
531
+ _mediaRec.start(500);
532
+ }
533
+
534
+ function _saveRec(){
535
+ clearInterval(_recIv);_recIv=null;
536
+ if(!_recChunks.length)return;
537
+ const blob=new Blob(_recChunks,{type:'video/webm'});
538
+ const url=URL.createObjectURL(blob);
539
+ _annotatedUrl=url;
540
+ /* Affiche dans le grand Result */
541
+ const rv=document.getElementById('resultVid');
542
+ rv.src=url;rv.style.display='block';rv.controls=true;
543
+ rv.onloadeddata=()=>rv.play().catch(()=>{});rv.load();
544
+ document.getElementById('srcLabel').textContent='Webcam — recorded';
545
+ document.getElementById('emptyState').style.display='none';
546
+ document.getElementById('hudEl').style.display='block';
547
+ /* Active download */
548
+ _set('dlName','webcam_trackiq.webm');
549
+ document.getElementById('dlBtn').removeAttribute('disabled');
550
+ document.getElementById('dlBtn').onclick=()=>{
551
+ const a=document.createElement('a');a.href=url;a.download='webcam_trackiq.webm';a.click();
552
+ };
553
+ }
554
+
555
+ function stopWc(){
556
+ /* 1. Arrête enregistrement → déclenche _saveRec → vidéo dans Result */
557
+ if(_mediaRec&&_mediaRec.state==='recording') _mediaRec.stop();
558
+ clearInterval(_wcFrameIv);_wcFrameIv=null;
559
+ if(_wcHiddenVideo){_wcHiddenVideo.srcObject=null;_wcHiddenVideo=null;}
560
+ _wcCanvas2=null;
561
+ if(wcStream){wcStream.getTracks().forEach(t=>t.stop());wcStream=null;}
562
+ fetch('/api/webcam/stop',{method:'POST'}).catch(()=>{});
563
+ clearInterval(_wcPollIv);_wcPollIv=null;
564
+
565
+ /* 2. Cache flux live */
566
+ const mi=document.getElementById('wcMiniYolo');mi.src='';mi.style.display='none';
567
+ const bi=document.getElementById('wcYoloImg');bi.src='';bi.style.display='none';
568
+
569
+ document.getElementById('wcPh').style.display='flex';
570
+ document.getElementById('recBadge').style.display='none';
571
+ document.getElementById('runBtn').style.display='flex';
572
+ document.getElementById('cOverlay').style.display='none';
573
+ document.getElementById('noObjBanner').style.display='none';
574
+
575
+ const btn=document.getElementById('wcBtn');
576
+ btn.innerHTML='<i class="fas fa-circle" style="color:#f87171;font-size:8px;"></i> Start webcam';
577
+ btn.onclick=startWc;
578
+ checkReady();
579
+ }
580
+
581
+ async function _pollWc(){
582
+ try{
583
+ const r=await fetch('/api/webcam/stats');
584
+ if(!r.ok)return;
585
+ const d=await r.json();
586
+ const fc=d.frame_counts||{};
587
+ const uc=d.unique_counts||{};
588
+ _set('hudFr',`F:${d.frame||0} ▶ LIVE`);
589
+ const hco=document.getElementById('hudCo');
590
+ if(hco) hco.innerHTML=Object.entries(fc).map(([k,v])=>`${k} = ${v}`).join('<br>')||
591
+ '<span style="color:#ef4444">No object</span>';
592
+ _showCountsWc(fc,uc);
593
+ _showBanner(!Object.keys(fc).length);
594
+ }catch(e){}
595
+ }
596
+
597
+ /* ── Affichage compteurs ── */
598
+ function _showCounts(uc){
599
+ const ov=document.getElementById('cOverlay');
600
+ ov.innerHTML='';
601
+ if(!Object.keys(uc).length){
602
+ _showBanner(true);return;
603
+ }
604
+ Object.entries(uc).forEach(([cls,cnt])=>{
605
+ const cl=CLASSES.find(c=>c.name===cls);
606
+ const color=cl?cl.color:'#a78bfa';
607
+ const div=document.createElement('div');div.className='cbdg';
608
+ div.style.borderLeftColor=color;
609
+ div.innerHTML=`<span class="cn" style="color:${color}">${cls} = ${cnt}</span>`;
610
+ ov.appendChild(div);
611
+ });
612
+ _showBanner(false);
613
+ }
614
+
615
+ function _showCountsWc(fc,uc){
616
+ const ov=document.getElementById('cOverlay');
617
+ ov.innerHTML='';
618
+ const all=new Set([...Object.keys(fc),...Object.keys(uc)]);
619
+ if(!all.size){_showBanner(true);return;}
620
+ all.forEach(cls=>{
621
+ const cl=CLASSES.find(c=>c.name===cls);
622
+ const color=cl?cl.color:'#a78bfa';
623
+ const div=document.createElement('div');div.className='cbdg';
624
+ div.style.borderLeftColor=color;
625
+ div.innerHTML=`<span class="cn" style="color:${color}">${cls} = ${fc[cls]||0}</span>
626
+ <span class="cu">Total unique : ${uc[cls]||0}</span>`;
627
+ ov.appendChild(div);
628
+ });
629
+ _showBanner(false);
630
+ }
631
+
632
+ function _showBanner(show){
633
+ const b=document.getElementById('noObjBanner');
634
+ b.style.display=show?'flex':'none';
635
+ }
636
+
637
+ /* ── Helpers ── */
638
+ function _set(id,val){const el=document.getElementById(id);if(el)el.textContent=val;}
639
+ function checkReady(){
640
+ const ok=(curSrc==='upload'&&vidFile)||(curSrc==='url'&&vidUrl)||(curSrc==='webcam'&&wcStream);
641
+ document.getElementById('runBtn').disabled=!ok;
642
+ }
643
+ function doDownload(){
644
+ const url=_annotatedUrl;if(!url)return;
645
+ const a=document.createElement('a');a.href=url;a.download='trackiq_annotated.mp4';a.click();
646
+ }
647
+ function resetAll(){
648
+ if(wcStream)stopWc();
649
+ if(_sse)_sse.close();
650
+ _jobId=null;_annotatedUrl=null;vidFile=null;vidUrl=null;
651
+ const rv=document.getElementById('resultVid');rv.src='';rv.style.display='none';
652
+ document.getElementById('fname').style.display='none';
653
+ document.getElementById('fname').textContent='';
654
+ document.getElementById('uzone').classList.remove('has');
655
+ document.getElementById('urlIn').value='';
656
+ document.getElementById('progWrap').style.display='none';
657
+ document.getElementById('progBar').style.width='0%';
658
+ document.getElementById('progTxt').style.display='none';
659
+ document.getElementById('emptyState').style.display='flex';
660
+ document.getElementById('cOverlay').style.display='none';
661
+ document.getElementById('hudEl').style.display='none';
662
+ document.getElementById('noObjBanner').style.display='none';
663
+ document.getElementById('dlBtn').disabled=true;
664
+ document.getElementById('dlName').textContent='No result yet';
665
+ document.getElementById('srcLabel').textContent='';
666
+ ['stF','stO','stFps','stD'].forEach(id=>_set(id,'—'));
667
+ const fi=document.getElementById('fileInput');if(fi)fi.value='';
668
+ checkReady();
669
+ }
670
+
671
+ /* ── Init ── */
672
+ (function(){
673
+ try{
674
+ const th=localStorage.getItem('tiq_theme');
675
+ if(th){document.documentElement.setAttribute('data-theme',th);
676
+ if(th==='dark'){['ti','ti2'].forEach(x=>{const e=document.getElementById(x);if(e)e.className='fas fa-sun';});}}
677
+ const lg=localStorage.getItem('tiq_lang');
678
+ if(lg&&lg!=='en'){const btn=document.querySelector('.lb[onclick*="\''+lg+'\'"]');if(btn)setLang(lg,btn);}
679
+ }catch(e){}
680
+ })();
681
+ </script>
682
+ <script src="/static/trackiq-api.js"></script>
683
+ </body>
684
+ </html>
templates/index.html ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ — Urban Traffic Detection</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300&family=DM+Mono:wght@400&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <style>
10
+ :root {
11
+ --ac: #880000;
12
+ --ac2: #660000;
13
+ --bg: #f9fafb;
14
+ --s: #fff;
15
+ --bd: rgba(0,0,0,0.07);
16
+ --t: #111827;
17
+ --t2: #6b7280;
18
+ --t3: #d1d5db;
19
+ --sh: 0 4px 24px rgba(0,0,0,0.09);
20
+ --shlg: 0 20px 25px -5px rgba(0,0,0,0.10), 0 10px 10px -5px rgba(0,0,0,0.04);
21
+ --r: 16px;
22
+ }
23
+ [data-theme="dark"] {
24
+ --bg: #0c1117;
25
+ --s: #151c25;
26
+ --t: #e8edf2;
27
+ --t2: #7a8fa0;
28
+ --t3: #3a4a5a;
29
+ --bd: rgba(255,255,255,0.07);
30
+ --sh: 0 4px 24px rgba(0,0,0,0.5);
31
+ --shlg: 0 20px 40px rgba(0,0,0,0.5);
32
+ }
33
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
34
+ html, body { min-height: 100%; }
35
+ body {
36
+ font-family: "DM Sans", sans-serif;
37
+ background: var(--bg);
38
+ color: var(--t);
39
+ transition: background 0.3s, color 0.3s;
40
+ }
41
+ ::-webkit-scrollbar { width: 6px; }
42
+ ::-webkit-scrollbar-track { background: transparent; }
43
+ ::-webkit-scrollbar-thumb { background: var(--t3); border-radius: 3px; }
44
+
45
+ /* ── FLOATING NAV ── */
46
+ .floatnav {
47
+ position: absolute;
48
+ top: 0; left: 0; right: 0;
49
+ z-index: 50;
50
+ pointer-events: none;
51
+ }
52
+ .floatnav-inner {
53
+ max-width: 1280px;
54
+ margin: 0 auto;
55
+ padding: 16px 24px;
56
+ display: flex;
57
+ justify-content: space-between;
58
+ align-items: flex-start;
59
+ }
60
+ .nav-brand {
61
+ pointer-events: auto;
62
+ background: rgba(255,255,255,0.92);
63
+ backdrop-filter: blur(12px);
64
+ border-radius: 10px;
65
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
66
+ padding: 8px 16px;
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 8px;
70
+ text-decoration: none;
71
+ transition: all 0.2s;
72
+ }
73
+ .nav-brand:hover { background: #fff; box-shadow: 0 8px 24px rgba(0,0,0,0.16); }
74
+ .nav-brand-icon {
75
+ width: 28px; height: 28px;
76
+ background: var(--ac);
77
+ border-radius: 7px;
78
+ display: flex; align-items: center; justify-content: center;
79
+ color: #fff; font-size: 12px;
80
+ }
81
+ .nav-brand-name {
82
+ font-size: 14px; font-weight: 600;
83
+ color: #111827; letter-spacing: -0.3px;
84
+ }
85
+ .nav-brand-name b { color: var(--ac); }
86
+ .nav-right {
87
+ pointer-events: auto;
88
+ display: flex; gap: 8px; align-items: center;
89
+ }
90
+ .nav-lang {
91
+ background: #fff;
92
+ border-radius: 10px;
93
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
94
+ padding: 4px;
95
+ display: flex; gap: 4px;
96
+ }
97
+ .lb {
98
+ font-size: 12px; font-weight: 500;
99
+ padding: 4px 10px;
100
+ border: none; background: none;
101
+ cursor: pointer; border-radius: 6px;
102
+ color: #6b7280;
103
+ font-family: "DM Sans", sans-serif;
104
+ transition: all 0.2s;
105
+ }
106
+ .lb.on { background: var(--ac); color: #fff; }
107
+ .lb:not(.on):hover { background: #f3f4f6; color: #111827; }
108
+ .nav-theme {
109
+ background: #fff;
110
+ border-radius: 10px;
111
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
112
+ width: 38px; height: 38px;
113
+ border: none; cursor: pointer;
114
+ display: flex; align-items: center; justify-content: center;
115
+ color: #6b7280; font-size: 13px;
116
+ transition: all 0.2s;
117
+ }
118
+ .nav-theme:hover { color: var(--ac); }
119
+
120
+ /* ── HERO ── */
121
+ .hero {
122
+ background-color: var(--ac);
123
+ background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23991111' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
124
+ color: #fff;
125
+ padding-top: 90px;
126
+ padding-bottom: 90px;
127
+ padding-left: 24px;
128
+ padding-right: 24px;
129
+ text-align: center;
130
+ position: relative;
131
+ overflow: hidden;
132
+ }
133
+ .hero-inner {
134
+ max-width: 860px;
135
+ margin: 0 auto;
136
+ position: relative;
137
+ z-index: 10;
138
+ animation: fadeUp 0.6s ease both;
139
+ }
140
+ @keyframes fadeUp {
141
+ from { opacity: 0; transform: translateY(24px); }
142
+ to { opacity: 1; transform: translateY(0); }
143
+ }
144
+ .hero-logo-wrap {
145
+ display: flex;
146
+ justify-content: center;
147
+ margin-bottom: 16px;
148
+ }
149
+ .hero-logo-box {
150
+ background: #fff;
151
+ padding: 14px;
152
+ border-radius: 20px;
153
+ box-shadow: 0 8px 32px rgba(0,0,0,0.2);
154
+ display: flex; align-items: center; justify-content: center;
155
+ width: 80px; height: 80px;
156
+ }
157
+ .hero-logo-box i {
158
+ font-size: 36px;
159
+ color: var(--ac);
160
+ }
161
+ .hero h1 {
162
+ font-size: clamp(32px, 5vw, 60px);
163
+ font-weight: 800;
164
+ letter-spacing: -2px;
165
+ line-height: 1.1;
166
+ margin-bottom: 14px;
167
+ }
168
+ .hero h1 .dim { opacity: 0.75; font-weight: 300; font-style: italic; }
169
+ .hero-sub {
170
+ font-size: clamp(14px, 1.8vw, 18px);
171
+ color: rgba(255,255,255,0.88);
172
+ font-weight: 400;
173
+ line-height: 1.65;
174
+ margin-bottom: 24px;
175
+ max-width: 560px;
176
+ margin-left: auto;
177
+ margin-right: auto;
178
+ }
179
+ .hero-cta {
180
+ display: inline-flex; align-items: center; gap: 10px;
181
+ background: #fff;
182
+ color: var(--ac);
183
+ font-weight: 700; font-size: 16px;
184
+ padding: 14px 32px;
185
+ border-radius: 50px;
186
+ text-decoration: none;
187
+ box-shadow: 0 8px 24px rgba(0,0,0,0.18);
188
+ transition: all 0.3s;
189
+ }
190
+ .hero-cta:hover {
191
+ background: #f3f4f6;
192
+ transform: scale(1.05);
193
+ box-shadow: 0 16px 40px rgba(0,0,0,0.22);
194
+ }
195
+ /* Wave bottom */
196
+ .hero-wave {
197
+ position: absolute;
198
+ bottom: 0; left: 0; right: 0;
199
+ pointer-events: none;
200
+ }
201
+
202
+ /* ── CARDS GRID ── */
203
+ .cards-section {
204
+ max-width: 1280px;
205
+ margin: 0 auto;
206
+ padding: 0 24px 64px;
207
+ margin-top: -64px;
208
+ position: relative;
209
+ z-index: 20;
210
+ }
211
+ .cards-grid {
212
+ display: grid;
213
+ grid-template-columns: repeat(1, 1fr);
214
+ gap: 24px;
215
+ }
216
+ @media (min-width: 640px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } }
217
+ @media (min-width: 1024px) { .cards-grid { grid-template-columns: repeat(4, 1fr); } }
218
+
219
+ .card {
220
+ background: var(--s);
221
+ border-radius: 20px;
222
+ padding: 32px 24px;
223
+ box-shadow: var(--shlg);
224
+ border: 1px solid rgba(0,0,0,0.05);
225
+ text-decoration: none;
226
+ display: block;
227
+ transition: all 0.3s ease;
228
+ animation: fadeUp 0.6s ease both;
229
+ }
230
+ .card:nth-child(1) { animation-delay: 0.05s; }
231
+ .card:nth-child(2) { animation-delay: 0.12s; }
232
+ .card:nth-child(3) { animation-delay: 0.18s; }
233
+ .card:nth-child(4) { animation-delay: 0.24s; }
234
+ .card:hover {
235
+ transform: translateY(-8px);
236
+ box-shadow: 0 20px 25px -5px rgba(0,0,0,0.13), 0 10px 10px -5px rgba(0,0,0,0.05);
237
+ border-color: var(--ac);
238
+ }
239
+ .card-icon-wrap {
240
+ width: 80px; height: 80px;
241
+ background: #fff1f1;
242
+ color: var(--ac);
243
+ border-radius: 50%;
244
+ display: flex; align-items: center; justify-content: center;
245
+ font-size: 28px;
246
+ margin: 0 auto 24px;
247
+ transition: all 0.3s;
248
+ box-shadow: 0 2px 8px rgba(136,0,0,0.08);
249
+ }
250
+ [data-theme="dark"] .card-icon-wrap {
251
+ background: rgba(136,0,0,0.18);
252
+ }
253
+ .card:hover .card-icon-wrap {
254
+ background: var(--ac);
255
+ color: #fff;
256
+ }
257
+ .card-title {
258
+ text-align: center;
259
+ font-size: 18px; font-weight: 700;
260
+ color: var(--t);
261
+ margin-bottom: 10px;
262
+ transition: color 0.2s;
263
+ }
264
+ .card:hover .card-title { color: var(--ac); }
265
+ .card-desc {
266
+ text-align: center;
267
+ font-size: 13px;
268
+ color: var(--t2);
269
+ line-height: 1.65;
270
+ }
271
+ .card-launch {
272
+ margin-top: 24px;
273
+ display: flex; justify-content: center;
274
+ }
275
+ .card-launch span {
276
+ font-size: 13px; font-weight: 600;
277
+ color: var(--ac);
278
+ display: flex; align-items: center; gap: 6px;
279
+ opacity: 0;
280
+ transition: opacity 0.2s;
281
+ }
282
+ .card:hover .card-launch span { opacity: 1; }
283
+
284
+ /* ── FOOTER ── */
285
+ footer {
286
+ background: var(--s);
287
+ border-top: 1px solid var(--bd);
288
+ padding: 28px 24px;
289
+ text-align: center;
290
+ transition: background 0.3s;
291
+ }
292
+ footer p {
293
+ font-size: 13px;
294
+ color: var(--t2);
295
+ line-height: 1.7;
296
+ }
297
+ footer p span {
298
+ font-size: 11px;
299
+ opacity: 0.6;
300
+ display: block;
301
+ margin-top: 4px;
302
+ }
303
+ </style>
304
+ </head>
305
+ <body>
306
+
307
+ <!-- FLOATING NAV -->
308
+ <div class="floatnav">
309
+ <div class="floatnav-inner">
310
+ <a href="#" class="nav-brand">
311
+ <div class="nav-brand-icon"><i class="fas fa-satellite-dish"></i></div>
312
+ <span class="nav-brand-name">Track<b>IQ</b></span>
313
+ </a>
314
+ <div class="nav-right">
315
+ <div class="nav-lang">
316
+ <button class="lb on" onclick="setLang('en',this)">EN</button>
317
+ <button class="lb" onclick="setLang('fr',this)">FR</button>
318
+ <button class="lb" onclick="setLang('wo',this)">WO</button>
319
+ </div>
320
+ <button class="nav-theme" onclick="toggleTheme()"><i class="fas fa-moon" id="ti"></i></button>
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- HERO -->
326
+ <div class="hero">
327
+ <div class="hero-inner">
328
+ <div class="hero-logo-wrap">
329
+ <div class="hero-logo-box">
330
+ <i class="fas fa-satellite-dish"></i>
331
+ </div>
332
+ </div>
333
+ <h1 id="t-h1">
334
+ TrackIQ Urban Traffic<br>
335
+ <span class="dim">Surveillance &amp; Detection</span>
336
+ </h1>
337
+ <p class="hero-sub" id="t-sub">
338
+ Based on Computer Vision — real-time vehicle detection and classification, speed estimation, congestion analysis and traffic sign identification.
339
+ </p>
340
+ <a href="/home" class="hero-cta" id="t-launch">
341
+ <i class="fas fa-play"></i>
342
+ <span>Launch</span>
343
+ </a>
344
+ </div>
345
+ <div class="hero-wave">
346
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 120" style="display:block;width:100%;">
347
+ <path fill="var(--bg)" fill-opacity="1"
348
+ d="M0,64L80,69.3C160,75,320,85,480,80C640,75,800,53,960,48C1120,43,1280,53,1360,58.7L1440,64L1440,120L1360,120C1280,120,1120,120,960,120C800,120,640,120,480,120C320,120,160,120,80,120L0,120Z"/>
349
+ </svg>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- CARDS -->
354
+ <div class="cards-section">
355
+ <div class="cards-grid">
356
+
357
+ <!-- Card 1 -->
358
+ <a href="/home" class="card">
359
+ <div class="card-icon-wrap"><i class="fas fa-car-side"></i></div>
360
+ <div class="card-title" id="t-c1t">Vehicle Detection</div>
361
+ <p class="card-desc" id="t-c1d">Real-time detection and classification of vehicles: cars, trucks, motorcycles, buses and more.</p>
362
+ <div class="card-launch"><span id="t-launch2">Launch <i class="fas fa-arrow-right"></i></span></div>
363
+ </a>
364
+
365
+ <!-- Card 2 -->
366
+ <a href="/home" class="card">
367
+ <div class="card-icon-wrap"><i class="fas fa-tachometer-alt"></i></div>
368
+ <div class="card-title" id="t-c2t">Speed Estimation</div>
369
+ <p class="card-desc" id="t-c2d">Automatic estimation of vehicle speed from video stream without additional sensors.</p>
370
+ <div class="card-launch"><span id="t-launch3">Launch <i class="fas fa-arrow-right"></i></span></div>
371
+ </a>
372
+
373
+ <!-- Card 3 -->
374
+ <a href="/home" class="card">
375
+ <div class="card-icon-wrap"><i class="fas fa-traffic-light"></i></div>
376
+ <div class="card-title" id="t-c3t">Signs &amp; Lights</div>
377
+ <p class="card-desc" id="t-c3d">Identification of traffic signs and lights, red light detection, stop signs and more.</p>
378
+ <div class="card-launch"><span id="t-launch4">Launch <i class="fas fa-arrow-right"></i></span></div>
379
+ </a>
380
+
381
+ <!-- Card 4 -->
382
+ <a href="/home" class="card">
383
+ <div class="card-icon-wrap"><i class="fas fa-chart-bar"></i></div>
384
+ <div class="card-title" id="t-c4t">Flow &amp; Congestion</div>
385
+ <p class="card-desc" id="t-c4d">Congestion analysis, flow measurement and annotated video export for reporting.</p>
386
+ <div class="card-launch"><span id="t-launch5">Launch <i class="fas fa-arrow-right"></i></span></div>
387
+ </a>
388
+
389
+ </div>
390
+ </div>
391
+
392
+ <!-- FOOTER -->
393
+ <footer>
394
+ <p>
395
+ © 2026 <strong>TrackIQ</strong> — Computer Vision Project. All rights reserved.
396
+ <span id="t-foot">Academic Project — Version 1.0</span>
397
+ </p>
398
+ </footer>
399
+
400
+ <script>
401
+ const T = {
402
+ en: {
403
+ h1: 'TrackIQ Urban Traffic<br><span class="dim">Surveillance &amp; Detection</span>',
404
+ sub: "Based on Computer Vision — real-time vehicle detection and classification, speed estimation, congestion analysis and traffic sign identification.",
405
+ launch: '<i class="fas fa-play"></i><span>Launch</span>',
406
+ c1t: "Vehicle Detection", c1d: "Real-time detection and classification of vehicles: cars, trucks, motorcycles, buses and more.",
407
+ c2t: "Speed Estimation", c2d: "Automatic estimation of vehicle speed from video stream without additional sensors.",
408
+ c3t: "Signs & Lights", c3d: "Identification of traffic signs and lights, red light detection, stop signs and more.",
409
+ c4t: "Flow & Congestion", c4d: "Congestion analysis, flow measurement and annotated video export for reporting.",
410
+ launchbtn: 'Launch <i class="fas fa-arrow-right"></i>',
411
+ foot: "Academic Project — Version 1.0"
412
+ },
413
+ fr: {
414
+ h1: 'TrackIQ Trafic Urbain<br><span class="dim">Surveillance &amp; Détection</span>',
415
+ sub: "Basé sur Computer Vision — détection et classification en temps réel des véhicules, estimation de vitesse, analyse de congestion et identification des feux et panneaux.",
416
+ launch: '<i class="fas fa-play"></i><span>Lancer</span>',
417
+ c1t: "Détection Véhicules", c1d: "Détection et classification en temps réel des véhicules : voitures, camions, motos, bus et plus.",
418
+ c2t: "Estimation Vitesse", c2d: "Estimation automatique de la vitesse des véhicules depuis un flux vidéo, sans capteur additionnel.",
419
+ c3t: "Feux &amp; Panneaux", c3d: "Identification des panneaux et feux de signalisation, détection des feux rouges, stops et plus.",
420
+ c4t: "Flux &amp; Congestion",c4d: "Analyse de congestion, mesure de flux et export vidéo annoté pour rapport.",
421
+ launchbtn: 'Lancer <i class="fas fa-arrow-right"></i>',
422
+ foot: "Projet Académique — Version 1.0"
423
+ },
424
+ wo: {
425
+ h1: 'TrackIQ Trafik Kanam<br><span class="dim">Xam ak Réy</span>',
426
+ sub: "Moom Computer Vision la tax — léegi-léegi di réy wërsëg yi, ak xam njariñ yoom yi, ci kanam.",
427
+ launch: '<i class="fas fa-play"></i><span>Tambali</span>',
428
+ c1t: "Réy Wërsëg", c1d: "Réy ak xam njariñ wërsëg yi ci kanam: yëgël, camion, moto, bus ak yeneen.",
429
+ c2t: "Yóbbu Yoom", c2d: "Xam yóbbu yoom wërsëg yi ci video, benn jëriñ mujj dafa soxor.",
430
+ c3t: "Siñali", c3d: "Xam siñali yi ak dëkkandiku, réy feux rouge, stop ak yeneen.",
431
+ c4t: "Rafet &amp; Xonq", c4d: "Xam xonq, jëf rafet ci kanam ak dëkkandiku video bu annotéwoon.",
432
+ launchbtn: 'Tambali <i class="fas fa-arrow-right"></i>',
433
+ foot: "Liggéey Académique — Version 1.0"
434
+ }
435
+ };
436
+
437
+ function setLang(l, btn) {
438
+ document.querySelectorAll(".lb").forEach(b => b.classList.remove("on"));
439
+ btn.classList.add("on");
440
+ const t = T[l];
441
+ if (!t) return;
442
+ document.getElementById("t-h1").innerHTML = t.h1;
443
+ document.getElementById("t-sub").textContent = t.sub;
444
+ document.getElementById("t-launch").innerHTML = t.launch;
445
+ ["c1t","c2t","c3t","c4t"].forEach(k => {
446
+ const el = document.getElementById("t-"+k);
447
+ if (el) el.innerHTML = t[k];
448
+ });
449
+ ["c1d","c2d","c3d","c4d"].forEach(k => {
450
+ const el = document.getElementById("t-"+k);
451
+ if (el) el.textContent = t[k];
452
+ });
453
+ ["t-launch2","t-launch3","t-launch4","t-launch5"].forEach(id => {
454
+ const el = document.getElementById(id);
455
+ if (el) el.innerHTML = t.launchbtn;
456
+ });
457
+ document.getElementById("t-foot").textContent = t.foot;
458
+ try { localStorage.setItem("tiq_lang", l); } catch(e) {}
459
+ }
460
+
461
+ function toggleTheme() {
462
+ const d = document.documentElement;
463
+ const dk = d.getAttribute("data-theme") === "dark";
464
+ d.setAttribute("data-theme", dk ? "light" : "dark");
465
+ document.getElementById("ti").className = dk ? "fas fa-moon" : "fas fa-sun";
466
+ try { localStorage.setItem("tiq_theme", dk ? "light" : "dark"); } catch(e) {}
467
+ }
468
+
469
+ (function() {
470
+ try {
471
+ const th = localStorage.getItem("tiq_theme");
472
+ if (th) {
473
+ document.documentElement.setAttribute("data-theme", th);
474
+ if (th === "dark") document.getElementById("ti").className = "fas fa-sun";
475
+ }
476
+ const lg = localStorage.getItem("tiq_lang");
477
+ if (lg && lg !== "en") {
478
+ const btn = document.querySelector('.lb[onclick*="\'' + lg + '\'"]');
479
+ if (btn) setLang(lg, btn);
480
+ }
481
+ } catch(e) {}
482
+ })();
483
+ </script>
484
+ <script src="/static/trackiq-api.js"></script>
485
+ </body>
486
+ </html>
templates/logs.html ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TrackIQ Logs</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
9
+ <style>
10
+ :root{--bg:#f0f4f8;--surface:#fff;--surface2:#f6f8fb;--border:rgba(0,0,0,0.07);--shadow:0 2px 16px rgba(0,0,0,0.09);--shadow-lg:0 8px 36px rgba(0,0,0,0.13);--text:#0f1923;--text2:#5a6a7a;--text3:#9aaab8;--accent:#880000;--accent2:#660000;--accent-light:rgba(136,0,0,0.07);--sidebar-w:60px;--radius:14px;--radius-sm:9px;}
11
+ [data-theme="dark"]{--bg:#0c1117;--surface:#151c25;--surface2:#1c2531;--border:rgba(255,255,255,0.07);--shadow:0 2px 16px rgba(0,0,0,0.45);--shadow-lg:0 8px 36px rgba(0,0,0,0.55);--text:#e8edf2;--text2:#7a8fa0;--text3:#3a4a5a;}
12
+ *{box-sizing:border-box;margin:0;padding:0}::-webkit-scrollbar{width:0;height:0}
13
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);font-size:13px;display:flex;min-height:100vh}
14
+ .sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;align-items:center;padding:16px 0;gap:4px;position:fixed;top:0;left:0;bottom:0;z-index:200}
15
+ .sb-logo{width:34px;height:34px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;margin-bottom:10px;text-decoration:none}
16
+ .sb-sep{width:28px;height:1px;background:var(--border);margin:4px 0}
17
+ .sb-btn{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:15px;cursor:pointer;transition:all .2s;text-decoration:none;border:none;background:none;position:relative}
18
+ .sb-btn:hover,.sb-btn.active{background:var(--accent-light);color:var(--accent)}
19
+ .sb-btn .tip{position:absolute;left:46px;top:50%;transform:translateY(-50%);background:var(--text);color:var(--surface);font-size:11px;padding:3px 8px;border-radius:5px;white-space:nowrap;opacity:0;pointer-events:none}.sb-btn:hover .tip{opacity:1}
20
+ .sb-spacer{flex:1}.page-wrap{margin-left:var(--sidebar-w);flex:1;display:flex;flex-direction:column}
21
+ .topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 22px}
22
+ .topbar-l,.topbar-r{display:flex;align-items:center;gap:10px}
23
+ .brand-name{font-size:15px;font-weight:600}.brand-name b{color:var(--accent)}.sep{color:var(--border);font-size:18px;margin:0 2px}
24
+ .lang-group{display:flex;background:var(--surface2);border-radius:6px;padding:2px;border:1px solid var(--border)}
25
+ .lang-btn{font-size:11px;font-weight:500;padding:3px 8px;border:none;background:none;cursor:pointer;border-radius:4px;color:var(--text2);font-family:'DM Sans',sans-serif}.lang-btn.active{background:var(--accent);color:white}
26
+ .icon-btn{width:30px;height:30px;border-radius:6px;border:1px solid var(--border);background:var(--surface);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:12px}
27
+ main{flex:1;padding:16px 18px;max-width:1440px;width:100%;margin:0 auto;display:flex;flex-direction:column;gap:12px}
28
+ .sum-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
29
+ .sc,.card,.tcard{background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);box-shadow:var(--shadow)}
30
+ .sc{padding:13px 15px}.sc-l{font-size:11px;color:var(--text2);margin-bottom:5px}.sc-v{font-size:22px;font-weight:300}.sc-s{font-size:10px;color:var(--text3);margin-top:3px}
31
+ .card{padding:14px 16px}.filter-row{display:flex;align-items:flex-end;gap:9px;flex-wrap:wrap}.fg{display:flex;flex-direction:column;gap:4px}.fg label{font-size:11px;color:var(--text2)}
32
+ .fin{padding:8px 10px;border:1px solid var(--border);border-radius:7px;font-size:12px;background:var(--surface2);color:var(--text);font-family:'DM Sans',sans-serif;outline:none;min-width:160px}.fin:focus{border-color:var(--accent)}
33
+ .f-sp{flex:1}
34
+ .btn{display:flex;align-items:center;gap:5px;border:none;border-radius:7px;padding:8px 13px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif}
35
+ .btn-exp{background:var(--accent);color:white}.btn-exp:hover{background:var(--accent2)}
36
+ .save-opt{display:flex;align-items:center;gap:10px;width:100%;padding:9px 14px;border:none;background:none;cursor:pointer;font-size:12px;color:var(--text);font-family:'DM Sans',sans-serif;text-align:left}.save-opt:hover{background:var(--surface2)}
37
+ .btn-clr{background:var(--surface2);color:var(--text2);border:1px solid var(--border)}
38
+ .tcard{overflow:hidden}.twrap{overflow:auto}
39
+ table{width:100%;border-collapse:collapse}
40
+ thead th{padding:10px 12px;text-align:left;font-size:11px;font-weight:500;color:var(--text2);background:var(--surface2);border-bottom:1px solid var(--border);white-space:nowrap}
41
+ tbody tr{border-bottom:1px solid var(--border)} tbody tr:last-child{border-bottom:none}
42
+ tbody td{padding:10px 12px;font-size:12px;vertical-align:middle}
43
+ .mono{font-family:'DM Mono',monospace}
44
+ .pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--surface2);border-radius:999px;padding:3px 8px}
45
+ .dot{width:8px;height:8px;border-radius:50%}
46
+ .empty{padding:38px;text-align:center;color:var(--text3)}
47
+ @media(max-width:1100px){.sum-row{grid-template-columns:repeat(2,minmax(0,1fr))}}
48
+ @media(max-width:760px){.sum-row{grid-template-columns:1fr}}
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <nav class="sidebar">
53
+ <a href="/" class="sb-logo"><i class="fas fa-satellite-dish"></i></a>
54
+ <div class="sb-sep"></div>
55
+ <a href="/home" class="sb-btn"><i class="fas fa-play-circle"></i><span class="tip">Analyze</span></a>
56
+ <a href="/dashboard" class="sb-btn"><i class="fas fa-chart-line"></i><span class="tip">Dashboard</span></a>
57
+ <a href="/logs" class="sb-btn active"><i class="fas fa-list-ul"></i><span class="tip">Logs</span></a>
58
+ <a href="/history" class="sb-btn"><i class="fas fa-film"></i><span class="tip">History</span></a>
59
+ <div class="sb-spacer"></div>
60
+ <button class="sb-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon"></i><span class="tip">Theme</span></button>
61
+ </nav>
62
+
63
+ <div class="page-wrap">
64
+ <header class="topbar">
65
+ <div class="topbar-l">
66
+ <span class="brand-name">Track<b>IQ</b></span>
67
+ <span class="sep">|</span>
68
+ <span style="font-size:13px;font-weight:500;" id="t-title">Detection Logs</span>
69
+ </div>
70
+ <div class="topbar-r">
71
+ <div class="lang-group">
72
+ <button class="lang-btn active" onclick="setLang('en',this)">EN</button>
73
+ <button class="lang-btn" onclick="setLang('fr',this)">FR</button>
74
+ <button class="lang-btn" onclick="setLang('wo',this)">WO</button>
75
+ </div>
76
+ <button class="icon-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon2"></i></button>
77
+ </div>
78
+ </header>
79
+
80
+ <main>
81
+ <div class="sum-row">
82
+ <div class="sc"><div class="sc-l" id="t-s1">Detections</div><div class="sc-v" id="sumDetections">0</div><div class="sc-s" id="sumDetectionsSub">Real rows from generated videos</div></div>
83
+ <div class="sc"><div class="sc-l" id="t-s2">Scenes</div><div class="sc-v" id="sumScenes">0</div><div class="sc-s" id="sumScenesSub">Processed analyses</div></div>
84
+ <div class="sc"><div class="sc-l" id="t-s3">Classes</div><div class="sc-v" id="sumClasses">0</div><div class="sc-s" id="sumClassesSub">Distinct detected labels</div></div>
85
+ <div class="sc"><div class="sc-l" id="t-s4">Latest video</div><div class="sc-v" id="sumLatest">-</div><div class="sc-s" id="sumLatestSub">Most recent analyzed clip</div></div>
86
+ </div>
87
+
88
+ <div class="card">
89
+ <div class="filter-row">
90
+ <div class="fg"><label id="t-search">Search video or scene</label><input id="searchIn" class="fin" oninput="applyFilters()"></div>
91
+ <div class="fg"><label id="t-scene">Scene</label><select id="sceneF" class="fin" onchange="applyFilters()"><option value="">All</option></select></div>
92
+ <div class="fg"><label id="t-type">Class</label><select id="classF" class="fin" onchange="applyFilters()"><option value="">All</option></select></div>
93
+ <div class="fg"><label id="t-direction">Direction</label><select id="dirF" class="fin" onchange="applyFilters()"><option value="">All</option><option>up</option><option>down</option><option>left</option><option>right</option><option>unknown</option></select></div>
94
+ <div class="f-sp"></div>
95
+ <button class="btn btn-clr" onclick="clearFilters()" ondblclick="clearAllLogs()" title="Click=clear filters | Double-click=delete all logs"><i class="fas fa-rotate-left"></i> <span id="t-clear">Clear</span></button>
96
+ <div style="position:relative">
97
+ <button class="btn btn-exp" onclick="toggleSaveMenu(event)"><i class="fas fa-cloud-arrow-up"></i> <span id="t-export">Save Logs</span> <i class="fas fa-chevron-down" style="font-size:9px;margin-left:2px"></i></button>
98
+ <div id="saveMenu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-lg);min-width:240px;z-index:999;overflow:hidden">
99
+ <div style="padding:10px 14px 6px;font-size:11px;color:var(--text2);font-weight:500;border-bottom:1px solid var(--border)">
100
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
101
+ <label style="font-size:11px;color:var(--text2)">Group name:</label>
102
+ <input id="groupNameIn" value="trackiq" style="flex:1;padding:4px 7px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:var(--surface2);color:var(--text);outline:none" placeholder="e.g. session1">
103
+ </div>
104
+ <span style="color:var(--text3);font-size:10px">File: <span id="csvPreview">trackiq_all.csv</span></span>
105
+ </div>
106
+ <button class="save-opt" onclick="saveLocal()"><i class="fas fa-download" style="color:#3b82f6"></i> Download locally</button>
107
+ <button class="save-opt" onclick="showGithubPanel()"><i class="fab fa-github" style="color:#e2e8f0"></i> Push to GitHub</button>
108
+ <button class="save-opt" onclick="saveDrive()"><i class="fab fa-google-drive" style="color:#34a853"></i> Save to Google Drive</button>
109
+ </div>
110
+ </div>
111
+ <!-- GitHub panel -->
112
+ <div id="githubPanel" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:none;align-items:center;justify-content:center">
113
+ <div style="background:var(--surface);border-radius:14px;padding:22px 24px;width:420px;box-shadow:var(--shadow-lg)">
114
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
115
+ <span style="font-weight:600;font-size:14px"><i class="fab fa-github"></i> Push to GitHub</span>
116
+ <button onclick="closeGithubPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:16px"><i class="fas fa-times"></i></button>
117
+ </div>
118
+ <div style="display:flex;flex-direction:column;gap:9px">
119
+ <div class="fg"><label>Personal Access Token (PAT)</label><input id="ghToken" class="fin" type="password" placeholder="ghp_xxxxxxxxxxxx"></div>
120
+ <div class="fg"><label>Repository (owner/repo)</label><input id="ghRepo" class="fin" placeholder="username/trackiq-logs"></div>
121
+ <div class="fg"><label>Branch</label><input id="ghBranch" class="fin" value="main" placeholder="main"></div>
122
+ <div class="fg"><label>Path in repo</label><input id="ghPath" class="fin" placeholder="logs/" value="logs/"></div>
123
+ </div>
124
+ <div id="ghStatus" style="margin-top:10px;font-size:12px;color:var(--text2);min-height:18px"></div>
125
+ <div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end">
126
+ <button class="btn btn-clr" onclick="closeGithubPanel()">Cancel</button>
127
+ <button class="btn btn-exp" onclick="pushToGithub()"><i class="fab fa-github"></i> Push CSV</button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <div class="tcard">
135
+ <div class="twrap">
136
+ <table>
137
+ <thead>
138
+ <tr>
139
+ <th id="th-video">Video</th>
140
+ <th id="th-scene">Scene</th>
141
+ <th id="th-time">Time</th>
142
+ <th id="th-frame">Frame</th>
143
+ <th id="th-track">Track</th>
144
+ <th id="th-class">Class</th>
145
+ <th id="th-confidence">Confidence</th>
146
+ <th id="th-direction">Direction</th>
147
+ <th id="th-bbox">Bounding Box</th>
148
+ <th id="th-position">Center</th>
149
+ </tr>
150
+ </thead>
151
+ <tbody id="tbody">
152
+ <tr><td colspan="9" class="empty">Loading real logs...</td></tr>
153
+ </tbody>
154
+ </table>
155
+ </div>
156
+ </div>
157
+ </main>
158
+ </div>
159
+
160
+ <script>
161
+ let allRows = [];
162
+ let filteredRows = [];
163
+
164
+ function toggleTheme(){
165
+ const d=document.documentElement;
166
+ const dark=d.getAttribute('data-theme')==='dark';
167
+ d.setAttribute('data-theme',dark?'light':'dark');
168
+ ['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className=dark?'fas fa-moon':'fas fa-sun';});
169
+ try{localStorage.setItem('tiq_theme',dark?'light':'dark');}catch(e){}
170
+ }
171
+
172
+ const TL = {
173
+ en:{title:'Detection Logs',s1:'Detections',s2:'Scenes',s3:'Classes',s4:'Latest video',search:'Search video or scene',scene:'Scene',type:'Class',direction:'Direction',clear:'Clear',export:'Save Logs',video:'Video',time:'Time',frame:'Frame',track:'Track',class:'Class',confidence:'Confidence',bbox:'Bounding Box',position:'Center'},
174
+ fr:{title:'Logs de détection',s1:'Détections',s2:'Scènes',s3:'Classes',s4:'Dernière vidéo',search:'Recherche vidéo ou scène',scene:'Scène',type:'Classe',direction:'Direction',clear:'Effacer',export:'Sauvegarder',video:'Vidéo',time:'Temps',frame:'Frame',track:'Track',class:'Classe',confidence:'Confiance',bbox:'Boîte englobante',position:'Centre'},
175
+ wo:{title:'Logs yi',s1:'Réy',s2:'Scène yi',s3:'Classe yi',s4:'Video mujj',search:'Seet video walla scène',scene:'Scène',type:'Classe',direction:'Direction',clear:'Baň',export:'Sauvegarder',video:'Video',time:'Waxtu',frame:'Frame',track:'Track',class:'Classe',confidence:'Confiance',bbox:'Bounding Box',position:'Centre'}
176
+ };
177
+
178
+ function setLang(l, btn){
179
+ document.querySelectorAll('.lang-btn').forEach(b=>b.classList.remove('active'));
180
+ btn.classList.add('active');
181
+ const t = TL[l];
182
+ const ids = {title:'t-title',s1:'t-s1',s2:'t-s2',s3:'t-s3',s4:'t-s4',search:'t-search',scene:'t-scene',type:'t-type',direction:'t-direction',clear:'t-clear',export:'t-export',video:'th-video',time:'th-time',frame:'th-frame',track:'th-track',class:'th-class',confidence:'th-confidence',bbox:'th-bbox',position:'th-position'};
183
+ Object.entries(ids).forEach(([k,id])=>{const el=document.getElementById(id);if(el)el.textContent=t[k];});
184
+ document.getElementById('th-scene').textContent = t.scene;
185
+ document.getElementById('th-direction').textContent = t.direction;
186
+ try{localStorage.setItem('tiq_lang',l);}catch(e){}
187
+ }
188
+
189
+ async function loadLogs(){
190
+ try{
191
+ const [rowsRes, scenesRes] = await Promise.all([fetch('/api/logs/rows'), fetch('/api/logs')]);
192
+ allRows = await rowsRes.json();
193
+ const scenes = await scenesRes.json();
194
+ fillFilters(scenes);
195
+ fillSummary(scenes, allRows);
196
+ applyFilters();
197
+ }catch(err){
198
+ const tb=document.getElementById('tbody');
199
+ tb.innerHTML = '<tr><td colspan="9" class="empty">Unable to load real logs.</td></tr>';
200
+ console.warn('[TrackIQ] real logs:', err);
201
+ }
202
+ }
203
+
204
+ function fillFilters(scenes){
205
+ const sceneF = document.getElementById('sceneF');
206
+ const classF = document.getElementById('classF');
207
+ sceneF.innerHTML = '<option value="">All</option>' + scenes.map(s=>`<option value="${s.scene_id}">${s.video_name || s.scene_id}</option>`).join('');
208
+ const classes = [...new Set(allRows.map(r=>r.class_name).filter(Boolean))].sort();
209
+ classF.innerHTML = '<option value="">All</option>' + classes.map(c=>`<option value="${c}">${c}</option>`).join('');
210
+ }
211
+
212
+ function fillSummary(scenes, rows){
213
+ document.getElementById('sumDetections').textContent = rows.length.toLocaleString();
214
+ document.getElementById('sumScenes').textContent = scenes.length.toLocaleString();
215
+ document.getElementById('sumClasses').textContent = new Set(rows.map(r=>r.class_name).filter(Boolean)).size.toLocaleString();
216
+ document.getElementById('sumLatest').textContent = scenes[0]?.video_name || '-';
217
+ }
218
+
219
+ function applyFilters(){
220
+ const search = document.getElementById('searchIn').value.toLowerCase();
221
+ const scene = document.getElementById('sceneF').value;
222
+ const cls = document.getElementById('classF').value;
223
+ const dir = document.getElementById('dirF').value;
224
+ filteredRows = allRows.filter(r =>
225
+ (!search || (r.video_name || '').toLowerCase().includes(search) || (r.scene_id || '').toLowerCase().includes(search)) &&
226
+ (!scene || r.scene_id === scene) &&
227
+ (!cls || r.class_name === cls) &&
228
+ (!dir || r.direction === dir)
229
+ );
230
+ renderRows();
231
+ }
232
+
233
+ async function clearAllLogs(){
234
+ if(!confirm('Delete all logs? This cannot be undone.')) return;
235
+ try{
236
+ const scenes = await fetch('/api/logs').then(r=>r.json());
237
+ await Promise.all(scenes.map(s=>fetch(`/api/logs/${s.scene_id}`,{method:'DELETE'})));
238
+ location.reload();
239
+ }catch(e){ alert('Error: '+e.message); }
240
+ }
241
+
242
+ function clearFilters(){
243
+ ['searchIn','sceneF','classF','dirF'].forEach(id=>{document.getElementById(id).value='';});
244
+ applyFilters();
245
+ }
246
+
247
+ function renderRows(){
248
+ const tb = document.getElementById('tbody');
249
+ if(!filteredRows.length){
250
+ tb.innerHTML = '<tr><td colspan="10" class="empty">No real detections found for this filter.</td></tr>';
251
+ return;
252
+ }
253
+ const colors = {Vehicle:'#3b82f6',Motorcycle:'#10b981',Truck:'#f59e0b',Bus:'#8b5cf6',Human:'#ec4899',Bicycle:'#06b6d4'};
254
+ tb.innerHTML = '';
255
+ filteredRows.forEach(r=>{
256
+ const tr = document.createElement('tr');
257
+ tr.innerHTML = `
258
+ <td title="${r.video_name || ''}">${(r.video_name || '-').slice(0,28)}</td>
259
+ <td class="mono">${r.scene_id}</td>
260
+ <td class="mono">${Number(r.timestamp_s || 0).toFixed(2)}s</td>
261
+ <td class="mono">${r.frame_id}</td>
262
+ <td class="mono">#${r.track_id}</td>
263
+ <td><span class="pill"><span class="dot" style="background:${colors[r.class_name] || '#888'}"></span>${r.class_name || '-'}</span></td>
264
+ <td class="mono">${Math.round((r.confidence || 0) * 100)}%</td>
265
+ <td>${r.direction || 'unknown'}</td>
266
+ <td class="mono" style="font-size:11px">(${r.x1},${r.y1})→(${r.x2},${r.y2})</td>
267
+ <td class="mono">(${r.cx}, ${r.cy})</td>
268
+ `;
269
+ tb.appendChild(tr);
270
+ });
271
+ }
272
+
273
+ /* ── Helpers CSV ─────────────────────────────────────────────── */
274
+ function getGroupName(){ return (document.getElementById('groupNameIn')?.value||'trackiq').trim().replace(/\s+/g,'_')||'trackiq'; }
275
+ function getCsvFilename(){
276
+ const g=getGroupName();
277
+ const scenes=[...new Set(filteredRows.map(r=>r.scene_id))];
278
+ return `${g}_${scenes.length===1?scenes[0]:'all'}.csv`;
279
+ }
280
+ function buildCsvContent(){
281
+ const hdr = 'group,scene_id,video_name,timestamp_s,frame_id,track_id,class_name,confidence,x1,y1,x2,y2,cx,cy,direction\n';
282
+ const g = getGroupName();
283
+ const rows = filteredRows.map(r => [g,r.scene_id,`"${(r.video_name||'').replace(/"/g,'""')}"`,r.timestamp_s,r.frame_id,r.track_id,r.class_name,r.confidence,r.x1,r.y1,r.x2,r.y2,r.cx,r.cy,r.direction].join(',')).join('\n');
284
+ return hdr + rows;
285
+ }
286
+ function updateCsvPreview(){
287
+ const el = document.getElementById('csvPreview');
288
+ if(el) el.textContent = getCsvFilename();
289
+ }
290
+
291
+ /* ── Save menu ───────────────────────────────────���───────────── */
292
+ function toggleSaveMenu(e){
293
+ e.stopPropagation();
294
+ const m = document.getElementById('saveMenu');
295
+ m.style.display = m.style.display==='none'?'block':'none';
296
+ updateCsvPreview();
297
+ }
298
+ document.addEventListener('click', ()=>{ const m=document.getElementById('saveMenu'); if(m) m.style.display='none'; });
299
+
300
+ /* ── 1. Download local ───────────────────────────────────────── */
301
+ function saveLocal(){
302
+ const a = document.createElement('a');
303
+ a.href = URL.createObjectURL(new Blob([buildCsvContent()], {type:'text/csv'}));
304
+ a.download = getCsvFilename();
305
+ a.click();
306
+ document.getElementById('saveMenu').style.display='none';
307
+ }
308
+
309
+ /* ── 2. GitHub push ──────────────────────────────────────────── */
310
+ function showGithubPanel(){
311
+ document.getElementById('saveMenu').style.display='none';
312
+ const p = document.getElementById('githubPanel');
313
+ p.style.display='flex';
314
+ updateCsvPreview();
315
+ }
316
+ function closeGithubPanel(){ document.getElementById('githubPanel').style.display='none'; }
317
+
318
+ async function pushToGithub(){
319
+ const token = document.getElementById('ghToken').value.trim();
320
+ const repo = document.getElementById('ghRepo').value.trim();
321
+ const branch = document.getElementById('ghBranch').value.trim() || 'main';
322
+ const path = (document.getElementById('ghPath').value.trim().replace(/\/+$/,'')||'logs') + '/' + getCsvFilename();
323
+ const status = document.getElementById('ghStatus');
324
+
325
+ if(!token||!repo){ status.textContent='⚠ Token and repo are required.'; return; }
326
+ status.textContent='Pushing…';
327
+
328
+ const content = btoa(unescape(encodeURIComponent(buildCsvContent())));
329
+ // Check if file exists (to get SHA for update)
330
+ let sha = null;
331
+ try{
332
+ const check = await fetch(`https://api.github.com/repos/${repo}/contents/${path}`,{headers:{Authorization:`token ${token}`}});
333
+ if(check.ok){ const d=await check.json(); sha=d.sha; }
334
+ }catch(e){}
335
+
336
+ try{
337
+ const body = {message:`TrackIQ logs export — ${getCsvFilename()}`, content, branch};
338
+ if(sha) body.sha = sha;
339
+ const res = await fetch(`https://api.github.com/repos/${repo}/contents/${path}`,{
340
+ method:'PUT', headers:{Authorization:`token ${token}`,'Content-Type':'application/json'},
341
+ body: JSON.stringify(body)
342
+ });
343
+ if(res.ok){ status.innerHTML='<span style="color:#10b981">✓ Pushed successfully!</span>'; }
344
+ else{ const e=await res.json(); status.innerHTML=`<span style="color:#ef4444">Error: ${e.message}</span>`; }
345
+ }catch(err){ status.innerHTML=`<span style="color:#ef4444">Network error: ${err.message}</span>`; }
346
+ }
347
+
348
+ /* ── 3. Google Drive ─────────────────────────────────────────── */
349
+ async function saveDrive(){
350
+ document.getElementById('saveMenu').style.display='none';
351
+ // Uses Google Drive File Picker API via OAuth implicit flow
352
+ const CLIENT_ID = ''; // User must fill their own GCP OAuth client ID
353
+ if(!CLIENT_ID){
354
+ const ok = confirm('To save to Google Drive you need a Google OAuth Client ID.\n\nWould you like instructions on how to set it up?\n\n(For now the file will be downloaded locally as a fallback)');
355
+ if(ok){ window.open('https://developers.google.com/drive/api/guides/about-auth','_blank'); }
356
+ saveLocal();
357
+ return;
358
+ }
359
+ // Full Drive upload with OAuth token
360
+ const scope = 'https://www.googleapis.com/auth/drive.file';
361
+ const redirect = location.origin + location.pathname;
362
+ const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(redirect)}&response_type=token&scope=${encodeURIComponent(scope)}`;
363
+ const popup = window.open(authUrl,'_blank','width=500,height=600');
364
+ // Listen for token in URL hash after redirect
365
+ const interval = setInterval(()=>{
366
+ try{
367
+ if(popup.closed){ clearInterval(interval); return; }
368
+ const hash = popup.location.hash;
369
+ if(hash && hash.includes('access_token')){
370
+ clearInterval(interval); popup.close();
371
+ const token = new URLSearchParams(hash.replace('#','')).get('access_token');
372
+ uploadToDrive(token);
373
+ }
374
+ }catch(e){}
375
+ },500);
376
+ }
377
+
378
+ async function uploadToDrive(token){
379
+ const csv = buildCsvContent();
380
+ const fname = getCsvFilename();
381
+ const meta = JSON.stringify({name:fname,mimeType:'text/csv'});
382
+ const form = new FormData();
383
+ form.append('metadata', new Blob([meta],{type:'application/json'}));
384
+ form.append('file', new Blob([csv], {type:'text/csv'}));
385
+ try{
386
+ const res = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',{
387
+ method:'POST', headers:{Authorization:`Bearer ${token}`}, body:form
388
+ });
389
+ if(res.ok){ alert(`✓ "${fname}" saved to Google Drive!`); }
390
+ else{ const e=await res.json(); alert('Drive error: '+e.error?.message); }
391
+ }catch(err){ alert('Drive upload failed: '+err.message); }
392
+ }
393
+
394
+ function exportFiltered(){ saveLocal(); } // legacy alias
395
+
396
+ (function(){
397
+ try{
398
+ const th=localStorage.getItem('tiq_theme');
399
+ if(th){
400
+ document.documentElement.setAttribute('data-theme',th);
401
+ if(th==='dark'){['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className='fas fa-sun';});}
402
+ }
403
+ const lg=localStorage.getItem('tiq_lang');
404
+ if(lg&&lg!=='en'){
405
+ const btn=document.querySelector(`.lang-btn[onclick*="'${lg}'"]`);
406
+ if(btn)setLang(lg,btn);
407
+ }
408
+ }catch(e){}
409
+ loadLogs();
410
+ // Update CSV filename preview when group name changes
411
+ document.addEventListener('input', e=>{ if(e.target.id==='groupNameIn') updateCsvPreview(); });
412
+ })();
413
+ </script>
414
+ </body>
415
+ </html>