// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // StatsView.swift : portrait du marché — tuiles KPI, histogramme des loyers, // palmarès villes / types, offre (inclusions), baisses de prix récentes // Dataviz : séries uniques → une seule teinte (vert), texte en encre, // étiquettes directes sélectives, barres ancrées à la base, bouts arrondis. // ----------------------------------------------------------------------------- import SwiftUI struct StatsView: View { @State private var stats: DetailedStats? @State private var failed = false var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { if let s = stats { kpiTiles(s) histogramCard(s) groupCard(title: "Par région", groups: s.byRegion ?? [], unit: "annonces") groupCard(title: "Villes les plus actives", groups: Array((s.byCity ?? []).prefix(8)), unit: "annonces") groupCard(title: "Par taille de logement", groups: s.byType ?? [], unit: "annonces") offreCard(s) baissesCard(s) } else if failed { errorView } else { ProgressView("Chargement…") .frame(maxWidth: .infinity) .padding(.vertical, 80) } } .padding(16) .padding(.bottom, 24) } .background(LK.paper) .navigationTitle("Le marché en chiffres") .navigationBarTitleDisplayMode(.inline) .refreshable { await load() } .task { await load() } } } private func load() async { failed = false do { stats = try await API.detailedStats() } catch { if stats == nil { failed = true } } } private var errorView: some View { VStack(spacing: 10) { Image(systemName: "wifi.exclamationmark").font(.system(size: 28)) Text("Impossible de charger les statistiques.") Button("Réessayer") { Task { await load() } } .buttonStyle(.borderedProminent) } .foregroundStyle(LK.ink2) .frame(maxWidth: .infinity) .padding(.vertical, 60) } // MARK: tuiles KPI private func kpiTiles(_ s: DetailedStats) -> some View { let t = s.totals var tiles: [(String, String)] = [] if let v = t?.total { tiles.append((Fmt.int(v), "annonces actives")) } if let v = t?.median { tiles.append((Fmt.price(v.rounded()), "loyer médian")) } if let v = t?.avg { tiles.append((Fmt.price(v.rounded()), "loyer moyen")) } if let v = t?.dispoNow { tiles.append((Fmt.int(v), "libres maintenant")) } if let v = t?.sources { tiles.append((Fmt.int(v), "sources actives")) } if let v = t?.cities { tiles.append((Fmt.int(v), "villes couvertes")) } return LazyVGrid(columns: [GridItem(.adaptive(minimum: 105), spacing: 10)], spacing: 10) { ForEach(tiles, id: \.1) { value, label in VStack(alignment: .leading, spacing: 3) { Text(value) .font(LKFont.display(21, .bold)) .kerning(-0.5) .lineLimit(1) .minimumScaleFactor(0.6) Text(label.uppercased()) .font(LKFont.mono(8.5, .medium)) .kerning(0.4) .foregroundStyle(LK.ink3) .lineLimit(2) } .padding(12) .frame(maxWidth: .infinity, minHeight: 68, alignment: .topLeading) .background(LK.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.ink, lineWidth: 1.5)) } } .padding(.top, 8) } // MARK: histogramme des loyers (série unique — teinte verte, base ancrée) @ViewBuilder private func histogramCard(_ s: DetailedStats) -> some View { if let buckets = s.histogram, !buckets.isEmpty { let maxCount = buckets.map(\.count).max() ?? 1 card(title: "Distribution des loyers") { VStack(spacing: 6) { HStack(alignment: .bottom, spacing: 2) { ForEach(buckets, id: \.self) { b in VStack(spacing: 3) { // étiquette directe : seulement le pic (sélectif) if b.count == maxCount { Text(Fmt.int(b.count)) .font(LKFont.mono(9.5, .medium)) .foregroundStyle(LK.ink2) .fixedSize() } UnevenRoundedRectangle(topLeadingRadius: 3, topTrailingRadius: 3) .fill(LK.green) .frame(height: max(3, 110 * CGFloat(b.count) / CGFloat(maxCount))) } .frame(maxWidth: .infinity, alignment: .bottom) } } .frame(height: 132, alignment: .bottom) Rectangle().fill(LK.line).frame(height: 1) HStack { Text(axisLabel(buckets.first)) Spacer() Text("loyer mensuel") Spacer() Text(axisLabel(buckets.last, last: true)) } .font(LKFont.mono(9.5)) .foregroundStyle(LK.ink3) } } } } private func axisLabel(_ b: DetailedStats.Bucket?, last: Bool = false) -> String { guard let b else { return "" } if last, b.hi == nil { return "\(Int(b.lo)) $ +" } return "\(Int(b.lo)) $" } // MARK: palmarès (barres horizontales, série unique) @ViewBuilder private func groupCard(title: String, groups: [DetailedStats.Group], unit: String) -> some View { if !groups.isEmpty { let maxCount = groups.map(\.count).max() ?? 1 card(title: title) { VStack(spacing: 10) { ForEach(groups, id: \.key) { g in VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline) { Text(g.key.isEmpty ? "—" : g.key) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) Spacer() Text(Fmt.int(g.count)) .font(LKFont.mono(11.5, .bold)) if let avg = g.avgPrice { Text("· moy \(Fmt.price(avg.rounded()))") .font(.system(size: 11)) .foregroundStyle(LK.ink3) } } GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(LK.line.opacity(0.5)) Capsule() .fill(LK.green) .frame(width: max(6, geo.size.width * CGFloat(g.count) / CGFloat(maxCount))) } } .frame(height: 7) } } } } } } // MARK: offre (pourcentages d'inclusions) @ViewBuilder private func offreCard(_ s: DetailedStats) -> some View { if let o = s.offre { let rows: [(String, Double)] = [ o.chauffagePct.map { ("Chauffage inclus", $0) }, o.electricitePct.map { ("Électricité incluse", $0) }, o.internetPct.map { ("Internet inclus", $0) }, o.stationnementPct.map { ("Stationnement", $0) }, o.climPct.map { ("Climatisation", $0) }, o.balconPct.map { ("Balcon", $0) }, o.furnishedPct.map { ("Meublé", $0) }, o.petsOuiPct.map { ("Animaux acceptés", $0) }, ].compactMap { $0 } if !rows.isEmpty { card(title: "Ce que l'offre inclut") { VStack(spacing: 9) { ForEach(rows, id: \.0) { label, pct in HStack(spacing: 10) { Text(label) .font(.system(size: 13, weight: .medium)) .frame(width: 150, alignment: .leading) GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(LK.line.opacity(0.5)) Capsule() .fill(LK.green) .frame(width: max(4, geo.size.width * min(1, pct / 100))) } } .frame(height: 7) Text("\(Int(pct.rounded())) %") .font(LKFont.mono(11.5, .bold)) .frame(width: 42, alignment: .trailing) } } } } } } } // MARK: baisses de prix @ViewBuilder private func baissesCard(_ s: DetailedStats) -> some View { if let baisses = s.baisses, !baisses.isEmpty { card(title: "Baisses de prix récentes") { VStack(spacing: 0) { ForEach(Array(baisses.prefix(6).enumerated()), id: \.offset) { i, b in HStack(spacing: 10) { VStack(alignment: .leading, spacing: 1) { Text(b.title) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) Text(b.city) .font(.system(size: 11)) .foregroundStyle(LK.ink3) } Spacer() VStack(alignment: .trailing, spacing: 1) { Text("\(Fmt.price(b.avant)) → \(Fmt.price(b.apres))") .font(LKFont.mono(11, .medium)) Text("−\(Int(abs(b.pct).rounded())) %") .font(.system(size: 11, weight: .bold)) .foregroundStyle(LK.green) } } .padding(.vertical, 8) if i < min(baisses.count, 6) - 1 { Divider() } } } } } } // MARK: conteneur de carte private func card(title: String, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: 14) { Kicker(text: title) content() } .padding(15) .frame(maxWidth: .infinity, alignment: .leading) .lkCard() .padding(.trailing, 5) } }