diff --git a/apps/native/Sources/VisionNativeCore/Screens/Shared/TimeAxisTicks.swift b/apps/native/Sources/VisionNativeCore/Screens/Shared/TimeAxisTicks.swift new file mode 100644 index 000000000..11cf5fdd1 --- /dev/null +++ b/apps/native/Sources/VisionNativeCore/Screens/Shared/TimeAxisTicks.swift @@ -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)) + } + } +} diff --git a/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift b/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift index d87aa56e1..090664035 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift @@ -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) { diff --git a/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift b/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift index be776f687..c605f6540 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift @@ -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 } diff --git a/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift b/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift index 26ddcc280..908d9de1f 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift @@ -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 } diff --git a/apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift b/apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift new file mode 100644 index 000000000..7d30dab91 --- /dev/null +++ b/apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift @@ -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) + } +}