mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-11 05:35:25 +03:00
initial working konva
This commit is contained in:
parent
392ff1319d
commit
7556d08e1c
93
web/package-lock.json
generated
93
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
web/public/space_landscape.jpg
Normal file
BIN
web/public/space_landscape.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@ -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 };
|
||||
}
|
||||
|
||||
197
web/src/components/settings/DebugCanvas.tsx
Normal file
197
web/src/components/settings/DebugCanvas.tsx
Normal file
@ -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<HTMLImageElement | undefined>();
|
||||
const imageRef = useRef<Konva.Image | null>(null);
|
||||
const dataRef = useRef<HTMLDivElement | null>(null);
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const [points, setPoints] = useState<number[][]>([]);
|
||||
const [size, setSize] = useState<{ width: number; height: number }>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const [flattenedPoints, setFlattenedPoints] = useState<number[]>([]);
|
||||
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<MouseEvent>) => {
|
||||
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<MouseEvent>) => {
|
||||
const stage = e.target.getStage()!;
|
||||
const mousePos = getMousePos(stage);
|
||||
setPosition(mousePos);
|
||||
};
|
||||
|
||||
const handleMouseOverStartPoint = (e: KonvaEventObject<MouseEvent>) => {
|
||||
if (isPolyComplete || points.length < 3) return;
|
||||
e.currentTarget.scale({ x: 3, y: 3 });
|
||||
setMouseOverPoint(true);
|
||||
};
|
||||
|
||||
const handleMouseOutStartPoint = (e: KonvaEventObject<MouseEvent>) => {
|
||||
e.currentTarget.scale({ x: 1, y: 1 });
|
||||
setMouseOverPoint(false);
|
||||
};
|
||||
|
||||
const handlePointDragMove = (e: KonvaEventObject<MouseEvent>) => {
|
||||
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<MouseEvent>) => {
|
||||
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 (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={columnStyle}>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={size.width || 650}
|
||||
height={size.height || 302}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<Layer>
|
||||
<Image
|
||||
ref={imageRef}
|
||||
image={image}
|
||||
x={0}
|
||||
y={0}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
/>
|
||||
<PolygonDrawer
|
||||
points={points}
|
||||
flattenedPoints={flattenedPoints}
|
||||
handlePointDragMove={handlePointDragMove}
|
||||
handleGroupDragEnd={handleGroupDragEnd}
|
||||
handleMouseOverStartPoint={handleMouseOverStartPoint}
|
||||
handleMouseOutStartPoint={handleMouseOutStartPoint}
|
||||
isFinished={isPolyComplete}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Button name="Undo" onClick={undo} />
|
||||
<Button name="Reset" onClick={reset} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={dataRef}
|
||||
style={{
|
||||
width: 375,
|
||||
height: 302,
|
||||
boxShadow: ".5px .5px 5px .4em rgba(0,0,0,.1)",
|
||||
marginTop: 20,
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
<pre style={{ whiteSpace: "pre-wrap" }}>{JSON.stringify(points)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DebugCanvas;
|
||||
141
web/src/components/settings/MotionTuner.tsx
Normal file
141
web/src/components/settings/MotionTuner.tsx
Normal file
@ -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<FrigateConfig>("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 <ActivityIndicator />;
|
||||
}
|
||||
|
||||
// console.log("selected camera", selectedCamera);
|
||||
// console.log("threshold", motionThreshold);
|
||||
// console.log("contour area", motionContourArea);
|
||||
// console.log(cameraConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Motion Detection Tuner</Heading>
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Select value={selectedCamera} onValueChange={setSelectedCamera}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Camera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Choose a camera</SelectLabel>
|
||||
{cameras.map((camera) => (
|
||||
<SelectItem
|
||||
key={camera.name}
|
||||
value={`${camera.name}`}
|
||||
className="capitalize"
|
||||
>
|
||||
{camera.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DebugCameraImage cameraConfig={cameraConfig} className="w-[50%]" />
|
||||
<div className="flex flex-col justify-evenly w-full">
|
||||
<div className="flex flex-row mb-5">
|
||||
<Slider
|
||||
id="motion-threshold"
|
||||
className="w-[300px]"
|
||||
value={[motionThreshold]}
|
||||
min={10}
|
||||
max={80}
|
||||
step={1}
|
||||
onValueChange={(value) => setMotionThreshold(value[0])}
|
||||
/>
|
||||
<Label htmlFor="motion-threshold" className="px-2">
|
||||
Threshold: {motionThreshold}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<Slider
|
||||
id="motion-contour-area"
|
||||
className="w-[300px]"
|
||||
value={[motionContourArea]}
|
||||
min={10}
|
||||
max={200}
|
||||
step={5}
|
||||
onValueChange={(value) => setMotionContourArea(value[0])}
|
||||
/>
|
||||
<Label htmlFor="motion-contour-area" className="px-2">
|
||||
Contour Area: {motionContourArea}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
180
web/src/components/settings/PolygonDrawer-off.tsx
Normal file
180
web/src/components/settings/PolygonDrawer-off.tsx
Normal file
@ -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<number[]>([]);
|
||||
const [anchors, setAnchors] = useState<Konva.Circle[]>([]);
|
||||
const [lastPointIndex, setLastPointIndex] = useState(0);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [selectedAnchorIndex, setSelectedAnchorIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const layerRef = useRef<Konva.Layer>(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<MouseEvent>) => {
|
||||
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 (
|
||||
<div id="konva" ref={containerRef}>
|
||||
<Stage
|
||||
width={600}
|
||||
height={480}
|
||||
// onDblClick={(e) => {
|
||||
// e.cancelBubble = true;
|
||||
// console.log("dbl");
|
||||
// handleDoubleClick();
|
||||
// }}
|
||||
// onMouseDown={handleMouseDown}
|
||||
// onMouseMove={handleMouseMove}
|
||||
// onClick={handleStageClick}
|
||||
ref={stageRef}
|
||||
>
|
||||
<Layer ref={layerRef}>
|
||||
{isDrawing && (
|
||||
<Line
|
||||
points={points}
|
||||
name="temp"
|
||||
fill="green"
|
||||
stroke="red"
|
||||
strokeWidth={1}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{anchors.map((anchor, index) => (
|
||||
<Circle
|
||||
key={index}
|
||||
x={anchor.x()}
|
||||
y={anchor.y()}
|
||||
radius={5}
|
||||
fill={selectedAnchorIndex === index ? "red" : "blue"}
|
||||
draggable
|
||||
onDragMove={(e) => handleAnchorDragMove(index, e)}
|
||||
/>
|
||||
))}
|
||||
</Layer>
|
||||
</Stage>
|
||||
<div id="debug">debug</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
web/src/components/settings/PolygonDrawer.tsx
Normal file
128
web/src/components/settings/PolygonDrawer.tsx
Normal file
@ -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<MouseEvent>) => void;
|
||||
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent>) => void;
|
||||
handleMouseOverStartPoint: (e: KonvaEventObject<MouseEvent>) => void;
|
||||
handleMouseOutStartPoint: (e: KonvaEventObject<MouseEvent>) => void;
|
||||
};
|
||||
|
||||
export default function PolygonDrawer({
|
||||
points,
|
||||
flattenedPoints,
|
||||
isFinished,
|
||||
handlePointDragMove,
|
||||
handleGroupDragEnd,
|
||||
handleMouseOverStartPoint,
|
||||
handleMouseOutStartPoint,
|
||||
}: PolygonDrawerProps) {
|
||||
const vertexRadius = 6;
|
||||
|
||||
const [stage, setStage] = useState<Konva.Stage>();
|
||||
|
||||
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<MouseEvent>) => {
|
||||
if (!isFinished) return;
|
||||
e.target.getStage()!.container().style.cursor = "move";
|
||||
setStage(e.target.getStage()!);
|
||||
};
|
||||
|
||||
const handleGroupMouseOut = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
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 (
|
||||
<Group
|
||||
name="polygon"
|
||||
draggable={isFinished}
|
||||
onDragStart={handleGroupDragStart}
|
||||
onDragEnd={handleGroupDragEnd}
|
||||
dragBoundFunc={groupDragBound}
|
||||
onMouseOver={handleGroupMouseOver}
|
||||
onMouseOut={handleGroupMouseOut}
|
||||
>
|
||||
<Line
|
||||
points={flattenedPoints}
|
||||
stroke="#00F1FF"
|
||||
strokeWidth={3}
|
||||
closed={isFinished}
|
||||
fill="rgb(140,30,255,0.5)"
|
||||
/>
|
||||
{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 (
|
||||
<Circle
|
||||
key={index}
|
||||
x={x}
|
||||
y={y}
|
||||
radius={vertexRadius}
|
||||
fill="#FF019A"
|
||||
stroke="#00F1FF"
|
||||
strokeWidth={2}
|
||||
draggable
|
||||
onDragMove={handlePointDragMove}
|
||||
dragBoundFunc={(pos) => {
|
||||
if (stage) {
|
||||
return dragBoundFunc(
|
||||
stage.width(),
|
||||
stage.height(),
|
||||
vertexRadius,
|
||||
pos,
|
||||
);
|
||||
} else {
|
||||
return pos; // Return original pos if stage is not defined
|
||||
}
|
||||
}}
|
||||
{...startPointAttr}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
79
web/src/components/settings/Zones.tsx
Normal file
79
web/src/components/settings/Zones.tsx
Normal file
@ -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<FrigateConfig>("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 <ActivityIndicator />;
|
||||
}
|
||||
|
||||
// console.log("selected camera", selectedCamera);
|
||||
// console.log("threshold", motionThreshold);
|
||||
// console.log("contour area", motionContourArea);
|
||||
// console.log(cameraConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Motion Detection Tuner</Heading>
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Select value={selectedCamera} onValueChange={setSelectedCamera}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Camera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Choose a camera</SelectLabel>
|
||||
{cameras.map((camera) => (
|
||||
<SelectItem
|
||||
key={camera.name}
|
||||
value={`${camera.name}`}
|
||||
className="capitalize"
|
||||
>
|
||||
{camera.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DebugCameraImage cameraConfig={cameraConfig} className="w-[50%]" />
|
||||
<DebugCanvas />
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
web/src/hooks/use-double-click.ts
Normal file
50
web/src/hooks/use-double-click.ts
Normal file
@ -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<HTMLElement>;
|
||||
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;
|
||||
@ -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 (
|
||||
<>
|
||||
<Heading as="h2">Settings</Heading>
|
||||
@ -41,4 +44,29 @@ function Settings() {
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
export default function Settings() {
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue="account" className="w-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="objects">Objects</TabsTrigger>
|
||||
<TabsTrigger value="zones">Zones</TabsTrigger>
|
||||
<TabsTrigger value="masks">Masks</TabsTrigger>
|
||||
<TabsTrigger value="motion">Motion</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general">
|
||||
<General />
|
||||
</TabsContent>
|
||||
<TabsContent value="objects">Objects</TabsContent>
|
||||
<TabsContent value="zones">
|
||||
<SettingsZones />
|
||||
</TabsContent>
|
||||
<TabsContent value="masks">Masks</TabsContent>
|
||||
<TabsContent value="motion">
|
||||
<MotionTuner />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
4
web/src/types/canvas.ts
Normal file
4
web/src/types/canvas.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
45
web/src/utils/canvasUtil.ts
Normal file
45
web/src/utils/canvasUtil.ts
Normal file
@ -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];
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user