Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Foundation

/// d3's `scaleTime().ticks(count)` (d3-time `ticks`): calendar-aligned tick dates at the
/// interval whose length is closest to `span / count`, in local time. Swift Charts'
/// `.automatic(desiredCount:)` picks coarser intervals, which left the market chart with far
/// fewer date labels than the web `AreaChart`.
public enum TimeAxisTicks {
private struct Interval {
let component: Calendar.Component
let step: Int
let seconds: Double
}

private static let minute = 60.0
private static let hour = 3_600.0
private static let day = 86_400.0
private static let week = 604_800.0
private static let month = 2_592_000.0
private static let year = 31_536_000.0

/// d3-time's `tickIntervals` from one minute up (the charts never span less).
private static let intervals: [Interval] = [
Interval(component: .minute, step: 1, seconds: minute),
Interval(component: .minute, step: 5, seconds: 5 * minute),
Interval(component: .minute, step: 15, seconds: 15 * minute),
Interval(component: .minute, step: 30, seconds: 30 * minute),
Interval(component: .hour, step: 1, seconds: hour),
Interval(component: .hour, step: 3, seconds: 3 * hour),
Interval(component: .hour, step: 6, seconds: 6 * hour),
Interval(component: .hour, step: 12, seconds: 12 * hour),
Interval(component: .day, step: 1, seconds: day),
Interval(component: .day, step: 2, seconds: 2 * day),
Interval(component: .weekOfYear, step: 1, seconds: week),
Interval(component: .month, step: 1, seconds: month),
Interval(component: .month, step: 3, seconds: 3 * month),
Interval(component: .year, step: 1, seconds: year),
]

/// The tick dates between `start` and `end` (both included when they fall on a tick).
public static func values(from start: Date, to end: Date, count: Int, calendar: Calendar = .current) -> [Date] {
guard count > 0, end > start else { return [] }
let target = end.timeIntervalSince(start) / Double(count)
let interval: Interval
if let index = intervals.firstIndex(where: { $0.seconds > target }) {
if index == 0 {
interval = intervals[0]
} else {
// d3: the neighbour whose length is closer to the target, by ratio.
let lower = intervals[index - 1]
let upper = intervals[index]
interval = target / lower.seconds < upper.seconds / target ? lower : upper
}
} else {
// Beyond a year: whole years at a nice step (d3 `tickStep` over years).
let years = target / year
interval = Interval(component: .year, step: niceStep(years), seconds: year)
}
return ticks(from: start, to: end, interval: interval, calendar: calendar)
}

private static func niceStep(_ value: Double) -> Int {
guard value > 1 else { return 1 }
let power = pow(10, (log10(value)).rounded(.down))
let error = value / power
let factor: Double = error >= 7.07 ? 10 : error >= 3.16 ? 5 : error >= 1.41 ? 2 : 1
return max(1, Int(factor * power))
}

private static func ticks(from start: Date, to end: Date, interval: Interval, calendar: Calendar) -> [Date] {
guard var tick = alignedStart(start, interval: interval, calendar: calendar) else { return [] }
var result: [Date] = []
var guardCount = 0
// d3's `timeDay.every(2)` keeps days whose day-of-month is `1 + k·step`, so the count
// restarts on the 1st of each month: step one day at a time and filter.
let filtersDays = interval.component == .day && interval.step > 1
let advance = filtersDays ? 1 : interval.step
while tick <= end, guardCount < 10_000 {
let keeps = !filtersDays || (calendar.component(.day, from: tick) - 1) % interval.step == 0
if tick >= start, keeps { result.append(tick) }
guard let next = calendar.date(byAdding: interval.component, value: advance, to: tick) else { break }
tick = next
guardCount += 1
}
return result
}

/// The interval boundary at or before `date`, aligned as d3's `every(step)` aligns: minutes,
/// hours, months and years on multiples of the step, days on day-of-month `1 + k·step`,
/// weeks on the calendar's first weekday.
private static func alignedStart(_ date: Date, interval: Interval, calendar: Calendar) -> Date? {
switch interval.component {
case .minute, .hour:
let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
var aligned = parts
if interval.component == .minute {
aligned.minute = (parts.minute ?? 0) / interval.step * interval.step
} else {
aligned.minute = 0
aligned.hour = (parts.hour ?? 0) / interval.step * interval.step
}
return calendar.date(from: aligned)
case .day:
let parts = calendar.dateComponents([.year, .month, .day], from: date)
var aligned = parts
aligned.day = ((parts.day ?? 1) - 1) / interval.step * interval.step + 1
return calendar.date(from: aligned)
case .weekOfYear:
return calendar.dateInterval(of: .weekOfYear, for: date)?.start
case .month:
let parts = calendar.dateComponents([.year, .month], from: date)
var aligned = parts
aligned.month = ((parts.month ?? 1) - 1) / interval.step * interval.step + 1
aligned.day = 1
return calendar.date(from: aligned)
default:
let parts = calendar.dateComponents([.year], from: date)
let year = (parts.year ?? 1970) / interval.step * interval.step
return calendar.date(from: DateComponents(year: year, month: 1, day: 1))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@ struct ResearchMarketChartView: View {
}
.chartXAxis {
// AreaChart's bottom axis: `numTicks={Math.max(2, Math.floor(innerWidth / 90))}`.
AxisMarks(values: .automatic(desiredCount: max(2, Int(chartWidth / 90)))) { value in
// d3 `scaleTime().ticks(n)` dates: `.automatic(desiredCount:)` picked coarser steps.
AxisMarks(
values: plot.times.first.flatMap { first in
plot.times.last.map { last in
TimeAxisTicks.values(from: first, to: last, count: max(2, Int(chartWidth / 90)))
}
} ?? []
) { value in
AxisTick()
AxisValueLabel {
if let date = value.as(Date.self) {
Expand Down
109 changes: 73 additions & 36 deletions apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,55 +125,92 @@ struct ResearchMarketScorecardView: View {
@Environment(\.visionTheme) private var theme
private var strings: any Localizing { shell.strings }
var body: some View {
// ScorecardPanel (ResearchScorecard.tsx): the grade badge, the evaluated count and the
// severity count pills, then the worst-first concerns as an inset `List` of `ListRow`s.
VStack(alignment: .leading, spacing: 12) {
if scorecard.evaluated == 0 {
Text(strings.t("research.scorecard.noData")).foregroundStyle(theme.secondaryLabel)
Text(strings.t("research.scorecard.noData")).font(Self.callout).foregroundStyle(theme.secondaryLabel)
.padding(.vertical, 8)
} else {
HStack {
Text(strings.t("research.scorecard.grade." + scorecard.grade)).font(.headline)
if let score = scorecard.score {
Text(ResearchMarketDisplay.number(score, shell: shell, digits: 0)).monospacedDigit()
}
Text(strings.t("research.scorecard.evaluated", ["count": String(scorecard.evaluated)])).font(
.caption
)
.foregroundStyle(theme.secondaryLabel)
Spacer()
ForEach(["risk", "warn", "caution"], id: \.self) { severity in
if let count = scorecard.counts[severity], count > 0 {
Text(strings.t("research.scorecard.severity." + severity) + " " + String(count)).font(
.caption
).foregroundStyle(tone(severity))
HStack(spacing: 12) {
VisionBadge(text: gradeText, tone: gradeTone)
Text(strings.t("research.scorecard.evaluated", ["count": String(scorecard.evaluated)]))
.font(Self.footnote).foregroundStyle(theme.secondaryLabel)
Spacer(minLength: 0)
HStack(spacing: 6) {
ForEach(["risk", "warn", "caution"], id: \.self) { severity in
if let count = scorecard.counts[severity], count > 0 {
VisionBadge(
text: String(count) + " " + strings.t("research.scorecard.severity." + severity),
tone: severity == "risk" ? .destructive : .warning, small: true)
}
}
}
}
let concerns = scorecard.flags.filter { $0.severity != "ok" }
if concerns.isEmpty {
Label(strings.t("research.scorecard.allClear"), systemImage: "checkmark.circle").foregroundStyle(
theme.success)
}
ForEach(concerns) { flag in
VStack(alignment: .leading, spacing: 5) {
HStack {
Image(
systemName: flag.severity == "risk"
? "exclamationmark.octagon" : "exclamationmark.triangle"
).foregroundStyle(tone(flag.severity)).accessibilityLabel(
strings.t("research.scorecard.severity." + flag.severity))
Text(strings.t("research.metric." + flag.metric)).font(.callout.weight(.medium))
Spacer()
Text(flagValue(flag)).monospacedDigit()
Label(strings.t("research.scorecard.allClear"), systemImage: "checkmark.circle")
.font(Self.callout).foregroundStyle(theme.success)
} else {
InsightsInsetList {
ForEach(Array(concerns.enumerated()), id: \.element.id) { index, flag in
if index > 0 { InsightsInsetDivider() }
concernRow(flag)
}
let key = "research.scorecard.reason." + flag.reasonKey
Text(strings.t(key) == key ? flag.reason : strings.t(key)).font(.caption).foregroundStyle(
theme.secondaryLabel)
Text(strings.t("research.scorecard.benchmark", ["value": localizedBenchmark(flag.benchmark)]))
.font(.caption).foregroundStyle(theme.secondaryLabel)
}.padding(.vertical, 7)
}
}
}
}
}
private static var footnote: Font { VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote) }
private static var callout: Font { VisionFonts.font(DesignTokens.Typography.callout, relativeTo: .callout) }
/// `ScorecardGradeBadge`: the score, then the grade.
private var gradeText: String {
let grade = strings.t("research.scorecard.grade." + scorecard.grade)
guard let score = scorecard.score else { return grade }
return ResearchMarketDisplay.number(score, shell: shell, digits: 0) + " " + grade
}
private var gradeTone: BadgeTone {
switch scorecard.grade {
case "strong", "healthy": return .success
case "mixed", "weak": return .warning
case "poor": return .destructive
default: return .muted
}
}
/// A concern's `ListRow`: the severity glyph, the metric over its reason, and the value over
/// its benchmark at the trailing edge.
private func concernRow(_ flag: ResearchScorecardFlag) -> some View {
let key = "research.scorecard.reason." + flag.reasonKey
return HStack(alignment: .center, spacing: 12) {
Image(systemName: Self.icon(flag.severity))
.font(.system(size: 15)).foregroundStyle(tone(flag.severity))
.frame(width: 28, height: 28)
.accessibilityLabel(strings.t("research.scorecard.severity." + flag.severity))
VStack(alignment: .leading, spacing: 0) {
Text(strings.t("research.metric." + flag.metric)).lineLimit(1).foregroundStyle(theme.foreground)
Text(strings.t(key) == key ? flag.reason : strings.t(key)).font(Self.footnote).lineLimit(1)
.foregroundStyle(theme.secondaryLabel)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 4) {
Text(flagValue(flag)).monospacedDigit().foregroundStyle(theme.foreground)
Text(strings.t("research.scorecard.benchmark", ["value": localizedBenchmark(flag.benchmark)]))
.foregroundStyle(theme.secondaryLabel)
}
.font(Self.footnote)
}
.padding(.horizontal, 16).padding(.vertical, 10)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
}
/// lucide `CheckCircle2`, `TriangleAlert`, `AlertTriangle` and `ShieldAlert`.
private static func icon(_ severity: String) -> String {
switch severity {
case "ok": return "checkmark.circle"
case "risk": return "exclamationmark.shield"
default: return "exclamationmark.triangle"
}
}
private func tone(_ severity: String) -> Color {
severity == "risk" ? theme.destructive : severity == "ok" ? theme.success : theme.warning
}
Expand Down
25 changes: 17 additions & 8 deletions apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,28 @@ struct ResearchSymbolSearchView: View {
Image(systemName: leadingSymbol).font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.secondaryLabel).frame(width: 14).accessibilityHidden(true)
}
Text(item.symbol ?? "—").font(.callout.monospaced().weight(.medium)).frame(
minWidth: 64, alignment: .leading)
Text(item.name ?? "").font(.callout).foregroundStyle(theme.secondaryLabel).lineLimit(1)
// SymbolSearchResultItem: mono `type-body` ticker (`min-w-[5rem]`), the name in
// `type-body` secondary, the type as an outline `Badge size="sm"` and the exchange in
// `type-footnote`.
Text(item.symbol ?? "—")
.font(.system(size: CGFloat(DesignTokens.Typography.body.size), weight: .medium, design: .monospaced))
.frame(minWidth: 80, alignment: .leading)
Text(item.name ?? "").font(VisionFonts.font(DesignTokens.Typography.body, relativeTo: .body))
.foregroundStyle(theme.secondaryLabel).lineLimit(1)
Spacer(minLength: 0)
if let type = item.type, !type.isEmpty {
Text(type).font(.caption).padding(.horizontal, 6).padding(.vertical, 3)
.overlay(RoundedRectangle(cornerRadius: 5).stroke(theme.border))
VisionBadge(text: type, tone: .outline, small: true).fixedSize()
}
if let exchange = item.exchange, !exchange.isEmpty {
Text(exchange).font(.caption).foregroundStyle(theme.secondaryLabel)
Text(exchange).font(VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote))
.foregroundStyle(theme.secondaryLabel).fixedSize()
}
}.padding(10).frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? theme.foreground.opacity(0.06) : .clear, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// `px-3 py-2.5 rounded-control`, `bg-foreground/[0.06]` while active.
.padding(.horizontal, 12).padding(.vertical, 10).frame(maxWidth: .infinity, alignment: .leading)
.background(
selected ? theme.foreground.opacity(0.06) : .clear,
in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control))
}
private func move(_ direction: Int) -> KeyPress.Result {
guard isOpen, !isUnavailable, !navigable.isEmpty else { return .ignored }
Expand Down
39 changes: 39 additions & 0 deletions apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation
import Testing
@testable import VisionNativeCore

/// The market chart's date ticks follow d3 `scaleTime().ticks(n)`.
struct TimeAxisTicksTests {
private let calendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Brussels")!
return calendar
}()

private func date(_ text: String) -> Date { ISO8601DateFormatter().date(from: text)! }

@Test func oneYearAtNineTicksIsMonthly() {
let ticks = TimeAxisTicks.values(
from: date("2025-11-14T00:00:00Z"), to: date("2026-11-14T00:00:00Z"), count: 9, calendar: calendar)
#expect(ticks.count == 12)
#expect(ticks.allSatisfy { calendar.component(.day, from: $0) == 1 })
}

@Test func oneMonthAtNineTicksIsEveryOtherDay() {
let ticks = TimeAxisTicks.values(
from: date("2026-10-14T00:00:00Z"), to: date("2026-11-14T00:00:00Z"), count: 9, calendar: calendar)
#expect(ticks.count == 16)
#expect(ticks.allSatisfy { (calendar.component(.day, from: $0) - 1) % 2 == 0 })
}

@Test func decadesStepByNiceYears() {
let ticks = TimeAxisTicks.values(
from: date("2000-01-01T00:00:00Z"), to: date("2026-11-14T00:00:00Z"), count: 9, calendar: calendar)
#expect(ticks.map { calendar.component(.year, from: $0) } == Array(stride(from: 2002, through: 2026, by: 2)))
}

@Test func emptyRangeHasNoTicks() {
let now = date("2026-11-14T00:00:00Z")
#expect(TimeAxisTicks.values(from: now, to: now, count: 9, calendar: calendar).isEmpty)
}
}
Loading