James P. Ketrenos e54fc968e6 Fix deep recursion glitch
Signed-off-by: James P. Ketrenos <james.p.ketrenos@intel.com>
2023-01-20 19:20:51 -08:00

574 lines
16 KiB
TypeScript

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 (
<div className="FaceBox"
key={faceId}
data-identity-id={identityId}
data-face-id={faceId}
style={{
left: offsetLeft + Math.floor(face.left * width) + "px",
top: offsetTop + Math.floor(face.top * height) + "px",
width: Math.floor((face.right - face.left) * width) + "px",
height: Math.floor((face.bottom - face.top) * height) + "px"
}}
onClick={(e) => { onFaceClick(e, face) }}
onMouseEnter={(e) => { onFaceMouseEnter(e, face) }}
onMouseLeave={(e) => { onFaceMouseLeave(e, face) }}
/>
)
});
};
const Photo = ({ photoId, onFaceClick }: any) => {
const [image, setImage] = useState<any>(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 (<div className="Image" ref={ref}>
<img
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}
style={{
objectFit: 'contain',
width: '100%',
height: '100%'
}} />
{ faces }
</div>
);
};
const onFaceMouseEnter = (e: any, face: FaceData) => {
const faceId = face.faceId;
const els = [...document.querySelectorAll(`[data-face-id="${faceId}"]`)];
if (face.identity) {
const identityId = face.identity.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}"]`)];
if (face.identity) {
const identityId = face.identity.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');
return (
<div
data-face-id={face.faceId}
data-identity-id={face.identityId}
{...rest}
onClick={(e) => { onFaceClick(e, face) }}
onMouseEnter={(e) => { onFaceMouseEnter(e, face) }}
onMouseLeave={(e) => { onFaceMouseLeave(e, face) }}
className='Face'>
<div className='Image'>
<img src={`${base}/../faces/${idPath}/${faceId}.jpg`}
style={{
objectFit: 'contain',
width: '100%',
height: '100%'
}}/>
<div className='Title'>{title}</div>
</div>
</div>
);
};
type ClusterProps = {
identity: IdentityData,
setImage(image: number): void,
setSelected(selected: number[]): void,
setIdentity(identity: IdentityData): void
};
const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps) => {
console.log(identity);
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 (
<div
key={face.faceId}
style={{
display: "flex",
justifyContent: 'center',
alignItems: 'center'}}>
<Face
face={face}
onFaceClick={faceClicked}
title={face.distance}/>
</div>
);
});
}, [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 data = await res.json();
setIdentity({ ...identity });
} catch (error) {
console.error(error);
}
};
if (identity === undefined) {
return (<div className='Cluster'>
Select identity to load.
</div>);
}
return (
<div className='Cluster'>
<div className="Info">
<form className="IdentityForm">
<div>Last name:</div>
<input type="text"
value={identity.lastName}
onChange={lastNameChanged}/>
<div>First name:</div>
<input type="text"
value={identity.firstName}
onChange={firstNameChanged} />
<div>Middle name:</div><input type="text"
value={identity.middleName}
onChange={middleNameChanged} />
<div>Display name:</div>
<input type="text"
value={identity.displayName}
onChange={displayNameChanged} />
</form>
<Button onClick={updateIdentity}>Update</Button>
</div>
<div>Faces: {identity.relatedFaces.length}</div>
<div className="Faces">
{ relatedFacesJSX }
</div>
</div>
);
};
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 (
<div
key={face.faceId}
style={{
display: "flex",
justifyContent: 'center',
alignItems: 'center'
}}>
<Face
face={face}
onFaceClick={onFaceClick}
title={identity.displayName}/>
</div>
);
});
}, [ identities, onFaceClick ]);
return (
<div className='Identities'>
{ identitiesJSX }
</div>
);
};
const Button = ({ onClick, children }: any) => {
return (
<button type="button" onClick={onClick}>
{children}
</button>
);
};
const App = () => {
const [identities, setIdentities] = useState<IdentityData[]>([]);
const { identityId, faceId } = useParams();
const [identity, setIdentity] = useState<IdentityData | undefined>(undefined);
const [image, setImage] = useState<number>(0);
const { loading, data } = useApi(
`${base}/api/v1/identities`
);
const [selected, setSelected] = useState<number[]>([]);
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 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) => {
if (!face.identity) {
console.log(`Face ${face.faceId} does not have an Identity`);
return;
}
const identityId = face.identity.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 identityId = face.identity.identityId;
loadIdentity(identityId);
}
return (
<div className="App">
<div className="Worksheet">
<PanelGroup className="Explorer"
autoSaveId="persistence" direction="horizontal">
<Panel defaultSize={50} className="ClusterEditor">
{loading && <div style={{ margin: '1rem' }}>Loading...</div>}
{!loading && identity !== undefined &&
<Cluster {...{
identity,
setIdentity,
setImage,
setSelected
}} />}
{!loading && identity === undefined && <div className="Cluster">
Select identity to edit
</div>}
<div className="Actions">
{ selected.length !== 0 && <>
<Button onClick={markSelectedIncorrectIdentity}>Remove</Button>
<Button onClick={markSelectedNotFace}>Not a face</Button>
</>}
</div>
</Panel>
<PanelResizeHandle className="Resizer"/>
<Panel>
{image === 0 && <div style={{ margin: '1rem' }}>Select image to view</div>}
{image !== 0 && <Photo onFaceClick={onFaceClick} photoId={image}/> }
</Panel>
<PanelResizeHandle className="Resizer" />
<Panel defaultSize={8.5} minSize={8.5} className="IdentitiesList">
{ !loading && <Identities
{... { onFaceClick: identitiesOnFaceClick, identities }}/> }
</Panel>
</PanelGroup>
</div>
</div>
);
}
const AppRouter = () => {
return (
<Router>
<Routes>
<Route element={<App />} path={`${base}/:identityId?/:faceId?`} />
</Routes>
</Router>
);
}
export default AppRouter;