import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { useApi } from './useApi'; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { BrowserRouter as Router, Route, Routes, useParams } from "react-router-dom"; import equal from "fast-deep-equal"; import './App.css'; const base = process.env.PUBLIC_URL; /* /identities -- set in .env */ const makeFaceBoxes = (photo: any, dimensions: any, onFaceClick: any): any => { const faces: FaceData[] = photo.faces; let width: number, height: number, offsetLeft = 0, offsetTop = 0; /* If photo is wider than viewport, it will be 100% width and < 100% height */ if (photo.width / photo.height > dimensions.width / dimensions.height) { width = dimensions.width; height = dimensions.height * photo.height / photo.width * dimensions.width / dimensions.height; offsetLeft = 0; offsetTop = (dimensions.height - height) * 0.5; } else { width = dimensions.width * photo.width / photo.height * dimensions.height / dimensions.width; height = dimensions.height; offsetLeft = (dimensions.width - width) * 0.5; offsetTop = 0; } return faces.map((face: FaceData) => { const faceId = face.faceId; const identityId = face.identityId; return (
{ onFaceClick(e, face) }} onMouseEnter={(e) => { onFaceMouseEnter(e, face) }} onMouseLeave={(e) => { onFaceMouseLeave(e, face) }} /> ) }); }; const Photo = ({ photoId, onFaceClick }: any) => { const [image, setImage] = useState(undefined); const ref = useRef(null); const [dimensions, setDimensions] = React.useState({width: 0, height: 0}); const faces = useMemo(() => { if (image === undefined || dimensions.height === 0) { return <>; } return makeFaceBoxes(image, dimensions, onFaceClick); }, [image, dimensions, onFaceClick]); const checkResize = useCallback(() => { if (!ref.current) { return; } const el: Element = ref.current as Element; if (dimensions.height !== el.clientHeight || dimensions.width !== el.clientWidth) { setDimensions({ height: el.clientHeight, width: el.clientWidth }) } }, [setDimensions, dimensions]); useEffect(() => { let timer = setInterval(() => checkResize(), 250); return () => { clearInterval(timer); } }, [checkResize]); useEffect(() => { if (photoId === 0) { return; } const fetchImageData = async (image: number) => { console.log(`Loading photo ${image}`); const res = await window.fetch(`${base}/api/v1/photos/${image}`); const photo = await res.json(); setImage(photo); }; fetchImageData(photoId); }, [photoId, setImage]); if (image === undefined) { return <> } return (
{ faces }
); }; const onFaceMouseEnter = (e: any, face: FaceData) => { const faceId = face.faceId; const els = [...document.querySelectorAll(`[data-face-id="${faceId}"]`)]; const identityId = face.identityId; els.splice(0, 0, ...document.querySelectorAll( `.Identities [data-identity-id="${identityId}"]`), ...document.querySelectorAll( `.Photo [data-identity-id="${identityId}"]`)); els.forEach(el => { el.classList.add('Active'); }) }; const onFaceMouseLeave = (e: any, face: FaceData) => { const faceId = face.faceId; const els = [...document.querySelectorAll(`[data-face-id="${faceId}"]`)]; const identityId = face.identityId; els.splice(0, 0, ...document.querySelectorAll(`[data-identity-id="${identityId}"]`)); els.forEach(el => { el.classList.remove('Active'); }) }; const Face = ({ face, onFaceClick, title, ...rest }: any) => { const faceId = face.faceId; const idPath = String(faceId % 100).padStart(2, '0'); const img = faceId === -1 ?
?
: ; return (
{ onFaceClick(e, face) }} onMouseEnter={(e) => { onFaceMouseEnter(e, face) }} onMouseLeave={(e) => { onFaceMouseLeave(e, face) }} className='Face'>
{ img }
{title}
); }; type ClusterProps = { identity: IdentityData, setImage(image: number): void, setSelected(selected: number[]): void, setIdentity(identity: IdentityData): void }; const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps) => { const relatedFacesJSX = useMemo(() => { const faceClicked = async (e: any, face: FaceData) => { if (!identity) { return; } const el = e.currentTarget; /* Control -- select / deselect single item */ if (e.ctrlKey) { const cluster = document.querySelector('.Cluster'); el.classList.toggle('Selected'); if (!cluster) { return; } const selected = [...cluster.querySelectorAll('.Selected')] .map((face: any) => face.getAttribute('data-face-id')); setSelected(selected); return; } /* Shift -- select groups */ if (e.shiftKey) { return; } /* Default to load image */ e.stopPropagation(); e.preventDefault(); setImage(face.photoId); } if (identity === undefined) { return <>; } return identity.relatedFaces.map((face: FaceData) => { return (
); }); }, [identity, setImage, setSelected]); const lastNameChanged = (e: any) => { setIdentity({...identity, lastName: e.currentTarget.value }); }; const firstNameChanged = (e: any) => { setIdentity({...identity, firstName: e.currentTarget.value }); }; const middleNameChanged = (e: any) => { setIdentity({...identity, middleName: e.currentTarget.value }); }; const displayNameChanged = (e: any) => { setIdentity({...identity, displayName: e.currentTarget.value }); }; const updateIdentity = async () => { try { const validFields = [ 'id', 'displayName', 'firstName', 'lastName', 'middleName']; const filtered: any = Object.assign({}, identity); for (let key in filtered) { if (validFields.indexOf(key) == -1) { delete filtered[key] } } const res = await window.fetch( `${base}/api/v1/identities/${identity.identityId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(filtered) }); const updated = await res.json(); setIdentity({ ...identity }); } catch (error) { console.error(error); } }; const createIdentity = async () => { try { const validFields = [ 'id', 'displayName', 'firstName', 'lastName', 'middleName']; const filtered: any = Object.assign({}, identity); for (let key in filtered) { if (validFields.indexOf(key) == -1) { delete filtered[key] } } const res = await window.fetch( `${base}/api/v1/identities/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(filtered) }); const created = await res.json(); setIdentity(created); } catch (error) { console.error(error); } }; if (identity === undefined) { return (
Select identity to load.
); } return (
Last name:
First name:
Middle name:
Display name:
Faces: {identity.relatedFaces.length}
{ relatedFacesJSX }
); }; type FaceData = { faceId: number, photoId: number, /* lastName: string, firstName: string, middleName: string, displayName: string,*/ identity: IdentityData, identityId: number, distance: number, descriptors: any[], top: number right: number, bottom: number, left: number, }; type IdentityData = { lastName: string, middleName: string, firstName: string, descriptors: number[], identityId: number displayName: string, relatedFaces: FaceData[] }; interface IdentitiesProps { identities: IdentityData[], onFaceClick(e: any, face: FaceData): void }; const Identities = ({ identities, onFaceClick } : IdentitiesProps) => { const identitiesJSX = useMemo(() => { return identities.map((identity) => { const face = identity.relatedFaces[0]; return (
); }); }, [ identities, onFaceClick ]); return (
{ identitiesJSX }
); }; const Button = ({ onClick, children }: any) => { return ( ); }; const App = () => { const [identities, setIdentities] = useState([]); const { identityId, faceId } = useParams(); const [selectedIdentities, setSelectedIdentities] = useState([]); const [identity, setIdentity] = useState(undefined); const [image, setImage] = useState(0); const { loading, data } = useApi( `${base}/api/v1/identities` ); const [selected, setSelected] = useState([]); const loadIdentity = async (identityId: number) => { try { const res = await window.fetch(`${base}/api/v1/identities/${identityId}`); const data = await res.json(); setIdentity(data[0]); } catch (error) { console.error(error); } }; /* If the identity changes, update its entry in the identities list */ useEffect(() => { if (!identity || identities.length === 0) { return; } for (let key in identities) { if (identities[key].identityId === identity.identityId) { let same = true; [ 'displayName', 'firstName', 'lastName', 'middleName' ] .forEach((field: string) => { same = same && (identities[key] as any)[field] === (identity as any)[field]; }); if (!same) { identities[key] = { ...identity, relatedFaces: identities[key].relatedFaces }; /* relatedFaces is a list of references to identity */ identity.relatedFaces.forEach(face => { face.identity = identity; }); setIdentities([...identities]); } return; } } }, [identity, setIdentities, identities]); useEffect(() => { if (identityId !== undefined && !isNaN(+identityId)) { loadIdentity(+identityId); } if (faceId !== undefined && !isNaN(+faceId)) { setImage(+faceId); } // eslint-disable-next-line }, []); useEffect(() => { if (data && data.length) { data.forEach((identity: IdentityData) => { identity.relatedFaces.forEach(face => { face.identity = identity; }); }); setIdentities(data as IdentityData[]); } }, [data]); const removeFacesFromIdentities = (faceIds: number[]) => { if (!identity) { return; } const pre = identity.relatedFaces.length; /* Remove all relatedFaces which are part of the set of removed * faces */ identity.relatedFaces = identity.relatedFaces.filter( (face: FaceData) => faceIds.indexOf(face.faceId) === -1); if (pre !== identity.relatedFaces.length) { setIdentity({ ...identity }) } } const markSelectedIncorrectIdentity = async () => { if (!identity) { return; } try { const res = await window.fetch( `${base}/api/v1/identities/faces/remove/${identity.identityId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faces: selected }) }); const data = await res.json(); removeFacesFromIdentities(data.faces); } catch (error) { console.error(error); } }; const changeSelectedIdentity = async () => { if (selectedIdentities.length === 0) { window.alert('You need to select an identity first (CTRL+CLICK)'); return; } try { const res = await window.fetch( `${base}/api/v1/identities/faces/add/${selectedIdentities[0]}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faces: selected }) }); const data = await res.json(); removeFacesFromIdentities(data.faces); } catch (error) { console.error(error); } }; const markSelectedNotFace = async () => { try { const res = await window.fetch( `${base}/api/v1/faces`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'not-a-face', faces: selected }) }); const data = await res.json(); removeFacesFromIdentities(data); } catch (error) { console.error(error); } }; const onFaceClick = (e: any, face: FaceData) => { const identityId = face.identityId; const faceId = face.faceId; console.log(`onFaceClick`, { faceId, identityId}); const faces = [ ...document.querySelectorAll(`.Identities [data-identity-id="${identityId}"]`), ...document.querySelectorAll(`.Cluster [data-face-id="${faceId}"]`)]; faces.forEach((el: any) => { el.scrollIntoView(); }); }; const identitiesOnFaceClick = (e: any, face: FaceData) => { const identitiesEl = document.querySelector('.Identities'); if (!identitiesEl) { return; } const identityId = face.identityId; const el = e.currentTarget; /* Control -- select / deselect single item */ if (e.ctrlKey) { [...identitiesEl.querySelectorAll('.Selected')].forEach(item => { item.classList.remove('Selected') }); el.classList.toggle('Selected'); const selected = [...identitiesEl.querySelectorAll('.Selected')] .map((face: any) => face.getAttribute('data-identity-id')); setSelectedIdentities(selected); return; } /* Shift -- select groups */ if (e.shiftKey) { return; } /* Default to load image */ e.stopPropagation(); e.preventDefault(); loadIdentity(identityId); } return (
{loading &&
Loading...
} {!loading && identity !== undefined && } {!loading && identity === undefined &&
Select identity to edit
}
{ selected.length !== 0 && <> }
{image === 0 &&
Select image to view
} {image !== 0 && }
{ !loading && }
); } const AppRouter = () => { return ( } path={`${base}/:identityId?/:faceId?`} /> ); } export default AppRouter;