Fix map points: icons, color updates, loading state
All checks were successful
Build Docker Image / build (push) Successful in 3m41s

- Add entityType prop to CatalogMap for icon selection
- Change circle layers to symbol layers with entity-specific icons
- Icons: shopping bag (offers), warehouse (hubs), factory (suppliers)
- Add watcher to update colors when pointColor/entityType changes
- Clear old points and show loading indicator when switching view modes
- Add clearNodes function to useClusteredNodes composable
This commit is contained in:
Ruslan Bakiev
2026-01-24 09:18:27 +07:00
parent 63d81ab42f
commit 5e55443975
3 changed files with 148 additions and 23 deletions

View File

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

View File

@@ -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 @@
</ClientOnly>
</div>
<!-- View mode loading indicator -->
<div
v-if="clusterLoading"
class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 bg-black/50 backdrop-blur-md rounded-full px-4 py-2 border border-white/20"
>
<span class="loading loading-spinner loading-sm text-white" />
<span class="text-white text-sm">{{ $t('common.loading') }}</span>
</div>
<!-- View toggle (top RIGHT overlay, below header) - works in both modes -->
<div class="absolute top-[116px] right-4 z-20 hidden lg:block">
<div class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
@@ -145,6 +155,15 @@ const VIEW_MODE_COLORS = {
const activePointColor = computed(() => VIEW_MODE_COLORS[mapViewMode.value] || VIEW_MODE_COLORS.offers)
// Entity type for icons based on view mode
const VIEW_MODE_ENTITY_TYPES = {
offers: 'offer',
hubs: 'hub',
suppliers: 'supplier'
} as const
const activeEntityType = computed(() => VIEW_MODE_ENTITY_TYPES[mapViewMode.value] || VIEW_MODE_ENTITY_TYPES.offers)
// Node type for server clustering based on view mode
const VIEW_MODE_NODE_TYPES = {
offers: 'offer',
@@ -154,6 +173,9 @@ const VIEW_MODE_NODE_TYPES = {
const activeClusterNodeType = computed(() => VIEW_MODE_NODE_TYPES[mapViewMode.value] || VIEW_MODE_NODE_TYPES.offers)
// Store current bounds for refetching when view mode changes
const currentBounds = ref<MapBounds | null>(null)
interface MapItem {
uuid: string
latitude?: number | null
@@ -189,7 +211,17 @@ const emit = defineEmits<{
}>()
// Server-side clustering - use computed node type based on view mode
const { clusteredNodes, fetchClusters } = useClusteredNodes(undefined, activeClusterNodeType)
const { clusteredNodes, fetchClusters, loading: clusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType)
// Refetch clusters when view mode changes
watch(mapViewMode, async () => {
// Clear old data first
clearNodes()
// Refetch with current bounds if available
if (currentBounds.value) {
await fetchClusters(currentBounds.value)
}
})
// Map refs
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
@@ -225,6 +257,7 @@ const itemsWithCoords = computed(() =>
)
const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
emit('bounds-change', bounds)
if (props.useServerClustering) {
fetchClusters(bounds)

View File

@@ -44,9 +44,14 @@ export function useClusteredNodes(
}
}
const clearNodes = () => {
clusteredNodes.value = []
}
return {
clusteredNodes,
loading,
fetchClusters
fetchClusters,
clearNodes
}
}