feat(lab): complete E30 evidence review gate
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
|
||||
|
||||
interface E30EvidencePointCloudProps {
|
||||
detail: E30ReviewItemDetail;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) {
|
||||
return new THREE.Color(value);
|
||||
}
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3
|
||||
? channels
|
||||
: fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function createPointTexture(): THREE.CanvasTexture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context) {
|
||||
const gradient = context.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
|
||||
gradient.addColorStop(0.72, "rgba(255, 255, 255, 0.94)");
|
||||
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, 64, 64);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function toMapScenePositions(
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[],
|
||||
positionMapXyzM: readonly [number, number, number],
|
||||
): Float32Array {
|
||||
const positions = new Float32Array(pointsMapXyzM.length * 3);
|
||||
pointsMapXyzM.forEach(([mapX, mapY, mapZ], index) => {
|
||||
const offset = index * 3;
|
||||
positions[offset] = mapX - positionMapXyzM[0];
|
||||
positions[offset + 1] = mapZ - positionMapXyzM[2];
|
||||
positions[offset + 2] = -(mapY - positionMapXyzM[1]);
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
function boundsFromPositions(positions: Float32Array): THREE.Box3 {
|
||||
const bounds = new THREE.Box3();
|
||||
const point = new THREE.Vector3();
|
||||
for (let offset = 0; offset < positions.length; offset += 3) {
|
||||
point.set(positions[offset], positions[offset + 1], positions[offset + 2]);
|
||||
bounds.expandByPoint(point);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function evidenceViewFromPositions(positions: Float32Array): {
|
||||
target: THREE.Vector3;
|
||||
radius: number;
|
||||
} {
|
||||
if (!positions.length) {
|
||||
return { target: new THREE.Vector3(), radius: 0.65 };
|
||||
}
|
||||
const xValues: number[] = [];
|
||||
const yValues: number[] = [];
|
||||
const zValues: number[] = [];
|
||||
for (let offset = 0; offset < positions.length; offset += 3) {
|
||||
xValues.push(positions[offset]);
|
||||
yValues.push(positions[offset + 1]);
|
||||
zValues.push(positions[offset + 2]);
|
||||
}
|
||||
const target = new THREE.Vector3(
|
||||
percentile(xValues, 0.5),
|
||||
percentile(yValues, 0.5),
|
||||
percentile(zValues, 0.5),
|
||||
);
|
||||
const radii = xValues.map((x, index) => Math.hypot(
|
||||
x - target.x,
|
||||
yValues[index] - target.y,
|
||||
zValues[index] - target.z,
|
||||
));
|
||||
return {
|
||||
target,
|
||||
radius: Math.max(percentile(radii, 0.9), 0.65),
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], fraction: number): number {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.max(0, Math.floor((sorted.length - 1) * fraction)),
|
||||
);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
export function E30EvidencePointCloud({ detail }: E30EvidencePointCloudProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const contextGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const rejectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const selectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const contextMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const rejectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const selectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||
const viewTargetRef = useRef(new THREE.Vector3());
|
||||
const viewDistanceRef = useRef(4);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать WebGL-сцену доказательства E30.");
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(
|
||||
tokenColor(host, "--nodedc-canvas", [5, 5, 6]),
|
||||
1,
|
||||
);
|
||||
renderer.domElement.setAttribute(
|
||||
"aria-label",
|
||||
"Интерактивное 3D-доказательство E30",
|
||||
);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 500);
|
||||
camera.position.set(4, 2.5, 4);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.2;
|
||||
controls.maxDistance = 300;
|
||||
controls.minPolarAngle = 0.04;
|
||||
controls.maxPolarAngle = Math.PI - 0.04;
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
controlsRef.current = controls;
|
||||
|
||||
const pointTexture = createPointTexture();
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
const contextMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 2.2,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
});
|
||||
const contextPoints = new THREE.Points(contextGeometry, contextMaterial);
|
||||
contextPoints.renderOrder = 0;
|
||||
scene.add(contextPoints);
|
||||
contextGeometryRef.current = contextGeometry;
|
||||
contextMaterialRef.current = contextMaterial;
|
||||
|
||||
const rejectedGeometry = new THREE.BufferGeometry();
|
||||
const rejectedMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 5.5,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.98,
|
||||
depthWrite: true,
|
||||
});
|
||||
const rejectedPoints = new THREE.Points(rejectedGeometry, rejectedMaterial);
|
||||
rejectedPoints.renderOrder = 1;
|
||||
scene.add(rejectedPoints);
|
||||
rejectedGeometryRef.current = rejectedGeometry;
|
||||
rejectedMaterialRef.current = rejectedMaterial;
|
||||
|
||||
const selectedGeometry = new THREE.BufferGeometry();
|
||||
const selectedMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 10.5,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const selectedPoints = new THREE.Points(selectedGeometry, selectedMaterial);
|
||||
selectedPoints.renderOrder = 3;
|
||||
scene.add(selectedPoints);
|
||||
selectedGeometryRef.current = selectedGeometry;
|
||||
selectedMaterialRef.current = selectedMaterial;
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
10,
|
||||
20,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const gridMaterials = Array.isArray(grid.material)
|
||||
? grid.material
|
||||
: [grid.material];
|
||||
gridMaterials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.16;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
gridRef.current = grid;
|
||||
scene.add(grid);
|
||||
|
||||
const sensorMarkerGeometry = new THREE.RingGeometry(0.08, 0.12, 32);
|
||||
const sensorMarkerMaterial = new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-secondary", [185, 187, 192]),
|
||||
transparent: true,
|
||||
opacity: 0.64,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sensorMarker = new THREE.Mesh(
|
||||
sensorMarkerGeometry,
|
||||
sensorMarkerMaterial,
|
||||
);
|
||||
sensorMarker.rotation.x = -Math.PI / 2;
|
||||
sensorMarker.renderOrder = 3;
|
||||
scene.add(sensorMarker);
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
resizeObserver.disconnect();
|
||||
controls.dispose();
|
||||
contextGeometry.dispose();
|
||||
contextMaterial.dispose();
|
||||
rejectedGeometry.dispose();
|
||||
rejectedMaterial.dispose();
|
||||
selectedGeometry.dispose();
|
||||
selectedMaterial.dispose();
|
||||
grid.geometry.dispose();
|
||||
gridMaterials.forEach((material) => material.dispose());
|
||||
sensorMarkerGeometry.dispose();
|
||||
sensorMarkerMaterial.dispose();
|
||||
pointTexture.dispose();
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
contextGeometryRef.current = null;
|
||||
rejectedGeometryRef.current = null;
|
||||
selectedGeometryRef.current = null;
|
||||
contextMaterialRef.current = null;
|
||||
rejectedMaterialRef.current = null;
|
||||
selectedMaterialRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
gridRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const contextGeometry = contextGeometryRef.current;
|
||||
const rejectedGeometry = rejectedGeometryRef.current;
|
||||
const selectedGeometry = selectedGeometryRef.current;
|
||||
const contextMaterial = contextMaterialRef.current;
|
||||
const rejectedMaterial = rejectedMaterialRef.current;
|
||||
const selectedMaterial = selectedMaterialRef.current;
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
const grid = gridRef.current;
|
||||
if (
|
||||
!host
|
||||
|| !contextGeometry
|
||||
|| !rejectedGeometry
|
||||
|| !selectedGeometry
|
||||
|| !contextMaterial
|
||||
|| !rejectedMaterial
|
||||
|| !selectedMaterial
|
||||
|| !camera
|
||||
|| !controls
|
||||
|| !grid
|
||||
) return;
|
||||
|
||||
const contextPositions = toMapScenePositions(
|
||||
detail.projection.pointsMapXyzM,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
const selectedPositions = toMapScenePositions(
|
||||
detail.selected.pointsMapXyzM,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
const selectedIndices = new Set(detail.selected.sourceIndices);
|
||||
const rejectedPointsMap = detail.candidate.pointsMapXyzM.filter(
|
||||
(_point, index) => !selectedIndices.has(
|
||||
detail.candidate.sourceIndices[index] ?? -1,
|
||||
),
|
||||
);
|
||||
const rejectedPositions = toMapScenePositions(
|
||||
rejectedPointsMap,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(contextPositions, 3),
|
||||
);
|
||||
rejectedGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(rejectedPositions, 3),
|
||||
);
|
||||
selectedGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(selectedPositions, 3),
|
||||
);
|
||||
contextGeometry.computeBoundingSphere();
|
||||
rejectedGeometry.computeBoundingSphere();
|
||||
selectedGeometry.computeBoundingSphere();
|
||||
|
||||
rejectedMaterial.color.copy(
|
||||
tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
|
||||
);
|
||||
selectedMaterial.color.copy(
|
||||
tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
);
|
||||
|
||||
const evidencePositions = selectedPositions.length || rejectedPositions.length
|
||||
? new Float32Array([...rejectedPositions, ...selectedPositions])
|
||||
: contextPositions;
|
||||
const evidenceBounds = boundsFromPositions(evidencePositions);
|
||||
const contextBounds = boundsFromPositions(contextPositions);
|
||||
const evidenceView = evidenceViewFromPositions(evidencePositions);
|
||||
const target = evidenceView.target;
|
||||
const evidenceRadius = evidenceView.radius;
|
||||
const evidenceSize = evidenceBounds.isEmpty()
|
||||
? new THREE.Vector3(1, 1, 1)
|
||||
: evidenceBounds.getSize(new THREE.Vector3());
|
||||
const contextSize = contextBounds.isEmpty()
|
||||
? evidenceSize
|
||||
: contextBounds.getSize(new THREE.Vector3());
|
||||
const contextRadius = Math.max(contextSize.length() / 2, evidenceRadius);
|
||||
const distance = Math.max(evidenceRadius * 2.45, 2.3);
|
||||
|
||||
viewTargetRef.current.copy(target);
|
||||
viewDistanceRef.current = distance;
|
||||
controls.target.copy(target);
|
||||
camera.position.set(
|
||||
target.x + distance * 0.86,
|
||||
target.y + distance * 0.52,
|
||||
target.z + distance * 0.86,
|
||||
);
|
||||
camera.near = Math.max(distance / 2_000, 0.005);
|
||||
camera.far = Math.max(contextRadius * 12, distance * 40, 120);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.maxDistance = Math.max(contextRadius * 5, distance * 5, 40);
|
||||
controls.update();
|
||||
|
||||
const contextHeights: number[] = [];
|
||||
for (let offset = 1; offset < contextPositions.length; offset += 3) {
|
||||
contextHeights.push(contextPositions[offset]);
|
||||
}
|
||||
const groundHeight = percentile(contextHeights, 0.04);
|
||||
const gridSize = THREE.MathUtils.clamp(evidenceRadius * 7, 8, 48);
|
||||
grid.position.set(target.x, groundHeight, target.z);
|
||||
grid.scale.setScalar(gridSize / 10);
|
||||
}, [detail]);
|
||||
|
||||
const resetCamera = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
const target = viewTargetRef.current;
|
||||
const distance = viewDistanceRef.current;
|
||||
controls.target.copy(target);
|
||||
camera.position.set(
|
||||
target.x + distance * 0.86,
|
||||
target.y + distance * 0.52,
|
||||
target.z + distance * 0.86,
|
||||
);
|
||||
controls.update();
|
||||
};
|
||||
|
||||
const selectedIndices = new Set(detail.selected.sourceIndices);
|
||||
const rejectedCount = detail.candidate.sourceIndices.filter(
|
||||
(sourceIndex) => !selectedIndices.has(sourceIndex),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="e30-evidence-scene" data-testid="e30-evidence-3d">
|
||||
<div ref={hostRef} className="e30-evidence-scene__viewport">
|
||||
{renderError ? (
|
||||
<p className="e30-evidence-scene__error">{renderError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="e30-evidence-scene__toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
onClick={resetCamera}
|
||||
>
|
||||
Сбросить ракурс
|
||||
</Button>
|
||||
<div className="e30-evidence-scene__gestures" aria-label="Управление 3D-сценой">
|
||||
<span>ЛКМ · вращение</span>
|
||||
<span>Колесо · масштаб</span>
|
||||
<span>ПКМ · панорама</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e30-evidence-scene__legend" aria-label="Легенда 3D-доказательства">
|
||||
<span data-point="context">
|
||||
Контекст · {detail.projection.pointsMapXyzM.length}
|
||||
</span>
|
||||
<span data-point="rejected">Отклонено · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
|
||||
|
||||
function tokenColor(host: HTMLElement, token: string, fallback: string): string {
|
||||
return getComputedStyle(host).getPropertyValue(token).trim() || fallback;
|
||||
}
|
||||
|
||||
function tokenRgb(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgb(${red} ${green} ${blue} / ${alpha})`;
|
||||
}
|
||||
|
||||
function depthColor(
|
||||
depth: number,
|
||||
minimumDepth: number,
|
||||
maximumDepth: number,
|
||||
): string {
|
||||
const span = Math.max(maximumDepth - minimumDepth, 0.001);
|
||||
const position = Math.min(1, Math.max(0, (depth - minimumDepth) / span));
|
||||
const hue = 220 - position * 205;
|
||||
return `hsl(${hue} 88% 62% / 0.78)`;
|
||||
}
|
||||
|
||||
export function E30EvidenceProjection({
|
||||
detail,
|
||||
projectionWidth,
|
||||
projectionHeight,
|
||||
pointLayerVisible,
|
||||
}: {
|
||||
detail: E30ReviewItemDetail;
|
||||
projectionWidth: number;
|
||||
projectionHeight: number;
|
||||
pointLayerVisible: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [frameState, setFrameState] = useState<
|
||||
"loading" | "ready" | "unavailable"
|
||||
>(detail.cameraFrame ? "loading" : "unavailable");
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !context || !host) return;
|
||||
|
||||
let cancelled = false;
|
||||
const canvasWidth = 1_200;
|
||||
const canvasHeight = Math.round(
|
||||
canvasWidth * projectionHeight / projectionWidth,
|
||||
);
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
const scaleX = canvasWidth / projectionWidth;
|
||||
const scaleY = canvasHeight / projectionHeight;
|
||||
const depths = detail.projection.depthM.filter(Number.isFinite);
|
||||
const minimumDepth = depths.length ? Math.min(...depths) : 0;
|
||||
const maximumDepth = depths.length ? Math.max(...depths) : 1;
|
||||
|
||||
const draw = (image: HTMLImageElement | null) => {
|
||||
if (cancelled) return;
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
if (image) {
|
||||
context.drawImage(image, 0, 0, canvasWidth, canvasHeight);
|
||||
if (pointLayerVisible) {
|
||||
context.fillStyle = "rgb(0 0 0 / 0.08)";
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
} else {
|
||||
context.fillStyle = tokenColor(host, "--nodedc-canvas", "#050506");
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
if (pointLayerVisible) {
|
||||
detail.projection.pixelsXy.forEach(([sourceX, sourceY], index) => {
|
||||
if (
|
||||
sourceX < 0
|
||||
|| sourceX > projectionWidth
|
||||
|| sourceY < 0
|
||||
|| sourceY > projectionHeight
|
||||
) return;
|
||||
const selected = detail.projection.selectedMask[index] === 1;
|
||||
const candidate = detail.projection.candidateMask[index] === 1;
|
||||
const x = sourceX * scaleX;
|
||||
const y = sourceY * scaleY;
|
||||
context.fillStyle = selected
|
||||
? tokenRgb(host, "--nodedc-accent-rgb", [247, 248, 244])
|
||||
: candidate
|
||||
? tokenRgb(host, "--nodedc-warning-rgb", [255, 209, 102])
|
||||
: depthColor(
|
||||
detail.projection.depthM[index] ?? minimumDepth,
|
||||
minimumDepth,
|
||||
maximumDepth,
|
||||
);
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
x,
|
||||
y,
|
||||
selected ? 6.5 : candidate ? 4.25 : 2.1,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
});
|
||||
}
|
||||
|
||||
const bbox = detail.snapshot.bboxXyxy;
|
||||
if (bbox) {
|
||||
context.strokeStyle = tokenRgb(
|
||||
host,
|
||||
detail.stratum === "conflict"
|
||||
? "--nodedc-danger-rgb"
|
||||
: "--nodedc-accent-rgb",
|
||||
detail.stratum === "conflict"
|
||||
? [255, 98, 92]
|
||||
: [247, 248, 244],
|
||||
);
|
||||
context.lineWidth = 2.5;
|
||||
context.strokeRect(
|
||||
bbox[0] * scaleX,
|
||||
bbox[1] * scaleY,
|
||||
(bbox[2] - bbox[0]) * scaleX,
|
||||
(bbox[3] - bbox[1]) * scaleY,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!detail.cameraFrame) {
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setFrameState("loading");
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.onload = () => {
|
||||
if (cancelled) return;
|
||||
if (
|
||||
image.naturalWidth !== detail.cameraFrame?.width
|
||||
|| image.naturalHeight !== detail.cameraFrame?.height
|
||||
) {
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
return;
|
||||
}
|
||||
setFrameState("ready");
|
||||
draw(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
};
|
||||
image.src = detail.cameraFrame.url;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = "";
|
||||
};
|
||||
}, [detail, pointLayerVisible, projectionHeight, projectionWidth]);
|
||||
|
||||
const rejectedCount = detail.projection.candidateMask.reduce(
|
||||
(count, candidate, index) => (
|
||||
count
|
||||
+ Number(
|
||||
candidate === 1
|
||||
&& detail.projection.selectedMask[index] !== 1,
|
||||
)
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="e30-projection-scene" data-testid="e30-evidence-camera">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-label={pointLayerVisible
|
||||
? "Камерный кадр с проекцией LiDAR и выбранным наблюдением E30"
|
||||
: "Исходный камерный кадр с рамкой наблюдения E30"}
|
||||
/>
|
||||
{frameState !== "ready" ? (
|
||||
<div className="e30-projection-scene__state" role="status">
|
||||
{frameState === "loading"
|
||||
? "Проверяем точный кадр камеры…"
|
||||
: "Точный кадр камеры не материализован"}
|
||||
</div>
|
||||
) : null}
|
||||
{pointLayerVisible ? (
|
||||
<div
|
||||
className="e30-evidence-scene__legend"
|
||||
aria-label="Легенда camera-LiDAR доказательства"
|
||||
>
|
||||
<span data-point="depth">
|
||||
LiDAR · глубина · {detail.projection.pointsMapXyzM.length}
|
||||
</span>
|
||||
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmationModal,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextAreaField,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createOrResumeE30HumanReview,
|
||||
finalizeE30HumanReview,
|
||||
saveE30HumanReviewDecision,
|
||||
type E30ExceptionDisposition,
|
||||
type E30HumanReviewDraft,
|
||||
} from "../core/laboratory/e30HumanReview";
|
||||
import type { E30EngineeringGeneration } from "../core/laboratory/e30Engineering";
|
||||
import type {
|
||||
E30ReviewItemDetail,
|
||||
E30ReviewResult,
|
||||
} from "../core/laboratory/e30Review";
|
||||
|
||||
const DISPOSITION_OPTIONS: readonly {
|
||||
value: E30ExceptionDisposition;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "object-present", label: "Объект есть" },
|
||||
{ value: "background-or-noise", label: "Фон или шум" },
|
||||
{ value: "insufficient-evidence", label: "Недостаточно данных" },
|
||||
];
|
||||
|
||||
const FALLBACK_REVIEW_PROMPT = {
|
||||
question: "Белый кластер — самостоятельное физическое препятствие?",
|
||||
focus: (
|
||||
"Сопоставьте выбранные белые точки с исходным кадром и решите, "
|
||||
+ "принадлежат ли они занятой геометрии реального объекта."
|
||||
),
|
||||
effects: {
|
||||
"object-present": "Сохранить кластер как занятую геометрию.",
|
||||
"background-or-noise": "Исключить кластер как фон или шум.",
|
||||
"insufficient-evidence": "Оставить кейс неизвестным без настройки порогов.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function decisionFor(
|
||||
review: E30HumanReviewDraft | null,
|
||||
itemId: string | undefined,
|
||||
) {
|
||||
return itemId
|
||||
? review?.decisions.find((decision) => decision.itemId === itemId) ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function E30HumanReviewPanel({
|
||||
result,
|
||||
generation,
|
||||
item,
|
||||
review,
|
||||
onReviewChange,
|
||||
onDecisionSaved,
|
||||
}: {
|
||||
result: E30ReviewResult;
|
||||
generation: E30EngineeringGeneration;
|
||||
item: E30ReviewItemDetail | null;
|
||||
review: E30HumanReviewDraft | null;
|
||||
onReviewChange: (review: E30HumanReviewDraft) => void;
|
||||
onDecisionSaved: (review: E30HumanReviewDraft) => void;
|
||||
}) {
|
||||
const [disposition, setDisposition] =
|
||||
useState<E30ExceptionDisposition>("object-present");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [finalizeOpen, setFinalizeOpen] = useState(false);
|
||||
const currentDecision = decisionFor(review, item?.itemId);
|
||||
const reviewPrompt = generation.humanExceptions.find(
|
||||
(exception) => exception.itemId === item?.itemId,
|
||||
)?.reviewPrompt ?? FALLBACK_REVIEW_PROMPT;
|
||||
|
||||
useEffect(() => {
|
||||
setDisposition(currentDecision?.disposition ?? "object-present");
|
||||
setNotes(currentDecision?.notes ?? "");
|
||||
setError(null);
|
||||
}, [currentDecision, item?.itemId]);
|
||||
|
||||
const begin = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
onReviewChange(await createOrResumeE30HumanReview(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
));
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Проверка недоступна.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!review || !item || review.state !== "active" || pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await saveE30HumanReviewDecision(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
review.draftId,
|
||||
item.itemId,
|
||||
{
|
||||
expectedRevision: review.revision,
|
||||
idempotencyKey: `ui-${crypto.randomUUID()}`,
|
||||
disposition,
|
||||
notes: notes.trim() || null,
|
||||
},
|
||||
);
|
||||
onReviewChange(next);
|
||||
onDecisionSaved(next);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Решение не сохранено.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = async () => {
|
||||
if (!review || review.state !== "active") return;
|
||||
setError(null);
|
||||
try {
|
||||
const finalized = await finalizeE30HumanReview(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
review.draftId,
|
||||
review.revision,
|
||||
);
|
||||
onReviewChange(finalized.draft);
|
||||
setFinalizeOpen(false);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Проверка не зафиксирована.",
|
||||
);
|
||||
throw caught;
|
||||
}
|
||||
};
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<section className="e30-human-review" aria-label="Проверка исключений">
|
||||
<header className="e30-human-review__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТРЕБУЕТСЯ РЕШЕНИЕ</span>
|
||||
<h3>{reviewPrompt.question}</h3>
|
||||
<p>{reviewPrompt.focus}</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">
|
||||
0 / {generation.summary.humanExceptionCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="e30-human-review__actions">
|
||||
<span>
|
||||
Решение изменит только отдельную A3-коррекцию; A2 останется
|
||||
неизменным.
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={pending}
|
||||
onClick={() => void begin()}
|
||||
>
|
||||
{pending ? "Открываем…" : "Начать проверку"}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="e30-human-review__error" role="alert">{error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const finalized = review.state === "finalized";
|
||||
return (
|
||||
<section className="e30-human-review" aria-label="Проверка исключений">
|
||||
<header className="e30-human-review__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПРОВЕРКА ИСКЛЮЧЕНИЙ</span>
|
||||
<h3>
|
||||
{finalized ? "Проверка зафиксирована" : reviewPrompt.question}
|
||||
</h3>
|
||||
{!finalized ? <p>{reviewPrompt.focus}</p> : null}
|
||||
</div>
|
||||
<StatusBadge tone={finalized ? "success" : "accent"}>
|
||||
{review.reviewedItemCount} / {review.itemCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{finalized ? (
|
||||
<p>
|
||||
Все спорные кадры получили отдельное человеческое решение.
|
||||
Исходные доказательства сохранены без изменений.
|
||||
</p>
|
||||
) : item ? (
|
||||
<>
|
||||
<div className="e30-human-review__form">
|
||||
<div className="e30-human-review__field">
|
||||
<span>Решение</span>
|
||||
<Select
|
||||
label="Что видно на выбранном кадре"
|
||||
value={disposition}
|
||||
options={[...DISPOSITION_OPTIONS]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={pending}
|
||||
onChange={setDisposition}
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField
|
||||
label="Комментарий"
|
||||
hint="необязательно"
|
||||
value={notes}
|
||||
rows={2}
|
||||
maxLength={2_000}
|
||||
disabled={pending}
|
||||
onChange={(event) => setNotes(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="e30-human-review__impact">
|
||||
<span>Что изменится после решения</span>
|
||||
<strong>{reviewPrompt.effects[disposition]}</strong>
|
||||
</div>
|
||||
<div className="e30-human-review__actions">
|
||||
<span>
|
||||
{currentDecision
|
||||
? "Этот кадр уже решён — его можно пересмотреть."
|
||||
: `${review.remainingItemCount} решений осталось.`}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={pending}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{pending
|
||||
? "Сохраняем…"
|
||||
: currentDecision
|
||||
? "Обновить кадр"
|
||||
: "Сохранить кадр"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={review.remainingItemCount !== 0 || pending}
|
||||
onClick={() => setFinalizeOpen(true)}
|
||||
>
|
||||
Зафиксировать проверку
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="e30-human-review__error" role="alert">{error}</p>
|
||||
) : null}
|
||||
|
||||
<ConfirmationModal
|
||||
open={finalizeOpen}
|
||||
title="Зафиксировать проверку?"
|
||||
description={(
|
||||
<p>
|
||||
Будет создан неизменяемый набор из {review.itemCount} решений.
|
||||
После фиксации их нельзя будет изменить.
|
||||
</p>
|
||||
)}
|
||||
confirmLabel="Зафиксировать"
|
||||
pendingLabel="Фиксируем…"
|
||||
onClose={() => setFinalizeOpen(false)}
|
||||
onConfirm={finalize}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import { E30EvidenceTelemetry } from "../components/laboratory/E30EvidenceTelemetry";
|
||||
import { E30EngineeringGenerationSummary } from "../components/laboratory/E30EngineeringGenerationSummary";
|
||||
import {
|
||||
fetchE30EngineeringCatalog,
|
||||
fetchE30EngineeringExceptions,
|
||||
type E30EngineeringGeneration,
|
||||
} from "../core/laboratory/e30Engineering";
|
||||
import type { E30HumanReviewDraft } from "../core/laboratory/e30HumanReview";
|
||||
import {
|
||||
E30_STRATA,
|
||||
fetchE30ReviewItemDetail,
|
||||
fetchE30ReviewItems,
|
||||
type E30ReviewItem,
|
||||
type E30ReviewItemDetail,
|
||||
type E30ReviewResult,
|
||||
type E30Stratum,
|
||||
} from "../core/laboratory/e30Review";
|
||||
import { formatNumber } from "../presentation";
|
||||
import { E30EvidencePointCloud } from "./E30EvidencePointCloud";
|
||||
import { E30EvidenceProjection } from "./E30EvidenceProjection";
|
||||
import { E30HumanReviewPanel } from "./E30HumanReviewPanel";
|
||||
|
||||
type E30EvidenceMode = "camera" | "3d";
|
||||
type E30Filter = E30Stratum | "review";
|
||||
|
||||
const FILTER_LABELS: Record<E30Filter, string> = {
|
||||
conflict: "Конфликт",
|
||||
agree: "Согласовано",
|
||||
"camera-only": "Только камера",
|
||||
unknown: "Неизвестно",
|
||||
"geometry-only": "Только геометрия",
|
||||
review: "Проверка",
|
||||
};
|
||||
const FILTERS: readonly E30Filter[] = [...E30_STRATA, "review"];
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||
}
|
||||
|
||||
function itemTitle(item: E30ReviewItem): string {
|
||||
if (item.snapshot.label) return item.snapshot.label;
|
||||
return item.locatorKind === "geometry-only-cluster"
|
||||
? "Геометрический кластер"
|
||||
: "Семантическое наблюдение";
|
||||
}
|
||||
|
||||
function evidenceRange(item: E30ReviewItem): string {
|
||||
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
|
||||
return value === null
|
||||
? "Дальность недоступна"
|
||||
: `${value.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`;
|
||||
}
|
||||
|
||||
export function E30ReviewWorkspace({
|
||||
result,
|
||||
}: {
|
||||
result: E30ReviewResult;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<E30Filter>("conflict");
|
||||
const [items, setItems] = useState<readonly E30ReviewItem[]>([]);
|
||||
const [itemTotal, setItemTotal] = useState(result.stratumCounts.conflict);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<E30ReviewItemDetail | null>(null);
|
||||
const [itemsLoading, setItemsLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [evidenceMode, setEvidenceMode] = useState<E30EvidenceMode>("camera");
|
||||
const [pointLayerVisible, setPointLayerVisible] = useState(true);
|
||||
const [viewerExpanded, setViewerExpanded] = useState(false);
|
||||
const [engineeringGeneration, setEngineeringGeneration] =
|
||||
useState<E30EngineeringGeneration | null>(null);
|
||||
const [engineeringLoading, setEngineeringLoading] = useState(true);
|
||||
const [humanReview, setHumanReview] =
|
||||
useState<E30HumanReviewDraft | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter === "review" && !engineeringGeneration) {
|
||||
setItems([]);
|
||||
setItemTotal(0);
|
||||
setSelectedItemId(null);
|
||||
setItemsLoading(engineeringLoading);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setItemsLoading(true);
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
const request = filter === "review"
|
||||
? fetchE30EngineeringExceptions(
|
||||
result.resultId,
|
||||
engineeringGeneration!.generationId,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
: fetchE30ReviewItems(result.resultId, filter, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
void request.then((next) => {
|
||||
setItems(next.items);
|
||||
setItemTotal(next.total);
|
||||
setSelectedItemId((current) => (
|
||||
next.items.some((item) => item.itemId === current)
|
||||
? current
|
||||
: next.items[0]?.itemId ?? null
|
||||
));
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setItems([]);
|
||||
setSelectedItemId(null);
|
||||
setError(caught instanceof Error ? caught.message : "Выборка E30 недоступна.");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setItemsLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
engineeringGeneration,
|
||||
engineeringLoading,
|
||||
filter,
|
||||
result.resultId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEngineeringLoading(true);
|
||||
setEngineeringGeneration(null);
|
||||
void fetchE30EngineeringCatalog(result.resultId, {
|
||||
signal: controller.signal,
|
||||
}).then((catalog) => {
|
||||
setEngineeringGeneration(catalog.items[0] ?? null);
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setEngineeringGeneration(null);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setEngineeringLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItemId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setDetailLoading(true);
|
||||
setError(null);
|
||||
void fetchE30ReviewItemDetail(result.resultId, selectedItemId, {
|
||||
signal: controller.signal,
|
||||
}).then(setDetail).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setDetail(null);
|
||||
setError(caught instanceof Error ? caught.message : "Доказательство E30 недоступно.");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setDetailLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedItemId]);
|
||||
|
||||
const selectItem = (item: E30ReviewItem) => {
|
||||
setSelectedItemId(item.itemId);
|
||||
};
|
||||
|
||||
const advanceAfterDecision = (next: E30HumanReviewDraft) => {
|
||||
const resolved = new Set(next.decisions.map((decision) => decision.itemId));
|
||||
const currentIndex = items.findIndex((item) => item.itemId === selectedItemId);
|
||||
const ordered = [
|
||||
...items.slice(currentIndex + 1),
|
||||
...items.slice(0, currentIndex + 1),
|
||||
];
|
||||
const unresolved = ordered.find((item) => !resolved.has(item.itemId));
|
||||
if (unresolved) setSelectedItemId(unresolved.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassSurface
|
||||
className="e30-review-workspace"
|
||||
tone="soft"
|
||||
padding="md"
|
||||
materialRim={false}
|
||||
role="region"
|
||||
aria-label="Рабочее место ревью E30"
|
||||
>
|
||||
<header className="e30-review-workspace__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">CAMERA-BACKED REVIEW SUBSTRATE</span>
|
||||
<h2>A2 evidence · A3 engineering audit</h2>
|
||||
<p>
|
||||
Точный camera frame, LiDAR-проекция и синхронный 3D сохраняют A2
|
||||
неизменяемым. A3 выпускает отдельные решения с явным provenance.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={result.cameraEvidenceAvailable ? "accent" : "warning"}>
|
||||
{result.cameraEvidenceAvailable
|
||||
? "Camera evidence привязано"
|
||||
: "Camera evidence отсутствует"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{engineeringGeneration ? (
|
||||
<E30EngineeringGenerationSummary
|
||||
generation={engineeringGeneration}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SegmentedControl
|
||||
className="e30-review-workspace__strata"
|
||||
value={filter}
|
||||
label="Группа E30"
|
||||
items={FILTERS.map((value) => ({
|
||||
value,
|
||||
label: `${FILTER_LABELS[value]} · ${formatNumber(
|
||||
value === "review"
|
||||
? engineeringGeneration?.summary.humanExceptionCount ?? 0
|
||||
: result.stratumCounts[value],
|
||||
0,
|
||||
)}`,
|
||||
}))}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
|
||||
<div className="e30-review-workspace__body">
|
||||
<aside className="e30-review-workspace__items" aria-label="Кейсы выбранной страты">
|
||||
<header>
|
||||
<span>{FILTER_LABELS[filter]}</span>
|
||||
<small>показано {items.length} из {itemTotal}</small>
|
||||
</header>
|
||||
<div className="e30-review-workspace__item-list">
|
||||
{itemsLoading ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем индекс</span>
|
||||
</div>
|
||||
) : items.length ? items.map((item) => (
|
||||
<Button
|
||||
key={item.itemId}
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
className="e30-review-workspace__item"
|
||||
data-active={item.itemId === selectedItemId ? "true" : undefined}
|
||||
aria-pressed={item.itemId === selectedItemId}
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span>{itemTitle(item)}</span>
|
||||
<strong>Кадр {formatNumber(item.sourceFrameIndex, 0)}</strong>
|
||||
<small>
|
||||
{formatSeconds(item.sessionSeconds)}
|
||||
{" · "}
|
||||
{evidenceRange(item)}
|
||||
{filter === "review"
|
||||
&& humanReview?.decisions.some(
|
||||
(decision) => decision.itemId === item.itemId,
|
||||
)
|
||||
? " · Решено"
|
||||
: ""}
|
||||
</small>
|
||||
</Button>
|
||||
)) : (
|
||||
<div className="e30-review-workspace__state">
|
||||
<Icon name="database" size={18} />
|
||||
<span>В этой страте кейсов нет</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="e30-review-workspace__detail">
|
||||
{detailLoading ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем camera-LiDAR доказательство</span>
|
||||
</div>
|
||||
) : error || !detail ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Выберите кейс."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="e30-review-workspace__case">
|
||||
<div>
|
||||
<span className="section-eyebrow">{detail.reviewKey}</span>
|
||||
<h3>{itemTitle(detail)}</h3>
|
||||
<p>
|
||||
{detail.snapshot.geometryReason ?? "Независимый geometry-only слой"}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={detail.stratum === "conflict" ? "danger" : "neutral"}>
|
||||
{FILTER_LABELS[detail.stratum]}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="e30-review-evidence">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Доказательство E30"
|
||||
mode={evidenceMode}
|
||||
modes={[
|
||||
{ value: "camera", label: "Камера" },
|
||||
{ value: "3d", label: "3D" },
|
||||
]}
|
||||
expanded={viewerExpanded}
|
||||
onModeChange={setEvidenceMode}
|
||||
onExpandedChange={setViewerExpanded}
|
||||
actions={evidenceMode === "camera" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={pointLayerVisible ? "primary" : "secondary"}
|
||||
icon={<Icon name="sliders" size={16} />}
|
||||
aria-pressed={pointLayerVisible}
|
||||
onClick={() => setPointLayerVisible((visible) => !visible)}
|
||||
>
|
||||
LiDAR
|
||||
</Button>
|
||||
) : undefined}
|
||||
overlay={(
|
||||
<E30EvidenceTelemetry
|
||||
detail={detail}
|
||||
mode={evidenceMode}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{evidenceMode === "camera" ? (
|
||||
<E30EvidenceProjection
|
||||
detail={detail}
|
||||
projectionWidth={result.projection.width}
|
||||
projectionHeight={result.projection.height}
|
||||
pointLayerVisible={pointLayerVisible}
|
||||
/>
|
||||
) : (
|
||||
<E30EvidencePointCloud detail={detail} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{!detailLoading
|
||||
&& !error
|
||||
&& filter === "review"
|
||||
&& engineeringGeneration
|
||||
&& detail ? (
|
||||
<E30HumanReviewPanel
|
||||
result={result}
|
||||
generation={engineeringGeneration}
|
||||
item={detail}
|
||||
review={humanReview}
|
||||
onReviewChange={setHumanReview}
|
||||
onDecisionSaved={advanceAfterDecision}
|
||||
/>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { ObservationSessionReplayCallbacks } from "../components/ObservationSessionSelect";
|
||||
import type {
|
||||
DeviceModelDefinition,
|
||||
DevicePluginConnectionProps,
|
||||
} from "../core/device-plugins/contracts";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
BackendStatus,
|
||||
MissionRuntimeState,
|
||||
} from "../core/runtime/contracts";
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
requestedPlaybackSeconds?: number | null;
|
||||
recordedReplay: ObservationSessionReplayLaunch | null;
|
||||
recordedSessionAdmission: RecordedSessionAdmissionController | null;
|
||||
sceneSettings: SceneSettings;
|
||||
accumulationSeconds: number;
|
||||
onAccumulationChange: (value: number) => void;
|
||||
onAccumulationCommit: () => void;
|
||||
livePerceptionLayers: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
};
|
||||
onLivePerceptionLayersChange: (next: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
}) => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
deviceLabel: string | null;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
View: ComponentType<DevicePluginConnectionProps>;
|
||||
model: DeviceModelDefinition;
|
||||
} | null;
|
||||
sessionArchive: ObservationSessionReplayCallbacks & {
|
||||
disabled: boolean;
|
||||
blockedReason: string | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratorySelector,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
type LaboratoryMethod,
|
||||
type LaboratoryMethodComponent,
|
||||
type LaboratoryOption,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../../core/observation/useObservationSessions";
|
||||
import {
|
||||
fetchE29EvidenceCatalog,
|
||||
fetchE29EvidenceFrame,
|
||||
type E29EvidenceFrame,
|
||||
type E29EvidenceResult,
|
||||
} from "../../core/laboratory/e29Evidence";
|
||||
import {
|
||||
fetchE30ReviewCatalog,
|
||||
type E30ReviewResult,
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceModel,
|
||||
} from "../../core/lidar/localSurface";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E30ReviewWorkspace } from "../E30ReviewWorkspace";
|
||||
import { LidarQualityWorkspace } from "../LidarQualityWorkspace";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
|
||||
type LaboratoryProfileId = "sensor-fusion" | "published-perception";
|
||||
type LaboratoryWorkId =
|
||||
| "e28-local-surface"
|
||||
| "e29-camera-geometry"
|
||||
| "e30-evidence-review"
|
||||
| `session:${string}`;
|
||||
|
||||
function digestFromContentId(value: string | null | undefined): string | null {
|
||||
const digest = value?.split("-").at(-1) ?? "";
|
||||
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
|
||||
}
|
||||
|
||||
function publishedLaboratoryMethod(
|
||||
session: ObservationSessionSummary,
|
||||
): LaboratoryMethod {
|
||||
const method = session.lab?.provenance.method;
|
||||
if (method && typeof method === "object" && !Array.isArray(method)) {
|
||||
const value = method as Record<string, unknown>;
|
||||
const rawComponents = Array.isArray(value.components) ? value.components : [];
|
||||
const components: LaboratoryMethodComponent[] = rawComponents.flatMap((component) => {
|
||||
if (!component || typeof component !== "object" || Array.isArray(component)) return [];
|
||||
const item = component as Record<string, unknown>;
|
||||
const kind = item.kind;
|
||||
if (
|
||||
kind !== "source"
|
||||
&& kind !== "tool"
|
||||
&& kind !== "model"
|
||||
&& kind !== "algorithm"
|
||||
&& kind !== "runtime"
|
||||
) return [];
|
||||
if (
|
||||
typeof item.name !== "string"
|
||||
|| typeof item.version !== "string"
|
||||
|| typeof item.role !== "string"
|
||||
) return [];
|
||||
return [{
|
||||
kind: kind as LaboratoryMethodComponent["kind"],
|
||||
name: item.name,
|
||||
version: item.version,
|
||||
role: item.role,
|
||||
identitySha256: typeof item.identity_sha256 === "string"
|
||||
? item.identity_sha256
|
||||
: null,
|
||||
}];
|
||||
});
|
||||
const executionClass = value.execution_class;
|
||||
const completeness = value.completeness;
|
||||
if (
|
||||
components.length
|
||||
&& typeof value.pipeline_id === "string"
|
||||
&& (
|
||||
executionClass === "deterministic"
|
||||
|| executionClass === "ai-inference"
|
||||
|| executionClass === "hybrid"
|
||||
)
|
||||
&& (completeness === "complete" || completeness === "legacy-partial")
|
||||
) {
|
||||
return {
|
||||
completeness,
|
||||
executionClass,
|
||||
pipelineId: value.pipeline_id,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resultKind = session.lab?.resultKind ?? "unknown";
|
||||
const algorithmNames: Record<string, string> = {
|
||||
"e10-integrated-perception": "Camera semantics + LiDAR metric fusion",
|
||||
"e21-realtime-envelope": "Bounded real-time perception replay",
|
||||
"e22-temporal-stability": "Temporal 2D/3D/semantic stabilization",
|
||||
"e23-inline-temporal-stability": "Inline warm-worker stabilization",
|
||||
"e24-world-motion": "World-frame motion tracking",
|
||||
"e25-persistent-support-motion": "Persistent occupied-support tracking",
|
||||
"e26-camera-ego-motion-fusion": "KB4 ego-motion + persistent LiDAR support",
|
||||
};
|
||||
return {
|
||||
completeness: "legacy-partial",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: resultKind,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: session.lab?.sourceResultId ?? session.lab?.sourceSessionId ?? session.id,
|
||||
version: "immutable source evidence",
|
||||
role: "read-only input",
|
||||
identitySha256: digestFromContentId(session.lab?.sourceResultId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: algorithmNames[resultKind] ?? resultKind,
|
||||
version: resultKind,
|
||||
role: "laboratory derivative",
|
||||
identitySha256: session.lab?.configSha256 ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||
}
|
||||
|
||||
function E29LaboratoryResult({
|
||||
props,
|
||||
rigLabel,
|
||||
result,
|
||||
sourceSession,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: LaboratoryWorkspaceProps;
|
||||
rigLabel: string;
|
||||
result: E29EvidenceResult;
|
||||
sourceSession: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [selectedFrameIndex, setSelectedFrameIndex] = useState(
|
||||
result.reviewFrames[0]?.frameIndex ?? 0,
|
||||
);
|
||||
const [frame, setFrame] = useState<E29EvidenceFrame | null>(null);
|
||||
const [frameLoading, setFrameLoading] = useState(false);
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const replayReady = props.recordedReplay?.sessionId === sourceSession.id;
|
||||
const semantic = result.metrics.semanticObservations;
|
||||
const geometryStatus = semantic.geometryStatus;
|
||||
const conflicts = frame?.semanticObservations.filter(
|
||||
(observation) => observation.geometryStatus === "conflict",
|
||||
) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFrameLoading(true);
|
||||
setFrameError(null);
|
||||
void fetchE29EvidenceFrame(result.resultId, selectedFrameIndex, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFrame(null);
|
||||
setFrameError(
|
||||
caught instanceof Error ? caught.message : "Кадр E29 недоступен.",
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setFrameLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedFrameIndex]);
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E29 · camera-first semantics + независимая геометрия"
|
||||
description="Камера сохраняет класс и идентичность объекта, а LiDAR независимо подтверждает дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
||||
status="Проверенные артефакты"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
||||
{
|
||||
label: "Источник",
|
||||
value: `${sourceSession.label} · ${formatNumber(result.identity.frameCount, 0)} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Наблюдений",
|
||||
value: formatNumber(semantic.total, 0),
|
||||
},
|
||||
{
|
||||
label: "Контур",
|
||||
value: "Read-only · hash verified",
|
||||
},
|
||||
]}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: result.identity.profileId,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.linkedEvidence.sourceResultId,
|
||||
version: "camera-first semantic observations",
|
||||
role: "semantic identity and class",
|
||||
identitySha256: digestFromContentId(result.linkedEvidence.sourceResultId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "Camera/LiDAR local-surface validation",
|
||||
version: result.identity.profileId,
|
||||
role: "range, occupied support and conflict classification",
|
||||
identitySha256: result.identity.producerSha256,
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
name: result.linkedEvidence.localSurfaceModelId,
|
||||
version: "L2.6 local surface",
|
||||
role: "independent metric geometry",
|
||||
identitySha256: digestFromContentId(
|
||||
result.linkedEvidence.localSurfaceModelId,
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ИСХОДНЫЕ ДАННЫЕ"
|
||||
title="LiDAR, траектория и камера RAVNOVES00"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
{replayReady ? (
|
||||
<props.SpatialView {...props} />
|
||||
) : (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||
<strong>{loading ? "Проверяем и открываем запись" : "Исходная запись не открыта"}</strong>
|
||||
<p>
|
||||
{error ?? (loading
|
||||
? "Viewer появится после серверной проверки неизменяемого RRD."
|
||||
: "Выберите LAB E29 повторно, чтобы открыть связанный источник.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>Camera-first контракт рассчитан, production gate не пройден</h2>
|
||||
</div>
|
||||
<StatusBadge tone={result.decision.productionPromotion ? "success" : "warning"}>
|
||||
{result.decision.productionPromotion ? "Допущено" : "Только диагностика"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
<div>
|
||||
<span>Поддержка геометрией</span>
|
||||
<strong>{formatNumber(geometryStatus.agree, 0)}</strong>
|
||||
<small>{(semantic.agreementFractionOfCurrent * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% current</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только камера</span>
|
||||
<strong>{formatNumber(geometryStatus.cameraOnly, 0)}</strong>
|
||||
<small>Семантика без LiDAR-подтверждения</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Postprocess p95</span>
|
||||
<strong>{result.metrics.runtime.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
|
||||
<small>{formatSeconds(result.metrics.runtime.buildElapsedMs / 1000)} полный build</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только геометрия</span>
|
||||
<strong>{formatNumber(result.metrics.geometryOnlyOccupied.clusterCount, 0)}</strong>
|
||||
<small>{formatNumber(result.metrics.geometryOnlyOccupied.pointCount, 0)} точек</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>{result.decision.nextGate}</p>
|
||||
</section>
|
||||
)}
|
||||
details={(
|
||||
<section className="laboratory-frame-review">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">КАДРЫ С КОНФЛИКТОМ</span>
|
||||
<h2>Покадровое доказательство из camera-geometry-frames.jsonl</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">
|
||||
{formatNumber(geometryStatus.conflict, 0)} конфликтов
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-frame-review__picker" role="list">
|
||||
{result.reviewFrames.slice(0, 16).map((review) => (
|
||||
<button
|
||||
key={review.frameIndex}
|
||||
type="button"
|
||||
className={review.frameIndex === selectedFrameIndex ? "is-active" : undefined}
|
||||
onClick={() => setSelectedFrameIndex(review.frameIndex)}
|
||||
>
|
||||
<span>Кадр {formatNumber(review.sourceFrameIndex, 0)}</span>
|
||||
<small>{formatSeconds(review.sessionSeconds)} · {review.conflictCount} конфликт</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{frameLoading ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Читаем подтверждённый кадр</span>
|
||||
</div>
|
||||
) : frameError || !frame ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<Icon name="database" size={18} />
|
||||
<span>{frameError ?? "Кадр недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="laboratory-frame-review__detail">
|
||||
<div>
|
||||
<span>Кадр источника</span>
|
||||
<strong>{formatNumber(frame.sourceFrameIndex, 0)}</strong>
|
||||
<small>{formatSeconds(frame.sessionSeconds)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Семантические наблюдения</span>
|
||||
<strong>{formatNumber(frame.semanticObservations.length, 0)}</strong>
|
||||
<small>{formatNumber(conflicts.length, 0)} требуют разбора</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Geometry-only компоненты</span>
|
||||
<strong>{formatNumber(frame.geometryOnlyOccupied.length, 0)}</strong>
|
||||
<small>Класс не назначается</small>
|
||||
</div>
|
||||
<div className="laboratory-frame-review__conflicts">
|
||||
<span>Фактические конфликты</span>
|
||||
{conflicts.length ? conflicts.map((observation) => (
|
||||
<p key={observation.trackId}>
|
||||
<strong>{observation.label} · track {observation.trackId}</strong>
|
||||
<small>
|
||||
{observation.geometryReason} · classified {observation.support.classifiedPoints}
|
||||
{" · "}surface {observation.support.surfacePoints}
|
||||
</small>
|
||||
</p>
|
||||
)) : <small>В этом кадре конфликт не найден.</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function E30LaboratoryResult({
|
||||
rigLabel,
|
||||
result,
|
||||
sourceSession,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E30ReviewResult;
|
||||
sourceSession: ObservationSessionSummary;
|
||||
}) {
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E30 · рабочее место evidence review"
|
||||
description="A2 связывает каждый кейс E29 с точным camera frame, LiDAR-проекцией и frame-local индексами. Камера отвечает на вопрос «что видит детектор», синхронный 3D проверяет принадлежность и форму точек."
|
||||
status="Camera evidence проверено"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · A2` },
|
||||
{ label: "Источник", value: sourceSession.label },
|
||||
{ label: "Кейсов", value: formatNumber(result.itemCount, 0) },
|
||||
{ label: "Контур", value: "Read-only · без LAB publish" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="CAMERA + LIDAR ДОКАЗАТЕЛЬСТВО"
|
||||
title="Точный кадр, проекция и синхронный 3D"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<E30ReviewWorkspace result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLaboratoryMetric(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||
if (typeof value === "number") {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function laboratoryMetricLabel(key: string): string {
|
||||
return key
|
||||
.replace(/_p95_ms$/, " · p95 мс")
|
||||
.replaceAll("_", " ")
|
||||
.replace(/^./, (value) => value.toLocaleUpperCase("ru-RU"));
|
||||
}
|
||||
|
||||
function laboratorySessionTitle(session: ObservationSessionSummary): string {
|
||||
const labPrefix = session.lab ? `${session.lab.labId} · ` : "";
|
||||
return labPrefix && session.label.startsWith(labPrefix)
|
||||
? session.label.slice(labPrefix.length)
|
||||
: session.label;
|
||||
}
|
||||
|
||||
function PublishedLaboratoryResult({
|
||||
props,
|
||||
session,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: LaboratoryWorkspaceProps;
|
||||
session: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const lab = session.lab;
|
||||
const metrics = Object.entries(lab?.provenance ?? {})
|
||||
.filter(([key, value]) => (
|
||||
(typeof value === "number" || typeof value === "boolean")
|
||||
&& !key.includes("sha256")
|
||||
&& !key.includes("authority")
|
||||
))
|
||||
.slice(0, 8);
|
||||
const replayReady = props.recordedReplay?.sessionId === session.id;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
|
||||
description="Опубликованная работа открывается по неизменяемой записи. Viewer показывает исходные синхронные каналы, а продуктовая выжимка — только зафиксированный метод и LAB-provenance."
|
||||
status="Зафиксированный результат"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Тип результата", value: lab?.resultKind ?? "—" },
|
||||
{ label: "Источник", value: lab?.sourceSessionId ?? session.id },
|
||||
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
|
||||
{ label: "Контур", value: "Диагностика · без команд" },
|
||||
]}
|
||||
method={publishedLaboratoryMethod(session)}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="Исходная запись выбранной лабораторной работы"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
{replayReady ? (
|
||||
<props.SpatialView {...props} />
|
||||
) : (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||
<strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
|
||||
<p>
|
||||
{error ?? (loading
|
||||
? "Связанное визуальное доказательство откроется после проверки записи."
|
||||
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЗАФИКСИРОВАННЫЕ МЕТРИКИ</span>
|
||||
<h2>Результат из LAB-provenance</h2>
|
||||
</div>
|
||||
<StatusBadge tone={
|
||||
lab?.provenance.benchmark_passed === true ? "success" : "warning"
|
||||
}>
|
||||
{lab?.provenance.benchmark_passed === true ? "Benchmark passed" : "Требует разбора"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
{metrics.length ? metrics.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span>{laboratoryMetricLabel(key)}</span>
|
||||
<strong>{formatLaboratoryMetric(value)}</strong>
|
||||
<small>Immutable provenance</small>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<span>Метрики</span>
|
||||
<strong>Не опубликованы</strong>
|
||||
<small>Доступна исходная запись</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit: 100,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
onReplayBegin: props.sessionArchive.onReplayBegin,
|
||||
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
||||
onReplaySettled: props.sessionArchive.onReplaySettled,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => (
|
||||
session.lab !== null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)),
|
||||
[sessions.items],
|
||||
);
|
||||
const sourceSessions = useMemo(
|
||||
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||
[sessions.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
setEvidenceError(null);
|
||||
void Promise.allSettled([
|
||||
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||
fetchE30ReviewCatalog({ signal: controller.signal }),
|
||||
]).then(([e28, e29, e30]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null;
|
||||
const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null;
|
||||
const nextE30 = e30.status === "fulfilled" ? e30.value.items[0] ?? null : null;
|
||||
setE28Model(nextE28);
|
||||
setE29Result(nextE29);
|
||||
setE30Result(nextE30);
|
||||
const failures = [
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
e30.status === "rejected" ? "E30" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
? `${failures.join(" и ")} не прошли серверную проверку и скрыты.`
|
||||
: null,
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setEvidenceLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const rigLabel = useMemo(() => {
|
||||
if (props.deviceLabel) return props.deviceLabel;
|
||||
const coordinateFrame = publishedWorks
|
||||
.map((session) => session.lab?.provenance.coordinate_frame)
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const sensorToken = typeof coordinateFrame === "string"
|
||||
? coordinateFrame.split("-")[0]?.trim()
|
||||
: "";
|
||||
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
||||
}, [props.deviceLabel, publishedWorks]);
|
||||
const sensorWorks = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
|
||||
if (e28Model) {
|
||||
items.push({
|
||||
id: "e28-local-surface",
|
||||
label: "LAB E28 · локальная поверхность L2.6",
|
||||
});
|
||||
}
|
||||
if (
|
||||
e29Result
|
||||
&& sourceSessions.has(e29Result.linkedEvidence.sourceSessionId)
|
||||
) {
|
||||
items.push({
|
||||
id: "e29-camera-geometry",
|
||||
label: "LAB E29 · camera-first + geometry",
|
||||
});
|
||||
}
|
||||
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e30-evidence-review",
|
||||
label: "LAB E30 · evidence review A2",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [e28Model, e29Result, e30Result, sourceSessions]);
|
||||
const profiles = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||
if (sensorWorks.length) {
|
||||
items.push({
|
||||
id: "sensor-fusion",
|
||||
label: `${rigLabel} · камера + LiDAR · control plane`,
|
||||
});
|
||||
}
|
||||
if (publishedWorks.length) {
|
||||
items.push({
|
||||
id: "published-perception",
|
||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [publishedWorks.length, rigLabel, sensorWorks.length]);
|
||||
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
|
||||
profileId === "sensor-fusion"
|
||||
? sensorWorks
|
||||
: publishedWorks.map((session) => ({
|
||||
id: `session:${session.id}` as const,
|
||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||
}));
|
||||
const selectedSessionId = workId.startsWith("session:")
|
||||
? workId.slice("session:".length)
|
||||
: null;
|
||||
const selectedSession = selectedSessionId
|
||||
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
||||
: null;
|
||||
const e29SourceSession = e29Result
|
||||
? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? null
|
||||
: null;
|
||||
const e30SourceSession = e30Result
|
||||
? sourceSessions.get(e30Result.sourceSessionId) ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||
const firstProfile = profiles[0];
|
||||
if (!firstProfile) return;
|
||||
setProfileId(firstProfile.id);
|
||||
if (firstProfile.id === "sensor-fusion") {
|
||||
const firstWork = sensorWorks[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
} else {
|
||||
const first = publishedWorks[0];
|
||||
if (first) setWorkId(`session:${first.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!workOptions.some((work) => work.id === workId)) {
|
||||
const firstWork = workOptions[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
}
|
||||
}, [
|
||||
evidenceLoading,
|
||||
profileId,
|
||||
profiles,
|
||||
publishedWorks,
|
||||
sensorWorks,
|
||||
sessions.state,
|
||||
workId,
|
||||
workOptions,
|
||||
]);
|
||||
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
if (next === "sensor-fusion") {
|
||||
const first = sensorWorks[0];
|
||||
if (first) setWorkId(first.id);
|
||||
return;
|
||||
}
|
||||
const first = publishedWorks[0];
|
||||
if (!first) return;
|
||||
const nextWork = `session:${first.id}` as const;
|
||||
setWorkId(nextWork);
|
||||
void sessions.replay(first.id);
|
||||
};
|
||||
|
||||
const selectWork = (next: LaboratoryWorkId) => {
|
||||
setWorkId(next);
|
||||
if (next.startsWith("session:")) {
|
||||
void sessions.replay(next.slice("session:".length));
|
||||
return;
|
||||
}
|
||||
if (next === "e29-camera-geometry" && e29SourceSession) {
|
||||
void sessions.replay(e29SourceSession.id);
|
||||
return;
|
||||
}
|
||||
if (next === "e30-evidence-review" && e30SourceSession) {
|
||||
void sessions.replay(e30SourceSession.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ревизия лабораторных данных</strong>
|
||||
<p>Проверяем артефакты, связанные исходные записи и доступность replay.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profiles.length) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||
<p>
|
||||
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewerFocused = Boolean(
|
||||
props.observationLayout.focusedSourceId
|
||||
|| props.observationLayout.maximizedFloatingSourceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lab-archive-workspace"
|
||||
data-viewer-focused={viewerFocused ? "true" : undefined}
|
||||
>
|
||||
<LaboratorySelector
|
||||
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
|
||||
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
|
||||
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
|
||||
label="Профиль"
|
||||
value={profileId}
|
||||
options={profiles}
|
||||
onChange={selectProfile}
|
||||
/>
|
||||
|
||||
<LaboratorySelector
|
||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
|
||||
label="Работа"
|
||||
value={workId}
|
||||
options={workOptions}
|
||||
disabled={workOptions.length === 0}
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
<div className="laboratory-work-output">
|
||||
{workId === "e28-local-surface" ? (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E28 · локальная модель поверхности L2.6"
|
||||
description="Запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает поверхность, препятствия, временные скачки и ошибку предсказания без изменения источника и без командного канала."
|
||||
status="Проверенные артефакты"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
|
||||
{
|
||||
label: "Покрытие",
|
||||
value: `${formatNumber(e28Model?.metrics.frames.valid ?? 0, 0)} / ${formatNumber(e28Model?.metrics.frames.total ?? 0, 0)} кадров`,
|
||||
},
|
||||
{ label: "Режим", value: "Recorded-source-paced shadow" },
|
||||
{ label: "Контур", value: "Read-only · hash verified" },
|
||||
]}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: e28Model?.method.executionClass ?? "deterministic",
|
||||
pipelineId: e28Model?.method.pipelineId ?? "local-surface/unavailable",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: e28Model?.sourcePackId ?? "Источник не загружен",
|
||||
version: "immutable vendor MAP + pose",
|
||||
role: "read-only LiDAR evidence",
|
||||
identitySha256: digestFromContentId(e28Model?.sourcePackId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: e28Model?.method.algorithm ?? "Rolling local surface",
|
||||
version: e28Model?.method.pipelineId ?? "—",
|
||||
role: "robust local plane, occupancy and temporal residuals",
|
||||
identitySha256: e28Model?.method.producerSha256 ?? null,
|
||||
},
|
||||
{
|
||||
kind: "runtime",
|
||||
name: "Mission Core worker D",
|
||||
version: "recorded-source-paced shadow",
|
||||
role: "bounded passive replay",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="Диагностическая поверхность и кадры LAB E28"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<LidarQualityWorkspace
|
||||
embedded
|
||||
deviceLabel={props.deviceLabel}
|
||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
/>
|
||||
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
|
||||
<E29LaboratoryResult
|
||||
props={props}
|
||||
rigLabel={rigLabel}
|
||||
result={e29Result}
|
||||
sourceSession={e29SourceSession}
|
||||
loading={sessions.replayingSessionId === e29SourceSession.id}
|
||||
error={
|
||||
sessions.failedSessionId === e29SourceSession.id
|
||||
? sessions.error
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : workId === "e30-evidence-review" && e30Result && e30SourceSession ? (
|
||||
<E30LaboratoryResult
|
||||
rigLabel={rigLabel}
|
||||
result={e30Result}
|
||||
sourceSession={e30SourceSession}
|
||||
/>
|
||||
) : selectedSession ? (
|
||||
<PublishedLaboratoryResult
|
||||
props={props}
|
||||
session={selectedSession}
|
||||
loading={sessions.replayingSessionId === selectedSession.id}
|
||||
error={sessions.failedSessionId === selectedSession.id ? sessions.error : null}
|
||||
/>
|
||||
) : (
|
||||
<div className="laboratory-result-pending">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Работа не прошла ревизию</strong>
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user