diff --git a/package.json b/package.json index 1173f48..fb2784f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@tmcw/togeojson": "^7.1.2", "@turf/bbox": "^7.2.0", "@turf/centroid": "^7.2.0", + "@turf/distance": "^7.3.1", "@turf/helpers": "^7.3.1", "@vee-validate/zod": "^4.15.1", "@vueuse/core": "^13.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a2a21..f664290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@turf/centroid': specifier: ^7.2.0 version: 7.3.1 + '@turf/distance': + specifier: ^7.3.1 + version: 7.3.1 '@turf/helpers': specifier: ^7.3.1 version: 7.3.1 @@ -1172,9 +1175,15 @@ packages: '@turf/centroid@7.3.1': resolution: {integrity: sha512-hRnsDdVBH4pX9mAjYympb2q5W8TCMUMNEjcRrAF7HTCyjIuRmjJf8vUtlzf7TTn9RXbsvPc1vtm3kLw20Jm8DQ==} + '@turf/distance@7.3.1': + resolution: {integrity: sha512-DK//doTGgYYjBkcWUywAe7wbZYcdP97hdEJ6rXYVYRoULwGGR3lhY96GNjozg6gaW9q2eSNYnZLpcL5iFVHqgw==} + '@turf/helpers@7.3.1': resolution: {integrity: sha512-zkL34JVhi5XhsuMEO0MUTIIFEJ8yiW1InMu4hu/oRqamlY4mMoZql0viEmH6Dafh/p+zOl8OYvMJ3Vm3rFshgg==} + '@turf/invariant@7.3.1': + resolution: {integrity: sha512-IdZJfDjIDCLH+Gu2yLFoSM7H23sdetIo5t4ET1/25X8gi3GE2XSqbZwaGjuZgNh02nisBewLqNiJs2bo+hrqZA==} + '@turf/meta@7.3.1': resolution: {integrity: sha512-NWsfOE5RVtWpLQNkfOF/RrYvLRPwwruxhZUV0UFIzHqfiRJ50aO9Y6uLY4bwCUe2TumLJQSR4yaoA72Rmr2mnQ==} @@ -3759,11 +3768,24 @@ snapshots: '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@turf/distance@7.3.1': + dependencies: + '@turf/helpers': 7.3.1 + '@turf/invariant': 7.3.1 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/helpers@7.3.1': dependencies: '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@turf/invariant@7.3.1': + dependencies: + '@turf/helpers': 7.3.1 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/meta@7.3.1': dependencies: '@turf/helpers': 7.3.1 diff --git a/src/components/commandpalette/CommandPalette.vue b/src/components/commandpalette/CommandPalette.vue index efcd6db..1caf13c 100644 --- a/src/components/commandpalette/CommandPalette.vue +++ b/src/components/commandpalette/CommandPalette.vue @@ -6,6 +6,7 @@ import { useSelectStore } from "@/stores/selectStore.ts"; import MilSymbol from "@/components/MilSymbol.vue"; import { flyToItem, + flyToPlace, type ScenarioAction, useScenarioActions, } from "@/composables/scenarioActions.ts"; @@ -18,6 +19,14 @@ import { type UnitSearchResult, useScenarioSearch, } from "@/composables/scenarioSearching.ts"; +import { type ExtendedPhotonSearchResult, useGeoSearch } from "@/composables/geosearching.ts"; +import CommandPalettePlaceItem from "@/components/commandpalette/CommandPalettePlaceItem.vue"; + +type SearchItemResult = + | UnitSearchResult + | EquipmentSearchResult + | ActionSearchResult + | ExtendedPhotonSearchResult; const { mlMap } = defineProps<{ mlMap?: maplibregl.Map; @@ -28,17 +37,22 @@ const { dispatchAction: _dispatchAction } = useScenarioActions(); const { msdl } = useScenarioStore(); const selectStore = useSelectStore(); +const { photonSearch } = useGeoSearch(); const rawQuery = ref(""); const query = computed(() => rawQuery.value.replace(/^[#@>]/, "").trim()); const debouncedQuery = useDebounce(query, 200); +const geoDebouncedQuery = useDebounce(query, 300); +const mapCenter = ref<[number, number] | null>(null); -const groupedHits = ref>(); +const groupedHits = ref | Map<"Places", ExtendedPhotonSearchResult[]>>(); const isActionSearch = computed( () => rawQuery.value.startsWith("#") || rawQuery.value.startsWith(">"), ); +const isGeoSearch = computed(() => rawQuery.value.startsWith("@")); + const noResults = computed(() => { if (debouncedQuery.value && groupedHits.value) { return groupedHits.value.size === 0; @@ -55,7 +69,7 @@ function dispatchAction(action: ScenarioAction) { // Watch for changes to the searchQuery watchEffect(() => { - if (isActionSearch.value) return; + if (isActionSearch.value || isGeoSearch) return; groupedHits.value = search(debouncedQuery.value); }); @@ -65,6 +79,31 @@ watch([isActionSearch, query], async ([isa, q]) => { groupedHits.value = new Map([["Actions", filteredActions]]); }); +watch( + () => isGeoSearch.value && geoDebouncedQuery.value.trim(), + async (q) => { + if (!q) return; + const data = await photonSearch(q, { mapCenter: mapCenter.value }); + groupedHits.value = new Map([["Places", data.map((d) => ({ ...d, category: "Places" }))]]); + }, +); + +watch( + open, + (isOpen) => { + if (isOpen) { + // get map center coordinates + const center = mlMap?.getCenter(); + if (center) { + mapCenter.value = [center.lng, center.lat]; + } else { + mapCenter.value = null; + } + } + }, + { immediate: true }, +); + function selectUnitOrEquipmentItem(itemId: string) { const activeItemId = itemId; if (!activeItemId) return; @@ -78,25 +117,33 @@ function selectUnitOrEquipmentItem(itemId: string) { } } -function selectItem(item: UnitSearchResult | EquipmentSearchResult | ActionSearchResult) { +function selectItem( + item: UnitSearchResult | EquipmentSearchResult | ActionSearchResult | ExtendedPhotonSearchResult, +) { if (isUnitEquipmentSearchResult(item)) { selectUnitOrEquipmentItem(item.id); } else if (isActionSearchResult(item)) { dispatchAction(item.action); + } else if (isGeoSearchResult(item)) { + if (!mlMap) return; + open.value = false; + flyToPlace(item, mlMap); } } function isUnitEquipmentSearchResult( - item: UnitSearchResult | EquipmentSearchResult | ActionSearchResult, + item: SearchItemResult, ): item is UnitSearchResult | EquipmentSearchResult { return item.category === "Units" || item.category === "Equipment"; } -function isActionSearchResult( - item: UnitSearchResult | EquipmentSearchResult | ActionSearchResult, -): item is ActionSearchResult { +function isActionSearchResult(item: SearchItemResult): item is ActionSearchResult { return item.category === "Actions"; } + +function isGeoSearchResult(item: SearchItemResult): item is ExtendedPhotonSearchResult { + return item.category === "Places"; +} +

No search results found.

diff --git a/src/components/commandpalette/CommandPaletteDialog.vue b/src/components/commandpalette/CommandPaletteDialog.vue index 973cea5..4158fb2 100644 --- a/src/components/commandpalette/CommandPaletteDialog.vue +++ b/src/components/commandpalette/CommandPaletteDialog.vue @@ -36,8 +36,9 @@ const forwarded = useForwardPropsEmits(props, emits); {{ description }} - Type > or # for actionsType @ for place name search, > or # for + actions diff --git a/src/components/commandpalette/CommandPalettePlaceItem.vue b/src/components/commandpalette/CommandPalettePlaceItem.vue new file mode 100644 index 0000000..844889d --- /dev/null +++ b/src/components/commandpalette/CommandPalettePlaceItem.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/composables/geosearching.ts b/src/composables/geosearching.ts new file mode 100644 index 0000000..254ac5f --- /dev/null +++ b/src/composables/geosearching.ts @@ -0,0 +1,72 @@ +import { ref } from "vue"; +import { useFetch } from "@vueuse/core"; +import type { Feature, FeatureCollection, Point } from "geojson"; + +export interface GeoSearchOptions { + mapCenter?: number[] | null; + limit?: number; + lang?: string; +} + +export interface PhotonFeatureProperties { + name: string; + country?: string; + city?: string; + state?: string; + extent?: number[]; + osm_key?: string; +} + +export interface GeoSearchProperties { + name: string; + country?: string; + city?: string; + state?: string; + extent?: number[]; + category?: string; + distance?: number; +} + +export type PhotonSearchResult = Feature; + +export interface ExtendedPhotonSearchResult extends PhotonSearchResult { + category: "Places"; +} + +export function useGeoSearch() { + const searchUrl = ref(""); + const { data, isFetching, error, execute } = useFetch(searchUrl, { + immediate: false, + }) + .get() + .json>(); + + async function photonSearch( + q: string, + options: GeoSearchOptions = {}, + ): Promise { + const { mapCenter, limit = 10, lang = "en" } = options; + searchUrl.value = `https://photon.komoot.io/api/?q=${q}&limit=${limit}&lang=${lang}`; + if (mapCenter) { + const [lon, lat] = mapCenter; + searchUrl.value += `&lon=${lon}&lat=${lat}`; + } + await execute(); + if (data.value) { + return data.value.features.map((item) => { + return { + ...item, + properties: { + name: item.properties.name, + country: item.properties.country, + city: item.properties.city, + state: item.properties.state, + extent: item.properties.extent, + category: item.properties.osm_key, + }, + } as PhotonSearchResult; + }); + } else return []; + } + return { isFetching, photonSearch }; +} diff --git a/src/composables/scenarioActions.ts b/src/composables/scenarioActions.ts index 86b323f..f0971fe 100644 --- a/src/composables/scenarioActions.ts +++ b/src/composables/scenarioActions.ts @@ -9,6 +9,8 @@ import { isForceSide, isUnitOrEquipment, triggerFlash } from "@/utils.ts"; import maplibregl, { type LngLatBoundsLike, type LngLatLike } from "maplibre-gl"; import bbox from "@turf/bbox"; import type { GeoJSON } from "geojson"; +import type { PhotonSearchResult } from "@/composables/geosearching.ts"; +import { fixExtent } from "@/lib/geoConvert.ts"; export type ScenarioAction = | "CreateNewMSDL" @@ -140,3 +142,19 @@ export function flyToItem( }); } } + +export function flyToPlace(item: PhotonSearchResult, mlMap: maplibregl.Map) { + const extent = fixExtent(item.properties.extent); + if (extent) { + mlMap?.fitBounds(extent as LngLatBoundsLike, { + maxZoom: 15, + duration: 1500, + }); + } else { + mlMap?.flyTo({ + center: item.geometry.coordinates as LngLatLike, + zoom: 15, + duration: 1500, + }); + } +} diff --git a/src/utils.ts b/src/utils.ts index d0455ad..6285009 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -179,3 +179,31 @@ export function groupByObj(arr: T[], key: K {} as Record, ); } + +export type MeasurementUnit = "metric" | "imperial" | "nautical"; + +export function formatLength(length: number, unit: MeasurementUnit = "metric") { + let output: string = ""; + if (unit === "metric") { + if (length > 100) { + output = Math.round((length / 1000) * 100) / 100 + " km"; + } else { + output = Math.round(length * 100) / 100 + " m"; + } + } else if (unit === "imperial") { + const miles = length * 0.000621371192; + if (miles > 0.1) { + output = miles.toFixed(2) + " mi"; + } else { + output = (miles * 5280).toFixed(2) + " ft"; + } + } else if (unit === "nautical") { + const nm = length * 0.000539956803; + if (nm > 0.1) { + output = nm.toFixed(2) + " nm"; + } else { + output = nm.toFixed(3) + " nm"; + } + } + return output; +}