refactor(web): responsive images on content size, throttle AutoUpdatingCameraImage

This commit is contained in:
Paul Armstrong 2021-01-26 16:18:45 -08:00 committed by Blake Blackshear
parent 75a01f657e
commit 2ec921593e
6 changed files with 127 additions and 82 deletions

View File

@ -26,7 +26,7 @@ export default function App() {
<Config.Provider value={config}> <Config.Provider value={config}>
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white"> <div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white">
<Sidebar /> <Sidebar />
<div className="p-4 min-w-0"> <div className="flex-auto p-4 lg:pl-8 lg:pr-8 min-w-0">
<Router> <Router>
<CameraMap path="/cameras/:camera/editor" /> <CameraMap path="/cameras/:camera/editor" />
<Camera path="/cameras/:camera" /> <Camera path="/cameras/:camera" />
@ -39,5 +39,4 @@ export default function App() {
</div> </div>
</Config.Provider> </Config.Provider>
); );
return;
} }

View File

@ -1,7 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import Box from './components/Box'; import Box from './components/Box';
import Button from './components/Button'; import Button from './components/Button';
import CameraImage from './components/CameraImage';
import Heading from './components/Heading'; import Heading from './components/Heading';
import Switch from './components/Switch'; import Switch from './components/Switch';
import { route } from 'preact-router'; import { route } from 'preact-router';
@ -253,7 +252,7 @@ ${Object.keys(objectMaskPoints)
<Box className="space-y-4"> <Box className="space-y-4">
<div className="relative"> <div className="relative">
<CameraImage imageRef={imageRef} camera={camera} /> <img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} />
<EditableMask <EditableMask
onChange={handleUpdateEditable} onChange={handleUpdateEditable}
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]} points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}

View File

@ -1,4 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import Box from './components/Box';
import Button from './components/Button';
import Heading from './components/Heading'; import Heading from './components/Heading';
import Link from './components/Link'; import Link from './components/Link';
import { ApiHost, Config } from './context'; import { ApiHost, Config } from './context';
@ -39,59 +41,73 @@ export default function Debug() {
const cameraNames = Object.keys(cameras); const cameraNames = Object.keys(cameras);
const cameraDataKeys = Object.keys(cameras[cameraNames[0]]); const cameraDataKeys = Object.keys(cameras[cameraNames[0]]);
const handleCopyConfig = useCallback(async () => {
await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2));
}, [config]);
return ( return (
<div> <div class="space-y-4">
<Heading> <Heading>
Debug <span className="text-sm">{service.version}</span> Debug <span className="text-sm">{service.version}</span>
</Heading> </Heading>
<Table className="w-full">
<Thead> <Box>
<Tr> <Table className="w-full">
<Th>detector</Th> <Thead>
{detectorDataKeys.map((name) => ( <Tr>
<Th>{name.replace('_', ' ')}</Th> <Th>detector</Th>
))}
</Tr>
</Thead>
<Tbody>
{detectorNames.map((detector, i) => (
<Tr index={i}>
<Td>{detector}</Td>
{detectorDataKeys.map((name) => ( {detectorDataKeys.map((name) => (
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td> <Th>{name.replace('_', ' ')}</Th>
))} ))}
</Tr> </Tr>
))} </Thead>
</Tbody> <Tbody>
</Table> {detectorNames.map((detector, i) => (
<Tr index={i}>
<Table className="w-full"> <Td>{detector}</Td>
<Thead> {detectorDataKeys.map((name) => (
<Tr> <Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
<Th>camera</Th> ))}
{cameraDataKeys.map((name) => ( </Tr>
<Th>{name.replace('_', ' ')}</Th>
))} ))}
</Tr> </Tbody>
</Thead> </Table>
<Tbody> </Box>
{cameraNames.map((camera, i) => (
<Tr index={i}> <Box>
<Td> <Table className="w-full">
<Link href={`/cameras/${camera}`}>{camera}</Link> <Thead>
</Td> <Tr>
<Th>camera</Th>
{cameraDataKeys.map((name) => ( {cameraDataKeys.map((name) => (
<Td key={`${name}-${camera}`}>{cameras[camera][name]}</Td> <Th>{name.replace('_', ' ')}</Th>
))} ))}
</Tr> </Tr>
))} </Thead>
</Tbody> <Tbody>
</Table> {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>
</Box>
<Heading size="sm">Config</Heading> <Box className="relative">
<pre className="font-mono overflow-y-scroll overflow-x-scroll max-h-96 rounded bg-white dark:bg-gray-900"> <Heading size="sm">Config</Heading>
{JSON.stringify(config, null, 2)} <Button className="absolute top-4 right-8" onClick={handleCopyConfig}>
</pre> 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>
</Box>
</div> </div>
); );
} }

View File

@ -23,7 +23,7 @@ export default function Events({ url } = {}) {
const searchKeys = Array.from(searchParams.keys()); const searchKeys = Array.from(searchParams.keys());
return ( return (
<div className="space-y-4"> <div className="space-y-4 w-full">
<Heading>Events</Heading> <Heading>Events</Heading>
{searchKeys.length ? ( {searchKeys.length ? (
@ -43,7 +43,7 @@ export default function Events({ url } = {}) {
) : null} ) : null}
<Box className="min-w-0 overflow-auto"> <Box className="min-w-0 overflow-auto">
<Table> <Table className="w-full">
<Thead> <Thead>
<Tr> <Tr>
<Th></Th> <Th></Th>

View File

@ -1,20 +1,29 @@
import { h } from 'preact'; import { h } from 'preact';
import CameraImage from './CameraImage'; import CameraImage from './CameraImage';
import { ApiHost, Config } from '../context'; import { ApiHost, Config } from '../context';
import { useCallback, useEffect, useContext, useState } from 'preact/hooks'; import { useCallback, useState } from 'preact/hooks';
export default function AutoUpdatingCameraImage({ camera, searchParams }) { const MIN_LOAD_TIMEOUT_MS = 200;
const apiHost = useContext(ApiHost);
export default function AutoUpdatingCameraImage({ camera, searchParams, showFps = true }) {
const [key, setKey] = useState(Date.now()); const [key, setKey] = useState(Date.now());
useEffect(() => { const [fps, setFps] = useState(0);
const timeoutId = setTimeout(() => {
setKey(Date.now());
}, 500);
return () => {
clearTimeout(timeoutId);
};
}, [key, searchParams]);
return <CameraImage camera={camera} searchParams={`cache=${key}&${searchParams}`} />; const handleLoad = useCallback(() => {
const loadTime = Date.now() - key;
setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1));
setTimeout(
() => {
setKey(Date.now());
},
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
);
}, [key, searchParams, setFps]);
return (
<div>
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
</div>
);
} }

View File

@ -1,38 +1,60 @@
import { h } from 'preact'; import { h } from 'preact';
import { ApiHost, Config } from '../context'; import { ApiHost, Config } from '../context';
import { useCallback, useEffect, useContext, useState } from 'preact/hooks'; import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
export default function CameraImage({ camera, searchParams = '', imageRef }) { export default function CameraImage({ camera, onload, searchParams = '' }) {
const config = useContext(Config); const config = useContext(Config);
const apiHost = useContext(ApiHost); const apiHost = useContext(ApiHost);
const [availableWidth, setAvailableWidth] = useState(0);
const [loadedSrc, setLoadedSrc] = useState(null);
const containerRef = useRef(null);
const { name, width, height } = config.cameras[camera]; const { name, width, height } = config.cameras[camera];
const aspectRatio = width / height; const aspectRatio = width / height;
const innerWidth = parseInt(window.innerWidth, 10);
const responsiveWidths = [640, 768, 1024, 1280]; const resizeObserver = useMemo(() => {
if (innerWidth > responsiveWidths[responsiveWidths.length - 1]) { return new ResizeObserver((entries) => {
responsiveWidths.push(innerWidth); window.requestAnimationFrame(() => {
} if (Array.isArray(entries) && entries.length) {
setAvailableWidth(entries[0].contentRect.width);
}
});
});
}, [setAvailableWidth, width]);
const src = `${apiHost}/api/${camera}/latest.jpg`; useEffect(() => {
const { srcset, sizes } = responsiveWidths.reduce( if (!containerRef.current) {
(memo, w, i) => { return;
memo.srcset.push(`${src}?h=${Math.ceil(w / aspectRatio)}&${searchParams} ${w}w`); }
memo.sizes.push(`(max-width: ${w}) ${Math.ceil((w / innerWidth) * 100)}vw`); resizeObserver.observe(containerRef.current);
return memo; }, [resizeObserver, containerRef.current]);
const scaledHeight = useMemo(() => Math.min(Math.ceil(availableWidth / aspectRatio), height), [
availableWidth,
aspectRatio,
height,
]);
const img = useMemo(() => new Image(), [camera]);
img.onload = useCallback(
(event) => {
const src = event.path[0].currentSrc;
setLoadedSrc(src);
onload && onload(event);
}, },
{ srcset: [], sizes: [] } [searchParams, onload]
); );
useEffect(() => {
if (!scaledHeight) {
return;
}
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
}, [apiHost, name, img, searchParams, scaledHeight]);
return ( return (
<img <div ref={containerRef}>
className="w-full" {loadedSrc ? <img width={scaledHeight * aspectRatio} height={scaledHeight} src={loadedSrc} alt={name} /> : null}
srcset={srcset.join(', ')} </div>
sizes={sizes.join(', ')}
src={`${srcset[srcset.length - 1]}`}
alt={name}
ref={imageRef}
/>
); );
} }