({ field, ndviData, soilData, weatherData, suitabilityData }: Omit<CropPlanningSectionProps, "mapToken">)
| 1360 | typeof data.summary === "string", |
| 1361 | ); |
| 1362 | } |
| 1363 | |
| 1364 | function buildLocalCropPlan({ field, ndviData, soilData, weatherData, suitabilityData }: Omit<CropPlanningSectionProps, "mapToken">): CropPlan { |
| 1365 | const signals = buildPlanningSignals({ field, ndviData, soilData, weatherData, suitabilityData }); |
| 1366 | const detectedRegions = detectRegion(signals.locationText); |
| 1367 | |
| 1368 | // Pre-filter profiles: only keep crops that share at least one region tag with the location |
| 1369 | const regionFiltered = CROP_PROFILES.filter(profile => { |
| 1370 | const profileRegionTags = profile.tags.filter(t => |
| 1371 | ["tropical", "subtropical", "mediterranean", "temperate", "continental", "arid", "highland"].includes(t) |
| 1372 | ); |
| 1373 | // If profile has no region tags, allow it (generic crop) |
| 1374 | if (profileRegionTags.length === 0) return true; |
| 1375 | return profileRegionTags.some(tag => detectedRegions.includes(tag as RegionTag)); |
| 1376 | }); |
| 1377 | |
| 1378 | const scoredProfiles = regionFiltered.map((profile) => ({ |
| 1379 | profile, |
| 1380 | score: scoreCropProfile(profile, signals, field.crop), |
| 1381 | })).sort((left, right) => right.score - left.score); |
| 1382 | |
| 1383 | const zoneCount = clamp(field.area > 5 ? 4 : 3, 3, 4); |
| 1384 | let chosen = scoredProfiles.slice(0, zoneCount); |
| 1385 | |
| 1386 | // Ensure current crop is in the plan (if it's region-appropriate) |
| 1387 | const currentCropInPlan = chosen.some(c => c.profile.name.toLowerCase() === field.crop.toLowerCase()); |
| 1388 | if (!currentCropInPlan) { |
| 1389 | const currentProfile = scoredProfiles.find(c => c.profile.name.toLowerCase() === field.crop.toLowerCase()); |
| 1390 | if (currentProfile) { |
| 1391 | chosen[chosen.length - 1] = currentProfile; |
| 1392 | } |
| 1393 | } |
| 1394 | |
| 1395 | // Ensure at least one tree-type crop from the region-filtered list |
| 1396 | const hasTree = chosen.some(c => c.profile.tags.includes("tree")); |
| 1397 | if (!hasTree) { |
| 1398 | const bestTree = scoredProfiles.find(c => c.profile.tags.includes("tree") && !chosen.includes(c)); |
| 1399 | if (bestTree) { |
| 1400 | const replaceIdx = chosen.length - 1; |
| 1401 | chosen[replaceIdx] = bestTree; |
| 1402 | } |
| 1403 | } |
| 1404 | |
| 1405 | const weights = normalizeAreaPercents(chosen.map((item) => item.score)); |
| 1406 | const positions = ZONE_POSITION_PRESETS[chosen.length] || ZONE_POSITION_PRESETS[4] || ZONE_POSITION_PRESETS[3]; |
| 1407 | |
| 1408 | const zones: CropZone[] = chosen.map(({ profile, score }, index) => ({ |
| 1409 | id: `zone-${index + 1}`, |
| 1410 | name: `${String.fromCharCode(65 + index)} Zone`, |
| 1411 | crop: profile.name, |
| 1412 | color: profile.color, |
| 1413 | area_pct: weights[index], |
| 1414 | reason: getZoneReason(profile, signals, index), |
| 1415 | spacing_m: profile.spacing_m, |
| 1416 | water_needs: profile.waterNeeds, |
| 1417 | season: profile.season, |
| 1418 | yield_estimate: formatYield(profile, clamp(score, 0.45, 1.25), signals.healthScore), |
| 1419 | position: positions[index] || { x: 0.5, y: 0.5 }, |
no test coverage detected