From f080c7b32c9ea6d5d634fa8d8877e78e1be17f37 Mon Sep 17 00:00:00 2001 From: Minji Kang Date: Thu, 30 Oct 2025 11:25:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=EC=B0=A8=ED=8A=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=20=EB=8B=A4=EC=9D=B4=EB=82=98?= =?UTF-8?q?=EB=AF=B9=20=ED=8F=B0=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Features/CoinDetail/View/ChartView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AIProject/iCo/Features/CoinDetail/View/ChartView.swift b/AIProject/iCo/Features/CoinDetail/View/ChartView.swift index 84277e58..aa6f23d1 100644 --- a/AIProject/iCo/Features/CoinDetail/View/ChartView.swift +++ b/AIProject/iCo/Features/CoinDetail/View/ChartView.swift @@ -111,22 +111,22 @@ 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) Text("거래대금 \(viewModel.headerAccTradePrice.formatMillion)") - .font(.system(size: 12, weight: .medium)) + .font(.ico12M) .foregroundStyle(.iCoLabelSecondary) .lineLimit(1) } From 540636e8af618d352b3919c2d42a6efc63d27cf0 Mon Sep 17 00:00:00 2001 From: Minji Kang Date: Thu, 30 Oct 2025 11:25:31 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20-=20=ED=85=8C=EB=A7=88=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EB=82=98=EB=AF=B9=20=ED=8F=B0=ED=8A=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Features/MyPage/View/Theme/ThemeRow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From ea9df62db75804571fb566db58f9226880bb4cee Mon Sep 17 00:00:00 2001 From: Minji Kang Date: Thu, 30 Oct 2025 21:03:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20x=EC=B6=95=EC=9D=98=20=EC=98=A4?= =?UTF-8?q?=EB=A5=B8=EC=AA=BD=20=EA=B2=BD=EA=B3=84=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=9E=98=EB=A6=AC=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoinDetail/View/CandleChartView.swift | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift b/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift index 1f152e8f..b744b716 100644 --- a/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift +++ b/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift @@ -16,6 +16,10 @@ struct CandleChartView: View { @State private var candleWidth: CGFloat = 4 /// 현재 가시 X 구간의 중심(스크롤 위치 바인딩) @State private var centerOfVisibleXRange: Date = Date() + /// X축 우측 경계 라벨이 띄워지는 데 걸리는 시간 + @State private var rightLabelGuardSec: TimeInterval = 180 // 초기값 3분 + /// 차트 오른쪽 여백 + @State private var trailingPlotPadding: CGFloat = 20 /// 동적으로 계산된 Y 도메인 (없으면 yRange 폴백) @State private var dynamicVisibleYDomain: ClosedRange? = nil /// 디바운스용 워크아이템 (중복 실행 / 레이스 방지) @@ -68,15 +72,12 @@ 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.filter { $0.addingTimeInterval(rightLabelGuardSec) <= visibleRight} // Y 라벨 포맷 범위 let yLablesDomain = dynamicVisibleYDomain ?? yRange @@ -111,10 +112,12 @@ struct CandleChartView: View { GeometryReader { _ in Color.clear .onAppear { + updateRightEdgeGuard(proxy) recalcWidth(proxy) plotHeight = max(1, proxy.plotSize.height) } .onChange(of: proxy.plotSize) { _, newSize in + updateRightEdgeGuard(proxy) recalcWidth(proxy) plotHeight = max(1, newSize.height) // 플롯 크기 변경 → 픽셀 가드 환산값도 변하므로 재계산 @@ -126,7 +129,7 @@ struct CandleChartView: View { .chartXScale(domain: xDomain) .chartScrollPosition(x: $centerOfVisibleXRange) .chartScrollableAxes(.horizontal) - .chartXVisibleDomain(length: visibleLengthInSeconds) + .chartXVisibleDomain(length: visibleLength) // Y축: 동적 도메인(없으면 yRange) .chartYScale(domain: dynamicVisibleYDomain ?? yRange) @@ -137,7 +140,7 @@ struct CandleChartView: View { 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() } // 정시에만 세로 선 } } } @@ -162,7 +165,7 @@ struct CandleChartView: View { // 플롯 여백: 라벨/상단 시각적 여유 .chartPlotStyle { plot in - plot.padding(.trailing, 20) + plot.padding(.trailing, trailingPlotPadding) .padding(.top, 6) .padding(.bottom, 8) } @@ -194,6 +197,32 @@ struct CandleChartView: View { } } + /// X축의 오른쪽 경계 라벨이 잘리지 않도록 여백(guard)을 계산 + /// - 다이내믹 폰트 크기에 따라 라벨 폭을 측정해 가변 여백을 반영 + private func updateRightEdgeGuard(_ proxy: ChartProxy) { + guard + let last = data.last?.date, + let prev = Calendar.current.date(byAdding: .minute, value: -1, to: last), + let x2 = proxy.position(forX: last), + let x1 = proxy.position(forX: prev) + else { return } + + // 1초가 몇 pt인지 + let ptPerSec = max(0.001, (x2 - x1) / 60.0) + + // 다이내믹 폰트를 반영한 라벨 폭 측정 + let baseFont = UIFont.systemFont(ofSize: 10, weight: .regular) + let scaledFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: baseFont) // 다이내믹 타입 반영 + let labelWidth = ("23:59" as NSString).size(withAttributes: [.font: scaledFont]).width // 가장 넓은 시간 문자열 기준 + + // 라벨 폭(pt)을 시간(초) 단위로 환산해 오른쪽 경계 가드 계산 + let guardPt = labelWidth / 2 + 6 + rightLabelGuardSec = max(180, TimeInterval(guardPt / ptPerSec)) // 최소 3분 보장 + + // 차트 오른쪽 여백도 라벨 반폭만큼 늘려서 잘림(클리핑) 방지 + trailingPlotPadding = max(20, guardPt) + } + /// 초기 스크롤 중심 계산 /// - 도메인이 24h 미만이면 오른쪽 패딩 0 (데이터 구간만 꽉 차게) /// - 그 외에는 +5m 버퍼를 주어 끝이 붙어 보이지 않게 From 6211bdc8d4f1c93788c475c74eed364f8de72696 Mon Sep 17 00:00:00 2001 From: Minji Kang Date: Tue, 4 Nov 2025 22:39:33 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=ED=8F=B0=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EC=BB=A4=EC=A7=80=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20=EA=B8=B4=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=EC=9D=B4=20=EC=9E=98=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20=EB=8C=80=EB=B9=84=ED=95=B4=202=EC=A4=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Features/CoinDetail/View/ChartView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AIProject/iCo/Features/CoinDetail/View/ChartView.swift b/AIProject/iCo/Features/CoinDetail/View/ChartView.swift index aa6f23d1..5e8cc73b 100644 --- a/AIProject/iCo/Features/CoinDetail/View/ChartView.swift +++ b/AIProject/iCo/Features/CoinDetail/View/ChartView.swift @@ -123,7 +123,8 @@ struct ChartView: View { Text("\(sign)\(absChange.formatKRW) (\(arrow)\(abs(viewModel.displayChangeRate).formatRate))") .font(.ico16M) .foregroundStyle(headerColor) - .lineLimit(1) + .lineLimit(2) + .multilineTextAlignment(.leading) Text("거래대금 \(viewModel.headerAccTradePrice.formatMillion)") .font(.ico12M) From 34c89a17f62d09e01e0a3c78869768d4f491d961 Mon Sep 17 00:00:00 2001 From: Minji Kang Date: Tue, 4 Nov 2025 22:47:24 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=EB=8B=A4=EC=9D=B4=EB=82=B4?= =?UTF-8?q?=EB=AF=B9=20=ED=8F=B0=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=9A=B0?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=94=A9=20=EB=82=A8=EA=B9=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B5=9C=EC=8B=A0=EB=B4=89=20auto-follow=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoinDetail/View/CandleChartView.swift | 121 +++++++++++------- 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift b/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift index b744b716..63f6f197 100644 --- a/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift +++ b/AIProject/iCo/Features/CoinDetail/View/CandleChartView.swift @@ -16,8 +16,6 @@ struct CandleChartView: View { @State private var candleWidth: CGFloat = 4 /// 현재 가시 X 구간의 중심(스크롤 위치 바인딩) @State private var centerOfVisibleXRange: Date = Date() - /// X축 우측 경계 라벨이 띄워지는 데 걸리는 시간 - @State private var rightLabelGuardSec: TimeInterval = 180 // 초기값 3분 /// 차트 오른쪽 여백 @State private var trailingPlotPadding: CGFloat = 20 /// 동적으로 계산된 Y 도메인 (없으면 yRange 폴백) @@ -26,6 +24,8 @@ struct CandleChartView: View { @State private var yAxisRecalcWorkItem: DispatchWorkItem? /// 플롯 높이 (픽셀 → 데이터 단위 환산에 필요) @State private var plotHeight: CGFloat = 1 + /// 최신 봉 자동 따라가기 플래그 (우측에 붙어 있을 때만 true) + @State private var followTail = true // MARK: - Constants /// 한 화면에 보여줄 X 구간 (초) - 48분 @@ -35,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 @@ -76,8 +78,7 @@ struct CandleChartView: View { // 라벨 기준: 눈에 실제 보이는 오른쪽 (마지막 캔들 시각) let visibleRight = data.last?.date ?? xDomain.upperBound - // 우측 경계 라벨 숨김 - let ticks = rawTicks.filter { $0.addingTimeInterval(rightLabelGuardSec) <= visibleRight} + let ticks = rawTicks // Y 라벨 포맷 범위 let yLablesDomain = dynamicVisibleYDomain ?? yRange @@ -112,15 +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() } } @@ -139,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() } // 정시에만 세로 선 + if date <= visibleRight { + AxisValueLabel { + Text(timeFormatter.string(from: date)) + .font(.ico11) + .lineLimit(1) + .minimumScaleFactor(0.75) + .dynamicTypeSize(.xSmall ... .medium) + .fixedSize(horizontal: true, vertical: false) + } + } } } } @@ -170,57 +184,71 @@ struct CandleChartView: View { .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 } } - /// X축의 오른쪽 경계 라벨이 잘리지 않도록 여백(guard)을 계산 + // 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) { - guard - let last = data.last?.date, - let prev = Calendar.current.date(byAdding: .minute, value: -1, to: last), - let x2 = proxy.position(forX: last), - let x1 = proxy.position(forX: prev) - else { return } - - // 1초가 몇 pt인지 - let ptPerSec = max(0.001, (x2 - x1) / 60.0) - - // 다이내믹 폰트를 반영한 라벨 폭 측정 + // 동적 타입 반영 라벨 폭 측정 (가장 넓은 케이스 "23:59" 기준) let baseFont = UIFont.systemFont(ofSize: 10, weight: .regular) - let scaledFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: baseFont) // 다이내믹 타입 반영 - let labelWidth = ("23:59" as NSString).size(withAttributes: [.font: scaledFont]).width // 가장 넓은 시간 문자열 기준 + let scaledFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: baseFont) + let labelWidth = ("23:59" as NSString).size(withAttributes: [.font: scaledFont]).width - // 라벨 폭(pt)을 시간(초) 단위로 환산해 오른쪽 경계 가드 계산 - let guardPt = labelWidth / 2 + 6 - rightLabelGuardSec = max(180, TimeInterval(guardPt / ptPerSec)) // 최소 3분 보장 - - // 차트 오른쪽 여백도 라벨 반폭만큼 늘려서 잘림(클리핑) 방지 - trailingPlotPadding = max(20, guardPt) + // 라벨 폭 + 여유 8pt, 최소 12pt 보장 → 플롯 오른쪽 패딩으로만 처리 + DispatchQueue.main.async { + self.trailingPlotPadding = max(12, labelWidth + 8) + } } /// 초기 스크롤 중심 계산 @@ -263,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 @@ -284,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 // 보이는 캔들의 순수 고저 폭 @@ -308,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, // 마지막 캔들 시각