KeywordAnalysisView.swift
1 // 2 // KeywordAnalysisView.swift 3 // RacerTracer 4 // 5 // Created by Alexander Kunau on 29.09.25. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct KeywordAnalysisView: View { 12 @EnvironmentObject var seoService: SEOAnalysisService 13 @State private var targetKeywords = "" 14 @State private var analysisResults: KeywordAnalysisResults? 15 @State private var isAnalyzing = false 16 @State private var selectedMetric: KeywordMetricType = .frequency 17 18 var body: some View { 19 VStack(spacing: 24) { 20 // Header with Input 21 KeywordInputSection( 22 targetKeywords: $targetKeywords, 23 onAnalyze: performKeywordAnalysis, 24 isAnalyzing: isAnalyzing 25 ) 26 27 if isAnalyzing { 28 KeywordAnalysisProgressView() 29 } else if let results = analysisResults { 30 KeywordResultsView( 31 results: results, 32 selectedMetric: $selectedMetric 33 ) 34 } else { 35 KeywordStartView() 36 } 37 } 38 .padding(24) 39 .navigationTitle("Keyword Analysis") 40 } 41 42 private func performKeywordAnalysis() { 43 guard !targetKeywords.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } 44 45 isAnalyzing = true 46 47 // Verwende die letzte Analyse wenn verfügbar, sonst simuliere Daten 48 if let lastAnalysis = seoService.currentAnalysis { 49 DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { 50 analysisResults = extractKeywordResults(from: lastAnalysis, targetKeywords: targetKeywords) 51 isAnalyzing = false 52 } 53 } else { 54 // Generiere Demo-Daten für Keyword-Analyse 55 DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 56 analysisResults = generateDemoKeywordResults(targetKeywords: targetKeywords) 57 isAnalyzing = false 58 } 59 } 60 } 61 62 private func extractKeywordResults(from analysis: SEOAnalysis, targetKeywords: String) -> KeywordAnalysisResults { 63 let keywords = targetKeywords.components(separatedBy: ",") 64 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } 65 .filter { !$0.isEmpty } 66 67 return KeywordAnalysisResults( 68 targetKeywords: keywords, 69 foundKeywords: analysis.metrics.keywords.primaryKeywords + 70 analysis.metrics.keywords.secondaryKeywords, 71 keywordDensity: analysis.metrics.keywords.keywordDensity, 72 keywordDistribution: analysis.metrics.keywords.keywordDistribution, 73 tfIdfScores: analysis.metrics.keywords.tfIdfScores, 74 competitorData: generateMockCompetitorData(for: keywords), 75 suggestions: generateKeywordSuggestions(from: analysis.metrics.keywords) 76 ) 77 } 78 79 private func generateDemoKeywordResults(targetKeywords: String) -> KeywordAnalysisResults { 80 let keywords = targetKeywords.components(separatedBy: ",") 81 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } 82 .filter { !$0.isEmpty } 83 84 let demoKeywords = keywords.map { keyword in 85 KeywordMetric( 86 keyword: keyword, 87 frequency: Int.random(in: 5...25), 88 density: Double.random(in: 0.005...0.03), 89 prominence: Double.random(in: 0.3...0.9), 90 contexts: ["title", "h1", "content"] 91 ) 92 } 93 94 let demoDistribution = keywords.map { keyword in 95 KeywordDistribution( 96 keyword: keyword, 97 titlePresence: Bool.random(), 98 h1Presence: Bool.random(), 99 h2Presence: Bool.random(), 100 metaDescriptionPresence: Bool.random(), 101 contentPresence: true, 102 urlPresence: Bool.random() 103 ) 104 } 105 106 let demoTFIDF = keywords.map { keyword in 107 TFIDFScore( 108 term: keyword, 109 tfIdf: Double.random(in: 0.1...2.5), 110 termFrequency: Double.random(in: 0.001...0.02), 111 inverseDocumentFrequency: Double.random(in: 1.5...4.0) 112 ) 113 } 114 115 return KeywordAnalysisResults( 116 targetKeywords: keywords, 117 foundKeywords: demoKeywords, 118 keywordDensity: Double.random(in: 0.01...0.05), 119 keywordDistribution: demoDistribution, 120 tfIdfScores: demoTFIDF, 121 competitorData: generateMockCompetitorData(for: keywords), 122 suggestions: generateDemoKeywordSuggestions(for: keywords) 123 ) 124 } 125 } 126 127 // MARK: - Supporting Views 128 129 struct KeywordInputSection: View { 130 @Binding var targetKeywords: String 131 let onAnalyze: () -> Void 132 let isAnalyzing: Bool 133 134 var body: some View { 135 VStack(alignment: .leading, spacing: 16) { 136 Text("Keyword Research & Analysis") 137 .font(.title2) 138 .fontWeight(.semibold) 139 140 Text("Analyze keyword performance, density, and competition. Enter keywords separated by commas.") 141 .font(.subheadline) 142 .foregroundColor(.secondary) 143 144 HStack(spacing: 12) { 145 TextField("Enter keywords (e.g., SEO, website optimization, search ranking)", text: $targetKeywords) 146 .textFieldStyle(RoundedBorderTextFieldStyle()) 147 .disabled(isAnalyzing) 148 149 Button(action: onAnalyze) { 150 HStack { 151 if isAnalyzing { 152 ProgressView() 153 .scaleEffect(0.8) 154 } else { 155 Image(systemName: "magnifyingglass") 156 } 157 Text(isAnalyzing ? "Analyzing..." : "Analyze") 158 } 159 } 160 .buttonStyle(.borderedProminent) 161 .disabled(targetKeywords.isEmpty || isAnalyzing) 162 } 163 } 164 .padding(20) 165 .background(Color(NSColor.controlBackgroundColor)) 166 .cornerRadius(12) 167 } 168 } 169 170 struct KeywordAnalysisProgressView: View { 171 var body: some View { 172 VStack(spacing: 20) { 173 ProgressView() 174 .scaleEffect(1.5) 175 176 Text("Analyzing Keywords...") 177 .font(.headline) 178 .foregroundColor(.secondary) 179 180 Text("Calculating keyword density, distribution, and TF-IDF scores") 181 .font(.subheadline) 182 .foregroundColor(.secondary) 183 .multilineTextAlignment(.center) 184 } 185 .frame(maxWidth: .infinity) 186 .frame(height: 200) 187 .background(Color(NSColor.controlBackgroundColor)) 188 .cornerRadius(12) 189 } 190 } 191 192 struct KeywordStartView: View { 193 var body: some View { 194 VStack(spacing: 20) { 195 Image(systemName: "key.fill") 196 .font(.system(size: 48)) 197 .foregroundColor(.accentColor) 198 199 Text("Keyword Analysis") 200 .font(.title2) 201 .fontWeight(.semibold) 202 203 Text("Enter your target keywords to analyze their performance, density, distribution, and competition.") 204 .font(.subheadline) 205 .foregroundColor(.secondary) 206 .multilineTextAlignment(.center) 207 } 208 .frame(maxWidth: .infinity, maxHeight: .infinity) 209 .background(Color(NSColor.controlBackgroundColor)) 210 .cornerRadius(12) 211 } 212 } 213 214 enum KeywordMetricType: String, CaseIterable { 215 case frequency = "Frequency" 216 case density = "Density" 217 case prominence = "Prominence" 218 case tfIdf = "TF-IDF" 219 } 220 221 struct KeywordResultsView: View { 222 let results: KeywordAnalysisResults 223 @Binding var selectedMetric: KeywordMetricType 224 225 var body: some View { 226 ScrollView { 227 VStack(spacing: 24) { 228 // Overview Cards 229 KeywordOverviewCards(results: results) 230 231 // Metrics Chart 232 KeywordMetricsChart( 233 keywords: results.foundKeywords, 234 selectedMetric: selectedMetric 235 ) 236 237 // Metric Selector 238 Picker("Metric", selection: $selectedMetric) { 239 ForEach(KeywordMetricType.allCases, id: \.self) { metric in 240 Text(metric.rawValue).tag(metric) 241 } 242 } 243 .pickerStyle(SegmentedPickerStyle()) 244 245 // Keyword Distribution 246 KeywordDistributionSection(distribution: results.keywordDistribution) 247 248 // TF-IDF Analysis 249 TFIDFAnalysisSection(scores: results.tfIdfScores) 250 251 // Competition Analysis 252 CompetitionAnalysisSection(competitorData: results.competitorData) 253 254 // Suggestions 255 KeywordSuggestionsSection(suggestions: results.suggestions) 256 } 257 .padding(20) 258 } 259 .background(Color(NSColor.controlBackgroundColor)) 260 .cornerRadius(12) 261 } 262 } 263 264 struct KeywordOverviewCards: View { 265 let results: KeywordAnalysisResults 266 267 var body: some View { 268 HStack(spacing: 16) { 269 KeywordOverviewCard( 270 title: "Keywords Found", 271 value: "\(results.foundKeywords.count)", 272 subtitle: "of \(results.targetKeywords.count) targets", 273 color: .blue 274 ) 275 276 KeywordOverviewCard( 277 title: "Avg. Density", 278 value: String(format: "%.2f%%", results.keywordDensity * 100), 279 subtitle: "keyword density", 280 color: .green 281 ) 282 283 KeywordOverviewCard( 284 title: "Avg. TF-IDF", 285 value: String(format: "%.2f", results.tfIdfScores.map(\.tfIdf).averageValue), 286 subtitle: "relevance score", 287 color: .orange 288 ) 289 } 290 } 291 } 292 293 struct KeywordOverviewCard: View { 294 let title: String 295 let value: String 296 let subtitle: String 297 let color: Color 298 299 var body: some View { 300 VStack(spacing: 8) { 301 Text(title) 302 .font(.caption) 303 .foregroundColor(.secondary) 304 305 Text(value) 306 .font(.title) 307 .fontWeight(.bold) 308 .foregroundColor(color) 309 310 Text(subtitle) 311 .font(.caption2) 312 .foregroundColor(.secondary) 313 } 314 .frame(maxWidth: .infinity) 315 .padding(16) 316 .background(Color(NSColor.textBackgroundColor)) 317 .cornerRadius(8) 318 } 319 } 320 321 struct KeywordMetricsChart: View { 322 let keywords: [KeywordMetric] 323 let selectedMetric: KeywordMetricType 324 325 var body: some View { 326 VStack(alignment: .leading, spacing: 16) { 327 Text("\(selectedMetric.rawValue) by Keyword") 328 .font(.headline) 329 .fontWeight(.semibold) 330 331 Chart(keywords.prefix(10), id: \.keyword) { keyword in 332 BarMark( 333 x: .value("Keyword", keyword.keyword), 334 y: .value(selectedMetric.rawValue, metricValue(for: keyword)) 335 ) 336 .foregroundStyle(Color.accentColor.gradient) 337 } 338 .frame(height: 200) 339 .chartXAxis { 340 AxisMarks { value in 341 AxisValueLabel() { 342 if let keyword = value.as(String.self) { 343 Text(keyword) 344 .rotationEffect(.degrees(-45)) 345 .font(.caption) 346 } 347 } 348 } 349 } 350 } 351 } 352 353 private func metricValue(for keyword: KeywordMetric) -> Double { 354 switch selectedMetric { 355 case .frequency: 356 return Double(keyword.frequency) 357 case .density: 358 return keyword.density * 100 359 case .prominence: 360 return keyword.prominence * 100 361 case .tfIdf: 362 return keyword.prominence // Placeholder, würde TF-IDF aus separater Liste nehmen 363 } 364 } 365 } 366 367 struct KeywordDistributionSection: View { 368 let distribution: [KeywordDistribution] 369 370 var body: some View { 371 VStack(alignment: .leading, spacing: 16) { 372 Text("Keyword Distribution") 373 .font(.headline) 374 .fontWeight(.semibold) 375 376 LazyVStack(spacing: 12) { 377 ForEach(distribution.prefix(8), id: \.id) { item in 378 KeywordDistributionItemRow(distribution: item) 379 } 380 } 381 } 382 } 383 } 384 385 struct KeywordDistributionItemRow: View { 386 let distribution: KeywordDistribution 387 388 var body: some View { 389 VStack(alignment: .leading, spacing: 8) { 390 Text(distribution.keyword) 391 .font(.subheadline) 392 .fontWeight(.medium) 393 394 HStack(spacing: 12) { 395 KeywordDistributionIndicator(label: "Title", isPresent: distribution.titlePresence) 396 KeywordDistributionIndicator(label: "H1", isPresent: distribution.h1Presence) 397 KeywordDistributionIndicator(label: "H2", isPresent: distribution.h2Presence) 398 KeywordDistributionIndicator(label: "Meta", isPresent: distribution.metaDescriptionPresence) 399 KeywordDistributionIndicator(label: "Content", isPresent: distribution.contentPresence) 400 KeywordDistributionIndicator(label: "URL", isPresent: distribution.urlPresence) 401 402 Spacer() 403 } 404 } 405 .padding(12) 406 .background(Color(NSColor.textBackgroundColor)) 407 .cornerRadius(8) 408 } 409 } 410 411 struct KeywordDistributionIndicator: View { 412 let label: String 413 let isPresent: Bool 414 415 var body: some View { 416 VStack(spacing: 4) { 417 Circle() 418 .fill(isPresent ? Color.green : Color.secondary.opacity(0.3)) 419 .frame(width: 8, height: 8) 420 421 Text(label) 422 .font(.caption2) 423 .foregroundColor(.secondary) 424 } 425 } 426 } 427 428 struct TFIDFAnalysisSection: View { 429 let scores: [TFIDFScore] 430 431 var body: some View { 432 VStack(alignment: .leading, spacing: 16) { 433 Text("TF-IDF Analysis") 434 .font(.headline) 435 .fontWeight(.semibold) 436 437 Text("Term Frequency-Inverse Document Frequency scores indicating keyword relevance") 438 .font(.caption) 439 .foregroundColor(.secondary) 440 441 LazyVStack(spacing: 8) { 442 ForEach(scores.sorted { $0.tfIdf > $1.tfIdf }.prefix(8), id: \.id) { score in 443 KeywordTFIDFScoreRow(score: score) 444 } 445 } 446 } 447 } 448 } 449 450 struct KeywordTFIDFScoreRow: View { 451 let score: TFIDFScore 452 453 var body: some View { 454 HStack { 455 VStack(alignment: .leading, spacing: 2) { 456 Text(score.term) 457 .font(.subheadline) 458 .fontWeight(.medium) 459 460 HStack(spacing: 16) { 461 Text("TF: \(String(format: "%.3f", score.termFrequency))") 462 .font(.caption) 463 .foregroundColor(.secondary) 464 465 Text("IDF: \(String(format: "%.3f", score.inverseDocumentFrequency))") 466 .font(.caption) 467 .foregroundColor(.secondary) 468 } 469 } 470 471 Spacer() 472 473 VStack(alignment: .trailing, spacing: 2) { 474 Text(String(format: "%.3f", score.tfIdf)) 475 .font(.title3) 476 .fontWeight(.semibold) 477 .foregroundColor(.accentColor) 478 479 Text("Relevance") 480 .font(.caption2) 481 .foregroundColor(.secondary) 482 } 483 } 484 .padding(12) 485 .background(Color(NSColor.textBackgroundColor)) 486 .cornerRadius(8) 487 } 488 } 489 490 struct CompetitionAnalysisSection: View { 491 let competitorData: [CompetitorKeywordData] 492 493 var body: some View { 494 VStack(alignment: .leading, spacing: 16) { 495 Text("Competition Analysis") 496 .font(.headline) 497 .fontWeight(.semibold) 498 499 LazyVStack(spacing: 12) { 500 ForEach(competitorData, id: \.keyword) { data in 501 CompetitorKeywordRow(data: data) 502 } 503 } 504 } 505 } 506 } 507 508 struct CompetitorKeywordRow: View { 509 let data: CompetitorKeywordData 510 511 var body: some View { 512 HStack { 513 VStack(alignment: .leading, spacing: 4) { 514 Text(data.keyword) 515 .font(.subheadline) 516 .fontWeight(.medium) 517 518 Text("Competition Level: \(data.competitionLevel)") 519 .font(.caption) 520 .foregroundColor(.secondary) 521 } 522 523 Spacer() 524 525 VStack(alignment: .trailing, spacing: 4) { 526 Text("Difficulty: \(data.difficulty)/10") 527 .font(.caption) 528 .foregroundColor(difficultyColor(data.difficulty)) 529 530 Text("\(data.monthlySearches) searches/month") 531 .font(.caption2) 532 .foregroundColor(.secondary) 533 } 534 } 535 .padding(12) 536 .background(Color(NSColor.textBackgroundColor)) 537 .cornerRadius(8) 538 } 539 540 private func difficultyColor(_ difficulty: Int) -> Color { 541 switch difficulty { 542 case 1...3: return .green 543 case 4...6: return .orange 544 case 7...10: return .red 545 default: return .secondary 546 } 547 } 548 } 549 550 struct KeywordSuggestionsSection: View { 551 let suggestions: [KeywordSuggestion] 552 553 var body: some View { 554 VStack(alignment: .leading, spacing: 16) { 555 Text("Keyword Suggestions") 556 .font(.headline) 557 .fontWeight(.semibold) 558 559 LazyVStack(spacing: 12) { 560 ForEach(suggestions, id: \.keyword) { suggestion in 561 KeywordSuggestionRow(suggestion: suggestion) 562 } 563 } 564 } 565 } 566 } 567 568 struct KeywordSuggestionRow: View { 569 let suggestion: KeywordSuggestion 570 571 var body: some View { 572 HStack { 573 VStack(alignment: .leading, spacing: 4) { 574 Text(suggestion.keyword) 575 .font(.subheadline) 576 .fontWeight(.medium) 577 578 Text(suggestion.reason) 579 .font(.caption) 580 .foregroundColor(.secondary) 581 } 582 583 Spacer() 584 585 VStack(alignment: .trailing, spacing: 4) { 586 Text("Potential: \(suggestion.potential)") 587 .font(.caption) 588 .foregroundColor(.accentColor) 589 .fontWeight(.medium) 590 591 Text("\(suggestion.searchVolume) vol/month") 592 .font(.caption2) 593 .foregroundColor(.secondary) 594 } 595 } 596 .padding(12) 597 .background(Color(NSColor.textBackgroundColor)) 598 .cornerRadius(8) 599 } 600 } 601 602 // MARK: - Data Models 603 604 struct KeywordAnalysisResults { 605 let targetKeywords: [String] 606 let foundKeywords: [KeywordMetric] 607 let keywordDensity: Double 608 let keywordDistribution: [KeywordDistribution] 609 let tfIdfScores: [TFIDFScore] 610 let competitorData: [CompetitorKeywordData] 611 let suggestions: [KeywordSuggestion] 612 } 613 614 struct CompetitorKeywordData { 615 let keyword: String 616 let competitionLevel: String 617 let difficulty: Int 618 let monthlySearches: Int 619 } 620 621 struct KeywordSuggestion { 622 let keyword: String 623 let reason: String 624 let potential: String 625 let searchVolume: Int 626 } 627 628 // MARK: - Helper Functions 629 630 private func generateMockCompetitorData(for keywords: [String]) -> [CompetitorKeywordData] { 631 return keywords.map { keyword in 632 CompetitorKeywordData( 633 keyword: keyword, 634 competitionLevel: ["Low", "Medium", "High"].randomElement() ?? "Medium", 635 difficulty: Int.random(in: 1...10), 636 monthlySearches: Int.random(in: 100...10000) 637 ) 638 } 639 } 640 641 private func generateKeywordSuggestions(from keywordAnalysis: KeywordAnalysis) -> [KeywordSuggestion] { 642 let baseKeywords = keywordAnalysis.primaryKeywords.map(\.keyword) 643 return baseKeywords.flatMap { keyword in 644 [ 645 KeywordSuggestion( 646 keyword: "long tail \(keyword)", 647 reason: "Lower competition, higher intent", 648 potential: "High", 649 searchVolume: Int.random(in: 50...500) 650 ), 651 KeywordSuggestion( 652 keyword: "\(keyword) guide", 653 reason: "Informational content opportunity", 654 potential: "Medium", 655 searchVolume: Int.random(in: 200...2000) 656 ) 657 ] 658 }.prefix(6).map { $0 } 659 } 660 661 private func generateDemoKeywordSuggestions(for keywords: [String]) -> [KeywordSuggestion] { 662 return keywords.flatMap { keyword in 663 [ 664 KeywordSuggestion( 665 keyword: "best \(keyword)", 666 reason: "High commercial intent", 667 potential: "High", 668 searchVolume: Int.random(in: 500...5000) 669 ), 670 KeywordSuggestion( 671 keyword: "\(keyword) tutorial", 672 reason: "Educational content opportunity", 673 potential: "Medium", 674 searchVolume: Int.random(in: 200...2000) 675 ) 676 ] 677 }.prefix(8).map { $0 } 678 } 679 680 681 682 #Preview { 683 KeywordAnalysisView() 684 .environmentObject(SEOAnalysisService()) 685 }