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
120 changes: 91 additions & 29 deletions AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ struct CandleChartView: View {
@State private var candleWidth: CGFloat = 4
/// 현재 가시 X 구간의 중심(스크롤 위치 바인딩)
@State private var centerOfVisibleXRange: Date = Date()
/// 차트 오른쪽 여백
@State private var trailingPlotPadding: CGFloat = 20
/// 동적으로 계산된 Y 도메인 (없으면 yRange 폴백)
@State private var dynamicVisibleYDomain: ClosedRange<Double>? = nil
/// 디바운스용 워크아이템 (중복 실행 / 레이스 방지)
@State private var yAxisRecalcWorkItem: DispatchWorkItem?
/// 플롯 높이 (픽셀 → 데이터 단위 환산에 필요)
@State private var plotHeight: CGFloat = 1
/// 최신 봉 자동 따라가기 플래그 (우측에 붙어 있을 때만 true)
@State private var followTail = true

// MARK: - Constants
/// 한 화면에 보여줄 X 구간 (초) - 48분
Expand All @@ -31,7 +35,9 @@ struct CandleChartView: View {
/// Y 계산 시 우측 1분, 좌측 30초 만큼 구간 확장
private let yLookahead: TimeInterval = 60
private let yLookbehind: TimeInterval = 30

/// 우측 끝에서 이 거리(초) 이내면 최신 봉 자동 추적 on
private let tailEpsilonSec: TimeInterval = 20

// MARK: - Inputs
let data: [CoinPrice]
let xDomain: ClosedRange<Date>
Expand Down Expand Up @@ -68,15 +74,11 @@ struct CandleChartView: View {

// X축 틱: 00/15/30/45만 생성
let rawTicks = quarterTicksStrict(in: xDomain, calendar: calendar)

// 우측 경계 버퍼 (경계 3분 내 라벨 숨김)
let step: TimeInterval = 15 * 60
let buffer = step * 0.2 // 15분의 20% = 3분

// 라벨 기준: 눈에 실제 보이는 오른쪽 (마지막 캔들 시각)
let visibleRight = data.last?.date ?? xDomain.upperBound
// 3분 버퍼 이내(경계 근접) 라벨은 숨김
let ticks = rawTicks.filter { $0.addingTimeInterval(buffer) <= visibleRight }

let ticks = rawTicks

// Y 라벨 포맷 범위
let yLablesDomain = dynamicVisibleYDomain ?? yRange
Expand Down Expand Up @@ -111,13 +113,19 @@ struct CandleChartView: View {
GeometryReader { _ in
Color.clear
.onAppear {
// 축 라벨 잘림 방지용 오른쪽 패딩 산출
updateRightEdgeGuard(proxy)
// 1분 간격에 맞춘 캔들 폭 계산
recalcWidth(proxy)
plotHeight = max(1, proxy.plotSize.height)
}
.onChange(of: proxy.plotSize) { _, newSize in
// 플롯 폭 변경 시 라벨 패딩 재산출
updateRightEdgeGuard(proxy)
// 플롯 스케일 변동에 따른 캔들폭 재계산
recalcWidth(proxy)
plotHeight = max(1, newSize.height)
// 플롯 크기 변경 → 픽셀 가드 환산값도 변하므로 재계산
// 픽셀→데이터 환산치가 변하므로 Y 도메인 재계산
recalcVisibleYAxisDomain()
}
}
Expand All @@ -126,7 +134,7 @@ struct CandleChartView: View {
.chartXScale(domain: xDomain)
.chartScrollPosition(x: $centerOfVisibleXRange)
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: visibleLengthInSeconds)
.chartXVisibleDomain(length: visibleLength)

// Y축: 동적 도메인(없으면 yRange)
.chartYScale(domain: dynamicVisibleYDomain ?? yRange)
Expand All @@ -136,8 +144,17 @@ struct CandleChartView: View {
AxisMarks(values: ticks) { value in
AxisTick()
if let date = value.as(Date.self) {
AxisValueLabel { Text(timeFormatter.string(from: date)) } // 00/15/30/45분에만 노출
if calendar.component(.minute, from: date) == 0 { AxisGridLine() } // 00분에만 세로 선
if calendar.component(.minute, from: date) == 0 { AxisGridLine() } // 정시에만 세로 선
if date <= visibleRight {
AxisValueLabel {
Text(timeFormatter.string(from: date))
.font(.ico11)
.lineLimit(1)
.minimumScaleFactor(0.75)
.dynamicTypeSize(.xSmall ... .medium)
.fixedSize(horizontal: true, vertical: false)
}
}
}
}
}
Expand All @@ -162,38 +179,78 @@ struct CandleChartView: View {

// 플롯 여백: 라벨/상단 시각적 여유
.chartPlotStyle { plot in
plot.padding(.trailing, 20)
plot.padding(.trailing, trailingPlotPadding)
.padding(.top, 6)
.padding(.bottom, 8)
}

// 초기 Y 계산
.onAppear {
recalcVisibleYAxisDomain() // Y축 첫 계산
}
// MARK: - Lifecycle & Observers

// 초기 스크롤 중심 계산: 데이터/신규상장 여부(24h 미만) 기준으로 계산
// 최초 진입
.onAppear {
centerOfVisibleXRange = initialCenter(for: data)
// (1) Y스케일 1회 계산
recalcVisibleYAxisDomain()

// (2) 최신 봉 우측 정렬
let last = data.last?.date ?? xDomain.upperBound
let span = xDomain.upperBound.timeIntervalSince(xDomain.lowerBound)
let vis = min(visibleLengthInSeconds, max(60, span))
centerOfVisibleXRange = last.addingTimeInterval(-vis / 2)
}

// 스크롤(중심) 변경 → 디바운스 후 2회 확인샷
.onChange(of: centerOfVisibleXRange, initial: false) { _, _ in

// 스크롤 중심 변경: 사용자가 드래그로 우측 끝에서 벗어났는지 판정(+Y 재계산 디바운스)
.onChange(of: centerOfVisibleXRange, initial: false) { _, newCenter in
let last = data.last?.date ?? xDomain.upperBound
let vis = currentVisibleLength(xDomain)
let rightAlignedCenter = last.addingTimeInterval(-vis / 2)
let diff = abs(newCenter.timeIntervalSince(rightAlignedCenter))

// 충분히 벗어나면 자동 따라가기 off, 다시 가까워지면 on
followTail = diff <= tailEpsilonSec
scheduleYAxisRecalcDebounced()
}

// 데이터 최신 봉 갱신 → 즉시 재계산
.onChange(of: data.last?.date, initial: false) { _, _ in

// 최신 봉 업데이트
.onChange(of: data.last?.date, initial: true) { _, _ in
// (1) Y 즉시 재계산
recalcVisibleYAxisDomain()

// (2) followTail이면 최신에 우측 정렬
guard followTail else { return }
let last = data.last?.date ?? xDomain.upperBound
let vis = currentVisibleLength(xDomain)
centerOfVisibleXRange = last.addingTimeInterval(-vis / 2)
}
// 뷰 소멸 시 디바운스 작업 정리(메모리/레이스 안전)

// 뷰 소멸 시 디바운스 작업 정리
.onDisappear {
yAxisRecalcWorkItem?.cancel()
yAxisRecalcWorkItem = nil
}
}

// MARK: - Helpers

/// 현재 도메인 길이에 맞춘 가시 길이(초) 계산
private func currentVisibleLength(_ domain: ClosedRange<Date>) -> TimeInterval {
let span = domain.upperBound.timeIntervalSince(domain.lowerBound)
return min(visibleLengthInSeconds, max(60, span))
}

/// X축의 오른쪽 경계 라벨이 잘리지 않도록 여백을 계산
/// - 다이내믹 폰트 크기에 따라 라벨 폭을 측정해 가변 여백을 반영
private func updateRightEdgeGuard(_ proxy: ChartProxy) {
// 동적 타입 반영 라벨 폭 측정 (가장 넓은 케이스 "23:59" 기준)
let baseFont = UIFont.systemFont(ofSize: 10, weight: .regular)
let scaledFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: baseFont)
let labelWidth = ("23:59" as NSString).size(withAttributes: [.font: scaledFont]).width

// 라벨 폭 + 여유 8pt, 최소 12pt 보장 → 플롯 오른쪽 패딩으로만 처리
DispatchQueue.main.async {
self.trailingPlotPadding = max(12, labelWidth + 8)
}
}

/// 초기 스크롤 중심 계산
/// - 도메인이 24h 미만이면 오른쪽 패딩 0 (데이터 구간만 꽉 차게)
/// - 그 외에는 +5m 버퍼를 주어 끝이 붙어 보이지 않게
Expand Down Expand Up @@ -234,7 +291,8 @@ struct CandleChartView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work)
}

// MARK: - Y 스케일 실제 재계산
/// 현재 가시 X 구간(중심±가시 길이/2) 내 캔들의 고저로 Y 도메인을 재계산하고
/// 꼭대기 잘림 방지를 위한 픽셀·상대 가드를 더해 여유를 둠
private func recalcVisibleYAxisDomain() {
guard !data.isEmpty else {
dynamicVisibleYDomain = yRange
Expand All @@ -255,7 +313,10 @@ struct CandleChartView: View {

guard let minPrice = visibleCandles.map(\.low).min(),
let maxPrice = visibleCandles.map(\.high).max()
else { dynamicVisibleYDomain = yRange; return }
else {
dynamicVisibleYDomain = yRange
return
}

// 여유 폭 계산
let rawRange = maxPrice - minPrice // 보이는 캔들의 순수 고저 폭
Expand All @@ -279,7 +340,8 @@ struct CandleChartView: View {
dynamicVisibleYDomain = nextLower ... nextUpper
}

// MARK: - 캔들 폭 재계산
/// 현재 축 스케일에서 1분이 화면상 몇 pt인지 측정해, 막대 폭을 (간격의 60%)로 설정
/// - 최소 1pt, 최대 (간격-1pt)로 클램프하여 항상 여백 유지
private func recalcWidth(_ proxy: ChartProxy) {
// 현재 축 스케일에서 1분이 화면상 몇 pt 인지 측정
guard let last = data.last?.date, // 마지막 캔들 시각
Expand Down
11 changes: 6 additions & 5 deletions AIProject/iCo/Features/CoinDetail/View/ChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,23 @@ struct ChartView: View {
/// 기준 시간 / 현재가 / 등락가, 등락률 / 거래대금
VStack(alignment: .leading, spacing: 8) {
Text(lastUpdatedText)
.font(.system(size: 10, weight: .regular))
.font(.ico10)
.foregroundStyle(.iCoLabel)
.lineLimit(1)

Text(viewModel.displayLastPrice.formatKRW)
.font(.system(size: 20, weight: .bold))
.font(.ico20B)
.foregroundStyle(.iCoLabel)
.lineLimit(1)

Text("\(sign)\(absChange.formatKRW) (\(arrow)\(abs(viewModel.displayChangeRate).formatRate))")
.font(.system(size: 16, weight: .medium))
.font(.ico16M)
.foregroundStyle(headerColor)
.lineLimit(1)
.lineLimit(2)
.multilineTextAlignment(.leading)

Text("거래대금 \(viewModel.headerAccTradePrice.formatMillion)")
.font(.system(size: 12, weight: .medium))
.font(.ico12M)
.foregroundStyle(.iCoLabelSecondary)
.lineLimit(1)
}
Expand Down
2 changes: 1 addition & 1 deletion AIProject/iCo/Features/MyPage/View/Theme/ThemeRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ struct ThemeRow: View {
HStack(spacing: 8) {
Text(title)
.frame(height: 36)
.font(.system(size: 14, weight: !isSelected ? .regular : .medium))
.font(isSelected ? .ico14M : .ico14)
.foregroundStyle(!isSelected ? .iCoLabel : .iCoAccent)

Spacer()
Expand Down