From af839d0235d29ce1276932fa6303b09a9c679f92 Mon Sep 17 00:00:00 2001 From: zilin Date: Wed, 8 Apr 2026 19:09:00 +0800 Subject: [PATCH 1/6] feat: add native LivelineCanvas Swift package for iOS --- Package.swift | 27 + README.md | 600 +++++++++--------- Sources/LivelineCanvas/Interpolation.swift | 44 ++ .../LivelineCanvas/LivelineCanvasView.swift | 160 +++++ Sources/LivelineCanvas/Models.swift | 88 +++ Sources/LivelineCanvas/Palette.swift | 46 ++ Sources/LivelineCanvas/Renderer.swift | 221 +++++++ Sources/LivelineCanvas/SwiftUIBridge.swift | 51 ++ .../LivelineCanvasTests.swift | 21 + 9 files changed, 968 insertions(+), 290 deletions(-) create mode 100644 Package.swift create mode 100644 Sources/LivelineCanvas/Interpolation.swift create mode 100644 Sources/LivelineCanvas/LivelineCanvasView.swift create mode 100644 Sources/LivelineCanvas/Models.swift create mode 100644 Sources/LivelineCanvas/Palette.swift create mode 100644 Sources/LivelineCanvas/Renderer.swift create mode 100644 Sources/LivelineCanvas/SwiftUIBridge.swift create mode 100644 Tests/LivelineCanvasTests/LivelineCanvasTests.swift diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5f82f5a --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "LivelineCanvas", + platforms: [ + .iOS(.v15), + .macCatalyst(.v15) + ], + products: [ + .library( + name: "LivelineCanvas", + targets: ["LivelineCanvas"] + ) + ], + targets: [ + .target( + name: "LivelineCanvas", + path: "Sources/LivelineCanvas" + ), + .testTarget( + name: "LivelineCanvasTests", + dependencies: ["LivelineCanvas"], + path: "Tests/LivelineCanvasTests" + ) + ] +) diff --git a/README.md b/README.md index 263de3a..97c7bd0 100644 --- a/README.md +++ b/README.md @@ -1,290 +1,310 @@ -# Liveline - -Real-time animated charts for React. Line, multi-series, and candlestick modes, canvas-rendered, 60fps, zero CSS imports. - -## Install - -```bash -pnpm add liveline -``` - -Peer dependency: `react >=18`. - -## Quick Start - -```tsx -import { Liveline } from 'liveline' -import type { LivelinePoint } from 'liveline' - -function Chart() { - const [data, setData] = useState([]) - const [value, setValue] = useState(0) - - // Feed data from WebSocket, polling, etc. - // Each point: { time: unixSeconds, value: number } - - return ( -
- -
- ) -} -``` - -The component fills its parent container. Set a height on the parent. Pass `data` as a growing array of points and `value` as the latest number — Liveline handles smooth interpolation between updates. - -## Props - -**Data** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `data` | `LivelinePoint[]` | required | Array of `{ time, value }` points | -| `value` | `number` | required | Latest value (smoothly interpolated) | - -**Appearance** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `theme` | `'light' \| 'dark'` | `'dark'` | Color scheme | -| `color` | `string` | `'#3b82f6'` | Accent color — all palette colors derived from this | -| `grid` | `boolean` | `true` | Y-axis grid lines + labels | -| `badge` | `boolean` | `true` | Value pill tracking chart tip | -| `badgeVariant` | `'default' \| 'minimal'` | `'default'` | Badge style: accent-colored or white with grey text | -| `badgeTail` | `boolean` | `true` | Pointed tail on badge pill | -| `fill` | `boolean` | `true` | Gradient under the curve | -| `pulse` | `boolean` | `true` | Pulsing ring on live dot | -| `lineWidth` | `number` | `2` | Stroke width of the main line in pixels | - -**Features** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `momentum` | `boolean \| Momentum` | `true` | Dot glow + arrows. `true` = auto-detect, or `'up' \| 'down' \| 'flat'` | -| `scrub` | `boolean` | `true` | Crosshair scrubbing on hover | -| `exaggerate` | `boolean` | `false` | Tight Y-axis — small moves fill chart height | -| `showValue` | `boolean` | `false` | Large live value overlay (60fps DOM update, no re-renders) | -| `valueMomentumColor` | `boolean` | `false` | Color the value text green/red by momentum | -| `degen` | `boolean \| DegenOptions` | `false` | Burst particles + chart shake on momentum swings | - -**Candlestick** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `mode` | `'line' \| 'candle'` | `'line'` | Chart type | -| `candles` | `CandlePoint[]` | — | OHLC candle data `{ time, open, high, low, close }` | -| `candleWidth` | `number` | — | Seconds per candle | -| `liveCandle` | `CandlePoint` | — | Current in-progress candle with real-time OHLC | -| `lineMode` | `boolean` | `false` | Morph candles into a line display | -| `lineData` | `LivelinePoint[]` | — | Tick-level data for line mode density | -| `lineValue` | `number` | — | Current tick value for line mode | -| `onModeChange` | `(mode) => void` | — | Callback for built-in line/candle toggle | - -When `mode="candle"`, pass `candles` (committed OHLC bars) and `liveCandle` (the current bar, updated every tick). `candleWidth` sets the time bucket in seconds. The `lineMode` prop smoothly morphs between candle and line views — candle bodies collapse to close price, then the line extends outward. Provide `lineData` and `lineValue` (tick-level resolution) for a smooth density transition during the morph. - -The `onModeChange` prop renders a built-in line/candle toggle next to the time window buttons. - -**Multi-series** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `series` | `LivelineSeries[]` | — | Multiple overlapping lines `{ id, data, value, color, label? }` | -| `onSeriesToggle` | `(id, visible) => void` | — | Callback when a series is toggled via built-in chips | -| `seriesToggleCompact` | `boolean` | `false` | Show only colored dots in toggle (no text labels) | - -Pass `series` instead of `data`/`value` to draw multiple lines sharing the same axes. Each series gets its own color, label, and endpoint dot. Toggle chips appear automatically when there are 2+ series — clicking one hides/shows that line with a smooth fade. The Y-axis range adjusts when series are hidden. Badge, momentum arrows, and fill are disabled in multi-series mode. - -**State** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `loading` | `boolean` | `false` | Breathing line animation — use while waiting for data | -| `paused` | `boolean` | `false` | Smoothly freeze chart scrolling; resume catches up to real time | -| `emptyText` | `string` | `'No data to display'` | Text shown in the empty state | - -When `loading` flips to `false` with data present, the loading line morphs into the actual chart shape. In line mode, the fill, grid, and badge animate in. In candle mode, flat lines expand into full OHLC bodies while the morph line fades out. When `data` is empty and `loading` is `false`, a minimal "No data" empty state is shown. - -**Time** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `window` | `number` | `30` | Visible time window in seconds | -| `windows` | `WindowOption[]` | — | Time horizon buttons `[{ label, secs }]` | -| `onWindowChange` | `(secs) => void` | — | Called when a window button is clicked | -| `windowStyle` | `'default' \| 'rounded' \| 'text'` | `'default'` | Window button visual style | - -**Crosshair** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `tooltipY` | `number` | `14` | Vertical offset for crosshair tooltip text | -| `tooltipOutline` | `boolean` | `true` | Stroke outline on tooltip text for readability | - -**Orderbook** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `orderbook` | `OrderbookData` | — | Bid/ask depth stream `{ bids, asks }` | - -**Advanced** - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `referenceLine` | `ReferenceLine` | — | Horizontal reference line `{ value, label? }` | -| `formatValue` | `(v: number) => string` | `v.toFixed(2)` | Value label formatter | -| `formatTime` | `(t: number) => string` | `HH:MM:SS` | Time axis formatter | -| `lerpSpeed` | `number` | `0.08` | Interpolation speed (0–1) | -| `padding` | `Padding` | `{ top: 12, right: auto, bottom: 28, left: 12 }` | Chart padding override (`right` is 80/54/12 based on badge/grid) | -| `onHover` | `(point \| null) => void` | — | Hover callback with `{ time, value, x, y }` | -| `cursor` | `string` | `'crosshair'` | CSS cursor on canvas hover | -| `className` | `string` | — | Container class | -| `style` | `CSSProperties` | — | Container styles | - -## Examples - -### Basic (line + badge) - -```tsx - -``` - -### Candlestick (minimal) - -```tsx - `$${v.toLocaleString('en-US', { minimumFractionDigits: 2 })}`} -/> -``` - -### Candlestick (line mode toggle + time windows) - -```tsx - setShowLine(mode === 'line')} - color="#f7931a" - formatValue={(v) => `$${v.toLocaleString('en-US', { minimumFractionDigits: 2 })}`} - windows={[ - { label: '5m', secs: 300 }, - { label: '15m', secs: 900 }, - { label: '1h', secs: 3600 }, - ]} -/> -``` - -### Crypto-style (momentum + degen + exaggerate) - -```tsx - `$${v.toLocaleString('en-US', { minimumFractionDigits: 2 })}`} -/> -``` - -### Dashboard (showValue + windows + no badge) - -```tsx - console.log('window:', secs)} -/> -``` - -### Multi-series (prediction market) - -```tsx - v.toFixed(1) + '%'} - onSeriesToggle={(id, visible) => console.log(id, visible)} - windows={[ - { label: '10s', secs: 10 }, - { label: '30s', secs: 30 }, - { label: '1m', secs: 60 }, - ]} -/> -``` - -### Loading + pause - -```tsx - -``` - -### Orderbook (orderbook data + particles) - -```tsx - -``` - -## How It Works - -- **Canvas rendering** — single `` element, no DOM nodes per data point -- **requestAnimationFrame** loop pauses when the tab is hidden -- **Fritsch-Carlson monotone splines** for smooth curves — no overshoots beyond local min/max -- **Frame-rate-independent lerp** on value, Y-axis range, badge color, and scrub opacity -- **Candlestick rendering** — OHLC bodies + wicks with bull/bear coloring, smooth live candle updates -- **Line/candle morph** — candle bodies collapse to close price, morph line extends center-out, coordinated alpha crossfade -- **Multi-series** — overlapping lines with per-series toggle, smooth alpha fade, and dynamic Y-axis range -- **ResizeObserver** tracks container size — no per-frame layout reads -- **Theme derivation** — full palette from one accent color + light/dark mode -- **Binary search interpolation** for hover value lookup - -No CSS imports. No external dependencies beyond React. - -## License - -© 2026 Benji Taylor - -Licensed under MIT +# Liveline + +> ✅ This repository now also ships a native **Swift Package**: `LivelineCanvas` (iOS/macCatalyst). +> +> - Swift sources: `Sources/LivelineCanvas` +> - Package manifest: `Package.swift` +> - Focus: Canvas-equivalent rendering core (line/candle/grid/crosshair/reference/loading), without React glue layer. + +Real-time animated charts for React. Line, multi-series, and candlestick modes, canvas-rendered, 60fps, zero CSS imports. + +## Install + +```bash +pnpm add liveline +``` + +Peer dependency: `react >=18`. + +## Quick Start + +```tsx +import { Liveline } from 'liveline' +import type { LivelinePoint } from 'liveline' + +function Chart() { + const [data, setData] = useState([]) + const [value, setValue] = useState(0) + + // Feed data from WebSocket, polling, etc. + // Each point: { time: unixSeconds, value: number } + + return ( +
+ +
+ ) +} +``` + +The component fills its parent container. Set a height on the parent. Pass `data` as a growing array of points and `value` as the latest number — Liveline handles smooth interpolation between updates. + +## Props + +**Data** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `LivelinePoint[]` | required | Array of `{ time, value }` points | +| `value` | `number` | required | Latest value (smoothly interpolated) | + +**Appearance** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `theme` | `'light' \| 'dark'` | `'dark'` | Color scheme | +| `color` | `string` | `'#3b82f6'` | Accent color — all palette colors derived from this | +| `grid` | `boolean` | `true` | Y-axis grid lines + labels | +| `badge` | `boolean` | `true` | Value pill tracking chart tip | +| `badgeVariant` | `'default' \| 'minimal'` | `'default'` | Badge style: accent-colored or white with grey text | +| `badgeTail` | `boolean` | `true` | Pointed tail on badge pill | +| `fill` | `boolean` | `true` | Gradient under the curve | +| `pulse` | `boolean` | `true` | Pulsing ring on live dot | +| `lineWidth` | `number` | `2` | Stroke width of the main line in pixels | + +**Features** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `momentum` | `boolean \| Momentum` | `true` | Dot glow + arrows. `true` = auto-detect, or `'up' \| 'down' \| 'flat'` | +| `scrub` | `boolean` | `true` | Crosshair scrubbing on hover | +| `exaggerate` | `boolean` | `false` | Tight Y-axis — small moves fill chart height | +| `showValue` | `boolean` | `false` | Large live value overlay (60fps DOM update, no re-renders) | +| `valueMomentumColor` | `boolean` | `false` | Color the value text green/red by momentum | +| `degen` | `boolean \| DegenOptions` | `false` | Burst particles + chart shake on momentum swings | + +**Candlestick** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `mode` | `'line' \| 'candle'` | `'line'` | Chart type | +| `candles` | `CandlePoint[]` | — | OHLC candle data `{ time, open, high, low, close }` | +| `candleWidth` | `number` | — | Seconds per candle | +| `liveCandle` | `CandlePoint` | — | Current in-progress candle with real-time OHLC | +| `lineMode` | `boolean` | `false` | Morph candles into a line display | +| `lineData` | `LivelinePoint[]` | — | Tick-level data for line mode density | +| `lineValue` | `number` | — | Current tick value for line mode | +| `onModeChange` | `(mode) => void` | — | Callback for built-in line/candle toggle | + +When `mode="candle"`, pass `candles` (committed OHLC bars) and `liveCandle` (the current bar, updated every tick). `candleWidth` sets the time bucket in seconds. The `lineMode` prop smoothly morphs between candle and line views — candle bodies collapse to close price, then the line extends outward. Provide `lineData` and `lineValue` (tick-level resolution) for a smooth density transition during the morph. + +The `onModeChange` prop renders a built-in line/candle toggle next to the time window buttons. + +**Multi-series** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `series` | `LivelineSeries[]` | — | Multiple overlapping lines `{ id, data, value, color, label? }` | +| `onSeriesToggle` | `(id, visible) => void` | — | Callback when a series is toggled via built-in chips | +| `seriesToggleCompact` | `boolean` | `false` | Show only colored dots in toggle (no text labels) | + +Pass `series` instead of `data`/`value` to draw multiple lines sharing the same axes. Each series gets its own color, label, and endpoint dot. Toggle chips appear automatically when there are 2+ series — clicking one hides/shows that line with a smooth fade. The Y-axis range adjusts when series are hidden. Badge, momentum arrows, and fill are disabled in multi-series mode. + +**State** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `loading` | `boolean` | `false` | Breathing line animation — use while waiting for data | +| `paused` | `boolean` | `false` | Smoothly freeze chart scrolling; resume catches up to real time | +| `emptyText` | `string` | `'No data to display'` | Text shown in the empty state | + +When `loading` flips to `false` with data present, the loading line morphs into the actual chart shape. In line mode, the fill, grid, and badge animate in. In candle mode, flat lines expand into full OHLC bodies while the morph line fades out. When `data` is empty and `loading` is `false`, a minimal "No data" empty state is shown. + +**Time** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `window` | `number` | `30` | Visible time window in seconds | +| `windows` | `WindowOption[]` | — | Time horizon buttons `[{ label, secs }]` | +| `onWindowChange` | `(secs) => void` | — | Called when a window button is clicked | +| `windowStyle` | `'default' \| 'rounded' \| 'text'` | `'default'` | Window button visual style | + +**Crosshair** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `tooltipY` | `number` | `14` | Vertical offset for crosshair tooltip text | +| `tooltipOutline` | `boolean` | `true` | Stroke outline on tooltip text for readability | + +**Orderbook** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `orderbook` | `OrderbookData` | — | Bid/ask depth stream `{ bids, asks }` | + +**Advanced** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `referenceLine` | `ReferenceLine` | — | Horizontal reference line `{ value, label? }` | +| `formatValue` | `(v: number) => string` | `v.toFixed(2)` | Value label formatter | +| `formatTime` | `(t: number) => string` | `HH:MM:SS` | Time axis formatter | +| `lerpSpeed` | `number` | `0.08` | Interpolation speed (0–1) | +| `padding` | `Padding` | `{ top: 12, right: auto, bottom: 28, left: 12 }` | Chart padding override (`right` is 80/54/12 based on badge/grid) | +| `onHover` | `(point \| null) => void` | — | Hover callback with `{ time, value, x, y }` | +| `cursor` | `string` | `'crosshair'` | CSS cursor on canvas hover | +| `className` | `string` | — | Container class | +| `style` | `CSSProperties` | — | Container styles | + +## Examples + +### Basic (line + badge) + +```tsx + +``` + +### Candlestick (minimal) + +```tsx + `$${v.toLocaleString('en-US', { minimumFractionDigits: 2 })}`} +/> +``` + +### Candlestick (line mode toggle + time windows) + +```tsx + setShowLine(mode === 'line')} + color="#f7931a" + formatValue={(v) => `$${v.toLocaleString('en-US', { minimumFractionDigits: 2 })}`} + windows={[ + { label: '5m', secs: 300 }, + { label: '15m', secs: 900 }, + { label: '1h', secs: 3600 }, + ]} +/> +``` + +### Crypto-style (momentum + degen + exaggerate) + +```tsx + `$${v.toLocaleString('en-US', { minimumFractionDigits: 2 })}`} +/> +``` + +### Dashboard (showValue + windows + no badge) + +```tsx + console.log('window:', secs)} +/> +``` + +### Multi-series (prediction market) + +```tsx + v.toFixed(1) + '%'} + onSeriesToggle={(id, visible) => console.log(id, visible)} + windows={[ + { label: '10s', secs: 10 }, + { label: '30s', secs: 30 }, + { label: '1m', secs: 60 }, + ]} +/> +``` + +### Loading + pause + +```tsx + +``` + +### Orderbook (orderbook data + particles) + +```tsx + +``` + +## How It Works + +- **Canvas rendering** — single `` element, no DOM nodes per data point +- **requestAnimationFrame** loop pauses when the tab is hidden +- **Fritsch-Carlson monotone splines** for smooth curves — no overshoots beyond local min/max +- **Frame-rate-independent lerp** on value, Y-axis range, badge color, and scrub opacity +- **Candlestick rendering** — OHLC bodies + wicks with bull/bear coloring, smooth live candle updates +- **Line/candle morph** — candle bodies collapse to close price, morph line extends center-out, coordinated alpha crossfade +- **Multi-series** — overlapping lines with per-series toggle, smooth alpha fade, and dynamic Y-axis range +- **ResizeObserver** tracks container size — no per-frame layout reads +- **Theme derivation** — full palette from one accent color + light/dark mode +- **Binary search interpolation** for hover value lookup + +No CSS imports. No external dependencies beyond React. + +## License + +© 2026 Benji Taylor + +Licensed under MIT + +## Swift Package (iOS) + +```swift +import LivelineCanvas + +let chart = LivelineCanvasView(frame: .zero) +chart.config = LivelineConfig(mode: .line, windowSeconds: 300) +chart.points = streamPoints +chart.liveValue = streamPoints.last?.value ?? 0 +chart.referenceLine = LivelineReferenceLine(value: 100) +``` + +For SwiftUI, use `LivelineChart(...)` from the same package. diff --git a/Sources/LivelineCanvas/Interpolation.swift b/Sources/LivelineCanvas/Interpolation.swift new file mode 100644 index 0000000..d8127e3 --- /dev/null +++ b/Sources/LivelineCanvas/Interpolation.swift @@ -0,0 +1,44 @@ +import Foundation + +@inlinable +func lerp(_ current: Double, _ target: Double, speed: Double, dt: Double) -> Double { + let s = max(0, min(1, speed * dt * 60)) + return current + (target - current) * s +} + +@inlinable +func clamp(_ value: T, _ lo: T, _ hi: T) -> T { + min(max(value, lo), hi) +} + +func valueRange(points: [LivelinePoint], candles: [CandlePoint], reference: LivelineReferenceLine?) -> (min: Double, max: Double) { + var minV = Double.greatestFiniteMagnitude + var maxV = -Double.greatestFiniteMagnitude + + for p in points { + minV = min(minV, p.value) + maxV = max(maxV, p.value) + } + + for c in candles { + minV = min(minV, c.low) + maxV = max(maxV, c.high) + } + + if let reference { + minV = min(minV, reference.value) + maxV = max(maxV, reference.value) + } + + if !minV.isFinite || !maxV.isFinite { + return (0, 1) + } + + if abs(maxV - minV) < .ulpOfOne { + let epsilon = max(0.0001, abs(minV) * 0.01) + return (minV - epsilon, maxV + epsilon) + } + + let pad = (maxV - minV) * 0.05 + return (minV - pad, maxV + pad) +} diff --git a/Sources/LivelineCanvas/LivelineCanvasView.swift b/Sources/LivelineCanvas/LivelineCanvasView.swift new file mode 100644 index 0000000..42eef38 --- /dev/null +++ b/Sources/LivelineCanvas/LivelineCanvasView.swift @@ -0,0 +1,160 @@ +import Foundation + +#if canImport(UIKit) +import UIKit + +public final class LivelineCanvasView: UIView { + public var config: LivelineConfig { + didSet { setNeedsDisplay() } + } + + public var palette: LivelinePalette { + didSet { setNeedsDisplay() } + } + + public var points: [LivelinePoint] = [] { + didSet { setNeedsDisplay() } + } + + public var candles: [CandlePoint] = [] { + didSet { setNeedsDisplay() } + } + + public var liveValue: Double = 0 { + didSet { setNeedsDisplay() } + } + + public var referenceLine: LivelineReferenceLine? { + didSet { setNeedsDisplay() } + } + + public var isPaused: Bool = false { + didSet { setNeedsDisplay() } + } + + public var isLoading: Bool = false { + didSet { setNeedsDisplay() } + } + + public var onHover: ((LivelinePoint?) -> Void)? + + private var displayLink: CADisplayLink? + private var hoverX: CGFloat? + private var state = RenderState() + private var lastTimestamp: CFTimeInterval? + + public override init(frame: CGRect) { + self.config = LivelineConfig() + self.palette = .default + super.init(frame: frame) + commonInit() + } + + public required init?(coder: NSCoder) { + self.config = LivelineConfig() + self.palette = .default + super.init(coder: coder) + commonInit() + } + + deinit { + displayLink?.invalidate() + } + + private func commonInit() { + isOpaque = true + contentMode = .redraw + let pan = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))) + pan.maximumNumberOfTouches = 1 + addGestureRecognizer(pan) + + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(onLongPress(_:))) + longPress.minimumPressDuration = 0.2 + addGestureRecognizer(longPress) + + let link = CADisplayLink(target: self, selector: #selector(tick(_:))) + link.add(to: .main, forMode: .common) + displayLink = link + } + + public override func draw(_ rect: CGRect) { + guard let ctx = UIGraphicsGetCurrentContext() else { return } + let now = Date().timeIntervalSince1970 + let dt: Double + if let lastTimestamp { + dt = min(1 / 20, now - lastTimestamp) + } else { + dt = 1 / 60 + } + lastTimestamp = now + + LivelineRenderer.render( + ctx, + input: RenderInput( + rect: bounds, + points: points, + candles: candles, + value: liveValue, + config: config, + palette: palette, + referenceLine: referenceLine, + hoverX: hoverX, + isPaused: isPaused, + isLoading: isLoading, + now: now + ), + state: &state, + dt: dt + ) + } + + @objc private func tick(_ sender: CADisplayLink) { + setNeedsDisplay() + } + + @objc private func onPan(_ gesture: UIPanGestureRecognizer) { + let point = gesture.location(in: self) + hoverX = point.x + emitHover(atX: point.x) + if gesture.state == .ended || gesture.state == .cancelled { + hoverX = nil + onHover?(nil) + } + setNeedsDisplay() + } + + @objc private func onLongPress(_ gesture: UILongPressGestureRecognizer) { + let point = gesture.location(in: self) + switch gesture.state { + case .began, .changed: + hoverX = point.x + emitHover(atX: point.x) + case .ended, .cancelled, .failed: + hoverX = nil + onHover?(nil) + default: + break + } + setNeedsDisplay() + } + + private func emitHover(atX x: CGFloat) { + guard !points.isEmpty else { return } + let chartRect = bounds.inset(by: UIEdgeInsets( + top: CGFloat(config.insets.top), + left: CGFloat(config.insets.left), + bottom: CGFloat(config.insets.bottom), + right: CGFloat(config.insets.right) + )) + + let ratio = clamp((x - chartRect.minX) / max(1, chartRect.width), 0, 1) + guard let newest = points.last?.time else { return } + let oldest = newest - config.windowSeconds + let targetTime = oldest + TimeInterval(ratio) * config.windowSeconds + + let nearest = points.min { abs($0.time - targetTime) < abs($1.time - targetTime) } + onHover?(nearest) + } +} + +#endif diff --git a/Sources/LivelineCanvas/Models.swift b/Sources/LivelineCanvas/Models.swift new file mode 100644 index 0000000..71ce383 --- /dev/null +++ b/Sources/LivelineCanvas/Models.swift @@ -0,0 +1,88 @@ +import Foundation + +public struct LivelinePoint: Sendable, Equatable { + public let time: TimeInterval + public let value: Double + + public init(time: TimeInterval, value: Double) { + self.time = time + self.value = value + } +} + +public struct CandlePoint: Sendable, Equatable { + public let time: TimeInterval + public let open: Double + public let high: Double + public let low: Double + public let close: Double + + public init(time: TimeInterval, open: Double, high: Double, low: Double, close: Double) { + self.time = time + self.open = open + self.high = high + self.low = low + self.close = close + } +} + +public enum LivelineMode: Sendable { + case line + case candle +} + +public struct LivelineReferenceLine: Sendable, Equatable { + public let value: Double + + public init(value: Double) { + self.value = value + } +} + +public struct LivelineInsets: Sendable, Equatable { + public var top: Double + public var left: Double + public var bottom: Double + public var right: Double + + public init(top: Double = 12, left: Double = 12, bottom: Double = 12, right: Double = 12) { + self.top = top + self.left = left + self.bottom = bottom + self.right = right + } +} + +public struct LivelineConfig: Sendable { + public var mode: LivelineMode + public var windowSeconds: TimeInterval + public var lerpSpeed: Double + public var showGrid: Bool + public var showFill: Bool + public var showCrosshair: Bool + public var showValueLabel: Bool + public var candleWidthSeconds: TimeInterval + public var insets: LivelineInsets + + public init( + mode: LivelineMode = .line, + windowSeconds: TimeInterval = 300, + lerpSpeed: Double = 0.18, + showGrid: Bool = true, + showFill: Bool = true, + showCrosshair: Bool = true, + showValueLabel: Bool = true, + candleWidthSeconds: TimeInterval = 60, + insets: LivelineInsets = LivelineInsets() + ) { + self.mode = mode + self.windowSeconds = windowSeconds + self.lerpSpeed = lerpSpeed + self.showGrid = showGrid + self.showFill = showFill + self.showCrosshair = showCrosshair + self.showValueLabel = showValueLabel + self.candleWidthSeconds = candleWidthSeconds + self.insets = insets + } +} diff --git a/Sources/LivelineCanvas/Palette.swift b/Sources/LivelineCanvas/Palette.swift new file mode 100644 index 0000000..1f68fd1 --- /dev/null +++ b/Sources/LivelineCanvas/Palette.swift @@ -0,0 +1,46 @@ +import Foundation + +#if canImport(UIKit) +import UIKit + +public struct LivelinePalette: Sendable { + public var background: UIColor + public var line: UIColor + public var fill: UIColor + public var grid: UIColor + public var upCandle: UIColor + public var downCandle: UIColor + public var text: UIColor + public var crosshair: UIColor + public var reference: UIColor + + public init( + background: UIColor = .black, + line: UIColor = UIColor(red: 0.22, green: 0.72, blue: 0.95, alpha: 1), + fill: UIColor = UIColor(red: 0.22, green: 0.72, blue: 0.95, alpha: 0.18), + grid: UIColor = UIColor(white: 1, alpha: 0.08), + upCandle: UIColor = UIColor(red: 0.23, green: 0.82, blue: 0.38, alpha: 1), + downCandle: UIColor = UIColor(red: 0.95, green: 0.33, blue: 0.33, alpha: 1), + text: UIColor = UIColor(white: 1, alpha: 0.92), + crosshair: UIColor = UIColor(white: 1, alpha: 0.45), + reference: UIColor = UIColor(white: 1, alpha: 0.35) + ) { + self.background = background + self.line = line + self.fill = fill + self.grid = grid + self.upCandle = upCandle + self.downCandle = downCandle + self.text = text + self.crosshair = crosshair + self.reference = reference + } + + public static let `default` = LivelinePalette() +} +#else +public struct LivelinePalette: Sendable { + public init() {} + public static let `default` = LivelinePalette() +} +#endif diff --git a/Sources/LivelineCanvas/Renderer.swift b/Sources/LivelineCanvas/Renderer.swift new file mode 100644 index 0000000..346e604 --- /dev/null +++ b/Sources/LivelineCanvas/Renderer.swift @@ -0,0 +1,221 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +import CoreGraphics + +struct RenderState { + var displayValue: Double = 0 + var displayMin: Double = 0 + var displayMax: Double = 1 + var initialized = false +} + +struct RenderInput { + let rect: CGRect + let points: [LivelinePoint] + let candles: [CandlePoint] + let value: Double + let config: LivelineConfig + let palette: LivelinePalette + let referenceLine: LivelineReferenceLine? + let hoverX: CGFloat? + let isPaused: Bool + let isLoading: Bool + let now: TimeInterval +} + +enum LivelineRenderer { + static func render(_ ctx: CGContext, input: RenderInput, state: inout RenderState, dt: Double) { + let rect = input.rect + ctx.setFillColor(input.palette.background.cgColor) + ctx.fill(rect) + + guard !input.isLoading else { + drawLoading(in: ctx, rect: rect, palette: input.palette, t: input.now) + return + } + + let chartRect = rect.inset(by: UIEdgeInsets( + top: CGFloat(input.config.insets.top), + left: CGFloat(input.config.insets.left), + bottom: CGFloat(input.config.insets.bottom), + right: CGFloat(input.config.insets.right) + )) + + if input.config.showGrid { + drawGrid(in: ctx, rect: chartRect, color: input.palette.grid) + } + + let range = valueRange(points: input.points, candles: input.candles, reference: input.referenceLine) + if !state.initialized { + state.displayValue = input.value + state.displayMin = range.min + state.displayMax = range.max + state.initialized = true + } else if !input.isPaused { + state.displayValue = lerp(state.displayValue, input.value, speed: input.config.lerpSpeed, dt: dt) + state.displayMin = lerp(state.displayMin, range.min, speed: input.config.lerpSpeed, dt: dt) + state.displayMax = lerp(state.displayMax, range.max, speed: input.config.lerpSpeed, dt: dt) + } + + if let reference = input.referenceLine { + drawReferenceLine(in: ctx, chartRect: chartRect, min: state.displayMin, max: state.displayMax, value: reference.value, color: input.palette.reference) + } + + let visiblePoints = input.points.filter { input.now - $0.time <= input.config.windowSeconds } + let visibleCandles = input.candles.filter { input.now - $0.time <= input.config.windowSeconds } + + switch input.config.mode { + case .line: + drawLine(in: ctx, chartRect: chartRect, points: visiblePoints, min: state.displayMin, max: state.displayMax, color: input.palette.line, fill: input.config.showFill ? input.palette.fill : nil) + case .candle: + drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, min: state.displayMin, max: state.displayMax, palette: input.palette, window: input.config.windowSeconds) + } + + if let hoverX = input.hoverX, input.config.showCrosshair { + drawCrosshair(in: ctx, chartRect: chartRect, x: hoverX, color: input.palette.crosshair) + } + + if input.config.showValueLabel { + drawValueLabel(in: ctx, rect: rect, value: state.displayValue, color: input.palette.text) + } + } + + private static func xForTime(_ t: TimeInterval, rightEdge: TimeInterval, window: TimeInterval, chartRect: CGRect) -> CGFloat { + let ratio = CGFloat((t - (rightEdge - window)) / window) + return chartRect.minX + ratio * chartRect.width + } + + private static func yForValue(_ v: Double, min: Double, max: Double, chartRect: CGRect) -> CGFloat { + let ratio = CGFloat((v - min) / (max - min)) + return chartRect.maxY - ratio * chartRect.height + } + + private static func drawGrid(in ctx: CGContext, rect: CGRect, color: UIColor) { + ctx.saveGState() + ctx.setStrokeColor(color.cgColor) + ctx.setLineWidth(1) + let rows = 4 + let cols = 4 + for i in 0...rows { + let y = rect.minY + (CGFloat(i) / CGFloat(rows)) * rect.height + ctx.move(to: CGPoint(x: rect.minX, y: y)) + ctx.addLine(to: CGPoint(x: rect.maxX, y: y)) + } + for i in 0...cols { + let x = rect.minX + (CGFloat(i) / CGFloat(cols)) * rect.width + ctx.move(to: CGPoint(x: x, y: rect.minY)) + ctx.addLine(to: CGPoint(x: x, y: rect.maxY)) + } + ctx.strokePath() + ctx.restoreGState() + } + + private static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], min: Double, max: Double, color: UIColor, fill: UIColor?) { + guard points.count >= 2, let last = points.last else { return } + let rightEdge = last.time + let window = points.last!.time - points.first!.time + let effectiveWindow = max(window, 1) + + let path = UIBezierPath() + for (index, p) in points.enumerated() { + let x = xForTime(p.time, rightEdge: rightEdge, window: effectiveWindow, chartRect: chartRect) + let y = yForValue(p.value, min: min, max: max, chartRect: chartRect) + if index == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } + } + + if let fill { + let fillPath = path.copy() as! UIBezierPath + fillPath.addLine(to: CGPoint(x: chartRect.maxX, y: chartRect.maxY)) + fillPath.addLine(to: CGPoint(x: chartRect.minX, y: chartRect.maxY)) + fillPath.close() + ctx.setFillColor(fill.cgColor) + ctx.addPath(fillPath.cgPath) + ctx.fillPath() + } + + ctx.setStrokeColor(color.cgColor) + ctx.setLineWidth(2) + ctx.addPath(path.cgPath) + ctx.strokePath() + } + + private static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], min: Double, max: Double, palette: LivelinePalette, window: TimeInterval) { + guard candles.count > 0, let last = candles.last else { return } + let rightEdge = last.time + let candleWidth = max(3, chartRect.width / CGFloat(max(candles.count, 1)) * 0.7) + + for c in candles { + let x = xForTime(c.time, rightEdge: rightEdge, window: window, chartRect: chartRect) + let yOpen = yForValue(c.open, min: min, max: max, chartRect: chartRect) + let yClose = yForValue(c.close, min: min, max: max, chartRect: chartRect) + let yHigh = yForValue(c.high, min: min, max: max, chartRect: chartRect) + let yLow = yForValue(c.low, min: min, max: max, chartRect: chartRect) + + let isUp = c.close >= c.open + let color = isUp ? palette.upCandle : palette.downCandle + + ctx.setStrokeColor(color.cgColor) + ctx.setLineWidth(1) + ctx.move(to: CGPoint(x: x, y: yHigh)) + ctx.addLine(to: CGPoint(x: x, y: yLow)) + ctx.strokePath() + + let bodyTop = min(yOpen, yClose) + let bodyBottom = max(yOpen, yClose) + let bodyRect = CGRect(x: x - candleWidth / 2, y: bodyTop, width: candleWidth, height: max(1, bodyBottom - bodyTop)) + ctx.setFillColor(color.cgColor) + ctx.fill(bodyRect) + } + } + + private static func drawCrosshair(in ctx: CGContext, chartRect: CGRect, x: CGFloat, color: UIColor) { + let clampedX = clamp(x, chartRect.minX, chartRect.maxX) + ctx.saveGState() + ctx.setStrokeColor(color.cgColor) + ctx.setLineWidth(1) + ctx.move(to: CGPoint(x: clampedX, y: chartRect.minY)) + ctx.addLine(to: CGPoint(x: clampedX, y: chartRect.maxY)) + ctx.strokePath() + ctx.restoreGState() + } + + private static func drawReferenceLine(in ctx: CGContext, chartRect: CGRect, min: Double, max: Double, value: Double, color: UIColor) { + let y = yForValue(value, min: min, max: max, chartRect: chartRect) + ctx.saveGState() + ctx.setStrokeColor(color.cgColor) + ctx.setLineDash(phase: 0, lengths: [4, 4]) + ctx.move(to: CGPoint(x: chartRect.minX, y: y)) + ctx.addLine(to: CGPoint(x: chartRect.maxX, y: y)) + ctx.strokePath() + ctx.restoreGState() + } + + private static func drawValueLabel(in ctx: CGContext, rect: CGRect, value: Double, color: UIColor) { + let text = String(format: "%.2f", value) + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium), + .foregroundColor: color + ] + let attr = NSAttributedString(string: text, attributes: attrs) + let size = attr.size() + let textRect = CGRect(x: rect.maxX - size.width - 12, y: 8, width: size.width, height: size.height) + attr.draw(in: textRect) + } + + private static func drawLoading(in ctx: CGContext, rect: CGRect, palette: LivelinePalette, t: TimeInterval) { + let shimmerWidth = rect.width * 0.25 + let x = ((CGFloat(t).truncatingRemainder(dividingBy: 1.5) / 1.5) * (rect.width + shimmerWidth)) - shimmerWidth + let base = UIColor(white: 1, alpha: 0.08) + let pulse = UIColor(white: 1, alpha: 0.14) + + ctx.setFillColor(base.cgColor) + ctx.fill(rect) + ctx.setFillColor(pulse.cgColor) + ctx.fill(CGRect(x: x, y: 0, width: shimmerWidth, height: rect.height)) + + drawValueLabel(in: ctx, rect: rect, value: 0, color: palette.text.withAlphaComponent(0.5)) + } +} +#endif diff --git a/Sources/LivelineCanvas/SwiftUIBridge.swift b/Sources/LivelineCanvas/SwiftUIBridge.swift new file mode 100644 index 0000000..89ceaa6 --- /dev/null +++ b/Sources/LivelineCanvas/SwiftUIBridge.swift @@ -0,0 +1,51 @@ +import Foundation + +#if canImport(SwiftUI) && canImport(UIKit) +import SwiftUI + +public struct LivelineChart: UIViewRepresentable { + public var points: [LivelinePoint] + public var candles: [CandlePoint] + public var value: Double + public var config: LivelineConfig + public var palette: LivelinePalette + public var referenceLine: LivelineReferenceLine? + public var loading: Bool + public var paused: Bool + + public init( + points: [LivelinePoint], + candles: [CandlePoint] = [], + value: Double, + config: LivelineConfig = .init(), + palette: LivelinePalette = .default, + referenceLine: LivelineReferenceLine? = nil, + loading: Bool = false, + paused: Bool = false + ) { + self.points = points + self.candles = candles + self.value = value + self.config = config + self.palette = palette + self.referenceLine = referenceLine + self.loading = loading + self.paused = paused + } + + public func makeUIView(context: Context) -> LivelineCanvasView { + LivelineCanvasView(frame: .zero) + } + + public func updateUIView(_ uiView: LivelineCanvasView, context: Context) { + uiView.config = config + uiView.palette = palette + uiView.points = points + uiView.candles = candles + uiView.liveValue = value + uiView.referenceLine = referenceLine + uiView.isLoading = loading + uiView.isPaused = paused + } +} +#endif diff --git a/Tests/LivelineCanvasTests/LivelineCanvasTests.swift b/Tests/LivelineCanvasTests/LivelineCanvasTests.swift new file mode 100644 index 0000000..55284b6 --- /dev/null +++ b/Tests/LivelineCanvasTests/LivelineCanvasTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import LivelineCanvas + +final class LivelineCanvasTests: XCTestCase { + func testLerpMovesTowardTarget() { + let result = lerp(100, 110, speed: 0.2, dt: 1.0 / 60) + XCTAssertGreaterThan(result, 100) + XCTAssertLessThan(result, 110) + } + + func testValueRangeAddsPadding() { + let points = [ + LivelinePoint(time: 1, value: 100), + LivelinePoint(time: 2, value: 120) + ] + + let range = valueRange(points: points, candles: [], reference: nil) + XCTAssertLessThan(range.min, 100) + XCTAssertGreaterThan(range.max, 120) + } +} From 0444743dfabed33bbcc19841811b0e4650a40be5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 19:36:13 +0000 Subject: [PATCH 2/6] fix: make LivelineCanvas compile on iOS and close core parity gaps The package's UIKit-gated code had never been compiled: swift test on Linux/macOS skips everything inside #if canImport(UIKit), and Renderer.swift had min/max parameters shadowing the stdlib functions, a hard compile error on any platform that can import UIKit. Verified by typechecking against UIKit/CoreGraphics stub modules: the old renderer fails, this one builds. Fixes: - Rename shadowing min/max parameters (minValue/maxValue) - Break the CADisplayLink retain cycle with a weak proxy (the view could never deinit and the link ran forever); pause the link off-window - Scroll by config.windowSeconds with a now-based right edge instead of scaling to the data span; freeze the clock while paused Parity with the React renderer: - Fritsch-Carlson monotone spline (port of src/math/spline.ts), testable cross-platform - Live tip interpolation: last point + tip follow the lerped display value - Frame-rate-independent exponential lerp (port of src/math/lerp.ts) - Y-range margins from src/math/range.ts incl. exaggerate; visible-data only - Gradient fill, dashed current-value line, pulsing live dot - Grid value labels, time axis with nice intervals, crosshair tooltip with interpolated value + time (port of src/math/interpolate.ts) - Candle sizing from candleWidthSeconds, wick width, dashed close line - Loading squiggly with breathing alpha, empty state, formatValue, palette derivation from accent color + light/dark theme - Tests expanded to 11, all passing via swift test README now documents actual feature coverage instead of claiming multi-series support that does not exist in the Swift code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U1KrgkhnUq4LKpoY9LpstF --- .gitignore | 1 + README.md | 16 +- Sources/LivelineCanvas/Interpolation.swift | 82 ++- .../LivelineCanvas/LivelineCanvasView.swift | 47 +- Sources/LivelineCanvas/Models.swift | 39 +- Sources/LivelineCanvas/Palette.swift | 78 ++- Sources/LivelineCanvas/Renderer.swift | 501 ++++++++++++++---- Sources/LivelineCanvas/Spline.swift | 72 +++ .../LivelineCanvasTests.swift | 123 +++++ 9 files changed, 812 insertions(+), 147 deletions(-) create mode 100644 Sources/LivelineCanvas/Spline.swift diff --git a/.gitignore b/.gitignore index cc7994f..cdd8347 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ *.tsbuildinfo dev/showcase.* dev/multiline.* +.build/ diff --git a/README.md b/README.md index 97c7bd0..3b1e704 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ # Liveline -> ✅ This repository now also ships a native **Swift Package**: `LivelineCanvas` (iOS/macCatalyst). -> -> - Swift sources: `Sources/LivelineCanvas` -> - Package manifest: `Package.swift` -> - Focus: Canvas-equivalent rendering core (line/candle/grid/crosshair/reference/loading), without React glue layer. +> This repository also ships a native **Swift Package**: `LivelineCanvas` (iOS 15+/macCatalyst), a **partial port** of the canvas rendering core. See [Swift Package (iOS)](#swift-package-ios) for what is and isn't covered. Real-time animated charts for React. Line, multi-series, and candlestick modes, canvas-rendered, 60fps, zero CSS imports. @@ -297,6 +293,8 @@ Licensed under MIT ## Swift Package (iOS) +`LivelineCanvas` is a native UIKit/SwiftUI port of the rendering core (iOS 15+, macCatalyst). Sources live in `Sources/LivelineCanvas`, manifest in `Package.swift`. + ```swift import LivelineCanvas @@ -307,4 +305,10 @@ chart.liveValue = streamPoints.last?.value ?? 0 chart.referenceLine = LivelineReferenceLine(value: 100) ``` -For SwiftUI, use `LivelineChart(...)` from the same package. +For SwiftUI, use `LivelineChart(...)` from the same package. Palettes can be derived from an accent color like the web version: `LivelinePalette.derive(accent: .systemBlue, theme: .dark)`. + +### Feature coverage vs. the React component + +Implemented: line mode with Fritsch-Carlson monotone spline, gradient fill, live-tip value interpolation, live dot with pulse ring, dashed current-value line, window-based scrolling (`windowSeconds`), frame-rate-independent lerp of value and Y-range, Y-range from visible data with the same margins (incl. `exaggerate`), grid with value labels, time axis with nice intervals, crosshair with value/time tooltip via pan or long-press, candlestick mode with wicks and dashed close line, reference line, `formatValue`, loading squiggly with breathing alpha, empty state, pause (freezes the clock), render loop that stops when the view leaves the window. + +Not (yet) ported: multi-series, badge pill, momentum detection/arrows, degen particles + shake, orderbook labels, line/candle morph + live candle animation, window-change transition, loading→data reveal morph, pause catch-up animation, left-edge fade, built-in window/mode/series toggle controls (build these in SwiftUI/UIKit around the view). diff --git a/Sources/LivelineCanvas/Interpolation.swift b/Sources/LivelineCanvas/Interpolation.swift index d8127e3..7a4a203 100644 --- a/Sources/LivelineCanvas/Interpolation.swift +++ b/Sources/LivelineCanvas/Interpolation.swift @@ -1,44 +1,84 @@ import Foundation +/// Frame-rate-independent exponential lerp, ported from `src/math/lerp.ts`. +/// `speed` is the fraction of the remaining distance covered per 16.67ms +/// (60fps) frame, so behavior matches at any display refresh rate. @inlinable func lerp(_ current: Double, _ target: Double, speed: Double, dt: Double) -> Double { - let s = max(0, min(1, speed * dt * 60)) - return current + (target - current) * s + let clamped = Swift.max(0, Swift.min(1, speed)) + let factor = 1 - pow(1 - clamped, dt * 1000 / 16.67) + return current + (target - current) * factor } @inlinable func clamp(_ value: T, _ lo: T, _ hi: T) -> T { - min(max(value, lo), hi) + Swift.min(Swift.max(value, lo), hi) } -func valueRange(points: [LivelinePoint], candles: [CandlePoint], reference: LivelineReferenceLine?) -> (min: Double, max: Double) { - var minV = Double.greatestFiniteMagnitude - var maxV = -Double.greatestFiniteMagnitude +/// Visible Y range from data + live value + reference line, ported from +/// `src/math/range.ts` — 12% margin, or a centered minimum range when the +/// data is flat; `exaggerate` tightens both so small moves fill the chart. +func valueRange( + points: [LivelinePoint], + candles: [CandlePoint], + currentValue: Double? = nil, + reference: LivelineReferenceLine? = nil, + exaggerate: Bool = false +) -> (min: Double, max: Double) { + var targetMin = Double.infinity + var targetMax = -Double.infinity for p in points { - minV = min(minV, p.value) - maxV = max(maxV, p.value) + targetMin = Swift.min(targetMin, p.value) + targetMax = Swift.max(targetMax, p.value) } - for c in candles { - minV = min(minV, c.low) - maxV = max(maxV, c.high) + targetMin = Swift.min(targetMin, c.low) + targetMax = Swift.max(targetMax, c.high) + } + if let currentValue { + targetMin = Swift.min(targetMin, currentValue) + targetMax = Swift.max(targetMax, currentValue) } - if let reference { - minV = min(minV, reference.value) - maxV = max(maxV, reference.value) + targetMin = Swift.min(targetMin, reference.value) + targetMax = Swift.max(targetMax, reference.value) } - if !minV.isFinite || !maxV.isFinite { - return (0, 1) + guard targetMin.isFinite, targetMax.isFinite else { return (0, 1) } + + let rawRange = targetMax - targetMin + let marginFactor = exaggerate ? 0.01 : 0.12 + var minRange = rawRange * (exaggerate ? 0.02 : 0.1) + if minRange == 0 { minRange = exaggerate ? 0.04 : 0.4 } + + if rawRange < minRange { + let mid = (targetMin + targetMax) / 2 + return (mid - minRange / 2, mid + minRange / 2) } + let margin = rawRange * marginFactor + return (targetMin - margin, targetMax + margin) +} + +/// Value at `time` by binary search + linear interpolation between the two +/// neighboring points, ported from `src/math/interpolate.ts`. Assumes +/// `points` is sorted by time ascending. +func interpolatedValue(_ points: [LivelinePoint], at time: TimeInterval) -> Double? { + guard let first = points.first, let last = points.last else { return nil } + if time <= first.time { return first.value } + if time >= last.time { return last.value } - if abs(maxV - minV) < .ulpOfOne { - let epsilon = max(0.0001, abs(minV) * 0.01) - return (minV - epsilon, maxV + epsilon) + var lo = 0 + var hi = points.count - 1 + while hi - lo > 1 { + let mid = (lo + hi) / 2 + if points[mid].time <= time { lo = mid } else { hi = mid } } - let pad = (maxV - minV) * 0.05 - return (minV - pad, maxV + pad) + let a = points[lo] + let b = points[hi] + let span = b.time - a.time + guard span > 0 else { return a.value } + let t = (time - a.time) / span + return a.value + (b.value - a.value) * t } diff --git a/Sources/LivelineCanvas/LivelineCanvasView.swift b/Sources/LivelineCanvas/LivelineCanvasView.swift index 42eef38..4df1cd9 100644 --- a/Sources/LivelineCanvas/LivelineCanvasView.swift +++ b/Sources/LivelineCanvas/LivelineCanvasView.swift @@ -3,6 +3,21 @@ import Foundation #if canImport(UIKit) import UIKit +/// CADisplayLink retains its target — pointing it at the view directly would +/// keep the view alive forever (deinit never runs, the link never stops). +/// This weak proxy breaks the cycle. +private final class DisplayLinkProxy: NSObject { + weak var view: LivelineCanvasView? + + init(view: LivelineCanvasView) { + self.view = view + } + + @objc func tick(_ link: CADisplayLink) { + view?.displayLinkFired() + } +} + public final class LivelineCanvasView: UIView { public var config: LivelineConfig { didSet { setNeedsDisplay() } @@ -64,6 +79,7 @@ public final class LivelineCanvasView: UIView { private func commonInit() { isOpaque = true contentMode = .redraw + let pan = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))) pan.maximumNumberOfTouches = 1 addGestureRecognizer(pan) @@ -72,11 +88,26 @@ public final class LivelineCanvasView: UIView { longPress.minimumPressDuration = 0.2 addGestureRecognizer(longPress) - let link = CADisplayLink(target: self, selector: #selector(tick(_:))) + let link = CADisplayLink(target: DisplayLinkProxy(view: self), selector: #selector(DisplayLinkProxy.tick(_:))) link.add(to: .main, forMode: .common) displayLink = link } + /// Stop the render loop while detached from a window — the equivalent of + /// the web version pausing requestAnimationFrame on a hidden tab. + public override func didMoveToWindow() { + super.didMoveToWindow() + displayLink?.isPaused = window == nil + if window != nil { + lastTimestamp = nil + setNeedsDisplay() + } + } + + fileprivate func displayLinkFired() { + setNeedsDisplay() + } + public override func draw(_ rect: CGRect) { guard let ctx = UIGraphicsGetCurrentContext() else { return } let now = Date().timeIntervalSince1970 @@ -108,10 +139,6 @@ public final class LivelineCanvasView: UIView { ) } - @objc private func tick(_ sender: CADisplayLink) { - setNeedsDisplay() - } - @objc private func onPan(_ gesture: UIPanGestureRecognizer) { let point = gesture.location(in: self) hoverX = point.x @@ -146,11 +173,13 @@ public final class LivelineCanvasView: UIView { bottom: CGFloat(config.insets.bottom), right: CGFloat(config.insets.right) )) + guard chartRect.width > 0 else { return } - let ratio = clamp((x - chartRect.minX) / max(1, chartRect.width), 0, 1) - guard let newest = points.last?.time else { return } - let oldest = newest - config.windowSeconds - let targetTime = oldest + TimeInterval(ratio) * config.windowSeconds + // Same time mapping as the renderer: the right edge is "now" + // (frozen while paused), not the newest data point. + let now = state.pausedAt ?? Date().timeIntervalSince1970 + let ratio = clamp((x - chartRect.minX) / chartRect.width, 0, 1) + let targetTime = (now - config.windowSeconds) + TimeInterval(ratio) * config.windowSeconds let nearest = points.min { abs($0.time - targetTime) < abs($1.time - targetTime) } onHover?(nearest) diff --git a/Sources/LivelineCanvas/Models.swift b/Sources/LivelineCanvas/Models.swift index 71ce383..afa461a 100644 --- a/Sources/LivelineCanvas/Models.swift +++ b/Sources/LivelineCanvas/Models.swift @@ -45,7 +45,9 @@ public struct LivelineInsets: Sendable, Equatable { public var bottom: Double public var right: Double - public init(top: Double = 12, left: Double = 12, bottom: Double = 12, right: Double = 12) { + /// Defaults match the React component's padding: top 12, left 12, + /// bottom 28 (time axis room), right 54 (grid label room). + public init(top: Double = 12, left: Double = 12, bottom: Double = 28, right: Double = 54) { self.top = top self.left = left self.bottom = bottom @@ -56,24 +58,47 @@ public struct LivelineInsets: Sendable, Equatable { public struct LivelineConfig: Sendable { public var mode: LivelineMode public var windowSeconds: TimeInterval + /// Fraction of the remaining distance covered per 60fps frame, + /// same semantics and default as the React `lerpSpeed` prop. public var lerpSpeed: Double public var showGrid: Bool public var showFill: Bool public var showCrosshair: Bool public var showValueLabel: Bool + /// Dashed horizontal line at the current value. + public var showDashLine: Bool + /// Live dot at the chart tip. + public var showDot: Bool + /// Expanding pulse ring on the live dot. + public var pulse: Bool + /// Tight Y-range so small moves fill the chart height. + public var exaggerate: Bool + public var lineWidth: Double public var candleWidthSeconds: TimeInterval public var insets: LivelineInsets + /// Vertical offset for the crosshair tooltip text. + public var tooltipY: Double + public var emptyText: String + public var formatValue: @Sendable (Double) -> String public init( mode: LivelineMode = .line, windowSeconds: TimeInterval = 300, - lerpSpeed: Double = 0.18, + lerpSpeed: Double = 0.08, showGrid: Bool = true, showFill: Bool = true, showCrosshair: Bool = true, showValueLabel: Bool = true, + showDashLine: Bool = true, + showDot: Bool = true, + pulse: Bool = true, + exaggerate: Bool = false, + lineWidth: Double = 2, candleWidthSeconds: TimeInterval = 60, - insets: LivelineInsets = LivelineInsets() + insets: LivelineInsets = LivelineInsets(), + tooltipY: Double = 14, + emptyText: String = "No data to display", + formatValue: @escaping @Sendable (Double) -> String = { String(format: "%.2f", $0) } ) { self.mode = mode self.windowSeconds = windowSeconds @@ -82,7 +107,15 @@ public struct LivelineConfig: Sendable { self.showFill = showFill self.showCrosshair = showCrosshair self.showValueLabel = showValueLabel + self.showDashLine = showDashLine + self.showDot = showDot + self.pulse = pulse + self.exaggerate = exaggerate + self.lineWidth = lineWidth self.candleWidthSeconds = candleWidthSeconds self.insets = insets + self.tooltipY = tooltipY + self.emptyText = emptyText + self.formatValue = formatValue } } diff --git a/Sources/LivelineCanvas/Palette.swift b/Sources/LivelineCanvas/Palette.swift index 1f68fd1..913ec60 100644 --- a/Sources/LivelineCanvas/Palette.swift +++ b/Sources/LivelineCanvas/Palette.swift @@ -3,40 +3,96 @@ import Foundation #if canImport(UIKit) import UIKit +public enum LivelineTheme: Sendable { + case dark + case light +} + public struct LivelinePalette: Sendable { public var background: UIColor public var line: UIColor - public var fill: UIColor + /// Top of the gradient fill under the curve. + public var fillTop: UIColor + /// Bottom of the gradient fill (normally transparent). + public var fillBottom: UIColor public var grid: UIColor + public var gridLabel: UIColor + public var timeLabel: UIColor + /// Dashed current-value line. + public var dashLine: UIColor public var upCandle: UIColor public var downCandle: UIColor public var text: UIColor public var crosshair: UIColor public var reference: UIColor + /// Outer circle of the live dot. + public var dotOuter: UIColor public init( - background: UIColor = .black, - line: UIColor = UIColor(red: 0.22, green: 0.72, blue: 0.95, alpha: 1), - fill: UIColor = UIColor(red: 0.22, green: 0.72, blue: 0.95, alpha: 0.18), - grid: UIColor = UIColor(white: 1, alpha: 0.08), - upCandle: UIColor = UIColor(red: 0.23, green: 0.82, blue: 0.38, alpha: 1), - downCandle: UIColor = UIColor(red: 0.95, green: 0.33, blue: 0.33, alpha: 1), - text: UIColor = UIColor(white: 1, alpha: 0.92), - crosshair: UIColor = UIColor(white: 1, alpha: 0.45), - reference: UIColor = UIColor(white: 1, alpha: 0.35) + background: UIColor = UIColor(red: 10 / 255, green: 10 / 255, blue: 10 / 255, alpha: 1), + line: UIColor = UIColor(red: 0.23, green: 0.51, blue: 0.96, alpha: 1), + fillTop: UIColor = UIColor(red: 0.23, green: 0.51, blue: 0.96, alpha: 0.12), + fillBottom: UIColor = UIColor(red: 0.23, green: 0.51, blue: 0.96, alpha: 0), + grid: UIColor = UIColor(white: 1, alpha: 0.06), + gridLabel: UIColor = UIColor(white: 1, alpha: 0.4), + timeLabel: UIColor = UIColor(white: 1, alpha: 0.35), + dashLine: UIColor = UIColor(red: 0.23, green: 0.51, blue: 0.96, alpha: 0.4), + upCandle: UIColor = UIColor(red: 0.13, green: 0.77, blue: 0.37, alpha: 1), + downCandle: UIColor = UIColor(red: 0.94, green: 0.27, blue: 0.27, alpha: 1), + text: UIColor = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1), + crosshair: UIColor = UIColor(white: 1, alpha: 0.2), + reference: UIColor = UIColor(white: 1, alpha: 0.15), + dotOuter: UIColor = UIColor(red: 40 / 255, green: 40 / 255, blue: 40 / 255, alpha: 0.95) ) { self.background = background self.line = line - self.fill = fill + self.fillTop = fillTop + self.fillBottom = fillBottom self.grid = grid + self.gridLabel = gridLabel + self.timeLabel = timeLabel + self.dashLine = dashLine self.upCandle = upCandle self.downCandle = downCandle self.text = text self.crosshair = crosshair self.reference = reference + self.dotOuter = dotOuter } public static let `default` = LivelinePalette() + + /// Derive a full palette from one accent color + theme, mirroring + /// `resolveTheme` in `src/theme.ts`. Candle/momentum colors stay + /// semantic green/red regardless of accent. + public static func derive(accent: UIColor, theme: LivelineTheme = .dark) -> LivelinePalette { + let dark = theme == .dark + let mono: (CGFloat, CGFloat) -> UIColor = { white, alpha in + UIColor(white: dark ? white : 1 - white, alpha: alpha) + } + return LivelinePalette( + background: dark + ? UIColor(red: 10 / 255, green: 10 / 255, blue: 10 / 255, alpha: 1) + : .white, + line: accent, + fillTop: accent.withAlphaComponent(dark ? 0.12 : 0.08), + fillBottom: accent.withAlphaComponent(0), + grid: mono(1, 0.06), + gridLabel: mono(1, dark ? 0.4 : 0.35), + timeLabel: mono(1, dark ? 0.35 : 0.3), + dashLine: accent.withAlphaComponent(0.4), + upCandle: UIColor(red: 0.13, green: 0.77, blue: 0.37, alpha: 1), + downCandle: UIColor(red: 0.94, green: 0.27, blue: 0.27, alpha: 1), + text: dark + ? UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1) + : UIColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1), + crosshair: mono(1, dark ? 0.2 : 0.12), + reference: mono(1, dark ? 0.15 : 0.12), + dotOuter: dark + ? UIColor(red: 40 / 255, green: 40 / 255, blue: 40 / 255, alpha: 0.95) + : UIColor(white: 1, alpha: 0.95) + ) + } } #else public struct LivelinePalette: Sendable { diff --git a/Sources/LivelineCanvas/Renderer.swift b/Sources/LivelineCanvas/Renderer.swift index 346e604..ebc1665 100644 --- a/Sources/LivelineCanvas/Renderer.swift +++ b/Sources/LivelineCanvas/Renderer.swift @@ -9,6 +9,10 @@ struct RenderState { var displayMin: Double = 0 var displayMax: Double = 1 var initialized = false + /// Wall-clock time captured when `isPaused` flipped on; while set, the + /// chart clock is frozen so scrolling stops. (The React engine also + /// animates a smooth catch-up on resume; that part is not ported.) + var pausedAt: TimeInterval? } struct RenderInput { @@ -28,163 +32,405 @@ struct RenderInput { enum LivelineRenderer { static func render(_ ctx: CGContext, input: RenderInput, state: inout RenderState, dt: Double) { let rect = input.rect - ctx.setFillColor(input.palette.background.cgColor) + let config = input.config + let palette = input.palette + + ctx.setFillColor(palette.background.cgColor) ctx.fill(rect) + let chartRect = rect.inset(by: UIEdgeInsets( + top: CGFloat(config.insets.top), + left: CGFloat(config.insets.left), + bottom: CGFloat(config.insets.bottom), + right: CGFloat(config.insets.right) + )) + guard chartRect.width > 0, chartRect.height > 0 else { return } + guard !input.isLoading else { - drawLoading(in: ctx, rect: rect, palette: input.palette, t: input.now) + drawSquiggly(in: ctx, chartRect: chartRect, palette: palette, now: input.now) return } - let chartRect = rect.inset(by: UIEdgeInsets( - top: CGFloat(input.config.insets.top), - left: CGFloat(input.config.insets.left), - bottom: CGFloat(input.config.insets.bottom), - right: CGFloat(input.config.insets.right) - )) + guard !(input.points.isEmpty && input.candles.isEmpty) else { + drawEmpty(in: ctx, chartRect: chartRect, config: config, palette: palette, now: input.now) + return + } - if input.config.showGrid { - drawGrid(in: ctx, rect: chartRect, color: input.palette.grid) + // Freeze the chart clock while paused so scrolling stops. + if input.isPaused { + if state.pausedAt == nil { state.pausedAt = input.now } + } else { + state.pausedAt = nil } + let now = state.pausedAt ?? input.now + + let window = Swift.max(config.windowSeconds, 1) + let visiblePoints = visibleSlice(input.points, leftTime: now - window) + let visibleCandles = input.candles.filter { $0.time >= now - window - config.candleWidthSeconds } - let range = valueRange(points: input.points, candles: input.candles, reference: input.referenceLine) + let range = valueRange( + points: config.mode == .line ? visiblePoints : [], + candles: config.mode == .candle ? visibleCandles : [], + currentValue: input.value, + reference: input.referenceLine, + exaggerate: config.exaggerate + ) if !state.initialized { state.displayValue = input.value state.displayMin = range.min state.displayMax = range.max state.initialized = true } else if !input.isPaused { - state.displayValue = lerp(state.displayValue, input.value, speed: input.config.lerpSpeed, dt: dt) - state.displayMin = lerp(state.displayMin, range.min, speed: input.config.lerpSpeed, dt: dt) - state.displayMax = lerp(state.displayMax, range.max, speed: input.config.lerpSpeed, dt: dt) + state.displayValue = lerp(state.displayValue, input.value, speed: config.lerpSpeed, dt: dt) + state.displayMin = lerp(state.displayMin, range.min, speed: config.lerpSpeed + 0.07, dt: dt) + state.displayMax = lerp(state.displayMax, range.max, speed: config.lerpSpeed + 0.07, dt: dt) } + let minValue = state.displayMin + let maxValue = state.displayMax - if let reference = input.referenceLine { - drawReferenceLine(in: ctx, chartRect: chartRect, min: state.displayMin, max: state.displayMax, value: reference.value, color: input.palette.reference) + if config.showGrid { + drawGrid(in: ctx, chartRect: chartRect, minValue: minValue, maxValue: maxValue, config: config, palette: palette) } + drawTimeAxis(in: ctx, chartRect: chartRect, now: now, window: window, palette: palette) - let visiblePoints = input.points.filter { input.now - $0.time <= input.config.windowSeconds } - let visibleCandles = input.candles.filter { input.now - $0.time <= input.config.windowSeconds } + if let reference = input.referenceLine { + drawReferenceLine(in: ctx, chartRect: chartRect, minValue: minValue, maxValue: maxValue, value: reference.value, color: palette.reference) + } - switch input.config.mode { + switch config.mode { case .line: - drawLine(in: ctx, chartRect: chartRect, points: visiblePoints, min: state.displayMin, max: state.displayMax, color: input.palette.line, fill: input.config.showFill ? input.palette.fill : nil) + drawLine(in: ctx, chartRect: chartRect, points: visiblePoints, smoothValue: state.displayValue, now: now, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + if config.showDot, !visiblePoints.isEmpty { + let tipY = yForValue(state.displayValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + // Clamp to the canvas (not the chart area) so the dot stays visible + let dotY = clamp(tipY, rect.minY + 10, rect.maxY - 10) + drawDot(in: ctx, at: CGPoint(x: chartRect.maxX, y: dotY), palette: palette, pulse: config.pulse, now: input.now) + } case .candle: - drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, min: state.displayMin, max: state.displayMax, palette: input.palette, window: input.config.windowSeconds) + drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, now: now, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + } + + if let hoverX = input.hoverX, config.showCrosshair { + drawCrosshair(in: ctx, chartRect: chartRect, x: hoverX, points: input.points, now: now, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) } - if let hoverX = input.hoverX, input.config.showCrosshair { - drawCrosshair(in: ctx, chartRect: chartRect, x: hoverX, color: input.palette.crosshair) + if config.showValueLabel { + drawText( + config.formatValue(state.displayValue), + at: CGPoint(x: rect.maxX - 12, y: rect.minY + 8), + anchorX: 1, + font: .monospacedDigitSystemFont(ofSize: 12, weight: .medium), + color: palette.text + ) } + } + + // MARK: - Geometry - if input.config.showValueLabel { - drawValueLabel(in: ctx, rect: rect, value: state.displayValue, color: input.palette.text) + /// Points inside the window plus one point just left of it, so the line + /// enters from the chart edge instead of popping in. + private static func visibleSlice(_ points: [LivelinePoint], leftTime: TimeInterval) -> [LivelinePoint] { + guard let firstVisible = points.firstIndex(where: { $0.time >= leftTime }) else { + // Everything is older than the window — keep the newest point so + // the live tip still has an anchor. + return points.suffix(1).map { $0 } } + let start = firstVisible > 0 ? firstVisible - 1 : 0 + return Array(points[start...]) } - private static func xForTime(_ t: TimeInterval, rightEdge: TimeInterval, window: TimeInterval, chartRect: CGRect) -> CGFloat { - let ratio = CGFloat((t - (rightEdge - window)) / window) + private static func xForTime(_ t: TimeInterval, now: TimeInterval, window: TimeInterval, chartRect: CGRect) -> CGFloat { + let ratio = CGFloat((t - (now - window)) / window) return chartRect.minX + ratio * chartRect.width } - private static func yForValue(_ v: Double, min: Double, max: Double, chartRect: CGRect) -> CGFloat { - let ratio = CGFloat((v - min) / (max - min)) + private static func yForValue(_ v: Double, minValue: Double, maxValue: Double, chartRect: CGRect) -> CGFloat { + let span = maxValue - minValue + guard span > 0 else { return chartRect.midY } + let ratio = CGFloat((v - minValue) / span) return chartRect.maxY - ratio * chartRect.height } - private static func drawGrid(in ctx: CGContext, rect: CGRect, color: UIColor) { + // MARK: - Grid + axes + + private static func drawGrid(in ctx: CGContext, chartRect: CGRect, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { + let rows = 4 ctx.saveGState() - ctx.setStrokeColor(color.cgColor) + ctx.setStrokeColor(palette.grid.cgColor) ctx.setLineWidth(1) - let rows = 4 - let cols = 4 + ctx.setLineDash(phase: 0, lengths: [1, 3]) + for i in 0...rows { + let y = chartRect.minY + (CGFloat(i) / CGFloat(rows)) * chartRect.height + ctx.move(to: CGPoint(x: chartRect.minX, y: y)) + ctx.addLine(to: CGPoint(x: chartRect.maxX, y: y)) + } + ctx.strokePath() + ctx.restoreGState() + + let font = UIFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) for i in 0...rows { - let y = rect.minY + (CGFloat(i) / CGFloat(rows)) * rect.height - ctx.move(to: CGPoint(x: rect.minX, y: y)) - ctx.addLine(to: CGPoint(x: rect.maxX, y: y)) + let ratio = Double(i) / Double(rows) + let value = maxValue - ratio * (maxValue - minValue) + let y = chartRect.minY + CGFloat(ratio) * chartRect.height + drawText( + config.formatValue(value), + at: CGPoint(x: chartRect.maxX + 8, y: y - font.lineHeight / 2), + anchorX: 0, + font: font, + color: palette.gridLabel + ) } - for i in 0...cols { - let x = rect.minX + (CGFloat(i) / CGFloat(cols)) * rect.width - ctx.move(to: CGPoint(x: x, y: rect.minY)) - ctx.addLine(to: CGPoint(x: x, y: rect.maxY)) + } + + private static func niceTimeInterval(_ windowSecs: TimeInterval) -> TimeInterval { + switch windowSecs { + case ...15: return 2 + case ...30: return 5 + case ...60: return 10 + case ...120: return 15 + case ...300: return 30 + case ...600: return 60 + case ...1800: return 300 + case ...3600: return 600 + case ...14400: return 1800 + case ...43200: return 3600 + case ...86400: return 7200 + case ...604800: return 86400 + default: return 604800 } + } + + private static func drawTimeAxis(in ctx: CGContext, chartRect: CGRect, now: TimeInterval, window: TimeInterval, palette: LivelinePalette) { + ctx.saveGState() + ctx.setStrokeColor(palette.grid.cgColor) + ctx.setLineWidth(1) + ctx.move(to: CGPoint(x: chartRect.minX, y: chartRect.maxY)) + ctx.addLine(to: CGPoint(x: chartRect.maxX, y: chartRect.maxY)) ctx.strokePath() + + var interval = niceTimeInterval(window) + while chartRect.width * CGFloat(interval / window) < 60 { interval *= 2 } + + let font = UIFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) + var t = ((now - window) / interval).rounded(.up) * interval + ctx.setStrokeColor(palette.timeLabel.cgColor) + while t <= now { + let x = xForTime(t, now: now, window: window, chartRect: chartRect) + ctx.move(to: CGPoint(x: x, y: chartRect.maxY)) + ctx.addLine(to: CGPoint(x: x, y: chartRect.maxY + 5)) + ctx.strokePath() + drawText( + timeString(t), + at: CGPoint(x: x, y: chartRect.maxY + 8), + anchorX: 0.5, + font: font, + color: palette.timeLabel + ) + t += interval + } ctx.restoreGState() } - private static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], min: Double, max: Double, color: UIColor, fill: UIColor?) { - guard points.count >= 2, let last = points.last else { return } - let rightEdge = last.time - let window = points.last!.time - points.first!.time - let effectiveWindow = max(window, 1) + // MARK: - Line mode + + private static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], smoothValue: Double, now: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { + guard !points.isEmpty else { return } + let clampY: (CGFloat) -> CGFloat = { clamp($0, chartRect.minY, chartRect.maxY) } - let path = UIBezierPath() + // Historical points keep their data values; the LAST data point takes + // the interpolated value so big jumps animate instead of snapping, + // then the live tip is appended at the right edge (matches + // src/draw/line.ts). + var pts: [CGPoint] = [] + pts.reserveCapacity(points.count + 1) for (index, p) in points.enumerated() { - let x = xForTime(p.time, rightEdge: rightEdge, window: effectiveWindow, chartRect: chartRect) - let y = yForValue(p.value, min: min, max: max, chartRect: chartRect) - if index == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } + let x = xForTime(p.time, now: now, window: window, chartRect: chartRect) + let v = index == points.count - 1 ? smoothValue : p.value + pts.append(CGPoint(x: x, y: clampY(yForValue(v, minValue: minValue, maxValue: maxValue, chartRect: chartRect)))) } + let tipY = clampY(yForValue(smoothValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect)) + pts.append(CGPoint(x: chartRect.maxX, y: tipY)) + guard pts.count >= 2 else { return } - if let fill { - let fillPath = path.copy() as! UIBezierPath - fillPath.addLine(to: CGPoint(x: chartRect.maxX, y: chartRect.maxY)) - fillPath.addLine(to: CGPoint(x: chartRect.minX, y: chartRect.maxY)) - fillPath.close() - ctx.setFillColor(fill.cgColor) - ctx.addPath(fillPath.cgPath) - ctx.fillPath() + let linePath = CGMutablePath() + linePath.move(to: pts[0]) + for segment in monotoneSplineSegments(pts) { + linePath.addCurve(to: segment.end, control1: segment.control1, control2: segment.control2) } - ctx.setStrokeColor(color.cgColor) - ctx.setLineWidth(2) - ctx.addPath(path.cgPath) + ctx.saveGState() + ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) + + if config.showFill { + let fillPath = linePath.mutableCopy()! + fillPath.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: chartRect.maxY)) + fillPath.addLine(to: CGPoint(x: pts[0].x, y: chartRect.maxY)) + fillPath.closeSubpath() + + ctx.saveGState() + ctx.addPath(fillPath) + ctx.clip() + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 + _ = palette.fillTop.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + _ = palette.fillBottom.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + let components: [CGFloat] = [r1, g1, b1, a1, r2, g2, b2, a2] + if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colorComponents: components, locations: [0, 1], count: 2) { + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: 0, y: chartRect.minY), + end: CGPoint(x: 0, y: chartRect.maxY), + options: [] + ) + } + ctx.restoreGState() + } + + ctx.setStrokeColor(palette.line.cgColor) + ctx.setLineWidth(CGFloat(config.lineWidth)) + ctx.setLineJoin(.round) + ctx.setLineCap(.round) + ctx.addPath(linePath) ctx.strokePath() + ctx.restoreGState() + + if config.showDashLine { + ctx.saveGState() + ctx.setStrokeColor(palette.dashLine.cgColor) + ctx.setLineWidth(1) + ctx.setLineDash(phase: 0, lengths: [4, 4]) + ctx.move(to: CGPoint(x: chartRect.minX, y: tipY)) + ctx.addLine(to: CGPoint(x: chartRect.maxX, y: tipY)) + ctx.strokePath() + ctx.restoreGState() + } + } + + /// Live dot: expanding accent pulse ring (1.5s interval, 0.9s duration), + /// outer circle with shadow, colored inner dot — port of src/draw/dot.ts. + private static func drawDot(in ctx: CGContext, at point: CGPoint, palette: LivelinePalette, pulse: Bool, now: TimeInterval) { + if pulse { + let t = now.truncatingRemainder(dividingBy: 1.5) / 0.9 + if t < 1 { + let radius = CGFloat(9 + t * 12) + ctx.saveGState() + ctx.setStrokeColor(palette.line.withAlphaComponent(CGFloat(0.35 * (1 - t))).cgColor) + ctx.setLineWidth(1.5) + ctx.strokeEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)) + ctx.restoreGState() + } + } + + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: 1), blur: 6, color: UIColor.black.withAlphaComponent(0.4).cgColor) + ctx.setFillColor(palette.dotOuter.cgColor) + ctx.fillEllipse(in: CGRect(x: point.x - 6.5, y: point.y - 6.5, width: 13, height: 13)) + ctx.restoreGState() + + ctx.setFillColor(palette.line.cgColor) + ctx.fillEllipse(in: CGRect(x: point.x - 3.5, y: point.y - 3.5, width: 7, height: 7)) } - private static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], min: Double, max: Double, palette: LivelinePalette, window: TimeInterval) { - guard candles.count > 0, let last = candles.last else { return } - let rightEdge = last.time - let candleWidth = max(3, chartRect.width / CGFloat(max(candles.count, 1)) * 0.7) + // MARK: - Candle mode + private static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], now: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { + guard !candles.isEmpty else { return } + let pxPerSecond = chartRect.width / CGFloat(window) + let bodyWidth = Swift.max(1, pxPerSecond * CGFloat(config.candleWidthSeconds) * 0.7) + let wickWidth = clamp(bodyWidth * 0.15, 0.8, 2) + + ctx.saveGState() + ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) for c in candles { - let x = xForTime(c.time, rightEdge: rightEdge, window: window, chartRect: chartRect) - let yOpen = yForValue(c.open, min: min, max: max, chartRect: chartRect) - let yClose = yForValue(c.close, min: min, max: max, chartRect: chartRect) - let yHigh = yForValue(c.high, min: min, max: max, chartRect: chartRect) - let yLow = yForValue(c.low, min: min, max: max, chartRect: chartRect) + // Candle time is the bucket's open time — center the body on it + let x = xForTime(c.time + config.candleWidthSeconds / 2, now: now, window: window, chartRect: chartRect) + let yOpen = yForValue(c.open, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + let yClose = yForValue(c.close, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + let yHigh = yForValue(c.high, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + let yLow = yForValue(c.low, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let isUp = c.close >= c.open let color = isUp ? palette.upCandle : palette.downCandle ctx.setStrokeColor(color.cgColor) - ctx.setLineWidth(1) + ctx.setLineWidth(wickWidth) ctx.move(to: CGPoint(x: x, y: yHigh)) ctx.addLine(to: CGPoint(x: x, y: yLow)) ctx.strokePath() - let bodyTop = min(yOpen, yClose) - let bodyBottom = max(yOpen, yClose) - let bodyRect = CGRect(x: x - candleWidth / 2, y: bodyTop, width: candleWidth, height: max(1, bodyBottom - bodyTop)) + let bodyTop = Swift.min(yOpen, yClose) + let bodyBottom = Swift.max(yOpen, yClose) + let bodyRect = CGRect( + x: x - bodyWidth / 2, + y: bodyTop, + width: bodyWidth, + height: Swift.max(1, bodyBottom - bodyTop) + ) ctx.setFillColor(color.cgColor) ctx.fill(bodyRect) } + ctx.restoreGState() + + // Dashed close-price line at the latest close (candle-colored) + if config.showDashLine, let last = candles.last { + let y = clamp( + yForValue(last.close, minValue: minValue, maxValue: maxValue, chartRect: chartRect), + chartRect.minY, chartRect.maxY + ) + let color = last.close >= last.open ? palette.upCandle : palette.downCandle + ctx.saveGState() + ctx.setStrokeColor(color.withAlphaComponent(0.4).cgColor) + ctx.setLineWidth(1) + ctx.setLineDash(phase: 0, lengths: [4, 4]) + ctx.move(to: CGPoint(x: chartRect.minX, y: y)) + ctx.addLine(to: CGPoint(x: chartRect.maxX, y: y)) + ctx.strokePath() + ctx.restoreGState() + } } - private static func drawCrosshair(in ctx: CGContext, chartRect: CGRect, x: CGFloat, color: UIColor) { + // MARK: - Overlays + + private static func drawCrosshair(in ctx: CGContext, chartRect: CGRect, x: CGFloat, points: [LivelinePoint], now: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { let clampedX = clamp(x, chartRect.minX, chartRect.maxX) + ctx.saveGState() - ctx.setStrokeColor(color.cgColor) + ctx.setStrokeColor(palette.crosshair.cgColor) ctx.setLineWidth(1) ctx.move(to: CGPoint(x: clampedX, y: chartRect.minY)) ctx.addLine(to: CGPoint(x: clampedX, y: chartRect.maxY)) ctx.strokePath() ctx.restoreGState() + + guard !points.isEmpty else { return } + let hoverTime = (now - window) + TimeInterval((clampedX - chartRect.minX) / Swift.max(chartRect.width, 1)) * window + guard let value = interpolatedValue(points, at: hoverTime) else { return } + + // Marker dot on the line at the hovered value + let y = clamp(yForValue(value, minValue: minValue, maxValue: maxValue, chartRect: chartRect), chartRect.minY, chartRect.maxY) + ctx.setFillColor(palette.line.cgColor) + ctx.fillEllipse(in: CGRect(x: clampedX - 4, y: y - 4, width: 8, height: 8)) + + // "VALUE · TIME" inline tooltip near the top, clamped into the chart + let font = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) + let text = "\(config.formatValue(value)) · \(timeString(hoverTime))" + let size = textSize(text, font: font) + let textX = clamp(clampedX - size.width / 2, chartRect.minX + 4, chartRect.maxX - size.width - 4) + drawText( + text, + at: CGPoint(x: textX, y: chartRect.minY + CGFloat(config.tooltipY) - size.height / 2), + anchorX: 0, + font: font, + color: palette.text + ) } - private static func drawReferenceLine(in ctx: CGContext, chartRect: CGRect, min: Double, max: Double, value: Double, color: UIColor) { - let y = yForValue(value, min: min, max: max, chartRect: chartRect) + private static func drawReferenceLine(in ctx: CGContext, chartRect: CGRect, minValue: Double, maxValue: Double, value: Double, color: UIColor) { + let y = yForValue(value, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + guard y >= chartRect.minY - 10, y <= chartRect.maxY + 10 else { return } ctx.saveGState() ctx.setStrokeColor(color.cgColor) + ctx.setLineWidth(1) ctx.setLineDash(phase: 0, lengths: [4, 4]) ctx.move(to: CGPoint(x: chartRect.minX, y: y)) ctx.addLine(to: CGPoint(x: chartRect.maxX, y: y)) @@ -192,30 +438,91 @@ enum LivelineRenderer { ctx.restoreGState() } - private static func drawValueLabel(in ctx: CGContext, rect: CGRect, value: Double, color: UIColor) { - let text = String(format: "%.2f", value) - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium), - .foregroundColor: color - ] - let attr = NSAttributedString(string: text, attributes: attrs) - let size = attr.size() - let textRect = CGRect(x: rect.maxX - size.width - 12, y: 8, width: size.width, height: size.height) - attr.draw(in: textRect) + // MARK: - Loading / empty + + /// Breathing squiggly line, same shape constants as src/draw/loadingShape.ts. + private static func drawSquiggly(in ctx: CGContext, chartRect: CGRect, palette: LivelinePalette, now: TimeInterval, alphaScale: Double = 1) { + // Keep the sine arguments small for precision — the shape repeats anyway + let ms = now.truncatingRemainder(dividingBy: 86_400) * 1000 + let scroll = ms * 0.001 + let breath = 0.22 + 0.08 * sin(ms / 1200 * .pi) + let amplitude = Double(chartRect.height) * 0.07 + let centerY = Double(chartRect.midY) + + let samples = 32 + var pts: [CGPoint] = [] + pts.reserveCapacity(samples + 1) + for i in 0...samples { + let t = Double(i) / Double(samples) + let y = centerY + amplitude * ( + sin(t * 9.4 + scroll) * 0.55 + + sin(t * 15.7 + scroll * 1.3) * 0.3 + + sin(t * 4.2 + scroll * 0.7) * 0.15 + ) + pts.append(CGPoint(x: chartRect.minX + CGFloat(t) * chartRect.width, y: CGFloat(y))) + } + + let path = CGMutablePath() + path.move(to: pts[0]) + for segment in monotoneSplineSegments(pts) { + path.addCurve(to: segment.end, control1: segment.control1, control2: segment.control2) + } + + ctx.saveGState() + ctx.setStrokeColor(palette.gridLabel.withAlphaComponent(CGFloat(breath * alphaScale)).cgColor) + ctx.setLineWidth(2) + ctx.setLineJoin(.round) + ctx.setLineCap(.round) + ctx.addPath(path) + ctx.strokePath() + ctx.restoreGState() } - private static func drawLoading(in ctx: CGContext, rect: CGRect, palette: LivelinePalette, t: TimeInterval) { - let shimmerWidth = rect.width * 0.25 - let x = ((CGFloat(t).truncatingRemainder(dividingBy: 1.5) / 1.5) * (rect.width + shimmerWidth)) - shimmerWidth - let base = UIColor(white: 1, alpha: 0.08) - let pulse = UIColor(white: 1, alpha: 0.14) + private static func drawEmpty(in ctx: CGContext, chartRect: CGRect, config: LivelineConfig, palette: LivelinePalette, now: TimeInterval) { + drawSquiggly(in: ctx, chartRect: chartRect, palette: palette, now: now) - ctx.setFillColor(base.cgColor) - ctx.fill(rect) - ctx.setFillColor(pulse.cgColor) - ctx.fill(CGRect(x: x, y: 0, width: shimmerWidth, height: rect.height)) + let font = UIFont.systemFont(ofSize: 12, weight: .regular) + let size = textSize(config.emptyText, font: font) + // Background-colored gap behind the text, standing in for the + // destination-out gradient the web version uses + let gap = CGRect( + x: chartRect.midX - size.width / 2 - 12, + y: chartRect.midY - size.height / 2 - 6, + width: size.width + 24, + height: size.height + 12 + ) + ctx.setFillColor(palette.background.cgColor) + ctx.fill(gap) + drawText( + config.emptyText, + at: CGPoint(x: chartRect.midX - size.width / 2, y: chartRect.midY - size.height / 2), + anchorX: 0, + font: font, + color: palette.gridLabel.withAlphaComponent(0.35) + ) + } + + // MARK: - Text helpers + + private static func timeString(_ t: TimeInterval) -> String { + let comps = Calendar.current.dateComponents( + [.hour, .minute, .second], + from: Date(timeIntervalSince1970: t) + ) + return String(format: "%02d:%02d:%02d", comps.hour ?? 0, comps.minute ?? 0, comps.second ?? 0) + } + + private static func textSize(_ string: String, font: UIFont) -> CGSize { + (string as NSString).size(withAttributes: [.font: font]) + } - drawValueLabel(in: ctx, rect: rect, value: 0, color: palette.text.withAlphaComponent(0.5)) + @discardableResult + private static func drawText(_ string: String, at point: CGPoint, anchorX: CGFloat, font: UIFont, color: UIColor) -> CGSize { + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + let size = (string as NSString).size(withAttributes: attributes) + let origin = CGPoint(x: point.x - size.width * anchorX, y: point.y) + (string as NSString).draw(at: origin, withAttributes: attributes) + return size } } #endif diff --git a/Sources/LivelineCanvas/Spline.swift b/Sources/LivelineCanvas/Spline.swift new file mode 100644 index 0000000..1f02d35 --- /dev/null +++ b/Sources/LivelineCanvas/Spline.swift @@ -0,0 +1,72 @@ +import Foundation + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +/// One cubic bezier segment of a spline. The curve starts at the previous +/// segment's `end` (or the first input point) and ends at `end`. +struct SplineSegment: Equatable { + let control1: CGPoint + let control2: CGPoint + let end: CGPoint +} + +/// Fritsch-Carlson monotone cubic interpolation, ported from `src/math/spline.ts`. +/// Guarantees no overshoots — the curve never exceeds local min/max. +/// Platform-independent so it can be unit-tested off-device; callers build a +/// `CGPath`/`UIBezierPath` from the returned segments. +func monotoneSplineSegments(_ pts: [CGPoint]) -> [SplineSegment] { + guard pts.count >= 2 else { return [] } + if pts.count == 2 { + return [SplineSegment(control1: pts[0], control2: pts[1], end: pts[1])] + } + + let n = pts.count + + // 1. Secant slopes between consecutive points + var h = [CGFloat](repeating: 0, count: n - 1) + var delta = [CGFloat](repeating: 0, count: n - 1) + for i in 0..<(n - 1) { + h[i] = pts[i + 1].x - pts[i].x + delta[i] = h[i] == 0 ? 0 : (pts[i + 1].y - pts[i].y) / h[i] + } + + // 2. Initial tangent estimates — zero at sign changes for monotonicity + var m = [CGFloat](repeating: 0, count: n) + m[0] = delta[0] + m[n - 1] = delta[n - 2] + for i in 1..<(n - 1) { + m[i] = delta[i - 1] * delta[i] <= 0 ? 0 : (delta[i - 1] + delta[i]) / 2 + } + + // 3. Fritsch-Carlson constraint: alpha^2 + beta^2 <= 9 + for i in 0..<(n - 1) { + if delta[i] == 0 { + m[i] = 0 + m[i + 1] = 0 + } else { + let alpha = m[i] / delta[i] + let beta = m[i + 1] / delta[i] + let s2 = alpha * alpha + beta * beta + if s2 > 9 { + let s = 3 / s2.squareRoot() + m[i] = s * alpha * delta[i] + m[i + 1] = s * beta * delta[i] + } + } + } + + // 4. Bezier control points from tangents + var segments: [SplineSegment] = [] + segments.reserveCapacity(n - 1) + for i in 0..<(n - 1) { + let hi = h[i] + segments.append(SplineSegment( + control1: CGPoint(x: pts[i].x + hi / 3, y: pts[i].y + m[i] * hi / 3), + control2: CGPoint(x: pts[i + 1].x - hi / 3, y: pts[i + 1].y - m[i + 1] * hi / 3), + end: pts[i + 1] + )) + } + return segments +} diff --git a/Tests/LivelineCanvasTests/LivelineCanvasTests.swift b/Tests/LivelineCanvasTests/LivelineCanvasTests.swift index 55284b6..76262ae 100644 --- a/Tests/LivelineCanvasTests/LivelineCanvasTests.swift +++ b/Tests/LivelineCanvasTests/LivelineCanvasTests.swift @@ -1,13 +1,30 @@ import XCTest @testable import LivelineCanvas +#if canImport(CoreGraphics) +import CoreGraphics +#endif + final class LivelineCanvasTests: XCTestCase { + + // MARK: - Lerp + func testLerpMovesTowardTarget() { let result = lerp(100, 110, speed: 0.2, dt: 1.0 / 60) XCTAssertGreaterThan(result, 100) XCTAssertLessThan(result, 110) } + func testLerpIsFrameRateIndependent() { + // One 33ms step should land where two 16.67ms steps do + let oneBigStep = lerp(0, 100, speed: 0.2, dt: 2 * 16.67 / 1000) + var twoSmallSteps = lerp(0, 100, speed: 0.2, dt: 16.67 / 1000) + twoSmallSteps = lerp(twoSmallSteps, 100, speed: 0.2, dt: 16.67 / 1000) + XCTAssertEqual(oneBigStep, twoSmallSteps, accuracy: 0.001) + } + + // MARK: - Value range + func testValueRangeAddsPadding() { let points = [ LivelinePoint(time: 1, value: 100), @@ -18,4 +35,110 @@ final class LivelineCanvasTests: XCTestCase { XCTAssertLessThan(range.min, 100) XCTAssertGreaterThan(range.max, 120) } + + func testValueRangeFlatDataGetsMinimumSpan() { + let points = [ + LivelinePoint(time: 1, value: 100), + LivelinePoint(time: 2, value: 100) + ] + + let range = valueRange(points: points, candles: [], reference: nil) + XCTAssertEqual(range.max - range.min, 0.4, accuracy: 0.0001) + XCTAssertEqual((range.max + range.min) / 2, 100, accuracy: 0.0001) + } + + func testValueRangeExaggerateIsTighter() { + let points = [ + LivelinePoint(time: 1, value: 100), + LivelinePoint(time: 2, value: 120) + ] + + let normal = valueRange(points: points, candles: [], reference: nil) + let tight = valueRange(points: points, candles: [], reference: nil, exaggerate: true) + XCTAssertLessThan(tight.max - tight.min, normal.max - normal.min) + } + + func testValueRangeIncludesReferenceAndCurrentValue() { + let points = [LivelinePoint(time: 1, value: 100)] + + let range = valueRange( + points: points, + candles: [], + currentValue: 150, + reference: LivelineReferenceLine(value: 50) + ) + XCTAssertLessThanOrEqual(range.min, 50) + XCTAssertGreaterThanOrEqual(range.max, 150) + } + + // MARK: - Interpolation + + func testInterpolatedValueAtMidpoint() { + let points = [ + LivelinePoint(time: 0, value: 10), + LivelinePoint(time: 10, value: 20), + LivelinePoint(time: 20, value: 40) + ] + + XCTAssertEqual(interpolatedValue(points, at: 5)!, 15, accuracy: 0.0001) + XCTAssertEqual(interpolatedValue(points, at: 15)!, 30, accuracy: 0.0001) + } + + func testInterpolatedValueClampsToEndpoints() { + let points = [ + LivelinePoint(time: 0, value: 10), + LivelinePoint(time: 10, value: 20) + ] + + XCTAssertEqual(interpolatedValue(points, at: -5), 10) + XCTAssertEqual(interpolatedValue(points, at: 99), 20) + XCTAssertNil(interpolatedValue([], at: 0)) + } + + // MARK: - Spline + + func testSplinePassesThroughInputPoints() { + let pts = [ + CGPoint(x: 0, y: 0), + CGPoint(x: 10, y: 5), + CGPoint(x: 20, y: 3), + CGPoint(x: 30, y: 8) + ] + + let segments = monotoneSplineSegments(pts) + XCTAssertEqual(segments.count, pts.count - 1) + for (i, segment) in segments.enumerated() { + XCTAssertEqual(segment.end, pts[i + 1]) + } + } + + func testSplineDoesNotOvershootMonotoneData() { + // Fritsch-Carlson guarantee: for monotone data, control points stay + // within each segment's y-bounds, so the curve never overshoots + let pts = [ + CGPoint(x: 0, y: 0), + CGPoint(x: 10, y: 1), + CGPoint(x: 20, y: 10), + CGPoint(x: 30, y: 11), + CGPoint(x: 40, y: 30) + ] + + var start = pts[0] + for (i, segment) in monotoneSplineSegments(pts).enumerated() { + let lo = min(start.y, pts[i + 1].y) - 0.0001 + let hi = max(start.y, pts[i + 1].y) + 0.0001 + XCTAssertGreaterThanOrEqual(segment.control1.y, lo) + XCTAssertLessThanOrEqual(segment.control1.y, hi) + XCTAssertGreaterThanOrEqual(segment.control2.y, lo) + XCTAssertLessThanOrEqual(segment.control2.y, hi) + start = segment.end + } + } + + func testSplineTwoPointsIsStraightLine() { + let pts = [CGPoint(x: 0, y: 0), CGPoint(x: 10, y: 10)] + let segments = monotoneSplineSegments(pts) + XCTAssertEqual(segments.count, 1) + XCTAssertEqual(segments[0].end, pts[1]) + } } From c90471a85d6366a52d72aa0129abd90d656d56eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 20:10:57 +0000 Subject: [PATCH 3/6] feat(batch 1/3): multi-series, momentum, badge, grid hysteresis, pause catch-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three parity batches for the LivelineCanvas Swift package. - Multi-series mode: `series` + `hiddenSeriesIDs` on the view/SwiftUI bridge, per-series colors (SERIES_COLORS defaults), endpoint dots with pulse, labels, visibility fade at 0.10, Y-range union over visible series, and a combined crosshair tooltip with colored bullets - Momentum: detection port of src/math/momentum.ts (auto/fixed/off modes), cascading chevron arrows next to the dot, momentum glow on the live dot, optional momentum-colored value label - Badge: canvas-drawn value pill with curved tail, width/Y lerp (0.15/0.35), momentum color blend at 0.12, default + minimal variants, hides while paused; right-edge time buffer widens when the badge is on (0.05 vs 0.015) - Grid: TradingView-style interval selection with cycling divisors, hysteresis, coarse + fine labels with per-label fade in/out (0.18/0.12) and edge fade, port of src/draw/grid.ts - Pause: smooth freeze via pauseProgress (0.12) and time-debt catch-up on resume (0.08, 0.22 when debt > 10s) instead of a hard clock stop - Adaptive value lerp (+0.2 boost for small ticks, snap at 0.001 of range) - CI: GitHub Actions workflow building the package for iOS via xcodebuild — the gate the original PR was missing — plus host unit tests Defaults now match React: showBadge on, showValueLabel (React showValue) off, right inset 80 for badge room. Verified: 19 unit tests pass (momentum, grid interval, spline, range, lerp, interpolation); full renderer typechecked against UIKit/CoreGraphics stubs. To test on device: line chart with defaults (badge + arrows + glow should track momentum), a 2-3 series chart with hiddenSeriesIDs toggling, pause → resume (chart should decelerate, then catch up), value-range changes (grid labels should fade, not pop). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U1KrgkhnUq4LKpoY9LpstF --- .github/workflows/swift-ci.yml | 28 ++ README.md | 4 +- Sources/LivelineCanvas/GridInterval.swift | 33 ++ .../LivelineCanvas/LivelineCanvasView.swift | 31 +- Sources/LivelineCanvas/Models.swift | 34 +- Sources/LivelineCanvas/Momentum.swift | 44 ++ Sources/LivelineCanvas/Palette.swift | 13 + Sources/LivelineCanvas/Renderer.swift | 410 +++++++++++++----- Sources/LivelineCanvas/RendererBadge.swift | 165 +++++++ Sources/LivelineCanvas/RendererMulti.swift | 138 ++++++ Sources/LivelineCanvas/Series.swift | 25 ++ Sources/LivelineCanvas/SwiftUIBridge.swift | 14 +- .../LivelineCanvasTests.swift | 53 +++ 13 files changed, 868 insertions(+), 124 deletions(-) create mode 100644 .github/workflows/swift-ci.yml create mode 100644 Sources/LivelineCanvas/GridInterval.swift create mode 100644 Sources/LivelineCanvas/Momentum.swift create mode 100644 Sources/LivelineCanvas/RendererBadge.swift create mode 100644 Sources/LivelineCanvas/RendererMulti.swift create mode 100644 Sources/LivelineCanvas/Series.swift diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml new file mode 100644 index 0000000..08b58a0 --- /dev/null +++ b/.github/workflows/swift-ci.yml @@ -0,0 +1,28 @@ +name: Swift CI + +on: + push: + paths: + - 'Sources/**' + - 'Tests/**' + - 'Package.swift' + - '.github/workflows/swift-ci.yml' + pull_request: + paths: + - 'Sources/**' + - 'Tests/**' + - 'Package.swift' + - '.github/workflows/swift-ci.yml' + +jobs: + ios-build: + name: Build for iOS + test + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + # The UIKit renderer only compiles on iOS/macCatalyst — swift test on + # macOS/Linux skips it entirely, so an iOS build is the real gate. + - name: Build (iOS) + run: xcodebuild build -scheme LivelineCanvas -destination 'generic/platform=iOS' + - name: Unit tests (host) + run: swift test diff --git a/README.md b/README.md index 3b1e704..257169f 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,6 @@ For SwiftUI, use `LivelineChart(...)` from the same package. Palettes can be der ### Feature coverage vs. the React component -Implemented: line mode with Fritsch-Carlson monotone spline, gradient fill, live-tip value interpolation, live dot with pulse ring, dashed current-value line, window-based scrolling (`windowSeconds`), frame-rate-independent lerp of value and Y-range, Y-range from visible data with the same margins (incl. `exaggerate`), grid with value labels, time axis with nice intervals, crosshair with value/time tooltip via pan or long-press, candlestick mode with wicks and dashed close line, reference line, `formatValue`, loading squiggly with breathing alpha, empty state, pause (freezes the clock), render loop that stops when the view leaves the window. +Implemented: line mode with Fritsch-Carlson monotone spline, gradient fill, live-tip value interpolation with adaptive lerp speed, live dot with pulse ring + momentum glow, momentum detection with cascading chevron arrows, value badge pill with tail / momentum color / `minimal` variant, multi-series with per-series colors, labels, endpoint dots, visibility fades and combined crosshair tooltip, dashed current-value line, window-based scrolling (`windowSeconds`) with badge-aware right-edge buffer, frame-rate-independent lerp of value and Y-range, Y-range from visible data with the same margins (incl. `exaggerate`), TradingView-style grid intervals with hysteresis and per-label fades, time axis with nice intervals, crosshair with value/time tooltip via pan or long-press, candlestick mode with wicks and dashed close line, reference line, `formatValue`, loading squiggly with breathing alpha, empty state, pause with smooth freeze and time-debt catch-up on resume, render loop that stops when the view leaves the window. -Not (yet) ported: multi-series, badge pill, momentum detection/arrows, degen particles + shake, orderbook labels, line/candle morph + live candle animation, window-change transition, loading→data reveal morph, pause catch-up animation, left-edge fade, built-in window/mode/series toggle controls (build these in SwiftUI/UIKit around the view). +Not (yet) ported: degen particles + shake, orderbook labels, line/candle morph + live candle animation, window-change transition, loading→data reveal morph, left-edge fade, built-in window/mode/series toggle controls (build these in SwiftUI/UIKit around the view). diff --git a/Sources/LivelineCanvas/GridInterval.swift b/Sources/LivelineCanvas/GridInterval.swift new file mode 100644 index 0000000..d70d924 --- /dev/null +++ b/Sources/LivelineCanvas/GridInterval.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Pick a nice grid interval using TradingView's cycling divisor approach, +/// ported from `src/draw/grid.ts`. Hysteresis: once chosen, the interval +/// sticks until its pixel spacing falls outside [0.5×, 4×] of `minGap`. +func pickGridInterval(valueRange: Double, pxPerUnit: Double, minGap: Double, previous: Double) -> Double { + guard valueRange > 0, pxPerUnit > 0 else { return 1 } + + if previous > 0 { + let px = previous * pxPerUnit + if px >= minGap * 0.5 && px <= minGap * 4 { return previous } + } + + let divisorSets: [[Double]] = [[2, 2.5, 2], [2, 2, 2.5], [2.5, 2, 2]] + var best = Double.infinity + for divs in divisorSets { + var span = pow(10, ceil(log10(valueRange))) + var i = 0 + while span / divs[i % 3] * pxPerUnit >= minGap { + span /= divs[i % 3] + i += 1 + } + if span < best { best = span } + } + return best.isFinite ? best : valueRange / 5 +} + +/// Float-safe divisibility check. +func isDivisible(_ value: Double, by interval: Double) -> Bool { + guard interval != 0 else { return false } + let ratio = value / interval + return abs(ratio - ratio.rounded()) < 0.01 +} diff --git a/Sources/LivelineCanvas/LivelineCanvasView.swift b/Sources/LivelineCanvas/LivelineCanvasView.swift index 4df1cd9..ff8dc68 100644 --- a/Sources/LivelineCanvas/LivelineCanvasView.swift +++ b/Sources/LivelineCanvas/LivelineCanvasView.swift @@ -35,6 +35,18 @@ public final class LivelineCanvasView: UIView { didSet { setNeedsDisplay() } } + /// Multi-series mode: when non-empty, overrides `points`/`liveValue` + /// and disables badge/momentum/fill, like the React `series` prop. + public var series: [LivelineSeries] = [] { + didSet { setNeedsDisplay() } + } + + /// Series IDs currently hidden (they fade out smoothly). Toggle from + /// your own chips UI and the Y-range re-adjusts, as on the web. + public var hiddenSeriesIDs: Set = [] { + didSet { setNeedsDisplay() } + } + public var liveValue: Double = 0 { didSet { setNeedsDisplay() } } @@ -125,6 +137,8 @@ public final class LivelineCanvasView: UIView { rect: bounds, points: points, candles: candles, + series: series, + hiddenSeriesIDs: hiddenSeriesIDs, value: liveValue, config: config, palette: palette, @@ -166,7 +180,8 @@ public final class LivelineCanvasView: UIView { } private func emitHover(atX x: CGFloat) { - guard !points.isEmpty else { return } + let hoverData = series.isEmpty ? points : (series.first(where: { !hiddenSeriesIDs.contains($0.id) })?.data ?? []) + guard !hoverData.isEmpty else { return } let chartRect = bounds.inset(by: UIEdgeInsets( top: CGFloat(config.insets.top), left: CGFloat(config.insets.left), @@ -175,13 +190,17 @@ public final class LivelineCanvasView: UIView { )) guard chartRect.width > 0 else { return } - // Same time mapping as the renderer: the right edge is "now" - // (frozen while paused), not the newest data point. - let now = state.pausedAt ?? Date().timeIntervalSince1970 + // Same time mapping as the renderer: chart clock (wall clock minus + // pause debt) plus the small right-edge buffer. + let isMulti = !series.isEmpty + let showBadge = config.showBadge && config.mode == .line && !isMulti + let buffer = showBadge ? 0.05 : 0.015 + let now = Date().timeIntervalSince1970 - state.timeDebt + let rightEdgeTime = now + config.windowSeconds * buffer let ratio = clamp((x - chartRect.minX) / chartRect.width, 0, 1) - let targetTime = (now - config.windowSeconds) + TimeInterval(ratio) * config.windowSeconds + let targetTime = (rightEdgeTime - config.windowSeconds) + TimeInterval(ratio) * config.windowSeconds - let nearest = points.min { abs($0.time - targetTime) < abs($1.time - targetTime) } + let nearest = hoverData.min { abs($0.time - targetTime) < abs($1.time - targetTime) } onHover?(nearest) } } diff --git a/Sources/LivelineCanvas/Models.swift b/Sources/LivelineCanvas/Models.swift index afa461a..27a6711 100644 --- a/Sources/LivelineCanvas/Models.swift +++ b/Sources/LivelineCanvas/Models.swift @@ -46,8 +46,9 @@ public struct LivelineInsets: Sendable, Equatable { public var right: Double /// Defaults match the React component's padding: top 12, left 12, - /// bottom 28 (time axis room), right 54 (grid label room). - public init(top: Double = 12, left: Double = 12, bottom: Double = 28, right: Double = 54) { + /// bottom 28 (time axis room), right 80 (badge room; use 54 when the + /// badge is off, 12 when the grid is off too). + public init(top: Double = 12, left: Double = 12, bottom: Double = 28, right: Double = 80) { self.top = top self.left = left self.bottom = bottom @@ -55,6 +56,13 @@ public struct LivelineInsets: Sendable, Equatable { } } +public enum LivelineBadgeVariant: Sendable { + /// Accent/momentum-colored pill with white text. + case `default` + /// Neutral background pill with theme text color. + case minimal +} + public struct LivelineConfig: Sendable { public var mode: LivelineMode public var windowSeconds: TimeInterval @@ -64,13 +72,23 @@ public struct LivelineConfig: Sendable { public var showGrid: Bool public var showFill: Bool public var showCrosshair: Bool + /// Large live value in the top-right corner (React `showValue`). public var showValueLabel: Bool + /// Color the value label green/red by momentum. + public var valueMomentumColor: Bool /// Dashed horizontal line at the current value. public var showDashLine: Bool /// Live dot at the chart tip. public var showDot: Bool /// Expanding pulse ring on the live dot. public var pulse: Bool + /// Value pill tracking the chart tip (line mode, single series only). + public var showBadge: Bool + public var badgeVariant: LivelineBadgeVariant + /// Pointed tail on the badge pill. + public var badgeTail: Bool + /// Momentum styling: dot glow, chevron arrows, badge color. + public var momentum: LivelineMomentumMode /// Tight Y-range so small moves fill the chart height. public var exaggerate: Bool public var lineWidth: Double @@ -88,10 +106,15 @@ public struct LivelineConfig: Sendable { showGrid: Bool = true, showFill: Bool = true, showCrosshair: Bool = true, - showValueLabel: Bool = true, + showValueLabel: Bool = false, + valueMomentumColor: Bool = false, showDashLine: Bool = true, showDot: Bool = true, pulse: Bool = true, + showBadge: Bool = true, + badgeVariant: LivelineBadgeVariant = .default, + badgeTail: Bool = true, + momentum: LivelineMomentumMode = .auto, exaggerate: Bool = false, lineWidth: Double = 2, candleWidthSeconds: TimeInterval = 60, @@ -107,9 +130,14 @@ public struct LivelineConfig: Sendable { self.showFill = showFill self.showCrosshair = showCrosshair self.showValueLabel = showValueLabel + self.valueMomentumColor = valueMomentumColor self.showDashLine = showDashLine self.showDot = showDot self.pulse = pulse + self.showBadge = showBadge + self.badgeVariant = badgeVariant + self.badgeTail = badgeTail + self.momentum = momentum self.exaggerate = exaggerate self.lineWidth = lineWidth self.candleWidthSeconds = candleWidthSeconds diff --git a/Sources/LivelineCanvas/Momentum.swift b/Sources/LivelineCanvas/Momentum.swift new file mode 100644 index 0000000..fc8e354 --- /dev/null +++ b/Sources/LivelineCanvas/Momentum.swift @@ -0,0 +1,44 @@ +import Foundation + +public enum LivelineMomentum: Sendable, Equatable { + case up + case down + case flat +} + +/// How the chart decides the current momentum (dot glow, arrows, badge color). +public enum LivelineMomentumMode: Sendable, Equatable { + /// No momentum styling at all. + case off + /// Detect from recent data (default). + case auto + /// Force a fixed direction. + case fixed(LivelineMomentum) + + var isOff: Bool { self == .off } +} + +/// Port of `src/math/momentum.ts`: range over the last `lookback` points sets +/// the scale, velocity over the last 5 points sets the direction. Threshold +/// is 12% of the recent range. +func detectMomentum(points: [LivelinePoint], lookback: Int = 20) -> LivelineMomentum { + guard points.count >= 5 else { return .flat } + + let recent = Array(points.suffix(lookback)) + var lo = Double.infinity + var hi = -Double.infinity + for p in recent { + lo = Swift.min(lo, p.value) + hi = Swift.max(hi, p.value) + } + let range = hi - lo + guard range > 0 else { return .flat } + + let tail = Array(recent.suffix(5)) + let delta = tail[tail.count - 1].value - tail[0].value + let threshold = range * 0.12 + + if delta > threshold { return .up } + if delta < -threshold { return .down } + return .flat +} diff --git a/Sources/LivelineCanvas/Palette.swift b/Sources/LivelineCanvas/Palette.swift index 913ec60..744dc84 100644 --- a/Sources/LivelineCanvas/Palette.swift +++ b/Sources/LivelineCanvas/Palette.swift @@ -62,6 +62,19 @@ public struct LivelinePalette: Sendable { public static let `default` = LivelinePalette() + /// Default multi-series colors, same values as `SERIES_COLORS` in + /// `src/theme.ts` (blue, red, green, amber, violet, pink, cyan, orange). + public static let seriesColors: [UIColor] = [ + UIColor(red: 0x3b / 255, green: 0x82 / 255, blue: 0xf6 / 255, alpha: 1), + UIColor(red: 0xef / 255, green: 0x44 / 255, blue: 0x44 / 255, alpha: 1), + UIColor(red: 0x22 / 255, green: 0xc5 / 255, blue: 0x5e / 255, alpha: 1), + UIColor(red: 0xf5 / 255, green: 0x9e / 255, blue: 0x0b / 255, alpha: 1), + UIColor(red: 0x8b / 255, green: 0x5c / 255, blue: 0xf6 / 255, alpha: 1), + UIColor(red: 0xec / 255, green: 0x48 / 255, blue: 0x99 / 255, alpha: 1), + UIColor(red: 0x06 / 255, green: 0xb6 / 255, blue: 0xd4 / 255, alpha: 1), + UIColor(red: 0xf9 / 255, green: 0x73 / 255, blue: 0x16 / 255, alpha: 1), + ] + /// Derive a full palette from one accent color + theme, mirroring /// `resolveTheme` in `src/theme.ts`. Candle/momentum colors stay /// semantic green/red regardless of accent. diff --git a/Sources/LivelineCanvas/Renderer.swift b/Sources/LivelineCanvas/Renderer.swift index ebc1665..a2c11a6 100644 --- a/Sources/LivelineCanvas/Renderer.swift +++ b/Sources/LivelineCanvas/Renderer.swift @@ -9,16 +9,39 @@ struct RenderState { var displayMin: Double = 0 var displayMax: Double = 1 var initialized = false - /// Wall-clock time captured when `isPaused` flipped on; while set, the - /// chart clock is frozen so scrolling stops. (The React engine also - /// animates a smooth catch-up on resume; that part is not ported.) - var pausedAt: TimeInterval? + + /// 0 = running, 1 = fully paused; lerps between them so the chart + /// decelerates into a pause instead of stopping dead. + var pauseProgress: Double = 0 + /// Seconds the chart clock lags wall clock. Accumulates while paused, + /// drains after resume so the chart smoothly catches up + /// (PAUSE_CATCHUP_SPEED semantics from useLivelineEngine.ts). + var timeDebt: TimeInterval = 0 + + /// Grid interval hysteresis + per-label alpha smoothing + /// (key = value * 1000 rounded). + var gridInterval: Double = 0 + var gridLabels: [Int: Double] = [:] + + var momentum: LivelineMomentum = .flat + var arrowUp: Double = 0 + var arrowDown: Double = 0 + + var badgeWidth: Double = 0 + var badgeY: Double = 0 + var badgeColor: (r: Double, g: Double, b: Double) = (0, 0, 0) + var badgeColorInitialized = false + + var seriesAlphas: [String: Double] = [:] + var seriesValues: [String: Double] = [:] } struct RenderInput { let rect: CGRect let points: [LivelinePoint] let candles: [CandlePoint] + let series: [LivelineSeries] + let hiddenSeriesIDs: Set let value: Double let config: LivelineConfig let palette: LivelinePalette @@ -51,76 +74,160 @@ enum LivelineRenderer { return } - guard !(input.points.isEmpty && input.candles.isEmpty) else { + let isMulti = !input.series.isEmpty + let hasData = isMulti + ? input.series.contains { !$0.data.isEmpty } + : !(input.points.isEmpty && input.candles.isEmpty) + guard hasData else { drawEmpty(in: ctx, chartRect: chartRect, config: config, palette: palette, now: input.now) return } - // Freeze the chart clock while paused so scrolling stops. - if input.isPaused { - if state.pausedAt == nil { state.pausedAt = input.now } - } else { - state.pausedAt = nil + // --- Pause: decelerate into a frozen clock, catch up on resume --- + state.pauseProgress = lerp(state.pauseProgress, input.isPaused ? 1 : 0, speed: 0.12, dt: dt) + if state.pauseProgress > 0.001 { + state.timeDebt += dt * state.pauseProgress + } + if !input.isPaused, state.timeDebt > 0 { + let speed = state.timeDebt > 10 ? 0.22 : 0.08 + state.timeDebt = lerp(state.timeDebt, 0, speed: speed, dt: dt) + if state.timeDebt < 0.01 { state.timeDebt = 0 } } - let now = state.pausedAt ?? input.now + let now = input.now - state.timeDebt + let pausedDt = dt * (1 - state.pauseProgress) let window = Swift.max(config.windowSeconds, 1) - let visiblePoints = visibleSlice(input.points, leftTime: now - window) - let visibleCandles = input.candles.filter { $0.time >= now - window - config.candleWidthSeconds } - - let range = valueRange( - points: config.mode == .line ? visiblePoints : [], - candles: config.mode == .candle ? visibleCandles : [], - currentValue: input.value, - reference: input.referenceLine, - exaggerate: config.exaggerate - ) + // Small time buffer past "now" keeps the live dot inside the chart; + // wider when the badge needs room (WINDOW_BUFFER semantics). + let showBadge = config.showBadge && config.mode == .line && !isMulti + let buffer = showBadge ? 0.05 : 0.015 + let rightEdgeTime = now + window * buffer + let leftTime = rightEdgeTime - window + + // --- Momentum --- + let momentum: LivelineMomentum + switch config.momentum { + case .off: + momentum = .flat + case .fixed(let m): + momentum = m + case .auto: + momentum = (isMulti || config.mode == .candle) ? .flat : detectMomentum(points: input.points) + } + state.momentum = momentum + + // --- Y range + display lerps --- + let visiblePoints = isMulti ? [] : visibleSlice(input.points, leftTime: leftTime) + let visibleCandles = input.candles.filter { $0.time >= leftTime - config.candleWidthSeconds } + + let range: (min: Double, max: Double) + if isMulti { + var uMin = Double.infinity + var uMax = -Double.infinity + for s in input.series where !input.hiddenSeriesIDs.contains(s.id) && !s.data.isEmpty { + let visible = visibleSlice(s.data, leftTime: leftTime) + let r = valueRange( + points: visible, + candles: [], + currentValue: s.value, + reference: input.referenceLine, + exaggerate: config.exaggerate + ) + uMin = Swift.min(uMin, r.min) + uMax = Swift.max(uMax, r.max) + } + range = uMin.isFinite ? (uMin, uMax) : (0, 1) + } else { + range = valueRange( + points: config.mode == .line ? visiblePoints : [], + candles: config.mode == .candle ? visibleCandles : [], + currentValue: input.value, + reference: input.referenceLine, + exaggerate: config.exaggerate + ) + } + if !state.initialized { state.displayValue = input.value state.displayMin = range.min state.displayMax = range.max state.initialized = true - } else if !input.isPaused { - state.displayValue = lerp(state.displayValue, input.value, speed: config.lerpSpeed, dt: dt) - state.displayMin = lerp(state.displayMin, range.min, speed: config.lerpSpeed + 0.07, dt: dt) - state.displayMax = lerp(state.displayMax, range.max, speed: config.lerpSpeed + 0.07, dt: dt) + } else { + // Adaptive speed: small ticks track fast, big jumps glide + // (ADAPTIVE_SPEED_BOOST = 0.2, VALUE_SNAP_THRESHOLD = 0.001) + let span = state.displayMax - state.displayMin + let gapRatio = span > 0 ? clamp(abs(input.value - state.displayValue) / span, 0, 1) : 0 + let adaptive = config.lerpSpeed + (1 - gapRatio) * 0.2 + state.displayValue = lerp(state.displayValue, input.value, speed: adaptive, dt: pausedDt) + if span > 0, abs(input.value - state.displayValue) < span * 0.001, state.pauseProgress < 0.5 { + state.displayValue = input.value + } + state.displayMin = lerp(state.displayMin, range.min, speed: config.lerpSpeed + 0.07, dt: pausedDt) + state.displayMax = lerp(state.displayMax, range.max, speed: config.lerpSpeed + 0.07, dt: pausedDt) } let minValue = state.displayMin let maxValue = state.displayMax if config.showGrid { - drawGrid(in: ctx, chartRect: chartRect, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + drawGrid(in: ctx, chartRect: chartRect, minValue: minValue, maxValue: maxValue, config: config, palette: palette, state: &state, dt: dt) } - drawTimeAxis(in: ctx, chartRect: chartRect, now: now, window: window, palette: palette) + drawTimeAxis(in: ctx, chartRect: chartRect, rightEdgeTime: rightEdgeTime, window: window, palette: palette) if let reference = input.referenceLine { drawReferenceLine(in: ctx, chartRect: chartRect, minValue: minValue, maxValue: maxValue, value: reference.value, color: palette.reference) } - switch config.mode { - case .line: - drawLine(in: ctx, chartRect: chartRect, points: visiblePoints, smoothValue: state.displayValue, now: now, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) - if config.showDot, !visiblePoints.isEmpty { - let tipY = yForValue(state.displayValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect) - // Clamp to the canvas (not the chart area) so the dot stays visible - let dotY = clamp(tipY, rect.minY + 10, rect.maxY - 10) - drawDot(in: ctx, at: CGPoint(x: chartRect.maxX, y: dotY), palette: palette, pulse: config.pulse, now: input.now) + if isMulti { + drawMultiSeries( + in: ctx, chartRect: chartRect, series: input.series, hidden: input.hiddenSeriesIDs, + now: now, animNow: input.now, rightEdgeTime: rightEdgeTime, window: window, + minValue: minValue, maxValue: maxValue, config: config, palette: palette, + state: &state, dt: pausedDt + ) + } else { + switch config.mode { + case .line: + drawLine(in: ctx, chartRect: chartRect, points: visiblePoints, smoothValue: state.displayValue, tipTime: now, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + if !visiblePoints.isEmpty { + let tipX = xForTime(now, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) + let tipY = yForValue(state.displayValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + let dot = CGPoint(x: tipX, y: clamp(tipY, rect.minY + 10, rect.maxY - 10)) + if config.showDot { + let glow: UIColor? = config.momentum.isOff ? nil : glowColor(for: momentum, palette: palette) + drawDot(in: ctx, at: dot, palette: palette, pulse: config.pulse, glow: glow, now: input.now) + } + if !config.momentum.isOff { + drawArrows(in: ctx, at: dot, momentum: momentum, palette: palette, state: &state, dt: dt, now: input.now) + } + if showBadge { + drawBadge(in: ctx, rect: rect, chartRect: chartRect, value: state.displayValue, valueY: tipY, momentum: momentum, config: config, palette: palette, state: &state, dt: dt) + } + } + case .candle: + drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) } - case .candle: - drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, now: now, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) } if let hoverX = input.hoverX, config.showCrosshair { - drawCrosshair(in: ctx, chartRect: chartRect, x: hoverX, points: input.points, now: now, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + if isMulti { + drawMultiCrosshair(in: ctx, chartRect: chartRect, x: hoverX, series: input.series, hidden: input.hiddenSeriesIDs, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + } else { + drawCrosshair(in: ctx, chartRect: chartRect, x: hoverX, points: input.points, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + } } if config.showValueLabel { + var color = palette.text + if config.valueMomentumColor { + if momentum == .up { color = palette.upCandle } + if momentum == .down { color = palette.downCandle } + } drawText( config.formatValue(state.displayValue), at: CGPoint(x: rect.maxX - 12, y: rect.minY + 8), anchorX: 1, - font: .monospacedDigitSystemFont(ofSize: 12, weight: .medium), - color: palette.text + font: .monospacedDigitSystemFont(ofSize: 14, weight: .medium), + color: color ) } } @@ -129,7 +236,7 @@ enum LivelineRenderer { /// Points inside the window plus one point just left of it, so the line /// enters from the chart edge instead of popping in. - private static func visibleSlice(_ points: [LivelinePoint], leftTime: TimeInterval) -> [LivelinePoint] { + static func visibleSlice(_ points: [LivelinePoint], leftTime: TimeInterval) -> [LivelinePoint] { guard let firstVisible = points.firstIndex(where: { $0.time >= leftTime }) else { // Everything is older than the window — keep the newest point so // the live tip still has an anchor. @@ -139,12 +246,12 @@ enum LivelineRenderer { return Array(points[start...]) } - private static func xForTime(_ t: TimeInterval, now: TimeInterval, window: TimeInterval, chartRect: CGRect) -> CGFloat { - let ratio = CGFloat((t - (now - window)) / window) + static func xForTime(_ t: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, chartRect: CGRect) -> CGFloat { + let ratio = CGFloat((t - (rightEdgeTime - window)) / window) return chartRect.minX + ratio * chartRect.width } - private static func yForValue(_ v: Double, minValue: Double, maxValue: Double, chartRect: CGRect) -> CGFloat { + static func yForValue(_ v: Double, minValue: Double, maxValue: Double, chartRect: CGRect) -> CGFloat { let span = maxValue - minValue guard span > 0 else { return chartRect.midY } let ratio = CGFloat((v - minValue) / span) @@ -153,36 +260,99 @@ enum LivelineRenderer { // MARK: - Grid + axes - private static func drawGrid(in ctx: CGContext, chartRect: CGRect, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { - let rows = 4 - ctx.saveGState() - ctx.setStrokeColor(palette.grid.cgColor) - ctx.setLineWidth(1) - ctx.setLineDash(phase: 0, lengths: [1, 3]) - for i in 0...rows { - let y = chartRect.minY + (CGFloat(i) / CGFloat(rows)) * chartRect.height - ctx.move(to: CGPoint(x: chartRect.minX, y: y)) - ctx.addLine(to: CGPoint(x: chartRect.maxX, y: y)) + /// TradingView-style value grid with interval hysteresis and per-label + /// fade in/out, ported from `src/draw/grid.ts`. + static func drawGrid(in ctx: CGContext, chartRect: CGRect, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, state: inout RenderState, dt: Double) { + let valRange = maxValue - minValue + guard valRange > 0 else { return } + let pxPerUnit = Double(chartRect.height) / valRange + + let coarse = pickGridInterval(valueRange: valRange, pxPerUnit: pxPerUnit, minGap: 36, previous: state.gridInterval) + state.gridInterval = coarse + let fine = coarse / 2 + let finePx = fine * pxPerUnit + let fineTarget = finePx < 40 ? 0 : finePx >= 60 ? 1 : (finePx - 40) / 20 + + let fadeZone = 32.0 + func edgeAlpha(_ y: CGFloat) -> Double { + let fromEdge = Double(Swift.min(y - chartRect.minY, chartRect.maxY - y)) + if fromEdge >= fadeZone { return 1 } + if fromEdge <= 0 { return 0 } + return fromEdge / fadeZone + } + + // Phase 1: target alpha for every label position currently in range + var targets: [Int: Double] = [:] + var val = (minValue / fine).rounded(.up) * fine + var guardCount = 0 + while val <= maxValue, guardCount < 512 { + guardCount += 1 + let y = yForValue(val, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + if y >= chartRect.minY - 2, y <= chartRect.maxY + 2 { + let isCoarse = isDivisible(val, by: coarse) + targets[Int((val * 1000).rounded())] = (isCoarse ? 1 : fineTarget) * edgeAlpha(y) + } + val += fine } - ctx.strokePath() - ctx.restoreGState() + // Phase 2: smooth all tracked label alphas (FADE_IN 0.18 / FADE_OUT 0.12) + for (key, alpha) in state.gridLabels { + let target = targets[key] ?? 0 + let speed = target >= alpha ? 0.18 : 0.12 + var next = lerp(alpha, target, speed: speed, dt: dt) + if abs(next - target) < 0.02 { next = target } + if next < 0.01, target == 0 { + state.gridLabels.removeValue(forKey: key) + } else { + state.gridLabels[key] = next + } + } + for (key, target) in targets where state.gridLabels[key] == nil { + state.gridLabels[key] = target * 0.18 + } + + // Phase 3: draw let font = UIFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) - for i in 0...rows { - let ratio = Double(i) / Double(rows) - let value = maxValue - ratio * (maxValue - minValue) - let y = chartRect.minY + CGFloat(ratio) * chartRect.height + for (key, alpha) in state.gridLabels { + guard alpha >= 0.02 else { continue } + let v = Double(key) / 1000 + let y = yForValue(v, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + guard y >= chartRect.minY - 10, y <= chartRect.maxY + 10 else { continue } + + ctx.saveGState() + ctx.setStrokeColor(palette.grid.withAlphaComponent(CGFloat(alpha) * gridLineAlpha(palette)).cgColor) + ctx.setLineWidth(1) + ctx.setLineDash(phase: 0, lengths: [1, 3]) + ctx.move(to: CGPoint(x: chartRect.minX, y: y)) + ctx.addLine(to: CGPoint(x: chartRect.maxX, y: y)) + ctx.strokePath() + ctx.restoreGState() + drawText( - config.formatValue(value), + config.formatValue(v), at: CGPoint(x: chartRect.maxX + 8, y: y - font.lineHeight / 2), anchorX: 0, font: font, - color: palette.gridLabel + color: palette.gridLabel.withAlphaComponent(CGFloat(alpha) * labelBaseAlpha(palette)) ) } } - private static func niceTimeInterval(_ windowSecs: TimeInterval) -> TimeInterval { + /// The palette's grid/label colors already carry their own alpha; scaling + /// with the fade alpha needs the base value so fades multiply correctly. + private static func gridLineAlpha(_ palette: LivelinePalette) -> CGFloat { + var a: CGFloat = 1 + palette.grid.getRed(nil, green: nil, blue: nil, alpha: &a) + return a + } + + private static func labelBaseAlpha(_ palette: LivelinePalette) -> CGFloat { + var a: CGFloat = 1 + palette.gridLabel.getRed(nil, green: nil, blue: nil, alpha: &a) + return a + } + + static func niceTimeInterval(_ windowSecs: TimeInterval) -> TimeInterval { switch windowSecs { case ...15: return 2 case ...30: return 5 @@ -200,7 +370,7 @@ enum LivelineRenderer { } } - private static func drawTimeAxis(in ctx: CGContext, chartRect: CGRect, now: TimeInterval, window: TimeInterval, palette: LivelinePalette) { + static func drawTimeAxis(in ctx: CGContext, chartRect: CGRect, rightEdgeTime: TimeInterval, window: TimeInterval, palette: LivelinePalette) { ctx.saveGState() ctx.setStrokeColor(palette.grid.cgColor) ctx.setLineWidth(1) @@ -212,10 +382,10 @@ enum LivelineRenderer { while chartRect.width * CGFloat(interval / window) < 60 { interval *= 2 } let font = UIFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) - var t = ((now - window) / interval).rounded(.up) * interval + var t = ((rightEdgeTime - window) / interval).rounded(.up) * interval ctx.setStrokeColor(palette.timeLabel.cgColor) - while t <= now { - let x = xForTime(t, now: now, window: window, chartRect: chartRect) + while t <= rightEdgeTime { + let x = xForTime(t, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) ctx.move(to: CGPoint(x: x, y: chartRect.maxY)) ctx.addLine(to: CGPoint(x: x, y: chartRect.maxY + 5)) ctx.strokePath() @@ -233,36 +403,53 @@ enum LivelineRenderer { // MARK: - Line mode - private static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], smoothValue: Double, now: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { - guard !points.isEmpty else { return } + static func linePoints(_ points: [LivelinePoint], smoothValue: Double, tipTime: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, chartRect: CGRect) -> [CGPoint] { + guard !points.isEmpty else { return [] } let clampY: (CGFloat) -> CGFloat = { clamp($0, chartRect.minY, chartRect.maxY) } // Historical points keep their data values; the LAST data point takes // the interpolated value so big jumps animate instead of snapping, - // then the live tip is appended at the right edge (matches - // src/draw/line.ts). + // then the live tip is appended at tipTime (src/draw/line.ts). var pts: [CGPoint] = [] pts.reserveCapacity(points.count + 1) for (index, p) in points.enumerated() { - let x = xForTime(p.time, now: now, window: window, chartRect: chartRect) + let x = xForTime(p.time, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) let v = index == points.count - 1 ? smoothValue : p.value pts.append(CGPoint(x: x, y: clampY(yForValue(v, minValue: minValue, maxValue: maxValue, chartRect: chartRect)))) } - let tipY = clampY(yForValue(smoothValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect)) - pts.append(CGPoint(x: chartRect.maxX, y: tipY)) - guard pts.count >= 2 else { return } + let tipX = xForTime(tipTime, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) + pts.append(CGPoint(x: tipX, y: clampY(yForValue(smoothValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect)))) + return pts + } - let linePath = CGMutablePath() - linePath.move(to: pts[0]) - for segment in monotoneSplineSegments(pts) { - linePath.addCurve(to: segment.end, control1: segment.control1, control2: segment.control2) + static func strokeSpline(_ ctx: CGContext, points: [CGPoint], color: UIColor, lineWidth: CGFloat) { + guard points.count >= 2 else { return } + let path = CGMutablePath() + path.move(to: points[0]) + for segment in monotoneSplineSegments(points) { + path.addCurve(to: segment.end, control1: segment.control1, control2: segment.control2) } + ctx.setStrokeColor(color.cgColor) + ctx.setLineWidth(lineWidth) + ctx.setLineJoin(.round) + ctx.setLineCap(.round) + ctx.addPath(path) + ctx.strokePath() + } + + static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], smoothValue: Double, tipTime: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { + let pts = linePoints(points, smoothValue: smoothValue, tipTime: tipTime, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + guard pts.count >= 2 else { return } ctx.saveGState() ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) if config.showFill { - let fillPath = linePath.mutableCopy()! + let fillPath = CGMutablePath() + fillPath.move(to: pts[0]) + for segment in monotoneSplineSegments(pts) { + fillPath.addCurve(to: segment.end, control1: segment.control1, control2: segment.control2) + } fillPath.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: chartRect.maxY)) fillPath.addLine(to: CGPoint(x: pts[0].x, y: chartRect.maxY)) fillPath.closeSubpath() @@ -286,15 +473,14 @@ enum LivelineRenderer { ctx.restoreGState() } - ctx.setStrokeColor(palette.line.cgColor) - ctx.setLineWidth(CGFloat(config.lineWidth)) - ctx.setLineJoin(.round) - ctx.setLineCap(.round) - ctx.addPath(linePath) - ctx.strokePath() + strokeSpline(ctx, points: pts, color: palette.line, lineWidth: CGFloat(config.lineWidth)) ctx.restoreGState() if config.showDashLine { + let tipY = clamp( + yForValue(smoothValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect), + chartRect.minY, chartRect.maxY + ) ctx.saveGState() ctx.setStrokeColor(palette.dashLine.cgColor) ctx.setLineWidth(1) @@ -306,9 +492,18 @@ enum LivelineRenderer { } } + static func glowColor(for momentum: LivelineMomentum, palette: LivelinePalette) -> UIColor { + switch momentum { + case .up: return palette.upCandle.withAlphaComponent(0.5) + case .down: return palette.downCandle.withAlphaComponent(0.5) + case .flat: return palette.line.withAlphaComponent(0.35) + } + } + /// Live dot: expanding accent pulse ring (1.5s interval, 0.9s duration), /// outer circle with shadow, colored inner dot — port of src/draw/dot.ts. - private static func drawDot(in ctx: CGContext, at point: CGPoint, palette: LivelinePalette, pulse: Bool, now: TimeInterval) { + /// `glow` adds a momentum-colored halo behind the dot. + static func drawDot(in ctx: CGContext, at point: CGPoint, palette: LivelinePalette, pulse: Bool, glow: UIColor?, now: TimeInterval) { if pulse { let t = now.truncatingRemainder(dividingBy: 1.5) / 0.9 if t < 1 { @@ -322,7 +517,11 @@ enum LivelineRenderer { } ctx.saveGState() - ctx.setShadow(offset: CGSize(width: 0, height: 1), blur: 6, color: UIColor.black.withAlphaComponent(0.4).cgColor) + if let glow { + ctx.setShadow(offset: .zero, blur: 14, color: glow.cgColor) + } else { + ctx.setShadow(offset: CGSize(width: 0, height: 1), blur: 6, color: UIColor.black.withAlphaComponent(0.4).cgColor) + } ctx.setFillColor(palette.dotOuter.cgColor) ctx.fillEllipse(in: CGRect(x: point.x - 6.5, y: point.y - 6.5, width: 13, height: 13)) ctx.restoreGState() @@ -333,7 +532,7 @@ enum LivelineRenderer { // MARK: - Candle mode - private static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], now: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { + static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { guard !candles.isEmpty else { return } let pxPerSecond = chartRect.width / CGFloat(window) let bodyWidth = Swift.max(1, pxPerSecond * CGFloat(config.candleWidthSeconds) * 0.7) @@ -343,7 +542,7 @@ enum LivelineRenderer { ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) for c in candles { // Candle time is the bucket's open time — center the body on it - let x = xForTime(c.time + config.candleWidthSeconds / 2, now: now, window: window, chartRect: chartRect) + let x = xForTime(c.time + config.candleWidthSeconds / 2, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) let yOpen = yForValue(c.open, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let yClose = yForValue(c.close, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let yHigh = yForValue(c.high, minValue: minValue, maxValue: maxValue, chartRect: chartRect) @@ -391,7 +590,7 @@ enum LivelineRenderer { // MARK: - Overlays - private static func drawCrosshair(in ctx: CGContext, chartRect: CGRect, x: CGFloat, points: [LivelinePoint], now: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { + static func drawCrosshair(in ctx: CGContext, chartRect: CGRect, x: CGFloat, points: [LivelinePoint], rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { let clampedX = clamp(x, chartRect.minX, chartRect.maxX) ctx.saveGState() @@ -403,7 +602,7 @@ enum LivelineRenderer { ctx.restoreGState() guard !points.isEmpty else { return } - let hoverTime = (now - window) + TimeInterval((clampedX - chartRect.minX) / Swift.max(chartRect.width, 1)) * window + let hoverTime = (rightEdgeTime - window) + TimeInterval((clampedX - chartRect.minX) / Swift.max(chartRect.width, 1)) * window guard let value = interpolatedValue(points, at: hoverTime) else { return } // Marker dot on the line at the hovered value @@ -425,7 +624,7 @@ enum LivelineRenderer { ) } - private static func drawReferenceLine(in ctx: CGContext, chartRect: CGRect, minValue: Double, maxValue: Double, value: Double, color: UIColor) { + static func drawReferenceLine(in ctx: CGContext, chartRect: CGRect, minValue: Double, maxValue: Double, value: Double, color: UIColor) { let y = yForValue(value, minValue: minValue, maxValue: maxValue, chartRect: chartRect) guard y >= chartRect.minY - 10, y <= chartRect.maxY + 10 else { return } ctx.saveGState() @@ -441,7 +640,7 @@ enum LivelineRenderer { // MARK: - Loading / empty /// Breathing squiggly line, same shape constants as src/draw/loadingShape.ts. - private static func drawSquiggly(in ctx: CGContext, chartRect: CGRect, palette: LivelinePalette, now: TimeInterval, alphaScale: Double = 1) { + static func drawSquiggly(in ctx: CGContext, chartRect: CGRect, palette: LivelinePalette, now: TimeInterval, alphaScale: Double = 1) { // Keep the sine arguments small for precision — the shape repeats anyway let ms = now.truncatingRemainder(dividingBy: 86_400) * 1000 let scroll = ms * 0.001 @@ -462,23 +661,10 @@ enum LivelineRenderer { pts.append(CGPoint(x: chartRect.minX + CGFloat(t) * chartRect.width, y: CGFloat(y))) } - let path = CGMutablePath() - path.move(to: pts[0]) - for segment in monotoneSplineSegments(pts) { - path.addCurve(to: segment.end, control1: segment.control1, control2: segment.control2) - } - - ctx.saveGState() - ctx.setStrokeColor(palette.gridLabel.withAlphaComponent(CGFloat(breath * alphaScale)).cgColor) - ctx.setLineWidth(2) - ctx.setLineJoin(.round) - ctx.setLineCap(.round) - ctx.addPath(path) - ctx.strokePath() - ctx.restoreGState() + strokeSpline(ctx, points: pts, color: palette.gridLabel.withAlphaComponent(CGFloat(breath * alphaScale)), lineWidth: 2) } - private static func drawEmpty(in ctx: CGContext, chartRect: CGRect, config: LivelineConfig, palette: LivelinePalette, now: TimeInterval) { + static func drawEmpty(in ctx: CGContext, chartRect: CGRect, config: LivelineConfig, palette: LivelinePalette, now: TimeInterval) { drawSquiggly(in: ctx, chartRect: chartRect, palette: palette, now: now) let font = UIFont.systemFont(ofSize: 12, weight: .regular) @@ -504,7 +690,7 @@ enum LivelineRenderer { // MARK: - Text helpers - private static func timeString(_ t: TimeInterval) -> String { + static func timeString(_ t: TimeInterval) -> String { let comps = Calendar.current.dateComponents( [.hour, .minute, .second], from: Date(timeIntervalSince1970: t) @@ -512,12 +698,12 @@ enum LivelineRenderer { return String(format: "%02d:%02d:%02d", comps.hour ?? 0, comps.minute ?? 0, comps.second ?? 0) } - private static func textSize(_ string: String, font: UIFont) -> CGSize { + static func textSize(_ string: String, font: UIFont) -> CGSize { (string as NSString).size(withAttributes: [.font: font]) } @discardableResult - private static func drawText(_ string: String, at point: CGPoint, anchorX: CGFloat, font: UIFont, color: UIColor) -> CGSize { + static func drawText(_ string: String, at point: CGPoint, anchorX: CGFloat, font: UIFont, color: UIColor) -> CGSize { let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] let size = (string as NSString).size(withAttributes: attributes) let origin = CGPoint(x: point.x - size.width * anchorX, y: point.y) diff --git a/Sources/LivelineCanvas/RendererBadge.swift b/Sources/LivelineCanvas/RendererBadge.swift new file mode 100644 index 0000000..7cdd209 --- /dev/null +++ b/Sources/LivelineCanvas/RendererBadge.swift @@ -0,0 +1,165 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +import CoreGraphics + +/// Badge pill + momentum arrows — ports of src/draw/badge.ts and the arrow +/// part of src/draw/dot.ts. The React badge is a DOM/SVG overlay; here it is +/// drawn straight into the canvas. +extension LivelineRenderer { + + // Badge geometry constants from src/draw/badge.ts + private static var badgePadX: CGFloat { 10 } + private static var badgeLineH: CGFloat { 16 } + private static var badgePadY: CGFloat { 3 } + private static var badgeTailLen: CGFloat { 5 } + private static var badgeTailSpread: CGFloat { 2.5 } + + static func drawBadge(in ctx: CGContext, rect: CGRect, chartRect: CGRect, value: Double, valueY: CGFloat, momentum: LivelineMomentum, config: LivelineConfig, palette: LivelinePalette, state: inout RenderState, dt: Double) { + // Hidden while pausing, like the web version + let visibility = 1 - state.pauseProgress + guard visibility > 0.02 else { return } + + let text = config.formatValue(value) + let font = UIFont.monospacedDigitSystemFont(ofSize: 11, weight: .medium) + let targetW = Double(textSize(text, font: font).width) + Double(badgePadX * 2) + + // Width lerps at 0.15, Y tracks the value at 0.35 (badge.ts constants) + state.badgeWidth = state.badgeWidth == 0 ? targetW : lerp(state.badgeWidth, targetW, speed: 0.15, dt: dt) + state.badgeY = state.badgeY == 0 ? Double(valueY) : lerp(state.badgeY, Double(valueY), speed: 0.35, dt: dt) + + // Momentum color: lerp toward green/red/accent at 0.12 + let targetColor: UIColor + if config.momentum.isOff { + targetColor = palette.line + } else { + switch momentum { + case .up: targetColor = palette.upCandle + case .down: targetColor = palette.downCandle + case .flat: targetColor = palette.line + } + } + var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0 + _ = targetColor.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) + if !state.badgeColorInitialized { + state.badgeColor = (Double(tr), Double(tg), Double(tb)) + state.badgeColorInitialized = true + } else { + state.badgeColor.r = lerp(state.badgeColor.r, Double(tr), speed: 0.12, dt: dt) + state.badgeColor.g = lerp(state.badgeColor.g, Double(tg), speed: 0.12, dt: dt) + state.badgeColor.b = lerp(state.badgeColor.b, Double(tb), speed: 0.12, dt: dt) + } + + let fillColor: UIColor + let textColor: UIColor + switch config.badgeVariant { + case .default: + fillColor = UIColor( + red: CGFloat(state.badgeColor.r), + green: CGFloat(state.badgeColor.g), + blue: CGFloat(state.badgeColor.b), + alpha: CGFloat(visibility) + ) + textColor = UIColor(white: 1, alpha: CGFloat(visibility)) + case .minimal: + fillColor = palette.dotOuter.withAlphaComponent(CGFloat(visibility * 0.95)) + textColor = palette.text.withAlphaComponent(CGFloat(visibility)) + } + + let pillH = badgeLineH + badgePadY * 2 + let tailLen = config.badgeTail ? badgeTailLen : 0 + // Tail tip sits just right of the chart area, pointing at the dot + let tipX = chartRect.maxX + 8 + let midY = clamp(CGFloat(state.badgeY), rect.minY + pillH / 2, rect.maxY - pillH / 2) + let pillRect = CGRect(x: tipX + tailLen, y: midY - pillH / 2, width: CGFloat(state.badgeWidth), height: pillH) + + let path = CGMutablePath() + addRoundedRect(pillRect, radius: pillH / 2, to: path) + if config.badgeTail { + path.move(to: CGPoint(x: pillRect.minX + 3, y: midY - 5)) + path.addQuadCurve(to: CGPoint(x: tipX, y: midY), control: CGPoint(x: tipX + 2, y: midY - badgeTailSpread)) + path.addQuadCurve(to: CGPoint(x: pillRect.minX + 3, y: midY + 5), control: CGPoint(x: tipX + 2, y: midY + badgeTailSpread)) + path.closeSubpath() + } + + ctx.saveGState() + if config.badgeVariant == .minimal { + ctx.setShadow(offset: CGSize(width: 0, height: 1), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) + } + ctx.setFillColor(fillColor.cgColor) + ctx.addPath(path) + ctx.fillPath() + ctx.restoreGState() + + drawText( + text, + at: CGPoint(x: pillRect.midX, y: midY - font.lineHeight / 2), + anchorX: 0.5, + font: font, + color: textColor + ) + } + + private static func addRoundedRect(_ rect: CGRect, radius: CGFloat, to path: CGMutablePath) { + let r = Swift.min(radius, Swift.min(rect.width, rect.height) / 2) + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), tangent2End: CGPoint(x: rect.maxX, y: rect.maxY), radius: r) + path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), tangent2End: CGPoint(x: rect.minX, y: rect.maxY), radius: r) + path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), tangent2End: CGPoint(x: rect.minX, y: rect.minY), radius: r) + path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), tangent2End: CGPoint(x: rect.maxX, y: rect.minY), radius: r) + path.closeSubpath() + } + + /// Momentum chevrons next to the dot — directional cascade, old direction + /// fades out fully before the new one fades in (src/draw/dot.ts drawArrows). + static func drawArrows(in ctx: CGContext, at dot: CGPoint, momentum: LivelineMomentum, palette: LivelinePalette, state: inout RenderState, dt: Double, now: TimeInterval) { + let upTarget = momentum == .up ? 1.0 : 0.0 + let downTarget = momentum == .down ? 1.0 : 0.0 + let canFadeInUp = state.arrowDown < 0.02 + let canFadeInDown = state.arrowUp < 0.02 + + state.arrowUp = lerp(state.arrowUp, canFadeInUp ? upTarget : 0, speed: upTarget > state.arrowUp ? 0.08 : 0.04, dt: dt) + state.arrowDown = lerp(state.arrowDown, canFadeInDown ? downTarget : 0, speed: downTarget > state.arrowDown ? 0.08 : 0.04, dt: dt) + if state.arrowUp < 0.01 { state.arrowUp = 0 } + if state.arrowDown < 0.01 { state.arrowDown = 0 } + if state.arrowUp > 0.99 { state.arrowUp = 1 } + if state.arrowDown > 0.99 { state.arrowDown = 1 } + + let ms = now.truncatingRemainder(dividingBy: 86_400) * 1000 + let cycle = ms.truncatingRemainder(dividingBy: 1400) / 1400 + + func chevrons(dir: CGFloat, opacity: Double) { + guard opacity >= 0.01 else { return } + let baseX = dot.x + 19 + + ctx.saveGState() + ctx.setLineWidth(2.5) + ctx.setLineJoin(.round) + ctx.setLineCap(.round) + + for i in 0..<2 { + // Stagger: arrow 0 brightens at t=0, arrow 1 at t=0.2; both + // stay visible at min 0.3 while the cascade brightens them + let start = Double(i) * 0.2 + let dur = 0.35 + let localT = cycle - start + let wave = (localT >= 0 && localT < dur) ? sin((localT / dur) * .pi) : 0 + let pulse = 0.3 + 0.7 * wave + + ctx.setStrokeColor(palette.gridLabel.withAlphaComponent(CGFloat(opacity * pulse)).cgColor) + let nudge: CGFloat = dir == -1 ? -3 : 3 + let cy = dot.y + dir * (CGFloat(i) * 8 - 4) + nudge + ctx.move(to: CGPoint(x: baseX - 5, y: cy - dir * 3.5)) + ctx.addLine(to: CGPoint(x: baseX, y: cy)) + ctx.addLine(to: CGPoint(x: baseX + 5, y: cy - dir * 3.5)) + ctx.strokePath() + } + ctx.restoreGState() + } + + chevrons(dir: -1, opacity: state.arrowUp) + chevrons(dir: 1, opacity: state.arrowDown) + } +} +#endif diff --git a/Sources/LivelineCanvas/RendererMulti.swift b/Sources/LivelineCanvas/RendererMulti.swift new file mode 100644 index 0000000..a6df766 --- /dev/null +++ b/Sources/LivelineCanvas/RendererMulti.swift @@ -0,0 +1,138 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +import CoreGraphics + +/// Multi-series rendering — port of drawMultiFrame in src/draw/index.ts. +/// Fill, badge, momentum and the dashed value line are disabled; each series +/// gets its own line, endpoint dot, optional label, and a visibility alpha +/// that fades at SERIES_TOGGLE_SPEED (0.10) when hidden/shown. +extension LivelineRenderer { + + static func seriesColor(_ series: LivelineSeries, index: Int) -> UIColor { + series.color ?? LivelinePalette.seriesColors[index % LivelinePalette.seriesColors.count] + } + + static func drawMultiSeries(in ctx: CGContext, chartRect: CGRect, series: [LivelineSeries], hidden: Set, now: TimeInterval, animNow: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, state: inout RenderState, dt: Double) { + for (index, s) in series.enumerated() { + let targetAlpha = hidden.contains(s.id) ? 0.0 : 1.0 + var alpha = lerp(state.seriesAlphas[s.id] ?? targetAlpha, targetAlpha, speed: 0.10, dt: dt) + if alpha < 0.005, targetAlpha == 0 { alpha = 0 } + if alpha > 0.995, targetAlpha == 1 { alpha = 1 } + state.seriesAlphas[s.id] = alpha + + guard !s.data.isEmpty else { + state.seriesValues[s.id] = s.value + continue + } + let smooth = lerp(state.seriesValues[s.id] ?? s.value, s.value, speed: config.lerpSpeed + 0.05, dt: dt) + state.seriesValues[s.id] = smooth + + guard alpha > 0.01 else { continue } + let color = seriesColor(s, index: index) + + let visible = visibleSlice(s.data, leftTime: rightEdgeTime - window) + let pts = linePoints(visible, smoothValue: smooth, tipTime: now, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + guard pts.count >= 2 else { continue } + + ctx.saveGState() + ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) + strokeSpline(ctx, points: pts, color: color.withAlphaComponent(CGFloat(alpha)), lineWidth: CGFloat(config.lineWidth)) + ctx.restoreGState() + + // Endpoint dot: colored ring pulse + solid dot (drawMultiDot) + let tip = pts[pts.count - 1] + if config.showDot { + drawMultiDot(in: ctx, at: tip, color: color, alpha: alpha, pulse: config.pulse, now: animNow) + } + + if let label = s.label { + drawText( + label, + at: CGPoint(x: tip.x + 9, y: tip.y - 5), + anchorX: 0, + font: .systemFont(ofSize: 10, weight: .semibold), + color: color.withAlphaComponent(CGFloat(alpha)) + ) + } + } + } + + /// Series endpoint dot: expanding colored ring (1.5s interval) + solid + /// dot, no white outer, no shadow — port of drawMultiDot in src/draw/dot.ts. + static func drawMultiDot(in ctx: CGContext, at point: CGPoint, color: UIColor, alpha: Double, pulse: Bool, now: TimeInterval) { + if pulse { + let t = now.truncatingRemainder(dividingBy: 1.5) / 0.9 + if t < 1 { + let radius = CGFloat(9 + t * 10) + ctx.saveGState() + ctx.setStrokeColor(color.withAlphaComponent(CGFloat(0.3 * (1 - t) * alpha)).cgColor) + ctx.setLineWidth(1.5) + ctx.strokeEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)) + ctx.restoreGState() + } + } + ctx.setFillColor(color.withAlphaComponent(CGFloat(alpha)).cgColor) + ctx.fillEllipse(in: CGRect(x: point.x - 3, y: point.y - 3, width: 6, height: 6)) + } + + /// Multi-series crosshair: one marker dot per visible series plus an + /// inline "TIME · ●Label V · ..." tooltip (drawMultiCrosshair port). + static func drawMultiCrosshair(in ctx: CGContext, chartRect: CGRect, x: CGFloat, series: [LivelineSeries], hidden: Set, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { + let clampedX = clamp(x, chartRect.minX, chartRect.maxX) + + ctx.saveGState() + ctx.setStrokeColor(palette.crosshair.cgColor) + ctx.setLineWidth(1) + ctx.move(to: CGPoint(x: clampedX, y: chartRect.minY)) + ctx.addLine(to: CGPoint(x: clampedX, y: chartRect.maxY)) + ctx.strokePath() + ctx.restoreGState() + + let hoverTime = (rightEdgeTime - window) + TimeInterval((clampedX - chartRect.minX) / Swift.max(chartRect.width, 1)) * window + + struct Segment { + let color: UIColor + let text: String + } + var segments: [Segment] = [] + for (index, s) in series.enumerated() where !hidden.contains(s.id) && !s.data.isEmpty { + guard let value = interpolatedValue(s.data, at: hoverTime) else { continue } + let color = seriesColor(s, index: index) + + let y = clamp(yForValue(value, minValue: minValue, maxValue: maxValue, chartRect: chartRect), chartRect.minY, chartRect.maxY) + ctx.setFillColor(color.cgColor) + ctx.fillEllipse(in: CGRect(x: clampedX - 3, y: y - 3, width: 6, height: 6)) + + let label = s.label ?? s.id + segments.append(Segment(color: color, text: "\(label) \(config.formatValue(value))")) + } + guard !segments.isEmpty else { return } + + // Layout: time first, then per-series bullet + text + let font = UIFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + let timeText = timeString(hoverTime) + let bulletR: CGFloat = 2.5 + let gap: CGFloat = 6 + + var totalW = textSize(timeText, font: font).width + for segment in segments { + totalW += gap + bulletR * 2 + 3 + textSize(segment.text, font: font).width + } + + var cursorX = clamp(clampedX - totalW / 2, chartRect.minX + 4, Swift.max(chartRect.minX + 4, chartRect.maxX - totalW - 4)) + let textY = chartRect.minY + CGFloat(config.tooltipY) - font.lineHeight / 2 + let centerY = textY + font.lineHeight / 2 + + cursorX += drawText(timeText, at: CGPoint(x: cursorX, y: textY), anchorX: 0, font: font, color: palette.gridLabel).width + for segment in segments { + cursorX += gap + ctx.setFillColor(segment.color.cgColor) + ctx.fillEllipse(in: CGRect(x: cursorX, y: centerY - bulletR, width: bulletR * 2, height: bulletR * 2)) + cursorX += bulletR * 2 + 3 + cursorX += drawText(segment.text, at: CGPoint(x: cursorX, y: textY), anchorX: 0, font: font, color: palette.text).width + } + } +} +#endif diff --git a/Sources/LivelineCanvas/Series.swift b/Sources/LivelineCanvas/Series.swift new file mode 100644 index 0000000..a5bb83d --- /dev/null +++ b/Sources/LivelineCanvas/Series.swift @@ -0,0 +1,25 @@ +import Foundation + +#if canImport(UIKit) +import UIKit + +/// One line in multi-series mode. When a `LivelineCanvasView`'s `series` is +/// non-empty it overrides `points`/`liveValue`, and badge/momentum/fill are +/// disabled — same semantics as the React `series` prop. +public struct LivelineSeries: Sendable { + public let id: String + public var data: [LivelinePoint] + public var value: Double + /// Defaults to `LivelinePalette.seriesColors[index % 8]` when nil. + public var color: UIColor? + public var label: String? + + public init(id: String, data: [LivelinePoint], value: Double, color: UIColor? = nil, label: String? = nil) { + self.id = id + self.data = data + self.value = value + self.color = color + self.label = label + } +} +#endif diff --git a/Sources/LivelineCanvas/SwiftUIBridge.swift b/Sources/LivelineCanvas/SwiftUIBridge.swift index 89ceaa6..91ff56e 100644 --- a/Sources/LivelineCanvas/SwiftUIBridge.swift +++ b/Sources/LivelineCanvas/SwiftUIBridge.swift @@ -6,31 +6,40 @@ import SwiftUI public struct LivelineChart: UIViewRepresentable { public var points: [LivelinePoint] public var candles: [CandlePoint] + public var series: [LivelineSeries] + public var hiddenSeriesIDs: Set public var value: Double public var config: LivelineConfig public var palette: LivelinePalette public var referenceLine: LivelineReferenceLine? public var loading: Bool public var paused: Bool + public var onHover: ((LivelinePoint?) -> Void)? public init( points: [LivelinePoint], candles: [CandlePoint] = [], + series: [LivelineSeries] = [], + hiddenSeriesIDs: Set = [], value: Double, config: LivelineConfig = .init(), palette: LivelinePalette = .default, referenceLine: LivelineReferenceLine? = nil, loading: Bool = false, - paused: Bool = false + paused: Bool = false, + onHover: ((LivelinePoint?) -> Void)? = nil ) { self.points = points self.candles = candles + self.series = series + self.hiddenSeriesIDs = hiddenSeriesIDs self.value = value self.config = config self.palette = palette self.referenceLine = referenceLine self.loading = loading self.paused = paused + self.onHover = onHover } public func makeUIView(context: Context) -> LivelineCanvasView { @@ -42,10 +51,13 @@ public struct LivelineChart: UIViewRepresentable { uiView.palette = palette uiView.points = points uiView.candles = candles + uiView.series = series + uiView.hiddenSeriesIDs = hiddenSeriesIDs uiView.liveValue = value uiView.referenceLine = referenceLine uiView.isLoading = loading uiView.isPaused = paused + uiView.onHover = onHover } } #endif diff --git a/Tests/LivelineCanvasTests/LivelineCanvasTests.swift b/Tests/LivelineCanvasTests/LivelineCanvasTests.swift index 76262ae..d0882a4 100644 --- a/Tests/LivelineCanvasTests/LivelineCanvasTests.swift +++ b/Tests/LivelineCanvasTests/LivelineCanvasTests.swift @@ -141,4 +141,57 @@ final class LivelineCanvasTests: XCTestCase { XCTAssertEqual(segments.count, 1) XCTAssertEqual(segments[0].end, pts[1]) } + + // MARK: - Momentum + + private func momentumPoints(_ values: [Double]) -> [LivelinePoint] { + values.enumerated().map { LivelinePoint(time: TimeInterval($0.offset), value: $0.element) } + } + + func testMomentumDetectsUp() { + let points = momentumPoints([100, 100, 101, 100, 99, 100, 101, 103, 106, 110]) + XCTAssertEqual(detectMomentum(points: points), .up) + } + + func testMomentumDetectsDown() { + let points = momentumPoints([110, 110, 109, 110, 111, 110, 108, 105, 102, 100]) + XCTAssertEqual(detectMomentum(points: points), .down) + } + + func testMomentumFlatForSmallMoves() { + let points = momentumPoints([100, 110, 100, 110, 100, 105, 105.1, 105, 105.1, 105]) + XCTAssertEqual(detectMomentum(points: points), .flat) + } + + func testMomentumFlatWithTooFewPoints() { + XCTAssertEqual(detectMomentum(points: momentumPoints([1, 2, 3])), .flat) + } + + // MARK: - Grid interval + + func testGridIntervalGivesReasonableSpacing() { + // 100-unit range over 300px → 3 px/unit; labels need >= 36px = 12 units + let interval = pickGridInterval(valueRange: 100, pxPerUnit: 3, minGap: 36, previous: 0) + XCTAssertGreaterThanOrEqual(interval * 3, 36) + XCTAssertLessThanOrEqual(interval * 3, 36 * 2.5 + 0.001) + } + + func testGridIntervalHysteresisKeepsPrevious() { + // Previous interval still within [0.5×, 4×] of minGap → unchanged + let interval = pickGridInterval(valueRange: 100, pxPerUnit: 3, minGap: 36, previous: 20) + XCTAssertEqual(interval, 20) + } + + func testGridIntervalRepicksWhenSpacingCollapses() { + // Previous interval now renders at 6px (< 18px) → must repick + let interval = pickGridInterval(valueRange: 1000, pxPerUnit: 0.3, minGap: 36, previous: 20) + XCTAssertNotEqual(interval, 20) + XCTAssertGreaterThanOrEqual(interval * 0.3, 36) + } + + func testIsDivisible() { + XCTAssertTrue(isDivisible(100, by: 25)) + XCTAssertTrue(isDivisible(0.3, by: 0.1)) + XCTAssertFalse(isDivisible(103, by: 25)) + } } From 5834691d5b445266978d53fec3866c433b37627a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 20:17:38 +0000 Subject: [PATCH 4/6] feat(batch 2/3): reveal morph, edge fade, live candle, degen particles, orderbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second parity batch for the LivelineCanvas Swift package. - Loading→data reveal morph: chart line morphs out of the loading squiggly center-out (reveal speed 0.09), sharing its breathing alpha, with grey→ accent color blend by reveal ≈ 0.3; the tip retracts from full width, the dashed value line unfolds from center, fill fades in with reveal, and dot/arrows/badge appear once reveal passes 0.5 - Left-edge fade: 40px background gradient so the line dissolves into the chart edge instead of being cut off - Live candle: `liveCandle` on the view/bridge, birth fade-in (0.12), OHLC lerped at 0.25, pulsing glow (0.12 + sin(t*4)*0.08), included in the Y-range; dashed close line now smoothed (0.25) so it never jumps on candle birth - Degen: `degen` config (scale, downMomentum) — burst particles from the dot on momentum swings > 8% of range with cooldown 400ms, 3-burst falloff [1, 0.6, 0.35], wide semicircle spread, drag 0.95/frame, plus chart shake with exponential decay; port of src/draw/particles.ts - Orderbook: `orderbook` on the view/bridge — Kalshi-style rising "+$size" labels in the left column, weighted-random by level size, speed 60→160 px/s driven by max(price swing, book churn) with fast-attack/slow-decay smoothing, deceleration toward the top; port of src/draw/orderbook.ts Verified: 19 unit tests pass; full renderer typechecked against UIKit/CoreGraphics stubs. To test on device: flip loading true→false with data present (line should bloom from the squiggly center-out), candle mode with a ticking liveCandle (glow + smooth close line), degen with fast up-moves (particles + shake), orderbook with a changing book (labels rise faster when the book churns). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U1KrgkhnUq4LKpoY9LpstF --- README.md | 4 +- .../LivelineCanvas/LivelineCanvasView.swift | 12 + Sources/LivelineCanvas/Models.swift | 38 +++ Sources/LivelineCanvas/Renderer.swift | 243 +++++++++++++++--- Sources/LivelineCanvas/RendererEffects.swift | 229 +++++++++++++++++ Sources/LivelineCanvas/SwiftUIBridge.swift | 8 + 6 files changed, 501 insertions(+), 33 deletions(-) create mode 100644 Sources/LivelineCanvas/RendererEffects.swift diff --git a/README.md b/README.md index 257169f..f75b483 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,6 @@ For SwiftUI, use `LivelineChart(...)` from the same package. Palettes can be der ### Feature coverage vs. the React component -Implemented: line mode with Fritsch-Carlson monotone spline, gradient fill, live-tip value interpolation with adaptive lerp speed, live dot with pulse ring + momentum glow, momentum detection with cascading chevron arrows, value badge pill with tail / momentum color / `minimal` variant, multi-series with per-series colors, labels, endpoint dots, visibility fades and combined crosshair tooltip, dashed current-value line, window-based scrolling (`windowSeconds`) with badge-aware right-edge buffer, frame-rate-independent lerp of value and Y-range, Y-range from visible data with the same margins (incl. `exaggerate`), TradingView-style grid intervals with hysteresis and per-label fades, time axis with nice intervals, crosshair with value/time tooltip via pan or long-press, candlestick mode with wicks and dashed close line, reference line, `formatValue`, loading squiggly with breathing alpha, empty state, pause with smooth freeze and time-debt catch-up on resume, render loop that stops when the view leaves the window. +Implemented: line mode with Fritsch-Carlson monotone spline, gradient fill, live-tip value interpolation with adaptive lerp speed, live dot with pulse ring + momentum glow, momentum detection with cascading chevron arrows, value badge pill with tail / momentum color / `minimal` variant, multi-series with per-series colors, labels, endpoint dots, visibility fades and combined crosshair tooltip, dashed current-value line, window-based scrolling (`windowSeconds`) with badge-aware right-edge buffer, frame-rate-independent lerp of value and Y-range, Y-range from visible data with the same margins (incl. `exaggerate`), TradingView-style grid intervals with hysteresis and per-label fades, time axis with nice intervals, crosshair with value/time tooltip via pan or long-press, candlestick mode with wicks, live candle (birth fade-in, lerped OHLC, pulsing glow) and a smoothed dashed close line, reference line, `formatValue`, loading squiggly with breathing alpha and a center-out reveal morph into the data line, left-edge fade, degen particles + chart shake, orderbook rising labels driven by price momentum and book churn, empty state, pause with smooth freeze and time-debt catch-up on resume, render loop that stops when the view leaves the window. -Not (yet) ported: degen particles + shake, orderbook labels, line/candle morph + live candle animation, window-change transition, loading→data reveal morph, left-edge fade, built-in window/mode/series toggle controls (build these in SwiftUI/UIKit around the view). +Not (yet) ported: line/candle morph transition, window-change transition, built-in window/mode/series toggle controls (build these in SwiftUI/UIKit around the view; batch 3 adds SwiftUI equivalents). diff --git a/Sources/LivelineCanvas/LivelineCanvasView.swift b/Sources/LivelineCanvas/LivelineCanvasView.swift index ff8dc68..799cf0c 100644 --- a/Sources/LivelineCanvas/LivelineCanvasView.swift +++ b/Sources/LivelineCanvas/LivelineCanvasView.swift @@ -35,6 +35,16 @@ public final class LivelineCanvasView: UIView { didSet { setNeedsDisplay() } } + /// Current in-progress candle, updated every tick (React `liveCandle`). + public var liveCandle: CandlePoint? { + didSet { setNeedsDisplay() } + } + + /// Bid/ask depth stream for the rising orderbook labels. + public var orderbook: LivelineOrderbook? { + didSet { setNeedsDisplay() } + } + /// Multi-series mode: when non-empty, overrides `points`/`liveValue` /// and disables badge/momentum/fill, like the React `series` prop. public var series: [LivelineSeries] = [] { @@ -137,8 +147,10 @@ public final class LivelineCanvasView: UIView { rect: bounds, points: points, candles: candles, + liveCandle: liveCandle, series: series, hiddenSeriesIDs: hiddenSeriesIDs, + orderbook: orderbook, value: liveValue, config: config, palette: palette, diff --git a/Sources/LivelineCanvas/Models.swift b/Sources/LivelineCanvas/Models.swift index 27a6711..3b975d7 100644 --- a/Sources/LivelineCanvas/Models.swift +++ b/Sources/LivelineCanvas/Models.swift @@ -56,6 +56,40 @@ public struct LivelineInsets: Sendable, Equatable { } } +/// Burst particles + chart shake on momentum swings (React `degen` prop). +public struct LivelineDegenOptions: Sendable, Equatable { + /// Particle count/size multiplier. + public var scale: Double + /// Also fire on downward swings (off by default, like the web). + public var downMomentum: Bool + + public init(scale: Double = 1, downMomentum: Bool = false) { + self.scale = scale + self.downMomentum = downMomentum + } +} + +public struct LivelineOrderbookLevel: Sendable, Equatable { + public let price: Double + public let size: Double + + public init(price: Double, size: Double) { + self.price = price + self.size = size + } +} + +/// Bid/ask depth stream for the rising orderbook labels. +public struct LivelineOrderbook: Sendable, Equatable { + public var bids: [LivelineOrderbookLevel] + public var asks: [LivelineOrderbookLevel] + + public init(bids: [LivelineOrderbookLevel], asks: [LivelineOrderbookLevel]) { + self.bids = bids + self.asks = asks + } +} + public enum LivelineBadgeVariant: Sendable { /// Accent/momentum-colored pill with white text. case `default` @@ -89,6 +123,8 @@ public struct LivelineConfig: Sendable { public var badgeTail: Bool /// Momentum styling: dot glow, chevron arrows, badge color. public var momentum: LivelineMomentumMode + /// Burst particles + chart shake on momentum swings. + public var degen: LivelineDegenOptions? /// Tight Y-range so small moves fill the chart height. public var exaggerate: Bool public var lineWidth: Double @@ -115,6 +151,7 @@ public struct LivelineConfig: Sendable { badgeVariant: LivelineBadgeVariant = .default, badgeTail: Bool = true, momentum: LivelineMomentumMode = .auto, + degen: LivelineDegenOptions? = nil, exaggerate: Bool = false, lineWidth: Double = 2, candleWidthSeconds: TimeInterval = 60, @@ -138,6 +175,7 @@ public struct LivelineConfig: Sendable { self.badgeVariant = badgeVariant self.badgeTail = badgeTail self.momentum = momentum + self.degen = degen self.exaggerate = exaggerate self.lineWidth = lineWidth self.candleWidthSeconds = candleWidthSeconds diff --git a/Sources/LivelineCanvas/Renderer.swift b/Sources/LivelineCanvas/Renderer.swift index a2c11a6..15af7c8 100644 --- a/Sources/LivelineCanvas/Renderer.swift +++ b/Sources/LivelineCanvas/Renderer.swift @@ -34,14 +34,36 @@ struct RenderState { var seriesAlphas: [String: Double] = [:] var seriesValues: [String: Double] = [:] + + /// 0 → 1 after loading flips off; drives the loading→data reveal morph. + var chartReveal: Double = 1 + + var particles: [DegenParticle] = [] + var particleCooldown: Double = 0 + var burstCount = 0 + var shakeAmplitude: Double = 0 + + var liveCandleTime: TimeInterval = -1 + var liveCandleAlpha: Double = 0 + var liveCandleSmooth: (open: Double, high: Double, low: Double, close: Double)? + var closeLineSmooth: Double? + + var obLabels: [OrderbookLabel] = [] + var obSpawnTimer: Double = 0 + var obSpeed: Double = 0 + var obPrevBidTotal: Double = 0 + var obPrevAskTotal: Double = 0 + var obChurnRate: Double = 0 } struct RenderInput { let rect: CGRect let points: [LivelinePoint] let candles: [CandlePoint] + let liveCandle: CandlePoint? let series: [LivelineSeries] let hiddenSeriesIDs: Set + let orderbook: LivelineOrderbook? let value: Double let config: LivelineConfig let palette: LivelinePalette @@ -70,6 +92,8 @@ enum LivelineRenderer { guard chartRect.width > 0, chartRect.height > 0 else { return } guard !input.isLoading else { + // Next data frame morphs out of this squiggly + state.chartReveal = 0 drawSquiggly(in: ctx, chartRect: chartRect, palette: palette, now: input.now) return } @@ -83,6 +107,27 @@ enum LivelineRenderer { return } + // --- Loading→data reveal (CHART_REVEAL_SPEED_FWD = 0.09) --- + if state.chartReveal < 1 { + state.chartReveal = lerp(state.chartReveal, 1, speed: 0.09, dt: dt) + if state.chartReveal > 0.995 { state.chartReveal = 1 } + } + let reveal = state.chartReveal + + // --- Degen shake: random translate, exponential decay --- + var shakeApplied = false + if state.shakeAmplitude > 0.2, reveal > 0.9 { + ctx.saveGState() + ctx.translateBy( + x: CGFloat(Double.random(in: -1...1) * state.shakeAmplitude), + y: CGFloat(Double.random(in: -1...1) * state.shakeAmplitude) + ) + shakeApplied = true + } + state.shakeAmplitude *= exp(-0.002 * dt * 1000) + if state.shakeAmplitude < 0.2 { state.shakeAmplitude = 0 } + defer { if shakeApplied { ctx.restoreGState() } } + // --- Pause: decelerate into a frozen clock, catch up on resume --- state.pauseProgress = lerp(state.pauseProgress, input.isPaused ? 1 : 0, speed: 0.12, dt: dt) if state.pauseProgress > 0.001 { @@ -138,9 +183,10 @@ enum LivelineRenderer { } range = uMin.isFinite ? (uMin, uMax) : (0, 1) } else { + let rangeCandles = input.liveCandle.map { visibleCandles + [$0] } ?? visibleCandles range = valueRange( points: config.mode == .line ? visiblePoints : [], - candles: config.mode == .candle ? visibleCandles : [], + candles: config.mode == .candle ? rangeCandles : [], currentValue: input.value, reference: input.referenceLine, exaggerate: config.exaggerate @@ -187,8 +233,8 @@ enum LivelineRenderer { } else { switch config.mode { case .line: - drawLine(in: ctx, chartRect: chartRect, points: visiblePoints, smoothValue: state.displayValue, tipTime: now, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) - if !visiblePoints.isEmpty { + drawLine(in: ctx, chartRect: chartRect, points: visiblePoints, smoothValue: state.displayValue, tipTime: now, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette, reveal: reveal, animNow: input.now) + if !visiblePoints.isEmpty, reveal > 0.5 { let tipX = xForTime(now, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) let tipY = yForValue(state.displayValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let dot = CGPoint(x: tipX, y: clamp(tipY, rect.minY + 10, rect.maxY - 10)) @@ -202,12 +248,29 @@ enum LivelineRenderer { if showBadge { drawBadge(in: ctx, rect: rect, chartRect: chartRect, value: state.displayValue, valueY: tipY, momentum: momentum, config: config, palette: palette, state: &state, dt: dt) } + if let degen = config.degen { + let swing = swingMagnitude(points: visiblePoints, span: maxValue - minValue) + let intensity = spawnParticles(state: &state, momentum: momentum, dot: dot, swing: swing, dt: dt, options: degen) + if intensity > 0 { + state.shakeAmplitude = (3 + swing * 4) * intensity + } + drawParticles(in: ctx, state: &state, color: palette.line, dt: dt) + } } case .candle: - drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, liveCandle: input.liveCandle, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette, state: &state, dt: pausedDt, animNow: input.now) } } + // Left-edge fade so the line dissolves into the chart edge + drawEdgeFade(in: ctx, chartRect: chartRect, background: palette.background) + + if let orderbook = input.orderbook { + let swingPoints = isMulti ? (input.series.first?.data ?? []) : input.points + let swing = swingMagnitude(points: swingPoints, span: maxValue - minValue) + drawOrderbook(in: ctx, chartRect: chartRect, orderbook: orderbook, swing: swing, palette: palette, state: &state, dt: dt) + } + if let hoverX = input.hoverX, config.showCrosshair { if isMulti { drawMultiCrosshair(in: ctx, chartRect: chartRect, x: hoverX, series: input.series, hidden: input.hiddenSeriesIDs, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) @@ -437,14 +500,44 @@ enum LivelineRenderer { ctx.strokePath() } - static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], smoothValue: Double, tipTime: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { - let pts = linePoints(points, smoothValue: smoothValue, tipTime: tipTime, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], smoothValue: Double, tipTime: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, reveal: Double = 1, animNow: TimeInterval = 0) { + var pts = linePoints(points, smoothValue: smoothValue, tipTime: tipTime, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, chartRect: chartRect) guard pts.count >= 2 else { return } + let ms = animNow.truncatingRemainder(dividingBy: 86_400) * 1000 + var lineAlpha = 1.0 + var fillAlpha = 1.0 + var strokeColor = palette.line + + if reveal < 1 { + // Morph out of the loading squiggly, center-out: the middle of + // the chart resolves first, edges last (src/draw/line.ts) + let centerY = Double(chartRect.midY) + let amplitude = Double(chartRect.height) * 0.07 + let scroll = ms * 0.001 + for i in pts.indices { + let t = clamp(Double((pts[i].x - chartRect.minX) / Swift.max(chartRect.width, 1)), 0, 1) + let centerDist = abs(t - 0.5) * 2 + let localReveal = clamp((reveal - centerDist * 0.4) / 0.6, 0, 1) + let baseY = loadingY(t: t, centerY: centerY, amplitude: amplitude, scroll: scroll) + pts[i].y = CGFloat(baseY + (Double(pts[i].y) - baseY) * localReveal) + } + // Tip X extends to the full width at reveal=0, matching the squiggly + let tip = pts[pts.count - 1] + pts[pts.count - 1].x = tip.x + (chartRect.maxX - tip.x) * CGFloat(1 - reveal) + + // Line shares the loading breath at reveal=0, ramps to full; + // color blends grey → accent by reveal ≈ 0.3 + let breath = 0.22 + 0.08 * sin(ms / 1200 * .pi) + lineAlpha = breath + (1 - breath) * reveal + fillAlpha = reveal + strokeColor = blendColor(palette.gridLabel, palette.line, t: Swift.min(1, reveal * 3)) + } + ctx.saveGState() ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) - if config.showFill { + if config.showFill, fillAlpha > 0.01 { let fillPath = CGMutablePath() fillPath.move(to: pts[0]) for segment in monotoneSplineSegments(pts) { @@ -461,7 +554,10 @@ enum LivelineRenderer { var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 _ = palette.fillTop.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) _ = palette.fillBottom.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) - let components: [CGFloat] = [r1, g1, b1, a1, r2, g2, b2, a2] + let components: [CGFloat] = [ + r1, g1, b1, a1 * CGFloat(fillAlpha), + r2, g2, b2, a2 * CGFloat(fillAlpha) + ] if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colorComponents: components, locations: [0, 1], count: 2) { ctx.drawLinearGradient( gradient, @@ -473,25 +569,69 @@ enum LivelineRenderer { ctx.restoreGState() } - strokeSpline(ctx, points: pts, color: palette.line, lineWidth: CGFloat(config.lineWidth)) + strokeSpline(ctx, points: pts, color: scaledAlpha(strokeColor, lineAlpha), lineWidth: CGFloat(config.lineWidth)) ctx.restoreGState() if config.showDashLine { - let tipY = clamp( + let realY = clamp( yForValue(smoothValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect), chartRect.minY, chartRect.maxY ) + // During reveal the dash line unfolds from the vertical center + let y = reveal < 1 ? chartRect.midY + (realY - chartRect.midY) * CGFloat(reveal) : realY ctx.saveGState() - ctx.setStrokeColor(palette.dashLine.cgColor) + ctx.setStrokeColor(scaledAlpha(palette.dashLine, reveal).cgColor) ctx.setLineWidth(1) ctx.setLineDash(phase: 0, lengths: [4, 4]) - ctx.move(to: CGPoint(x: chartRect.minX, y: tipY)) - ctx.addLine(to: CGPoint(x: chartRect.maxX, y: tipY)) + ctx.move(to: CGPoint(x: chartRect.minX, y: y)) + ctx.addLine(to: CGPoint(x: chartRect.maxX, y: y)) ctx.strokePath() ctx.restoreGState() } } + /// Fade the line/fill into the left chart edge over 40px — stands in for + /// the destination-out gradient the web version uses (FADE_EDGE_WIDTH). + static func drawEdgeFade(in ctx: CGContext, chartRect: CGRect, background: UIColor) { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + _ = background.getRed(&r, green: &g, blue: &b, alpha: &a) + let components: [CGFloat] = [r, g, b, 1, r, g, b, 0] + guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colorComponents: components, locations: [0, 1], count: 2) else { return } + ctx.saveGState() + ctx.clip(to: CGRect(x: chartRect.minX, y: chartRect.minY, width: 40, height: chartRect.height)) + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: chartRect.minX, y: 0), + end: CGPoint(x: chartRect.minX + 40, y: 0), + options: [] + ) + ctx.restoreGState() + } + + // MARK: - Color helpers + + static func blendColor(_ a: UIColor, _ b: UIColor, t: Double) -> UIColor { + let tt = CGFloat(clamp(t, 0, 1)) + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 + _ = a.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + _ = b.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + return UIColor( + red: r1 + (r2 - r1) * tt, + green: g1 + (g2 - g1) * tt, + blue: b1 + (b2 - b1) * tt, + alpha: a1 + (a2 - a1) * tt + ) + } + + /// Multiply a color's existing alpha by `factor` (withAlphaComponent + /// would replace it and lose palette alphas like gridLabel's 0.4). + static func scaledAlpha(_ color: UIColor, _ factor: Double) -> UIColor { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + _ = color.getRed(&r, green: &g, blue: &b, alpha: &a) + return UIColor(red: r, green: g, blue: b, alpha: a * CGFloat(clamp(factor, 0, 1))) + } + static func glowColor(for momentum: LivelineMomentum, palette: LivelinePalette) -> UIColor { switch momentum { case .up: return palette.upCandle.withAlphaComponent(0.5) @@ -532,24 +672,21 @@ enum LivelineRenderer { // MARK: - Candle mode - static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette) { - guard !candles.isEmpty else { return } + static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], liveCandle: CandlePoint?, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, state: inout RenderState, dt: Double, animNow: TimeInterval) { + guard !candles.isEmpty || liveCandle != nil else { return } let pxPerSecond = chartRect.width / CGFloat(window) let bodyWidth = Swift.max(1, pxPerSecond * CGFloat(config.candleWidthSeconds) * 0.7) let wickWidth = clamp(bodyWidth * 0.15, 0.8, 2) - ctx.saveGState() - ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) - for c in candles { - // Candle time is the bucket's open time — center the body on it - let x = xForTime(c.time + config.candleWidthSeconds / 2, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) + func drawCandle(_ c: CandlePoint, at time: TimeInterval, alpha: Double) { + let x = xForTime(time + config.candleWidthSeconds / 2, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) let yOpen = yForValue(c.open, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let yClose = yForValue(c.close, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let yHigh = yForValue(c.high, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let yLow = yForValue(c.low, minValue: minValue, maxValue: maxValue, chartRect: chartRect) let isUp = c.close >= c.open - let color = isUp ? palette.upCandle : palette.downCandle + let color = (isUp ? palette.upCandle : palette.downCandle).withAlphaComponent(CGFloat(alpha)) ctx.setStrokeColor(color.cgColor) ctx.setLineWidth(wickWidth) @@ -568,15 +705,53 @@ enum LivelineRenderer { ctx.setFillColor(color.cgColor) ctx.fill(bodyRect) } + + ctx.saveGState() + ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) + // Candle time is the bucket's open time — bodies are centered on it + for c in candles { + drawCandle(c, at: c.time, alpha: 1) + } + + // Live candle: birth fade-in, lerped OHLC, pulsing glow + var closeSource: CandlePoint? = candles.last + if let live = liveCandle { + if state.liveCandleTime != live.time { + state.liveCandleTime = live.time + state.liveCandleAlpha = 0 + state.liveCandleSmooth = (live.open, live.high, live.low, live.close) + } + state.liveCandleAlpha = lerp(state.liveCandleAlpha, 1, speed: 0.12, dt: dt) + var smooth = state.liveCandleSmooth ?? (live.open, live.high, live.low, live.close) + smooth.open = lerp(smooth.open, live.open, speed: 0.25, dt: dt) + smooth.high = lerp(smooth.high, live.high, speed: 0.25, dt: dt) + smooth.low = lerp(smooth.low, live.low, speed: 0.25, dt: dt) + smooth.close = lerp(smooth.close, live.close, speed: 0.25, dt: dt) + state.liveCandleSmooth = smooth + + let display = CandlePoint(time: live.time, open: smooth.open, high: smooth.high, low: smooth.low, close: smooth.close) + let ms = animNow.truncatingRemainder(dividingBy: 86_400) * 1000 + let glowAlpha = 0.12 + sin(ms * 0.004) * 0.08 + let glowBase = display.close >= display.open ? palette.upCandle : palette.downCandle + + ctx.saveGState() + ctx.setShadow(offset: .zero, blur: 8, color: glowBase.withAlphaComponent(CGFloat(Swift.max(0, glowAlpha))).cgColor) + drawCandle(display, at: live.time, alpha: state.liveCandleAlpha) + ctx.restoreGState() + + closeSource = display + } ctx.restoreGState() - // Dashed close-price line at the latest close (candle-colored) - if config.showDashLine, let last = candles.last { + // Dashed close-price line, smoothed so it never jumps on candle birth + if config.showDashLine, let source = closeSource { + let smoothClose = lerp(state.closeLineSmooth ?? source.close, source.close, speed: 0.25, dt: dt) + state.closeLineSmooth = smoothClose let y = clamp( - yForValue(last.close, minValue: minValue, maxValue: maxValue, chartRect: chartRect), + yForValue(smoothClose, minValue: minValue, maxValue: maxValue, chartRect: chartRect), chartRect.minY, chartRect.maxY ) - let color = last.close >= last.open ? palette.upCandle : palette.downCandle + let color = source.close >= source.open ? palette.upCandle : palette.downCandle ctx.saveGState() ctx.setStrokeColor(color.withAlphaComponent(0.4).cgColor) ctx.setLineWidth(1) @@ -639,7 +814,17 @@ enum LivelineRenderer { // MARK: - Loading / empty - /// Breathing squiggly line, same shape constants as src/draw/loadingShape.ts. + /// Squiggly Y position shared by the loading line and the reveal morph — + /// same shape constants as src/draw/loadingShape.ts. + static func loadingY(t: Double, centerY: Double, amplitude: Double, scroll: Double) -> Double { + centerY + amplitude * ( + sin(t * 9.4 + scroll) * 0.55 + + sin(t * 15.7 + scroll * 1.3) * 0.3 + + sin(t * 4.2 + scroll * 0.7) * 0.15 + ) + } + + /// Breathing squiggly loading line. static func drawSquiggly(in ctx: CGContext, chartRect: CGRect, palette: LivelinePalette, now: TimeInterval, alphaScale: Double = 1) { // Keep the sine arguments small for precision — the shape repeats anyway let ms = now.truncatingRemainder(dividingBy: 86_400) * 1000 @@ -653,11 +838,7 @@ enum LivelineRenderer { pts.reserveCapacity(samples + 1) for i in 0...samples { let t = Double(i) / Double(samples) - let y = centerY + amplitude * ( - sin(t * 9.4 + scroll) * 0.55 + - sin(t * 15.7 + scroll * 1.3) * 0.3 + - sin(t * 4.2 + scroll * 0.7) * 0.15 - ) + let y = loadingY(t: t, centerY: centerY, amplitude: amplitude, scroll: scroll) pts.append(CGPoint(x: chartRect.minX + CGFloat(t) * chartRect.width, y: CGFloat(y))) } diff --git a/Sources/LivelineCanvas/RendererEffects.swift b/Sources/LivelineCanvas/RendererEffects.swift new file mode 100644 index 0000000..bd32514 --- /dev/null +++ b/Sources/LivelineCanvas/RendererEffects.swift @@ -0,0 +1,229 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +import CoreGraphics + +/// Degen particles + shake (src/draw/particles.ts) and the Kalshi-style +/// orderbook label stream (src/draw/orderbook.ts). + +struct DegenParticle { + var x: Double + var y: Double + var vx: Double + var vy: Double + var life: Double // 1 → 0 + var size: Double +} + +struct OrderbookLabel { + var y: Double + let text: String + let green: Bool + var life: Double + let maxLife: Double + let intensity: Double // bigger orders = brighter +} + +extension LivelineRenderer { + + // MARK: - Particles + + private static var maxParticles: Int { 80 } + private static var particleLifetime: Double { 1.0 } + private static var particleCooldown: Double { 0.4 } + private static var magnitudeThreshold: Double { 0.08 } + private static var maxBursts: Int { 3 } + + /// Swing magnitude over the last 5 points, normalized by the visible + /// range (useLivelineEngine.ts:1824). + static func swingMagnitude(points: [LivelinePoint], span: Double) -> Double { + guard span > 0, points.count >= 2 else { return 0 } + let tail = points.suffix(5) + guard let first = tail.first, let last = tail.last else { return 0 } + return Swift.min(abs(last.value - first.value) / span, 1) + } + + /// Spawn particles on large swings. Returns the burst intensity + /// (0 = didn't fire) so the caller can scale the shake. + static func spawnParticles(state: inout RenderState, momentum: LivelineMomentum, dot: CGPoint, swing: Double, dt: Double, options: LivelineDegenOptions) -> Double { + state.particleCooldown = Swift.max(0, state.particleCooldown - dt) + + guard momentum != .flat else { return 0 } + guard state.particleCooldown <= 0 else { return 0 } + + // Below threshold — calm period resets the burst limiter + guard swing >= magnitudeThreshold else { + state.burstCount = 0 + return 0 + } + if momentum == .down && !options.downMomentum { return 0 } + guard state.burstCount < maxBursts else { return 0 } + + state.particleCooldown = particleCooldown + + // First burst is biggest, subsequent taper — unless the swing is huge + let mag = Swift.min(swing * 5, 1) + let falloffs = [1.0, 0.6, 0.35] + let burstFalloff = mag > 0.6 ? 1 : falloffs[Swift.min(state.burstCount, falloffs.count - 1)] + state.burstCount += 1 + + let count = Int(((12 + mag * 20) * options.scale * burstFalloff).rounded()) + let speedMultiplier = 1.0 + mag * 0.8 + let baseAngle = momentum == .up ? -Double.pi / 2 : Double.pi / 2 + + for _ in 0.. 0 else { continue } + p.x += p.vx * dt + p.y += p.vy * dt + p.vx *= drag + p.vy *= drag + + let radius = CGFloat(p.size * (0.5 + p.life * 0.5)) + ctx.setFillColor(color.withAlphaComponent(CGFloat(p.life * 0.55)).cgColor) + ctx.fillEllipse(in: CGRect(x: CGFloat(p.x) - radius, y: CGFloat(p.y) - radius, width: radius * 2, height: radius * 2)) + alive.append(p) + } + state.particles = alive + } + + // MARK: - Orderbook + + private static var obGreen: (Double, Double, Double) { (34, 197, 94) } + private static var obRed: (Double, Double, Double) { (239, 68, 68) } + + static func drawOrderbook(in ctx: CGContext, chartRect: CGRect, orderbook: LivelineOrderbook, swing: Double, palette: LivelinePalette, state: inout RenderState, dt: Double) { + guard !orderbook.bids.isEmpty || !orderbook.asks.isEmpty else { return } + + var maxSize = 0.0 + var bidTotal = 0.0 + var askTotal = 0.0 + for level in orderbook.bids { bidTotal += level.size; maxSize = Swift.max(maxSize, level.size) } + for level in orderbook.asks { askTotal += level.size; maxSize = Swift.max(maxSize, level.size) } + guard maxSize > 0 else { return } + + // Orderbook churn: normalized change in total size since last frame, + // smoothed with fast attack / slow decay + let prevTotal = state.obPrevBidTotal + state.obPrevAskTotal + var churnSignal = 0.0 + if prevTotal > 0 { + let delta = abs(bidTotal - state.obPrevBidTotal) + abs(askTotal - state.obPrevAskTotal) + churnSignal = Swift.min(delta / prevTotal, 1) + } + state.obPrevBidTotal = bidTotal + state.obPrevAskTotal = askTotal + let churnLerp = churnSignal > state.obChurnRate ? 0.3 : 0.05 + state.obChurnRate += (churnSignal - state.obChurnRate) * churnLerp + + // Speed from whichever is stronger: price momentum or book churn + let activity = Swift.max(Swift.min(swing * 5, 1), state.obChurnRate) + let targetSpeed = 60 + activity * 100 + state.obSpeed = lerp(state.obSpeed == 0 ? 60 : state.obSpeed, targetSpeed, speed: 0.05, dt: dt) + + let labelX = chartRect.minX + 8 + let bottomY = Double(chartRect.maxY) - 6 + let topY = Double(chartRect.minY) + + // Spawn at the bottom every 40ms, keeping a 22px gap + state.obSpawnTimer += dt * 1000 + while state.obSpawnTimer >= 40, state.obLabels.count < 50 { + state.obSpawnTimer -= 40 + if state.obLabels.contains(where: { abs($0.y - bottomY) < 22 }) { break } + + var levels: [(size: Double, green: Bool)] = [] + for level in orderbook.bids { levels.append((level.size, true)) } + for level in orderbook.asks { levels.append((level.size, false)) } + let totalWeight = levels.reduce(0) { $0 + $1.size } + var r = Double.random(in: 0..<1) * totalWeight + var picked = levels[0] + for level in levels { + r -= level.size + if r <= 0 { picked = level; break } + } + + state.obLabels.append(OrderbookLabel( + y: bottomY, + text: "+ \(formatOrderSize(picked.size))", + green: picked.green, + life: 6, + maxLife: 6, + intensity: 0.5 + (picked.size / maxSize) * 0.5 + )) + } + + // Rise + decelerate toward the top, expire by life or position + let span = bottomY - topY + var alive: [OrderbookLabel] = [] + alive.reserveCapacity(state.obLabels.count) + for var label in state.obLabels { + label.life -= dt + guard label.life > 0 else { continue } + let yProgress = span > 0 ? (label.y - topY) / span : 1 + label.y -= state.obSpeed * (0.7 + 0.3 * yProgress) * dt + guard label.y >= topY - 14 else { continue } + alive.append(label) + } + state.obLabels = alive + + var bgR: CGFloat = 0, bgG: CGFloat = 0, bgB: CGFloat = 0, bgA: CGFloat = 0 + _ = palette.background.getRed(&bgR, green: &bgG, blue: &bgB, alpha: &bgA) + let bg = (Double(bgR) * 255, Double(bgG) * 255, Double(bgB) * 255) + + let font = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold) + let chartH = Double(chartRect.height) + for label in state.obLabels { + let lifeRatio = label.life / label.maxLife + let fadeIn = Swift.min((1 - lifeRatio) * 10, 1) + let yRatio = chartH > 0 ? (label.y - topY) / chartH : 1 + let fadeOut = yRatio < 0.45 ? yRatio / 0.45 : 1 + let strength = label.intensity * fadeIn * fadeOut + guard strength > 0.01 else { continue } + + let base = label.green ? obGreen : obRed + let mixed = UIColor( + red: CGFloat((base.0 + (bg.0 - base.0) * (1 - strength)) / 255), + green: CGFloat((base.1 + (bg.1 - base.1) * (1 - strength)) / 255), + blue: CGFloat((base.2 + (bg.2 - base.2) * (1 - strength)) / 255), + alpha: 1 + ) + drawText( + label.text, + at: CGPoint(x: labelX, y: CGFloat(label.y) - font.lineHeight / 2), + anchorX: 0, + font: font, + color: mixed + ) + } + } + + static func formatOrderSize(_ size: Double) -> String { + if size >= 10 { return "$\(Int(size.rounded()))" } + if size >= 1 { return String(format: "$%.1f", size) } + return String(format: "$%.2f", size) + } +} +#endif diff --git a/Sources/LivelineCanvas/SwiftUIBridge.swift b/Sources/LivelineCanvas/SwiftUIBridge.swift index 91ff56e..67f5551 100644 --- a/Sources/LivelineCanvas/SwiftUIBridge.swift +++ b/Sources/LivelineCanvas/SwiftUIBridge.swift @@ -6,8 +6,10 @@ import SwiftUI public struct LivelineChart: UIViewRepresentable { public var points: [LivelinePoint] public var candles: [CandlePoint] + public var liveCandle: CandlePoint? public var series: [LivelineSeries] public var hiddenSeriesIDs: Set + public var orderbook: LivelineOrderbook? public var value: Double public var config: LivelineConfig public var palette: LivelinePalette @@ -19,8 +21,10 @@ public struct LivelineChart: UIViewRepresentable { public init( points: [LivelinePoint], candles: [CandlePoint] = [], + liveCandle: CandlePoint? = nil, series: [LivelineSeries] = [], hiddenSeriesIDs: Set = [], + orderbook: LivelineOrderbook? = nil, value: Double, config: LivelineConfig = .init(), palette: LivelinePalette = .default, @@ -31,8 +35,10 @@ public struct LivelineChart: UIViewRepresentable { ) { self.points = points self.candles = candles + self.liveCandle = liveCandle self.series = series self.hiddenSeriesIDs = hiddenSeriesIDs + self.orderbook = orderbook self.value = value self.config = config self.palette = palette @@ -51,8 +57,10 @@ public struct LivelineChart: UIViewRepresentable { uiView.palette = palette uiView.points = points uiView.candles = candles + uiView.liveCandle = liveCandle uiView.series = series uiView.hiddenSeriesIDs = hiddenSeriesIDs + uiView.orderbook = orderbook uiView.liveValue = value uiView.referenceLine = referenceLine uiView.isLoading = loading From ff26451b7c6f5836bdd5c64a47f7c2a1dc05825b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 04:37:03 +0000 Subject: [PATCH 5/6] feat(batch 3/3): candle/line morph, animated window changes, SwiftUI controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final parity batch for the LivelineCanvas Swift package. - Candle↔line morph: `lineMode` + `lineData` on the view/bridge. OHLC collapses toward the close over a 500ms cosine ease while candles fade out and the morph line (tick data when provided, candle closes otherwise) fades in; Y-range blends between OHLC and line ranges; the dot appears and the crosshair switches to value/time once the line carries the values - Animated window changes: assigning a new `config.windowSeconds` transitions the visible window over 750ms with cosine easing on a logarithmic scale (WINDOW_TRANSITION_MS semantics) — no separate API needed - SwiftUI controls, equivalents of the React built-in UI row: * LivelineWindowBar — time buttons with sliding indicator, standard / rounded / text styles * LivelineModeToggle — line/candle icon toggle * LivelineSeriesChips — series visibility chips, compact mode, never hides the last visible series Verified: 19 unit tests pass; renderer typechecked against UIKit/CoreGraphics stubs (SwiftUI controls compile-checked only by the iOS CI job, not the stub harness). To test on device: candle chart with a lineMode toggle bound to LivelineModeToggle (candles should melt into the line and back), window bar switching 30s→5m (smooth zoom, not a jump), 3-series chart with chips (hiding fades the series and re-ranges; last chip refuses to hide). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U1KrgkhnUq4LKpoY9LpstF --- README.md | 6 +- .../LivelineCanvas/LivelineCanvasView.swift | 41 +++- Sources/LivelineCanvas/LivelineControls.swift | 202 ++++++++++++++++++ Sources/LivelineCanvas/Models.swift | 13 ++ Sources/LivelineCanvas/Renderer.swift | 122 +++++++++-- Sources/LivelineCanvas/SwiftUIBridge.swift | 8 + 6 files changed, 373 insertions(+), 19 deletions(-) create mode 100644 Sources/LivelineCanvas/LivelineControls.swift diff --git a/README.md b/README.md index f75b483..60cc7cc 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,8 @@ For SwiftUI, use `LivelineChart(...)` from the same package. Palettes can be der ### Feature coverage vs. the React component -Implemented: line mode with Fritsch-Carlson monotone spline, gradient fill, live-tip value interpolation with adaptive lerp speed, live dot with pulse ring + momentum glow, momentum detection with cascading chevron arrows, value badge pill with tail / momentum color / `minimal` variant, multi-series with per-series colors, labels, endpoint dots, visibility fades and combined crosshair tooltip, dashed current-value line, window-based scrolling (`windowSeconds`) with badge-aware right-edge buffer, frame-rate-independent lerp of value and Y-range, Y-range from visible data with the same margins (incl. `exaggerate`), TradingView-style grid intervals with hysteresis and per-label fades, time axis with nice intervals, crosshair with value/time tooltip via pan or long-press, candlestick mode with wicks, live candle (birth fade-in, lerped OHLC, pulsing glow) and a smoothed dashed close line, reference line, `formatValue`, loading squiggly with breathing alpha and a center-out reveal morph into the data line, left-edge fade, degen particles + chart shake, orderbook rising labels driven by price momentum and book churn, empty state, pause with smooth freeze and time-debt catch-up on resume, render loop that stops when the view leaves the window. +Implemented: line mode with Fritsch-Carlson monotone spline, gradient fill, live-tip value interpolation with adaptive lerp speed, live dot with pulse ring + momentum glow, momentum detection with cascading chevron arrows, value badge pill with tail / momentum color / `minimal` variant, multi-series with per-series colors, labels, endpoint dots, visibility fades and combined crosshair tooltip, dashed current-value line, window-based scrolling (`windowSeconds`) with badge-aware right-edge buffer, frame-rate-independent lerp of value and Y-range, Y-range from visible data with the same margins (incl. `exaggerate`), TradingView-style grid intervals with hysteresis and per-label fades, time axis with nice intervals, crosshair with value/time tooltip via pan or long-press, candlestick mode with wicks, live candle (birth fade-in, lerped OHLC, pulsing glow) and a smoothed dashed close line, candle↔line morph (`lineMode` + optional tick-level `lineData`; OHLC collapses to close over a 500ms cosine ease with blended Y-range), animated window changes (set a new `windowSeconds` and the chart transitions over 750ms on a log scale), reference line, `formatValue`, loading squiggly with breathing alpha and a center-out reveal morph into the data line, left-edge fade, degen particles + chart shake, orderbook rising labels driven by price momentum and book churn, empty state, pause with smooth freeze and time-debt catch-up on resume, render loop that stops when the view leaves the window. -Not (yet) ported: line/candle morph transition, window-change transition, built-in window/mode/series toggle controls (build these in SwiftUI/UIKit around the view; batch 3 adds SwiftUI equivalents). +SwiftUI controls (equivalents of the React built-in UI): `LivelineWindowBar` (time-horizon buttons, three styles, sliding indicator), `LivelineModeToggle` (line/candle icons), `LivelineSeriesChips` (series visibility, never hides the last one). Bind them to `config.windowSeconds`, `lineMode`, and `hiddenSeriesIDs` respectively. + +Remaining differences vs. the web version: candle crosshair shows the vertical line only (no OHLC tooltip), the candle→line density blend uses `lineData` directly instead of a timed 350ms transition, no reverse morph when data empties, and no reduced-motion mode. diff --git a/Sources/LivelineCanvas/LivelineCanvasView.swift b/Sources/LivelineCanvas/LivelineCanvasView.swift index 799cf0c..b7f19fb 100644 --- a/Sources/LivelineCanvas/LivelineCanvasView.swift +++ b/Sources/LivelineCanvas/LivelineCanvasView.swift @@ -20,7 +20,15 @@ private final class DisplayLinkProxy: NSObject { public final class LivelineCanvasView: UIView { public var config: LivelineConfig { - didSet { setNeedsDisplay() } + didSet { + // Window changes animate over 750ms with logarithmic + // interpolation, like the React window buttons + if oldValue.windowSeconds != config.windowSeconds { + let now = Date().timeIntervalSince1970 + windowMorph = (from: effectiveWindow(at: now, previous: oldValue.windowSeconds), start: now) + } + setNeedsDisplay() + } } public var palette: LivelinePalette { @@ -73,12 +81,38 @@ public final class LivelineCanvasView: UIView { didSet { setNeedsDisplay() } } + /// Candle mode: morph the candles into a line display (React `lineMode`). + public var lineMode: Bool = false { + didSet { setNeedsDisplay() } + } + + /// Tick-level data for line-mode density during the morph (React `lineData`). + public var lineData: [LivelinePoint] = [] { + didSet { setNeedsDisplay() } + } + public var onHover: ((LivelinePoint?) -> Void)? private var displayLink: CADisplayLink? private var hoverX: CGFloat? private var state = RenderState() private var lastTimestamp: CFTimeInterval? + private var windowMorph: (from: TimeInterval, start: TimeInterval)? + + /// Window during a change transition: 750ms cosine ease over a + /// logarithmic scale (WINDOW_TRANSITION_MS semantics). + private func effectiveWindow(at now: TimeInterval, previous: TimeInterval? = nil) -> TimeInterval { + guard let morph = windowMorph else { return previous ?? config.windowSeconds } + let target = previous ?? config.windowSeconds + let progress = (now - morph.start) / 0.75 + if progress >= 1 { + windowMorph = nil + return target + } + let eased = 0.5 - 0.5 * cos(progress * .pi) + let from = Swift.max(morph.from, 1) + return exp(log(from) + (log(Swift.max(target, 1)) - log(from)) * eased) + } public override init(frame: CGRect) { self.config = LivelineConfig() @@ -158,7 +192,10 @@ public final class LivelineCanvasView: UIView { hoverX: hoverX, isPaused: isPaused, isLoading: isLoading, - now: now + now: now, + window: effectiveWindow(at: now), + lineMode: lineMode, + lineData: lineData ), state: &state, dt: dt diff --git a/Sources/LivelineCanvas/LivelineControls.swift b/Sources/LivelineCanvas/LivelineControls.swift new file mode 100644 index 0000000..76dbc6c --- /dev/null +++ b/Sources/LivelineCanvas/LivelineControls.swift @@ -0,0 +1,202 @@ +import Foundation + +#if canImport(SwiftUI) && canImport(UIKit) +import SwiftUI + +/// Time-horizon buttons with a sliding active indicator — SwiftUI equivalent +/// of the React window bar (`windows` / `windowStyle` / `onWindowChange`). +/// Bind `selection` to the value you pass as `config.windowSeconds`; the +/// chart animates the window change itself (750ms, log-interpolated). +public struct LivelineWindowBar: View { + public enum Style { + /// Subtle background, rounded corners (React `default`). + case standard + /// Fully rounded pill (React `rounded`). + case rounded + /// No chrome, text only (React `text`). + case text + } + + private let options: [LivelineWindowOption] + @Binding private var selection: TimeInterval + private let style: Style + @Namespace private var indicator + + public init(options: [LivelineWindowOption], selection: Binding, style: Style = .standard) { + self.options = options + self._selection = selection + self.style = style + } + + public var body: some View { + HStack(spacing: style == .text ? 4 : 2) { + ForEach(options) { option in + let isActive = option.seconds == selection + Button { + withAnimation(.easeInOut(duration: 0.25)) { + selection = option.seconds + } + } label: { + Text(option.label) + .font(.system(size: 11, weight: isActive ? .semibold : .regular, design: .monospaced)) + .foregroundColor(isActive ? .primary : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Group { + if isActive && style != .text { + RoundedRectangle(cornerRadius: style == .rounded ? 999 : 5) + .fill(Color.primary.opacity(0.12)) + .matchedGeometryEffect(id: "window-indicator", in: indicator) + } + } + ) + } + .buttonStyle(.plain) + } + } + .padding(style == .text ? 0 : (style == .rounded ? 3 : 2)) + .background( + Group { + if style != .text { + RoundedRectangle(cornerRadius: style == .rounded ? 999 : 6) + .fill(Color.primary.opacity(0.05)) + } + } + ) + } +} + +/// Line/candle mode toggle — SwiftUI equivalent of the built-in toggle the +/// React component renders when `onModeChange` is set. Bind `isLineMode` to +/// the value you pass as the chart's `lineMode`. +public struct LivelineModeToggle: View { + @Binding private var isLineMode: Bool + @Namespace private var indicator + + public init(isLineMode: Binding) { + self._isLineMode = isLineMode + } + + public var body: some View { + HStack(spacing: 2) { + modeButton(line: true) + modeButton(line: false) + } + .padding(2) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.05))) + } + + private func modeButton(line: Bool) -> some View { + let isActive = isLineMode == line + return Button { + withAnimation(.easeInOut(duration: 0.25)) { + isLineMode = line + } + } label: { + Group { + if line { LineModeIcon() } else { CandleModeIcon() } + } + .frame(width: 22, height: 16) + .opacity(isActive ? 1 : 0.45) + .padding(.horizontal, 5) + .padding(.vertical, 4) + .background( + Group { + if isActive { + RoundedRectangle(cornerRadius: 5) + .fill(Color.primary.opacity(0.12)) + .matchedGeometryEffect(id: "mode-indicator", in: indicator) + } + } + ) + } + .buttonStyle(.plain) + } +} + +private struct LineModeIcon: View { + var body: some View { + Path { p in + p.move(to: CGPoint(x: 1, y: 12)) + p.addCurve(to: CGPoint(x: 8, y: 6), control1: CGPoint(x: 4, y: 12), control2: CGPoint(x: 5, y: 6)) + p.addCurve(to: CGPoint(x: 14, y: 10), control1: CGPoint(x: 11, y: 6), control2: CGPoint(x: 12, y: 10)) + p.addCurve(to: CGPoint(x: 21, y: 3), control1: CGPoint(x: 17, y: 10), control2: CGPoint(x: 18, y: 3)) + } + .stroke(Color.primary, style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)) + } +} + +private struct CandleModeIcon: View { + var body: some View { + ZStack { + Path { p in + p.move(to: CGPoint(x: 7, y: 1)) + p.addLine(to: CGPoint(x: 7, y: 15)) + p.move(to: CGPoint(x: 15, y: 1)) + p.addLine(to: CGPoint(x: 15, y: 15)) + } + .stroke(Color.primary, lineWidth: 1.2) + Path { p in + p.addRoundedRect(in: CGRect(x: 4.5, y: 4, width: 5, height: 7), cornerSize: CGSize(width: 1, height: 1)) + p.addRoundedRect(in: CGRect(x: 12.5, y: 6, width: 5, height: 6), cornerSize: CGSize(width: 1, height: 1)) + } + .fill(Color.primary) + } + } +} + +/// Series visibility chips — SwiftUI equivalent of the React toggle chips in +/// multi-series mode. Bind `hidden` to the chart's `hiddenSeriesIDs`; the +/// last visible series can never be hidden, matching the web behavior. +public struct LivelineSeriesChips: View { + private let series: [LivelineSeries] + @Binding private var hidden: Set + private let compact: Bool + + public init(series: [LivelineSeries], hidden: Binding>, compact: Bool = false) { + self.series = series + self._hidden = hidden + self.compact = compact + } + + public var body: some View { + HStack(spacing: 6) { + ForEach(Array(series.enumerated()), id: \.element.id) { index, s in + let isHidden = hidden.contains(s.id) + Button { + toggle(s.id) + } label: { + HStack(spacing: 5) { + Circle() + .fill(Color(uiColor: s.color ?? LivelinePalette.seriesColors[index % LivelinePalette.seriesColors.count])) + .frame(width: compact ? 8 : 6, height: compact ? 8 : 6) + if !compact { + Text(s.label ?? s.id) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, compact ? 5 : 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color.primary.opacity(0.05))) + .opacity(isHidden ? 0.4 : 1) + } + .buttonStyle(.plain) + } + } + } + + private func toggle(_ id: String) { + withAnimation(.easeInOut(duration: 0.2)) { + if hidden.contains(id) { + hidden.remove(id) + } else { + let visibleCount = series.filter { !hidden.contains($0.id) }.count + guard visibleCount > 1 else { return } + hidden.insert(id) + } + } + } +} +#endif diff --git a/Sources/LivelineCanvas/Models.swift b/Sources/LivelineCanvas/Models.swift index 3b975d7..05ed4ea 100644 --- a/Sources/LivelineCanvas/Models.swift +++ b/Sources/LivelineCanvas/Models.swift @@ -90,6 +90,19 @@ public struct LivelineOrderbook: Sendable, Equatable { } } +/// One time-horizon button for `LivelineWindowBar`. +public struct LivelineWindowOption: Sendable, Equatable, Identifiable { + public let label: String + public let seconds: TimeInterval + + public var id: TimeInterval { seconds } + + public init(label: String, seconds: TimeInterval) { + self.label = label + self.seconds = seconds + } +} + public enum LivelineBadgeVariant: Sendable { /// Accent/momentum-colored pill with white text. case `default` diff --git a/Sources/LivelineCanvas/Renderer.swift b/Sources/LivelineCanvas/Renderer.swift index 15af7c8..f19a190 100644 --- a/Sources/LivelineCanvas/Renderer.swift +++ b/Sources/LivelineCanvas/Renderer.swift @@ -54,6 +54,12 @@ struct RenderState { var obPrevBidTotal: Double = 0 var obPrevAskTotal: Double = 0 var obChurnRate: Double = 0 + + /// Candle↔line morph: 0 = candles, 1 = line (LINE_MORPH_MS = 500, cosine). + var lineModeProg: Double = 0 + var lineMorphTarget: Double = 0 + var lineMorphFrom: Double = 0 + var lineMorphStart: TimeInterval = -1 } struct RenderInput { @@ -72,6 +78,13 @@ struct RenderInput { let isPaused: Bool let isLoading: Bool let now: TimeInterval + /// Effective visible window — differs from config.windowSeconds while a + /// window-change transition is animating. + let window: TimeInterval + /// Candle mode: morph candles into a line display (React `lineMode`). + let lineMode: Bool + /// Tick-level data for line-mode density (React `lineData`). + let lineData: [LivelinePoint] } enum LivelineRenderer { @@ -141,7 +154,21 @@ enum LivelineRenderer { let now = input.now - state.timeDebt let pausedDt = dt * (1 - state.pauseProgress) - let window = Swift.max(config.windowSeconds, 1) + // --- Candle↔line morph progress (timed 500ms cosine ease) --- + let lineTarget = input.lineMode ? 1.0 : 0.0 + if state.lineMorphTarget != lineTarget { + state.lineMorphTarget = lineTarget + state.lineMorphFrom = state.lineModeProg + state.lineMorphStart = input.now + } + if state.lineModeProg != lineTarget { + let progress = state.lineMorphStart < 0 ? 1 : clamp((input.now - state.lineMorphStart) / 0.5, 0, 1) + let eased = 0.5 - 0.5 * cos(progress * .pi) + state.lineModeProg = state.lineMorphFrom + (lineTarget - state.lineMorphFrom) * eased + } + let lineProg = config.mode == .candle ? state.lineModeProg : 0 + + let window = Swift.max(input.window, 1) // Small time buffer past "now" keeps the live dot inside the chart; // wider when the badge needs room (WINDOW_BUFFER semantics). let showBadge = config.showBadge && config.mode == .line && !isMulti @@ -165,6 +192,23 @@ enum LivelineRenderer { let visiblePoints = isMulti ? [] : visibleSlice(input.points, leftTime: leftTime) let visibleCandles = input.candles.filter { $0.time >= leftTime - config.candleWidthSeconds } + // Line the candles morph into: tick-level data when provided, + // otherwise the candle closes + let morphLinePoints: [LivelinePoint] + if config.mode == .candle, lineProg > 0.001 { + if !input.lineData.isEmpty { + morphLinePoints = visibleSlice(input.lineData, leftTime: leftTime) + } else { + var closes = visibleCandles.map { LivelinePoint(time: $0.time + config.candleWidthSeconds / 2, value: $0.close) } + if let live = input.liveCandle { + closes.append(LivelinePoint(time: live.time + config.candleWidthSeconds / 2, value: live.close)) + } + morphLinePoints = closes + } + } else { + morphLinePoints = [] + } + let range: (min: Double, max: Double) if isMulti { var uMin = Double.infinity @@ -184,13 +228,29 @@ enum LivelineRenderer { range = uMin.isFinite ? (uMin, uMax) : (0, 1) } else { let rangeCandles = input.liveCandle.map { visibleCandles + [$0] } ?? visibleCandles - range = valueRange( + let baseRange = valueRange( points: config.mode == .line ? visiblePoints : [], candles: config.mode == .candle ? rangeCandles : [], currentValue: input.value, reference: input.referenceLine, exaggerate: config.exaggerate ) + if config.mode == .candle, lineProg > 0.001, !morphLinePoints.isEmpty { + // Blend candle OHLC range into the line range during the morph + let lineRange = valueRange( + points: morphLinePoints, + candles: [], + currentValue: input.value, + reference: input.referenceLine, + exaggerate: config.exaggerate + ) + range = ( + baseRange.min + (lineRange.min - baseRange.min) * lineProg, + baseRange.max + (lineRange.max - baseRange.max) * lineProg + ) + } else { + range = baseRange + } } if !state.initialized { @@ -258,7 +318,18 @@ enum LivelineRenderer { } } case .candle: - drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, liveCandle: input.liveCandle, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette, state: &state, dt: pausedDt, animNow: input.now) + if lineProg < 0.999 { + drawCandles(in: ctx, chartRect: chartRect, candles: visibleCandles, liveCandle: input.liveCandle, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette, state: &state, dt: pausedDt, animNow: input.now, collapse: lineProg, alphaScale: 1 - lineProg) + } + if lineProg > 0.001, !morphLinePoints.isEmpty { + drawLine(in: ctx, chartRect: chartRect, points: morphLinePoints, smoothValue: state.displayValue, tipTime: now, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette, reveal: reveal, animNow: input.now, alphaScale: lineProg) + if lineProg > 0.5, config.showDot, reveal > 0.5 { + let tipX = xForTime(now, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) + let tipY = yForValue(state.displayValue, minValue: minValue, maxValue: maxValue, chartRect: chartRect) + let dot = CGPoint(x: tipX, y: clamp(tipY, rect.minY + 10, rect.maxY - 10)) + drawDot(in: ctx, at: dot, palette: palette, pulse: config.pulse, glow: nil, now: input.now) + } + } } } @@ -275,7 +346,15 @@ enum LivelineRenderer { if isMulti { drawMultiCrosshair(in: ctx, chartRect: chartRect, x: hoverX, series: input.series, hidden: input.hiddenSeriesIDs, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) } else { - drawCrosshair(in: ctx, chartRect: chartRect, x: hoverX, points: input.points, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) + // In candle mode the value tooltip only makes sense once the + // morph line carries the values + let crosshairPoints: [LivelinePoint] + if config.mode == .candle { + crosshairPoints = lineProg > 0.5 ? (input.lineData.isEmpty ? morphLinePoints : input.lineData) : [] + } else { + crosshairPoints = input.points + } + drawCrosshair(in: ctx, chartRect: chartRect, x: hoverX, points: crosshairPoints, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, config: config, palette: palette) } } @@ -500,13 +579,13 @@ enum LivelineRenderer { ctx.strokePath() } - static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], smoothValue: Double, tipTime: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, reveal: Double = 1, animNow: TimeInterval = 0) { + static func drawLine(in ctx: CGContext, chartRect: CGRect, points: [LivelinePoint], smoothValue: Double, tipTime: TimeInterval, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, reveal: Double = 1, animNow: TimeInterval = 0, alphaScale: Double = 1) { var pts = linePoints(points, smoothValue: smoothValue, tipTime: tipTime, rightEdgeTime: rightEdgeTime, window: window, minValue: minValue, maxValue: maxValue, chartRect: chartRect) guard pts.count >= 2 else { return } let ms = animNow.truncatingRemainder(dividingBy: 86_400) * 1000 - var lineAlpha = 1.0 - var fillAlpha = 1.0 + var lineAlpha = alphaScale + var fillAlpha = alphaScale var strokeColor = palette.line if reveal < 1 { @@ -529,8 +608,8 @@ enum LivelineRenderer { // Line shares the loading breath at reveal=0, ramps to full; // color blends grey → accent by reveal ≈ 0.3 let breath = 0.22 + 0.08 * sin(ms / 1200 * .pi) - lineAlpha = breath + (1 - breath) * reveal - fillAlpha = reveal + lineAlpha = (breath + (1 - breath) * reveal) * alphaScale + fillAlpha = reveal * alphaScale strokeColor = blendColor(palette.gridLabel, palette.line, t: Swift.min(1, reveal * 3)) } @@ -580,7 +659,7 @@ enum LivelineRenderer { // During reveal the dash line unfolds from the vertical center let y = reveal < 1 ? chartRect.midY + (realY - chartRect.midY) * CGFloat(reveal) : realY ctx.saveGState() - ctx.setStrokeColor(scaledAlpha(palette.dashLine, reveal).cgColor) + ctx.setStrokeColor(scaledAlpha(palette.dashLine, reveal * alphaScale).cgColor) ctx.setLineWidth(1) ctx.setLineDash(phase: 0, lengths: [4, 4]) ctx.move(to: CGPoint(x: chartRect.minX, y: y)) @@ -672,12 +751,25 @@ enum LivelineRenderer { // MARK: - Candle mode - static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], liveCandle: CandlePoint?, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, state: inout RenderState, dt: Double, animNow: TimeInterval) { + static func drawCandles(in ctx: CGContext, chartRect: CGRect, candles: [CandlePoint], liveCandle: CandlePoint?, rightEdgeTime: TimeInterval, window: TimeInterval, minValue: Double, maxValue: Double, config: LivelineConfig, palette: LivelinePalette, state: inout RenderState, dt: Double, animNow: TimeInterval, collapse: Double = 0, alphaScale: Double = 1) { guard !candles.isEmpty || liveCandle != nil else { return } let pxPerSecond = chartRect.width / CGFloat(window) let bodyWidth = Swift.max(1, pxPerSecond * CGFloat(config.candleWidthSeconds) * 0.7) let wickWidth = clamp(bodyWidth * 0.15, 0.8, 2) + // During the line morph, OHLC collapses toward the close price + func collapsed(_ c: CandlePoint) -> CandlePoint { + guard collapse > 0 else { return c } + let k = 1 - collapse + return CandlePoint( + time: c.time, + open: c.close + (c.open - c.close) * k, + high: c.close + (c.high - c.close) * k, + low: c.close + (c.low - c.close) * k, + close: c.close + ) + } + func drawCandle(_ c: CandlePoint, at time: TimeInterval, alpha: Double) { let x = xForTime(time + config.candleWidthSeconds / 2, rightEdgeTime: rightEdgeTime, window: window, chartRect: chartRect) let yOpen = yForValue(c.open, minValue: minValue, maxValue: maxValue, chartRect: chartRect) @@ -710,7 +802,7 @@ enum LivelineRenderer { ctx.clip(to: chartRect.insetBy(dx: -1, dy: 0)) // Candle time is the bucket's open time — bodies are centered on it for c in candles { - drawCandle(c, at: c.time, alpha: 1) + drawCandle(collapsed(c), at: c.time, alpha: alphaScale) } // Live candle: birth fade-in, lerped OHLC, pulsing glow @@ -731,12 +823,12 @@ enum LivelineRenderer { let display = CandlePoint(time: live.time, open: smooth.open, high: smooth.high, low: smooth.low, close: smooth.close) let ms = animNow.truncatingRemainder(dividingBy: 86_400) * 1000 - let glowAlpha = 0.12 + sin(ms * 0.004) * 0.08 + let glowAlpha = (0.12 + sin(ms * 0.004) * 0.08) * alphaScale let glowBase = display.close >= display.open ? palette.upCandle : palette.downCandle ctx.saveGState() ctx.setShadow(offset: .zero, blur: 8, color: glowBase.withAlphaComponent(CGFloat(Swift.max(0, glowAlpha))).cgColor) - drawCandle(display, at: live.time, alpha: state.liveCandleAlpha) + drawCandle(collapsed(display), at: live.time, alpha: state.liveCandleAlpha * alphaScale) ctx.restoreGState() closeSource = display @@ -753,7 +845,7 @@ enum LivelineRenderer { ) let color = source.close >= source.open ? palette.upCandle : palette.downCandle ctx.saveGState() - ctx.setStrokeColor(color.withAlphaComponent(0.4).cgColor) + ctx.setStrokeColor(color.withAlphaComponent(CGFloat(0.4 * alphaScale)).cgColor) ctx.setLineWidth(1) ctx.setLineDash(phase: 0, lengths: [4, 4]) ctx.move(to: CGPoint(x: chartRect.minX, y: y)) diff --git a/Sources/LivelineCanvas/SwiftUIBridge.swift b/Sources/LivelineCanvas/SwiftUIBridge.swift index 67f5551..3f1ba39 100644 --- a/Sources/LivelineCanvas/SwiftUIBridge.swift +++ b/Sources/LivelineCanvas/SwiftUIBridge.swift @@ -16,6 +16,8 @@ public struct LivelineChart: UIViewRepresentable { public var referenceLine: LivelineReferenceLine? public var loading: Bool public var paused: Bool + public var lineMode: Bool + public var lineData: [LivelinePoint] public var onHover: ((LivelinePoint?) -> Void)? public init( @@ -31,6 +33,8 @@ public struct LivelineChart: UIViewRepresentable { referenceLine: LivelineReferenceLine? = nil, loading: Bool = false, paused: Bool = false, + lineMode: Bool = false, + lineData: [LivelinePoint] = [], onHover: ((LivelinePoint?) -> Void)? = nil ) { self.points = points @@ -45,6 +49,8 @@ public struct LivelineChart: UIViewRepresentable { self.referenceLine = referenceLine self.loading = loading self.paused = paused + self.lineMode = lineMode + self.lineData = lineData self.onHover = onHover } @@ -65,6 +71,8 @@ public struct LivelineChart: UIViewRepresentable { uiView.referenceLine = referenceLine uiView.isLoading = loading uiView.isPaused = paused + uiView.lineMode = lineMode + uiView.lineData = lineData uiView.onHover = onHover } } From 4ef5a3284e637d66a30a7a35e01fba9ea88b009c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 04:38:27 +0000 Subject: [PATCH 6/6] fix: correct CGGradient argument label caught by iOS CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The components-based CGGradient initializer takes colorSpace: (the CFArray variant is the one spelled colorsSpace:) — the stub harness had the wrong label so the typecheck passed while the real iOS SDK rejected it. Both call sites fixed; this was the only iOS compile error across all three batches. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U1KrgkhnUq4LKpoY9LpstF --- Sources/LivelineCanvas/Renderer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/LivelineCanvas/Renderer.swift b/Sources/LivelineCanvas/Renderer.swift index f19a190..a84709a 100644 --- a/Sources/LivelineCanvas/Renderer.swift +++ b/Sources/LivelineCanvas/Renderer.swift @@ -637,7 +637,7 @@ enum LivelineRenderer { r1, g1, b1, a1 * CGFloat(fillAlpha), r2, g2, b2, a2 * CGFloat(fillAlpha) ] - if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colorComponents: components, locations: [0, 1], count: 2) { + if let gradient = CGGradient(colorSpace: CGColorSpaceCreateDeviceRGB(), colorComponents: components, locations: [0, 1], count: 2) { ctx.drawLinearGradient( gradient, start: CGPoint(x: 0, y: chartRect.minY), @@ -675,7 +675,7 @@ enum LivelineRenderer { var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 _ = background.getRed(&r, green: &g, blue: &b, alpha: &a) let components: [CGFloat] = [r, g, b, 1, r, g, b, 0] - guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colorComponents: components, locations: [0, 1], count: 2) else { return } + guard let gradient = CGGradient(colorSpace: CGColorSpaceCreateDeviceRGB(), colorComponents: components, locations: [0, 1], count: 2) else { return } ctx.saveGState() ctx.clip(to: CGRect(x: chartRect.minX, y: chartRect.minY, width: 40, height: chartRect.height)) ctx.drawLinearGradient(