feat: finalize corrected-route planning and Rerun recording review
This commit is contained in:
@@ -43,8 +43,7 @@ export function initialObservationWindowRect(
|
||||
const row = Math.floor(index / columns);
|
||||
const column = index % columns;
|
||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||
const rowStart = Math.min(WINDOW_INSET, Math.max(0, bounds.width - width));
|
||||
|
||||
return {
|
||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
import { MAX_ACCUMULATION_SECONDS } from "./sceneSettings";
|
||||
|
||||
type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function ObservationTimeline({
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
accumulationMaxSeconds = MAX_ACCUMULATION_SECONDS,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
@@ -37,6 +39,7 @@ export function ObservationTimeline({
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
accumulationMaxSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
@@ -75,7 +78,7 @@ export function ObservationTimeline({
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
max={Math.max(1, accumulationMaxSeconds, accumulationValue)}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
@@ -165,12 +168,15 @@ export function ObservationTimeline({
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
if (seconds === 0) return "Кадр";
|
||||
if (seconds < 60) return `${seconds} с`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return seconds % 60 === 0 ? `${minutes} мин` : `${minutes} мин ${seconds % 60} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {Checker,ColorField,ControlRow,Inspector,RangeControl,Select} from '@nodedc/ui-react';
|
||||
import type {SceneSettings,PointColorMode,PointPalette} from './sceneSettings';
|
||||
import {MAX_ACCUMULATION_SECONDS} from './sceneSettings';
|
||||
import {formatAccumulationDuration} from './ObservationTimeline';
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
@@ -28,7 +30,7 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
description: "Размер, плотность и окрашивание",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div
|
||||
@@ -47,6 +49,17 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||
/>
|
||||
</div>
|
||||
{replayPresented ? <>
|
||||
<RangeControl
|
||||
label="Прореживание облака"
|
||||
value={displayDraft.pointDecimationPercent ?? 0}
|
||||
min={0} max={100} step={0.1}
|
||||
exactValueBounds={{min: 0, max: 100}}
|
||||
formatValue={(value) => `${value.toLocaleString('ru-RU')} %`}
|
||||
onChange={(pointDecimationPercent) => stageDisplayPatch({pointDecimationPercent})}
|
||||
/>
|
||||
<p className="scene-window-note">0% — все точки, 100% — без точек. Предпросмотр обновляется после изменения. Настройки сохраняются при закрытии окна; исходная запись не меняется.</p>
|
||||
</> : null}
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
@@ -85,12 +98,6 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
</ControlRow>
|
||||
</div>
|
||||
) : null}
|
||||
{replayPresented ? (
|
||||
<p className="scene-window-note">
|
||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -100,6 +107,18 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
description: "История облака и траектория",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Диапазон шкалы, мин"
|
||||
value={(displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS) / 60}
|
||||
min={1} max={30} step={0.1}
|
||||
exactValueBounds={{min: 1 / 60}}
|
||||
formatValue={(value) => `${value.toLocaleString('ru-RU', {maximumFractionDigits: 2})} мин`}
|
||||
onChange={(minutes) => {
|
||||
const accumulationMaxSeconds = Math.max(1, Math.round(minutes * 60));
|
||||
stageDisplayPatch({accumulationMaxSeconds,
|
||||
accumulationSeconds: Math.min(displayDraft.accumulationSeconds, accumulationMaxSeconds)});
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
@@ -110,10 +129,12 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
max={displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS}
|
||||
exactValueBounds={{min: 0}}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
||||
formatValue={formatAccumulationDuration}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds,
|
||||
accumulationMaxSeconds: Math.max(accumulationSeconds, displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
|
||||
@@ -3,13 +3,11 @@ import {StatusBadge} from '@nodedc/ui-react';
|
||||
|
||||
/** The existing scene composition. Hosts supply transport/renderers and authority. */
|
||||
export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximized, toolbar,
|
||||
renderer, deviceControls, sourceControls, status, metrics, timeline, media, overlays, footer,
|
||||
navigationReady=false}: {
|
||||
renderer, deviceControls, sourceControls, status, metrics, timeline, media, overlays, footer}: {
|
||||
viewportRef: RefObject<HTMLDivElement|null>; focused?:boolean; primaryFocused?:boolean;
|
||||
mediaMaximized?:boolean; toolbar:ReactNode; renderer:ReactNode; deviceControls?:ReactNode;
|
||||
sourceControls?:ReactNode; status:{label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
||||
metrics:ReactNode; timeline?:ReactNode; media?:ReactNode; overlays?:ReactNode; footer?:ReactNode;
|
||||
navigationReady?:boolean;
|
||||
}) {
|
||||
const detailsHidden = primaryFocused || mediaMaximized;
|
||||
return <div className="spatial-workspace" data-focused={focused?'true':undefined}>
|
||||
@@ -28,7 +26,6 @@ export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximiz
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены">{metrics}</div>
|
||||
</div>
|
||||
{overlays}
|
||||
{navigationReady&&!mediaMaximized&&<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
||||
{timeline}{media}
|
||||
</div>
|
||||
{footer}
|
||||
|
||||
@@ -32,25 +32,6 @@
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.scene-navigation-hint {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
right: 0.85rem;
|
||||
bottom: 4.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@ export type SceneProjection = "3d" | "2d" | "map";
|
||||
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
|
||||
/** Default slider travel, not a limit on a recording or exact entry. */
|
||||
export const MAX_ACCUMULATION_SECONDS = 3 * 60;
|
||||
|
||||
export interface SceneSettings {
|
||||
projection: SceneProjection;
|
||||
pointSize: number;
|
||||
pointDecimationPercent?: number;
|
||||
accumulationMaxSeconds?: number;
|
||||
colorMode: PointColorMode;
|
||||
palette: PointPalette;
|
||||
customColor: string;
|
||||
@@ -19,6 +24,8 @@ export interface SceneSettings {
|
||||
export const defaultSceneSettings: SceneSettings = {
|
||||
projection: "3d",
|
||||
pointSize: 2.5,
|
||||
pointDecimationPercent: 0,
|
||||
accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS,
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#35d7c1",
|
||||
|
||||
@@ -367,16 +367,19 @@
|
||||
.scene-operation-status-stack {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
left: 0.85rem;
|
||||
bottom: 7.15rem;
|
||||
right: 0.85rem;
|
||||
top: 0.85rem;
|
||||
min-height: 2.75rem;
|
||||
max-width: calc(100% - 8rem);
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
align-content: center;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.scene-operation-status {
|
||||
display: inline-flex;
|
||||
max-width: min(24rem, calc(100% - 1.7rem));
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
@@ -388,7 +391,7 @@
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user