Render map points by entity type
Some checks failed
Build Docker Image / build (push) Has been cancelled

This commit is contained in:
Ruslan Bakiev
2026-02-06 11:19:48 +07:00
parent fb29c2a4f6
commit 4bdefc9ce9
3 changed files with 368 additions and 11 deletions

View File

@@ -44,11 +44,13 @@ const props = withDefaults(defineProps<{
mapId: string
items?: MapItem[]
clusteredPoints?: ClusterPointType[]
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPointType[]>>
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<string, any> | 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<string, any> | 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

View File

@@ -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)
}
}
}

View File

@@ -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)