mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Add graph showing motion and object activity to history timeline desktop view (#9184)
* Add timeline graph component * Add more custom colors and improve graph * Add api and data * Fix data sorting * Add graph to timeline * Only show timeline for selected hour * Make data full range
This commit is contained in:
		
							parent
							
								
									6dd9d54f70
								
							
						
					
					
						commit
						9c4b69191b
					
				@ -8,6 +8,7 @@ import re
 | 
				
			|||||||
import subprocess as sp
 | 
					import subprocess as sp
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
import traceback
 | 
					import traceback
 | 
				
			||||||
 | 
					from collections import defaultdict
 | 
				
			||||||
from datetime import datetime, timedelta, timezone
 | 
					from datetime import datetime, timedelta, timezone
 | 
				
			||||||
from functools import reduce
 | 
					from functools import reduce
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
@ -620,7 +621,9 @@ def hourly_timeline():
 | 
				
			|||||||
    after = request.args.get("after", type=float)
 | 
					    after = request.args.get("after", type=float)
 | 
				
			||||||
    limit = request.args.get("limit", 200)
 | 
					    limit = request.args.get("limit", 200)
 | 
				
			||||||
    tz_name = request.args.get("timezone", default="utc", type=str)
 | 
					    tz_name = request.args.get("timezone", default="utc", type=str)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _, minute_modifier, _ = get_tz_modifiers(tz_name)
 | 
					    _, minute_modifier, _ = get_tz_modifiers(tz_name)
 | 
				
			||||||
 | 
					    minute_offset = int(minute_modifier.split(" ")[0])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    clauses = []
 | 
					    clauses = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -675,7 +678,7 @@ def hourly_timeline():
 | 
				
			|||||||
                minute=0, second=0, microsecond=0
 | 
					                minute=0, second=0, microsecond=0
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            + timedelta(
 | 
					            + timedelta(
 | 
				
			||||||
                minutes=int(minute_modifier.split(" ")[0]),
 | 
					                minutes=minute_offset,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        ).timestamp()
 | 
					        ).timestamp()
 | 
				
			||||||
        if hour not in hours:
 | 
					        if hour not in hours:
 | 
				
			||||||
@ -693,6 +696,87 @@ def hourly_timeline():
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.route("/<camera_name>/recording/hourly/activity")
 | 
				
			||||||
 | 
					def hourly_timeline_activity(camera_name: str):
 | 
				
			||||||
 | 
					    """Get hourly summary for timeline."""
 | 
				
			||||||
 | 
					    if camera_name not in current_app.frigate_config.cameras:
 | 
				
			||||||
 | 
					        return make_response(
 | 
				
			||||||
 | 
					            jsonify({"success": False, "message": "Camera not found"}),
 | 
				
			||||||
 | 
					            404,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before = request.args.get("before", type=float, default=datetime.now())
 | 
				
			||||||
 | 
					    after = request.args.get(
 | 
				
			||||||
 | 
					        "after", type=float, default=datetime.now() - timedelta(hours=1)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    tz_name = request.args.get("timezone", default="utc", type=str)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _, minute_modifier, _ = get_tz_modifiers(tz_name)
 | 
				
			||||||
 | 
					    minute_offset = int(minute_modifier.split(" ")[0])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    all_recordings: list[Recordings] = (
 | 
				
			||||||
 | 
					        Recordings.select(
 | 
				
			||||||
 | 
					            Recordings.start_time,
 | 
				
			||||||
 | 
					            Recordings.duration,
 | 
				
			||||||
 | 
					            Recordings.objects,
 | 
				
			||||||
 | 
					            Recordings.motion,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .where(Recordings.camera == camera_name)
 | 
				
			||||||
 | 
					        .where(Recordings.motion > 0)
 | 
				
			||||||
 | 
					        .where((Recordings.start_time > after) & (Recordings.end_time < before))
 | 
				
			||||||
 | 
					        .order_by(Recordings.start_time.asc())
 | 
				
			||||||
 | 
					        .iterator()
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # data format is ex:
 | 
				
			||||||
 | 
					    # {timestamp: [{ date: 1, count: 1, type: motion }]}] }}
 | 
				
			||||||
 | 
					    hours: dict[int, list[dict[str, any]]] = defaultdict(list)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    key = datetime.fromtimestamp(after).replace(second=0, microsecond=0) + timedelta(
 | 
				
			||||||
 | 
					        minutes=minute_offset
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    check = (key + timedelta(hours=1)).timestamp()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # set initial start so data is representative of full hour
 | 
				
			||||||
 | 
					    hours[int(key.timestamp())].append(
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "date": key.timestamp(),
 | 
				
			||||||
 | 
					            "count": 0,
 | 
				
			||||||
 | 
					            "type": "motion",
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for recording in all_recordings:
 | 
				
			||||||
 | 
					        if recording.start_time > check:
 | 
				
			||||||
 | 
					            hours[int(key.timestamp())].append(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "date": (key + timedelta(hours=1)).timestamp(),
 | 
				
			||||||
 | 
					                    "count": 0,
 | 
				
			||||||
 | 
					                    "type": "motion",
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            key = key + timedelta(hours=1)
 | 
				
			||||||
 | 
					            check = (key + timedelta(hours=1)).timestamp()
 | 
				
			||||||
 | 
					            hours[int(key.timestamp())].append(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "date": key.timestamp(),
 | 
				
			||||||
 | 
					                    "count": 0,
 | 
				
			||||||
 | 
					                    "type": "motion",
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        data_type = "motion" if recording.objects == 0 else "objects"
 | 
				
			||||||
 | 
					        hours[int(key.timestamp())].append(
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "date": recording.start_time + (recording.duration / 2),
 | 
				
			||||||
 | 
					                "count": recording.motion,
 | 
				
			||||||
 | 
					                "type": data_type,
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return jsonify(hours)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@bp.route("/<camera_name>/<label>/best.jpg")
 | 
					@bp.route("/<camera_name>/<label>/best.jpg")
 | 
				
			||||||
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
 | 
					@bp.route("/<camera_name>/<label>/thumbnail.jpg")
 | 
				
			||||||
def label_thumbnail(camera_name, label):
 | 
					def label_thumbnail(camera_name, label):
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										131
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										131
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -24,6 +24,7 @@
 | 
				
			|||||||
        "@radix-ui/react-switch": "^1.0.3",
 | 
					        "@radix-ui/react-switch": "^1.0.3",
 | 
				
			||||||
        "@radix-ui/react-tabs": "^1.0.4",
 | 
					        "@radix-ui/react-tabs": "^1.0.4",
 | 
				
			||||||
        "@radix-ui/react-tooltip": "^1.0.7",
 | 
					        "@radix-ui/react-tooltip": "^1.0.7",
 | 
				
			||||||
 | 
					        "apexcharts": "^3.45.1",
 | 
				
			||||||
        "axios": "^1.6.2",
 | 
					        "axios": "^1.6.2",
 | 
				
			||||||
        "class-variance-authority": "^0.7.0",
 | 
					        "class-variance-authority": "^0.7.0",
 | 
				
			||||||
        "clsx": "^2.0.0",
 | 
					        "clsx": "^2.0.0",
 | 
				
			||||||
@ -34,6 +35,7 @@
 | 
				
			|||||||
        "lucide-react": "^0.294.0",
 | 
					        "lucide-react": "^0.294.0",
 | 
				
			||||||
        "monaco-yaml": "^5.1.0",
 | 
					        "monaco-yaml": "^5.1.0",
 | 
				
			||||||
        "react": "^18.2.0",
 | 
					        "react": "^18.2.0",
 | 
				
			||||||
 | 
					        "react-apexcharts": "^1.4.1",
 | 
				
			||||||
        "react-day-picker": "^8.9.1",
 | 
					        "react-day-picker": "^8.9.1",
 | 
				
			||||||
        "react-dom": "^18.2.0",
 | 
					        "react-dom": "^18.2.0",
 | 
				
			||||||
        "react-hook-form": "^7.48.2",
 | 
					        "react-hook-form": "^7.48.2",
 | 
				
			||||||
@ -2782,6 +2784,11 @@
 | 
				
			|||||||
        "node": ">=10.0.0"
 | 
					        "node": ">=10.0.0"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@yr/monotone-cubic-spline": {
 | 
				
			||||||
 | 
					      "version": "1.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/acorn": {
 | 
					    "node_modules/acorn": {
 | 
				
			||||||
      "version": "8.11.2",
 | 
					      "version": "8.11.2",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
 | 
				
			||||||
@ -2933,6 +2940,20 @@
 | 
				
			|||||||
        "node": ">= 8"
 | 
					        "node": ">= 8"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/apexcharts": {
 | 
				
			||||||
 | 
					      "version": "3.45.1",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.45.1.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-pPjj/SA6dfPvR/IKRZF0STdfBGpBh3WRt7K0DFuW9P8erypYkX17EHu3/molPRfo2zSiQwTVpshHC5ncysqfkA==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@yr/monotone-cubic-spline": "^1.0.3",
 | 
				
			||||||
 | 
					        "svg.draggable.js": "^2.2.2",
 | 
				
			||||||
 | 
					        "svg.easing.js": "^2.0.0",
 | 
				
			||||||
 | 
					        "svg.filter.js": "^2.0.2",
 | 
				
			||||||
 | 
					        "svg.pathmorphing.js": "^0.1.3",
 | 
				
			||||||
 | 
					        "svg.resize.js": "^1.4.3",
 | 
				
			||||||
 | 
					        "svg.select.js": "^3.0.1"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/arg": {
 | 
					    "node_modules/arg": {
 | 
				
			||||||
      "version": "5.0.2",
 | 
					      "version": "5.0.2",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
 | 
				
			||||||
@ -6341,6 +6362,21 @@
 | 
				
			|||||||
        "node": ">= 0.6.0"
 | 
					        "node": ">= 0.6.0"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/prop-types": {
 | 
				
			||||||
 | 
					      "version": "15.8.1",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "loose-envify": "^1.4.0",
 | 
				
			||||||
 | 
					        "object-assign": "^4.1.1",
 | 
				
			||||||
 | 
					        "react-is": "^16.13.1"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/prop-types/node_modules/react-is": {
 | 
				
			||||||
 | 
					      "version": "16.13.1",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/propagating-hammerjs": {
 | 
					    "node_modules/propagating-hammerjs": {
 | 
				
			||||||
      "version": "2.0.1",
 | 
					      "version": "2.0.1",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/propagating-hammerjs/-/propagating-hammerjs-2.0.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/propagating-hammerjs/-/propagating-hammerjs-2.0.1.tgz",
 | 
				
			||||||
@ -6406,6 +6442,18 @@
 | 
				
			|||||||
        "node": ">=0.10.0"
 | 
					        "node": ">=0.10.0"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/react-apexcharts": {
 | 
				
			||||||
 | 
					      "version": "1.4.1",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.4.1.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-G14nVaD64Bnbgy8tYxkjuXEUp/7h30Q0U33xc3AwtGFijJB9nHqOt1a6eG0WBn055RgRg+NwqbKGtqPxy15d0Q==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "prop-types": "^15.8.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "apexcharts": "^3.41.0",
 | 
				
			||||||
 | 
					        "react": ">=0.13"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/react-day-picker": {
 | 
					    "node_modules/react-day-picker": {
 | 
				
			||||||
      "version": "8.9.1",
 | 
					      "version": "8.9.1",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.9.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.9.1.tgz",
 | 
				
			||||||
@ -7270,6 +7318,89 @@
 | 
				
			|||||||
        "url": "https://github.com/sponsors/ljharb"
 | 
					        "url": "https://github.com/sponsors/ljharb"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.draggable.js": {
 | 
				
			||||||
 | 
					      "version": "2.2.2",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "svg.js": "^2.0.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">= 0.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.easing.js": {
 | 
				
			||||||
 | 
					      "version": "2.0.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "svg.js": ">=2.3.x"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">= 0.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.filter.js": {
 | 
				
			||||||
 | 
					      "version": "2.0.2",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "svg.js": "^2.2.5"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">= 0.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.js": {
 | 
				
			||||||
 | 
					      "version": "2.7.1",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA=="
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.pathmorphing.js": {
 | 
				
			||||||
 | 
					      "version": "0.1.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "svg.js": "^2.4.0"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">= 0.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.resize.js": {
 | 
				
			||||||
 | 
					      "version": "1.4.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "svg.js": "^2.6.5",
 | 
				
			||||||
 | 
					        "svg.select.js": "^2.1.2"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">= 0.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.resize.js/node_modules/svg.select.js": {
 | 
				
			||||||
 | 
					      "version": "2.1.2",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "svg.js": "^2.2.5"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">= 0.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/svg.select.js": {
 | 
				
			||||||
 | 
					      "version": "3.0.1",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "svg.js": "^2.6.5"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">= 0.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/swr": {
 | 
					    "node_modules/swr": {
 | 
				
			||||||
      "version": "2.2.4",
 | 
					      "version": "2.2.4",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.4.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.4.tgz",
 | 
				
			||||||
 | 
				
			|||||||
@ -29,6 +29,7 @@
 | 
				
			|||||||
    "@radix-ui/react-switch": "^1.0.3",
 | 
					    "@radix-ui/react-switch": "^1.0.3",
 | 
				
			||||||
    "@radix-ui/react-tabs": "^1.0.4",
 | 
					    "@radix-ui/react-tabs": "^1.0.4",
 | 
				
			||||||
    "@radix-ui/react-tooltip": "^1.0.7",
 | 
					    "@radix-ui/react-tooltip": "^1.0.7",
 | 
				
			||||||
 | 
					    "apexcharts": "^3.45.1",
 | 
				
			||||||
    "axios": "^1.6.2",
 | 
					    "axios": "^1.6.2",
 | 
				
			||||||
    "class-variance-authority": "^0.7.0",
 | 
					    "class-variance-authority": "^0.7.0",
 | 
				
			||||||
    "clsx": "^2.0.0",
 | 
					    "clsx": "^2.0.0",
 | 
				
			||||||
@ -39,6 +40,7 @@
 | 
				
			|||||||
    "lucide-react": "^0.294.0",
 | 
					    "lucide-react": "^0.294.0",
 | 
				
			||||||
    "monaco-yaml": "^5.1.0",
 | 
					    "monaco-yaml": "^5.1.0",
 | 
				
			||||||
    "react": "^18.2.0",
 | 
					    "react": "^18.2.0",
 | 
				
			||||||
 | 
					    "react-apexcharts": "^1.4.1",
 | 
				
			||||||
    "react-day-picker": "^8.9.1",
 | 
					    "react-day-picker": "^8.9.1",
 | 
				
			||||||
    "react-dom": "^18.2.0",
 | 
					    "react-dom": "^18.2.0",
 | 
				
			||||||
    "react-hook-form": "^7.48.2",
 | 
					    "react-hook-form": "^7.48.2",
 | 
				
			||||||
 | 
				
			|||||||
@ -83,19 +83,19 @@ export default function DynamicCameraImage({
 | 
				
			|||||||
      <div className="flex absolute right-0 bottom-0 bg-black bg-opacity-20 rounded p-1">
 | 
					      <div className="flex absolute right-0 bottom-0 bg-black bg-opacity-20 rounded p-1">
 | 
				
			||||||
        <MdLeakAdd
 | 
					        <MdLeakAdd
 | 
				
			||||||
          className={`${
 | 
					          className={`${
 | 
				
			||||||
            detectingMotion == "ON" ? "text-red-500" : "text-gray-600"
 | 
					            detectingMotion == "ON" ? "text-motion" : "text-gray-600"
 | 
				
			||||||
          }`}
 | 
					          }`}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <TbUserScan
 | 
					        <TbUserScan
 | 
				
			||||||
          className={`${
 | 
					          className={`${
 | 
				
			||||||
            activeObjects.length > 0 ? "text-cyan-500" : "text-gray-600"
 | 
					            activeObjects.length > 0 ? "text-object" : "text-gray-600"
 | 
				
			||||||
          }`}
 | 
					          }`}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        {camera.audio.enabled && (
 | 
					        {camera.audio.enabled && (
 | 
				
			||||||
          <LuEar
 | 
					          <LuEar
 | 
				
			||||||
            className={`${
 | 
					            className={`${
 | 
				
			||||||
              parseInt(audioRms) >= camera.audio.min_volume
 | 
					              parseInt(audioRms) >= camera.audio.min_volume
 | 
				
			||||||
                ? "text-orange-500"
 | 
					                ? "text-audio"
 | 
				
			||||||
                : "text-gray-600"
 | 
					                : "text-gray-600"
 | 
				
			||||||
            }`}
 | 
					            }`}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
 | 
				
			|||||||
@ -59,7 +59,7 @@ export default function HistoryCard({
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <Button className="px-2 py-2" variant="ghost" size="xs">
 | 
					          <Button className="px-2 py-2" variant="ghost" size="xs">
 | 
				
			||||||
            <LuTrash
 | 
					            <LuTrash
 | 
				
			||||||
              className="w-5 h-5 stroke-red-500"
 | 
					              className="w-5 h-5 stroke-danger"
 | 
				
			||||||
              onClick={(e: Event) => {
 | 
					              onClick={(e: Event) => {
 | 
				
			||||||
                e.stopPropagation();
 | 
					                e.stopPropagation();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										65
									
								
								web/src/components/graph/TimelineGraph.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								web/src/components/graph/TimelineGraph.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					import { GraphData } from "@/types/graph";
 | 
				
			||||||
 | 
					import Chart from "react-apexcharts";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type TimelineGraphProps = {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  data: GraphData[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * A graph meant to be overlaid on top of a timeline
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export default function TimelineGraph({ id, data }: TimelineGraphProps) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Chart
 | 
				
			||||||
 | 
					      type="bar"
 | 
				
			||||||
 | 
					      options={{
 | 
				
			||||||
 | 
					        colors: ["#991b1b", "#06b6d4", "#ea580c"],
 | 
				
			||||||
 | 
					        chart: {
 | 
				
			||||||
 | 
					          id: id,
 | 
				
			||||||
 | 
					          selection: {
 | 
				
			||||||
 | 
					            enabled: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          toolbar: {
 | 
				
			||||||
 | 
					            show: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          zoom: {
 | 
				
			||||||
 | 
					            enabled: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        dataLabels: { enabled: false },
 | 
				
			||||||
 | 
					        grid: {
 | 
				
			||||||
 | 
					          show: false,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        legend: {
 | 
				
			||||||
 | 
					          show: false,
 | 
				
			||||||
 | 
					          position: "top",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        tooltip: {
 | 
				
			||||||
 | 
					          enabled: false,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        xaxis: {
 | 
				
			||||||
 | 
					          type: "datetime",
 | 
				
			||||||
 | 
					          axisBorder: {
 | 
				
			||||||
 | 
					            show: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          axisTicks: {
 | 
				
			||||||
 | 
					            show: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          labels: {
 | 
				
			||||||
 | 
					            show: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        yaxis: {
 | 
				
			||||||
 | 
					          labels: {
 | 
				
			||||||
 | 
					            show: false,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          logarithmic: true,
 | 
				
			||||||
 | 
					          logBase: 10,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					      series={data}
 | 
				
			||||||
 | 
					      height="100%"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -150,9 +150,9 @@ function ConfigEditor() {
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {success && <div className="max-h-20 text-green-500">{success}</div>}
 | 
					      {success && <div className="max-h-20 text-success">{success}</div>}
 | 
				
			||||||
      {error && (
 | 
					      {error && (
 | 
				
			||||||
        <div className="p-4 overflow-scroll text-red-500 whitespace-pre-wrap">
 | 
					        <div className="p-4 overflow-scroll text-danger whitespace-pre-wrap">
 | 
				
			||||||
          {error}
 | 
					          {error}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
				
			|||||||
@ -123,7 +123,7 @@ function Camera({ camera }: { camera: CameraConfig }) {
 | 
				
			|||||||
                    ? recordValue == "ON"
 | 
					                    ? recordValue == "ON"
 | 
				
			||||||
                      ? "text-primary"
 | 
					                      ? "text-primary"
 | 
				
			||||||
                      : "text-gray-400"
 | 
					                      : "text-gray-400"
 | 
				
			||||||
                    : "text-red-500"
 | 
					                    : "text-danger"
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                onClick={(e) => {
 | 
					                onClick={(e) => {
 | 
				
			||||||
                  e.stopPropagation();
 | 
					                  e.stopPropagation();
 | 
				
			||||||
 | 
				
			|||||||
@ -160,7 +160,7 @@ function Export() {
 | 
				
			|||||||
      {message.text && (
 | 
					      {message.text && (
 | 
				
			||||||
        <div
 | 
					        <div
 | 
				
			||||||
          className={`max-h-20 ${
 | 
					          className={`max-h-20 ${
 | 
				
			||||||
            message.error ? "text-red-500" : "text-green-500"
 | 
					            message.error ? "text-danger" : "text-success"
 | 
				
			||||||
          }`}
 | 
					          }`}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          {message.text}
 | 
					          {message.text}
 | 
				
			||||||
 | 
				
			|||||||
@ -212,7 +212,7 @@ function History() {
 | 
				
			|||||||
              Cancel
 | 
					              Cancel
 | 
				
			||||||
            </AlertDialogCancel>
 | 
					            </AlertDialogCancel>
 | 
				
			||||||
            <AlertDialogAction
 | 
					            <AlertDialogAction
 | 
				
			||||||
              className="bg-red-500"
 | 
					              className="bg-danger"
 | 
				
			||||||
              onClick={() => onDeleteMulti()}
 | 
					              onClick={() => onDeleteMulti()}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              Delete
 | 
					              Delete
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										9
									
								
								web/src/types/graph.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								web/src/types/graph.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					export type GraphDataPoint = {
 | 
				
			||||||
 | 
					  x: Date;
 | 
				
			||||||
 | 
					  y: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type GraphData = {
 | 
				
			||||||
 | 
					  name?: string;
 | 
				
			||||||
 | 
					  data: GraphDataPoint[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -56,6 +56,12 @@ interface HistoryFilter extends FilterType {
 | 
				
			|||||||
  detailLevel: "normal" | "extra" | "full";
 | 
					  detailLevel: "normal" | "extra" | "full";
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type HistoryTimeline = {
 | 
				
			||||||
 | 
					  start: number;
 | 
				
			||||||
 | 
					  end: number;
 | 
				
			||||||
 | 
					  playbackItems: TimelinePlayback[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type TimelinePlayback = {
 | 
					type TimelinePlayback = {
 | 
				
			||||||
  camera: string;
 | 
					  camera: string;
 | 
				
			||||||
  range: { start: number; end: number };
 | 
					  range: { start: number; end: number };
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,30 @@
 | 
				
			|||||||
type Recording = {
 | 
					type Recording = {
 | 
				
			||||||
    id: string,
 | 
					  id: string;
 | 
				
			||||||
    camera: string,
 | 
					  camera: string;
 | 
				
			||||||
    start_time: number,
 | 
					  start_time: number;
 | 
				
			||||||
    end_time: number,
 | 
					  end_time: number;
 | 
				
			||||||
    path: string,
 | 
					  path: string;
 | 
				
			||||||
    segment_size: number,
 | 
					  segment_size: number;
 | 
				
			||||||
    motion: number,
 | 
					  motion: number;
 | 
				
			||||||
    objects: number,
 | 
					  objects: number;
 | 
				
			||||||
    dBFS: number,
 | 
					  dBFS: number;
 | 
				
			||||||
}
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type RecordingSegment = {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  start_time: number;
 | 
				
			||||||
 | 
					  end_time: number;
 | 
				
			||||||
 | 
					  motion: number;
 | 
				
			||||||
 | 
					  objects: number;
 | 
				
			||||||
 | 
					  segment_size: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type RecordingActivity = {
 | 
				
			||||||
 | 
					  [hour: number]: RecordingSegmentActivity[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type RecordingSegmentActivity = {
 | 
				
			||||||
 | 
					  date: number;
 | 
				
			||||||
 | 
					  count: number;
 | 
				
			||||||
 | 
					  type: "motion" | "objects";
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -107,13 +107,14 @@ export function getTimelineHoursForDay(
 | 
				
			|||||||
  cards: CardsData,
 | 
					  cards: CardsData,
 | 
				
			||||||
  allPreviews: Preview[],
 | 
					  allPreviews: Preview[],
 | 
				
			||||||
  timestamp: number
 | 
					  timestamp: number
 | 
				
			||||||
): TimelinePlayback[] {
 | 
					): HistoryTimeline {
 | 
				
			||||||
  const now = new Date();
 | 
					  const now = new Date();
 | 
				
			||||||
  const data: TimelinePlayback[] = [];
 | 
					  const data: TimelinePlayback[] = [];
 | 
				
			||||||
  const startDay = new Date(timestamp * 1000);
 | 
					  const startDay = new Date(timestamp * 1000);
 | 
				
			||||||
  startDay.setHours(23, 59, 59, 999);
 | 
					  startDay.setHours(23, 59, 59, 999);
 | 
				
			||||||
  const dayEnd = startDay.getTime() / 1000;
 | 
					  const dayEnd = startDay.getTime() / 1000;
 | 
				
			||||||
  startDay.setHours(0, 0, 0, 0);
 | 
					  startDay.setHours(0, 0, 0, 0);
 | 
				
			||||||
 | 
					  const startTimestamp = startDay.getTime() / 1000;
 | 
				
			||||||
  let start = startDay.getTime() / 1000;
 | 
					  let start = startDay.getTime() / 1000;
 | 
				
			||||||
  let end = 0;
 | 
					  let end = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -134,7 +135,7 @@ export function getTimelineHoursForDay(
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (dayIdx == undefined) {
 | 
					  if (dayIdx == undefined) {
 | 
				
			||||||
    return [];
 | 
					    return { start: 0, end: 0, playbackItems: [] };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const day = cards[dayIdx];
 | 
					  const day = cards[dayIdx];
 | 
				
			||||||
@ -179,5 +180,5 @@ export function getTimelineHoursForDay(
 | 
				
			|||||||
    start = startDay.getTime() / 1000;
 | 
					    start = startDay.getTime() / 1000;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return data.reverse();
 | 
					  return { start: startTimestamp, end, playbackItems: data.reverse() };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,8 @@ import useSWR from "swr";
 | 
				
			|||||||
import Player from "video.js/dist/types/player";
 | 
					import Player from "video.js/dist/types/player";
 | 
				
			||||||
import TimelineItemCard from "@/components/card/TimelineItemCard";
 | 
					import TimelineItemCard from "@/components/card/TimelineItemCard";
 | 
				
			||||||
import { getTimelineHoursForDay } from "@/utils/historyUtil";
 | 
					import { getTimelineHoursForDay } from "@/utils/historyUtil";
 | 
				
			||||||
 | 
					import { GraphDataPoint } from "@/types/graph";
 | 
				
			||||||
 | 
					import TimelineGraph from "@/components/graph/TimelineGraph";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type DesktopTimelineViewProps = {
 | 
					type DesktopTimelineViewProps = {
 | 
				
			||||||
  timelineData: CardsData;
 | 
					  timelineData: CardsData;
 | 
				
			||||||
@ -166,6 +168,50 @@ export default function DesktopTimelineView({
 | 
				
			|||||||
    []
 | 
					    []
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { data: activity } = useSWR<RecordingActivity>(
 | 
				
			||||||
 | 
					    [
 | 
				
			||||||
 | 
					      `${initialPlayback.camera}/recording/hourly/activity`,
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        after: timelineStack.start,
 | 
				
			||||||
 | 
					        before: timelineStack.end,
 | 
				
			||||||
 | 
					        timezone,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    { revalidateOnFocus: false }
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const timelineGraphData = useMemo(() => {
 | 
				
			||||||
 | 
					    if (!activity) {
 | 
				
			||||||
 | 
					      return {};
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const graphData: {
 | 
				
			||||||
 | 
					      [hour: string]: { objects: GraphDataPoint[]; motion: GraphDataPoint[] };
 | 
				
			||||||
 | 
					    } = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Object.entries(activity).forEach(([hour, data]) => {
 | 
				
			||||||
 | 
					      const objects: GraphDataPoint[] = [];
 | 
				
			||||||
 | 
					      const motion: GraphDataPoint[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      data.forEach((seg) => {
 | 
				
			||||||
 | 
					        if (seg.type == "objects") {
 | 
				
			||||||
 | 
					          objects.push({
 | 
				
			||||||
 | 
					            x: new Date(seg.date * 1000),
 | 
				
			||||||
 | 
					            y: seg.count,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          motion.push({
 | 
				
			||||||
 | 
					            x: new Date(seg.date * 1000),
 | 
				
			||||||
 | 
					            y: seg.count,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      graphData[hour] = { objects, motion };
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return graphData;
 | 
				
			||||||
 | 
					  }, [activity]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!config) {
 | 
					  if (!config) {
 | 
				
			||||||
    return <ActivityIndicator />;
 | 
					    return <ActivityIndicator />;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -271,14 +317,17 @@ export default function DesktopTimelineView({
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div className="m-1 max-h-72 2xl:max-h-80 3xl:max-h-96 overflow-auto">
 | 
					      <div className="m-1 max-h-72 2xl:max-h-80 3xl:max-h-96 overflow-auto">
 | 
				
			||||||
        {timelineStack.map((timeline) => {
 | 
					        {timelineStack.playbackItems.map((timeline) => {
 | 
				
			||||||
          const isSelected =
 | 
					          const isSelected =
 | 
				
			||||||
            timeline.range.start == selectedPlayback.range.start;
 | 
					            timeline.range.start == selectedPlayback.range.start;
 | 
				
			||||||
 | 
					          const graphData = timelineGraphData[timeline.range.start];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          return (
 | 
					          return (
 | 
				
			||||||
            <div
 | 
					            <div
 | 
				
			||||||
              key={timeline.range.start}
 | 
					              key={timeline.range.start}
 | 
				
			||||||
              className={`p-2 ${isSelected ? "bg-secondary bg-opacity-30 rounded-md" : ""}`}
 | 
					              className={`relative p-2 ${
 | 
				
			||||||
 | 
					                isSelected ? "bg-secondary bg-opacity-30 rounded-md" : ""
 | 
				
			||||||
 | 
					              }`}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <ActivityScrubber
 | 
					              <ActivityScrubber
 | 
				
			||||||
                items={[]}
 | 
					                items={[]}
 | 
				
			||||||
@ -324,9 +373,6 @@ export default function DesktopTimelineView({
 | 
				
			|||||||
                  setScrubbing(false);
 | 
					                  setScrubbing(false);
 | 
				
			||||||
                  playerRef.current?.play();
 | 
					                  playerRef.current?.play();
 | 
				
			||||||
                }}
 | 
					                }}
 | 
				
			||||||
                doubleClickHandler={() => {
 | 
					 | 
				
			||||||
                  setSelectedPlayback(timeline);
 | 
					 | 
				
			||||||
                }}
 | 
					 | 
				
			||||||
                selectHandler={(data) => {
 | 
					                selectHandler={(data) => {
 | 
				
			||||||
                  if (data.items.length > 0) {
 | 
					                  if (data.items.length > 0) {
 | 
				
			||||||
                    const selected = data.items[0];
 | 
					                    const selected = data.items[0];
 | 
				
			||||||
@ -337,7 +383,22 @@ export default function DesktopTimelineView({
 | 
				
			|||||||
                    );
 | 
					                    );
 | 
				
			||||||
                  }
 | 
					                  }
 | 
				
			||||||
                }}
 | 
					                }}
 | 
				
			||||||
 | 
					                doubleClickHandler={() => setSelectedPlayback(timeline)}
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
 | 
					              {isSelected && graphData && (
 | 
				
			||||||
 | 
					                <div className="w-full absolute left-0 top-0 h-[84px]">
 | 
				
			||||||
 | 
					                  <TimelineGraph
 | 
				
			||||||
 | 
					                    id={timeline.range.start.toString()}
 | 
				
			||||||
 | 
					                    data={[
 | 
				
			||||||
 | 
					                      {
 | 
				
			||||||
 | 
					                        name: "Motion",
 | 
				
			||||||
 | 
					                        data: graphData.motion,
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                      { name: "Active Objects", data: graphData.objects },
 | 
				
			||||||
 | 
					                    ]}
 | 
				
			||||||
 | 
					                  />
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
        })}
 | 
					        })}
 | 
				
			||||||
 | 
				
			|||||||
@ -20,6 +20,12 @@ module.exports = {
 | 
				
			|||||||
        border: "hsl(var(--border))",
 | 
					        border: "hsl(var(--border))",
 | 
				
			||||||
        input: "hsl(var(--input))",
 | 
					        input: "hsl(var(--input))",
 | 
				
			||||||
        ring: "hsl(var(--ring))",
 | 
					        ring: "hsl(var(--ring))",
 | 
				
			||||||
 | 
					        danger: "#ef4444",
 | 
				
			||||||
 | 
					        success: "#22c55e",
 | 
				
			||||||
 | 
					        // detection colors
 | 
				
			||||||
 | 
					        motion: "#991b1b",
 | 
				
			||||||
 | 
					        object: "#06b6d4",
 | 
				
			||||||
 | 
					        audio: "#ea580c",
 | 
				
			||||||
        background: "hsl(var(--background))",
 | 
					        background: "hsl(var(--background))",
 | 
				
			||||||
        foreground: "hsl(var(--foreground))",
 | 
					        foreground: "hsl(var(--foreground))",
 | 
				
			||||||
        primary: {
 | 
					        primary: {
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user