// Partitura — main timeline component.
// Supports: day/week/month/year periods, sort (alpha/load), modules as nested agent sub-rows,
// event cluster popover, resizable right panel, sticky panel.
const { useState, useRef, useEffect, useMemo, useLayoutEffect } = React;
// ============================================================
// CONSTANTS & SCALE HELPERS
// ============================================================
const TARGET_W = 1200;
const LABEL_W = 320;
const PRESETS = [
{ id: "day", label: "День", visibleMin: 1440 },
{ id: "8h", label: "8 ч", visibleMin: 8 * 60 },
{ id: "1h", label: "Час", visibleMin: 60 },
{ id: "30m", label: "30 мин",visibleMin: 30 },
{ id: "15m", label: "15 мин",visibleMin: 15 },
{ id: "5m", label: "5 мин", visibleMin: 5 },
];
const SCALE_MIN = 0.5;
const SCALE_MAX = 280;
function presetToScale(preset, viewportW) {
return viewportW / preset.visibleMin;
}
function scaleToSlider(scale) {
return (Math.log(scale) - Math.log(SCALE_MIN)) / (Math.log(SCALE_MAX) - Math.log(SCALE_MIN));
}
function sliderToScale(v) {
return Math.exp(Math.log(SCALE_MIN) + v * (Math.log(SCALE_MAX) - Math.log(SCALE_MIN)));
}
// ============================================================
// RULER
// ============================================================
function getTickStep(scale) {
if (scale < 1.2) return { minor: 60, major: 180 };
if (scale < 2.5) return { minor: 30, major: 60 };
if (scale < 6) return { minor: 15, major: 60 };
if (scale < 14) return { minor: 5, major: 30 };
if (scale < 40) return { minor: 5, major: 15 };
if (scale < 100) return { minor: 1, major: 5 };
return { minor: 0.5, major: 1 };
}
function Ruler({ scale, width }) {
const step = getTickStep(scale);
const ticks = [];
for (let m = 0; m <= 24 * 60; m += step.minor) {
const isMajor = (Math.round(m * 10) % Math.round(step.major * 10)) === 0;
ticks.push({ m, major: isMajor });
}
return (
{ticks.map((t, i) => {
const x = t.m * scale;
return (
);
})}
{ticks.filter((t) => t.major).map((t, i) => {
const x = t.m * scale;
const label = scale >= 100 ? fmtHMS(t.m) : fmtHM(t.m);
return (
{label}
);
})}
);
}
// ============================================================
// BAR (one task segment on day timeline)
// ============================================================
function Bar({ seg, scale, top, onClick, isSelected = false }) {
const left = seg.start * scale;
const width = Math.max(2, (seg.end - seg.start) * scale);
const style = STATE_STYLES[seg.state];
const isBlocked = seg.state === "blocked";
const bg = isBlocked
? `repeating-linear-gradient(45deg, rgba(239,68,68,0.10) 0 5px, transparent 5px 10px)`
: style.bg;
const modCol = moduleColor(seg.module);
return (
{ e.stopPropagation(); onClick(); }}
title={`PB-${seg.seq} · ${seg.title} · ${fmtHM(seg.start)}–${fmtHM(seg.end)}`}
style={{
position: "absolute", left, top, height: 30, width,
background: bg,
border: `1px solid ${style.border}`,
borderLeft: `3px solid ${style.color}`,
borderRadius: 3,
display: "flex", alignItems: "center", gap: 5, padding: "0 5px",
cursor: "pointer", overflow: "hidden", whiteSpace: "nowrap",
fontSize: 11, color: "var(--text)",
boxShadow: isSelected
? `0 0 0 2px var(--accent)`
: (isBlocked ? "inset 0 0 0 1px rgba(239,68,68,0.3)" : "none"),
zIndex: isSelected ? 2 : 1,
transition: "background 0.1s, box-shadow 0.1s",
}}
>
PB-{seg.seq}
{width > 80 && {seg.title}}
{isBlocked && width > 40 && (
BLOCKED
)}
);
}
function OfflineZone({ start, end, scale, top, height }) {
const left = start * scale;
const width = (end - start) * scale;
return (
);
}
function EventMarker({ ev, scale, onClick, top = 44, highlighted = false }) {
const left = ev.t * scale;
return (
{ e.stopPropagation(); onClick(e); }}
title={`${fmtHM(ev.t)} · PB-${ev.seq} · ${ev.text}`}
style={{
position: "absolute", left: left - 9, top,
width: 18, height: 18,
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", zIndex: highlighted ? 5 : 2,
borderRadius: 4,
background: highlighted ? "var(--accent-soft)" : "transparent",
boxShadow: highlighted ? "0 0 0 2px var(--accent)" : "none",
}}
>
);
}
function EventCluster({ events, scale, onClick, top = 42 }) {
const center = events[0].t * scale;
const sev = events.find((e) => e.kind === "alert" && e.sev === "critical") ? "critical"
: events.find((e) => e.kind === "alert" && e.sev === "warning") ? "warning"
: null;
const ringColor = sev === "critical" ? "var(--crit)" : sev === "warning" ? "var(--warn)" : "var(--text-dim)";
return (
{ e.stopPropagation(); onClick(e); }}
title={`${events.length} событий — кликни, чтобы выбрать конкретное`}
style={{
position: "absolute", left: center - 12, top,
width: 24, height: 22, borderRadius: 11,
background: "var(--bg-elev)", border: `1.5px solid ${ringColor}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 10, fontFamily: "var(--font-mono)",
color: "var(--text)", fontWeight: 600,
cursor: "pointer", zIndex: 3,
}}
>{events.length}
);
}
function clusterEvents(events, scale) {
if (events.length === 0) return [];
const minDistPx = 14;
const sorted = events.slice().sort((a, b) => a.t - b.t);
const groups = [];
let current = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const prev = current[current.length - 1];
const dxPx = (sorted[i].t - prev.t) * scale;
if (dxPx < minDistPx) current.push(sorted[i]);
else { groups.push(current); current = [sorted[i]]; }
}
groups.push(current);
return groups;
}
// ============================================================
// LABELS
// ============================================================
function AgentTrackLabel({ agent, alerts }) {
return (
{agent.name}
{agent.roleLabel}
{alerts && alerts.length > 0 && (
a.ruleTitle).join("\n")}>
{alerts.length}
)}
{agent.watcherOnline ? (
<>
watcher
{Math.floor(agent.watcherUptimeMin/60)}ч {agent.watcherUptimeMin%60}м
>
) : (
<>
offline
{Math.round(agent.lastHeartbeatMin)} мин
>
)}
{agent.currentTask ? (
<>
PB-{agent.currentTask.seq}
{agent.currentTask.title}
>
) : — свободен —}
ctx
= 70 ? "var(--ok)" : agent.contextPct >= 30 ? "var(--warn)" : "var(--crit)",
}}>{agent.contextPct}%
tools
{agent.avgToolCalls}
);
}
// Subagent label inside a module — compact agent row.
function SubAgentLabel({ agent, segments, range, isLead }) {
const total = (segments || []).reduce((acc, s) => {
const ov = Math.max(0, Math.min(s.end, range[1]) - Math.max(s.start, range[0]));
return acc + ov;
}, 0);
return (
{agent.name}
{agent.roleLabel}
{Math.round(total)}м
{segments.length} плашек
);
}
function ModuleTrackLabel({ moduleName, segments }) {
const uniqueAgents = Array.from(new Set(segments.map(s => s._agent).filter(Boolean)));
const totalMin = segments.reduce((acc, s) => acc + (s.end - s.start), 0);
const blockedMin = segments.filter(s => s.state === "blocked").reduce((acc, s) => acc + (s.end - s.start), 0);
const taskCount = new Set(segments.map(s => s.seq)).size;
return (
{moduleName[0].toUpperCase()}
{moduleName}
{taskCount} задач · {uniqueAgents.length} агент{uniqueAgents.length === 1 ? "" : "ов"}
{uniqueAgents.slice(0, 6).map((aid) => {
const a = teamById(aid);
if (!a) return null;
return
;
})}
сегодня
{Math.round(totalMin)}м
{blockedMin > 0 && (
block
{Math.round(blockedMin)}м
)}
);
}
function MiniBars({ data, width = 76, height = 14 }) {
const max = Math.max(...data, 1);
const w = width / data.length - 1.5;
return (
);
}
// ============================================================
// HELPERS — workload, sort
// ============================================================
function workload(segments, range) {
if (!segments) return 0;
return segments.reduce((acc, s) => {
const ov = Math.max(0, Math.min(s.end, range[1]) - Math.max(s.start, range[0]));
return acc + ov;
}, 0);
}
function applySort(items, sortMode, range) {
if (sortMode === "default") return items;
const sorted = items.slice();
if (sortMode === "alphaAsc" || sortMode === "alphaDesc") {
sorted.sort((a, b) => (a.sortName || "").localeCompare(b.sortName || "", "ru"));
if (sortMode === "alphaDesc") sorted.reverse();
} else if (sortMode === "loadAsc" || sortMode === "loadDesc") {
sorted.sort((a, b) => workload(a.segments || [], range) - workload(b.segments || [], range));
if (sortMode === "loadDesc") sorted.reverse();
}
return sorted;
}
function packSegments(segs) {
const sorted = segs.slice().sort((a, b) => a.start - b.start);
const rowEnds = [];
return sorted.map((s) => {
let row = rowEnds.findIndex((e) => e <= s.start);
if (row === -1) { row = rowEnds.length; rowEnds.push(s.end); }
else rowEnds[row] = s.end;
return { ...s, _row: row };
});
}
// ============================================================
// CONTROLS
// ============================================================
function Controls({
mode, setMode,
period, setPeriod,
sortMode, setSortMode,
scale, setScale, viewportW,
filters, setFilters,
scrollToNow,
totalRows,
isDay,
}) {
const sliderVal = scaleToSlider(scale);
const visibleMin = viewportW / scale;
return (
{/* Row 1: Period + Mode + Sort + Now */}
{/* Period */}
{PERIODS.map((p) => (
))}
{/* Mode */}
{[{ id: "agents", label: "По агентам" }, { id: "modules", label: "По модулям" }].map((opt) => (
))}
{/* Sort */}
строк: {totalRows}
{isDay && (
)}
{/* Row 2: Zoom (only for day) */}
{isDay && (
)}
{/* Row 3: Filter chips */}
{isDay ? "события:" : "учитывать:"}
{EVENT_KINDS_ORDER.map((k) => (
setFilters({ ...filters, [k]: !filters[k] })}>
{EVENT_LABELS[k]}
))}
{isDay && (
<>
setFilters({ ...filters, _blocked: !filters._blocked })}>
Blocked
>
)}
);
}
function FilterChip({ active, onClick, children }) {
return (
);
}
// ============================================================
// CLUSTER POPOVER
// ============================================================
function ClusterPopover({ events, rowKey, x, y, onPickEvent, onClose }) {
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
const onClick = () => onClose();
setTimeout(() => window.addEventListener("click", onClick), 0);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("click", onClick);
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
return (
e.stopPropagation()}
style={{
position: "fixed",
left: Math.min(x, window.innerWidth - 320),
top: Math.min(y, window.innerHeight - 280),
width: 300,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 10px 32px rgba(0,0,0,0.35)",
zIndex: 200,
maxHeight: 320,
display: "flex",
flexDirection: "column",
}}
>
событий в этом моменте: {events.length}
{events.map((ev, i) => (
onPickEvent(ev)}
style={{
display: "grid", gridTemplateColumns: "22px 1fr",
gap: 8, padding: "8px 12px",
borderBottom: i < events.length - 1 ? "1px solid var(--border-soft)" : "none",
cursor: "pointer",
transition: "background 0.08s",
}}
onMouseEnter={(e) => e.currentTarget.style.background = "var(--surface-hi)"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
>
{fmtHM(ev.t)}
PB-{ev.seq}
{ev.text}
))}
);
}
// ============================================================
// ROW — handles agent / module-head / module-sub kinds
// ============================================================
function Row({
row, scale, totalWidth,
isSelected, selectedSegKey, highlightEvKey,
onClickRow, onClickBar, onClickEventGroup,
}) {
const isHead = row.kind === "module-head";
const isSub = row.kind === "module-sub";
// Layout per kind
const isHeader = isHead;
const segmentRows = (row.segments || []).map((s) => ({ ...s, _row: 0 }));
const trackHeight = isHead ? 52 : (isSub ? 76 : 124);
const barTop = isHead ? 16 : Math.round((trackHeight - 30) / 2 - (isSub ? 4 : 8));
const eventTop = isHead ? 16 : (barTop + 36);
const groups = clusterEvents(row.events || [], scale);
return (
{row.label}
{/* Offline zones (agent + module-sub) */}
{!isHead && (row.offline || []).map((o, i) => (
))}
{/* Bars (no bars on module-head) */}
{!isHead && segmentRows.map((seg, i) => {
const isSegSel = isSelected && selectedSegKey === `${seg.seq}_${seg.start}`;
return (
onClickBar(seg)} />
);
})}
{/* Events */}
{groups.map((g, i) => {
if (g.length === 1) {
const ev = g[0];
const evKey = `${ev.t}_${ev.agent}_${ev.kind}_${ev.seq}`;
return onClickEventGroup(g, e)} />;
}
return onClickEventGroup(g, e)} />;
})}
{/* Module head accent strip */}
{isHead && (
)}
);
}
// ============================================================
// STATS ROW (week / month / year)
// ============================================================
function StatsRow({
row, isSelected, onClickRow, period,
}) {
const isHead = row.kind === "stats-module-head";
const isSub = row.kind === "stats-module-sub";
const isAgent = row.kind === "stats-agent";
return (
{row.label}
{/* Big numeric stats */}
0 ? "var(--crit)" : "var(--text-dim)"} />
{/* Daily/weekly strip */}
{/* Trend */}
0 ? "var(--ok)" : row.stats.trend < 0 ? "var(--crit)" : "var(--text-dim)",
}}>
{row.stats.trend > 0 ? "↑" : row.stats.trend < 0 ? "↓" : "·"} {Math.abs(Math.round(row.stats.trend))}%
vs пред.
);
}
function PtStat({ lbl, val, accent }) {
return (
);
}
// Daily strip — stacked horizontal bars showing per-state breakdown
function DailyStrip({ history, buckets, period, eventCounts }) {
const data = period === "year" ? buckets : history;
// Each bucket: { planning, in-progress, review, testing, research, blocked, total }
const maxTotal = Math.max(...data.map(d => d.total || 0), 1);
const states = ["in-progress", "review", "testing", "planning", "research", "blocked"];
return (
);
}
// ============================================================
// ROW BUILDERS
// ============================================================
function buildRowsDay({ mode, sortMode, filters, visibleEvents, alertsForLabels, visRange }) {
if (mode === "agents") {
const items = TEAM.map((a) => {
const segments = (PT_AGENT_SEGMENTS[a.id] || []).filter(s => filters._blocked || s.state !== "blocked");
const events = visibleEvents.filter(e => e.agent === a.id);
const myAlerts = (alertsForLabels || []).filter(x => x.agent === a.id && x.status === "active");
return {
key: `agent:${a.id}`,
kind: "agent",
sortName: a.name,
agent: a,
label: ,
segments,
events,
offline: PT_OFFLINE[a.id] || [],
};
});
return applySort(items, sortMode, visRange);
}
// modules
const allModules = Array.from(new Set(
Object.values(PT_AGENT_SEGMENTS).flatMap(segs => segs.map(s => s.module))
));
const moduleData = allModules.map((m) => {
const subAgents = [];
TEAM.forEach((a) => {
const segs = (PT_AGENT_SEGMENTS[a.id] || []).filter(s =>
s.module === m && (filters._blocked || s.state !== "blocked")
);
if (segs.length === 0) return;
const segsWithAgent = segs.map(s => ({ ...s, _agent: a.id }));
const evs = visibleEvents.filter(e => {
if (e.agent !== a.id) return false;
const ownerSeg = (PT_AGENT_SEGMENTS[a.id] || []).find(s => s.seq === e.seq);
return ownerSeg && ownerSeg.module === m;
});
subAgents.push({
key: `module:${m}/agent:${a.id}`,
kind: "module-sub",
sortName: a.name,
agent: a,
moduleName: m,
segments: segsWithAgent,
events: evs,
offline: (PT_OFFLINE[a.id] || []).filter(o => true), // could narrow further
label: ,
});
});
const allSegs = subAgents.flatMap(x => x.segments);
const allEvs = subAgents.flatMap(x => x.events);
const totalLoad = subAgents.reduce((acc, x) => acc + workload(x.segments, visRange), 0);
return {
moduleName: m,
head: {
key: `module:${m}`,
kind: "module-head",
sortName: m,
moduleName: m,
moduleColor: moduleColor(m),
segments: allSegs,
events: allEvs,
label: ,
},
subAgents,
totalLoad,
};
});
// Sort modules
moduleData.sort((a, b) => {
if (sortMode === "alphaDesc") return b.moduleName.localeCompare(a.moduleName, "ru");
if (sortMode === "loadDesc") return b.totalLoad - a.totalLoad;
if (sortMode === "loadAsc") return a.totalLoad - b.totalLoad;
return a.moduleName.localeCompare(b.moduleName, "ru");
});
// Flatten + sort sub-agents within each module
const out = [];
moduleData.forEach((md) => {
out.push(md.head);
applySort(md.subAgents, sortMode, visRange).forEach((sa) => out.push(sa));
});
return out;
}
function buildRowsStats({ mode, sortMode, period }) {
const pdef = periodDef(period);
const days = pdef.days;
if (mode === "agents") {
const items = TEAM.map((a) => {
const history = genHistory(a.id, days);
const eventCounts = genEventCounts(a.id, days);
const agg = aggregateHistory(history);
const prevAgg = aggregateHistory(genHistory(a.id, days * 2).slice(days)); // prev period
const trend = prevAgg.total > 0 ? ((agg.total - prevAgg.total) / prevAgg.total) * 100 : 0;
const totalEvents = eventCounts.reduce((s, x) => s + x.events, 0);
const totalAlerts = eventCounts.reduce((s, x) => s + x.alerts, 0);
const allSegs = Object.values(history).flatMap(h => h);
const segmentCount = history.reduce((s, h) => s + (h.total > 0 ? 1 : 0), 0); // simplified
return {
key: `agent:${a.id}`,
kind: "stats-agent",
sortName: a.name,
agent: a,
history,
eventCounts,
weekBuckets: period === "year" ? bucketHistory(history, pdef.bucket) : null,
stats: {
hours: Math.round(agg.total / 60),
segments: Math.round(history.length * 1.5),
events: totalEvents,
alerts: totalAlerts,
trend: Math.round(trend),
},
segments: history, // for sort purpose
label: ,
};
});
if (sortMode === "alphaAsc" || sortMode === "alphaDesc") {
items.sort((a, b) => a.sortName.localeCompare(b.sortName, "ru"));
if (sortMode === "alphaDesc") items.reverse();
} else if (sortMode === "loadAsc" || sortMode === "loadDesc") {
items.sort((a, b) => a.stats.hours - b.stats.hours);
if (sortMode === "loadDesc") items.reverse();
}
return items;
}
// Modules stats — aggregate per module across agents
const allModules = Array.from(new Set(
Object.values(PT_AGENT_SEGMENTS).flatMap(segs => segs.map(s => s.module))
));
const data = allModules.map((m) => {
const agentsInModule = TEAM.filter(a =>
(PT_AGENT_SEGMENTS[a.id] || []).some(s => s.module === m)
);
// synthesize module history: sum agent histories scaled by their fraction in this module
const moduleHistory = Array.from({ length: days }, () => ({
planning: 0, "in-progress": 0, review: 0, testing: 0, research: 0, blocked: 0, total: 0,
}));
agentsInModule.forEach((a) => {
const h = genHistory(a.id, days);
h.forEach((day, i) => {
const factor = 0.4 + 0.5 * Math.random(); // each agent puts 40-90% of work to this module on a given day
Object.keys(moduleHistory[i]).forEach((k) => {
moduleHistory[i][k] += Math.round((day[k] || 0) * factor);
});
});
});
const agg = aggregateHistory(moduleHistory);
const eventCounts = Array.from({ length: days }, (_, i) => ({
events: agentsInModule.reduce((s, a) => s + (genEventCounts(a.id, days)[i].events || 0), 0),
alerts: agentsInModule.reduce((s, a) => s + (genEventCounts(a.id, days)[i].alerts || 0), 0),
}));
const totalEvents = eventCounts.reduce((s, x) => s + x.events, 0);
const totalAlerts = eventCounts.reduce((s, x) => s + x.alerts, 0);
const trend = Math.round((Math.random() - 0.5) * 30);
return {
moduleName: m,
head: {
key: `module:${m}`,
kind: "stats-module-head",
sortName: m,
moduleName: m,
moduleColor: moduleColor(m),
history: moduleHistory,
eventCounts,
weekBuckets: period === "year" ? bucketHistory(moduleHistory, pdef.bucket) : null,
stats: {
hours: Math.round(agg.total / 60),
segments: agentsInModule.length,
events: totalEvents,
alerts: totalAlerts,
trend,
},
segments: moduleHistory,
label: ,
},
subAgents: agentsInModule.map((a) => {
const h = genHistory(a.id, days);
const aagg = aggregateHistory(h);
const ec = genEventCounts(a.id, days);
return {
key: `module:${m}/agent:${a.id}`,
kind: "stats-module-sub",
sortName: a.name,
agent: a,
history: h,
eventCounts: ec,
weekBuckets: period === "year" ? bucketHistory(h, pdef.bucket) : null,
stats: {
hours: Math.round(aagg.total / 60 * 0.6),
segments: Math.max(1, Math.round(h.filter(x => x.total > 0).length * 0.4)),
events: Math.round(ec.reduce((s, x) => s + x.events, 0) * 0.5),
alerts: ec.reduce((s, x) => s + x.alerts, 0),
trend: Math.round((Math.random() - 0.5) * 30),
},
segments: h,
label: ,
};
}),
};
});
// Sort modules
data.sort((a, b) => {
if (sortMode === "alphaDesc") return b.moduleName.localeCompare(a.moduleName, "ru");
if (sortMode === "loadAsc") return a.head.stats.hours - b.head.stats.hours;
if (sortMode === "loadDesc") return b.head.stats.hours - a.head.stats.hours;
return a.moduleName.localeCompare(b.moduleName, "ru");
});
const out = [];
data.forEach((md) => {
out.push(md.head);
let subs = md.subAgents.slice();
if (sortMode === "alphaAsc" || sortMode === "alphaDesc") {
subs.sort((a, b) => a.sortName.localeCompare(b.sortName, "ru"));
if (sortMode === "alphaDesc") subs.reverse();
} else if (sortMode === "loadAsc" || sortMode === "loadDesc") {
subs.sort((a, b) => a.stats.hours - b.stats.hours);
if (sortMode === "loadDesc") subs.reverse();
}
subs.forEach((s) => out.push(s));
});
return out;
}
// ============================================================
// MAIN
// ============================================================
function Partitura({ embedded = false, maxHeight, alertsForLabels = [], stickyTop = 96 }) {
const [mode, setMode] = useState("agents");
const [period, setPeriod] = useState("day");
const [sortMode, setSortMode] = useState("default");
const [scale, setScale] = useState(0.9);
const [filters, setFilters] = useState({
new_task: true, state_change: true, assigned: true,
label: true, comment: true, commit: true, alert: true,
_blocked: true,
});
const [selectedKey, setSelectedKey] = useState(null);
const [selectedSeg, setSelectedSeg] = useState(null); // { seg, rowKey }
const [highlightEvKey, setHighlightEvKey] = useState(null);
const [clusterPopover, setClusterPopover] = useState(null);
const [panelW, setPanelW] = useState(340);
const [visRange, setVisRange] = useState([0, 1440]);
const [viewportW, setViewportW] = useState(900);
const containerRef = useRef(null);
const scrollRef = useRef(null);
const rafRef = useRef(0);
const isDay = period === "day";
// Reset selection on mode/period switch
useEffect(() => {
setSelectedKey(null); setSelectedSeg(null);
setHighlightEvKey(null); setClusterPopover(null);
}, [mode, period]);
// Measure viewport
useLayoutEffect(() => {
const measure = () => {
if (scrollRef.current) {
const w = scrollRef.current.clientWidth - LABEL_W;
if (w > 100) setViewportW(w);
}
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
// Resize panel
const onDragStart = (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = panelW;
const onMove = (ev) => {
const next = Math.max(240, Math.min(640, startW + (startX - ev.clientX)));
setPanelW(next);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
const totalWidth = 24 * 60 * scale;
// Recompute visible range
const recomputeVis = () => {
const el = scrollRef.current;
if (!el) return;
const start = el.scrollLeft / scale;
const end = (el.scrollLeft + (el.clientWidth - LABEL_W)) / scale;
setVisRange([Math.max(0, start), Math.min(1440, end)]);
};
const onTimelineScroll = () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(recomputeVis);
};
useEffect(() => { recomputeVis(); }, [scale]);
// Visible events (kind filters)
const visibleEvents = useMemo(() => PT_EVENTS.filter(ev => filters[ev.kind]), [filters]);
// Build rows
const rows = useMemo(() => {
if (isDay) {
return buildRowsDay({ mode, sortMode, filters, visibleEvents, alertsForLabels, visRange });
}
return buildRowsStats({ mode, sortMode, period });
}, [isDay, mode, sortMode, period, filters, visibleEvents, alertsForLabels, visRange]);
// All visible events for the panel
const allVisibleEvents = useMemo(() => {
const seen = new Set();
const out = [];
rows.forEach((r) => {
if (!r.events) return;
r.events.forEach((ev) => {
const k = `${ev.t}_${ev.agent}_${ev.kind}_${ev.seq}`;
if (!seen.has(k)) { seen.add(k); out.push(ev); }
});
});
return out;
}, [rows]);
// Scroll-to-now
const scrollToNow = () => {
if (!scrollRef.current) return;
const target = PT_NOW_MIN * scale - viewportW / 2 + 100;
scrollRef.current.scrollTo({ left: Math.max(0, target), behavior: "smooth" });
};
// Keep center stable on zoom
const prevScaleRef = useRef(scale);
useEffect(() => {
const prev = prevScaleRef.current;
if (!scrollRef.current || prev === scale) return;
const ratio = scale / prev;
const left = scrollRef.current.scrollLeft;
const centerPx = left + scrollRef.current.clientWidth / 2;
scrollRef.current.scrollLeft = centerPx * ratio - scrollRef.current.clientWidth / 2;
prevScaleRef.current = scale;
}, [scale]);
// Initial scroll
useEffect(() => {
if (scrollRef.current && isDay) {
scrollRef.current.scrollLeft = 7.5 * 60 * scale;
requestAnimationFrame(recomputeVis);
}
// eslint-disable-next-line
}, [isDay]);
// Selection helpers
const onBarClick = (seg, row) => {
setSelectedKey(row.key);
setSelectedSeg({ seg, rowKey: row.key });
setHighlightEvKey(null);
setClusterPopover(null);
};
const onEventGroup = (events, row, e) => {
if (events.length === 1) {
const ev = events[0];
setSelectedKey(row.key);
setSelectedSeg(null);
setHighlightEvKey(`${ev.t}_${ev.agent}_${ev.kind}_${ev.seq}`);
setClusterPopover(null);
} else {
const rect = e.target.getBoundingClientRect ? e.target.getBoundingClientRect() : null;
const x = rect ? rect.right + 6 : e.clientX || 200;
const y = rect ? rect.top : e.clientY || 200;
setClusterPopover({ events, rowKey: row.key, x, y });
}
};
const onRowClick = (row) => {
if (selectedKey === row.key) {
setSelectedKey(null); setSelectedSeg(null); setHighlightEvKey(null);
} else {
setSelectedKey(row.key); setSelectedSeg(null); setHighlightEvKey(null);
}
setClusterPopover(null);
};
const pickFromPopover = (ev) => {
setSelectedKey(clusterPopover.rowKey);
setSelectedSeg(null);
setHighlightEvKey(`${ev.t}_${ev.agent}_${ev.kind}_${ev.seq}`);
setClusterPopover(null);
};
const computedStyle = maxHeight ? { height: maxHeight } : undefined;
return (
setScale(Math.max(SCALE_MIN, Math.min(SCALE_MAX, s)))}
viewportW={viewportW}
filters={filters} setFilters={setFilters}
scrollToNow={scrollToNow}
totalRows={rows.length}
isDay={isDay}
/>
{isDay ? (
{mode === "agents" ? "Агент / роль" : "Модуль / агенты"}
{rows.map((r) => (
onRowClick(r)}
onClickBar={(seg) => onBarClick(seg, r)}
onClickEventGroup={(g, e) => onEventGroup(g, r, e)}
/>
))}
СЕЙЧАС · {fmtHM(PT_NOW_MIN)}
) : (
// Stats mode — no horizontal scroll, no ruler
{mode === "agents" ? "Агент / роль" : "Модуль / агенты"}
период: {periodDef(period).label} ({periodDef(period).days} дн.) · акцент на статистику и динамику
{rows.map((r) => (
onRowClick(r)}
/>
))}
)}
{ setSelectedKey(k); setSelectedSeg(null); setHighlightEvKey(null); }}
visRange={visRange}
mode={mode}
period={period}
selectedSeg={selectedSeg}
onClearSeg={() => setSelectedSeg(null)}
highlightEventKey={highlightEvKey}
agents={TEAM}
stickyTop={stickyTop}
/>
{clusterPopover && (
setClusterPopover(null)}
/>
)}
);
}
window.Partitura = Partitura;