Add Info panel for catalog with tabbed interface

Implemented Info mode для детального просмотра объектов каталога (hub/supplier/offer) с навигацией между связанными объектами.

Новые компоненты:
- InfoPanel.vue - панель с детальной информацией и табами для связанных объектов
- useCatalogInfo.ts - composable для управления Info state и загрузки данных

Изменения:
- useCatalogSearch.ts - добавлен infoId state и функции openInfo/closeInfo
- catalog/index.vue - интеграция InfoPanel, обработчики событий, relatedPoints для карты
- CatalogPage.vue - проброс relatedPoints в CatalogMap
- CatalogMap.vue - related points layer (cyan circles) для отображения связанных объектов

Флоу:
1. Клик на чип → Selection → Выбор → Info открывается
2. Клик на карту → Info открывается напрямую
3. В Info показываются табы со связанными объектами (top-12)
4. Клик на связанный объект → навигация к его Info
5. Кнопка "Добавить в фильтр" - добавляет объект в chips

URL sharing: ?info=type:uuid для шаринга ссылок

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-01-25 14:17:47 +07:00
parent 9b99d8981c
commit 2ce3bd0bd2
6 changed files with 882 additions and 12 deletions

View File

@@ -51,6 +51,13 @@ const props = withDefaults(defineProps<{
entityType?: 'offer' | 'hub' | 'supplier'
initialCenter?: [number, number]
initialZoom?: number
relatedPoints?: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}>
}>(), {
pointColor: '#f97316',
entityType: 'offer',
@@ -58,7 +65,8 @@ const props = withDefaults(defineProps<{
initialZoom: 2,
useServerClustering: false,
items: () => [],
clusteredPoints: () => []
clusteredPoints: () => [],
relatedPoints: () => []
})
const emit = defineEmits<{
@@ -170,8 +178,32 @@ const hoveredPointGeoJson = computed(() => ({
}] : []
}))
// Related points GeoJSON (for Info mode)
const relatedPointsGeoJson = computed(() => {
if (!props.relatedPoints || props.relatedPoints.length === 0) {
return { type: 'FeatureCollection' as const, features: [] }
}
return {
type: 'FeatureCollection' as const,
features: props.relatedPoints.map(point => ({
type: 'Feature' as const,
properties: {
uuid: point.uuid,
name: point.name,
type: point.type
},
geometry: {
type: 'Point' as const,
coordinates: [point.longitude, point.latitude]
}
}))
}
})
const sourceId = computed(() => `${props.mapId}-points`)
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
const relatedSourceId = computed(() => `${props.mapId}-related`)
const emitBoundsChange = (map: MapboxMapType) => {
const bounds = map.getBounds()
@@ -338,6 +370,55 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
}
})
// Related points layer (for Info mode - smaller cyan circles)
map.addSource(relatedSourceId.value, {
type: 'geojson',
data: relatedPointsGeoJson.value
})
map.addLayer({
id: `${props.mapId}-related-circles`,
type: 'circle',
source: relatedSourceId.value,
paint: {
'circle-radius': 8,
'circle-color': '#06b6d4', // cyan
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
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.2]
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1
}
})
// Click handlers for related points
map.on('click', `${props.mapId}-related-circles`, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] })
if (!features.length) return
const props_data = features[0].properties
emit('select-item', props_data?.uuid, props_data)
})
map.on('mouseenter', `${props.mapId}-related-circles`, () => {
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', `${props.mapId}-related-circles`, () => {
map.getCanvas().style.cursor = ''
})
// Auto-fit bounds to all items
if (!didFitBounds.value && props.items.length > 0) {
const bounds = new LngLatBounds()
@@ -470,6 +551,55 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
'circle-stroke-color': '#ffffff'
}
})
// Related points layer (for Info mode - smaller cyan circles)
map.addSource(relatedSourceId.value, {
type: 'geojson',
data: relatedPointsGeoJson.value
})
map.addLayer({
id: `${props.mapId}-related-circles`,
type: 'circle',
source: relatedSourceId.value,
paint: {
'circle-radius': 8,
'circle-color': '#06b6d4', // cyan
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
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.2]
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1
}
})
// Click handlers for related points
map.on('click', `${props.mapId}-related-circles`, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] })
if (!features.length) return
const props_data = features[0].properties
emit('select-item', props_data?.uuid, props_data)
})
map.on('mouseenter', `${props.mapId}-related-circles`, () => {
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', `${props.mapId}-related-circles`, () => {
map.getCanvas().style.cursor = ''
})
}
// Update map data when items or clusteredPoints change
@@ -490,6 +620,15 @@ watch(() => props.hoveredItem, () => {
}
}, { deep: true })
// Update related points layer when relatedPoints changes
watch(() => props.relatedPoints, () => {
if (!mapRef.value || !mapInitialized.value) return
const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(relatedPointsGeoJson.value)
}
}, { 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