Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 104 additions & 23 deletions web/src/components/MiniLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,25 @@
* limitations under the License.
*/

import { useMemo } from 'react';

let _lineId = 0;

interface MiniLineProps {
data: number[];
color?: string;
height?: number;
width?: number;
/** Fill area under the curve */
fill?: boolean;
/** Stroke width */
strokeWidth?: number;
/** Show dot on last data point */
showDot?: boolean;
/** Animate on mount */
animated?: boolean;
/** Make SVG responsive (width=100%, preserves aspect ratio) */
responsive?: boolean;
}

const MiniLine = ({
Expand All @@ -29,44 +42,112 @@ const MiniLine = ({
height = 32,
width = 120,
fill = true,
strokeWidth = 2,
showDot = true,
animated = true,
responsive = false,
}: MiniLineProps) => {
if (data.length < 2) return null;

const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;

const padding = 2;
const innerW = width - padding * 2;
const innerH = height - padding * 2;
const pad = 4;
const innerW = width - pad * 2;
const innerH = height - pad * 2;

const gradientId = useMemo(() => `ml-grad-${++_lineId}`, []);
const glowId = useMemo(() => `ml-glow-${++_lineId}`, []);

const points = data.map((v, i) => {
const x = padding + (i / (data.length - 1)) * innerW;
const y = padding + innerH - ((v - min) / range) * innerH;
return `${x},${y}`;
});
if (data.length < 2) return null;

// Build smooth Catmull-Rom → Bezier control points
const points = data.map((v, i) => ({
x: pad + (i / (data.length - 1)) * innerW,
y: pad + innerH - ((v - min) / range) * innerH,
}));

// Convert points to a smooth SVG path using cardinal spline
const smoothPath = (() => {
if (points.length < 2) return '';
let d = `M${points[0].x},${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
const tension = 0.3;
const cp1x = p1.x + (p2.x - p0.x) * tension;
const cp1y = p1.y + (p2.y - p0.y) * tension;
const cp2x = p2.x - (p3.x - p1.x) * tension;
const cp2y = p2.y - (p3.y - p1.y) * tension;
d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
}
return d;
})();

const linePath = `M${points.join(' L')}`;
const areaPath = `${linePath} L${padding + innerW},${height - padding} L${padding},${height - padding} Z`;
const areaPath = `${smoothPath} L${pad + innerW},${height - pad} L${pad},${height - pad} Z`;

const lastPoint = points[points.length - 1];

return (
<svg width={width} height={height} style={{ display: 'block' }}>
{fill && <path d={areaPath} fill={color} opacity={0.1} />}
<svg
width={responsive ? '100%' : width}
height={height}
viewBox={responsive ? `0 0 ${width} ${height}` : undefined}
preserveAspectRatio={responsive ? 'none' : undefined}
style={{ display: 'block', overflow: 'visible' }}
>
{' '}
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
<filter id={glowId}>
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{fill && <path d={areaPath} fill={`url(#${gradientId})`} />}
<path
d={linePath}
d={smoothPath}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
filter={`url(#${glowId})`}
style={
animated
? {
strokeDasharray: 500,
strokeDashoffset: 500,
animation: 'miniLineDraw 1s ease-out forwards',
}
: undefined
}
/>
{/* Last point dot */}
<circle
cx={padding + innerW}
cy={padding + innerH - ((data[data.length - 1] - min) / range) * innerH}
r={2.5}
fill={color}
/>
{showDot && (
<>
<circle cx={lastPoint.x} cy={lastPoint.y} r={4} fill={color} opacity={0.2} />
<circle
cx={lastPoint.x}
cy={lastPoint.y}
r={2.5}
fill="#fff"
stroke={color}
strokeWidth={1.5}
/>
</>
)}
<style>{`
@keyframes miniLineDraw {
to { stroke-dashoffset: 0; }
}
`}</style>
</svg>
);
};
Expand Down
85 changes: 80 additions & 5 deletions web/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,87 @@
* limitations under the License.
*/

const pad = (n: number, width = 2): string => String(n).padStart(width, '0');

/**
* Format a date string or Date object to 'YYYY-MM-DD HH:mm:ss'.
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return String(date);
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
);
}

/**
* Format a date string or Date object to 'YYYY-MM-DD'.
*/
export function formatDate(date: string | Date): string {
// TODO: implement
return String(date);
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return String(date);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

export function formatBytes(bytes: number): string {
// TODO: implement
return `${bytes} B`;
/**
* Format bytes into human-readable string (1024-based).
* e.g. 1536 → '1.5 KB', 1048576 → '1 MB'
*/
export function formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0 B';
if (bytes < 0) return `-${formatBytes(-bytes, decimals)}`;

const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(decimals)} ${units[i]}`;
}

/**
* Format a number with thousands separators.
* e.g. 1234567 → '1,234,567'
*/
export function formatNumber(num: number): string {
return num.toLocaleString('en-US');
}

/**
* Format delay seconds into human-readable duration.
* Supports i18n via the lang parameter.
* e.g. 82500 → zh: "22小时55分钟", en: "22h 55m"
*/
export function formatDelay(totalSeconds: number, lang: 'zh' | 'en' = 'zh'): string {
if (totalSeconds <= 0) return lang === 'zh' ? '0秒' : '0s';

const days = Math.floor(totalSeconds / 86400);
let remaining = totalSeconds % 86400;
const hours = Math.floor(remaining / 3600);
remaining %= 3600;
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;

if (lang === 'en') {
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (seconds > 0 && parts.length < 3) parts.push(`${seconds}s`);
return parts.length > 0 ? parts.join(' ') : '0s';
}

const parts: string[] = [];
if (days > 0) parts.push(`${days}天`);
if (hours > 0) parts.push(`${hours}小时`);
if (minutes > 0) parts.push(`${minutes}分钟`);
if (seconds > 0 && parts.length < 3) parts.push(`${seconds}秒`);
return parts.length > 0 ? parts.join('') : '0秒';
}

/**
* Format a percentage value (0-100) with fixed decimals.
*/
export function formatPercent(value: number, decimals = 1): string {
return `${value.toFixed(decimals)}%`;
}
Loading