feat: update version to 1.3.1, enhance GanttChart and MetricsPanel, and add ThreadMetricsChart

This commit is contained in:
Lucas Rayan committed 2026-03-25 09:59:47 -03:00
1 parent 5761dfe70a
commit 44ee62e2c2
8 files changed
+300 -17

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pss", "name": "pss",
"version": "1.3.0", "version": "1.3.1",
"description": "Process Scheduler Simulator — an interactive educational tool that runs entirely in the browser", "description": "Process Scheduler Simulator — an interactive educational tool that runs entirely in the browser",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+3 -3
View File
@@ -47,7 +47,7 @@ const GanttChart = () => {
<div className="flex sticky top-0 z-10 bg-slate-950 border-b border-slate-800"> <div className="flex sticky top-0 z-10 bg-slate-950 border-b border-slate-800">
<div <div
className="sticky left-0 z-20 bg-slate-950 border-r border-slate-700 shrink-0" className="sticky left-0 z-20 bg-slate-950 border-r border-slate-700 shrink-0"
style={{ width: 48, height: 16 }} style={{ width: 60, height: 16 }}
/> />
{tickNumbers.map((t) => ( {tickNumbers.map((t) => (
<div <div
@@ -73,7 +73,7 @@ const GanttChart = () => {
> >
<div <div
className="sticky left-0 z-20 bg-slate-900 border-r border-slate-700 px-2 flex items-center shrink-0" className="sticky left-0 z-20 bg-slate-900 border-r border-slate-700 px-2 flex items-center shrink-0"
style={{ width: 48, height: CELL_H }} style={{ width: 60, height: CELL_H }}
> >
<span <span
className="text-[10px] font-bold truncate" className="text-[10px] font-bold truncate"
@@ -117,7 +117,7 @@ const GanttChart = () => {
{currentTickIndex >= 0 && ( {currentTickIndex >= 0 && (
<div <div
className="gantt-cursor absolute top-0 bottom-0 w-px bg-white/20 pointer-events-none" className="gantt-cursor absolute top-0 bottom-0 w-px bg-white/20 pointer-events-none"
style={{ left: 48 + currentTickIndex * CELL_W + CELL_W / 2 }} style={{ left: 60 + currentTickIndex * CELL_W + CELL_W / 2 }}
/> />
)} )}
</div> </div>
+1 -1
View File
@@ -39,7 +39,7 @@ const GanttThreadRow = React.memo(function GanttThreadRow({
{/* Rótulo da thread (sticky) */} {/* Rótulo da thread (sticky) */}
<div <div
className="sticky left-0 z-20 bg-slate-900/90 border-r border-slate-700/50 px-2 flex items-center shrink-0 gap-1" className="sticky left-0 z-20 bg-slate-900/90 border-r border-slate-700/50 px-2 flex items-center shrink-0 gap-1"
style={{ width: 48, height: THREAD_CELL_H }} style={{ width: 60, height: THREAD_CELL_H }}
> >
<span <span
className="w-1.5 h-1.5 rounded-full shrink-0" className="w-1.5 h-1.5 rounded-full shrink-0"
+1 -1
View File
@@ -4,7 +4,7 @@ import MetricsPanel from '../MetricsPanel/MetricsPanel';
const MainArea = () => { const MainArea = () => {
return ( return (
<main className="flex-1 flex flex-col h-full bg-slate-900 overflow-hidden relative"> <main className="flex-1 flex flex-col bg-slate-900 overflow-y-auto relative">
<ControlBar /> <ControlBar />
<GanttChart /> <GanttChart />
<MetricsPanel /> <MetricsPanel />
+10 -5
View File
@@ -8,8 +8,9 @@ import MetricsTable from './MetricsTable';
import GlobalMetrics from './GlobalMetrics'; import GlobalMetrics from './GlobalMetrics';
import MetricsChart from './MetricsChart'; import MetricsChart from './MetricsChart';
import ThreadMetricsTable from './ThreadMetricsTable'; import ThreadMetricsTable from './ThreadMetricsTable';
import ThreadMetricsChart from './ThreadMetricsChart';
type Tab = 'table' | 'global' | 'charts' | 'threads'; type Tab = 'table' | 'global' | 'charts' | 'threads' | 'thread-charts';
const MetricsPanel = () => { const MetricsPanel = () => {
const [activeTab, setActiveTab] = useState<Tab>('table'); const [activeTab, setActiveTab] = useState<Tab>('table');
@@ -55,12 +56,13 @@ const MetricsPanel = () => {
const TAB_CONFIG: { id: Tab; label: string; icon: React.ReactNode; show: boolean }[] = [ const TAB_CONFIG: { id: Tab; label: string; icon: React.ReactNode; show: boolean }[] = [
{ id: 'table', label: 'Per Process', icon: <Table size={13} />, show: true }, { id: 'table', label: 'Per Process', icon: <Table size={13} />, show: true },
{ id: 'global', label: 'Global', icon: <Globe size={13} />, show: true }, { id: 'global', label: 'Global', icon: <Globe size={13} />, show: true },
{ id: 'charts', label: 'Charts', icon: <BarChart2 size={13} />, show: true }, { id: 'charts', label: 'Process Charts', icon: <BarChart2 size={13} />, show: true },
{ id: 'threads', label: 'Threads', icon: <Layers size={13} />, show: hasThreads }, { id: 'threads', label: 'Threads', icon: <Layers size={13} />, show: hasThreads },
{ id: 'thread-charts', label: 'Thread Charts', icon: <BarChart2 size={13} />, show: hasThreads },
]; ];
return ( return (
<div className="border-t border-slate-700 bg-slate-950 flex flex-col overflow-hidden" style={{ height: '600px' }}> <div className="border-t border-slate-700 bg-slate-950 flex flex-col">
{/* Panel header with tabs + export button */} {/* Panel header with tabs + export button */}
<div className="flex items-center px-3 border-b border-slate-700 shrink-0 h-10 gap-1"> <div className="flex items-center px-3 border-b border-slate-700 shrink-0 h-10 gap-1">
{TAB_CONFIG.filter(t => t.show).map(tab => ( {TAB_CONFIG.filter(t => t.show).map(tab => (
@@ -86,7 +88,7 @@ const MetricsPanel = () => {
<div className="ml-auto"> <div className="ml-auto">
<button <button
onClick={() => exportCSV(processMetrics, processMap)} onClick={() => exportCSV(processMetrics, processMap, hasThreads ? threadMetrics : undefined)}
disabled={!canExportCSV} disabled={!canExportCSV}
title={!canExportCSV ? 'Available when simulation completes' : 'Export metrics as CSV'} title={!canExportCSV ? 'Available when simulation completes' : 'Export metrics as CSV'}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 px-2.5 py-1 rounded-md hover:bg-slate-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 px-2.5 py-1 rounded-md hover:bg-slate-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
@@ -99,7 +101,7 @@ const MetricsPanel = () => {
</div> </div>
{/* Tab content */} {/* Tab content */}
<div className="flex-1 overflow-y-auto min-h-0"> <div>
{activeTab === 'table' && ( {activeTab === 'table' && (
<MetricsTable metrics={processMetrics} processMap={processMap} /> <MetricsTable metrics={processMetrics} processMap={processMap} />
)} )}
@@ -112,6 +114,9 @@ const MetricsPanel = () => {
{activeTab === 'threads' && ( {activeTab === 'threads' && (
<ThreadMetricsTable metrics={threadMetrics} processMap={processMap} /> <ThreadMetricsTable metrics={threadMetrics} processMap={processMap} />
)} )}
{activeTab === 'thread-charts' && (
<ThreadMetricsChart metrics={threadMetrics} processMap={processMap} />
)}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,215 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
RadarChart,
PolarGrid,
PolarAngleAxis,
Radar,
} from 'recharts';
import type { ThreadMetrics, Process } from '@core/types';
import { deriveThreadColor } from '../shared/threadUtils';
interface ThreadMetricsChartProps {
metrics: ThreadMetrics[];
processMap: Record<string, Process>;
}
const TOOLTIP_STYLE = {
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
fontSize: '11px',
color: '#e2e8f0',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
};
const ChartBox = ({ height, children }: { height: number; children: React.ReactNode }) => (
<div style={{ width: '100%', height, overflow: 'hidden', flexShrink: 0 }}>
<ResponsiveContainer width="100%" height="100%">
{children as React.ReactElement}
</ResponsiveContainer>
</div>
);
const ThreadMetricsChart = ({ metrics, processMap }: ThreadMetricsChartProps) => {
if (metrics.length === 0) {
return (
<div className="flex items-center justify-center h-48 text-slate-500 text-sm">
No data available.
</div>
);
}
// Pre-compute thread colors in order of appearance
const threadIndexByProcess: Record<string, number> = {};
const threadColors: Record<string, string> = {};
metrics.forEach(m => {
if (threadIndexByProcess[m.processId] === undefined) {
threadIndexByProcess[m.processId] = 0;
}
const idx = threadIndexByProcess[m.processId]!;
const procColor = processMap[m.processId]?.color ?? '#94a3b8';
threadColors[`${m.processId}-${m.threadId}`] = deriveThreadColor(procColor, idx);
threadIndexByProcess[m.processId] = idx + 1;
});
const getThreadLabel = (m: ThreadMetrics) => {
const procName = processMap[m.processId]?.name ?? m.processId;
return `${procName}/${m.threadName}`;
};
const getThreadColor = (m: ThreadMetrics) =>
threadColors[`${m.processId}-${m.threadId}`] ?? '#94a3b8';
// ── Radar tooltip ──────────────────────────────────────────────
const labelToMetric = Object.fromEntries(metrics.map(m => [getThreadLabel(m), m]));
const radarTooltipContent = ({ active, payload, label }: { active?: boolean; payload?: readonly any[]; label?: string | number }) => {
if (!active || !payload?.length) return null;
return (
<div style={{ ...TOOLTIP_STYLE, padding: '8px 12px' }}>
<p style={{ fontSize: '10px', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, marginBottom: 4 }}>
{label}
</p>
{payload.map((entry: any) => {
const m = labelToMetric[entry.name];
return (
<div key={entry.name} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '11px', marginBottom: 2 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0, display: 'inline-block' }} />
<span style={{ color: '#cbd5e1' }}>{m ? getThreadLabel(m) : entry.name}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 12, fontFamily: 'monospace', color: '#e2e8f0' }}>
{typeof entry.value === 'number' ? `${entry.value.toFixed(1)}%` : '-'}
</span>
</div>
);
})}
</div>
);
};
// ── Stacked bar data ──────────────────────────────────────────
const barData = metrics.map(m => ({
name: getThreadLabel(m),
'Waiting': m.waitingTime,
'CPU': m.cpuTime,
'I/O': m.ioTime,
}));
// ── Radar chart data (normalized 0-100 per dimension) ─────────
const maxT = Math.max(...metrics.map(m => m.turnaroundTime), 1);
const maxR = Math.max(...metrics.map(m => m.responseTime), 1);
const maxW = Math.max(...metrics.map(m => m.waitingTime), 1);
const maxCpu = Math.max(...metrics.map(m => m.cpuTime), 1);
const maxIo = Math.max(...metrics.map(m => m.ioTime), 1);
const radarDimensions = ['Turnaround', 'Response', 'Waiting', 'CPU Time', 'I/O Time'] as const;
const radarData = radarDimensions.map(dim => {
const row: Record<string, string | number> = { dimension: dim };
metrics.forEach(m => {
const key = getThreadLabel(m);
switch (dim) {
case 'Turnaround': row[key] = (m.turnaroundTime / maxT) * 100; break;
case 'Response': row[key] = (m.responseTime / maxR) * 100; break;
case 'Waiting': row[key] = (m.waitingTime / maxW) * 100; break;
case 'CPU Time': row[key] = (m.cpuTime / maxCpu) * 100; break;
case 'I/O Time': row[key] = (m.ioTime / maxIo) * 100; break;
}
});
return row;
});
const showRadar = metrics.length >= 2;
return (
<div className="p-3 flex flex-col gap-6">
{/* ── Radar chart ───────────────────────────────────────── */}
{showRadar && (
<div className="w-full">
<p className="text-[10px] text-slate-400 uppercase tracking-wider mb-1 font-medium text-center">
Comparative Profile (normalized)
</p>
<ChartBox height={360}>
<RadarChart data={radarData} margin={{ top: 8, right: 20, bottom: 8, left: 20 }}>
<PolarGrid stroke="#334155" />
<PolarAngleAxis
dataKey="dimension"
tick={{ fill: '#94a3b8', fontSize: 10 }}
/>
{metrics.map(m => (
<Radar
key={`${m.processId}-${m.threadId}`}
name={getThreadLabel(m)}
dataKey={getThreadLabel(m)}
stroke={getThreadColor(m)}
fill={getThreadColor(m)}
fillOpacity={0.15}
strokeWidth={2}
dot={{ r: 3, fill: getThreadColor(m) }}
/>
))}
<Tooltip content={radarTooltipContent} />
<Legend
wrapperStyle={{ fontSize: '10px', paddingTop: '4px', color: '#94a3b8' }}
/>
</RadarChart>
</ChartBox>
</div>
)}
{/* ── Stacked bar chart ─────────────────────────────────── */}
<div className="w-full">
<p className="text-[10px] text-slate-400 uppercase tracking-wider mb-1 font-medium text-center">
Breakdown per Thread (ticks)
</p>
<ChartBox height={360}>
<BarChart data={barData} margin={{ top: 8, right: 12, left: -8, bottom: 48 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis
dataKey="name"
tick={{ fill: '#94a3b8', fontSize: 10 }}
axisLine={{ stroke: '#334155' }}
tickLine={false}
angle={-35}
textAnchor="end"
interval={0}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 10 }}
axisLine={false}
tickLine={false}
label={{
value: 'ticks',
angle: -90,
position: 'insideLeft',
offset: 14,
style: { fill: '#475569', fontSize: 9 },
}}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
cursor={{ fill: 'rgba(255,255,255,0.04)' }}
/>
<Legend
wrapperStyle={{ fontSize: '10px', paddingTop: '4px', color: '#94a3b8' }}
/>
<Bar dataKey="Waiting" stackId="a" fill="#f59e0b" radius={[0, 0, 0, 0]} />
<Bar dataKey="CPU" stackId="a" fill="#22c55e" radius={[0, 0, 0, 0]} />
<Bar dataKey="I/O" stackId="a" fill="#06b6d4" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartBox>
<p className="text-center text-[9px] text-slate-600 mt-0.5">
Waiting + CPU + I/O = Turnaround
</p>
</div>
</div>
);
};
export default ThreadMetricsChart;
+18
View File
@@ -551,6 +551,24 @@ export class SimulationEngine {
// ── CPU burst da thread concluído ──────────────────────────────────── // ── CPU burst da thread concluído ────────────────────────────────────
this._activeThreads.set(processId, null); this._activeThreads.set(processId, null);
this._handleThreadBurstComplete(cpu, activeThread, process); this._handleThreadBurstComplete(cpu, activeThread, process);
// Se o quantum do processo também expirou neste mesmo tick, cede a CPU
// agora (após tratar o burst) em vez de dar um tick extra ao processo.
if (this._cpuProcess !== null && this._algorithm.isQuantumExpired(cpu, this._config)) {
const activeAfter = this._activeThreads.get(processId) ?? null;
if (activeAfter !== null) {
this._transitionThread(activeAfter, "Ready");
if (policy === "RR") {
this._readyThreadQueues.get(processId)!.push(activeAfter);
} else {
this._readyThreadQueues.get(processId)!.unshift(activeAfter);
}
this._activeThreads.set(processId, null);
}
this._transition(cpu, "Ready");
this._readyQueue.push(cpu);
this._cpuProcess = null;
}
} else if (scheduler.isQuantumExpired(activeThread)) { } else if (scheduler.isQuantumExpired(activeThread)) {
// ── Quantum intra-processo expirou (RR): rotaciona thread ──────────── // ── Quantum intra-processo expirou (RR): rotaciona thread ────────────
this._transitionThread(activeThread, "Ready"); this._transitionThread(activeThread, "Ready");
+51 -6
View File
@@ -1,5 +1,4 @@
import type { ProcessMetrics } from "@core/types"; import type { ProcessMetrics, ThreadMetrics, Process } from "@core/types";
import type { Process } from "@core/types";
interface ProcessMap { interface ProcessMap {
[id: string]: Process; [id: string]: Process;
@@ -16,8 +15,13 @@ function downloadBlob(content: string, filename: string, mimeType: string) {
} }
export function useExport() { export function useExport() {
const exportCSV = (metrics: ProcessMetrics[], processMap: ProcessMap) => { const exportCSV = (
const headers = [ metrics: ProcessMetrics[],
processMap: ProcessMap,
threadMetrics?: ThreadMetrics[],
) => {
// ── Seção de processos ─────────────────────────────────────────────────
const processHeaders = [
"PID", "PID",
"Process", "Process",
"Priority", "Priority",
@@ -33,7 +37,7 @@ export function useExport() {
"Bound", "Bound",
]; ];
const rows = metrics.map((m) => { const processRows = metrics.map((m) => {
const proc = processMap[m.processId]; const proc = processMap[m.processId];
const pid = proc?.pid ?? ""; const pid = proc?.pid ?? "";
const name = proc?.name ?? m.processId; const name = proc?.name ?? m.processId;
@@ -55,7 +59,48 @@ export function useExport() {
].join(","); ].join(",");
}); });
const csv = [headers.join(","), ...rows].join("\n"); const sections: string[] = [
"# Processes",
processHeaders.join(","),
...processRows,
];
// ── Seção de threads (opcional) ────────────────────────────────────────
if (threadMetrics && threadMetrics.length > 0) {
const threadHeaders = [
"Thread",
"Process",
"Arrival",
"Start",
"Finish",
"Response",
"Turnaround",
"Waiting",
"CPU Time",
"I/O Time",
];
const threadRows = threadMetrics.map((t) => {
const proc = processMap[t.processId];
const procName = proc?.name ?? t.processId;
return [
t.threadName,
procName,
t.arrivalTime,
t.startTick,
t.finishTick,
t.responseTime,
t.turnaroundTime,
t.waitingTime,
t.cpuTime,
t.ioTime,
].join(",");
});
sections.push("", "# Threads", threadHeaders.join(","), ...threadRows);
}
const csv = sections.join("\n");
downloadBlob(csv, "pss-metrics.csv", "text/csv;charset=utf-8;"); downloadBlob(csv, "pss-metrics.csv", "text/csv;charset=utf-8;");
}; };