// 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 (
watcher offline
); } 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 ( {data.map((v, i) => { const h = (v / max) * (height - 1); 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 && (
зум: {PRESETS.map((p) => { const targetScale = presetToScale(p, viewportW); const isActive = Math.abs(targetScale - scale) / scale < 0.06; return ( ); })}
setScale(sliderToScale(parseFloat(e.target.value)))} style={{ flex: 1, maxWidth: 320, accentColor: "var(--accent)" }} />
видно: { const v = parseFloat(e.target.value); if (v > 0) setScale(viewportW / v); }} className="pt-numinput" /> мин · {scale.toFixed(2)} px/мин
)} {/* 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 (
{val}
{lbl}
); } // 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 ( {data.slice().reverse().map((d, idx) => { const x = idx * 24 + 2; const totalH = ((d.total || 0) / maxTotal) * 36; let yCursor = 44; return ( {states.map((s) => { const v = d[s] || 0; if (v === 0) return null; const h = (v / maxTotal) * 36; yCursor -= h; const st = STATE_STYLES[s]; return ; })} {/* Event count dot */} {eventCounts && eventCounts[data.length - 1 - idx] && eventCounts[data.length - 1 - idx].alerts > 0 && ( )} ); })} ); } // ============================================================ // 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;