diff --git a/app/components/catalog/CatalogMap.vue b/app/components/catalog/CatalogMap.vue index 2d51278..e7eac1f 100644 --- a/app/components/catalog/CatalogMap.vue +++ b/app/components/catalog/CatalogMap.vue @@ -48,10 +48,12 @@ const props = withDefaults(defineProps<{ hoveredItemId?: string | null hoveredItem?: HoveredItem | null pointColor?: string + entityType?: 'offer' | 'hub' | 'supplier' initialCenter?: [number, number] initialZoom?: number }>(), { pointColor: '#f97316', + entityType: 'offer', initialCenter: () => [37.64, 55.76], initialZoom: 2, useServerClustering: false, @@ -69,6 +71,55 @@ const { flyThroughSpace } = useMapboxFlyAnimation() const didFitBounds = ref(false) const mapInitialized = ref(false) +// Entity type icons - SVG data URLs with specific colors +const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => { + const icons = { + offer: ``, + hub: ``, + supplier: `` + } + return icons[type] +} + +// Load icon into map as image +const loadEntityIcon = async (map: MapboxMapType, type: 'offer' | 'hub' | 'supplier', color: string) => { + const iconName = `entity-icon-${type}` + if (map.hasImage(iconName)) { + map.removeImage(iconName) + } + + const svg = createEntityIcon(type, color) + const img = new Image(32, 32) + + return new Promise((resolve) => { + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = 32 + canvas.height = 32 + const ctx = canvas.getContext('2d') + if (ctx) { + // Draw colored circle background + ctx.beginPath() + ctx.arc(16, 16, 15, 0, 2 * Math.PI) + ctx.fillStyle = color + ctx.fill() + ctx.strokeStyle = 'white' + ctx.lineWidth = 2 + ctx.stroke() + // Draw icon on top + ctx.drawImage(img, 4, 4, 24, 24) + } + + const imageData = ctx?.getImageData(0, 0, 32, 32) + if (imageData) { + map.addImage(iconName, { width: 32, height: 32, data: imageData.data }) + } + resolve() + } + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + }) +} + const mapOptions = computed(() => ({ style: 'mapbox://styles/mapbox/satellite-streets-v12', center: props.initialCenter, @@ -135,7 +186,7 @@ const emitBoundsChange = (map: MapboxMapType) => { } const onMapCreated = (map: MapboxMapType) => { - const initMap = () => { + const initMap = async () => { map.setFog({ color: 'rgb(186, 210, 235)', 'high-color': 'rgb(36, 92, 223)', @@ -145,9 +196,9 @@ const onMapCreated = (map: MapboxMapType) => { }) if (props.useServerClustering) { - initServerClusteringLayers(map) + await initServerClusteringLayers(map) } else { - initClientClusteringLayers(map) + await initClientClusteringLayers(map) } // Emit initial bounds @@ -166,7 +217,10 @@ const onMapCreated = (map: MapboxMapType) => { } } -const initClientClusteringLayers = (map: MapboxMapType) => { +const initClientClusteringLayers = async (map: MapboxMapType) => { + // Load entity icon first + await loadEntityIcon(map, props.entityType, props.pointColor) + map.addSource(sourceId.value, { type: 'geojson', data: geoJsonData.value, @@ -203,14 +257,13 @@ const initClientClusteringLayers = (map: MapboxMapType) => { map.addLayer({ id: 'unclustered-point', - type: 'circle', + type: 'symbol', source: sourceId.value, filter: ['!', ['has', 'point_count']], - paint: { - 'circle-radius': 12, - 'circle-color': props.pointColor, - 'circle-stroke-width': 3, - 'circle-stroke-color': '#ffffff' + layout: { + 'icon-image': `entity-icon-${props.entityType}`, + 'icon-size': 1, + 'icon-allow-overlap': true } }) @@ -221,7 +274,7 @@ const initClientClusteringLayers = (map: MapboxMapType) => { filter: ['!', ['has', 'point_count']], layout: { 'text-field': ['get', 'name'], - 'text-offset': [0, 1.5], + 'text-offset': [0, 1.8], 'text-size': 12, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, @@ -296,13 +349,16 @@ const initClientClusteringLayers = (map: MapboxMapType) => { } } -const initServerClusteringLayers = (map: MapboxMapType) => { +const initServerClusteringLayers = async (map: MapboxMapType) => { + // Load entity icon first + await loadEntityIcon(map, props.entityType, props.pointColor) + map.addSource(sourceId.value, { type: 'geojson', data: serverClusteredGeoJson.value }) - // Clusters (count > 1) + // Clusters (count > 1) - circle with count map.addLayer({ id: 'server-clusters', type: 'circle', @@ -329,17 +385,16 @@ const initServerClusteringLayers = (map: MapboxMapType) => { paint: { 'text-color': '#ffffff' } }) - // Individual points (count == 1) + // Individual points (count == 1) - icon with entity type map.addLayer({ id: 'server-points', - type: 'circle', + type: 'symbol', source: sourceId.value, filter: ['==', ['get', 'count'], 1], - paint: { - 'circle-radius': 12, - 'circle-color': props.pointColor, - 'circle-stroke-width': 3, - 'circle-stroke-color': '#ffffff' + layout: { + 'icon-image': `entity-icon-${props.entityType}`, + 'icon-size': 1, + 'icon-allow-overlap': true } }) @@ -350,7 +405,7 @@ const initServerClusteringLayers = (map: MapboxMapType) => { filter: ['==', ['get', 'count'], 1], layout: { 'text-field': ['get', 'name'], - 'text-offset': [0, 1.5], + 'text-offset': [0, 1.8], 'text-size': 12, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, @@ -434,6 +489,38 @@ watch(() => props.hoveredItem, () => { } }, { deep: true }) +// Watch for pointColor or entityType changes - update colors and icons +watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => { + if (!mapRef.value || !mapInitialized.value) return + const map = mapRef.value + + // Reload icon with new color and type + await loadEntityIcon(map, newType, newColor) + + // Update cluster circle colors + if (props.useServerClustering) { + if (map.getLayer('server-clusters')) { + map.setPaintProperty('server-clusters', 'circle-color', newColor) + } + // Update icon reference for points + if (map.getLayer('server-points')) { + map.setLayoutProperty('server-points', 'icon-image', `entity-icon-${newType}`) + } + } else { + if (map.getLayer('clusters')) { + map.setPaintProperty('clusters', 'circle-color', newColor) + } + if (map.getLayer('unclustered-point')) { + map.setLayoutProperty('unclustered-point', 'icon-image', `entity-icon-${newType}`) + } + } + + // Update hovered point color + if (map.getLayer('hovered-point-layer')) { + map.setPaintProperty('hovered-point-layer', 'circle-color', newColor) + } +}) + // fitBounds for server clustering when first data arrives watch(() => props.clusteredPoints, (points) => { if (!mapRef.value || !mapInitialized.value) return diff --git a/app/components/page/CatalogPage.vue b/app/components/page/CatalogPage.vue index 1075033..1e53c72 100644 --- a/app/components/page/CatalogPage.vue +++ b/app/components/page/CatalogPage.vue @@ -20,6 +20,7 @@ :clustered-points="useServerClustering ? clusteredNodes : []" :use-server-clustering="useServerClustering" :point-color="activePointColor" + :entity-type="activeEntityType" :hovered-item-id="hoveredId" :hovered-item="hoveredItem" @select-item="onMapSelect" @@ -28,6 +29,15 @@ + +
+ + {{ $t('common.loading') }} +
+