Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift
T
gentpan c9d722f20d 账号同步、磁盘工具、IP 归属地数据源与纯净度、弹窗与设置调整
- 账号与同步:GitHub / Google / Apple 登录后把菜单栏项目与风格等偏好同步到 getopenstats.com,换一台 Mac 登录即恢复(AccountSync 模块、SyncController、SettingsDocument、账号设置页,openstats:// 回调)
- 磁盘页成为工具页:已用 / 可清除 / 可用分段容量条、空间分析、文件系统检查、本地快照清理(辅助工具协议 v4)、其他磁盘推出
- 公网 IP:归属地数据源可选 CleanIP.io / ipapi.is / DB-IP / ipinfo.io / 本地 GeoLite2,每台 Mac 自己查、本地缓存 7 天;IPv4 / IPv6 双栈各自查询与切换;弹窗显示 ASN、运营方、原生 / 住宅等徽章与纯净度评分色带;接口区块可重置上传 / 下载统计
- 弹窗:右上角按钮改为该指标自己的图标 + 总设置;主窗口页面标题栏的开关点不动已修复;滚动条改为悬浮式,内容不再偏左;网络弹窗卡片被标题行撑宽的问题已修复
- 设置:⌘ 拖动排序说明;每个菜单栏项目单独选风格并带实时预览;网络与磁盘的进程榜单固定 5 行并平滑排序;CPU 持续高负载通知
- 设计系统:分段条、评分色带、标签徽章、胶囊切换、刷新按钮等组件与徽章配色 token
- 更新日志、架构说明、README 与译文同步
2026-09-15 03:37:24 +05:00

589 lines
27 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Localization
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
/// 在 25% / 50% / 75% 画三条淡淡的参考线,图表较高时(主窗口)用来读数
var grid = false
var height: CGFloat = DS.Size.chartHeight
var body: some View {
Canvas { context, size in
if grid {
let usable = size.height - DS.Size.chartLine * 2
for fraction in [0.25, 0.5, 0.75] {
let y = (DS.Size.chartLine + usable * CGFloat(1 - fraction)).rounded()
context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: DS.Size.stroke)),
with: .color(DS.Palette.border))
}
}
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
}
}
/// 按时间定位的短期走势线:最新的采样固定在右边缘,往左按真实时间间隔铺开,
/// 采样间隔变化(后台 2 秒、打开详情后 1 秒)也不会把曲线拉伸;相邻采样间隔超过 10 秒(睡眠)线条断开
struct TimedLineChart: View {
let points: [TimedValue]
let duration: TimeInterval
let end: Date
var color: Color = DS.Palette.primary
/// 在 25% / 50% / 75% 画三条淡淡的参考线
var grid = false
var height: CGFloat = DS.Size.chartHeight
static let gapLimit: TimeInterval = 10
var body: some View {
Canvas { context, size in
if grid {
let usable = size.height - DS.Size.chartLine * 2
for fraction in [0.25, 0.5, 0.75] {
let y = (DS.Size.chartLine + usable * CGFloat(1 - fraction)).rounded()
context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: DS.Size.stroke)),
with: .color(DS.Palette.border))
}
}
for segment in Self.segments(points, duration: duration, end: end, in: size) {
guard let first = segment.first, let last = segment.last else { continue }
var line = Path()
line.addLines(segment.count == 1 ? [first, CGPoint(x: first.x + DS.Size.stroke, y: first.y)] : segment)
var area = line
area.addLine(to: CGPoint(x: last.x, y: size.height))
area.addLine(to: CGPoint(x: first.x, 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)
}
/// 落在 [end − duration, end] 内的采样换算成坐标(0 在下、1 在上),按间隔断开成若干段
static func segments(_ points: [TimedValue], duration: TimeInterval, end: Date, in size: CGSize) -> [[CGPoint]] {
let start = end.addingTimeInterval(-duration)
let inset = DS.Size.chartLine
let usable = size.height - inset * 2
var segments: [[CGPoint]] = []
var previous: Date?
for point in points where point.date >= start && point.date <= end {
let location = CGPoint(x: size.width * CGFloat(point.date.timeIntervalSince(start) / max(duration, 1)),
y: inset + usable * CGFloat(1 - min(1, max(0, point.value))))
if let previous, point.date.timeIntervalSince(previous) <= gapLimit, !segments.isEmpty {
segments[segments.count - 1].append(location)
} else {
segments.append([location])
}
previous = point.date
}
return segments
}
}
/// 上传 / 下载双折线,按两者峰值归一化
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(tr("各核心负载"))
}
private func label(_ cluster: CPUCluster) -> String {
"\(cluster.name) · \(cluster.coreIndices.count)"
}
}
/// 核心热力图:每行一个核心(按类型分组,高性能档在上),每列一次采样,最新在右;
/// 最右侧单独一根粗条画此刻的占用,一眼能看出现在哪些核心在忙。
/// 颜色越深越忙,超过 85% 用警告色
struct CoreHeatmap: View {
let topology: CPUTopology
let history: [[Double]]
var columns = 40
var rowHeight: CGFloat = DS.Space.s1 - DS.Size.stroke
private static let gap = DS.Size.stroke
private static let groupGap = DS.Space.s1
private static let nowWidth = DS.Space.s2
private static let nowGap = DS.Space.s1
var body: some View {
let clusters = topology.clusters.sorted { $0.id < $1.id }
let height = clusters.reduce(CGFloat(0)) { $0 + CGFloat($1.coreIndices.count) * (rowHeight + Self.gap) }
+ CGFloat(max(0, clusters.count - 1)) * Self.groupGap
HStack(alignment: .top, spacing: DS.Space.s2) {
VStack(alignment: .leading, spacing: Self.groupGap) {
ForEach(clusters) { cluster in
// 类型名后面带上核心数,与“各核心占用”里的分组标题一致
Text(verbatim: "\(cluster.name) · \(cluster.coreIndices.count)")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.frame(height: CGFloat(cluster.coreIndices.count) * (rowHeight + Self.gap), alignment: .center)
}
}
.fixedSize()
Canvas { context, size in
let recent = Array(history.suffix(columns))
let gridWidth = size.width - Self.nowWidth - Self.nowGap
let cellWidth = (gridWidth - Self.gap * CGFloat(columns - 1)) / CGFloat(columns)
let offset = columns - recent.count
let latest = recent.last
var y: CGFloat = 0
for cluster in clusters {
for core in cluster.coreIndices.reversed() {
for column in 0..<columns {
let rect = CGRect(x: CGFloat(column) * (cellWidth + Self.gap), y: y, width: cellWidth, height: rowHeight)
let value = column >= offset ? recent[column - offset][safe: core] : nil
context.fill(Path(rect), with: .color(Self.color(value)))
}
let now = CGRect(x: size.width - Self.nowWidth, y: y, width: Self.nowWidth, height: rowHeight)
context.fill(Path(roundedRect: now, cornerRadius: DS.Radius.sm / 2), with: .color(Self.color(latest?[safe: core])))
y += rowHeight + Self.gap
}
y += Self.groupGap
}
}
.frame(height: height)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(tr("核心热力图"))
}
static func color(_ value: Double?) -> Color {
guard let value else { return DS.Palette.track }
if value >= 0.85 { return DS.Palette.warning }
return DS.Palette.primary.opacity(0.08 + 0.92 * min(1, max(0, value)))
}
}
/// 热力图图例:低 → 高
struct HeatLegend: View {
var body: some View {
HStack(spacing: DS.Space.s1) {
Text(tr("闲")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
ForEach([0.05, 0.3, 0.55, 0.8, 0.9], id: \.self) { value in
RoundedRectangle(cornerRadius: DS.Radius.sm / 2)
.fill(CoreHeatmap.color(value))
.frame(width: DS.Space.s2, height: DS.Space.s2)
}
Text(tr("忙")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
/// 压力走势条:每次采样一段,绿色正常、橙色偏高、红色严重
struct PressureStrip: View {
let history: [MemoryPressure]
var capacity = MetricsStore.historyCapacity
var body: some View {
Canvas { context, size in
let width = size.width / CGFloat(capacity)
let offset = capacity - min(capacity, history.count)
let recent = history.suffix(capacity)
context.clip(to: Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: size.height / 2))
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(DS.Palette.track))
for (index, pressure) in recent.enumerated() {
let color: Color = switch pressure {
case .normal: DS.Palette.success
case .warning: DS.Palette.warning
case .critical: DS.Palette.error
}
// 多画 1pt 盖住相邻段之间的缝
let rect = CGRect(x: CGFloat(offset + index) * width, y: 0, width: width + DS.Size.stroke, height: size.height)
context.fill(Path(rect), with: .color(color))
}
}
.frame(height: DS.Space.s2)
.accessibilityElement()
.accessibilityLabel(tr("最近的内存压力走势"))
}
}
/// 横向水位条:各段按大小依次排开,段与段之间留 1pt 缝
struct WaterlineBar: View {
let segments: [(value: Double, color: Color)]
let total: Double
var height: CGFloat = DS.Space.s4 + DS.Space.s1
var body: some View {
Canvas { context, size in
context.clip(to: Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: DS.Radius.sm))
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(DS.Palette.track))
var x: CGFloat = 0
for segment in segments where segment.value > 0 {
let width = size.width * CGFloat(segment.value / max(total, 1))
context.fill(Path(CGRect(x: x, y: 0, width: max(0, width - DS.Size.stroke), height: size.height)),
with: .color(segment.color))
x += width
}
}
.frame(height: height)
.accessibilityHidden(true)
}
}
extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
/// 上下镜像的流量图:上半部分上传、下半部分下载,各自按自己的峰值缩放
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(tr("连接探测历史"))
}
}
// MARK: - 评分色带
/// 0–100 分的六段色带:F、D、C、B、A、A+ 各占自己的分数区间,段内写等级,下方标刻度,
/// 得分处有一个带当前等级的小标记
struct ScoreBand: View {
let score: Int
var height: CGFloat = DS.Size.segmentHeight
private static let markerHeight: CGFloat = DS.Size.iconStandalone + DS.Space.s1
private static let tickHeight: CGFloat = DS.Space.s3
var body: some View {
let bands = DS.Grade.bands
let clamped = min(100, max(0, score))
GeometryReader { proxy in
let width = proxy.size.width
ZStack(alignment: .topLeading) {
VStack(spacing: DS.Space.s1 / 2) {
HStack(spacing: 1) {
ForEach(Array(bands.enumerated()), id: \.offset) { index, band in
let lower = index == 0 ? 0 : bands[index - 1].upper
let segmentWidth = width * CGFloat(band.upper - lower) / 100
ZStack {
Rectangle().fill(band.color)
if segmentWidth >= DS.Space.s6 {
Text(verbatim: band.grade)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.onPrimary)
}
}
.frame(width: max(0, segmentWidth - (index == bands.count - 1 ? 0 : 1)))
}
}
.frame(height: height)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm))
ZStack(alignment: .topLeading) {
// A 与 A+ 的分界太密,刻度上不标 95;弹窗那么窄时 85 也挤不下
ForEach([0] + bands.map(\.upper).filter { $0 != 95 && ($0 != 85 || width >= DS.Size.panelWidth / 2) }, id: \.self) { tick in
Text(verbatim: "\(tick)")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
.fixedSize()
.alignmentGuide(.leading) { dimensions in
// 0 靠左、100 靠右,其余居中对齐刻度
tick == 0 ? 0 : tick == 100 ? dimensions.width - width : dimensions.width / 2 - width * CGFloat(tick) / 100
}
}
}
.frame(width: width, height: Self.tickHeight, alignment: .topLeading)
}
.padding(.top, Self.markerHeight)
// 得分标记:气泡里写等级,尖角指向色带
let color = DS.Grade.color(for: clamped)
VStack(spacing: 0) {
Text(verbatim: DS.Grade.bands.first { clamped < $0.upper }?.grade ?? "A+")
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.onPrimary)
.padding(.horizontal, DS.Space.s1)
.frame(height: DS.Size.iconInline)
.background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
Triangle().fill(color).frame(width: DS.Space.s2, height: DS.Space.s1)
}
.fixedSize()
.alignmentGuide(.leading) { dimensions in dimensions.width / 2 - width * CGFloat(clamped) / 100 }
}
}
.frame(height: height + Self.markerHeight + Self.tickHeight + DS.Space.s1 / 2)
.accessibilityLabel(tr("纯净度 \(clamped) 分"))
}
}
private struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
path.closeSubpath()
return path
}
}