From 4bdefc9ce999eda261eb8ed76a9d62a2a9fb0d94 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:19:48 +0700 Subject: [PATCH] Render map points by entity type --- app/components/catalog/CatalogMap.vue | 302 +++++++++++++++++++++++++- app/components/page/CatalogPage.vue | 64 +++++- app/pages/catalog/index.vue | 13 +- 3 files changed, 368 insertions(+), 11 deletions(-) diff --git a/app/components/catalog/CatalogMap.vue b/app/components/catalog/CatalogMap.vue index a77017a..033771e 100644 --- a/app/components/catalog/CatalogMap.vue +++ b/app/components/catalog/CatalogMap.vue @@ -44,11 +44,13 @@ const props = withDefaults(defineProps<{ mapId: string items?: MapItem[] clusteredPoints?: ClusterPointType[] + clusteredPointsByType?: Partial> useServerClustering?: boolean hoveredItemId?: string | null hoveredItem?: HoveredItem | null pointColor?: string entityType?: 'offer' | 'hub' | 'supplier' + visibleTypes?: Array<'offer' | 'hub' | 'supplier'> initialCenter?: [number, number] initialZoom?: number infoLoading?: boolean @@ -68,6 +70,8 @@ const props = withDefaults(defineProps<{ infoLoading: false, items: () => [], clusteredPoints: () => [], + clusteredPointsByType: undefined, + visibleTypes: undefined, relatedPoints: () => [] }) @@ -81,6 +85,11 @@ const { flyThroughSpace } = useMapboxFlyAnimation() const didFitBounds = ref(false) const mapInitialized = ref(false) +const usesTypedClusters = computed(() => { + const typed = props.clusteredPointsByType + return !!typed && Object.keys(typed).length > 0 +}) + // Entity type icons - SVG data URLs with specific colors const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => { const icons = { @@ -137,6 +146,8 @@ const ENTITY_COLORS = { offer: '#f97316' // orange } as const +const CLUSTER_TYPES: Array<'offer' | 'hub' | 'supplier'> = ['offer', 'hub', 'supplier'] + // Load all icons for related points (each type with its standard color) const loadRelatedPointIcons = async (map: MapboxMapType) => { const types: Array<'hub' | 'supplier' | 'offer'> = ['hub', 'supplier', 'offer'] @@ -216,6 +227,33 @@ const serverClusteredGeoJson = computed(() => ({ })) })) +const serverClusteredGeoJsonByType = computed(() => { + const build = (points: ClusterPointType[] | undefined, type: 'offer' | 'hub' | 'supplier') => ({ + type: 'FeatureCollection' as const, + features: (points || []).filter(Boolean).map(point => ({ + type: 'Feature' as const, + properties: { + id: point!.id, + name: point!.name, + count: point!.count ?? 1, + expansionZoom: point!.expansionZoom, + isCluster: (point!.count ?? 1) > 1, + type + }, + geometry: { + type: 'Point' as const, + coordinates: [point!.longitude ?? 0, point!.latitude ?? 0] + } + })) + }) + + return { + offer: build(props.clusteredPointsByType?.offer, 'offer'), + hub: build(props.clusteredPointsByType?.hub, 'hub'), + supplier: build(props.clusteredPointsByType?.supplier, 'supplier') + } +}) + // Hovered point GeoJSON (separate layer on top) const hoveredPointGeoJson = computed(() => ({ type: 'FeatureCollection' as const, @@ -256,6 +294,17 @@ const sourceId = computed(() => `${props.mapId}-points`) const hoveredSourceId = computed(() => `${props.mapId}-hovered`) const relatedSourceId = computed(() => `${props.mapId}-related`) +const getServerSourceId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}` +const getServerClusterLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-clusters` +const getServerClusterCountLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-cluster-count` +const getServerPointLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-points` +const getServerPointLabelLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-point-labels` + +const isTypeVisible = (type: 'offer' | 'hub' | 'supplier') => { + if (!props.visibleTypes || props.visibleTypes.length === 0) return true + return props.visibleTypes.includes(type) +} + const emitBoundsChange = (map: MapboxMapType) => { const bounds = map.getBounds() if (!bounds) return @@ -279,7 +328,11 @@ const onMapCreated = (map: MapboxMapType) => { }) if (props.useServerClustering) { - await initServerClusteringLayers(map) + if (usesTypedClusters.value) { + await initServerClusteringLayersByType(map) + } else { + await initServerClusteringLayers(map) + } } else { await initClientClusteringLayers(map) } @@ -677,15 +730,218 @@ const initServerClusteringLayers = async (map: MapboxMapType) => { }) } +const initServerClusteringLayersByType = async (map: MapboxMapType) => { + for (const type of CLUSTER_TYPES) { + await loadEntityIcon(map, type, ENTITY_COLORS[type]) + + const sourceIdByType = getServerSourceId(type) + map.addSource(sourceIdByType, { + type: 'geojson', + data: serverClusteredGeoJsonByType.value[type] + }) + + const clusterLayerId = getServerClusterLayerId(type) + const clusterCountLayerId = getServerClusterCountLayerId(type) + const pointLayerId = getServerPointLayerId(type) + const pointLabelLayerId = getServerPointLabelLayerId(type) + + map.addLayer({ + id: clusterLayerId, + type: 'circle', + source: sourceIdByType, + filter: ['>', ['get', 'count'], 1], + paint: { + 'circle-color': ENTITY_COLORS[type], + 'circle-radius': ['step', ['get', 'count'], 20, 10, 30, 50, 40], + 'circle-opacity': 0.8, + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + }, + layout: { + visibility: isTypeVisible(type) ? 'visible' : 'none' + } + }) + + map.addLayer({ + id: clusterCountLayerId, + type: 'symbol', + source: sourceIdByType, + filter: ['>', ['get', 'count'], 1], + layout: { + 'text-field': ['get', 'count'], + 'text-size': 14, + visibility: isTypeVisible(type) ? 'visible' : 'none' + }, + paint: { 'text-color': '#ffffff' } + }) + + map.addLayer({ + id: pointLayerId, + type: 'symbol', + source: sourceIdByType, + filter: ['==', ['get', 'count'], 1], + layout: { + 'icon-image': `entity-icon-${type}`, + 'icon-size': 1, + 'icon-allow-overlap': true, + visibility: isTypeVisible(type) ? 'visible' : 'none' + } + }) + + map.addLayer({ + id: pointLabelLayerId, + type: 'symbol', + source: sourceIdByType, + filter: ['==', ['get', 'count'], 1], + layout: { + 'text-field': ['get', 'name'], + 'text-offset': [0, 1.8], + 'text-size': 12, + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + visibility: isTypeVisible(type) ? 'visible' : 'none' + }, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': '#000000', + 'text-halo-width': 1.5 + } + }) + + map.on('click', clusterLayerId, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerId] }) + const feature = features[0] + if (!feature) return + const clusterId = feature.properties?.cluster_id + const source = map.getSource(sourceIdByType) as mapboxgl.GeoJSONSource + source.getClusterExpansionZoom(clusterId, (err, zoom) => { + if (err) return + const geometry = feature.geometry as GeoJSON.Point + map.easeTo({ center: geometry.coordinates as [number, number], zoom: zoom || 4 }) + }) + }) + + map.on('click', pointLayerId, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [pointLayerId] }) + const feature = features[0] + if (!feature) return + const props_data = feature.properties as Record | undefined + emit('select-item', props_data?.id, props_data) + }) + + map.on('mouseenter', clusterLayerId, () => { map.getCanvas().style.cursor = 'pointer' }) + map.on('mouseleave', clusterLayerId, () => { map.getCanvas().style.cursor = '' }) + map.on('mouseenter', pointLayerId, () => { map.getCanvas().style.cursor = 'pointer' }) + map.on('mouseleave', pointLayerId, () => { map.getCanvas().style.cursor = '' }) + } + + // Hovered point layer (on top of everything) + map.addSource(hoveredSourceId.value, { + type: 'geojson', + data: hoveredPointGeoJson.value + }) + map.addLayer({ + id: 'hovered-point-ring', + type: 'circle', + source: hoveredSourceId.value, + paint: { + 'circle-radius': 20, + 'circle-color': 'transparent', + 'circle-stroke-width': 3, + 'circle-stroke-color': '#ffffff' + } + }) + map.addLayer({ + id: 'hovered-point-layer', + type: 'circle', + source: hoveredSourceId.value, + paint: { + 'circle-radius': 14, + 'circle-color': props.pointColor, + 'circle-stroke-width': 3, + 'circle-stroke-color': '#ffffff' + } + }) + + // Related points layer + await loadRelatedPointIcons(map) + + map.addSource(relatedSourceId.value, { + type: 'geojson', + data: relatedPointsGeoJson.value + }) + map.addLayer({ + id: `${props.mapId}-related-points`, + type: 'symbol', + source: relatedSourceId.value, + layout: { + 'icon-image': [ + 'match', + ['get', 'type'], + 'hub', 'related-icon-hub', + 'supplier', 'related-icon-supplier', + 'offer', 'related-icon-offer', + 'related-icon-offer' + ], + 'icon-size': 1, + 'icon-allow-overlap': true + } + }) + map.addLayer({ + id: `${props.mapId}-related-labels`, + type: 'symbol', + source: relatedSourceId.value, + layout: { + 'text-field': ['get', 'name'], + 'text-size': 11, + 'text-anchor': 'top', + 'text-offset': [0, 1.5] + }, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': '#000000', + 'text-halo-width': 1 + } + }) + + map.on('click', `${props.mapId}-related-points`, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-points`] }) + const feature = features[0] + if (!feature) return + const props_data = feature.properties as Record | undefined + emit('select-item', props_data?.uuid, props_data) + }) + + map.on('mouseenter', `${props.mapId}-related-points`, () => { + map.getCanvas().style.cursor = 'pointer' + }) + + map.on('mouseleave', `${props.mapId}-related-points`, () => { + map.getCanvas().style.cursor = '' + }) +} + // Update map data when items or clusteredPoints change watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonData.value, (newData) => { if (!mapRef.value || !mapInitialized.value) return + if (usesTypedClusters.value) return const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined if (source) { source.setData(newData) } }, { deep: true }) +watch(() => serverClusteredGeoJsonByType.value, (newData) => { + if (!mapRef.value || !mapInitialized.value) return + if (!usesTypedClusters.value) return + for (const type of CLUSTER_TYPES) { + const sourceIdByType = getServerSourceId(type) + const source = mapRef.value.getSource(sourceIdByType) as mapboxgl.GeoJSONSource | undefined + if (source) { + source.setData(newData[type]) + } + } +}, { deep: true }) + // Update hovered point layer when hoveredItem changes watch(() => props.hoveredItem, () => { if (!mapRef.value || !mapInitialized.value) return @@ -706,6 +962,25 @@ watch(() => props.relatedPoints, () => { } }, { deep: true }) +watch(() => props.visibleTypes, () => { + if (!mapRef.value || !mapInitialized.value) return + if (!usesTypedClusters.value) return + for (const type of CLUSTER_TYPES) { + const visibility = isTypeVisible(type) ? 'visible' : 'none' + const layers = [ + getServerClusterLayerId(type), + getServerClusterCountLayerId(type), + getServerPointLayerId(type), + getServerPointLabelLayerId(type) + ] + layers.forEach((layerId) => { + if (mapRef.value?.getLayer(layerId)) { + mapRef.value.setLayoutProperty(layerId, 'visibility', visibility) + } + }) + } +}, { deep: true }) + // Fit bounds when info loading finishes (all related data loaded) watch(() => props.infoLoading, (loading, wasLoading) => { // Only fit bounds when loading changes from true to false (data finished loading) @@ -731,10 +1006,12 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp // Update cluster circle colors if (props.useServerClustering) { + if (usesTypedClusters.value) { + return + } 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}`) } @@ -755,6 +1032,7 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp // fitBounds for server clustering when first data arrives watch(() => props.clusteredPoints, (points) => { + if (usesTypedClusters.value) return if (!mapRef.value || !mapInitialized.value) return if (!didFitBounds.value && points && points.length > 0) { const bounds = new LngLatBounds() @@ -770,6 +1048,26 @@ watch(() => props.clusteredPoints, (points) => { } }, { immediate: true }) +watch([() => props.clusteredPointsByType, () => props.visibleTypes], () => { + if (!usesTypedClusters.value) return + if (!mapRef.value || !mapInitialized.value) return + if (didFitBounds.value) return + + const bounds = new LngLatBounds() + const visible = props.visibleTypes && props.visibleTypes.length > 0 ? props.visibleTypes : CLUSTER_TYPES + visible.forEach(type => { + const points = serverClusteredGeoJsonByType.value[type]?.features ?? [] + points.forEach((p) => { + const coords = (p.geometry as GeoJSON.Point).coordinates as [number, number] + bounds.extend(coords) + }) + }) + if (!bounds.isEmpty()) { + mapRef.value.fitBounds(bounds, { padding: 50, maxZoom: 6 }) + didFitBounds.value = true + } +}, { deep: true, immediate: true }) + // Expose flyTo method for external use (with space fly animation) const flyTo = async (lat: number, lng: number, zoom = 8) => { if (!mapRef.value) return diff --git a/app/components/page/CatalogPage.vue b/app/components/page/CatalogPage.vue index 74e46f8..76421d6 100644 --- a/app/components/page/CatalogPage.vue +++ b/app/components/page/CatalogPage.vue @@ -17,7 +17,9 @@ ref="mapRef" :map-id="mapId" :items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)" - :clustered-points="isInfoMode ? [] : (useServerClustering ? clusteredNodes : [])" + :clustered-points="isInfoMode ? [] : (useServerClustering && !useTypedClusters ? clusteredNodes : [])" + :clustered-points-by-type="isInfoMode ? undefined : (useServerClustering && useTypedClusters ? clusteredPointsByType : undefined)" + :visible-types="useServerClustering && useTypedClusters ? visibleTypes : undefined" :use-server-clustering="useServerClustering && !isInfoMode" :point-color="activePointColor" :entity-type="activeEntityType" @@ -246,6 +248,7 @@ const props = withDefaults(defineProps<{ loading?: boolean useServerClustering?: boolean clusterNodeType?: string + useTypedClusters?: boolean mapId?: string pointColor?: string hoveredId?: string @@ -266,6 +269,7 @@ const props = withDefaults(defineProps<{ loading: false, useServerClustering: true, clusterNodeType: 'offer', + useTypedClusters: false, mapId: 'catalog-map', pointColor: '#f97316', items: () => [], @@ -284,11 +288,59 @@ const emit = defineEmits<{ 'update:filter-by-bounds': [value: boolean] }>() -// Server-side clustering - use computed node type based on view mode -const { clusteredNodes, fetchClusters, loading: clusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType) +const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering) + +// Server-side clustering (single-type mode) +const { clusteredNodes, fetchClusters, loading: singleClusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType) + +// Server-side clustering (typed mode) +const offerClusters = useClusteredNodes(undefined, ref('offer')) +const hubClusters = useClusteredNodes(undefined, ref('logistics')) +const supplierClusters = useClusteredNodes(undefined, ref('supplier')) + +const clusteredPointsByType = computed(() => ({ + offer: offerClusters.clusteredNodes.value, + hub: hubClusters.clusteredNodes.value, + supplier: supplierClusters.clusteredNodes.value +})) + +const activeClusterType = computed<'offer' | 'hub' | 'supplier'>(() => { + if (mapViewMode.value === 'hubs') return 'hub' + if (mapViewMode.value === 'suppliers') return 'supplier' + return 'offer' +}) + +const visibleTypes = computed(() => [activeClusterType.value]) + +const clusterLoading = computed(() => { + if (!useTypedClusters.value) return singleClusterLoading.value + if (activeClusterType.value === 'hub') return hubClusters.loading.value + if (activeClusterType.value === 'supplier') return supplierClusters.loading.value + return offerClusters.loading.value +}) + +const fetchActiveClusters = async () => { + if (!currentBounds.value) return + if (activeClusterType.value === 'hub') { + await hubClusters.fetchClusters(currentBounds.value) + return + } + if (activeClusterType.value === 'supplier') { + await supplierClusters.fetchClusters(currentBounds.value) + return + } + await offerClusters.fetchClusters(currentBounds.value) +} // Refetch clusters when view mode changes watch(mapViewMode, async () => { + if (!props.useServerClustering) return + if (useTypedClusters.value) { + if (currentBounds.value) { + await fetchActiveClusters() + } + return + } // Clear old data first clearNodes() // Refetch with current bounds if available @@ -338,7 +390,11 @@ const onBoundsChange = (bounds: MapBounds) => { emit('bounds-change', bounds) // Don't fetch clusters when in info mode if (props.useServerClustering && !isInfoMode.value) { - fetchClusters(bounds) + if (useTypedClusters.value) { + fetchActiveClusters() + } else { + fetchClusters(bounds) + } } } diff --git a/app/pages/catalog/index.vue b/app/pages/catalog/index.vue index e87e434..9480caf 100644 --- a/app/pages/catalog/index.vue +++ b/app/pages/catalog/index.vue @@ -4,6 +4,7 @@ ref="catalogPageRef" :loading="isLoading" :use-server-clustering="true" + :use-typed-clusters="true" :cluster-node-type="clusterNodeType" map-id="unified-catalog-map" :point-color="mapPointColor" @@ -410,10 +411,12 @@ const onMapSelect = async (item: MapSelectItem) => { const itemName = item.name || itemId.slice(0, 8) + '...' + const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type + // If in selection mode, use map click to fill the selector if (selectMode.value) { // For hubs selection - click on hub fills hub selector - if (selectMode.value === 'hub' && mapViewMode.value === 'hubs') { + if (selectMode.value === 'hub' && (itemType === 'hub' || mapViewMode.value === 'hubs')) { selectItem('hub', itemId, itemName) showQuoteResults.value = false offers.value = [] @@ -421,7 +424,7 @@ const onMapSelect = async (item: MapSelectItem) => { } // For supplier selection - click on supplier fills supplier selector - if (selectMode.value === 'supplier' && mapViewMode.value === 'suppliers') { + if (selectMode.value === 'supplier' && (itemType === 'supplier' || mapViewMode.value === 'suppliers')) { selectItem('supplier', itemId, itemName) showQuoteResults.value = false offers.value = [] @@ -429,7 +432,7 @@ const onMapSelect = async (item: MapSelectItem) => { } // For product selection viewing offers - fetch offer to get productUuid - if (selectMode.value === 'product' && mapViewMode.value === 'offers') { + if (selectMode.value === 'product' && (itemType === 'offer' || mapViewMode.value === 'offers')) { // Fetch offer details to get productUuid (not available in cluster data) const data = await execute(GetOfferDocument, { uuid: itemId }, 'public', 'exchange') const offer = data?.getOffer @@ -444,8 +447,8 @@ const onMapSelect = async (item: MapSelectItem) => { // NEW: Default behavior - open Info directly let infoType: 'hub' | 'supplier' | 'offer' - if (mapViewMode.value === 'hubs') infoType = 'hub' - else if (mapViewMode.value === 'suppliers') infoType = 'supplier' + if (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub' + else if (itemType === 'supplier' || mapViewMode.value === 'suppliers') infoType = 'supplier' else infoType = 'offer' openInfo(infoType, itemId)