mirror of
https://github.com/carey314/mio-plugin-fund.git
synced 2026-08-10 05:44:31 +00:00
Real-time OTC fund and gold tracker for the macOS notch. Three tabs: - 持仓: live intraday estimates from 天天基金, hero card with total value / today P&L / cumulative P&L, per-row 当日 + 累计. - 黄金: SHFE 沪金 realtime + daily K-line + 伦敦金 reference. - 添加: search 26k+ public funds via 东方财富 suggest API. No API keys, no Python, no servers. All data comes from public endpoints (天天基金, 东方财富, 新浪财经). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
126 lines
4.4 KiB
Swift
126 lines
4.4 KiB
Swift
//
|
|
// SparkLine.swift
|
|
// 盯基金 plugin v0.2
|
|
//
|
|
// Pure-SwiftUI port of the design's <svg> sparkline for the gold
|
|
// chart. Handles:
|
|
// - smooth quadratic curve through points
|
|
// - filled area under the curve with a vertical gradient
|
|
// - pulsing "current point" indicator
|
|
// - dashed midline grid
|
|
// - empty / 1-point degenerate cases
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct SparkLine: View {
|
|
let values: [Double]
|
|
let lineColor: Color
|
|
let fillColor: Color
|
|
let pulseColor: Color
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let W = geo.size.width
|
|
let H = geo.size.height
|
|
let padX: CGFloat = 8
|
|
let padY: CGFloat = 14
|
|
|
|
ZStack {
|
|
// dashed midline
|
|
Path { p in
|
|
p.move(to: CGPoint(x: 0, y: H / 2))
|
|
p.addLine(to: CGPoint(x: W, y: H / 2))
|
|
}
|
|
.stroke(
|
|
Color.white.opacity(0.04),
|
|
style: StrokeStyle(lineWidth: 0.5, lineCap: .round, dash: [2, 4])
|
|
)
|
|
|
|
// bail when not enough data
|
|
if values.count >= 2,
|
|
let maxV = values.max(),
|
|
let minV = values.min(),
|
|
maxV != minV {
|
|
|
|
let xs: (Int) -> CGFloat = { i in
|
|
padX + (CGFloat(i) / CGFloat(values.count - 1)) * (W - padX * 2)
|
|
}
|
|
let ys: (Double) -> CGFloat = { v in
|
|
let t = (v - minV) / (maxV - minV)
|
|
return padY + (1 - CGFloat(t)) * (H - padY * 2)
|
|
}
|
|
|
|
// area fill
|
|
Path { p in
|
|
p.move(to: CGPoint(x: xs(0), y: ys(values[0])))
|
|
for i in 1..<values.count {
|
|
p.addLine(to: CGPoint(x: xs(i), y: ys(values[i])))
|
|
}
|
|
p.addLine(to: CGPoint(x: xs(values.count - 1), y: H))
|
|
p.addLine(to: CGPoint(x: xs(0), y: H))
|
|
p.closeSubpath()
|
|
}
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [fillColor.opacity(0.45), fillColor.opacity(0)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
|
|
// line
|
|
Path { p in
|
|
p.move(to: CGPoint(x: xs(0), y: ys(values[0])))
|
|
for i in 1..<values.count {
|
|
p.addLine(to: CGPoint(x: xs(i), y: ys(values[i])))
|
|
}
|
|
}
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: [lineColor.opacity(0.9), lineColor],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
),
|
|
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)
|
|
)
|
|
|
|
// current point dot + pulsing halo
|
|
let lastX = xs(values.count - 1)
|
|
let lastY = ys(values[values.count - 1])
|
|
Circle()
|
|
.fill(pulseColor)
|
|
.frame(width: 7, height: 7)
|
|
.position(x: lastX, y: lastY)
|
|
PulsingRing(color: pulseColor)
|
|
.frame(width: 18, height: 18)
|
|
.position(x: lastX, y: lastY)
|
|
} else {
|
|
// degenerate: flat line in the middle
|
|
Path { p in
|
|
p.move(to: CGPoint(x: padX, y: H / 2))
|
|
p.addLine(to: CGPoint(x: W - padX, y: H / 2))
|
|
}
|
|
.stroke(lineColor.opacity(0.5), style: StrokeStyle(lineWidth: 1.2))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PulsingRing: View {
|
|
let color: Color
|
|
@State private var pulse = false
|
|
|
|
var body: some View {
|
|
Circle()
|
|
.stroke(color.opacity(pulse ? 0 : 0.6), lineWidth: 1.2)
|
|
.scaleEffect(pulse ? 1.4 : 0.6)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 2.0).repeatForever(autoreverses: false)) {
|
|
pulse = true
|
|
}
|
|
}
|
|
}
|
|
}
|