initial working konva

This commit is contained in:
Josh Hawkins 2024-04-05 07:25:23 -05:00
parent 392ff1319d
commit 7556d08e1c
14 changed files with 981 additions and 10 deletions

93
web/package-lock.json generated
View File

@ -37,6 +37,7 @@
"hls.js": "^1.5.8", "hls.js": "^1.5.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.0.4", "immer": "^10.0.4",
"konva": "^9.3.6",
"lucide-react": "^0.368.0", "lucide-react": "^0.368.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@ -47,6 +48,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.51.3",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
"react-konva": "^18.2.10",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14", "react-tracked": "^1.7.14",
@ -2518,8 +2520,7 @@
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.11", "version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
"devOptional": true
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.2.78", "version": "18.2.78",
@ -2550,6 +2551,14 @@
"react-icons": "*" "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": { "node_modules/@types/react-transition-group": {
"version": "4.4.10", "version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
@ -2559,6 +2568,11 @@
"@types/react": "*" "@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": { "node_modules/@types/semver": {
"version": "7.5.6", "version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
@ -5046,6 +5060,17 @@
"node": ">=8" "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": { "node_modules/jest-diff": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
@ -5177,6 +5202,25 @@
"json-buffer": "3.0.1" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -6289,6 +6333,51 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true "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": { "node_modules/react-remove-scroll": {
"version": "2.5.5", "version": "2.5.5",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",

View File

@ -42,6 +42,7 @@
"hls.js": "^1.5.8", "hls.js": "^1.5.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.0.4", "immer": "^10.0.4",
"konva": "^9.3.6",
"lucide-react": "^0.368.0", "lucide-react": "^0.368.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@ -52,6 +53,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.51.3",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
"react-konva": "^18.2.10",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14", "react-tracked": "^1.7.14",

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -206,3 +206,31 @@ export function useAudioActivity(camera: string): { payload: number } {
} = useWs(`${camera}/audio/rms`, ""); } = useWs(`${camera}/audio/rms`, "");
return { payload: payload as number }; 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 };
}

View 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;

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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 />
</>
);
}

View 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;

View File

@ -10,8 +10,11 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; 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 ( return (
<> <>
<Heading as="h2">Settings</Heading> <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
View File

@ -0,0 +1,4 @@
export type Point = {
x: number;
y: number;
};

View 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];
};

View File

@ -12,24 +12,24 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:5000", target: "http://192.168.5.4:5000",
ws: true, ws: true,
}, },
"/vod": { "/vod": {
target: "http://localhost:5000", target: "http://192.168.5.4:5000",
}, },
"/clips": { "/clips": {
target: "http://localhost:5000", target: "http://192.168.5.4:5000",
}, },
"/exports": { "/exports": {
target: "http://localhost:5000", target: "http://192.168.5.4:5000",
}, },
"/ws": { "/ws": {
target: "ws://localhost:5000", target: "ws://192.168.5.4:5000",
ws: true, ws: true,
}, },
"/live": { "/live": {
target: "ws://localhost:5000", target: "ws://192.168.5.4:5000",
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },