turash/bugulma/frontend/utils/map/iconCache.ts
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

306 lines
9.7 KiB
TypeScript

import { DivIcon } from 'leaflet';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Building } from 'lucide-react';
import { getOrganizationIconSvg } from '@/components/map/organizationIcons';
import { mapDatabaseSubtypeToSchemaSubtype } from '@/lib/organizationSubtypeMapper.ts';
import type { OrganizationSubtype } from '@/schemas/organizationSubtype.ts';
/**
* Icon cache to prevent recreating icons on every render
* Uses WeakMap for automatic garbage collection when icons are no longer needed
*/
const iconCache = new WeakMap<object, Map<string, DivIcon>>();
/**
* Create a cache key for an icon based on its properties
*/
function createIconCacheKey(
orgId: string,
isSelected: boolean,
isHovered: boolean,
logoUrl?: string | null
): string {
return `${orgId}-${isSelected}-${isHovered}-${logoUrl || 'no-logo'}`;
}
/**
* Get or create a cached icon for an organization marker
* Includes error handling for edge cases
*/
/**
* Get computed CSS variable value
* This ensures CSS variables resolve properly in inline HTML
*/
function getCSSVariableValue(variableName: string, fallback: string = '#000000'): string {
if (typeof window === 'undefined') return fallback;
try {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(variableName)
.trim();
return value || fallback;
} catch {
return fallback;
}
}
/**
* Get sector color from CSS variable or fallback
*/
function getSectorColor(colorKey: string | undefined): string {
if (!colorKey) return '#6b7280'; // gray fallback
const cssVar = `--sector-${colorKey}`;
const color = getCSSVariableValue(cssVar);
// If we got a hex color, use it directly; otherwise try to parse
if (color.startsWith('#')) {
return color;
}
// Fallback colors for common sectors
const fallbackColors: Record<string, string> = {
construction: '#5d7c9a',
production: '#2e7d32',
recreation: '#d97757',
logistics: '#7b68b8',
};
return fallbackColors[colorKey] || '#6b7280';
}
export function getCachedOrganizationIcon(
orgId: string,
org: { LogoURL?: string | null; Name: string; Subtype?: string | null | undefined },
sector: { icon?: React.ReactElement; colorKey?: string } | null,
isSelected: boolean,
isHovered: boolean
): DivIcon {
try {
// Use org object as WeakMap key for automatic cleanup
const orgCache = iconCache.get(org) || new Map<string, DivIcon>();
const cacheKey = createIconCacheKey(orgId, isSelected, isHovered, org.LogoURL);
// Return cached icon if available
const cached = orgCache.get(cacheKey);
if (cached) {
return cached;
}
// Create new icon - larger size for better visibility
// Base size increased for better visibility at all zoom levels
const size = Math.max(20, isSelected ? 36 : isHovered ? 28 : 26); // Ensure minimum size
// Ensure size is a valid number
if (!Number.isFinite(size) || size <= 0) {
console.warn('[IconCache] Invalid size calculated:', size, { isSelected, isHovered });
}
const borderWidth = isSelected || isHovered ? 3 : 2;
// Get actual color values instead of CSS variables for inline styles
const primaryColor = getCSSVariableValue('--primary', '#3b82f6');
const borderColorValue = isSelected || isHovered
? primaryColor
: getCSSVariableValue('--border', '#e5e7eb');
const bgColor = org.LogoURL
? getCSSVariableValue('--primary-foreground', '#ffffff')
: getSectorColor(sector?.colorKey);
// Render sector icon to HTML string if no logo
let iconHtml = '';
if (!org.LogoURL && sector?.icon && React.isValidElement(sector.icon)) {
try {
const iconSize = Math.max(12, size * 0.6); // Ensure minimum icon size
const iconElement = React.cloneElement(
sector.icon as React.ReactElement<{
width?: number;
height?: number;
className?: string;
style?: React.CSSProperties;
}>,
{
width: iconSize,
height: iconSize,
className: 'text-primary-foreground',
style: {
width: `${iconSize}px`,
height: `${iconSize}px`,
color: 'hsl(var(--primary-foreground))',
fill: 'currentColor',
},
}
);
iconHtml = renderToString(iconElement);
} catch {
// Fallback if icon rendering fails
iconHtml = '';
}
}
// Escape HTML in organization name for safety
const escapedName = org.Name.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
// Map database subtype to schema subtype
// Database has granular subtypes (retail, food_beverage, etc.)
// Schema expects broader categories (commercial, healthcare, etc.)
const schemaSubtype = mapDatabaseSubtypeToSchemaSubtype(org.Subtype);
// Create fallback HTML for when logo fails to load
// Use organization subtype icon based on mapped schema subtype
const iconColor = '#ffffff'; // White icons on colored backgrounds
const fallbackBgColor = getCSSVariableValue('--primary-foreground', '#ffffff');
let fallbackIconSvg: string;
// Prefer schema subtype icon (mapped from database subtype)
fallbackIconSvg = getOrganizationIconSvg(schemaSubtype, size, iconColor, bgColor);
const fallbackHtml =
`<div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background-color: ${bgColor}; border-radius: 50%;">
${fallbackIconSvg}
</div>`;
const html = `
<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background-color: ${bgColor};
border: ${borderWidth}px solid ${borderColorValue};
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
position: relative;
overflow: hidden;
">
${
org.LogoURL
? `<img src="${org.LogoURL}" alt="${escapedName}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;" loading="lazy" onerror="this.onerror=null;this.style.display='none';this.nextElementSibling&&(this.nextElementSibling.style.display='flex');" />
<div style="display:none;width:100%;height:100%;align-items:center;justify-content:center;">${fallbackHtml}</div>`
: fallbackHtml
}
</div>
`;
const icon = new DivIcon({
html,
className: 'custom-organization-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -size / 2],
});
// Debug logging in development
if (process.env.NODE_ENV === 'development') {
console.log(`[IconCache] Created icon for ${org.Name}:`, {
size,
bgColor,
borderColor: borderColorValue,
hasLogo: !!org.LogoURL,
sector: sector?.colorKey,
});
}
// Cache the icon
orgCache.set(cacheKey, icon);
iconCache.set(org, orgCache);
return icon;
} catch {
// Fallback icon in case of any error
const size = 24;
return new DivIcon({
html: `<div style="width: ${size}px; height: ${size}px; border-radius: 50%; background-color: hsl(var(--muted)); border: 2px solid hsl(var(--border));"></div>`,
className: 'custom-organization-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
}
/**
* Get or create a cached icon for a historical landmark marker
*/
export function getCachedHistoricalIcon(
landmarkId: string,
landmark: { id: string },
isSelected: boolean,
isHovered: boolean
): DivIcon {
const landmarkCache = iconCache.get(landmark) || new Map<string, DivIcon>();
const cacheKey = createIconCacheKey(landmarkId, isSelected, isHovered);
const cached = landmarkCache.get(cacheKey);
if (cached) {
return cached;
}
const size = isSelected ? 40 : isHovered ? 32 : 28;
const borderWidth = isSelected || isHovered ? 3 : 2;
// Get actual color values
const primaryColor = getCSSVariableValue('--primary', '#3b82f6');
const borderColorValue = isSelected || isHovered ? primaryColor : getCSSVariableValue('--border', '#e5e7eb');
const bgColor = '#8b5cf6'; // Purple/violet color for historical buildings
const iconColor = '#ffffff';
// Create a friendly building icon using Lucide
const iconSize = size * 0.7;
const buildingIcon = renderToString(
React.createElement(Building, {
size: iconSize,
color: iconColor,
strokeWidth: 1.5
})
);
const html = `
<div style="
width: ${size}px;
height: ${size}px;
border-radius: 8px;
background: linear-gradient(135deg, ${bgColor} 0%, ${bgColor}dd 100%);
border: ${borderWidth}px solid ${borderColorValue};
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25), 0 1px 3px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
position: relative;
overflow: visible;
">
${buildingIcon}
${isSelected || isHovered ? `
<div style="
position: absolute;
top: -${size * 0.15}px;
right: -${size * 0.15}px;
width: ${size * 0.3}px;
height: ${size * 0.3}px;
background: ${primaryColor};
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
"></div>
` : ''}
</div>
`;
const icon = new DivIcon({
html,
className: 'custom-historical-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -size / 2],
});
landmarkCache.set(cacheKey, icon);
iconCache.set(landmark, landmarkCache);
return icon;
}