574 lines
16 KiB
TypeScript
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;
|