import SwiftUI /// 도메인 raw 값(영문/한자 enum 키) → 한글 표시 라벨. 색은 Sage.domainColor(raw) 가 raw 로 키잉하므로 /// 색에는 raw, 표시에만 이 라벨을 쓴다. 미매핑은 원본 그대로. func localizedDomain(_ raw: String?) -> String { guard let raw, !raw.isEmpty else { return "미분류" } // 경로형(Philosophy/Aesthetics)이면 leaf 만 매핑 시도, 없으면 leaf 원본 let leaf = raw.split(separator: "/").last.map(String.init) ?? raw let map: [String: String] = [ "Engineering": "엔지니어링", "Industrial_Safety": "산업안전", "General": "자료실", "Programming": "프로그래밍", "법령": "법령", "Philosophy": "철학", ] return map[raw] ?? map[leaf] ?? leaf } /// 카드/섹션 머리말 라벨 (대문자·heavy·muted) — 대시보드/인스펙터 공용. struct SectionLabel: View { let text: String init(_ text: String) { self.text = text } var body: some View { Text(text) .font(.caption.weight(.heavy)) .textCase(.uppercase) .kerning(0.7) .foregroundStyle(Sage.muted) } } /// 공용 카드 크롬 (Sage.card + corner 12 + Sage.line stroke + 패딩). struct DashCard: ViewModifier { var padding: CGFloat = 18 func body(content: Content) -> some View { content .padding(padding) .background(Sage.card, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line)) } } extension View { func dashCard(padding: CGFloat = 18) -> some View { modifier(DashCard(padding: padding)) } } /// 보더리스 인라인 통계 셀 (대시보드 스탯 스트립). StatCard 와 달리 카드 테두리 없음. struct StatCell: View { let value: Int let label: String var color: Color = Sage.ink var body: some View { VStack(alignment: .leading, spacing: 3) { Text("\(value)").font(.system(size: 20, weight: .semibold)).kerning(-0.6) .monospacedDigit().foregroundStyle(color) Text(label).font(.caption2).foregroundStyle(Sage.muted) } .frame(maxWidth: .infinity, alignment: .leading) } } struct StatCard: View { let title: String let value: Int let color: Color var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title).font(.caption).foregroundStyle(Sage.muted) Text("\(value)").font(.title.weight(.bold)).foregroundStyle(color) } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .background(Sage.card, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line)) } } struct DomainBar: View { let name: String let count: Int let max: Int var body: some View { HStack(spacing: 10) { Text(name).font(.caption).foregroundStyle(Sage.ink) .frame(width: 120, alignment: .leading).lineLimit(1) GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 4).fill(Sage.surface) RoundedRectangle(cornerRadius: 4) .fill(Sage.domainColor(name)) .frame(width: geo.size.width * fraction) } } .frame(height: 10) Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) .frame(width: 44, alignment: .trailing) } } private var fraction: CGFloat { max > 0 ? CGFloat(count) / CGFloat(max) : 0 } } struct ScoreBar: View { let score: Double var body: some View { HStack(spacing: 6) { GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3).fill(Sage.surface) RoundedRectangle(cornerRadius: 3).fill(Sage.brand) .frame(width: geo.size.width * CGFloat(min(max(score, 0), 1))) } } .frame(height: 6) Text(String(format: "%.2f", score)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) } } } struct ErrorBanner: View { let text: String var body: some View { Text(text) .font(.callout) .foregroundStyle(Sage.danger) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .background(Sage.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Sage.danger.opacity(0.3))) } }