This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 9921a0e8 feat: implement format utilities and enhance MiniLine chart 
(#660)
9921a0e8 is described below

commit 9921a0e8bb993b861d4a95ee533e0d701cd5507f
Author: zhaohai <[email protected]>
AuthorDate: Fri Jul 31 16:08:24 2026 +0800

    feat: implement format utilities and enhance MiniLine chart (#660)
    
    * [studio] Implement format utility functions (date, bytes, number, delay, 
percent)
    
    Replace TODO stub implementations in format.ts with full-featured utilities:
    - formatDateTime / formatDate: proper date formatting with padding
    - formatBytes: human-readable 1024-based byte formatting
    - formatNumber: thousands separator formatting
    - formatDelay: duration formatting with i18n support (zh/en)
    - formatPercent: fixed-decimal percentage formatting
    
    * [studio] Enhance MiniLine chart component
    
    Smooth cardinal-spline path, gradient area fill, glow on last-point dot,
    configurable strokeWidth/showDot/animated, and responsive SVG mode.
    The MiniBar zero-value change from the original PR is dropped: trunk
    intentionally renders no visible bar at zero throughput (#629).
---
 web/src/components/MiniLine.tsx | 127 ++++++++++++++++++++++++++++++++--------
 web/src/utils/format.ts         |  85 +++++++++++++++++++++++++--
 2 files changed, 184 insertions(+), 28 deletions(-)

diff --git a/web/src/components/MiniLine.tsx b/web/src/components/MiniLine.tsx
index 8a483ba8..a7658d24 100644
--- a/web/src/components/MiniLine.tsx
+++ b/web/src/components/MiniLine.tsx
@@ -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 = ({
@@ -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>
   );
 };
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index c3db0e94..4df61936 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -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)}%`;
 }

Reply via email to