// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Résultat d'estimation — transparence totale : valeur, fourchette P10–P90, // indice de confiance, fusion modèle/comparables, historique 2021→2026, // caractéristiques du rôle et chaque comparable avec ses ajustements en dollars. import SwiftUI import Charts struct EstimateView: View { let data: UnitEstimate @State private var reportURL: URL? @State private var loadingReport: String? @State private var reportError: String? private var r: EstimateResult { data.result } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { if let unit = data.unit { addressHeader(unit) } estimateCard fusionCard if let unit = data.unit { historyCard(unit) specsCard(unit) } if !r.comps.isEmpty { compsSection } if data.unit != nil { reportsCard } disclaimer } .padding(16) } .background(Color.vpPaper) .toolbar { ToolbarItem(placement: .principal) { Wordmark(size: 18) } } .toolbarBackground(Color.vpPaper, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .sheet(item: $reportURL) { url in PDFViewerView(url: url) } } // MARK: - En-tête d'adresse private func addressHeader(_ unit: PropertyUnit) -> some View { VStack(alignment: .leading, spacing: 6) { Kicker(text: PropType.label(for: unit.typeProp)) Text(unit.adresse ?? "Adresse inconnue") .font(.vpDisplay(24)) .foregroundStyle(Color.vpInk) .textCase(.uppercase) HStack(spacing: 8) { Text(unit.municipalite ?? "—") .font(.vpBody(14)) .foregroundStyle(Color.vpInk2) if let libelle = unit.cubfLibelle { Text("· \(libelle)") .font(.vpBody(14)) .foregroundStyle(Color.vpInk3) } } } } // MARK: - Carte estimation private var estimateCard: some View { VStack(alignment: .leading, spacing: 14) { Kicker(text: "Valeur marchande estimée") Text(Fmt.cad(r.estimate)) .font(.vpDisplay(44)) .foregroundStyle(Color.vpInk) .minimumScaleFactor(0.6) .lineLimit(1) RangeBar(low: r.low, estimate: r.estimate, high: r.high) HStack { VStack(alignment: .leading, spacing: 2) { Text("BAS (P10)").font(.vpMono(9.5)).foregroundStyle(Color.vpInk3) Text(Fmt.cad(r.low)).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk) } Spacer() ConfidenceBadge(level: r.confidenceLevel, pct: r.confidencePct) Spacer() VStack(alignment: .trailing, spacing: 2) { Text("HAUT (P90)").font(.vpMono(9.5)).foregroundStyle(Color.vpInk3) Text(Fmt.cad(r.high)).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk) } } if let unit = data.unit, let role = unit.valeurRole { Rectangle().fill(Color.vpInk.opacity(0.15)).frame(height: 1) HStack { Text("Valeur au rôle 2024–2026") .font(.vpBody(13)) .foregroundStyle(Color.vpInk2) Spacer() Text(Fmt.cad(role)) .font(.vpMonoBold(13)) .foregroundStyle(Color.vpInk) if role > 0 { Text(Fmt.signedCad(r.estimate - role)) .font(.vpMono(11)) .foregroundStyle(r.estimate >= role ? Color.vpGreen : Color.vpInk3) } } } } .padding(18) .vpCard() } // MARK: - Fusion modèle + comparables private var fusionCard: some View { VStack(alignment: .leading, spacing: 12) { Kicker(text: "Comment ce chiffre est calculé") if r.modelEstimate != nil { fusionRow( label: "Modèle hédonique", sub: "LightGBM · 18 variables · pré-calculé", value: r.modelEstimate, weight: r.modelWeight ) } fusionRow( label: "Comparables ajustés", sub: "\(r.nCompsUsed) ventes réelles · médiane pondérée", value: r.compsEstimate, weight: 1 - r.modelWeight ) if let disp = r.compsDispersionPct { Text("Dispersion des comparables : \(Fmt.pct(disp))") .font(.vpMono(10)) .foregroundStyle(Color.vpInk3) } } .padding(18) .vpCard() } private func fusionRow(label: String, sub: String, value: Double?, weight: Double) -> some View { HStack(alignment: .center, spacing: 12) { Text("\(Int((weight * 100).rounded())) %") .font(.vpDisplay(19)) .foregroundStyle(Color.vpLime) .frame(width: 62, alignment: .leading) VStack(alignment: .leading, spacing: 2) { Text(label).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk) Text(sub).font(.vpBody(12)).foregroundStyle(Color.vpInk3) } Spacer() Text(Fmt.cad(value)) .font(.vpMonoBold(14)) .foregroundStyle(Color.vpInk) } .padding(12) .background(RoundedRectangle(cornerRadius: 10).fill(Color.vpSurface2)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.vpInk.opacity(0.2), lineWidth: 1)) } // MARK: - Historique 2021→2026 private func historyCard(_ unit: PropertyUnit) -> some View { let points = unit.history.filter { $0.value != nil } return VStack(alignment: .leading, spacing: 12) { Kicker(text: "Historique des estimations") if points.count >= 2 { Chart(points) { p in LineMark( x: .value("Année", String(p.year)), y: .value("Valeur", p.value ?? 0) ) .foregroundStyle(Color.vpInk) .lineStyle(StrokeStyle(lineWidth: 2.5)) PointMark( x: .value("Année", String(p.year)), y: .value("Valeur", p.value ?? 0) ) .foregroundStyle(p.year == 2026 ? Color.vpLime : Color.vpInk) } .chartYAxis { AxisMarks(position: .leading) { v in AxisGridLine().foregroundStyle(Color.vpInk.opacity(0.1)) AxisValueLabel { if let d = v.as(Double.self) { Text(shortCad(d)).font(.vpMono(9)).foregroundStyle(Color.vpInk3) } } } } .chartXAxis { AxisMarks { _ in AxisValueLabel().font(.vpMono(9)).foregroundStyle(Color.vpInk2) } } .frame(height: 170) if let first = points.first?.value, let last = points.last?.value, first > 0 { Text("Croissance \(points.first!.year)→\(points.last!.year) : \(Fmt.pct((last / first - 1) * 100))") .font(.vpMono(10.5)) .foregroundStyle(Color.vpGreen) } } else { Text("Historique insuffisant pour cette unité.") .font(.vpBody(13)) .foregroundStyle(Color.vpInk3) } } .padding(18) .vpCard() } private func shortCad(_ v: Double) -> String { if abs(v) >= 1_000_000 { return String(format: "%.1f M$", locale: Locale(identifier: "fr_CA"), v / 1_000_000) } return "\(Int((v / 1000).rounded())) k$" } // MARK: - Caractéristiques private func specsCard(_ unit: PropertyUnit) -> some View { VStack(alignment: .leading, spacing: 12) { Kicker(text: "La propriété selon le rôle") LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { specCell("Année de construction", unit.anneeConstruction.map(String.init) ?? "—") specCell("Aire des étages", Fmt.m2(unit.aireEtagesM2)) specCell("Terrain", Fmt.m2(unit.superficieTerrainM2)) specCell("Logements", unit.nbLogements.map(String.init) ?? "—") specCell("Étages", unit.specs.nbEtages.map(String.init) ?? "—") specCell("Façade du terrain", unit.specs.frontTerrainM.map { Fmt.num($0) + " m" } ?? "—") specCell("Lien physique", unit.specs.lienPhysique ?? "—") specCell("Genre", unit.specs.genreConstruction ?? "—") specCell("Valeur du terrain", Fmt.cad(unit.specs.valeurTerrain)) specCell("Valeur du bâtiment", Fmt.cad(unit.specs.valeurBatiment)) } } .padding(18) .vpCard() } private func specCell(_ label: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 3) { Text(label.uppercased()) .font(.vpMono(9)) .tracking(0.6) .foregroundStyle(Color.vpInk3) Text(value) .font(.vpDisplayMedium(14.5)) .foregroundStyle(Color.vpInk) } .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Comparables private var compsSection: some View { VStack(alignment: .leading, spacing: 12) { Kicker(text: "Ventes comparables — \(r.nCompsUsed) utilisées") Text("Chaque ajustement est montré en dollars. Aucune boîte noire.") .font(.vpBody(13)) .foregroundStyle(Color.vpInk2) ForEach(topComps) { c in CompCard(comp: c) } } } private var topComps: [AdjustedComp] { Array(r.comps.sorted { $0.weight > $1.weight }.prefix(8)) } // MARK: - Rapports PDF private var reportsCard: some View { VStack(alignment: .leading, spacing: 12) { Kicker(text: "Rapports PDF") Button { downloadReport(pro: false) } label: { labelWithSpinner("Rapport standard (3 pages)", active: loadingReport == "standard") } .buttonStyle(VPPrimaryButtonStyle()) Button { downloadReport(pro: true) } label: { labelWithSpinner("Rapport professionnel (6 pages)", active: loadingReport == "pro") } .buttonStyle(VPGhostButtonStyle()) if let reportError { Text(reportError) .font(.vpBody(13)) .foregroundStyle(Color.vpGreen) } } .padding(18) .vpCard() } private func labelWithSpinner(_ title: String, active: Bool) -> some View { HStack(spacing: 10) { if active { ProgressView().controlSize(.small) } Text(active ? "Génération…" : title) } } private func downloadReport(pro: Bool) { guard let unit = data.unit, loadingReport == nil else { return } loadingReport = pro ? "pro" : "standard" reportError = nil Task { defer { loadingReport = nil } do { let path = pro ? "/api/report/pro" : "/api/report" let name = "vrai-prix-\(pro ? "pro-" : "")\(unit.id).pdf" reportURL = try await VraiPrixAPI.shared.downloadReport( path: path, query: [URLQueryItem(name: "id", value: unit.id)], fileName: name ) } catch { reportError = error.localizedDescription } } } private var disclaimer: some View { Text("Estimation statistique à titre indicatif, fondée sur les rôles d'évaluation foncière du Québec (MAMH, licence ouverte) et 745 119 ventes réelles. Ne remplace pas une évaluation par un évaluateur agréé (OEAQ).") .font(.vpMono(9.5)) .foregroundStyle(Color.vpInk3) .padding(.top, 2) .padding(.bottom, 12) } } // URL identifiable pour .sheet(item:) extension URL: @retroactive Identifiable { public var id: String { absoluteString } } // MARK: - Fourchette P10–P90 struct RangeBar: View { let low: Double let estimate: Double let high: Double var body: some View { GeometryReader { geo in let w = geo.size.width let span = max(high - low, 1) let x = min(max((estimate - low) / span, 0), 1) * w ZStack(alignment: .leading) { Capsule() .fill(Color.vpLimeSoft) .frame(height: 10) .overlay(Capsule().stroke(Color.vpInk, lineWidth: 1.5)) Circle() .fill(Color.vpLime) .overlay(Circle().stroke(Color.vpInk, lineWidth: 2)) .frame(width: 20, height: 20) .offset(x: min(max(x - 10, 0), w - 20)) } } .frame(height: 20) } } // MARK: - Badge de confiance A–D struct ConfidenceBadge: View { let level: String let pct: Double var body: some View { HStack(spacing: 8) { Text(level) .font(.vpDisplay(20)) .foregroundStyle(Color.vpLime) .frame(width: 38, height: 38) .background(RoundedRectangle(cornerRadius: 9).fill(Color.vpInk)) .rotationEffect(.degrees(-2)) VStack(alignment: .leading, spacing: 1) { Text("CONFIANCE") .font(.vpMono(8.5)) .tracking(1) .foregroundStyle(Color.vpInk3) Text("\(Int(pct.rounded())) %") .font(.vpDisplayMedium(15)) .foregroundStyle(Color.vpInk) } } } } // MARK: - Carte comparable struct CompCard: View { let comp: AdjustedComp @State private var expanded = false var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text(comp.street ?? "Adresse non divulguée") .font(.vpDisplayMedium(15)) .foregroundStyle(Color.vpInk) Text("\(comp.city ?? "—") · vendu en \(Fmt.monthYear(comp.date))") .font(.vpBody(12)) .foregroundStyle(Color.vpInk2) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(Fmt.cad(comp.amount)) .font(.vpMonoBold(14)) .foregroundStyle(Color.vpInk) Text("PRIX PAYÉ") .font(.vpMono(8)) .tracking(0.8) .foregroundStyle(Color.vpInk3) } } HStack(spacing: 8) { chip(Fmt.distance(comp.distanceM)) if let area = comp.floorArea { chip(Fmt.m2(area)) } if let year = comp.yearBuilt { chip(String(year)) } chip("poids \(Fmt.pct(comp.weight * 100, decimals: 0))") } if expanded { VStack(spacing: 6) { adjRow("Conditions de marché", comp.adjTime) adjRow("Superficie", comp.adjArea) adjRow("Âge du bâtiment", comp.adjAge) Rectangle().fill(Color.vpInk.opacity(0.2)).frame(height: 1) HStack { Text("Prix ajusté") .font(.vpDisplayMedium(13.5)) .foregroundStyle(Color.vpInk) Spacer() Text(Fmt.cad(comp.adjustedPrice)) .font(.vpMonoBold(14)) .foregroundStyle(Color.vpGreen) } } .padding(10) .background(RoundedRectangle(cornerRadius: 10).fill(Color.vpSurface2)) } Button { withAnimation(.snappy(duration: 0.2)) { expanded.toggle() } } label: { Text(expanded ? "Masquer les ajustements" : "Voir les ajustements en $") .font(.vpMonoBold(10.5)) .tracking(0.6) .foregroundStyle(Color.vpGreen) } .buttonStyle(.plain) } .padding(14) .vpCard(corner: 12) } private func chip(_ text: String) -> some View { Text(text) .font(.vpMono(10)) .foregroundStyle(Color.vpInk2) .padding(.horizontal, 9) .padding(.vertical, 4) .background(Capsule().fill(Color.vpSurface)) .overlay(Capsule().stroke(Color.vpInk.opacity(0.35), lineWidth: 1)) } private func adjRow(_ label: String, _ value: Double) -> some View { HStack { Text(label).font(.vpBody(13)).foregroundStyle(Color.vpInk2) Spacer() Text(Fmt.signedCad(value)) .font(.vpMono(12.5)) .foregroundStyle(abs(value) < 0.5 ? Color.vpInk3 : Color.vpInk) } } }