Files
ui/src/features/edge-detail/components/ArchiveView.tsx

274 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import type { RefObject } from 'react';
import { useRef } from 'react';
import { CalendarDays, CalendarClock, Check, ChevronDown, Clock3, DatabaseZap, Search } from 'lucide-react';
import type { CurrentItem } from '../../../entities/current/types';
import { HistoryChart } from '../../../features/history-chart/HistoryChart';
import { RANGE_PRESETS, createRange, type DateRangeState } from '../../../features/history/dateRange';
import { toIsoFromInput } from '../../../utils/format';
import type { HistoryGranularity } from '../../../utils/historyGranularity';
type ArchiveViewProps = {
edgeId: string;
getTagLabel: (tag: string) => string;
historyAxis: HistoryGranularity;
historyGranulate?: string;
items: CurrentItem[];
range: DateRangeState;
search: string;
selectedTags: string[];
tagLabels: Record<string, string>;
onClearTags: () => void;
onClearVisibleTags: () => void;
onRangeChange: (value: DateRangeState) => void;
onSearchChange: (value: string) => void;
onSelectFirstTags: () => void;
onSelectVisibleTags: () => void;
onToggleTag: (tag: string) => void;
};
type RangePart = 'from' | 'to';
type RangeInputPart = 'date' | 'time';
function getRangeInputPart(value: string, part: RangeInputPart): string {
const [date = '', time = ''] = value.split('T');
return part === 'date' ? date : time;
}
function updateRangeInputPart(value: string, part: RangeInputPart, nextValue: string): string {
const date = getRangeInputPart(value, 'date');
const time = getRangeInputPart(value, 'time');
return part === 'date' ? `${nextValue}T${time}` : `${date}T${nextValue}`;
}
function openInputPicker(input: HTMLInputElement | null): void {
input?.focus();
input?.showPicker?.();
}
type DateRangeFieldProps = {
icon: 'date' | 'time';
inputRef: RefObject<HTMLInputElement | null>;
type: 'date' | 'time';
value: string;
onChange: (value: string) => void;
};
function DateRangeField({ icon, inputRef, type, value, onChange }: DateRangeFieldProps) {
const Icon = icon === 'date' ? CalendarDays : Clock3;
const label = icon === 'date' ? 'Открыть календарь' : 'Открыть выбор времени';
return (
<div className="archive-date-field" onClick={() => openInputPicker(inputRef.current)}>
<button type="button" className="archive-date-picker-button" aria-label={label}>
<Icon size={16} />
</button>
<input
ref={inputRef}
aria-label={label}
className={`archive-date-input archive-date-input--${type}`}
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
}
export function ArchiveView({
edgeId,
getTagLabel,
historyAxis,
historyGranulate,
items,
range,
search,
selectedTags,
tagLabels,
onClearTags,
onClearVisibleTags,
onRangeChange,
onSearchChange,
onSelectFirstTags,
onSelectVisibleTags,
onToggleTag,
}: ArchiveViewProps) {
const [selectorOpen, setSelectorOpen] = useState(false);
const selectedPreview = selectedTags.slice(0, 10);
const hiddenSelectedCount = Math.max(0, selectedTags.length - selectedPreview.length);
const fromDateRef = useRef<HTMLInputElement>(null);
const fromTimeRef = useRef<HTMLInputElement>(null);
const toDateRef = useRef<HTMLInputElement>(null);
const toTimeRef = useRef<HTMLInputElement>(null);
const fromIso = toIsoFromInput(range.from) as string;
const toIso = toIsoFromInput(range.to) as string;
const updateRangePart = (rangePart: RangePart, inputPart: RangeInputPart, value: string) => {
onRangeChange({
...range,
[rangePart]: updateRangeInputPart(range[rangePart], inputPart, value),
});
};
return (
<section className="chart-section chart-section--archive">
<div className="section-header">
<div>
<span className="page-kicker">
<CalendarClock size={14} />
История
</span>
<h2>График параметров</h2>
</div>
<div className="source-chip">
<DatabaseZap size={16} />
cloud-v3 · {historyGranulate ?? 'ожидание'}
</div>
</div>
<div className="archive-tag-panel">
<button
type="button"
className="archive-tag-panel__toggle"
onClick={() => setSelectorOpen((open) => !open)}
aria-expanded={selectorOpen}
>
<span>
Показатели
<strong>
{selectedTags.length} выбрано · {items.length} найдено
</strong>
</span>
<ChevronDown size={18} />
</button>
{selectorOpen ? (
<div className="tag-selector">
<div className="tag-selector__header">
<label className="search-box">
<Search size={16} />
<input value={search} onChange={(event) => onSearchChange(event.target.value)} placeholder="Поиск" />
</label>
<div className="tag-selector__tools">
<button type="button" onClick={onSelectFirstTags}>
Первый
</button>
<button type="button" onClick={onSelectVisibleTags}>
Выбрать найденный
</button>
<button type="button" onClick={onClearVisibleTags}>
Снять найденные
</button>
<button type="button" onClick={onClearTags}>
Сбросить все
</button>
</div>
</div>
<div className="selected-tags">
{selectedPreview.length ? (
selectedPreview.map((tag) => <span key={tag}>{getTagLabel(tag)}</span>)
) : (
<span>Не выбрано</span>
)}
{hiddenSelectedCount > 0 ? <span>+{hiddenSelectedCount}</span> : null}
</div>
<div className="tag-select-list">
{items.map((item) => {
const selected = selectedTags.includes(item.tag);
const label = getTagLabel(item.tag);
return (
<button
key={item.tag}
type="button"
className={`tag-select-item ${selected ? 'tag-select-item--selected' : ''}`}
title={item.tag}
onClick={() => onToggleTag(item.tag)}
aria-pressed={selected}
>
<span className="tag-select-item__name">
<span>{label}</span>
{label !== item.tag ? <small>{item.tag}</small> : null}
</span>
<span className="tag-select-item__side">
<strong>{item.value.toLocaleString('ru-RU', { maximumFractionDigits: item.precision ?? 3 })}</strong>
{selected ? <Check size={15} /> : null}
</span>
</button>
);
})}
</div>
</div>
) : null}
</div>
<div
className="archive-chart"
onFocusCapture={() => setSelectorOpen(false)}
onPointerDown={() => setSelectorOpen(false)}
onPointerEnter={() => setSelectorOpen(false)}
>
<div className="toolbar">
<div className="segmented">
{RANGE_PRESETS.map((preset) => (
<button key={preset.id} type="button" onClick={() => onRangeChange(createRange(preset.hours))}>
{preset.label}
</button>
))}
</div>
<div className="archive-date-range">
<span>С</span>
<DateRangeField
icon="date"
inputRef={fromDateRef}
type="date"
value={getRangeInputPart(range.from, 'date')}
onChange={(value) => updateRangePart('from', 'date', value)}
/>
<DateRangeField
icon="time"
inputRef={fromTimeRef}
type="time"
value={getRangeInputPart(range.from, 'time')}
onChange={(value) => updateRangePart('from', 'time', value)}
/>
</div>
<div className="archive-date-range">
<span>По</span>
<DateRangeField
icon="date"
inputRef={toDateRef}
type="date"
value={getRangeInputPart(range.to, 'date')}
onChange={(value) => updateRangePart('to', 'date', value)}
/>
<DateRangeField
icon="time"
inputRef={toTimeRef}
type="time"
value={getRangeInputPart(range.to, 'time')}
onChange={(value) => updateRangePart('to', 'time', value)}
/>
</div>
</div>
{selectedTags.length ? (
<HistoryChart
key={`${range.from}:${range.to}:${historyAxis.granulate}`}
edge={edgeId}
from={fromIso}
to={toIso}
granulate={historyAxis.granulate}
tickIntervalMs={historyAxis.tickIntervalMs}
labelFormat={historyAxis.labelFormat}
tags={selectedTags}
tagLabels={tagLabels}
/>
) : (
<div className="chart-placeholder">Разверните список показателей и выберите серию для графика</div>
)}
</div>
</section>
);
}