forked from Github/frigate
refactor(web): async routing
This commit is contained in:
committed by
Blake Blackshear
parent
24ec13e36d
commit
7aee28d080
114
web/src/routes/Camera.jsx
Normal file
114
web/src/routes/Camera.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { h } from 'preact';
|
||||
import AutoUpdatingCameraImage from '../components/AutoUpdatingCameraImage';
|
||||
import Button from '../components/Button';
|
||||
import Card from '../components/Card';
|
||||
import Heading from '../components/Heading';
|
||||
import Link from '../components/Link';
|
||||
import SettingsIcon from '../icons/Settings';
|
||||
import Switch from '../components/Switch';
|
||||
import { route } from 'preact-router';
|
||||
import { usePersistence } from '../context';
|
||||
import { useCallback, useContext, useMemo, useState } from 'preact/hooks';
|
||||
import { useApiHost, useConfig } from '../api';
|
||||
|
||||
export default function Camera({ camera }) {
|
||||
const { data: config } = useConfig();
|
||||
const apiHost = useApiHost();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
if (!config) {
|
||||
return <div>{`No camera named ${camera}`}</div>;
|
||||
}
|
||||
|
||||
const cameraConfig = config.cameras[camera];
|
||||
const [options, setOptions, optionsLoaded] = usePersistence(`${camera}-feed`, Object.freeze({}));
|
||||
|
||||
const objectCount = useMemo(() => cameraConfig.objects.track.length, [cameraConfig]);
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
(id, value) => {
|
||||
const newOptions = { ...options, [id]: value };
|
||||
setOptions(newOptions);
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
const searchParams = useMemo(
|
||||
() =>
|
||||
new URLSearchParams(
|
||||
Object.keys(options).reduce((memo, key) => {
|
||||
memo.push([key, options[key] === true ? '1' : '0']);
|
||||
return memo;
|
||||
}, [])
|
||||
),
|
||||
[camera, options]
|
||||
);
|
||||
|
||||
const handleToggleSettings = useCallback(() => {
|
||||
setShowSettings(!showSettings);
|
||||
}, [showSettings, setShowSettings]);
|
||||
|
||||
const optionContent = showSettings ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div className="flex space-x-3">
|
||||
<Switch checked={options['bbox']} id="bbox" onChange={handleSetOption} />
|
||||
<span class="inline-flex">Bounding box</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Switch checked={options['timestamp']} id="timestamp" onChange={handleSetOption} />
|
||||
<span class="inline-flex">Timestamp</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Switch checked={options['zones']} id="zones" onChange={handleSetOption} />
|
||||
<span class="inline-flex">Zones</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Switch checked={options['mask']} id="mask" onChange={handleSetOption} />
|
||||
<span class="inline-flex">Masks</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Switch checked={options['motion']} id="motion" onChange={handleSetOption} />
|
||||
<span class="inline-flex">Motion boxes</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Switch checked={options['regions']} id="regions" onChange={handleSetOption} />
|
||||
<span class="inline-flex">Regions</span>
|
||||
</div>
|
||||
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Heading size="2xl">{camera}</Heading>
|
||||
{optionsLoaded ? (
|
||||
<div>
|
||||
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button onClick={handleToggleSettings} type="text">
|
||||
<span class="w-5 h-5">
|
||||
<SettingsIcon />
|
||||
</span>{' '}
|
||||
<span>{showSettings ? 'Hide' : 'Show'} Options</span>
|
||||
</Button>
|
||||
{showSettings ? <Card header="Options" elevated={false} content={optionContent} /> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Heading size="sm">Tracked objects</Heading>
|
||||
<div className="flex flex-wrap justify-start">
|
||||
{cameraConfig.objects.track.map((objectType) => (
|
||||
<Card
|
||||
className="mb-4 mr-4"
|
||||
key={objectType}
|
||||
header={objectType}
|
||||
href={`/events?camera=${camera}&label=${objectType}`}
|
||||
media={<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
653
web/src/routes/CameraMap.jsx
Normal file
653
web/src/routes/CameraMap.jsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import { h } from 'preact';
|
||||
import Card from '../components/Card';
|
||||
import Button from '../components/Button';
|
||||
import Heading from '../components/Heading';
|
||||
import Switch from '../components/Switch';
|
||||
import { route } from 'preact-router';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useApiHost, useConfig } from './api';
|
||||
|
||||
export default function CameraMasks({ camera, url }) {
|
||||
const { data: config } = useConfig();
|
||||
const apiHost = useApiHost();
|
||||
const imageRef = useRef(null);
|
||||
const [imageScale, setImageScale] = useState(1);
|
||||
const [snap, setSnap] = useState(true);
|
||||
|
||||
if (!(camera in config.cameras)) {
|
||||
return <div>{`No camera named ${camera}`}</div>;
|
||||
}
|
||||
|
||||
const cameraConfig = config.cameras[camera];
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
motion: { mask: motionMask },
|
||||
objects: { filters: objectFilters },
|
||||
zones,
|
||||
} = cameraConfig;
|
||||
|
||||
const resizeObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (Array.isArray(entries) && entries.length) {
|
||||
const scaledWidth = entries[0].contentRect.width;
|
||||
const scale = scaledWidth / width;
|
||||
setImageScale(scale);
|
||||
}
|
||||
});
|
||||
}),
|
||||
[camera, width, setImageScale]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageRef.current) {
|
||||
return;
|
||||
}
|
||||
resizeObserver.observe(imageRef.current);
|
||||
}, [resizeObserver, imageRef.current]);
|
||||
|
||||
const [motionMaskPoints, setMotionMaskPoints] = useState(
|
||||
Array.isArray(motionMask)
|
||||
? motionMask.map((mask) => getPolylinePoints(mask))
|
||||
: motionMask
|
||||
? [getPolylinePoints(motionMask)]
|
||||
: []
|
||||
);
|
||||
|
||||
const [zonePoints, setZonePoints] = useState(
|
||||
Object.keys(zones).reduce((memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }), {})
|
||||
);
|
||||
|
||||
const [objectMaskPoints, setObjectMaskPoints] = useState(
|
||||
Object.keys(objectFilters).reduce(
|
||||
(memo, name) => ({
|
||||
...memo,
|
||||
[name]: Array.isArray(objectFilters[name].mask)
|
||||
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
|
||||
: objectFilters[name].mask
|
||||
? [getPolylinePoints(objectFilters[name].mask)]
|
||||
: [],
|
||||
}),
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
const [editing, setEditing] = useState({ set: motionMaskPoints, key: 0, fn: setMotionMaskPoints });
|
||||
|
||||
const handleUpdateEditable = useCallback(
|
||||
(newPoints) => {
|
||||
let newSet;
|
||||
if (Array.isArray(editing.set)) {
|
||||
newSet = [...editing.set];
|
||||
newSet[editing.key] = newPoints;
|
||||
} else if (editing.subkey !== undefined) {
|
||||
newSet = { ...editing.set };
|
||||
newSet[editing.key][editing.subkey] = newPoints;
|
||||
} else {
|
||||
newSet = { ...editing.set, [editing.key]: newPoints };
|
||||
}
|
||||
editing.set = newSet;
|
||||
editing.fn(newSet);
|
||||
},
|
||||
[editing]
|
||||
);
|
||||
|
||||
const handleSelectEditable = useCallback(
|
||||
(name) => {
|
||||
setEditing(name);
|
||||
},
|
||||
[setEditing]
|
||||
);
|
||||
|
||||
const handleRemoveEditable = useCallback(
|
||||
(name) => {
|
||||
const filteredZonePoints = Object.keys(zonePoints)
|
||||
.filter((zoneName) => zoneName !== name)
|
||||
.reduce((memo, name) => {
|
||||
memo[name] = zonePoints[name];
|
||||
return memo;
|
||||
}, {});
|
||||
setZonePoints(filteredZonePoints);
|
||||
},
|
||||
[zonePoints, setZonePoints]
|
||||
);
|
||||
|
||||
// Motion mask methods
|
||||
const handleAddMask = useCallback(() => {
|
||||
const newMotionMaskPoints = [...motionMaskPoints, []];
|
||||
setMotionMaskPoints(newMotionMaskPoints);
|
||||
setEditing({ set: newMotionMaskPoints, key: newMotionMaskPoints.length - 1, fn: setMotionMaskPoints });
|
||||
}, [motionMaskPoints, setMotionMaskPoints]);
|
||||
|
||||
const handleEditMask = useCallback(
|
||||
(key) => {
|
||||
setEditing({ set: motionMaskPoints, key, fn: setMotionMaskPoints });
|
||||
},
|
||||
[setEditing, motionMaskPoints, setMotionMaskPoints]
|
||||
);
|
||||
|
||||
const handleRemoveMask = useCallback(
|
||||
(key) => {
|
||||
const newMotionMaskPoints = [...motionMaskPoints];
|
||||
newMotionMaskPoints.splice(key, 1);
|
||||
setMotionMaskPoints(newMotionMaskPoints);
|
||||
},
|
||||
[motionMaskPoints, setMotionMaskPoints]
|
||||
);
|
||||
|
||||
const handleCopyMotionMasks = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(` motion:
|
||||
mask:
|
||||
${motionMaskPoints.map((mask, i) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
|
||||
}, [motionMaskPoints]);
|
||||
|
||||
// Zone methods
|
||||
const handleEditZone = useCallback(
|
||||
(key) => {
|
||||
setEditing({ set: zonePoints, key, fn: setZonePoints });
|
||||
},
|
||||
[setEditing, zonePoints, setZonePoints]
|
||||
);
|
||||
|
||||
const handleAddZone = useCallback(() => {
|
||||
const n = Object.keys(zonePoints).filter((name) => name.startsWith('zone_')).length;
|
||||
const zoneName = `zone_${n}`;
|
||||
const newZonePoints = { ...zonePoints, [zoneName]: [] };
|
||||
setZonePoints(newZonePoints);
|
||||
setEditing({ set: newZonePoints, key: zoneName, fn: setZonePoints });
|
||||
}, [zonePoints, setZonePoints]);
|
||||
|
||||
const handleRemoveZone = useCallback(
|
||||
(key) => {
|
||||
const newZonePoints = { ...zonePoints };
|
||||
delete newZonePoints[key];
|
||||
setZonePoints(newZonePoints);
|
||||
},
|
||||
[zonePoints, setZonePoints]
|
||||
);
|
||||
|
||||
const handleCopyZones = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(` zones:
|
||||
${Object.keys(zonePoints)
|
||||
.map(
|
||||
(zoneName) => ` ${zoneName}:
|
||||
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
|
||||
)
|
||||
.join('\n')}`);
|
||||
}, [zonePoints]);
|
||||
|
||||
// Object methods
|
||||
const handleEditObjectMask = useCallback(
|
||||
(key, subkey) => {
|
||||
setEditing({ set: objectMaskPoints, key, subkey, fn: setObjectMaskPoints });
|
||||
},
|
||||
[setEditing, objectMaskPoints, setObjectMaskPoints]
|
||||
);
|
||||
|
||||
const handleAddObjectMask = useCallback(() => {
|
||||
const n = Object.keys(objectMaskPoints).filter((name) => name.startsWith('object_')).length;
|
||||
const newObjectName = `object_${n}`;
|
||||
const newObjectMaskPoints = { ...objectMaskPoints, [newObjectName]: [[]] };
|
||||
setObjectMaskPoints(newObjectMaskPoints);
|
||||
setEditing({ set: newObjectMaskPoints, key: newObjectName, subkey: 0, fn: setObjectMaskPoints });
|
||||
}, [objectMaskPoints, setObjectMaskPoints, setEditing]);
|
||||
|
||||
const handleRemoveObjectMask = useCallback(
|
||||
(key, subkey) => {
|
||||
const newObjectMaskPoints = { ...objectMaskPoints };
|
||||
delete newObjectMaskPoints[key][subkey];
|
||||
setObjectMaskPoints(newObjectMaskPoints);
|
||||
},
|
||||
[objectMaskPoints, setObjectMaskPoints]
|
||||
);
|
||||
|
||||
const handleCopyObjectMasks = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(` objects:
|
||||
filters:
|
||||
${Object.keys(objectMaskPoints)
|
||||
.map((objectName) =>
|
||||
objectMaskPoints[objectName].length
|
||||
? ` ${objectName}:
|
||||
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n')}`);
|
||||
}, [objectMaskPoints]);
|
||||
|
||||
const handleAddToObjectMask = useCallback(
|
||||
(key) => {
|
||||
const newObjectMaskPoints = { ...objectMaskPoints, [key]: [...objectMaskPoints[key], []] };
|
||||
setObjectMaskPoints(newObjectMaskPoints);
|
||||
setEditing({
|
||||
set: newObjectMaskPoints,
|
||||
key,
|
||||
subkey: newObjectMaskPoints[key].length - 1,
|
||||
fn: setObjectMaskPoints,
|
||||
});
|
||||
},
|
||||
[objectMaskPoints, setObjectMaskPoints, setEditing]
|
||||
);
|
||||
|
||||
const handleChangeSnap = useCallback(
|
||||
(id, value) => {
|
||||
setSnap(value);
|
||||
},
|
||||
[setSnap]
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="flex-col space-y-4">
|
||||
<Heading size="2xl">{camera} mask & zone creator</Heading>
|
||||
|
||||
<Card
|
||||
content={
|
||||
<p>
|
||||
This tool can help you create masks & zones for your {camera} camera. When done, copy each mask
|
||||
configuration into your <code className="font-mono">config.yml</code> file restart your Frigate instance to
|
||||
save your changes.
|
||||
</p>
|
||||
}
|
||||
header="Warning"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} />
|
||||
<EditableMask
|
||||
onChange={handleUpdateEditable}
|
||||
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
|
||||
scale={imageScale}
|
||||
snap={snap}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<span>Snap to edges</span> <Switch checked={snap} onChange={handleChangeSnap} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-col space-y-4">
|
||||
<MaskValues
|
||||
editing={editing}
|
||||
title="Motion masks"
|
||||
onCopy={handleCopyMotionMasks}
|
||||
onCreate={handleAddMask}
|
||||
onEdit={handleEditMask}
|
||||
onRemove={handleRemoveMask}
|
||||
points={motionMaskPoints}
|
||||
yamlPrefix={'motion:\n mask:'}
|
||||
yamlKeyPrefix={maskYamlKeyPrefix}
|
||||
/>
|
||||
|
||||
<MaskValues
|
||||
editing={editing}
|
||||
title="Zones"
|
||||
onCopy={handleCopyZones}
|
||||
onCreate={handleAddZone}
|
||||
onEdit={handleEditZone}
|
||||
onRemove={handleRemoveZone}
|
||||
points={zonePoints}
|
||||
yamlPrefix="zones:"
|
||||
yamlKeyPrefix={zoneYamlKeyPrefix}
|
||||
/>
|
||||
|
||||
<MaskValues
|
||||
isMulti
|
||||
editing={editing}
|
||||
title="Object masks"
|
||||
onAdd={handleAddToObjectMask}
|
||||
onCopy={handleCopyObjectMasks}
|
||||
onCreate={handleAddObjectMask}
|
||||
onEdit={handleEditObjectMask}
|
||||
onRemove={handleRemoveObjectMask}
|
||||
points={objectMaskPoints}
|
||||
yamlPrefix={'objects:\n filters:'}
|
||||
yamlKeyPrefix={objectYamlKeyPrefix}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maskYamlKeyPrefix(points) {
|
||||
return ` - `;
|
||||
}
|
||||
|
||||
function zoneYamlKeyPrefix(points, key) {
|
||||
return ` ${key}:
|
||||
coordinates: `;
|
||||
}
|
||||
|
||||
function objectYamlKeyPrefix(points, key, subkey) {
|
||||
return ` - `;
|
||||
}
|
||||
|
||||
const MaskInset = 20;
|
||||
|
||||
function EditableMask({ onChange, points, scale, snap, width, height }) {
|
||||
if (!points) {
|
||||
return null;
|
||||
}
|
||||
const boundingRef = useRef(null);
|
||||
|
||||
function boundedSize(value, maxValue) {
|
||||
const newValue = Math.min(Math.max(0, Math.round(value)), maxValue);
|
||||
if (snap) {
|
||||
if (newValue <= MaskInset) {
|
||||
return 0;
|
||||
} else if (maxValue - newValue <= MaskInset) {
|
||||
return maxValue;
|
||||
}
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
|
||||
const handleMovePoint = useCallback(
|
||||
(index, newX, newY) => {
|
||||
if (newX < 0 && newY < 0) {
|
||||
return;
|
||||
}
|
||||
let x = boundedSize(newX / scale, width, snap);
|
||||
let y = boundedSize(newY / scale, height, snap);
|
||||
|
||||
const newPoints = [...points];
|
||||
newPoints[index] = [x, y];
|
||||
onChange(newPoints);
|
||||
},
|
||||
[scale, points, snap]
|
||||
);
|
||||
|
||||
// Add a new point between the closest two other points
|
||||
const handleAddPoint = useCallback(
|
||||
(event) => {
|
||||
const { offsetX, offsetY } = event;
|
||||
const scaledX = boundedSize((offsetX - MaskInset) / scale, width, snap);
|
||||
const scaledY = boundedSize((offsetY - MaskInset) / scale, height, snap);
|
||||
const newPoint = [scaledX, scaledY];
|
||||
|
||||
let closest;
|
||||
const { index } = points.reduce(
|
||||
(result, point, i) => {
|
||||
const nextPoint = points.length === i + 1 ? points[0] : points[i + 1];
|
||||
const distance0 = Math.sqrt(Math.pow(point[0] - newPoint[0], 2) + Math.pow(point[1] - newPoint[1], 2));
|
||||
const distance1 = Math.sqrt(Math.pow(point[0] - nextPoint[0], 2) + Math.pow(point[1] - nextPoint[1], 2));
|
||||
const distance = distance0 + distance1;
|
||||
return distance < result.distance ? { distance, index: i } : result;
|
||||
},
|
||||
{ distance: Infinity, index: -1 }
|
||||
);
|
||||
const newPoints = [...points];
|
||||
newPoints.splice(index, 0, newPoint);
|
||||
onChange(newPoints);
|
||||
},
|
||||
[scale, points, onChange, snap]
|
||||
);
|
||||
|
||||
const handleRemovePoint = useCallback(
|
||||
(index) => {
|
||||
const newPoints = [...points];
|
||||
newPoints.splice(index, 1);
|
||||
onChange(newPoints);
|
||||
},
|
||||
[points, onChange]
|
||||
);
|
||||
|
||||
const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={`top: -${MaskInset}px; right: -${MaskInset}px; bottom: -${MaskInset}px; left: -${MaskInset}px`}
|
||||
>
|
||||
{!scaledPoints
|
||||
? null
|
||||
: scaledPoints.map(([x, y], i) => (
|
||||
<PolyPoint
|
||||
boundingRef={boundingRef}
|
||||
index={i}
|
||||
onMove={handleMovePoint}
|
||||
onRemove={handleRemovePoint}
|
||||
x={x + MaskInset}
|
||||
y={y + MaskInset}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute inset-0 right-0 bottom-0" onclick={handleAddPoint} ref={boundingRef} />
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="absolute pointer-events-none"
|
||||
style={`top: ${MaskInset}px; right: ${MaskInset}px; bottom: ${MaskInset}px; left: ${MaskInset}px`}
|
||||
>
|
||||
{!scaledPoints ? null : (
|
||||
<g>
|
||||
<polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaskValues({
|
||||
isMulti = false,
|
||||
editing,
|
||||
title,
|
||||
onAdd,
|
||||
onCopy,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onRemove,
|
||||
points,
|
||||
yamlPrefix,
|
||||
yamlKeyPrefix,
|
||||
}) {
|
||||
const [showButtons, setShowButtons] = useState(false);
|
||||
|
||||
const handleMousein = useCallback(() => {
|
||||
setShowButtons(true);
|
||||
}, [setShowButtons]);
|
||||
|
||||
const handleMouseout = useCallback(
|
||||
(event) => {
|
||||
const el = event.toElement || event.relatedTarget;
|
||||
if (!el || el.parentNode === event.target) {
|
||||
return;
|
||||
}
|
||||
setShowButtons(false);
|
||||
},
|
||||
[setShowButtons]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(event) => {
|
||||
const { key, subkey } = event.target.dataset;
|
||||
onEdit(key, subkey);
|
||||
},
|
||||
[onEdit]
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(event) => {
|
||||
const { key, subkey } = event.target.dataset;
|
||||
onRemove(key, subkey);
|
||||
},
|
||||
[onRemove]
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(event) => {
|
||||
const { key } = event.target.dataset;
|
||||
onAdd(key);
|
||||
},
|
||||
[onAdd]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden" onmouseover={handleMousein} onmouseout={handleMouseout}>
|
||||
<div class="flex space-x-4">
|
||||
<Heading className="flex-grow self-center" size="base">
|
||||
{title}
|
||||
</Heading>
|
||||
<Button onClick={onCopy}>Copy</Button>
|
||||
<Button onClick={onCreate}>Add</Button>
|
||||
</div>
|
||||
<pre class="relative overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2">
|
||||
{yamlPrefix}
|
||||
{Object.keys(points).map((mainkey) => {
|
||||
if (isMulti) {
|
||||
return (
|
||||
<div>
|
||||
{` ${mainkey}:\n mask:\n`}
|
||||
{onAdd && showButtons ? (
|
||||
<Button className="absolute -mt-12 right-0 font-sans" data-key={mainkey} onClick={handleAdd}>
|
||||
{`Add to ${mainkey}`}
|
||||
</Button>
|
||||
) : null}
|
||||
{points[mainkey].map((item, subkey) => (
|
||||
<Item
|
||||
mainkey={mainkey}
|
||||
subkey={subkey}
|
||||
editing={editing}
|
||||
handleEdit={handleEdit}
|
||||
handleRemove={handleRemove}
|
||||
points={item}
|
||||
showButtons={showButtons}
|
||||
yamlKeyPrefix={yamlKeyPrefix}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Item
|
||||
mainkey={mainkey}
|
||||
editing={editing}
|
||||
handleAdd={onAdd ? handleAdd : undefined}
|
||||
handleEdit={handleEdit}
|
||||
handleRemove={handleRemove}
|
||||
points={points[mainkey]}
|
||||
showButtons={showButtons}
|
||||
yamlKeyPrefix={yamlKeyPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, handleAdd, handleRemove, yamlKeyPrefix }) {
|
||||
return (
|
||||
<span
|
||||
data-key={mainkey}
|
||||
data-subkey={subkey}
|
||||
className={`block hover:text-blue-400 cursor-pointer relative ${
|
||||
editing.key === mainkey && editing.subkey === subkey ? 'text-blue-800 dark:text-blue-600' : ''
|
||||
}`}
|
||||
onClick={handleEdit}
|
||||
title="Click to edit"
|
||||
>
|
||||
{`${yamlKeyPrefix(points, mainkey, subkey)}${polylinePointsToPolyline(points)}`}
|
||||
{showButtons ? (
|
||||
<Button
|
||||
className="absolute top-0 right-0"
|
||||
color="red"
|
||||
data-key={mainkey}
|
||||
data-subkey={subkey}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getPolylinePoints(polyline) {
|
||||
if (!polyline) {
|
||||
return;
|
||||
}
|
||||
|
||||
return polyline.split(',').reduce((memo, point, i) => {
|
||||
if (i % 2) {
|
||||
memo[memo.length - 1].push(parseInt(point, 10));
|
||||
} else {
|
||||
memo.push([parseInt(point, 10)]);
|
||||
}
|
||||
return memo;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function scalePolylinePoints(polylinePoints, scale) {
|
||||
if (!polylinePoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
return polylinePoints.map(([x, y]) => [Math.round(x * scale), Math.round(y * scale)]);
|
||||
}
|
||||
|
||||
function polylinePointsToPolyline(polylinePoints) {
|
||||
if (!polylinePoints) {
|
||||
return;
|
||||
}
|
||||
return polylinePoints.reduce((memo, [x, y]) => `${memo}${x},${y},`, '').replace(/,$/, '');
|
||||
}
|
||||
|
||||
const PolyPointRadius = 10;
|
||||
function PolyPoint({ boundingRef, index, x, y, onMove, onRemove }) {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event) => {
|
||||
if (
|
||||
!boundingRef.current ||
|
||||
(event.target !== boundingRef.current && !boundingRef.current.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onMove(index, event.layerX - PolyPointRadius * 2, event.layerY - PolyPointRadius * 2);
|
||||
},
|
||||
[onMove, index, boundingRef.current]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
boundingRef.current && boundingRef.current.addEventListener('dragover', handleDragOver, false);
|
||||
setHidden(true);
|
||||
}, [setHidden, boundingRef.current, handleDragOver]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
boundingRef.current && boundingRef.current.removeEventListener('dragover', handleDragOver);
|
||||
setHidden(false);
|
||||
}, [setHidden, boundingRef.current, handleDragOver]);
|
||||
|
||||
const handleRightClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
onRemove(index);
|
||||
},
|
||||
[onRemove, index]
|
||||
);
|
||||
|
||||
const handleClick = useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
|
||||
style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
|
||||
draggable
|
||||
onclick={handleClick}
|
||||
oncontextmenu={handleRightClick}
|
||||
ondragstart={handleDragStart}
|
||||
ondragend={handleDragEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
web/src/routes/Cameras.jsx
Normal file
31
web/src/routes/Cameras.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Card from '../components/Card';
|
||||
import CameraImage from '../components/CameraImage';
|
||||
import Heading from '../components/Heading';
|
||||
import { route } from 'preact-router';
|
||||
import { useConfig } from '../api';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
|
||||
export default function Cameras() {
|
||||
const { data: config, status } = useConfig();
|
||||
|
||||
if (!config) {
|
||||
return <p>loading…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
||||
{Object.keys(config.cameras).map((camera) => (
|
||||
<Camera name={camera} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Camera({ name }) {
|
||||
const href = `/cameras/${name}`;
|
||||
const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]);
|
||||
|
||||
return <Card buttons={buttons} href={href} header={name} media={<CameraImage camera={name} />} />;
|
||||
}
|
||||
111
web/src/routes/Debug.jsx
Normal file
111
web/src/routes/Debug.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Button from '../components/Button';
|
||||
import Heading from '../components/Heading';
|
||||
import Link from '../components/Link';
|
||||
import { FetchStatus, useConfig, useStats } from '../api';
|
||||
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function Debug() {
|
||||
const config = useConfig();
|
||||
|
||||
const [timeoutId, setTimeoutId] = useState(null);
|
||||
|
||||
const forceUpdate = useCallback(async () => {
|
||||
setTimeoutId(setTimeout(forceUpdate, 1000));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
forceUpdate();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [timeoutId]);
|
||||
const { data: stats, status } = useStats(null, timeoutId);
|
||||
|
||||
if (stats === null && (status === FetchStatus.LOADING || status === FetchStatus.NONE)) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
const { detectors, detection_fps, service, ...cameras } = stats;
|
||||
|
||||
const detectorNames = Object.keys(detectors);
|
||||
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);
|
||||
|
||||
const cameraNames = Object.keys(cameras);
|
||||
const cameraDataKeys = Object.keys(cameras[cameraNames[0]]);
|
||||
|
||||
const handleCopyConfig = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<Heading>
|
||||
Debug <span className="text-sm">{service.version}</span>
|
||||
</Heading>
|
||||
|
||||
<div class="min-w-0 overflow-auto">
|
||||
<Table className="w-full">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>detector</Th>
|
||||
{detectorDataKeys.map((name) => (
|
||||
<Th>{name.replace('_', ' ')}</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{detectorNames.map((detector, i) => (
|
||||
<Tr index={i}>
|
||||
<Td>{detector}</Td>
|
||||
{detectorDataKeys.map((name) => (
|
||||
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 overflow-auto">
|
||||
<Table className="w-full">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>camera</Th>
|
||||
{cameraDataKeys.map((name) => (
|
||||
<Th>{name.replace('_', ' ')}</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{cameraNames.map((camera, i) => (
|
||||
<Tr index={i}>
|
||||
<Td>
|
||||
<Link href={`/cameras/${camera}`}>{camera}</Link>
|
||||
</Td>
|
||||
{cameraDataKeys.map((name) => (
|
||||
<Td key={`${name}-${camera}`}>{cameras[camera][name]}</Td>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Heading size="sm">Config</Heading>
|
||||
<Button className="absolute top-8 right-4" onClick={handleCopyConfig}>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
<pre className="overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2 max-h-96">
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/src/routes/Event.jsx
Normal file
74
web/src/routes/Event.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Heading from '../components/Heading';
|
||||
import Link from '../components/Link';
|
||||
import { FetchStatus, useApiHost, useEvent } from '../api';
|
||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
||||
|
||||
export default function Event({ eventId }) {
|
||||
const apiHost = useApiHost();
|
||||
const { data, status } = useEvent(eventId);
|
||||
|
||||
if (status !== FetchStatus.LOADED) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
const startime = new Date(data.start_time * 1000);
|
||||
const endtime = new Date(data.end_time * 1000);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Heading>
|
||||
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
||||
</Heading>
|
||||
|
||||
<Table class="w-full">
|
||||
<Thead>
|
||||
<Th>Key</Th>
|
||||
<Th>Value</Th>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Camera</Td>
|
||||
<Td>
|
||||
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr index={1}>
|
||||
<Td>Timeframe</Td>
|
||||
<Td>
|
||||
{startime.toLocaleString()} – {endtime.toLocaleString()}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Score</Td>
|
||||
<Td>{(data.top_score * 100).toFixed(2)}%</Td>
|
||||
</Tr>
|
||||
<Tr index={1}>
|
||||
<Td>Zones</Td>
|
||||
<Td>{data.zones.join(', ')}</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
{data.has_clip ? (
|
||||
<Fragment>
|
||||
<Heading size="sm">Clip</Heading>
|
||||
<video autoplay className="w-100" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
|
||||
</Fragment>
|
||||
) : (
|
||||
<p>No clip available</p>
|
||||
)}
|
||||
|
||||
<Heading size="sm">{data.has_snapshot ? 'Best image' : 'Thumbnail'}</Heading>
|
||||
<img
|
||||
src={
|
||||
data.has_snapshot
|
||||
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
||||
: `data:image/jpeg;base64,${data.thumbnail}`
|
||||
}
|
||||
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
web/src/routes/Events.jsx
Normal file
295
web/src/routes/Events.jsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Card from '../components/Card';
|
||||
import Heading from '../components/Heading';
|
||||
import Link from '../components/Link';
|
||||
import Select from '../components/Select';
|
||||
import produce from 'immer';
|
||||
import { route } from 'preact-router';
|
||||
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
|
||||
|
||||
const API_LIMIT = 25;
|
||||
|
||||
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
||||
const reducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case 'APPEND_EVENTS': {
|
||||
const {
|
||||
meta: { searchString },
|
||||
payload,
|
||||
} = action;
|
||||
return produce(state, (draftState) => {
|
||||
draftState.searchStrings[searchString] = true;
|
||||
draftState.events.push(...payload);
|
||||
});
|
||||
}
|
||||
|
||||
case 'REACHED_END': {
|
||||
const {
|
||||
meta: { searchString },
|
||||
} = action;
|
||||
return produce(state, (draftState) => {
|
||||
draftState.reachedEnd = true;
|
||||
draftState.searchStrings[searchString] = true;
|
||||
});
|
||||
}
|
||||
|
||||
case 'RESET':
|
||||
return initialState;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultSearchString = `include_thumbnails=0&limit=${API_LIMIT}`;
|
||||
function removeDefaultSearchKeys(searchParams) {
|
||||
searchParams.delete('limit');
|
||||
searchParams.delete('include_thumbnails');
|
||||
searchParams.delete('before');
|
||||
}
|
||||
|
||||
export default function Events({ path: pathname } = {}) {
|
||||
const apiHost = useApiHost();
|
||||
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
|
||||
const { searchParams: initialSearchParams } = new URL(window.location);
|
||||
const [searchString, setSearchString] = useState(`${defaultSearchString}&${initialSearchParams.toString()}`);
|
||||
const { data, status } = useEvents(searchString);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !(searchString in searchStrings)) {
|
||||
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
||||
}
|
||||
if (Array.isArray(data) && data.length < API_LIMIT) {
|
||||
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const observer = useRef(
|
||||
new IntersectionObserver((entries, observer) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
// under certain edge cases, a ref may be applied / in memory twice
|
||||
// avoid fetching twice by grabbing the last observed entry only
|
||||
const entry = entries[entries.length - 1];
|
||||
if (entry.isIntersecting) {
|
||||
const { startTime } = entry.target.dataset;
|
||||
const { searchParams } = new URL(window.location);
|
||||
searchParams.set('before', parseFloat(startTime) - 0.0001);
|
||||
|
||||
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const lastCellRef = useCallback(
|
||||
(node) => {
|
||||
if (node !== null) {
|
||||
observer.current.disconnect();
|
||||
if (!reachedEnd) {
|
||||
observer.current.observe(node);
|
||||
}
|
||||
}
|
||||
},
|
||||
[observer.current, reachedEnd]
|
||||
);
|
||||
|
||||
const handleFilter = useCallback(
|
||||
(searchParams) => {
|
||||
dispatch({ type: 'RESET' });
|
||||
removeDefaultSearchKeys(searchParams);
|
||||
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
|
||||
route(`${pathname}?${searchParams.toString()}`);
|
||||
},
|
||||
[pathname, setSearchString]
|
||||
);
|
||||
|
||||
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
<Heading>Events</Heading>
|
||||
|
||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
||||
|
||||
<div className="min-w-0 overflow-auto">
|
||||
<Table className="min-w-full table-fixed">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th></Th>
|
||||
<Th>Camera</Th>
|
||||
<Th>Label</Th>
|
||||
<Th>Score</Th>
|
||||
<Th>Zones</Th>
|
||||
<Th>Date</Th>
|
||||
<Th>Start</Th>
|
||||
<Th>End</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{events.map(
|
||||
(
|
||||
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
|
||||
i
|
||||
) => {
|
||||
const start = new Date(parseInt(startTime * 1000, 10));
|
||||
const end = new Date(parseInt(endTime * 1000, 10));
|
||||
const ref = i === events.length - 1 ? lastCellRef : undefined;
|
||||
return (
|
||||
<Tr key={id}>
|
||||
<Td className="w-40">
|
||||
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
|
||||
<img
|
||||
width="150"
|
||||
height="150"
|
||||
style="min-height: 48px; min-width: 48px;"
|
||||
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
||||
/>
|
||||
</a>
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchParams}
|
||||
paramName="camera"
|
||||
name={camera}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchParams}
|
||||
paramName="label"
|
||||
name={label}
|
||||
/>
|
||||
</Td>
|
||||
<Td>{(score * 100).toFixed(2)}%</Td>
|
||||
<Td>
|
||||
<ul>
|
||||
{zones.map((zone) => (
|
||||
<li>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchString}
|
||||
paramName="zone"
|
||||
name={zone}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Td>
|
||||
<Td>{start.toLocaleDateString()}</Td>
|
||||
<Td>{start.toLocaleTimeString()}</Td>
|
||||
<Td>{end.toLocaleTimeString()}</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Tbody>
|
||||
<Tfoot>
|
||||
<Tr>
|
||||
<Td className="text-center p-4" colspan="8">
|
||||
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tfoot>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
||||
const href = useMemo(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(paramName, name);
|
||||
removeDefaultSearchKeys(params);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}, [searchParams]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
route(href, true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(paramName, name);
|
||||
onFilter(params);
|
||||
},
|
||||
[href, searchParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link href={href} onclick={handleClick}>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Filters({ onChange, searchParams }) {
|
||||
const { data } = useConfig();
|
||||
|
||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
||||
|
||||
const zones = useMemo(
|
||||
() =>
|
||||
Object.values(data.cameras)
|
||||
.reduce((memo, camera) => {
|
||||
memo = memo.concat(Object.keys(camera.zones));
|
||||
return memo;
|
||||
}, [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i),
|
||||
[data]
|
||||
);
|
||||
|
||||
const labels = useMemo(() => {
|
||||
return Object.values(data.cameras)
|
||||
.reduce((memo, camera) => {
|
||||
memo = memo.concat(camera.objects?.track || []);
|
||||
return memo;
|
||||
}, data.objects?.track || [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4">
|
||||
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({ onChange, searchParams, paramName, options }) {
|
||||
const handleSelect = useCallback(
|
||||
(key) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
if (key !== 'all') {
|
||||
newParams.set(paramName, key);
|
||||
} else {
|
||||
newParams.delete(paramName);
|
||||
}
|
||||
|
||||
onChange(newParams);
|
||||
},
|
||||
[searchParams, paramName, onChange]
|
||||
);
|
||||
|
||||
const selectOptions = useMemo(() => ['all', ...options], [options]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
||||
onChange={handleSelect}
|
||||
options={selectOptions}
|
||||
selected={searchParams.get(paramName) || 'all'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
web/src/routes/StyleGuide.jsx
Normal file
91
web/src/routes/StyleGuide.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { h } from 'preact';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Card from '../components/Card';
|
||||
import Button from '../components/Button';
|
||||
import Heading from '../components/Heading';
|
||||
import Select from '../components/Select';
|
||||
import Switch from '../components/Switch';
|
||||
import TextField from '../components/TextField';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export default function StyleGuide() {
|
||||
const [switches, setSwitches] = useState({ 0: false, 1: true });
|
||||
|
||||
const handleSwitch = useCallback(
|
||||
(id, checked) => {
|
||||
setSwitches({ ...switches, [id]: checked });
|
||||
},
|
||||
[switches]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading size="md">Button</Heading>
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<Button>Default</Button>
|
||||
<Button color="red">Danger</Button>
|
||||
<Button color="green">Save</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
</div>
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<Button type="text">Default</Button>
|
||||
<Button color="red" type="text">
|
||||
Danger
|
||||
</Button>
|
||||
<Button color="green" type="text">
|
||||
Save
|
||||
</Button>
|
||||
<Button disabled type="text">
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<Button type="outlined">Default</Button>
|
||||
<Button color="red" type="outlined">
|
||||
Danger
|
||||
</Button>
|
||||
<Button color="green" type="outlined">
|
||||
Save
|
||||
</Button>
|
||||
<Button disabled type="outlined">
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Heading size="md">Switch</Heading>
|
||||
<div class="flex">
|
||||
<div>
|
||||
<p>Disabled, off</p>
|
||||
<Switch />
|
||||
</div>
|
||||
<div>
|
||||
<p>Disabled, on</p>
|
||||
<Switch checked />
|
||||
</div>
|
||||
<div>
|
||||
<p>Enabled, (off initial)</p>
|
||||
<Switch checked={switches[0]} id={0} onChange={handleSwitch} label="Default" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Enabled, (on initial)</p>
|
||||
<Switch checked={switches[1]} id={1} onChange={handleSwitch} label="Default" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heading size="md">Select</Heading>
|
||||
<div class="flex space-x-4 mb-4 max-w-4xl">
|
||||
<Select label="Basic select box" options={['All', 'None', 'Other']} selected="None" />
|
||||
</div>
|
||||
|
||||
<Heading size="md">TextField</Heading>
|
||||
<div class="flex-col space-y-4 max-w-4xl">
|
||||
<TextField label="Default text field" />
|
||||
<TextField label="Pre-filled" value="This is my pre-filled value" />
|
||||
<TextField label="With help" helpText="This is some help text" />
|
||||
<TextField label="Leading icon" leadingIcon={ArrowDropdown} />
|
||||
<TextField label="Trailing icon" trailingIcon={ArrowDropup} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
web/src/routes/index.js
Normal file
29
web/src/routes/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export async function getCameraMap(url, cb, props) {
|
||||
const module = await import('./CameraMap.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getCamera(url, cb, props) {
|
||||
const module = await import('./Camera.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getEvent(url, cb, props) {
|
||||
const module = await import('./Event.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getEvents(url, cb, props) {
|
||||
const module = await import('./Events.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getDebug(url, cb, props) {
|
||||
const module = await import('./Debug.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getStyleGuide(url, cb, props) {
|
||||
const module = await import('./StyleGuide.jsx');
|
||||
return module.default;
|
||||
}
|
||||
Reference in New Issue
Block a user