import { h, Fragment } from 'preact'; import ActivityIndicator from '../components/ActivityIndicator'; 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 Event from './Event'; import { useIntersectionObserver } from '../hooks'; import { FetchStatus, useApiHost, useConfig, useEvents } from '../api'; import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table'; import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/hooks'; const API_LIMIT = 25; const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 }); const reducer = (state = initialState, action) => { switch (action.type) { case 'DELETE_EVENT': { const { deletedId } = action; return produce(state, (draftState) => { const idx = draftState.events.findIndex((e) => e.id === deletedId); if (idx === -1) return state; draftState.events.splice(idx, 1); draftState.deleted++; }); } case 'APPEND_EVENTS': { const { meta: { searchString }, payload, } = action; return produce(state, (draftState) => { draftState.searchStrings[searchString] = true; draftState.events.push(...payload); draftState.deleted = 0; }); } 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 = (limit) => `include_thumbnails=0&limit=${limit}`; function removeDefaultSearchKeys(searchParams) { searchParams.delete('limit'); searchParams.delete('include_thumbnails'); searchParams.delete('before'); } export default function Events({ path: pathname, limit = API_LIMIT } = {}) { const apiHost = useApiHost(); const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState); const { searchParams: initialSearchParams } = new URL(window.location); const [viewEvent, setViewEvent] = useState(null); const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`); const { data, status, deletedId } = useEvents(searchString); let scrollToRef = {}; useEffect(() => { if (data && !(searchString in searchStrings)) { dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } }); } if (data && Array.isArray(data) && data.length + deleted < limit) { console.log('reached end'); dispatch({ type: 'REACHED_END', meta: { searchString } }); } }, [data, limit, searchString, searchStrings, deleted]); useEffect(() => { if (deletedId) { dispatch({ type: 'DELETE_EVENT', deletedId }); } }, [deletedId]); const [entry, setIntersectNode] = useIntersectionObserver(); useEffect(() => { if (entry && entry.isIntersecting) { const { startTime } = entry.target.dataset; const { searchParams } = new URL(window.location); searchParams.set('before', parseFloat(startTime) - 0.0001); setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`); } }, [entry, limit]); const lastCellRef = useCallback( (node) => { if (node !== null && !reachedEnd) { setIntersectNode(node); } }, [setIntersectNode, reachedEnd] ); const handleFilter = useCallback( (searchParams) => { dispatch({ type: 'RESET' }); removeDefaultSearchKeys(searchParams); setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`); route(`${pathname}?${searchParams.toString()}`); }, [limit, pathname, setSearchString] ); const viewEventHandler = (id) => { //Toggle event view if (viewEvent === id) return setViewEvent(null); //Set event id to be rendered. setViewEvent(id); //scroll the event into view if (id in scrollToRef) scrollToRef[id].scrollIntoView(); }; const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); const viewEventById = (eventId) => ( setViewEvent(null)} /> ); return (
Events
{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 ( {viewEvent === id ? viewEventById(viewEvent) : null} ); } )}
Camera Label Score Zones Date Start End
viewEventHandler(id)} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd} > (scrollToRef[id] = el)} width="150" height="150" className="cursor-pointer" style="min-height: 48px; min-width: 48px;" src={`${apiHost}/api/events/${id}/thumbnail.jpg`} /> {(score * 100).toFixed(2)}%
    {zones.map((zone) => (
  • ))}
{start.toLocaleDateString()} {start.toLocaleTimeString()} {end.toLocaleTimeString()}
{status === FetchStatus.LOADING ? : reachedEnd ? 'No more events' : null}
); } 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, paramName, pathname, name]); const handleClick = useCallback( (event) => { event.preventDefault(); route(href, true); const params = new URLSearchParams(searchParams.toString()); params.set(paramName, name); onFilter(params); }, [href, searchParams, onFilter, paramName, name] ); return ( {name} ); } 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 (
); } 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 (