Compare commits

...

2 Commits

Author SHA1 Message Date
Ruslan Bakiev
b02e3882cc feat(catalog): add bounds filtering to list queries
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Add west/south/east/north params to HubsList, SuppliersList, ProductsList GraphQL
- Update useCatalogHubs to pass bounds to query
- Update useCatalogSuppliers to pass bounds to query
- Update useCatalogProducts to pass bounds to query
2026-01-26 21:37:23 +07:00
Ruslan Bakiev
c56bb57fbf fix(CatalogMap): use proper icons with colors for related points
- Add loadRelatedPointIcons to load icons for all entity types
- Change related points layer from circle to symbol with icons
- Each type (hub, supplier, offer) gets its standard color:
  - hub: green (#22c55e)
  - supplier: blue (#3b82f6)
  - offer: orange (#f97316)
2026-01-26 21:34:07 +07:00
7 changed files with 122 additions and 48 deletions

View File

@@ -128,6 +128,53 @@ const loadEntityIcon = async (map: MapboxMapType, type: 'offer' | 'hub' | 'suppl
}) })
} }
// Standard colors for entity types
const ENTITY_COLORS = {
hub: '#22c55e', // green
supplier: '#3b82f6', // blue
offer: '#f97316' // orange
} as const
// 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(() => ({ const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/satellite-streets-v12', style: 'mapbox://styles/mapbox/satellite-streets-v12',
center: props.initialCenter, center: props.initialCenter,
@@ -372,27 +419,28 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
} }
}) })
// Related points layer (for Info mode - colored by type) // Related points layer (for Info mode - icons by type)
await loadRelatedPointIcons(map)
map.addSource(relatedSourceId.value, { map.addSource(relatedSourceId.value, {
type: 'geojson', type: 'geojson',
data: relatedPointsGeoJson.value data: relatedPointsGeoJson.value
}) })
map.addLayer({ map.addLayer({
id: `${props.mapId}-related-circles`, id: `${props.mapId}-related-points`,
type: 'circle', type: 'symbol',
source: relatedSourceId.value, source: relatedSourceId.value,
paint: { layout: {
'circle-radius': 8, 'icon-image': [
'circle-color': [
'match', 'match',
['get', 'type'], ['get', 'type'],
'hub', '#22c55e', // green 'hub', 'related-icon-hub',
'supplier', '#3b82f6', // blue 'supplier', 'related-icon-supplier',
'offer', '#f97316', // orange 'offer', 'related-icon-offer',
'#06b6d4' // default cyan 'related-icon-offer' // default
], ],
'circle-stroke-width': 2, 'icon-size': 1,
'circle-stroke-color': '#ffffff' 'icon-allow-overlap': true
} }
}) })
map.addLayer({ map.addLayer({
@@ -403,7 +451,7 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 11, 'text-size': 11,
'text-anchor': 'top', 'text-anchor': 'top',
'text-offset': [0, 1.2] 'text-offset': [0, 1.5]
}, },
paint: { paint: {
'text-color': '#ffffff', 'text-color': '#ffffff',
@@ -413,19 +461,19 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
}) })
// Click handlers for related points // Click handlers for related points
map.on('click', `${props.mapId}-related-circles`, (e) => { map.on('click', `${props.mapId}-related-points`, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] }) const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-points`] })
const feature = features[0] const feature = features[0]
if (!feature) return if (!feature) return
const props_data = feature.properties as Record<string, any> | undefined const props_data = feature.properties as Record<string, any> | undefined
emit('select-item', props_data?.uuid, props_data) emit('select-item', props_data?.uuid, props_data)
}) })
map.on('mouseenter', `${props.mapId}-related-circles`, () => { map.on('mouseenter', `${props.mapId}-related-points`, () => {
map.getCanvas().style.cursor = 'pointer' map.getCanvas().style.cursor = 'pointer'
}) })
map.on('mouseleave', `${props.mapId}-related-circles`, () => { map.on('mouseleave', `${props.mapId}-related-points`, () => {
map.getCanvas().style.cursor = '' map.getCanvas().style.cursor = ''
}) })
@@ -564,27 +612,28 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
} }
}) })
// Related points layer (for Info mode - colored by type) // Related points layer (for Info mode - icons by type)
await loadRelatedPointIcons(map)
map.addSource(relatedSourceId.value, { map.addSource(relatedSourceId.value, {
type: 'geojson', type: 'geojson',
data: relatedPointsGeoJson.value data: relatedPointsGeoJson.value
}) })
map.addLayer({ map.addLayer({
id: `${props.mapId}-related-circles`, id: `${props.mapId}-related-points`,
type: 'circle', type: 'symbol',
source: relatedSourceId.value, source: relatedSourceId.value,
paint: { layout: {
'circle-radius': 8, 'icon-image': [
'circle-color': [
'match', 'match',
['get', 'type'], ['get', 'type'],
'hub', '#22c55e', // green 'hub', 'related-icon-hub',
'supplier', '#3b82f6', // blue 'supplier', 'related-icon-supplier',
'offer', '#f97316', // orange 'offer', 'related-icon-offer',
'#06b6d4' // default cyan 'related-icon-offer' // default
], ],
'circle-stroke-width': 2, 'icon-size': 1,
'circle-stroke-color': '#ffffff' 'icon-allow-overlap': true
} }
}) })
map.addLayer({ map.addLayer({
@@ -595,7 +644,7 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 11, 'text-size': 11,
'text-anchor': 'top', 'text-anchor': 'top',
'text-offset': [0, 1.2] 'text-offset': [0, 1.5]
}, },
paint: { paint: {
'text-color': '#ffffff', 'text-color': '#ffffff',
@@ -605,19 +654,19 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
}) })
// Click handlers for related points // Click handlers for related points
map.on('click', `${props.mapId}-related-circles`, (e) => { map.on('click', `${props.mapId}-related-points`, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] }) const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-points`] })
const feature = features[0] const feature = features[0]
if (!feature) return if (!feature) return
const props_data = feature.properties as Record<string, any> | undefined const props_data = feature.properties as Record<string, any> | undefined
emit('select-item', props_data?.uuid, props_data) emit('select-item', props_data?.uuid, props_data)
}) })
map.on('mouseenter', `${props.mapId}-related-circles`, () => { map.on('mouseenter', `${props.mapId}-related-points`, () => {
map.getCanvas().style.cursor = 'pointer' map.getCanvas().style.cursor = 'pointer'
}) })
map.on('mouseleave', `${props.mapId}-related-circles`, () => { map.on('mouseleave', `${props.mapId}-related-points`, () => {
map.getCanvas().style.cursor = '' map.getCanvas().style.cursor = ''
}) })
} }

View File

@@ -84,7 +84,13 @@ export function useCatalogHubs() {
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
transportType, transportType,
country country,
...(filterBounds.value && {
west: filterBounds.value.west,
south: filterBounds.value.south,
east: filterBounds.value.east,
north: filterBounds.value.north
})
}, },
'public', 'public',
'geo' 'geo'

View File

@@ -16,6 +16,7 @@ const isInitialized = ref(false)
// Filter state // Filter state
const filterSupplierUuid = ref<string | null>(null) const filterSupplierUuid = ref<string | null>(null)
const filterHubUuid = ref<string | null>(null) const filterHubUuid = ref<string | null>(null)
const filterBounds = ref<{ west: number; south: number; east: number; north: number } | null>(null)
export function useCatalogProducts() { export function useCatalogProducts() {
const { execute } = useGraphQL() const { execute } = useGraphQL()
@@ -117,7 +118,15 @@ export function useCatalogProducts() {
// All products from graph // All products from graph
data = await execute( data = await execute(
ProductsListDocument, ProductsListDocument,
{ limit: 500 }, {
limit: 500,
...(filterBounds.value && {
west: filterBounds.value.west,
south: filterBounds.value.south,
east: filterBounds.value.east,
north: filterBounds.value.north
})
},
'public', 'public',
'geo' 'geo'
) )
@@ -168,11 +177,12 @@ export function useCatalogProducts() {
} }
} }
// Products don't have coordinates directly (they're an aggregation of offers) // Products are filtered by offer locations within bounds
// Bounds filtering would require a new backend query that filters by offer locations const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
// For now, this is a no-op - products show all regardless of map bounds filterBounds.value = bounds
const setBoundsFilter = (_bounds: { west: number; south: number; east: number; north: number } | null) => { if (isInitialized.value) {
// No-op: products are not filterable by map bounds in current implementation fetchProducts()
}
} }
return { return {

View File

@@ -47,7 +47,16 @@ export function useCatalogSuppliers() {
// Default: fetch all suppliers from GEO (graph-based) // Default: fetch all suppliers from GEO (graph-based)
const data = await execute( const data = await execute(
SuppliersListDocument, SuppliersListDocument,
{ limit: PAGE_SIZE, offset }, {
limit: PAGE_SIZE,
offset,
...(filterBounds.value && {
west: filterBounds.value.west,
south: filterBounds.value.south,
east: filterBounds.value.east,
north: filterBounds.value.north
})
},
'public', 'public',
'geo' 'geo'
) )

View File

@@ -1,5 +1,5 @@
query HubsList($limit: Int, $offset: Int, $country: String, $transportType: String) { query HubsList($limit: Int, $offset: Int, $country: String, $transportType: String, $west: Float, $south: Float, $east: Float, $north: Float) {
hubsList(limit: $limit, offset: $offset, country: $country, transportType: $transportType) { hubsList(limit: $limit, offset: $offset, country: $country, transportType: $transportType, west: $west, south: $south, east: $east, north: $north) {
uuid uuid
name name
latitude latitude

View File

@@ -1,5 +1,5 @@
query ProductsList($limit: Int, $offset: Int) { query ProductsList($limit: Int, $offset: Int, $west: Float, $south: Float, $east: Float, $north: Float) {
productsList(limit: $limit, offset: $offset) { productsList(limit: $limit, offset: $offset, west: $west, south: $south, east: $east, north: $north) {
uuid uuid
name name
offersCount offersCount

View File

@@ -1,5 +1,5 @@
query SuppliersList($limit: Int, $offset: Int, $country: String) { query SuppliersList($limit: Int, $offset: Int, $country: String, $west: Float, $south: Float, $east: Float, $north: Float) {
suppliersList(limit: $limit, offset: $offset, country: $country) { suppliersList(limit: $limit, offset: $offset, country: $country, west: $west, south: $south, east: $east, north: $north) {
uuid uuid
name name
latitude latitude