diff --git a/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift b/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift index 1f152e8f..63f6f197 100644 --- a/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift +++ b/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift @@ -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? = nil /// 디바운스용 워크아이템 (중복 실행 / 레이스 방지) @State private var yAxisRecalcWorkItem: DispatchWorkItem? /// 플롯 높이 (픽셀 → 데이터 단위 환산에 필요) @State private var plotHeight: CGFloat = 1 + /// 최신 봉 자동 따라가기 플래그 (우측에 붙어 있을 때만 true) + @State private var followTail = true // MARK: - Constants /// 한 화면에 보여줄 X 구간 (초) - 48분 @@ -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 @@ -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 @@ -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() } } @@ -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) @@ -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) + } + } } } } @@ -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) -> 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 버퍼를 주어 끝이 붙어 보이지 않게 @@ -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 @@ -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 // 보이는 캔들의 순수 고저 폭 @@ -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, // 마지막 캔들 시각 diff --git a/AIProject/iCo/Features/CoinDetail/View/ChartView.swift b/AIProject/iCo/Features/CoinDetail/View/ChartView.swift index 84277e58..5e8cc73b 100644 --- a/AIProject/iCo/Features/CoinDetail/View/ChartView.swift +++ b/AIProject/iCo/Features/CoinDetail/View/ChartView.swift @@ -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) } diff --git a/AIProject/iCo/Features/MyPage/View/Theme/ThemeRow.swift b/AIProject/iCo/Features/MyPage/View/Theme/ThemeRow.swift index 79019626..fb7a3661 100644 --- a/AIProject/iCo/Features/MyPage/View/Theme/ThemeRow.swift +++ b/AIProject/iCo/Features/MyPage/View/Theme/ThemeRow.swift @@ -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()