turash/bugulma/frontend/components/map/ResourceFlowMarkers.tsx
Damir Mukimov 6347f42e20
Consolidate repositories: Remove nested frontend .git and merge into main repository
- Remove nested git repository from bugulma/frontend/.git
- Add all frontend files to main repository tracking
- Convert from separate frontend/backend repos to unified monorepo
- Preserve all frontend code and development history as tracked files
- Eliminate nested repository complexity for simpler development workflow

This creates a proper monorepo structure with frontend and backend
coexisting in the same repository for easier development and deployment.
2025-11-25 06:02:57 +01:00

266 lines
8.2 KiB
TypeScript

import L from 'leaflet';
import React, { useMemo } from 'react';
import { Marker, Popup } from 'react-leaflet';
import MarkerClusterGroup from 'react-leaflet-markercluster';
import { useTranslation } from '@/hooks/useI18n';
import type { BackendMatch } from '@/schemas/backend/match';
// Resource type color mapping
const resourceTypeColors: Record<string, string> = {
heat: '#FF6B6B',
water: '#4ECDC4',
steam: '#95E1D3',
CO2: '#45B7D1',
biowaste: '#96CEB4',
cooling: '#FECA57',
logistics: '#FF9FF3',
materials: '#54A0FF',
service: '#5F27CD',
};
interface ResourceFlowMarkersProps {
matches: BackendMatch[];
selectedMatchId: string | null;
onMatchSelect: (matchId: string) => void;
}
/**
* Individual resource flow marker component
*/
const ResourceFlowMarker = React.memo<{
position: [number, number];
resourceType: string;
direction: string;
match: BackendMatch;
isInSelectedMatch: boolean;
onClick: () => void;
}>(({ position, resourceType, direction, match, isInSelectedMatch, onClick }) => {
const { t } = useTranslation();
const color = resourceTypeColors[resourceType] || '#6C757D';
// Create custom icon
const icon = useMemo(() => {
const markerHtml = `
<div style="
background-color: ${color};
border: 3px solid white;
border-radius: 50%;
width: 20px;
height: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
${isInSelectedMatch ? 'transform: scale(1.2);' : ''}
transition: transform 0.2s ease;
">
<div style="
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
background-color: white;
border-radius: 50%;
"></div>
</div>
`;
return L.divIcon({
html: markerHtml,
className: 'custom-resource-flow-marker',
iconSize: [26, 26],
iconAnchor: [13, 13],
});
}, [color, isInSelectedMatch]);
const formatScore = (score: number) => {
return `${(score * 100).toFixed(1)}%`;
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
return (
<Marker
position={position}
icon={icon}
eventHandlers={{
click: onClick,
mouseover: (e) => {
// Could add hover effects here
},
mouseout: (e) => {
// Reset hover effects here
},
}}
>
<Popup>
<div className="min-w-64">
<div className="flex items-center gap-2 mb-3">
<div
className="w-4 h-4 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: color }}
/>
<h4 className="font-semibold text-sm">
{t(`resourceTypes.${resourceType}`, { defaultValue: resourceType })} {t('matchesMap.flow')}
</h4>
<span className={`text-xs px-2 py-1 rounded ${
direction === 'input' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
}`}>
{direction === 'input' ? t('matchesMap.input') : t('matchesMap.output')}
</span>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">{t('matchesMap.matchId')}:</span>
<div className="font-mono text-xs bg-muted px-2 py-1 rounded mt-1">
{match.ID}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground">{t('matchesMap.compatibility')}:</span>
<div className="font-medium">{formatScore(match.CompatibilityScore)}</div>
</div>
<div>
<span className="text-muted-foreground">{t('matchesMap.distance')}:</span>
<div className="font-medium">{match.DistanceKm.toFixed(1)} km</div>
</div>
</div>
<div>
<span className="text-muted-foreground">{t('matchesMap.economicValue')}:</span>
<div className="font-medium text-success">{formatCurrency(match.EconomicValue)}</div>
</div>
<div>
<span className="text-muted-foreground">{t('matchDetail.updateStatus')}:</span>
<span className={`ml-2 text-xs px-2 py-1 rounded ${
match.Status === 'live' ? 'bg-success/20 text-success' :
match.Status === 'contracted' ? 'bg-success/20 text-success' :
match.Status === 'negotiating' ? 'bg-warning/20 text-warning' :
'bg-muted text-muted-foreground'
}`}>
{t(`matchStatus.${match.Status}`, { defaultValue: match.Status })}
</span>
</div>
</div>
<button
onClick={onClick}
className="mt-3 w-full px-3 py-2 bg-primary text-primary-foreground rounded text-sm font-medium hover:bg-primary/90 transition-colors"
>
{t('matchesMap.viewMatchDetails')}
</button>
</div>
</Popup>
</Marker>
);
});
ResourceFlowMarker.displayName = 'ResourceFlowMarker';
const ResourceFlowMarkers: React.FC<ResourceFlowMarkersProps> = ({
matches,
selectedMatchId,
onMatchSelect
}) => {
// Transform matches into resource flow markers
const resourceFlowMarkers = useMemo(() => {
const markers: Array<{
id: string;
position: [number, number];
resourceType: string;
direction: string;
match: BackendMatch;
}> = [];
matches.forEach(match => {
// For now, we'll generate mock positions based on match ID
// In a real implementation, we'd get coordinates from site data
const baseLat = 55.1644; // Bugulma center
const baseLng = 50.2050;
const hash = match.ID.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const lat1 = baseLat + (hash % 100 - 50) * 0.01;
const lng1 = baseLng + ((hash * 7) % 100 - 50) * 0.01;
const lat2 = baseLat + ((hash * 13) % 100 - 50) * 0.01;
const lng2 = baseLng + ((hash * 17) % 100 - 50) * 0.01;
// Add source flow marker (assuming it's an output)
markers.push({
id: `${match.ID}-source`,
position: [lat1, lng1],
resourceType: 'heat', // Would come from match data
direction: 'output',
match,
});
// Add target flow marker (assuming it's an input)
markers.push({
id: `${match.ID}-target`,
position: [lat2, lng2],
resourceType: 'heat', // Would come from match data
direction: 'input',
match,
});
});
return markers;
}, [matches]);
return (
<MarkerClusterGroup
chunkedLoading={true}
chunkDelay={50}
chunkInterval={100}
maxClusterRadius={60}
spiderfyOnMaxZoom={true}
showCoverageOnHover={false}
zoomToBoundsOnClick={true}
disableClusteringAtZoom={16}
removeOutsideVisibleBounds={false}
animate={true}
animateAddingMarkers={true}
spiderfyDistanceMultiplier={2}
spiderLegPolylineOptions={{
weight: 1.5,
color: 'hsl(var(--muted-foreground))',
opacity: 0.5,
}}
iconCreateFunction={(cluster) => {
const count = cluster.getChildCount();
const size = count < 10 ? 'small' : count < 100 ? 'medium' : 'large';
const className = `marker-cluster marker-cluster-${size}`;
return L.divIcon({
html: `<div><span>${count}</span></div>`,
className,
iconSize: [40, 40],
});
}}
>
{resourceFlowMarkers.map(marker => (
<ResourceFlowMarker
key={marker.id}
position={marker.position}
resourceType={marker.resourceType}
direction={marker.direction}
match={marker.match}
isInSelectedMatch={marker.match.ID === selectedMatchId}
onClick={() => onMatchSelect(marker.match.ID)}
/>
))}
</MarkerClusterGroup>
);
};
export default React.memo(ResourceFlowMarkers);