@lynxflow/seo-engine 1.8.30 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +47 -93
  2. package/dist/ai-copilot-client.d.ts +2 -0
  3. package/dist/built-in-locations.d.ts +18 -0
  4. package/dist/content-ai-arsenal.d.ts +28 -0
  5. package/dist/existing-media-harvester.d.ts +33 -0
  6. package/dist/extended-schemas.d.ts +84 -0
  7. package/dist/features-knowledge-harvester.d.ts +56 -0
  8. package/dist/geo-citation-scorer.d.ts +34 -0
  9. package/dist/i18n-detector.d.ts +1 -1
  10. package/dist/index.d.ts +32 -7
  11. package/dist/index.js +538 -82
  12. package/dist/index.mjs +2028 -163
  13. package/dist/internal-pagerank-graph.d.ts +59 -0
  14. package/dist/knowledge-bank-builder.d.ts +91 -0
  15. package/dist/marketing-skills-engine.d.ts +74 -0
  16. package/dist/matrix-engine.d.ts +26 -0
  17. package/dist/power-words-psychology.d.ts +27 -15
  18. package/dist/public-manifest-engine.d.ts +58 -0
  19. package/dist/rag-knowledge-engine.d.ts +50 -0
  20. package/dist/rate-limited-translator.d.ts +19 -0
  21. package/dist/token-quota-manager.d.ts +4 -0
  22. package/package.json +11 -3
  23. package/TUTORIEL_INTEGRATION_SITE.md +0 -283
  24. package/connectors/cloudflare-worker/worker.js +0 -106
  25. package/connectors/laravel/LynxSeoController.php +0 -37
  26. package/connectors/wordpress/lynxseo-admin-app.js +0 -2611
  27. package/connectors/wordpress/lynxseo-connector.php +0 -1712
  28. package/connectors/wordpress/lynxseo-connector.zip +0 -0
  29. package/lynxflow-seo-engine-1.2.0.tgz +0 -0
  30. package/src/ad-intelligence-cro.ts +0 -140
  31. package/src/ai-bots-log-analyzer.ts +0 -127
  32. package/src/ai-copilot-client.ts +0 -99
  33. package/src/analytics-client.ts +0 -315
  34. package/src/auth-key.ts +0 -262
  35. package/src/backlinks-client.ts +0 -85
  36. package/src/brand-dna-calendar.ts +0 -120
  37. package/src/brand-icons.ts +0 -138
  38. package/src/built-in-locations.ts +0 -170
  39. package/src/content-ai-arsenal.ts +0 -268
  40. package/src/copy-frameworks-master.ts +0 -89
  41. package/src/cro-copywriting-engine.ts +0 -105
  42. package/src/crosslink-scorer.ts +0 -101
  43. package/src/embeddable-seo-widget.ts +0 -57
  44. package/src/engine.test.ts +0 -386
  45. package/src/engine.ts +0 -813
  46. package/src/existing-media-harvester.ts +0 -98
  47. package/src/extended-schemas.ts +0 -589
  48. package/src/features-knowledge-harvester.ts +0 -190
  49. package/src/free-public-tools.ts +0 -224
  50. package/src/geo-mesh-linking.ts +0 -93
  51. package/src/google-business-profile.ts +0 -280
  52. package/src/i18n-detector.ts +0 -98
  53. package/src/i18n-dictionary.ts +0 -422
  54. package/src/index.ts +0 -164
  55. package/src/indexnow-client.ts +0 -94
  56. package/src/instant-matrix-search.ts +0 -48
  57. package/src/isr-cache-manager.ts +0 -78
  58. package/src/keyword-permutator.ts +0 -118
  59. package/src/knowledge-bank-builder.ts +0 -299
  60. package/src/knowledge-graph-linker.ts +0 -81
  61. package/src/lago-token-meter.ts +0 -120
  62. package/src/legal-disclaimers.ts +0 -407
  63. package/src/llm-content-cleaner.ts +0 -64
  64. package/src/llm-prompt.ts +0 -323
  65. package/src/master-marketing-engine.ts +0 -314
  66. package/src/matrix-engine.ts +0 -1204
  67. package/src/mcp-seo-server.ts +0 -108
  68. package/src/ngram-density-analyzer.ts +0 -108
  69. package/src/og-image-generator.ts +0 -109
  70. package/src/power-words-psychology.ts +0 -369
  71. package/src/public-manifest-engine.ts +0 -109
  72. package/src/rag-knowledge-engine.ts +0 -163
  73. package/src/rank-math-parity.ts +0 -235
  74. package/src/rate-limited-translator.ts +0 -110
  75. package/src/real-reviews-sync.ts +0 -207
  76. package/src/rss-syndication-feed.ts +0 -56
  77. package/src/schema-builder.ts +0 -134
  78. package/src/seo-opportunities-decay.ts +0 -154
  79. package/src/serp-client.ts +0 -89
  80. package/src/serp-history-alerts.ts +0 -75
  81. package/src/site-auditor.ts +0 -83
  82. package/src/site-crawler.ts +0 -406
  83. package/src/slug-engine.ts +0 -183
  84. package/src/social-ads-seo.ts +0 -211
  85. package/src/social-growth-suite.ts +0 -310
  86. package/src/social-trend-seo.ts +0 -103
  87. package/src/social-video-seo.ts +0 -100
  88. package/src/team-rbac.ts +0 -61
  89. package/src/technical-rules-auditor.ts +0 -171
  90. package/src/token-quota-manager.ts +0 -91
  91. package/src/types.ts +0 -143
  92. package/src/ui-icons.ts +0 -134
  93. package/src/urlytics-engine.ts +0 -97
  94. package/src/video-youtube-analyzer.ts +0 -81
  95. package/src/yoast-parity.ts +0 -161
  96. package/tsconfig.json +0 -15
@@ -1,190 +0,0 @@
1
- /**
2
- * 📚 Features & Documentation Knowledge Harvester (Automated Content Extractor)
3
- *
4
- * Automatically crawls and ingests public documentation, feature pages, and WordPress sitemaps.
5
- * Extracts structured knowledge chunks:
6
- * - Module Name, Category & H1 Title
7
- * - Executive Summaries & Key Paragraphs (up to 15 in-depth paragraphs)
8
- * - Workflows & Step-by-Step Guides
9
- * - Key Capabilities & Benefit Bullet Points
10
- * - Structured FAQs & Objection Handling
11
- *
12
- * Provides instant in-memory retrieval for programmatic page generation with zero manual writing.
13
- */
14
-
15
- export interface ExtractedFeatureKnowledge {
16
- slug: string;
17
- url: string;
18
- title: string;
19
- headline?: string;
20
- category: string;
21
- executiveSummary: string;
22
- paragraphs: string[]; // Up to 15 clean thematic paragraphs
23
- keyBenefits: string[];
24
- workflowSteps: { step: number; title: string; description: string }[];
25
- faqs: { question: string; answer: string }[];
26
- rawTextLength: number;
27
- }
28
-
29
- export class FeaturesKnowledgeHarvester {
30
- private static knowledgeStore: Map<string, ExtractedFeatureKnowledge> = new Map();
31
-
32
- /**
33
- * Extract knowledge from raw HTML or Markdown content of a public page
34
- */
35
- static extractFromHtml(url: string, html: string, slug?: string): ExtractedFeatureKnowledge {
36
- const pageSlug = slug || url.split("/").filter(Boolean).pop() || "feature";
37
-
38
- // Extract Title (H1 or <title>)
39
- const titleMatch = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) || html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
40
- const title = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, "").trim() : "Module " + pageSlug;
41
-
42
- // Extract Paragraphs (<p>)
43
- const paragraphMatches = html.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi);
44
- const paragraphs: string[] = [];
45
- for (const match of paragraphMatches) {
46
- const clean = match[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
47
- if (clean.length > 40 && !clean.includes("Cookie") && !clean.includes("Copyright")) {
48
- paragraphs.push(clean);
49
- if (paragraphs.length >= 15) break; // Keep the top 15 rich paragraphs
50
- }
51
- }
52
-
53
- // Extract List Items (<li>) as Key Benefits
54
- const liMatches = html.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi);
55
- const keyBenefits: string[] = [];
56
- for (const match of liMatches) {
57
- const clean = match[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
58
- if (clean.length > 20 && clean.length < 200) {
59
- keyBenefits.push(clean);
60
- if (keyBenefits.length >= 8) break;
61
- }
62
- }
63
-
64
- // Extract FAQs (H3/H4 questions with following text or <details>/<summary>)
65
- const faqs: { question: string; answer: string }[] = [];
66
- const faqMatches = html.matchAll(/<summary[^>]*>([\s\S]*?)<\/summary>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/gi);
67
- for (const match of faqMatches) {
68
- const question = match[1].replace(/<[^>]+>/g, "").trim();
69
- const answer = match[2].replace(/<[^>]+>/g, "").trim();
70
- if (question && answer) {
71
- faqs.push({ question, answer });
72
- }
73
- }
74
-
75
- // Default workflow steps if not parsed
76
- const workflowSteps = [
77
- { step: 1, title: "Configuration Automatique", description: `Activation du module ${title} en 1 clic.` },
78
- { step: 2, title: "Résolution en Mémoire Vive", description: "Traitement ultra-rapide sans latence SQL." },
79
- { step: 3, title: "Déploiement Continu", description: "Mise à jour en temps réel sur l'ensemble de vos pages." },
80
- ];
81
-
82
- const executiveSummary = paragraphs.slice(0, 2).join(" ") || `Module de haute performance ${title} optimisé pour Google et le Search IA.`;
83
-
84
- const knowledge: ExtractedFeatureKnowledge = {
85
- slug: pageSlug,
86
- url,
87
- title,
88
- category: "Feature Suite",
89
- executiveSummary,
90
- paragraphs,
91
- keyBenefits,
92
- workflowSteps,
93
- faqs,
94
- rawTextLength: paragraphs.join(" ").length,
95
- };
96
-
97
- this.knowledgeStore.set(pageSlug, knowledge);
98
- return knowledge;
99
- }
100
-
101
- /**
102
- * Batch ingest multiple pages from a sitemap or list of URLs
103
- */
104
- static async ingestPagesFromSitemap(sitemapUrl: string): Promise<number> {
105
- try {
106
- const res = await fetch(sitemapUrl);
107
- if (!res.ok) return 0;
108
- const xml = await res.text();
109
- const urls = [...xml.matchAll(/<loc>([\s\S]*?)<\/loc>/gi)].map((m) => m[1].trim());
110
-
111
- let count = 0;
112
- for (const u of urls.slice(0, 50)) {
113
- try {
114
- const pageRes = await fetch(u);
115
- if (pageRes.ok) {
116
- const html = await pageRes.text();
117
- this.extractFromHtml(u, html);
118
- count++;
119
- }
120
- } catch {
121
- // ignore network timeout
122
- }
123
- }
124
- return count;
125
- } catch {
126
- return 0;
127
- }
128
- }
129
-
130
- /**
131
- * Retrieve extracted knowledge for any module by slug
132
- */
133
- static getKnowledge(slug: string): ExtractedFeatureKnowledge | undefined {
134
- return this.knowledgeStore.get(slug);
135
- }
136
-
137
- /**
138
- * Get all registered module knowledge chunks
139
- */
140
- static getAllKnowledge(): ExtractedFeatureKnowledge[] {
141
- return Array.from(this.knowledgeStore.values());
142
- }
143
-
144
- /**
145
- * Generate an ultra-complete, 1000-word programmatic page using harvested knowledge
146
- */
147
- static buildEnrichedPage(slug: string, locationOrSector: string): string {
148
- const k = this.getKnowledge(slug);
149
- if (!k) {
150
- return `# Solution ${slug} à ${locationOrSector}\n\nExplications complètes et déploiement immédiat.`;
151
- }
152
-
153
- return `
154
- # ${k.title} à ${locationOrSector} (Guide Complet & Déploiement 2026)
155
-
156
- > **Résumé Exécutif :** ${k.executiveSummary}
157
-
158
- ---
159
-
160
- ## 📖 Analyse Approfondie & Fonctionnement
161
-
162
- ${k.paragraphs.slice(0, 6).join("\n\n")}
163
-
164
- ---
165
-
166
- ## 💎 Fonctionnalités Clés & Avantages Concrets :
167
-
168
- ${k.keyBenefits.map((b) => `* **${b}**`).join("\n")}
169
-
170
- ---
171
-
172
- ## 🛠️ Guide de Mise en Œuvre en 3 Étapes :
173
-
174
- ${k.workflowSteps.map((s) => `${s.step}. **${s.title}** : ${s.description}`).join("\n")}
175
-
176
- ---
177
-
178
- ${k.paragraphs.slice(6, 12).join("\n\n")}
179
-
180
- ---
181
-
182
- ## ❓ Questions Fréquentes & Réponses Officielles :
183
-
184
- ${k.faqs.length > 0
185
- ? k.faqs.map((f) => `### ${f.question}\n${f.answer}`).join("\n\n")
186
- : `### Comment activer ${k.title} à ${locationOrSector} ?\nL'activation est instantanée en 1 clic sans compétences techniques.`
187
- }
188
- `.trim();
189
- }
190
- }
@@ -1,224 +0,0 @@
1
- /**
2
- * 🛠️ Free Public Interactive Tools & Lead Magnets Suite
3
- * Inspired by Ahrefs, Semrush, Moz, and WordStream free tool ecosystems.
4
- *
5
- * Provides 6 zero-dependency interactive calculators and simulators that can be embedded
6
- * on public programmatic pages to maximize dwell time, engagement, backlink acquisition, and user conversion.
7
- */
8
-
9
- import { cleanSeoSlug } from "./slug-engine";
10
- import { ExtendedSchemaGraphBuilder } from "./extended-schemas";
11
-
12
- export interface SerpSimulatorResult {
13
- title: string;
14
- truncatedTitle: string;
15
- titlePixelWidth: number;
16
- isTitleTruncated: boolean;
17
- description: string;
18
- truncatedDescription: string;
19
- isDescriptionTruncated: boolean;
20
- urlPreview: string;
21
- estimatedCtrScore: number; // 0 to 100
22
- recommendations: string[];
23
- }
24
-
25
- export interface RoiCalculatorResult {
26
- hoursSavedWeekly: number;
27
- hoursSavedMonthly: number;
28
- monthlyCostSavings: number;
29
- annualCostSavings: number;
30
- roiPercentage: number;
31
- breakEvenDays: number;
32
- }
33
-
34
- export interface DensityAnalysisResult {
35
- totalWords: number;
36
- characterCount: number;
37
- readingTimeMinutes: number;
38
- topKeywords: { word: string; count: number; densityPercent: number }[];
39
- seoReadabilityScore: number; // 0 to 100
40
- }
41
-
42
- export class FreePublicToolsEngine {
43
- /**
44
- * 1. Google SERP Preview & CTR Optimizer Simulator (Ahrefs / Moz Parity)
45
- */
46
- static simulateSerpSnippet(input: {
47
- title: string;
48
- description: string;
49
- url: string;
50
- targetKeyword?: string;
51
- }): SerpSimulatorResult {
52
- const title = (input.title || "").trim();
53
- const description = (input.description || "").trim();
54
- const url = (input.url || "https://example.com").trim();
55
- const kw = (input.targetKeyword || "").toLowerCase().trim();
56
-
57
- // Average pixel width estimation (approx 10px per char on Arial 20px)
58
- const titlePixelWidth = title.length * 9.5;
59
- const maxTitlePixels = 580; // Google Desktop breakpoint
60
- const maxDescChars = 158;
61
-
62
- const isTitleTruncated = titlePixelWidth > maxTitlePixels;
63
- const truncatedTitle = isTitleTruncated ? title.slice(0, 56) + "..." : title;
64
-
65
- const isDescriptionTruncated = description.length > maxDescChars;
66
- const truncatedDescription = isDescriptionTruncated ? description.slice(0, 155) + "..." : description;
67
-
68
- let ctrScore = 50;
69
- const recommendations: string[] = [];
70
-
71
- // Title checks
72
- if (title.length >= 40 && title.length <= 60) {
73
- ctrScore += 20;
74
- } else if (title.length < 30) {
75
- recommendations.push("Le titre est trop court (< 30 car.). Ajoutez des précisions pour booster le clic.");
76
- } else if (isTitleTruncated) {
77
- ctrScore -= 10;
78
- recommendations.push("Le titre dépasse 580px et sera tronqué sur Google Desktop.");
79
- }
80
-
81
- // Keyword presence
82
- if (kw && title.toLowerCase().includes(kw)) {
83
- ctrScore += 15;
84
- } else if (kw) {
85
- recommendations.push(`Le mot-clé principal "${kw}" est absent du titre.`);
86
- }
87
-
88
- // Description checks
89
- if (description.length >= 120 && description.length <= 158) {
90
- ctrScore += 15;
91
- } else if (description.length < 80) {
92
- recommendations.push("La méta-description est trop courte (< 80 car.).");
93
- }
94
-
95
- return {
96
- title,
97
- truncatedTitle,
98
- titlePixelWidth: Math.round(titlePixelWidth),
99
- isTitleTruncated,
100
- description,
101
- truncatedDescription,
102
- isDescriptionTruncated,
103
- urlPreview: url,
104
- estimatedCtrScore: Math.min(Math.max(ctrScore, 10), 100),
105
- recommendations,
106
- };
107
- }
108
-
109
- /**
110
- * 2. Free ROI & Time-Saved Automation Calculator
111
- */
112
- static calculateRoi(input: {
113
- hoursSpentPerWeek: number;
114
- hourlyRateOrSalary: number;
115
- teamMembersCount?: number;
116
- softwareMonthlyPrice?: number;
117
- }): RoiCalculatorResult {
118
- const hours = Math.max(input.hoursSpentPerWeek || 5, 1);
119
- const rate = Math.max(input.hourlyRateOrSalary || 35, 1);
120
- const team = Math.max(input.teamMembersCount || 1, 1);
121
- const softwareCost = input.softwareMonthlyPrice ?? 49;
122
-
123
- // Assuming automation saves ~75% of manual operational time
124
- const hoursSavedWeekly = Math.round(hours * 0.75 * team * 10) / 10;
125
- const hoursSavedMonthly = Math.round(hoursSavedWeekly * 4.33);
126
-
127
- const monthlyGrossSavings = hoursSavedMonthly * rate;
128
- const monthlyNetSavings = Math.max(monthlyGrossSavings - softwareCost, 0);
129
- const annualCostSavings = monthlyNetSavings * 12;
130
-
131
- const roiPercentage = softwareCost > 0 ? Math.round((monthlyNetSavings / softwareCost) * 100) : 1000;
132
- const breakEvenDays = monthlyGrossSavings > 0 ? Math.max(Math.round((softwareCost / (monthlyGrossSavings / 30))), 1) : 30;
133
-
134
- return {
135
- hoursSavedWeekly,
136
- hoursSavedMonthly,
137
- monthlyCostSavings: Math.round(monthlyNetSavings),
138
- annualCostSavings: Math.round(annualCostSavings),
139
- roiPercentage,
140
- breakEvenDays,
141
- };
142
- }
143
-
144
- /**
145
- * 3. Instant Keyword Density & Readability Analyzer
146
- */
147
- static analyzeTextDensity(text: string, language: string = "fr"): DensityAnalysisResult {
148
- if (!text || typeof text !== "string") {
149
- return { totalWords: 0, characterCount: 0, readingTimeMinutes: 0, topKeywords: [], seoReadabilityScore: 0 };
150
- }
151
-
152
- const clean = text.toLowerCase().replace(/[^a-z0-9à-ÿ\s]/g, " ");
153
- const words = clean.split(/\s+/).filter((w) => w.length > 2);
154
- const totalWords = words.length;
155
- const characterCount = text.length;
156
- const readingTimeMinutes = Math.max(Math.round((totalWords / 200) * 10) / 10, 0.5);
157
-
158
- const stopWords = new Set(["pour", "dans", "avec", "sur", "les", "des", "une", "que", "qui", "est", "par", "the", "and", "for", "with", "this", "that"]);
159
- const frequencyMap = new Map<string, number>();
160
-
161
- for (const w of words) {
162
- if (!stopWords.has(w)) {
163
- frequencyMap.set(w, (frequencyMap.get(w) || 0) + 1);
164
- }
165
- }
166
-
167
- const sorted = Array.from(frequencyMap.entries()).sort((a, b) => b[1] - a[1]);
168
- const topKeywords = sorted.slice(0, 8).map(([word, count]) => ({
169
- word,
170
- count,
171
- densityPercent: totalWords > 0 ? Math.round((count / totalWords) * 1000) / 10 : 0,
172
- }));
173
-
174
- let seoReadabilityScore = 70;
175
- if (totalWords >= 600) seoReadabilityScore += 20;
176
- else if (totalWords < 300) seoReadabilityScore -= 25;
177
-
178
- return {
179
- totalWords,
180
- characterCount,
181
- readingTimeMinutes,
182
- topKeywords,
183
- seoReadabilityScore: Math.min(Math.max(seoReadabilityScore, 10), 100),
184
- };
185
- }
186
-
187
- /**
188
- * 4. Free SEO Slug Cleanser Helper
189
- */
190
- static cleanSlugTool(input: string, lang: string = "fr"): { original: string; cleanSlug: string; charSavings: number } {
191
- const clean = cleanSeoSlug(input, { language: lang });
192
- return {
193
- original: input,
194
- cleanSlug: clean,
195
- charSavings: Math.max(input.length - clean.length, 0),
196
- };
197
- }
198
-
199
- /**
200
- * 5. Free Schema.org Generator Helper
201
- */
202
- static generateSampleSchema(type: "localBusiness" | "softwareApp" | "faq", name: string, url: string): Record<string, unknown> {
203
- if (type === "localBusiness") {
204
- return ExtendedSchemaGraphBuilder.buildLocalBusiness({
205
- name,
206
- url,
207
- city: "Paris",
208
- country: "France",
209
- });
210
- }
211
- if (type === "faq") {
212
- return ExtendedSchemaGraphBuilder.buildFAQPage([
213
- { question: `Comment fonctionne ${name} ?`, answer: `${name} automatise vos opérations en quelques clics.` },
214
- { question: `Quel est le tarif de ${name} ?`, answer: `Un essai gratuit de 14 jours est disponible sans carte bancaire.` },
215
- ]);
216
- }
217
- return ExtendedSchemaGraphBuilder.buildSoftwareApplication({
218
- name,
219
- description: `Solution logicielle professionnelle ${name}`,
220
- url,
221
- priceMonthly: 29,
222
- });
223
- }
224
- }
@@ -1,93 +0,0 @@
1
- /**
2
- * 🗺️ Geographic Mesh Linking Engine
3
- *
4
- * Implements geodesic distance calculation (Haversine formula) and
5
- * administrative zone clustering to connect neighboring cities and propagate PageRank.
6
- */
7
-
8
- export interface GeoCoordinate {
9
- latitude: number;
10
- longitude: number;
11
- }
12
-
13
- export interface MeshCity {
14
- slug: string;
15
- name: string;
16
- departmentCode?: string;
17
- region?: string;
18
- country: string;
19
- coordinates?: GeoCoordinate;
20
- population?: number;
21
- }
22
-
23
- export class GeoMeshLinkingEngine {
24
- /**
25
- * Computes the great-circle distance between two points in kilometers (Haversine formula).
26
- */
27
- static computeDistanceKm(coord1: GeoCoordinate, coord2: GeoCoordinate): number {
28
- const R = 6371; // Earth radius in KM
29
- const dLat = this.deg2rad(coord2.latitude - coord1.latitude);
30
- const dLon = this.deg2rad(coord2.longitude - coord1.longitude);
31
-
32
- const a =
33
- Math.sin(dLat / 2) * Math.sin(dLat / 2) +
34
- Math.cos(this.deg2rad(coord1.latitude)) *
35
- Math.cos(this.deg2rad(coord2.latitude)) *
36
- Math.sin(dLon / 2) *
37
- Math.sin(dLon / 2);
38
-
39
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
40
- return R * c;
41
- }
42
-
43
- private static deg2rad(deg: number): number {
44
- return deg * (Math.PI / 180);
45
- }
46
-
47
- /**
48
- * Finds the N closest neighbor cities within the same country / department / distance.
49
- */
50
- static findClosestNeighbors(
51
- targetCity: MeshCity,
52
- allCities: MeshCity[],
53
- maxNeighbors = 4,
54
- maxDistanceKm = 50,
55
- ): { city: MeshCity; distanceKm?: number }[] {
56
- const candidateCities = allCities.filter(
57
- (c) => c.slug !== targetCity.slug && c.country === targetCity.country,
58
- );
59
-
60
- // If coordinates are available, calculate exact geodesic proximity
61
- if (targetCity.coordinates) {
62
- const withDistance = candidateCities
63
- .filter((c) => Boolean(c.coordinates))
64
- .map((c) => ({
65
- city: c,
66
- distanceKm: this.computeDistanceKm(targetCity.coordinates!, c.coordinates!),
67
- }))
68
- .filter((item) => item.distanceKm <= maxDistanceKm)
69
- .sort((a, b) => a.distanceKm - b.distanceKm);
70
-
71
- if (withDistance.length > 0) {
72
- return withDistance.slice(0, maxNeighbors);
73
- }
74
- }
75
-
76
- // Fallback: match by same department or region
77
- const sameDepartment = candidateCities.filter(
78
- (c) =>
79
- targetCity.departmentCode &&
80
- c.departmentCode === targetCity.departmentCode,
81
- );
82
-
83
- if (sameDepartment.length > 0) {
84
- return sameDepartment.slice(0, maxNeighbors).map((c) => ({ city: c }));
85
- }
86
-
87
- const sameRegion = candidateCities.filter(
88
- (c) => targetCity.region && c.region === targetCity.region,
89
- );
90
-
91
- return sameRegion.slice(0, maxNeighbors).map((c) => ({ city: c }));
92
- }
93
- }