Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
227 lines
5.9 KiB
TypeScript
227 lines
5.9 KiB
TypeScript
/**
|
|
* LossChart & ProgressRing Components
|
|
*
|
|
* SVG-based visualizations for training metrics:
|
|
* - LossChart: Line chart for training/validation loss curves
|
|
* - ProgressRing: Circular progress indicator
|
|
*/
|
|
|
|
'use client'
|
|
|
|
import type { TrainingDataPoint } from './training-metrics-types'
|
|
|
|
/**
|
|
* Simple line chart component for loss visualization
|
|
*/
|
|
export function LossChart({
|
|
data,
|
|
height = 200,
|
|
className = ''
|
|
}: {
|
|
data: TrainingDataPoint[]
|
|
height?: number
|
|
className?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className={`flex items-center justify-center bg-slate-50 rounded-lg ${className}`} style={{ height }}>
|
|
<span className="text-slate-400 text-sm">Keine Daten</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Calculate bounds
|
|
const losses = data.map(d => d.loss)
|
|
const valLosses = data.filter(d => d.val_loss !== undefined).map(d => d.val_loss!)
|
|
const allLosses = [...losses, ...valLosses]
|
|
const minLoss = Math.min(...allLosses) * 0.9
|
|
const maxLoss = Math.max(...allLosses) * 1.1
|
|
const lossRange = maxLoss - minLoss || 1
|
|
|
|
// SVG dimensions
|
|
const width = 400
|
|
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
|
|
const chartWidth = width - padding.left - padding.right
|
|
const chartHeight = height - padding.top - padding.bottom
|
|
|
|
// Generate path for loss line
|
|
const generatePath = (values: number[]) => {
|
|
if (values.length === 0) return ''
|
|
return values
|
|
.map((loss, idx) => {
|
|
const x = padding.left + (idx / (values.length - 1 || 1)) * chartWidth
|
|
const y = padding.top + chartHeight - ((loss - minLoss) / lossRange) * chartHeight
|
|
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
|
})
|
|
.join(' ')
|
|
}
|
|
|
|
const lossPath = generatePath(losses)
|
|
const valLossPath = valLosses.length > 0 ? generatePath(valLosses) : ''
|
|
|
|
// Y-axis labels
|
|
const yLabels = [maxLoss, (maxLoss + minLoss) / 2, minLoss].map(v => v.toFixed(3))
|
|
|
|
return (
|
|
<svg viewBox={`0 0 ${width} ${height}`} className={`w-full ${className}`} preserveAspectRatio="xMidYMid meet">
|
|
{/* Grid lines */}
|
|
{[0, 0.5, 1].map((ratio, idx) => (
|
|
<line
|
|
key={idx}
|
|
x1={padding.left}
|
|
y1={padding.top + chartHeight * ratio}
|
|
x2={width - padding.right}
|
|
y2={padding.top + chartHeight * ratio}
|
|
stroke="#e2e8f0"
|
|
strokeWidth="1"
|
|
/>
|
|
))}
|
|
|
|
{/* Y-axis labels */}
|
|
{yLabels.map((label, idx) => (
|
|
<text
|
|
key={idx}
|
|
x={padding.left - 5}
|
|
y={padding.top + (idx / 2) * chartHeight + 4}
|
|
textAnchor="end"
|
|
className="fill-slate-500 text-xs"
|
|
>
|
|
{label}
|
|
</text>
|
|
))}
|
|
|
|
{/* X-axis label */}
|
|
<text
|
|
x={width / 2}
|
|
y={height - 5}
|
|
textAnchor="middle"
|
|
className="fill-slate-500 text-xs"
|
|
>
|
|
Epoche / Schritt
|
|
</text>
|
|
|
|
{/* Y-axis label */}
|
|
<text
|
|
x={12}
|
|
y={height / 2}
|
|
textAnchor="middle"
|
|
className="fill-slate-500 text-xs"
|
|
transform={`rotate(-90, 12, ${height / 2})`}
|
|
>
|
|
Loss
|
|
</text>
|
|
|
|
{/* Training loss line */}
|
|
<path
|
|
d={lossPath}
|
|
fill="none"
|
|
stroke="#8b5cf6"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
|
|
{/* Validation loss line (dashed) */}
|
|
{valLossPath && (
|
|
<path
|
|
d={valLossPath}
|
|
fill="none"
|
|
stroke="#22c55e"
|
|
strokeWidth="2"
|
|
strokeDasharray="5,5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
)}
|
|
|
|
{/* Data points */}
|
|
{data.map((point, idx) => {
|
|
const x = padding.left + (idx / (data.length - 1 || 1)) * chartWidth
|
|
const y = padding.top + chartHeight - ((point.loss - minLoss) / lossRange) * chartHeight
|
|
return (
|
|
<circle
|
|
key={idx}
|
|
cx={x}
|
|
cy={y}
|
|
r="3"
|
|
fill="#8b5cf6"
|
|
className="hover:r-4 transition-all"
|
|
>
|
|
<title>Epoch {point.epoch}, Step {point.step}: {point.loss.toFixed(4)}</title>
|
|
</circle>
|
|
)
|
|
})}
|
|
|
|
{/* Legend */}
|
|
<g transform={`translate(${padding.left}, ${height - 25})`}>
|
|
<line x1="0" y1="0" x2="20" y2="0" stroke="#8b5cf6" strokeWidth="2" />
|
|
<text x="25" y="4" className="fill-slate-600 text-xs">Training Loss</text>
|
|
|
|
{valLossPath && (
|
|
<>
|
|
<line x1="120" y1="0" x2="140" y2="0" stroke="#22c55e" strokeWidth="2" strokeDasharray="5,5" />
|
|
<text x="145" y="4" className="fill-slate-600 text-xs">Val Loss</text>
|
|
</>
|
|
)}
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Progress ring component
|
|
*/
|
|
export function ProgressRing({
|
|
progress,
|
|
size = 80,
|
|
strokeWidth = 6,
|
|
className = ''
|
|
}: {
|
|
progress: number
|
|
size?: number
|
|
strokeWidth?: number
|
|
className?: string
|
|
}) {
|
|
const radius = (size - strokeWidth) / 2
|
|
const circumference = radius * 2 * Math.PI
|
|
const offset = circumference - (progress / 100) * circumference
|
|
|
|
return (
|
|
<svg width={size} height={size} className={className}>
|
|
{/* Background circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#e2e8f0"
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
{/* Progress circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#8b5cf6"
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
className="transition-all duration-300"
|
|
/>
|
|
{/* Center text */}
|
|
<text
|
|
x={size / 2}
|
|
y={size / 2}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
className="fill-slate-900 font-bold text-lg"
|
|
>
|
|
{progress.toFixed(0)}%
|
|
</text>
|
|
</svg>
|
|
)
|
|
}
|