Skip to content

Commit

Permalink
feat: show crag info on hover (#1081)
Browse files Browse the repository at this point in the history
  • Loading branch information
vnugent authored Jan 21, 2024
1 parent 4bdfffe commit 977f11f
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 34 deletions.
8 changes: 8 additions & 0 deletions src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,11 @@ A slightly deemphasized dotted underline for a tag in order to not competing wit
@apply bg-neutral-300 rounded-full;
}
}

/**
* Force mapbox-gl library to use our font otherwise components inside the map
* will use their font and look out of place.
*/
.mapboxgl-map {
font-family: inherit !important;
}
42 changes: 37 additions & 5 deletions src/components/maps/AreaActiveMarker.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,44 @@
import { Marker } from 'react-map-gl'
import { Point } from '@turf/helpers'
import { Marker, Source, Layer, LineLayer } from 'react-map-gl'
import { Point, Polygon } from '@turf/helpers'
import { MapPin } from '@phosphor-icons/react/dist/ssr'

export const AreaActiveMarker: React.FC<{ point: Point }> = ({ point }) => {
const { coordinates } = point
/**
* Highlight selected feature on the map
*/
export const SelectedFeature: React.FC<{ geometry: Point | Polygon }> = ({ geometry }) => {
switch (geometry.type) {
case 'Point':
return <SelectedPoint geometry={geometry} />
case 'Polygon':
return <SelectedPolygon geometry={geometry} />
default: return null
}
}

const SelectedPoint: React.FC<{ geometry: Point }> = ({ geometry }) => {
const { coordinates } = geometry
return (
<Marker longitude={coordinates[0]} latitude={coordinates[1]} style={{ zIndex: 2000 }}>
<Marker longitude={coordinates[0]} latitude={coordinates[1]}>
<MapPin size={36} weight='fill' className='text-accent' />
</Marker>
)
}

export const SelectedPolygon: React.FC<{ geometry: Polygon }> = ({ geometry }) => {
return (
<Source id='selected-polygon' type='geojson' data={geometry}>
<Layer {...selectedBoundary} />
</Source>
)
}

const selectedBoundary: LineLayer = {
id: 'polygon2',
type: 'line',
paint: {
'line-opacity': ['step', ['zoom'], 0.85, 10, 0.5],
'line-width': ['step', ['zoom'], 2, 10, 10],
'line-color': '#004F6E', // See 'area-cue' in tailwind.config.js
'line-blur': 4
}
}
20 changes: 17 additions & 3 deletions src/components/maps/AreaInfoDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import * as Popover from '@radix-ui/react-popover'

import { MapAreaFeatureProperties } from './AreaMap'
import { getAreaPageFriendlyUrl } from '@/js/utils'
import { Card } from '../core/Card'
import { EntityIcon } from '@/app/editArea/[slug]/general/components/AreaItem'
import { ArrowRight } from '@phosphor-icons/react/dist/ssr'

/**
* Area info panel
*/
export const AreaInfoDrawer: React.FC<{ data: MapAreaFeatureProperties | null, onClose?: () => void }> = ({ data, onClose }) => {
const parent = data?.parent == null ? null : JSON.parse(data.parent)
const parentName = parent?.name ?? 'Unknown'
const parentName = parent?.name ?? ''
const parentId = parent?.id ?? null
return (
<Popover.Root open={data != null}>
Expand All @@ -21,9 +23,14 @@ export const AreaInfoDrawer: React.FC<{ data: MapAreaFeatureProperties | null, o
)
}

export const Content: React.FC<MapAreaFeatureProperties & { parentName: string, parentId: string | null }> = ({ id, name, parentName, parentId }) => {
export const Content: React.FC<MapAreaFeatureProperties & { parentName: string, parentId: string | null }> = ({ id, name, parentName, parentId, content }) => {
const url = parentId == null
? parentName
? (
<div className='inline-flex items-center gap-1.5'>
<EntityIcon type='area' size={16} />
<span className='text-secondary font-medium'>{parentName}</span>
</div>
)
: (
<a
href={getAreaPageFriendlyUrl(parentId, name)}
Expand All @@ -32,6 +39,8 @@ export const Content: React.FC<MapAreaFeatureProperties & { parentName: string,
<EntityIcon type='area' size={16} /><span className='text-secondary font-medium hover:underline '>{parentName}</span>
</a>
)

const friendlyUrl = getAreaPageFriendlyUrl(id, name)
return (
<Card>
<div className='flex flex-col gap-y-1 text-xs'>
Expand All @@ -40,6 +49,11 @@ export const Content: React.FC<MapAreaFeatureProperties & { parentName: string,
<span className='text-secondary'>&#8735;</span><a href={getAreaPageFriendlyUrl(id, name)} className='text-sm font-medium hover:underline'>{name}</a>
</div>
</div>
<hr className='mt-6' />
<div className='flex items-center justify-end gap-2'>
<a className='btn btn-link btn-sm no-underline' href={`/editArea/${id}`}>Edit</a>
<a className='btn btn-primary btn-sm' href={friendlyUrl}>Visit area <ArrowRight /></a>
</div>
</Card>
)
}
53 changes: 53 additions & 0 deletions src/components/maps/AreaInfoHover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as Popover from '@radix-ui/react-popover'
import { HoverInfo, MapAreaFeatureProperties } from './AreaMap'
import { getAreaPageFriendlyUrl } from '@/js/utils'
import { Card } from '../core/Card'
import { EntityIcon } from '@/app/editArea/[slug]/general/components/AreaItem'
import { SelectedPolygon } from './AreaActiveMarker'
/**
* Area info panel
*/
export const AreaInfoHover: React.FC<HoverInfo> = ({ data, geometry, mapInstance }) => {
const parent = data?.parent == null ? null : JSON.parse(data.parent)
const parentName = parent?.name ?? 'Unknown'
const parentId = parent?.id ?? null

let screenXY
if (geometry.type === 'Point') {
screenXY = mapInstance.project(geometry.coordinates)
} else {
return <SelectedPolygon geometry={geometry} />
}
return (
<Popover.Root defaultOpen>
<Popover.Anchor style={{ position: 'absolute', left: screenXY.x, top: screenXY.y }} />
<Popover.Content align='center' side='top' alignOffset={12}>
{data != null && <Content {...data} parentName={parentName} parentId={parentId} />}
<Popover.Arrow />
</Popover.Content>
</Popover.Root>
)
}

export const Content: React.FC<MapAreaFeatureProperties & { parentName: string, parentId: string | null }> = ({ id, name, parentName, parentId }) => {
const url = parentId == null
? parentName
: (
<a
href={getAreaPageFriendlyUrl(parentId, name)}
className='inline-flex items-center gap-1.5'
>
<EntityIcon type='area' size={16} /><span className='text-secondary font-medium hover:underline '>{parentName}</span>
</a>
)
return (
<Card>
<div className='flex flex-col gap-y-1 text-xs'>
<div>{url}</div>
<div className='ml-2'>
<span className='text-secondary'>&#8735;</span><a href={getAreaPageFriendlyUrl(id, name)} className='text-sm font-medium hover:underline'>{name}</a>
</div>
</div>
</Card>
)
}
105 changes: 79 additions & 26 deletions src/components/maps/AreaMap.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Map, ScaleControl, FullscreenControl, NavigationControl, Source, Layer, MapLayerMouseEvent, LineLayer } from 'react-map-gl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Map, ScaleControl, FullscreenControl, NavigationControl, Source, Layer, MapLayerMouseEvent, LineLayer, MapInstance } from 'react-map-gl'
import dynamic from 'next/dynamic'
import { lineString, Point, point } from '@turf/helpers'
import { lineString, Point, Polygon, point } from '@turf/helpers'
import lineToPolygon from '@turf/line-to-polygon'
import { useDebouncedCallback } from 'use-debounce'

import { AreaMetadataType, AreaType } from '../../js/types'
import { MAP_STYLES } from './BaseMap'
import { AreaInfoDrawer } from './AreaInfoDrawer'
import { AreaActiveMarker } from './AreaActiveMarker'
import { AreaInfoHover } from './AreaInfoHover'
import { SelectedFeature } from './AreaActiveMarker'

type ChildArea = Pick<AreaType, 'uuid' | 'areaName'> & { metadata: Pick<AreaMetadataType, 'lat' | 'lng' | 'leaf' | 'bbox' | 'polygon'> }
interface AreaMapProps {
Expand All @@ -22,20 +22,33 @@ interface AreaMapProps {
export interface MapAreaFeatureProperties {
id: string
name: string
content: {
description: string
}
parent: string // due to a backend backend bug, this is a string instead of a parent object
// parent: {
// id: string
// name: string
// }
}

export interface HoverInfo {
geometry: Point | Polygon
data: MapAreaFeatureProperties
mapInstance: MapInstance
}

/**
* Area map
*/
const AreaMap: React.FC<AreaMapProps> = ({ area, subAreas }) => {
const [hovered, setHovered] = useState<MapAreaFeatureProperties | null>(null)
const [selected, setSelected] = useState<Point | null>(null)
const mapRef = useRef(null)
const [clickInfo, setClickInfo] = useState<MapAreaFeatureProperties | null>(null)
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
const [selected, setSelected] = useState<Point | Polygon | null>(null)
const [mapInstance, setMapInstance] = useState<MapInstance | null>(null)
const [cursor, setCursor] = useState<string>('default')
const mapRef = useRef<any>(null)

let fitBoundOpts: any = { padding: { top: 45, left: 45, bottom: 45, right: 45 } }
if (subAreas.length === 0) {
fitBoundOpts = { maxZoom: 14 }
Expand All @@ -44,25 +57,51 @@ const AreaMap: React.FC<AreaMapProps> = ({ area, subAreas }) => {
const { metadata } = area
const boundary = metadata?.polygon == null ? null : lineToPolygon(lineString(metadata.polygon), { properties: { name: area.areaName } })

const onClick = (event: MapLayerMouseEvent): void => {
const feature = event?.features?.[0]
if (feature == null) {
setSelected(null)
setHovered(null)
} else {
setSelected(feature.geometry as unknown as Point)
setHovered(feature.properties as MapAreaFeatureProperties)
}
}

useEffect(() => {
if (mapRef.current != null) {
setMapInstance(mapRef.current)
}
/**
* Show drop pin if viewing a leaf area
*/
if (metadata.leaf) {
setSelected(point([metadata.lng, metadata.lat]).geometry as unknown as Point)
}
}, [metadata.leaf])
}, [metadata.leaf, mapRef?.current])

const onClick = useCallback((event: MapLayerMouseEvent): void => {
const feature = event?.features?.[0]
if (feature == null) {
setSelected(null)
setClickInfo(null)
} else {
setSelected(feature.geometry as Point | Polygon)
setClickInfo(feature.properties as MapAreaFeatureProperties)
}
}, [mapInstance])

const onHover = useCallback((event: MapLayerMouseEvent) => {
const obLayerId = event.features?.findIndex((f) => f.layer.id === 'crags' || f.layer.id === 'crag-group-boundaries') ?? -1

if (obLayerId !== -1) {
setCursor('pointer')
const feature = event.features?.[obLayerId]
if (feature != null && mapInstance != null) {
const { geometry } = feature
if (geometry.type === 'Point' || geometry.type === 'Polygon') {
setHoverInfo({
geometry: feature.geometry as Point | Polygon,
data: feature.properties as MapAreaFeatureProperties,
mapInstance
})
}
}
} else {
setHoverInfo(null)
setCursor('default')
}
}, [mapInstance])

return (
<div className='relative w-full h-full'>
<Map
Expand All @@ -72,23 +111,36 @@ const AreaMap: React.FC<AreaMapProps> = ({ area, subAreas }) => {
bounds: metadata.bbox,
fitBoundsOptions: fitBoundOpts
}}
onClick={useDebouncedCallback(onClick, 200, { leading: true, maxWait: 200 })}
onDragStart={() => {
setCursor('move')
}}
onDragEnd={() => {
setCursor('default')
}}
onMouseEnter={onHover}
onMouseLeave={() => {
setHoverInfo(null)
setCursor('default')
}}
onClick={onClick}
reuseMaps
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_API_KEY}
mapStyle={MAP_STYLES.light}
cursor={cursor}
cooperativeGestures
interactiveLayerIds={['crags']}
interactiveLayerIds={['crags', 'crag-group-boundaries']}
>
<ScaleControl />
<FullscreenControl />
<NavigationControl showCompass={false} />
{selected != null &&
<AreaActiveMarker point={selected} />}
<AreaInfoDrawer data={hovered} />
<SelectedFeature geometry={selected} />}
<AreaInfoDrawer data={clickInfo} />
{boundary != null &&
<Source id='child-areas-polygon' type='geojson' data={boundary}>
<Layer {...areaPolygonStyle} />
</Source>}
{hoverInfo != null && <AreaInfoHover {...hoverInfo} />}
</Map>
</div>
)
Expand All @@ -106,7 +158,8 @@ const areaPolygonStyle: LineLayer = {
type: 'line',
paint: {
'line-opacity': ['step', ['zoom'], 0.85, 10, 0.5],
'line-width': ['step', ['zoom'], 2, 10, 6],
'line-color': 'rgb(219,39,119)'
'line-width': ['step', ['zoom'], 2, 10, 8],
'line-color': 'rgb(219,39,119)',
'line-blur': 4
}
}

1 comment on commit 977f11f

@vercel
Copy link

@vercel vercel bot commented on 977f11f Jan 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.