Skip to content

Commit

Permalink
feat(visual): integrate search for waypoints
Browse files Browse the repository at this point in the history
  • Loading branch information
devshred committed Apr 21, 2024
1 parent d8896b6 commit bda44bd
Show file tree
Hide file tree
Showing 7 changed files with 324 additions and 69 deletions.
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@hello-pangea/dnd": "^16.5.0",
"@types/geojson": "^7946.0.14",
"@types/leaflet": "^1.9.8",
"@uidotdev/usehooks": "^2.4.1",
"axios": "^1.6.7",
"dompurify": "^3.0.9",
"gpxparser": "^3.0.8",
Expand Down
40 changes: 39 additions & 1 deletion src/components/common/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
convertOsmToPoiType,
decodeFromBase64,
encodeToBase64,
generateGeoJson,
Expand Down Expand Up @@ -116,3 +117,40 @@ describe('generate GeoJSON', () => {
})
})
})

describe('convert OSM key and value to PoiType', () => {
it('convert amenity / food', () => {
const actual = convertOsmToPoiType('amenity', 'cafe')
expect(actual).toStrictEqual('FOOD')
})

it('convert amenity / non-food', () => {
const actual = convertOsmToPoiType('amenity', 'library')
expect(actual).toStrictEqual('GENERIC')
})

it('convert mountain pass', () => {
const actual = convertOsmToPoiType('mountain_pass', '')
expect(actual).toStrictEqual('SUMMIT')
})

it('convert peak', () => {
const actual = convertOsmToPoiType('natural', 'peak')
expect(actual).toStrictEqual('SUMMIT')
})

it('convert saddle', () => {
const actual = convertOsmToPoiType('natural', 'saddle')
expect(actual).toStrictEqual('SUMMIT')
})

it('convert hotel', () => {
const actual = convertOsmToPoiType('tourism', 'hotel')
expect(actual).toStrictEqual('RESIDENCE')
})

it('convert / fallback', () => {
const actual = convertOsmToPoiType('unknown', 'whatever')
expect(actual).toStrictEqual('GENERIC')
})
})
145 changes: 101 additions & 44 deletions src/components/common/tools.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,115 @@
import DOMPurify from 'dompurify'
import { WayPoint } from '../../@types/gps.ts'
import { Feature, FeatureCollection } from 'geojson'
import {PoiType, WayPoint} from '../../@types/gps.ts'
import {Feature, FeatureCollection} from 'geojson'

export const sanitizeFilename = (input: string) => {
var sanitized = input
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim()

sanitized = DOMPurify.sanitize(sanitized, {
USE_PROFILES: { html: false },
})

sanitized = sanitized.replace(/&lt;/g, '<').replace(/&gt;/g, '>')

if (sanitized.length > 0) {
return sanitized
} else {
return 'unnamed'
}
var sanitized = input
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim()

sanitized = DOMPurify.sanitize(sanitized, {
USE_PROFILES: {html: false},
})

sanitized = sanitized.replace(/&lt;/g, '<').replace(/&gt;/g, '>')

if (sanitized.length > 0) {
return sanitized
} else {
return 'unnamed'
}
}

export const decodeFromBase64 = (input: string) => {
const binString = atob(input)
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0)
return new TextDecoder().decode(bytes)
const binString = atob(input)
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0)
return new TextDecoder().decode(bytes)
}

export const encodeToBase64 = (input: string) => {
const bytes = new TextEncoder().encode(input)
const binString = String.fromCodePoint(...bytes)
return btoa(binString)
const bytes = new TextEncoder().encode(input)
const binString = String.fromCodePoint(...bytes)
return btoa(binString)
}

export const generateGeoJson = (
markerPositions: WayPoint[]
markerPositions: WayPoint[]
): FeatureCollection => {
return {
type: 'FeatureCollection',
features: markerPositions.map(
(waypoint) =>
({
type: 'Feature',
properties: {
name: waypoint.name,
type: waypoint.type,
},
geometry: {
type: 'Point',
coordinates: [waypoint.position[1], waypoint.position[0]],
},
} as Feature)
),
}
return {
type: 'FeatureCollection',
features: markerPositions.map(
(waypoint) =>
({
type: 'Feature',
properties: {
name: waypoint.name,
type: waypoint.type,
},
geometry: {
type: 'Point',
coordinates: [waypoint.position[1], waypoint.position[0]],
},
} as Feature)
),
}
}

export const convertOsmToPoiType = (
osm_key: string,
osm_value: string
): PoiType => {
if (osm_key === 'amenity') {
switch (osm_value) {
case 'bar':
case 'biergarten':
case 'cafe':
case 'fast_food':
case 'food_court':
case 'ice_cream':
case 'pub':
case 'restaurant':
return 'FOOD'

case 'hotel':
case 'spa_resort':
return 'RESIDENCE'

case 'drinking_water':
return 'WATER'

default:
return 'GENERIC'
}
} else if (osm_key === 'mountain_pass') {
return 'SUMMIT'
} else if (
osm_key === 'natural') {
switch (osm_value) {
case 'peak':
case 'saddle':
return 'SUMMIT'
case 'spring':
return 'WATER'
default:
return 'GENERIC'
}
} else if (
osm_key === 'shop') {
switch (osm_value) {
case 'convenience':
case 'supermarket':
return 'FOOD'
default:
return 'GENERIC'
}
} else if (osm_key === 'tourism' && osm_value === 'hotel') {
return 'RESIDENCE'
} else if (osm_key === 'man_made' && osm_value === 'water_tap') {
return 'WATER'
}

return 'GENERIC'
}
121 changes: 121 additions & 0 deletions src/components/merge/MarkerSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import { useDebounce } from '@uidotdev/usehooks'
import { Feature, FeatureCollection, Point } from 'geojson'
import { WayPoint } from '../../@types/gps'
import { v4 as uuidv4 } from 'uuid'
import { convertOsmToPoiType } from '../common/tools.ts'
import { useMap } from 'react-leaflet'

type MarkerSearchProps = {
setMarkerPositions: Dispatch<SetStateAction<WayPoint[]>>
}

const MarkerSearch: React.FC<MarkerSearchProps> = ({ setMarkerPositions }) => {
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, 1000)
const [results, setResults] = useState<Feature[]>([])
const dropdownDivRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const map = useMap()

useEffect(() => {
if (debouncedSearchTerm === undefined || debouncedSearchTerm.length == 0) {
return
}

const bounds = map.getBounds()
const west = bounds.getWest()
const east = bounds.getEast()
const north = bounds.getNorth()
const south = bounds.getSouth()
const minLon = Math.min(west, east)
const maxLon = Math.max(west, east)
const minLat = Math.min(north, south)
const maxLat = Math.max(north, south)

axios
.get('https://photon.komoot.io/api/', {
params: {
q: debouncedSearchTerm,
bbox: `${minLon},${minLat},${maxLon},${maxLat}`,
},
})
.then((response) => {
const featureCollection: FeatureCollection =
response.data as FeatureCollection
setResults(featureCollection.features)
})
.catch((error) => {
console.error('error', error)
})
}, [debouncedSearchTerm])

const createMarker = (marker: Feature) => {
const point = marker.geometry as Point
const poiType = convertOsmToPoiType(
marker.properties?.osm_key,
marker.properties?.osm_value
)
const newWayPoint: WayPoint = {
id: uuidv4(),
name: marker.properties?.name ?? 'unnamed',
type: poiType,
position: [point.coordinates[1], point.coordinates[0]],
}
setMarkerPositions((prevState) => [...prevState, newWayPoint])

// clear input
setSearchTerm('')
setResults([])

// close dropdown
const elem = document.activeElement
if (elem && elem instanceof HTMLElement) {
elem.blur()
}
}

return (
<div className='container mx-auto px-3 pt-2'>
<div className='dropdown w-full' ref={dropdownDivRef}>
<input
id='markerSearchInput'
type='text'
ref={inputRef}
className='input input-bordered w-full'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder='Search for waypoints…'
tabIndex={0}
/>
<div className='dropdown-content bg-base-200 top-14 max-h-96 overflow-auto flex-col rounded-md'>
{results.length > 0 && (
<ul
className='menu menu-compact'
// calculate the width of the dropdown
style={{ width: dropdownDivRef.current?.clientWidth }}
>
{results.map((item, index) => {
return (
<li
key={index}
tabIndex={index + 1}
onClick={() => {
createMarker(item)
}}
className='border-b border-b-base-content/10 w-full'
>
<button>{item.properties!.name}</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)
}

export default MarkerSearch
Loading

0 comments on commit bda44bd

Please sign in to comment.