Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift
T
gentpan 50d04ebda8 CPU 与内存详情改用圆环、仪表与分色柱形图,新增用 Apple 智能解释进程
- CPU:温度 / 占用 / 负载三个圆环,负载历史面积图,核心按类型分色,各类核心平均占用与 1 / 5 / 15 分钟平均负载
- 内存:压力仪表与按 App / 联动 / 压缩分段的圆环,补充已用、可用与交换区
- 进程右键或进程页星形按钮调用系统自带的本机大模型解释进程,附带路径、所属应用与签名方,不联网
- 进程显示本地化应用名,详情弹窗进程列表 8 行;修复截图模式下短页面卡片被拉高
- 更新 README 截图与说明,更新日志同步到官网
2026-09-13 06:01:41 +05:00

373 lines
17 KiB
Swift

import Metrics
import SwiftUI
// 面板每秒刷新,图表全部用 Canvas 直接绘制:比 Swift Charts 轻得多,也能被 ImageRenderer 截图。
// 刷新时不加隐式动画,避免每秒触发一次持续重绘。
/// 历史柱形图:每个采样一根柱子,空位画基线
struct BarHistoryChart: View {
let values: [Double]
var capacity: Int = 24
var color: Color = DS.Palette.primary
var warnColor: Color = DS.Palette.warning
var warnThreshold: Double = 0.8
var height: CGFloat = DS.Size.chartHeight
var body: some View {
Canvas { context, size in
let gap = DS.Space.s1 / 2
let barWidth = max(1, (size.width - gap * CGFloat(capacity - 1)) / CGFloat(capacity))
let recent = values.suffix(capacity)
let offset = capacity - recent.count
let baseline = DS.Size.stroke * 2
for index in 0..<capacity {
let x = CGFloat(index) * (barWidth + gap)
guard index >= offset else {
context.fill(Path(CGRect(x: x, y: size.height - baseline, width: barWidth, height: baseline)),
with: .color(DS.Palette.track))
continue
}
let value = min(1, max(0, recent[recent.startIndex + index - offset]))
let barHeight = max(baseline, size.height * value)
let rect = CGRect(x: x, y: size.height - barHeight, width: barWidth, height: barHeight)
let fill = value >= warnThreshold ? warnColor : color
context.fill(Path(roundedRect: rect, cornerRadius: min(DS.Radius.sm / 2, barWidth / 2)),
with: .color(value < 0.02 ? DS.Palette.neutral300 : fill))
}
}
.frame(height: height)
.accessibilityHidden(true)
}
}
/// 历史折线(可带实色半透明填充)
struct LineHistoryChart: View {
let values: [Double]
var capacity: Int = MetricsStore.historyCapacity
var maxValue: Double = 1
var color: Color = DS.Palette.primary
var filled = true
var height: CGFloat = DS.Size.chartHeight
var body: some View {
Canvas { context, size in
let line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: maxValue, in: size)
guard let line else { return }
if filled {
var area = line
area.addLine(to: CGPoint(x: size.width, y: size.height))
area.addLine(to: CGPoint(x: area.boundingRect.minX, y: size.height))
area.closeSubpath()
context.fill(area, with: .color(color.opacity(0.14)))
}
context.stroke(line, with: .color(color),
style: StrokeStyle(lineWidth: DS.Size.chartLine, lineCap: .round, lineJoin: .round))
}
.frame(height: height)
.accessibilityHidden(true)
}
/// 右对齐:最新的点在最右侧
static func path(values: [Double], capacity: Int, maxValue: Double, in size: CGSize) -> Path? {
let recent = Array(values.suffix(capacity))
guard recent.count > 1 else { return nil }
let step = size.width / CGFloat(max(1, capacity - 1))
let inset = DS.Size.chartLine
let usable = size.height - inset * 2
let peak = max(maxValue, .leastNonzeroMagnitude)
var path = Path()
for (index, value) in recent.enumerated() {
let x = size.width - CGFloat(recent.count - 1 - index) * step
let y = inset + usable * (1 - CGFloat(min(1, max(0, value / peak))))
index == 0 ? path.move(to: CGPoint(x: x, y: y)) : path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
}
/// 上传 / 下载双折线,按两者峰值归一化
struct DualLineChart: View {
let upload: [Double]
let download: [Double]
var capacity: Int = MetricsStore.historyCapacity
var height: CGFloat = DS.Size.chartHeight
var body: some View {
Canvas { context, size in
let peak = max(upload.max() ?? 0, download.max() ?? 0, 64 * 1024)
let style = StrokeStyle(lineWidth: DS.Size.chartLine, lineCap: .round, lineJoin: .round)
let series: [([Double], Color)] = [(download, Color(nsColor: DS.NetworkPalette.download)),
(upload, Color(nsColor: DS.NetworkPalette.upload))]
for (values, color) in series {
guard let line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: peak * 1.1, in: size) else { continue }
var area = line
area.addLine(to: CGPoint(x: size.width, y: size.height))
area.addLine(to: CGPoint(x: area.boundingRect.minX, y: size.height))
area.closeSubpath()
context.fill(area, with: .color(color.opacity(0.10)))
context.stroke(line, with: .color(color), style: style)
}
}
.frame(height: height)
.accessibilityHidden(true)
}
}
/// 环形进度(饼图风格),中间可放图标或文字
struct RingGauge<Center: View>: View {
let fraction: Double
var color: Color = DS.Palette.success
var lineWidth: CGFloat = DS.Space.s1 + DS.Space.s1 / 2
var size: CGFloat = DS.Space.s12 + DS.Space.s4
@ViewBuilder var center: Center
var body: some View {
ZStack {
Circle().stroke(DS.Palette.track, lineWidth: lineWidth)
Circle()
.trim(from: 0, to: min(1, max(0, fraction)))
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
center
}
.padding(lineWidth / 2)
.frame(width: size, height: size)
}
}
/// 每个核心一根竖条,按性能档分组(高性能档在左)
struct CoreClusterBars: View {
let topology: CPUTopology
let perCore: [Double]
var barHeight: CGFloat = DS.Size.coreBarHeight
/// 按核心类型着色(各类核心的平均占用显示在详细信息里)
var byCluster = false
static func average(of cluster: CPUCluster, perCore: [Double]) -> Double? {
let values = cluster.coreIndices.compactMap { $0 < perCore.count ? perCore[$0] : nil }
return values.isEmpty ? nil : values.reduce(0, +) / Double(values.count)
}
private static let barGap = DS.Space.s1 / 2
private static let groupGap = DS.Space.s3
private static func barWidth(for width: CGFloat, clusters: [CPUCluster], total: Int) -> CGFloat {
let gaps = CGFloat(total - clusters.count) * barGap + CGFloat(max(0, clusters.count - 1)) * groupGap
return max(1, (width - gaps) / CGFloat(total))
}
var body: some View {
let clusters = topology.clusters.sorted { ($0.coreIndices.first ?? 0) > ($1.coreIndices.first ?? 0) }
let total = max(1, clusters.reduce(0) { $0 + $1.coreIndices.count })
VStack(alignment: .leading, spacing: DS.Space.s1) {
Canvas { context, size in
var x: CGFloat = 0
let barWidth = Self.barWidth(for: size.width, clusters: clusters, total: total)
for cluster in clusters {
for index in cluster.coreIndices {
let value = index < perCore.count ? min(1, max(0, perCore[index])) : 0
let track = CGRect(x: x, y: 0, width: barWidth, height: size.height)
context.fill(Path(roundedRect: track, cornerRadius: DS.Radius.sm / 2), with: .color(DS.Palette.track))
let fillHeight = size.height * value
let fill = CGRect(x: x, y: size.height - fillHeight, width: barWidth, height: fillHeight)
let color = byCluster ? DS.Palette.cluster(cluster.id) : value >= 0.8 ? DS.Palette.warning : DS.Palette.primary
context.fill(Path(roundedRect: fill, cornerRadius: DS.Radius.sm / 2), with: .color(color))
x += barWidth + Self.barGap
}
x += Self.groupGap - Self.barGap
}
}
.frame(height: barHeight)
// 标签宽度与上方柱组一致
GeometryReader { proxy in
let barWidth = Self.barWidth(for: proxy.size.width, clusters: clusters, total: total)
HStack(spacing: Self.groupGap) {
ForEach(clusters) { cluster in
let count = CGFloat(cluster.coreIndices.count)
HStack(spacing: DS.Space.s1) {
if byCluster {
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(DS.Palette.cluster(cluster.id))
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
}
Text(verbatim: label(cluster))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
.lineLimit(1)
}
.frame(width: count * barWidth + (count - 1) * Self.barGap, alignment: .leading)
}
}
}
.frame(height: DS.TextSize.xs.rawValue * 1.5)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("各核心负载")
}
private func label(_ cluster: CPUCluster) -> String {
"\(cluster.name) · \(cluster.coreIndices.count)"
}
}
/// 分段圆环:各段首尾相接(内存的 App / 联动 / 压缩),剩余为底槽
struct SegmentedRing<Center: View>: View {
let segments: [(fraction: Double, color: Color)]
var lineWidth: CGFloat = DS.Space.s2
var size: CGFloat = DS.Space.s16
@ViewBuilder var center: Center
var body: some View {
ZStack {
Circle().stroke(DS.Palette.track, lineWidth: lineWidth)
ForEach(Array(ranges.enumerated()), id: \.offset) { _, range in
Circle()
.trim(from: range.start, to: range.end)
.stroke(range.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
.rotationEffect(.degrees(-90))
}
center
}
.padding(lineWidth / 2)
.frame(width: size, height: size)
}
private var ranges: [(start: Double, end: Double, color: Color)] {
var start = 0.0
return segments.map { segment in
let end = min(1, start + max(0, segment.fraction))
defer { start = end }
return (start, end, segment.color)
}
}
}
/// 内存压力仪表:半圆弧分正常、偏高、严重三段,指针指向当前档位
struct PressureGauge: View {
let pressure: MemoryPressure?
var width: CGFloat = DS.Space.s16
var body: some View {
VStack(spacing: DS.Space.s1) {
Canvas { context, size in
let lineWidth = DS.Space.s2 - DS.Space.s1 / 2
let radius = min(size.width / 2, size.height) - lineWidth / 2
let center = CGPoint(x: size.width / 2, y: size.height - DS.Space.s1)
// 角度从左(0°)经过顶部到右(180°)
func point(_ degrees: Double, _ r: CGFloat) -> CGPoint {
let radians = degrees * .pi / 180
return CGPoint(x: center.x - r * cos(radians), y: center.y - r * sin(radians))
}
let bands: [(Double, Double, Color)] = [(0, 58, DS.Palette.success), (62, 118, DS.Palette.warning), (122, 180, DS.Palette.error)]
for (start, end, color) in bands {
var arc = Path()
arc.move(to: point(start, radius))
for step in stride(from: start, through: end, by: 2) { arc.addLine(to: point(step, radius)) }
context.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
}
let angle: Double = switch pressure {
case .warning: 90
case .critical: 150
default: 30
}
var needle = Path()
needle.move(to: center)
needle.addLine(to: point(angle, radius * 0.72))
context.stroke(needle, with: .color(DS.Palette.textPrimary),
style: StrokeStyle(lineWidth: DS.Size.chartLine * 2, lineCap: .round))
let dot = DS.Space.s1 + DS.Space.s1 / 2
context.fill(Path(ellipseIn: CGRect(x: center.x - dot / 2, y: center.y - dot / 2, width: dot, height: dot)),
with: .color(DS.Palette.textPrimary))
}
.frame(width: width, height: width / 2 + DS.Space.s1)
Text(pressure.map { "压力\($0.title)" } ?? "—")
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.textSecondary)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("内存压力 \(pressure?.title ?? "未知")")
}
}
/// 上下镜像的流量图:上半部分上传、下半部分下载,各自按自己的峰值缩放
struct MirroredRateChart: View {
let upload: [Double]
let download: [Double]
var capacity: Int = MetricsStore.historyCapacity
var height: CGFloat = DS.Size.chartHeight
/// 峰值太小时按 64 KB/s 缩放,避免空闲时的噪声被放大成满格
static let floor = 64.0 * 1024
var body: some View {
Canvas { context, size in
let middle = size.height / 2
let half = CGSize(width: size.width, height: middle)
let series: [([Double], Color, Bool)] = [
(upload, Color(nsColor: DS.NetworkPalette.upload), true),
(download, Color(nsColor: DS.NetworkPalette.download), false),
]
for (values, color, above) in series {
let peak = max(values.max() ?? 0, Self.floor)
guard var line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: peak, in: half) else { continue }
if !above {
// 下半部分:向下翻转
line = line.applying(CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: middle * 2))
}
var area = line
area.addLine(to: CGPoint(x: size.width, y: middle))
area.addLine(to: CGPoint(x: line.boundingRect.minX, y: middle))
area.closeSubpath()
context.fill(area, with: .color(color.opacity(0.14)))
context.stroke(line, with: .color(color), style: StrokeStyle(lineWidth: DS.Size.chartLine, lineJoin: .round))
}
context.fill(Path(CGRect(x: 0, y: middle - DS.Size.stroke / 2, width: size.width, height: DS.Size.stroke)),
with: .color(DS.Palette.border))
}
.frame(height: height)
.accessibilityHidden(true)
}
}
/// 连接探测格子:按时间从左到右、从上到下排列,最新的一格在末尾。
/// 绿色正常,橙色延迟偏高,红色超时,灰色尚无数据
struct ProbeGrid: View {
let samples: [ProbeSample]
var columns = 20
var rows = 3
var slowThreshold: Double = 200
var body: some View {
let capacity = columns * rows
let recent = Array(samples.suffix(capacity))
Canvas { context, size in
let gap = DS.Space.s1 / 2
let cellWidth = (size.width - gap * CGFloat(columns - 1)) / CGFloat(columns)
let cellHeight = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows)
let offset = capacity - recent.count
for index in 0..<capacity {
let rect = CGRect(x: CGFloat(index % columns) * (cellWidth + gap),
y: CGFloat(index / columns) * (cellHeight + gap),
width: cellWidth, height: cellHeight)
let color: Color
if index < offset {
color = DS.Palette.track
} else if let latency = recent[index - offset].latency {
color = latency >= slowThreshold ? DS.Palette.warning : DS.Palette.success
} else {
color = DS.Palette.error
}
context.fill(Path(roundedRect: rect, cornerRadius: DS.Radius.sm / 2), with: .color(color))
}
}
// 格子保持接近正方形,宽度随容器变化
.aspectRatio(CGFloat(columns) / CGFloat(rows), contentMode: .fit)
.fixedSize(horizontal: false, vertical: true)
.accessibilityElement()
.accessibilityLabel("连接探测历史")
}
}