Files
webapp/app/components/catalog/CatalogMap.vue
Ruslan Bakiev 28eff7c323
All checks were successful
Build Docker Image / build (push) Successful in 5m4s
feat(catalog): pad map fit and graph hubs filter
2026-02-07 10:18:07 +07:00

1066 lines
32 KiB
Vue

<template>
<div class="flex flex-col flex-1 min-h-0 w-full h-full">
<ClientOnly>
<MapboxMap
:map-id="mapId"
class="flex-1 min-h-0"
style="width: 100%; height: 100%"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds } from 'mapbox-gl'
import type { ClusterPointType } from '~/composables/graphql/public/geo-generated'
interface MapItem {
uuid?: string | null
name?: string | null
latitude?: number | null
longitude?: number | null
country?: string | null
}
export interface MapBounds {
west: number
south: number
east: number
north: number
zoom: number
}
interface HoveredItem {
latitude: number
longitude: number
}
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'
initialCenter?: [number, number]
initialZoom?: number
infoLoading?: boolean
fitPaddingLeft?: number
relatedPoints?: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}>
}>(), {
pointColor: '#f97316',
entityType: 'offer',
initialCenter: () => [37.64, 55.76],
initialZoom: 2,
useServerClustering: false,
infoLoading: false,
fitPaddingLeft: 0,
items: () => [],
clusteredPoints: () => [],
clusteredPointsByType: undefined,
relatedPoints: () => []
})
const emit = defineEmits<{
'select-item': [uuid: string, properties?: Record<string, any>]
'bounds-change': [bounds: MapBounds]
}>()
const mapRef = useMapboxRef(props.mapId)
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
})
const buildFitPadding = (base: number) => {
const extraLeft = Math.max(0, props.fitPaddingLeft || 0)
return {
top: base,
bottom: base,
left: base + extraLeft,
right: base
}
}
// Entity type icons - SVG data URLs with specific colors
const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => {
const icons = {
offer: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>`,
hub: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z"/><path d="M6 18h12"/><path d="M6 14h12"/><rect width="12" height="12" x="6" y="10"/></svg>`,
supplier: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7 5V8l-7 5V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M17 18h1"/><path d="M12 18h1"/><path d="M7 18h1"/></svg>`
}
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<void>((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)
})
}
// Standard colors for entity types
const ENTITY_COLORS = {
hub: '#22c55e', // green
supplier: '#3b82f6', // blue
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']
for (const type of types) {
const iconName = `related-icon-${type}`
if (map.hasImage(iconName)) {
map.removeImage(iconName)
}
const svg = createEntityIcon(type, ENTITY_COLORS[type])
const img = new Image(32, 32)
await new Promise<void>((resolve) => {
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.beginPath()
ctx.arc(16, 16, 15, 0, 2 * Math.PI)
ctx.fillStyle = ENTITY_COLORS[type]
ctx.fill()
ctx.strokeStyle = 'white'
ctx.lineWidth = 2
ctx.stroke()
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,
zoom: props.initialZoom,
projection: 'globe',
pitch: 20
}))
// Client-side clustering GeoJSON (when not using server clustering)
const geoJsonData = computed(() => ({
type: 'FeatureCollection' as const,
features: props.items
.filter(item => item.latitude != null && item.longitude != null)
.map(item => ({
type: 'Feature' as const,
properties: { uuid: item.uuid, name: item.name, country: item.country },
geometry: { type: 'Point' as const, coordinates: [item.longitude!, item.latitude!] }
}))
}))
// Server-side clustering GeoJSON
const serverClusteredGeoJson = computed(() => ({
type: 'FeatureCollection' as const,
features: (props.clusteredPoints || []).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
},
geometry: {
type: 'Point' as const,
coordinates: [point!.longitude ?? 0, point!.latitude ?? 0]
}
}))
}))
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,
features: props.hoveredItem ? [{
type: 'Feature' as const,
properties: {},
geometry: {
type: 'Point' as const,
coordinates: [props.hoveredItem.longitude, props.hoveredItem.latitude]
}
}] : []
}))
// 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 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 emitBoundsChange = (map: MapboxMapType) => {
const bounds = map.getBounds()
if (!bounds) return
emit('bounds-change', {
west: bounds.getWest(),
south: bounds.getSouth(),
east: bounds.getEast(),
north: bounds.getNorth(),
zoom: map.getZoom()
})
}
const onMapCreated = (map: MapboxMapType) => {
const initMap = async () => {
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.02,
'space-color': 'rgb(11, 11, 25)',
'star-intensity': 0.6
})
if (props.useServerClustering) {
if (usesTypedClusters.value) {
await initServerClusteringLayersByType(map)
} else {
await initServerClusteringLayers(map)
}
} else {
await initClientClusteringLayers(map)
}
// Emit initial bounds
emitBoundsChange(map)
// Emit bounds on move/zoom end
map.on('moveend', () => emitBoundsChange(map))
mapInitialized.value = true
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
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,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
})
map.addLayer({
id: 'clusters',
type: 'circle',
source: sourceId.value,
filter: ['has', 'point_count'],
paint: {
'circle-color': props.pointColor,
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 50, 40],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: sourceId.value,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 14
},
paint: { 'text-color': '#ffffff' }
})
map.addLayer({
id: 'unclustered-point',
type: 'symbol',
source: sourceId.value,
filter: ['!', ['has', 'point_count']],
layout: {
'icon-image': `entity-icon-${props.entityType}`,
'icon-size': 1,
'icon-allow-overlap': true
}
})
map.addLayer({
id: 'point-labels',
type: 'symbol',
source: sourceId.value,
filter: ['!', ['has', 'point_count']],
layout: {
'text-field': ['get', 'name'],
'text-offset': [0, 1.8],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1.5
}
})
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
const feature = features[0]
if (!feature) return
const clusterId = feature.properties?.cluster_id
const source = map.getSource(sourceId.value) 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', 'unclustered-point', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
const feature = features[0]
if (!feature) return
emit('select-item', feature.properties?.uuid)
})
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'unclustered-point', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'unclustered-point', () => { map.getCanvas().style.cursor = '' })
// Hovered point layer (on top of everything) - "target" effect with border
map.addSource(hoveredSourceId.value, {
type: 'geojson',
data: hoveredPointGeoJson.value
})
// Outer ring (white)
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'
}
})
// Inner point (same as entity color)
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 (for Info mode - icons by type)
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' // default
],
'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
}
})
// Click handlers for related points
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 = ''
})
// Auto-fit bounds to all items
if (!didFitBounds.value && props.items.length > 0) {
const bounds = new LngLatBounds()
props.items.forEach(item => {
if (item.longitude != null && item.latitude != null) {
bounds.extend([item.longitude, item.latitude])
}
})
map.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 10 })
didFitBounds.value = true
}
}
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) - circle with count
map.addLayer({
id: 'server-clusters',
type: 'circle',
source: sourceId.value,
filter: ['>', ['get', 'count'], 1],
paint: {
'circle-color': props.pointColor,
'circle-radius': ['step', ['get', 'count'], 20, 10, 30, 50, 40],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'server-cluster-count',
type: 'symbol',
source: sourceId.value,
filter: ['>', ['get', 'count'], 1],
layout: {
'text-field': ['get', 'count'],
'text-size': 14
},
paint: { 'text-color': '#ffffff' }
})
// Individual points (count == 1) - icon with entity type
map.addLayer({
id: 'server-points',
type: 'symbol',
source: sourceId.value,
filter: ['==', ['get', 'count'], 1],
layout: {
'icon-image': `entity-icon-${props.entityType}`,
'icon-size': 1,
'icon-allow-overlap': true
}
})
map.addLayer({
id: 'server-point-labels',
type: 'symbol',
source: sourceId.value,
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']
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1.5
}
})
// Click on cluster to zoom in
map.on('click', 'server-clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['server-clusters'] })
const feature = features[0]
if (!feature) return
const expansionZoom = feature.properties?.expansionZoom
const geometry = feature.geometry as GeoJSON.Point
map.easeTo({
center: geometry.coordinates as [number, number],
zoom: expansionZoom || map.getZoom() + 2
})
})
// Click on individual point - emit full properties
map.on('click', 'server-points', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['server-points'] })
const feature = features[0]
if (!feature) return
const props = feature.properties || {}
emit('select-item', props.id, props)
})
map.on('mouseenter', 'server-clusters', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'server-clusters', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'server-points', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'server-points', () => { map.getCanvas().style.cursor = '' })
// Hovered point layer (on top of everything) - "target" effect with border
map.addSource(hoveredSourceId.value, {
type: 'geojson',
data: hoveredPointGeoJson.value
})
// Outer ring (white)
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'
}
})
// Inner point (same as entity color)
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 (for Info mode - icons by type)
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' // default
],
'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
}
})
// Click handlers for related points
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 = ''
})
}
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: {}
})
map.addLayer({
id: clusterCountLayerId,
type: 'symbol',
source: sourceIdByType,
filter: ['>', ['get', 'count'], 1],
layout: {
'text-field': ['get', 'count'],
'text-size': 14
},
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
}
})
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']
},
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 expansionZoom = feature.properties?.expansionZoom
const geometry = feature.geometry as GeoJSON.Point
map.easeTo({
center: geometry.coordinates as [number, number],
zoom: expansionZoom || map.getZoom() + 2
})
})
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
const source = mapRef.value.getSource(hoveredSourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(hoveredPointGeoJson.value)
}
}, { deep: true })
// Update related points layer when relatedPoints changes
watch(() => props.relatedPoints, () => {
if (!mapRef.value || !mapInitialized.value) return
// Update the source data immediately
const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(relatedPointsGeoJson.value)
}
}, { deep: true })
// no visibility toggling; layers are data-driven by query
// 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)
if (wasLoading && !loading && props.relatedPoints && props.relatedPoints.length > 0) {
if (!mapRef.value) return
const bounds = new LngLatBounds()
props.relatedPoints.forEach(p => {
bounds.extend([p.longitude, p.latitude])
})
if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(80), maxZoom: 12 })
}
}
})
// 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 (usesTypedClusters.value) {
return
}
if (map.getLayer('server-clusters')) {
map.setPaintProperty('server-clusters', 'circle-color', newColor)
}
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 (usesTypedClusters.value) return
if (!mapRef.value || !mapInitialized.value) return
if (!didFitBounds.value && points && points.length > 0) {
const bounds = new LngLatBounds()
points.forEach(p => {
if (p?.longitude && p?.latitude) {
bounds.extend([p.longitude, p.latitude])
}
})
if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
didFitBounds.value = true
}
}
}, { immediate: true })
watch(() => props.clusteredPointsByType, () => {
if (!usesTypedClusters.value) return
if (!mapRef.value || !mapInitialized.value) return
if (didFitBounds.value) return
const bounds = new LngLatBounds()
CLUSTER_TYPES.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: buildFitPadding(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
await flyThroughSpace(mapRef.value, {
targetCenter: [lng, lat],
targetZoom: zoom,
totalDuration: 5000,
minZoom: 3
})
}
defineExpose({ flyTo })
</script>