Fix map points: icons, color updates, loading state
All checks were successful
Build Docker Image / build (push) Successful in 3m41s
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -44,9 +44,14 @@ export function useClusteredNodes(
|
||||
}
|
||||
}
|
||||
|
||||
const clearNodes = () => {
|
||||
clusteredNodes.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
clusteredNodes,
|
||||
loading,
|
||||
fetchClusters
|
||||
fetchClusters,
|
||||
clearNodes
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user