From f0c687c3ff927c399c105ed229936c3c2bed1024 Mon Sep 17 00:00:00 2001
From: Ruslan Bakiev <572431+veikab@users.noreply.github.com>
Date: Fri, 6 Feb 2026 15:21:24 +0700
Subject: [PATCH] Improve selection panel and hub card compass
---
app/components/catalog/HubCard.vue | 51 ++++++++++--
app/components/catalog/InfoPanel.vue | 9 +++
app/components/catalog/SelectionPanel.vue | 95 ++++++++++++++++-------
app/pages/catalog/index.vue | 1 +
4 files changed, 121 insertions(+), 35 deletions(-)
diff --git a/app/components/catalog/HubCard.vue b/app/components/catalog/HubCard.vue
index 19c7ed5..d532b5f 100644
--- a/app/components/catalog/HubCard.vue
+++ b/app/components/catalog/HubCard.vue
@@ -16,16 +16,29 @@
]"
>
-
-
{{ hub.name }}
-
+
+
+
{{ hub.name }}
+
+
{{ distanceLabel }}
+
+
+
+
+
{{ Math.round(bearing) }}°
+
+
+
+
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
-
- {{ distanceLabel }}
-
@@ -47,6 +60,8 @@ interface Hub {
name?: string | null
country?: string | null
countryCode?: string | null
+ latitude?: number | null
+ longitude?: number | null
distance?: string
distanceKm?: number | null
transportTypes?: (string | null)[] | null
@@ -54,6 +69,7 @@ interface Hub {
const props = defineProps<{
hub: Hub
+ origin?: { latitude: number; longitude: number } | null
selectable?: boolean
isSelected?: boolean
linkTo?: string
@@ -88,4 +104,27 @@ const distanceLabel = computed(() => {
if (props.hub.distanceKm != null) return `${Math.round(props.hub.distanceKm)} km`
return ''
})
+
+const toRadians = (deg: number) => (deg * Math.PI) / 180
+const toDegrees = (rad: number) => (rad * 180) / Math.PI
+
+const bearing = computed(() => {
+ const origin = props.origin
+ const lat2 = props.hub.latitude
+ const lon2 = props.hub.longitude
+ if (!origin || lat2 == null || lon2 == null) return null
+ const lat1 = origin.latitude
+ const lon1 = origin.longitude
+ if (lat1 == null || lon1 == null) return null
+
+ const φ1 = toRadians(lat1)
+ const φ2 = toRadians(lat2)
+ const Δλ = toRadians(lon2 - lon1)
+
+ const y = Math.sin(Δλ) * Math.cos(φ2)
+ const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
+ const θ = Math.atan2(y, x)
+ const deg = (toDegrees(θ) + 360) % 360
+ return deg
+})
diff --git a/app/components/catalog/InfoPanel.vue b/app/components/catalog/InfoPanel.vue
index ce47937..bd17d96 100644
--- a/app/components/catalog/InfoPanel.vue
+++ b/app/components/catalog/InfoPanel.vue
@@ -206,6 +206,7 @@
v-for="(hub, index) in railHubs"
:key="hub.uuid ?? index"
:hub="hub"
+ :origin="originCoords"
selectable
@select="onHubSelect(hub)"
/>
@@ -226,6 +227,7 @@
v-for="(hub, index) in seaHubs"
:key="hub.uuid ?? index"
:hub="hub"
+ :origin="originCoords"
selectable
@select="onHubSelect(hub)"
/>
@@ -306,6 +308,13 @@ const entityLocation = computed(() => {
return parts.length > 0 ? parts.join(', ') : null
})
+const originCoords = computed(() => {
+ const lat = props.entity?.locationLatitude ?? props.entity?.latitude
+ const lon = props.entity?.locationLongitude ?? props.entity?.longitude
+ if (lat == null || lon == null) return null
+ return { latitude: Number(lat), longitude: Number(lon) }
+})
+
// Products section title based on entity type
const productsSectionTitle = computed(() => {
return props.entityType === 'hub'
diff --git a/app/components/catalog/SelectionPanel.vue b/app/components/catalog/SelectionPanel.vue
index 0ee75cf..a9cdec0 100644
--- a/app/components/catalog/SelectionPanel.vue
+++ b/app/components/catalog/SelectionPanel.vue
@@ -8,12 +8,21 @@
-
+
+
+ {{ item.name }}
+
+
+
@@ -22,7 +31,7 @@
+
{{ $t('catalog.empty.noResults') }}
@@ -31,11 +40,19 @@
+
+
+
@@ -118,8 +151,8 @@ const emit = defineEmits<{
const { t } = useI18n()
-const searchQuery = ref('')
const loadMoreSentinel = ref(null)
+const pinnedIds = ref([])
// Infinite scroll using IntersectionObserver
let observer: IntersectionObserver | null = null
@@ -128,7 +161,7 @@ onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
- if (entry?.isIntersecting && props.hasMore && !props.loadingMore && !searchQuery.value) {
+ if (entry?.isIntersecting && props.hasMore && !props.loadingMore) {
emit('load-more')
}
},
@@ -158,15 +191,6 @@ const title = computed(() => {
}
})
-const searchPlaceholder = computed(() => {
- switch (props.selectMode) {
- case 'product': return t('catalog.search.searchProducts')
- case 'hub': return t('catalog.search.searchHubs')
- case 'supplier': return t('catalog.search.searchSuppliers')
- default: return t('catalog.search.placeholder')
- }
-})
-
const items = computed(() => {
switch (props.selectMode) {
case 'product': return props.products || []
@@ -176,14 +200,27 @@ const items = computed(() => {
}
})
-const filteredItems = computed(() => {
- if (!searchQuery.value.trim()) return items.value
+const isPinned = (uuid: string) => pinnedIds.value.includes(uuid)
- const query = searchQuery.value.toLowerCase()
- return items.value.filter(item =>
- item.name?.toLowerCase().includes(query) ||
- item.country?.toLowerCase().includes(query)
- )
+const togglePin = (item: Item) => {
+ if (!item.uuid) return
+ if (isPinned(item.uuid)) {
+ pinnedIds.value = pinnedIds.value.filter(id => id !== item.uuid)
+ } else {
+ pinnedIds.value = [...pinnedIds.value, item.uuid]
+ }
+}
+
+const pinnedItems = computed(() =>
+ items.value.filter(item => item.uuid && isPinned(item.uuid))
+)
+
+const displayItems = computed(() => {
+ if (pinnedIds.value.length === 0) return items.value
+ const pinned = pinnedItems.value
+ const pinnedSet = new Set(pinned.map(p => p.uuid).filter(Boolean) as string[])
+ const rest = items.value.filter(item => !item.uuid || !pinnedSet.has(item.uuid))
+ return [...pinned, ...rest]
})
// Select item and emit
diff --git a/app/pages/catalog/index.vue b/app/pages/catalog/index.vue
index 7d290cc..d647943 100644
--- a/app/pages/catalog/index.vue
+++ b/app/pages/catalog/index.vue
@@ -6,6 +6,7 @@
:use-server-clustering="true"
:use-typed-clusters="true"
:cluster-node-type="clusterNodeType"
+ panel-width="w-[32rem]"
map-id="unified-catalog-map"
:point-color="mapPointColor"
:items="currentSelectionItems"