diff --git a/web/package-lock.json b/web/package-lock.json index 262c11421..a09f066ea 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,6 +37,7 @@ "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", "immer": "^10.0.4", + "konva": "^9.3.6", "lucide-react": "^0.368.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", @@ -47,6 +48,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.51.3", "react-icons": "^5.0.1", + "react-konva": "^18.2.10", "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", @@ -2518,8 +2520,7 @@ "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "devOptional": true + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { "version": "18.2.78", @@ -2550,6 +2551,14 @@ "react-icons": "*" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.8.tgz", + "integrity": "sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -2559,6 +2568,11 @@ "@types/react": "*" } }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -5046,6 +5060,17 @@ "node": ">=8" } }, + "node_modules/its-fine": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.1.3.tgz", + "integrity": "sha512-mncCA+yb6tuh5zK26cHqKlsSyxm4zdm4YgJpxycyx6p9fgxgK5PLu3iDVpKhzTn57Yrv3jk/r0aK0RFTT1OjFw==", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -5177,6 +5202,25 @@ "json-buffer": "3.0.1" } }, + "node_modules/konva": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.6.tgz", + "integrity": "sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ] + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6289,6 +6333,51 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/react-konva": { + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz", + "integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.1.1", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz", + "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", diff --git a/web/package.json b/web/package.json index 626b9a409..c39567e4c 100644 --- a/web/package.json +++ b/web/package.json @@ -42,6 +42,7 @@ "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", "immer": "^10.0.4", + "konva": "^9.3.6", "lucide-react": "^0.368.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", @@ -52,6 +53,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.51.3", "react-icons": "^5.0.1", + "react-konva": "^18.2.10", "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", diff --git a/web/public/space_landscape.jpg b/web/public/space_landscape.jpg new file mode 100644 index 000000000..dc0987e55 Binary files /dev/null and b/web/public/space_landscape.jpg differ diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index a6638a94d..362a98749 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -206,3 +206,31 @@ export function useAudioActivity(camera: string): { payload: number } { } = useWs(`${camera}/audio/rms`, ""); return { payload: payload as number }; } + +export function useMotionThreshold(camera: string): { + payload: string; + send: (payload: number, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/motion_threshold/state`, + `${camera}/motion_threshold/set`, + ); + return { payload: payload as string, send }; +} + +export function useMotionContourArea(camera: string): { + payload: string; + send: (payload: number, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/motion_contour_area/state`, + `${camera}/motion_contour_area/set`, + ); + return { payload: payload as string, send }; +} diff --git a/web/src/components/settings/DebugCanvas.tsx b/web/src/components/settings/DebugCanvas.tsx new file mode 100644 index 000000000..e0739fc15 --- /dev/null +++ b/web/src/components/settings/DebugCanvas.tsx @@ -0,0 +1,197 @@ +import React, { useMemo, useRef, useState, useEffect } from "react"; +import PolygonDrawer from "./PolygonDrawer"; +import { Stage, Layer, Image } from "react-konva"; +import Konva from "konva"; +import type { KonvaEventObject } from "konva/lib/Node"; +import { Button } from "../ui/button"; + +const videoSource = "./space_landscape.jpg"; + +const wrapperStyle: React.CSSProperties = { + display: "flex", + justifyContent: "center", + marginTop: 20, + backgroundColor: "aliceblue", +}; + +const columnStyle: React.CSSProperties = { + display: "flex", + justifyContent: "center", + flexDirection: "column", + alignItems: "center", + marginTop: 20, + backgroundColor: "aliceblue", +}; + +export function DebugCanvas() { + const [image, setImage] = useState(); + const imageRef = useRef(null); + const dataRef = useRef(null); + const stageRef = useRef(null); + const [points, setPoints] = useState([]); + const [size, setSize] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + const [flattenedPoints, setFlattenedPoints] = useState([]); + const [position, setPosition] = useState([0, 0]); + const [isMouseOverPoint, setMouseOverPoint] = useState(false); + const [isPolyComplete, setPolyComplete] = useState(false); + + const videoElement = useMemo(() => { + const element = new window.Image(); + element.width = 650; + element.height = 302; + element.src = videoSource; + return element; + }, []); // No dependency needed here since videoSource is a constant + + useEffect(() => { + const onload = function () { + setSize({ + width: videoElement.width, + height: videoElement.height, + }); + setImage(videoElement); + if (imageRef.current) imageRef.current = videoElement; + }; + videoElement.addEventListener("load", onload); + return () => { + videoElement.removeEventListener("load", onload); + }; + }, [videoElement]); + + const getMousePos = (stage: Konva.Stage) => { + return [stage.getPointerPosition()!.x, stage.getPointerPosition()!.y]; + }; + + const handleMouseDown = (e: KonvaEventObject) => { + if (isPolyComplete) return; + const stage = e.target.getStage()!; + const mousePos = getMousePos(stage); + if (isMouseOverPoint && points.length >= 3) { + setPolyComplete(true); + } else { + setPoints([...points, mousePos]); + } + }; + + const handleMouseMove = (e: KonvaEventObject) => { + const stage = e.target.getStage()!; + const mousePos = getMousePos(stage); + setPosition(mousePos); + }; + + const handleMouseOverStartPoint = (e: KonvaEventObject) => { + if (isPolyComplete || points.length < 3) return; + e.currentTarget.scale({ x: 3, y: 3 }); + setMouseOverPoint(true); + }; + + const handleMouseOutStartPoint = (e: KonvaEventObject) => { + e.currentTarget.scale({ x: 1, y: 1 }); + setMouseOverPoint(false); + }; + + const handlePointDragMove = (e: KonvaEventObject) => { + const stage = e.target.getStage(); + if (stage) { + const index = e.target.index - 1; + const pos = [e.target._lastPos!.x, e.target._lastPos!.y]; + if (pos[0] < 0) pos[0] = 0; + if (pos[1] < 0) pos[1] = 0; + if (pos[0] > stage.width()) pos[0] = stage.width(); + if (pos[1] > stage.height()) pos[1] = stage.height(); + setPoints([...points.slice(0, index), pos, ...points.slice(index + 1)]); + } + }; + + useEffect(() => { + setFlattenedPoints( + points + .concat(isPolyComplete ? [] : position) + .reduce((a, b) => a.concat(b), []), + ); + }, [points, isPolyComplete, position]); + + const undo = () => { + setPoints(points.slice(0, -1)); + setPolyComplete(false); + setPosition(points[points.length - 1]); + }; + + const reset = () => { + setPoints([]); + setPolyComplete(false); + }; + + const handleGroupDragEnd = (e: KonvaEventObject) => { + if (e.target.name() === "polygon") { + const result: number[][] = []; + const copyPoints = [...points]; + copyPoints.map((point) => + result.push([point[0] + e.target.x(), point[1] + e.target.y()]), + ); + e.target.position({ x: 0, y: 0 }); + setPoints(result); + } + }; + + return ( +
+
+ + + + + + +
+
+
+
+
{JSON.stringify(points)}
+
+
+ ); +} + +export default DebugCanvas; diff --git a/web/src/components/settings/MotionTuner.tsx b/web/src/components/settings/MotionTuner.tsx new file mode 100644 index 000000000..bb49bd052 --- /dev/null +++ b/web/src/components/settings/MotionTuner.tsx @@ -0,0 +1,141 @@ +import Heading from "@/components/ui/heading"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import DebugCameraImage from "@/components/camera/DebugCameraImage"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useCallback, useMemo, useState } from "react"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { useMotionContourArea, useMotionThreshold } from "@/api/ws"; + +export default function MotionTuner() { + const { data: config } = useSWR("config"); + + const cameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter((conf) => conf.ui.dashboard && conf.enabled) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const [selectedCamera, setSelectedCamera] = useState(cameras[0].name); + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + const motionThreshold = useMemo(() => { + return cameraConfig?.motion.threshold ?? 0; + }, [cameraConfig?.motion.threshold]); + + const motionContourArea = useMemo( + () => cameraConfig?.motion.contour_area ?? 0, + [cameraConfig?.motion.contour_area], + ); + + const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera); + const { send: sendMotionContourArea } = useMotionContourArea(selectedCamera); + + const setMotionThreshold = useCallback( + (threshold: number) => { + if (cameraConfig && threshold != motionThreshold) { + cameraConfig.motion.threshold = threshold; + sendMotionThreshold(threshold); + console.log("setting motion threshold", threshold); + } + }, + [cameraConfig, motionThreshold, sendMotionThreshold], + ); + + const setMotionContourArea = useCallback( + (contour_area: number) => { + if (cameraConfig && contour_area != motionContourArea) { + cameraConfig.motion.contour_area = contour_area; + sendMotionContourArea(contour_area); + console.log("setting motion contour area", contour_area); + } + }, + [cameraConfig, motionContourArea, sendMotionContourArea], + ); + + if (!cameraConfig && !selectedCamera) { + return ; + } + + // console.log("selected camera", selectedCamera); + // console.log("threshold", motionThreshold); + // console.log("contour area", motionContourArea); + // console.log(cameraConfig); + + return ( + <> + Motion Detection Tuner +
+ +
+ +
+
+ setMotionThreshold(value[0])} + /> + +
+
+ setMotionContourArea(value[0])} + /> + +
+
+ + ); +} diff --git a/web/src/components/settings/PolygonDrawer-off.tsx b/web/src/components/settings/PolygonDrawer-off.tsx new file mode 100644 index 000000000..64fbd6e71 --- /dev/null +++ b/web/src/components/settings/PolygonDrawer-off.tsx @@ -0,0 +1,180 @@ +import { useEffect, useRef, useState } from "react"; +import Konva from "konva"; +import { Stage, Layer, Line, Circle, Transformer } from "react-konva"; +import useDoubleClick from "@/hooks/use-double-click"; + +export default function PolygonDrawer() { + const [points, setPoints] = useState([]); + const [anchors, setAnchors] = useState([]); + const [lastPointIndex, setLastPointIndex] = useState(0); + const [isDrawing, setIsDrawing] = useState(false); + const [selectedAnchorIndex, setSelectedAnchorIndex] = useState( + null, + ); + const containerRef = useRef(null); + const stageRef = useRef(null); + const layerRef = useRef(null); + + const handleMouseDown = () => { + if (!stageRef.current) { + return; + } + + const stage = stageRef.current.getStage(); + const mousePos = stage?.getPointerPosition(); + if (!mousePos) return; + + const updatedPoints = [...points]; + updatedPoints.push(mousePos.x, mousePos.y); + setPoints(updatedPoints); + + const newAnchor = new Konva.Circle({ + x: mousePos.x, + y: mousePos.y, + radius: 5, + fill: "blue", + draggable: true, + name: "anchor", + }); + setAnchors((prevAnchors) => [...prevAnchors, newAnchor]); + layerRef.current?.add(newAnchor); + + setIsDrawing(true); + }; + console.log(isDrawing); + + const handleMouseMove = () => { + const stage = stageRef.current?.getStage(); + const mousePos = stage?.getPointerPosition(); + if (!mousePos || !isDrawing) return; + + const updatedPoints = [...points]; + updatedPoints[updatedPoints.length - 2] = mousePos.x; + updatedPoints[updatedPoints.length - 1] = mousePos.y; + setPoints(updatedPoints); + layerRef.current?.batchDraw(); + }; + + const handleDoubleClick = () => { + alert("double clicked 1"); + const layer = layerRef.current; + console.log(layer); + if (!layer) return; + console.log("double clicked"); + document.body.style.background = "red"; + + const tempLine = layerRef.current.findOne(".temp"); + if (tempLine) tempLine.destroy(); + + const polyObj = new Konva.Line({ + points, + name: "poly", + fill: "green", + stroke: "red", + strokeWidth: 1, + draggable: true, + closed: true, + hitStrokeWidth: 10, + }); + layerRef.current.add(polyObj); + layerRef.current.batchDraw(); + + setIsDrawing(false); + setLastPointIndex(0); + setPoints([]); + setAnchors([]); + }; + + const handleStageClick = (e: Konva.KonvaEventObject) => { + const clickedTarget = e.target; + if (clickedTarget instanceof Konva.Circle) { + const index = anchors.findIndex((anchor) => anchor === clickedTarget); + setSelectedAnchorIndex(index); + } else { + setSelectedAnchorIndex(null); + } + }; + + const handleAnchorDragMove = (index: number) => { + const stage = stageRef.current?.getStage(); + const mousePos = stage?.getPointerPosition(); + if (!mousePos) return; + + const updatedAnchors = [...anchors]; + updatedAnchors[index] = new Konva.Circle({ + ...updatedAnchors[index].attrs, + x: mousePos.x, + y: mousePos.y, + }); + setAnchors(updatedAnchors); + + const updatedPoints = [...points]; + updatedPoints[index * 2] = mousePos.x; + updatedPoints[index * 2 + 1] = mousePos.y; + setPoints(updatedPoints); + + layerRef.current?.batchDraw(); + }; + + useEffect(() => { + const stage = stageRef.current; + if (stage) { + console.log(stage); + stage.on("mousedown", handleMouseDown); + stage.on("mousemove", handleMouseMove); + stage.on("dblclick", handleDoubleClick); + } + return () => { + if (stage) { + stage.off("mousedown", handleMouseDown); + stage.off("mousemove", handleMouseMove); + stage.off("dblclick", handleDoubleClick); + } + }; + }, []); + + console.log(points); + + return ( +
+ { + // e.cancelBubble = true; + // console.log("dbl"); + // handleDoubleClick(); + // }} + // onMouseDown={handleMouseDown} + // onMouseMove={handleMouseMove} + // onClick={handleStageClick} + ref={stageRef} + > + + {isDrawing && ( + + )} + {anchors.map((anchor, index) => ( + handleAnchorDragMove(index, e)} + /> + ))} + + +
debug
+
+ ); +} diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx new file mode 100644 index 000000000..8e95f70d3 --- /dev/null +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { Line, Circle, Group } from "react-konva"; +import { minMax, dragBoundFunc } from "@/utils/canvasUtil"; +import type { KonvaEventObject } from "konva/lib/Node"; +import Konva from "konva"; +import { Vector2d } from "konva/lib/types"; + +type PolygonDrawerProps = { + points: number[][]; + flattenedPoints: number[]; + isFinished: boolean; + handlePointDragMove: (e: KonvaEventObject) => void; + handleGroupDragEnd: (e: KonvaEventObject) => void; + handleMouseOverStartPoint: (e: KonvaEventObject) => void; + handleMouseOutStartPoint: (e: KonvaEventObject) => void; +}; + +export default function PolygonDrawer({ + points, + flattenedPoints, + isFinished, + handlePointDragMove, + handleGroupDragEnd, + handleMouseOverStartPoint, + handleMouseOutStartPoint, +}: PolygonDrawerProps) { + const vertexRadius = 6; + + const [stage, setStage] = useState(); + + const [minMaxX, setMinMaxX] = useState<[number, number]>([0, 0]); //min and max in x axis + const [minMaxY, setMinMaxY] = useState<[number, number]>([0, 0]); //min and max in y axis + + const handleGroupMouseOver = (e: Konva.KonvaEventObject) => { + if (!isFinished) return; + e.target.getStage()!.container().style.cursor = "move"; + setStage(e.target.getStage()!); + }; + + const handleGroupMouseOut = (e: Konva.KonvaEventObject) => { + if (!e.target) return; + e.target.getStage()!.container().style.cursor = "default"; + }; + + const handleGroupDragStart = () => { + const arrX = points.map((p) => p[0]); + const arrY = points.map((p) => p[1]); + setMinMaxX(minMax(arrX)); + setMinMaxY(minMax(arrY)); + }; + + const groupDragBound = (pos: Vector2d) => { + if (!stage) { + return pos; + } + let { x, y } = pos; + const sw = stage.width(); + const sh = stage.height(); + if (minMaxY[0] + y < 0) y = -1 * minMaxY[0]; + if (minMaxX[0] + x < 0) x = -1 * minMaxX[0]; + if (minMaxY[1] + y > sh) y = sh - minMaxY[1]; + if (minMaxX[1] + x > sw) x = sw - minMaxX[1]; + return { x, y }; + }; + + // const flattenedPointsAsNumber = useMemo( + // () => flattenedPoints.flatMap((point) => [point.x, point.y]), + // [flattenedPoints], + // ); + + return ( + + + {points.map((point, index) => { + const x = point[0] - vertexRadius / 2; + const y = point[1] - vertexRadius / 2; + const startPointAttr = + index === 0 + ? { + hitStrokeWidth: 12, + onMouseOver: handleMouseOverStartPoint, + onMouseOut: handleMouseOutStartPoint, + } + : null; + return ( + { + if (stage) { + return dragBoundFunc( + stage.width(), + stage.height(), + vertexRadius, + pos, + ); + } else { + return pos; // Return original pos if stage is not defined + } + }} + {...startPointAttr} + /> + ); + })} + + ); +} diff --git a/web/src/components/settings/Zones.tsx b/web/src/components/settings/Zones.tsx new file mode 100644 index 000000000..a8f01c5c8 --- /dev/null +++ b/web/src/components/settings/Zones.tsx @@ -0,0 +1,79 @@ +import Heading from "@/components/ui/heading"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import DebugCameraImage from "@/components/camera/DebugCameraImage"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useCallback, useMemo, useState } from "react"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { useMotionContourArea, useMotionThreshold } from "@/api/ws"; +import DebugCanvas from "./DebugCanvas"; + +export default function SettingsZones() { + const { data: config } = useSWR("config"); + + const cameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter((conf) => conf.ui.dashboard && conf.enabled) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const [selectedCamera, setSelectedCamera] = useState(cameras[0].name); + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + if (!cameraConfig && !selectedCamera) { + return ; + } + + // console.log("selected camera", selectedCamera); + // console.log("threshold", motionThreshold); + // console.log("contour area", motionContourArea); + // console.log(cameraConfig); + + return ( + <> + Motion Detection Tuner +
+ +
+ + + + ); +} diff --git a/web/src/hooks/use-double-click.ts b/web/src/hooks/use-double-click.ts new file mode 100644 index 000000000..db3048f20 --- /dev/null +++ b/web/src/hooks/use-double-click.ts @@ -0,0 +1,50 @@ +import { useEffect, MutableRefObject } from "react"; + +/** + * A simple React hook for differentiating single and double clicks on the same component. + * + * @param ref Dom node to watch for double clicks + * @param latency The amount of time (in milliseconds) to wait before differentiating a single from a double click + * @param onSingleClick A callback function for single click events + * @param onDoubleClick A callback function for double click events + */ +const useDoubleClick = ({ + ref, + latency = 300, + onSingleClick = () => null, + onDoubleClick = () => null, +}: { + ref: MutableRefObject; + latency?: number; + onSingleClick?: (e: MouseEvent) => void; + onDoubleClick?: (e: MouseEvent) => void; +}) => { + useEffect(() => { + const clickRef = ref.current; + let clickCount = 0; + const handleClick = (e: MouseEvent) => { + clickCount += 1; + + setTimeout(() => { + if (clickCount === 1) onSingleClick(e); + else if (clickCount === 2) onDoubleClick(e); + + clickCount = 0; + }, latency); + }; + + // Add event listener for click events + if (clickRef) { + clickRef.addEventListener("click", handleClick); + } + + // Remove event listener + return () => { + if (clickRef) { + clickRef.removeEventListener("click", handleClick); + } + }; + }, [ref, latency, onSingleClick, onDoubleClick]); +}; + +export default useDoubleClick; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index b80040203..65cf793f3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -10,8 +10,11 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import MotionTuner from "@/components/settings/MotionTuner"; +import SettingsZones from "@/components/settings/Zones"; -function Settings() { +function General() { return ( <> Settings @@ -41,4 +44,29 @@ function Settings() { ); } -export default Settings; +export default function Settings() { + return ( + <> + + + General + Objects + Zones + Masks + Motion + + + + + Objects + + + + Masks + + + + + + ); +} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts new file mode 100644 index 000000000..6522523a5 --- /dev/null +++ b/web/src/types/canvas.ts @@ -0,0 +1,4 @@ +export type Point = { + x: number; + y: number; +}; diff --git a/web/src/utils/canvasUtil.ts b/web/src/utils/canvasUtil.ts new file mode 100644 index 000000000..7a7bc25cd --- /dev/null +++ b/web/src/utils/canvasUtil.ts @@ -0,0 +1,45 @@ +export const getAveragePoint = (points: number[]): number => { + let totalX = 0; + let totalY = 0; + for (let i = 0; i < points.length; i += 2) { + totalX += points[i]; + totalY += points[i + 1]; + } + return { + x: totalX / (points.length / 2), + y: totalY / (points.length / 2), + }; +}; + +export const getDistance = (node1: number[], node2: number[]): string => { + const diffX = Math.abs(node1[0] - node2[0]); + const diffY = Math.abs(node1[1] - node2[1]); + const distanceInPixel = Math.sqrt(diffX * diffX + diffY * diffY); + return distanceInPixel.toFixed(2); +}; + +export const dragBoundFunc = ( + stageWidth: number, + stageHeight: number, + vertexRadius: number, + pos: Point, +): Point => { + let x = pos.x; + let y = pos.y; + if (pos.x + vertexRadius > stageWidth) x = stageWidth; + if (pos.x - vertexRadius < 0) x = 0; + if (pos.y + vertexRadius > stageHeight) y = stageHeight; + if (pos.y - vertexRadius < 0) y = 0; + return { x, y }; +}; + +export const minMax = (points: number[]): [number, number] => { + return points.reduce( + (acc: [number | undefined, number | undefined], val) => { + acc[0] = acc[0] === undefined || val < acc[0] ? val : acc[0]; + acc[1] = acc[1] === undefined || val > acc[1] ? val : acc[1]; + return acc; + }, + [undefined, undefined], + ) as [number, number]; +}; diff --git a/web/vite.config.ts b/web/vite.config.ts index 5afefa331..b914f76c9 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -12,24 +12,24 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://localhost:5000", + target: "http://192.168.5.4:5000", ws: true, }, "/vod": { - target: "http://localhost:5000", + target: "http://192.168.5.4:5000", }, "/clips": { - target: "http://localhost:5000", + target: "http://192.168.5.4:5000", }, "/exports": { - target: "http://localhost:5000", + target: "http://192.168.5.4:5000", }, "/ws": { - target: "ws://localhost:5000", + target: "ws://192.168.5.4:5000", ws: true, }, "/live": { - target: "ws://localhost:5000", + target: "ws://192.168.5.4:5000", changeOrigin: true, ws: true, },