This commit implements a complete blockchain-based plant tracking system that preserves lineage across clones, seeds, and all plant offspring while connecting growers through geographic proximity. Features implemented: - Custom blockchain with proof-of-work consensus - Plant registration and cloning with lineage tracking - Geographic discovery to find nearby plants and growers - Integration with plants.net API for plant identification - Comprehensive web UI for plant management - RESTful API endpoints for all operations - Network statistics and visualization Core Components: - lib/blockchain/: PlantBlock, PlantChain, and blockchain manager - lib/services/: plants.net API and geolocation services - pages/api/plants/: REST API endpoints for all operations - pages/: Frontend UI pages for registration, exploration, and lineage Technical Details: - TypeScript for type safety - Next.js for server-side rendering - Tailwind CSS for responsive design - JSON file-based blockchain storage - Haversine distance calculations for geolocation - OpenStreetMap integration for geocoding This system enables large-scale adoption by: - Making plant lineage tracking accessible to everyone - Connecting local communities through plant sharing - Providing immutable proof of plant provenance - Supporting unlimited generations of plant propagation - Scaling from individual growers to global networks Documentation includes comprehensive README with: - Quick start guide - API reference - Architecture details - Scaling recommendations - Use cases for various audiences - Roadmap for future enhancements
302 lines
8 KiB
TypeScript
302 lines
8 KiB
TypeScript
/**
|
|
* Geolocation Service
|
|
* Provides location-based features for connecting plant owners
|
|
*/
|
|
|
|
import { PlantData, PlantLocation } from '../blockchain/types';
|
|
|
|
export interface PlantCluster {
|
|
centerLat: number;
|
|
centerLon: number;
|
|
plantCount: number;
|
|
plants: PlantData[];
|
|
radius: number; // in km
|
|
dominantSpecies: string[];
|
|
}
|
|
|
|
export interface ConnectionSuggestion {
|
|
plant1: PlantData;
|
|
plant2: PlantData;
|
|
distance: number;
|
|
matchReason: string; // e.g., "same species", "same lineage", "nearby location"
|
|
compatibilityScore: number; // 0-100
|
|
}
|
|
|
|
export class GeolocationService {
|
|
/**
|
|
* Calculate distance between two coordinates using Haversine formula
|
|
*/
|
|
calculateDistance(
|
|
lat1: number,
|
|
lon1: number,
|
|
lat2: number,
|
|
lon2: number
|
|
): number {
|
|
const R = 6371; // Earth's radius in km
|
|
const dLat = this.toRadians(lat2 - lat1);
|
|
const dLon = this.toRadians(lon2 - lon1);
|
|
|
|
const a =
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos(this.toRadians(lat1)) *
|
|
Math.cos(this.toRadians(lat2)) *
|
|
Math.sin(dLon / 2) *
|
|
Math.sin(dLon / 2);
|
|
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
|
|
private toRadians(degrees: number): number {
|
|
return degrees * (Math.PI / 180);
|
|
}
|
|
|
|
/**
|
|
* Find plant clusters in a region
|
|
* Groups nearby plants together to show hotspots of activity
|
|
*/
|
|
findPlantClusters(
|
|
plants: PlantData[],
|
|
clusterRadius: number = 10 // km
|
|
): PlantCluster[] {
|
|
const clusters: PlantCluster[] = [];
|
|
const processed = new Set<string>();
|
|
|
|
for (const plant of plants) {
|
|
if (processed.has(plant.id)) continue;
|
|
|
|
// Find all plants within cluster radius
|
|
const clusterPlants: PlantData[] = [plant];
|
|
processed.add(plant.id);
|
|
|
|
for (const otherPlant of plants) {
|
|
if (processed.has(otherPlant.id)) continue;
|
|
|
|
const distance = this.calculateDistance(
|
|
plant.location.latitude,
|
|
plant.location.longitude,
|
|
otherPlant.location.latitude,
|
|
otherPlant.location.longitude
|
|
);
|
|
|
|
if (distance <= clusterRadius) {
|
|
clusterPlants.push(otherPlant);
|
|
processed.add(otherPlant.id);
|
|
}
|
|
}
|
|
|
|
// Calculate cluster center (average of all positions)
|
|
const centerLat =
|
|
clusterPlants.reduce((sum, p) => sum + p.location.latitude, 0) /
|
|
clusterPlants.length;
|
|
const centerLon =
|
|
clusterPlants.reduce((sum, p) => sum + p.location.longitude, 0) /
|
|
clusterPlants.length;
|
|
|
|
// Find dominant species
|
|
const speciesCount: { [key: string]: number } = {};
|
|
for (const p of clusterPlants) {
|
|
if (p.scientificName) {
|
|
speciesCount[p.scientificName] =
|
|
(speciesCount[p.scientificName] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
const dominantSpecies = Object.entries(speciesCount)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 3)
|
|
.map(([species]) => species);
|
|
|
|
clusters.push({
|
|
centerLat,
|
|
centerLon,
|
|
plantCount: clusterPlants.length,
|
|
plants: clusterPlants,
|
|
radius: clusterRadius,
|
|
dominantSpecies,
|
|
});
|
|
}
|
|
|
|
return clusters.sort((a, b) => b.plantCount - a.plantCount);
|
|
}
|
|
|
|
/**
|
|
* Suggest connections between plant owners
|
|
* Finds compatible plants for sharing/trading
|
|
*/
|
|
suggestConnections(
|
|
userPlant: PlantData,
|
|
allPlants: PlantData[],
|
|
maxDistance: number = 50 // km
|
|
): ConnectionSuggestion[] {
|
|
const suggestions: ConnectionSuggestion[] = [];
|
|
|
|
for (const otherPlant of allPlants) {
|
|
// Skip if same owner
|
|
if (otherPlant.owner.id === userPlant.owner.id) continue;
|
|
|
|
// Skip if same plant
|
|
if (otherPlant.id === userPlant.id) continue;
|
|
|
|
const distance = this.calculateDistance(
|
|
userPlant.location.latitude,
|
|
userPlant.location.longitude,
|
|
otherPlant.location.latitude,
|
|
otherPlant.location.longitude
|
|
);
|
|
|
|
// Skip if too far
|
|
if (distance > maxDistance) continue;
|
|
|
|
let matchReason = '';
|
|
let compatibilityScore = 0;
|
|
|
|
// Check for same species
|
|
if (
|
|
userPlant.scientificName &&
|
|
userPlant.scientificName === otherPlant.scientificName
|
|
) {
|
|
matchReason = 'Same species';
|
|
compatibilityScore += 40;
|
|
}
|
|
|
|
// Check for same lineage
|
|
if (
|
|
userPlant.parentPlantId === otherPlant.parentPlantId &&
|
|
userPlant.parentPlantId
|
|
) {
|
|
matchReason =
|
|
matchReason + (matchReason ? ', ' : '') + 'Same parent plant';
|
|
compatibilityScore += 30;
|
|
}
|
|
|
|
// Check for same genus
|
|
if (userPlant.genus && userPlant.genus === otherPlant.genus) {
|
|
if (!matchReason) matchReason = 'Same genus';
|
|
compatibilityScore += 20;
|
|
}
|
|
|
|
// Proximity bonus
|
|
const proximityScore = Math.max(0, 20 - distance / 2.5);
|
|
compatibilityScore += proximityScore;
|
|
|
|
// Only suggest if there's some compatibility
|
|
if (compatibilityScore > 20) {
|
|
if (!matchReason) matchReason = 'Nearby location';
|
|
|
|
suggestions.push({
|
|
plant1: userPlant,
|
|
plant2: otherPlant,
|
|
distance,
|
|
matchReason,
|
|
compatibilityScore: Math.min(100, compatibilityScore),
|
|
});
|
|
}
|
|
}
|
|
|
|
return suggestions.sort((a, b) => b.compatibilityScore - a.compatibilityScore);
|
|
}
|
|
|
|
/**
|
|
* Get address from coordinates using reverse geocoding
|
|
* Note: In production, you'd use a service like Google Maps or OpenStreetMap
|
|
*/
|
|
async reverseGeocode(
|
|
latitude: number,
|
|
longitude: number
|
|
): Promise<Partial<PlantLocation>> {
|
|
try {
|
|
// Using OpenStreetMap Nominatim (free, but rate-limited)
|
|
const response = await fetch(
|
|
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
|
{
|
|
headers: {
|
|
'User-Agent': 'LocalGreenChain/1.0',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
console.error('Reverse geocoding error:', response.statusText);
|
|
return { latitude, longitude };
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
return {
|
|
latitude,
|
|
longitude,
|
|
address: data.display_name,
|
|
city: data.address?.city || data.address?.town || data.address?.village,
|
|
country: data.address?.country,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error in reverse geocoding:', error);
|
|
return { latitude, longitude };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get coordinates from address using forward geocoding
|
|
*/
|
|
async geocode(
|
|
address: string
|
|
): Promise<{ latitude: number; longitude: number } | null> {
|
|
try {
|
|
const response = await fetch(
|
|
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}`,
|
|
{
|
|
headers: {
|
|
'User-Agent': 'LocalGreenChain/1.0',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
console.error('Geocoding error:', response.statusText);
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.length === 0) return null;
|
|
|
|
return {
|
|
latitude: parseFloat(data[0].lat),
|
|
longitude: parseFloat(data[0].lon),
|
|
};
|
|
} catch (error) {
|
|
console.error('Error in geocoding:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a location is within a given boundary
|
|
*/
|
|
isWithinBounds(
|
|
location: { latitude: number; longitude: number },
|
|
bounds: {
|
|
north: number;
|
|
south: number;
|
|
east: number;
|
|
west: number;
|
|
}
|
|
): boolean {
|
|
return (
|
|
location.latitude <= bounds.north &&
|
|
location.latitude >= bounds.south &&
|
|
location.longitude <= bounds.east &&
|
|
location.longitude >= bounds.west
|
|
);
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let geolocationService: GeolocationService | null = null;
|
|
|
|
export function getGeolocationService(): GeolocationService {
|
|
if (!geolocationService) {
|
|
geolocationService = new GeolocationService();
|
|
}
|
|
return geolocationService;
|
|
}
|