Skip to content

Commit

Permalink
feat: data layer toolbar
Browse files Browse the repository at this point in the history
  • Loading branch information
vnugent committed May 19, 2024
1 parent c5151a0 commit 4404ab7
Show file tree
Hide file tree
Showing 11 changed files with 330 additions and 124 deletions.
3 changes: 2 additions & 1 deletion src/components/maps/AreaActiveMarker.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Marker, Source, Layer, LineLayer } from 'react-map-gl'
import { Geometry } from 'geojson'
import { Point, Polygon } from '@turf/helpers'
import { MapPin } from '@phosphor-icons/react/dist/ssr'

/**
* Highlight selected feature on the map
*/
export const SelectedFeature: React.FC<{ geometry: Point | Polygon }> = ({ geometry }) => {
export const SelectedFeature: React.FC<{ geometry: Geometry }> = ({ geometry }) => {
switch (geometry.type) {
case 'Point':
return <SelectedPoint geometry={geometry} />
Expand Down
71 changes: 31 additions & 40 deletions src/components/maps/GlobalMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,19 @@
import { useCallback, useState } from 'react'
import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, MapInstance, ViewStateChangeEvent } from 'react-map-gl/maplibre'
import maplibregl, { MapLibreEvent } from 'maplibre-gl'
import { Point, Polygon } from '@turf/helpers'
import dynamic from 'next/dynamic'
import { Geometry } from 'geojson'

import { MAP_STYLES, type MapStyles } from './MapSelector'
import { AreaInfoDrawer } from './AreaInfoDrawer'
import { AreaInfoHover } from './AreaInfoHover'
import { AreaInfoDrawer } from './TileHandlers/AreaInfoDrawer'
import { AreaInfoHover } from './TileHandlers/AreaInfoHover'
import { SelectedFeature } from './AreaActiveMarker'
import { OBCustomLayers } from './OBCustomLayers'
import { AreaType, ClimbType, MediaWithTags } from '@/js/types'
import { TileProps, transformTileProps } from './utils'
import { tileToFeature } from './utils'
import { ActiveFeature, TileProps } from './TileTypes'
import MapLayersSelector from './MapLayersSelector'
import { debounce } from 'underscore'

export type SimpleClimbType = Pick<ClimbType, 'id' | 'name' | 'type'>

export type MediaWithTagsInMapTile = Omit<MediaWithTags, 'id'> & { _id: string }
export type MapAreaFeatureProperties = Pick<AreaType, 'id' | 'areaName' | 'content' | 'ancestors' | 'pathTokens'> & {
climbs: SimpleClimbType[]
media: MediaWithTagsInMapTile[]
}

export interface HoverInfo {
geometry: Point | Polygon
data: MapAreaFeatureProperties
mapInstance: MapInstance
}
import { MapToolbar } from './MapToolbar'

export interface CameraInfo {
center: {
Expand All @@ -37,6 +24,10 @@ export interface CameraInfo {
zoom: number
}

export interface DataLayersDisplayState {
cragGroups: boolean
organizations: boolean
}
interface GlobalMapProps {
showFullscreenControl?: boolean
initialCenter?: [number, number]
Expand All @@ -55,12 +46,16 @@ interface GlobalMapProps {
export const GlobalMap: React.FC<GlobalMapProps> = ({
showFullscreenControl = true, initialCenter, initialZoom, initialViewState, onCameraMovement, children
}) => {
const [clickInfo, setClickInfo] = useState<MapAreaFeatureProperties | null>(null)
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
const [selected, setSelected] = useState<Point | Polygon | null>(null)
const [clickInfo, setClickInfo] = useState<ActiveFeature | null>(null)
const [hoverInfo, setHoverInfo] = useState < ActiveFeature | null>(null)
const [selected, setSelected] = useState<Geometry | null>(null)
const [mapInstance, setMapInstance] = useState<MapInstance | null>(null)
const [cursor, setCursor] = useState<string>('default')
const [mapStyle, setMapStyle] = useState<string>(MAP_STYLES.standard.style)
const [dataLayersDisplayState, setDataLayersDisplayState] = useState<DataLayersDisplayState>({
cragGroups: false,
organizations: false
})

const onMove = useCallback(debounce((e: ViewStateChangeEvent) => {
if (onCameraMovement != null) {
Expand Down Expand Up @@ -89,41 +84,36 @@ export const GlobalMap: React.FC<GlobalMapProps> = ({
*/
const onClick = useCallback((event: MapLayerMouseEvent): void => {
const feature = event?.features?.[0]
if (feature == null) {
if (feature == null || mapInstance == null) {
setSelected(null)
setClickInfo(null)
} else {
setSelected(feature.geometry as Point | Polygon)
setClickInfo(transformTileProps(feature.properties as TileProps))
const { layer, geometry, properties } = feature
setSelected(feature.geometry)
setClickInfo(tileToFeature(layer.id, event.point, geometry, properties as TileProps, mapInstance))
}
}, [mapInstance])

/**
* Handle click event on the popover. Behave as if the user clicked on a feature on the map.
*/
const onHoverCardClick = ({ geometry, data }: HoverInfo): void => {
setSelected(geometry)
setClickInfo(data)
const onHoverCardClick = (feature: ActiveFeature): void => {
setSelected(feature.geometry)
setClickInfo(feature)
}

/**
* Handle over event on the map. Show the popover with the area info.
*/
const onHover = useCallback((event: MapLayerMouseEvent) => {
const obLayerId = event.features?.findIndex((f) => f.layer.id === 'crags' || f.layer.id === 'crag-group-boundaries') ?? -1
const obLayerId = event.features?.findIndex((f) => f.layer.id === 'crag-markers' || f.layer.id === 'crag-name-labels' || 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: transformTileProps(feature.properties as TileProps),
mapInstance
})
}
const { layer, geometry, properties } = feature
setHoverInfo(tileToFeature(layer.id, event.point, geometry, properties as TileProps, mapInstance))
}
} else {
setHoverInfo(null)
Expand Down Expand Up @@ -157,18 +147,19 @@ export const GlobalMap: React.FC<GlobalMapProps> = ({
mapStyle={mapStyle}
cursor={cursor}
cooperativeGestures={showFullscreenControl}
interactiveLayerIds={['crags', 'crag-group-boundaries']}
interactiveLayerIds={['crag-markers', 'crag-name-labels', 'crag-group-boundaries', 'organizations']}
>
<MapToolbar layerState={dataLayersDisplayState} onChange={setDataLayersDisplayState} />
<MapLayersSelector emit={updateMapLayer} />
<ScaleControl unit='imperial' style={{ marginBottom: 10 }} position='bottom-left' />
<ScaleControl unit='metric' style={{ marginBottom: 0 }} position='bottom-left' />

<OBCustomLayers />
<OBCustomLayers layersState={dataLayersDisplayState} />
{showFullscreenControl && <FullscreenControl />}
<NavigationControl showCompass={false} position='bottom-right' />
{selected != null &&
<SelectedFeature geometry={selected} />}
<AreaInfoDrawer data={clickInfo} />
<AreaInfoDrawer feature={clickInfo} />
{hoverInfo != null && (
<AreaInfoHover
{...hoverInfo}
Expand Down
2 changes: 1 addition & 1 deletion src/components/maps/MapLayersSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const MapLayersSelector: React.FC<Props> = ({ emit }) => {
<div className='px-1.5' key={key} onClick={() => emitMap(key)}>
<span className='grid grid-cols-1 justify-items-center'>
<img
className='w-12 h-12 md:w-16 md:h-16 rounded col-span-1 shadow border-[1px] border-base-300'
className='w-12 h-12 md:w-16 md:h-16 rounded col-span-1 shadow border-base-300'
src={imgUrl}
alt='Currently selected maptiler layer'
/>
Expand Down
25 changes: 25 additions & 0 deletions src/components/maps/MapToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { DataLayersDisplayState } from './GlobalMap'

export interface MapToolbarProps {
layerState: DataLayersDisplayState
onChange: (newLayerState: DataLayersDisplayState) => void
}

/**
* Toolbar for filtering/toggling data layers
*/
export const MapToolbar: React.FC<MapToolbarProps> = ({ onChange, layerState }) => {
const { cragGroups } = layerState
return (
<div className='absolute top-20 md:top-6 left-0 w-screen flex flex-col items-center justify-center'>
<ul className='p-2.5 flex items-center gap-2 bg-base-200 rounded-box shadow-md'>
<li className='flex items-center gap-2'>
<input
type='checkbox' className='checkbox' checked={cragGroups}
onChange={() => onChange({ ...layerState, cragGroups: !cragGroups })}
/> Crag groups
</li>
</ul>
</div>
)
}
113 changes: 78 additions & 35 deletions src/components/maps/OBCustomLayers.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,88 @@
import { Source, Layer } from 'react-map-gl'
import { DataLayersDisplayState } from './GlobalMap'

interface OBCustomLayersProps {
layersState: DataLayersDisplayState
}
/**
* OpenBeta custom map tiles.
* - Crags: crag markers and labels
* - Crag groups: polygon boundaries for crag groups (TBD)
*/
export const OBCustomLayers: React.FC = () => {
export const OBCustomLayers: React.FC<OBCustomLayersProps> = ({ layersState }) => {
const { cragGroups } = layersState
return (
<Source
id='crags-source' // can be any unique id
type='vector'
tiles={[
'https://maptiles.openbeta.io/crags/{z}/{x}/{y}.pbf'
]}
attribution='© OpenBeta contributors'
>
<Layer
id='crags' // can be any unique id. Must match the id in ReactMapGL.interactiveLayerIds
type='symbol'
source-layer='crags' // source-layer is the layer name in the vector tileset
layout={{
'icon-anchor': 'center',
'text-field': ['get', 'name'],
'text-size': 12,
'text-font': ['Segoe UI', 'Roboto', 'Ubuntu', 'Helvetica Neue', 'Oxygen', 'Cantarell', 'sans-serif'],
'icon-image': 'circle-dot',
'icon-size': ['interpolate', ['linear'], ['zoom'], 8, 0.5, 18, 1],
'text-variable-anchor': ['bottom', 'top', 'left', 'right'],
'text-radial-offset': ['interpolate', ['linear'], ['zoom'], 6, 0.25, 16, 1],
'text-optional': true,
'icon-padding': 8,
'symbol-sort-key': ['match', ['string', ['get', 'media']], ['[]'], 1, 0]
}}
paint={{
'icon-color': ['match', ['string', ['get', 'media']], ['[]'], '#111827', '#881337'],
'text-halo-blur': 1,
'text-halo-width': 2,
'text-color': ['match', ['string', ['get', 'media']], ['[]'], '#111827', '#881337'],
'text-halo-color': '#f8fafc'
}}
/>
</Source>
<>
<Source
id='crags-source2' // can be any unique id
type='vector'
tiles={[
'https://maptiles.openbeta.io/crag-groups/{z}/{x}/{y}.pbf'
]}
maxzoom={8}
attribution='© OpenBeta contributors'
>

<Layer
id='crag-group-boundaries' // can be any unique id. Must match the id in ReactMapGL.interactiveLayerIds
type='line'
source-layer='crag-groups' // source-layer is the layer name in the vector tileset
paint={{
'line-color': '#ec407a',
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1, 19, 4],
'line-opacity': 0.8
}}
layout={{
visibility: cragGroups ? 'visible' : 'none'
}}
/>
</Source>
<Source
id='crags-source1' // can be any unique id
type='vector'
tiles={[
'https://maptiles.openbeta.io/crags/{z}/{x}/{y}.pbf'
]}
maxzoom={11}
attribution='© OpenBeta contributors'
>
<Layer
id='crag-markers' // can be any unique id. Must match the id in ReactMapGL.interactiveLayerIds
type='circle'
source-layer='crags' // source-layer is the layer name in the vector tileset
paint={{
'circle-radius': { stops: [[8, 2], [18, 4]] },
'circle-color': '#ff6e40',
'circle-blur': 0.2,
'circle-stroke-width': 1,
'circle-stroke-color': '#546e7a',
'circle-stroke-opacity': 0.8
}}
minzoom={6}
/>
<Layer
id='crag-name-labels' // can be any unique id. Must match the id in ReactMapGL.interactiveLayerIds
type='symbol'
source-layer='crags' // source-layer is the layer name in the vector tileset
layout={{
'icon-anchor': 'center',
'text-field': ['get', 'name'],
'text-size': { stops: [[8, 10], [12, 12]] },
'text-font': ['Segoe UI', 'Roboto', 'Ubuntu', 'Helvetica Neue', 'Oxygen', 'Cantarell', 'sans-serif'],
'text-variable-anchor': ['bottom', 'top', 'left', 'right'],
'text-radial-offset': ['interpolate', ['linear'], ['zoom'], 16, 0.5],
'text-optional': true,
'symbol-sort-key': ['match', ['string', ['get', 'media']], ['[]'], 1, 0]
}}
paint={{
'icon-color': ['match', ['string', ['get', 'media']], ['[]'], '#111827', '#881337'],
'text-halo-blur': 1,
'text-halo-width': 2,
'text-color': ['match', ['string', ['get', 'media']], ['[]'], '#111827', '#881337'],
'text-halo-color': '#f8fafc'
}}
/>
</Source>
</>
)
}
28 changes: 28 additions & 0 deletions src/components/maps/TileHandlers/AreaInfoDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as Popover from '@radix-ui/react-popover'

import { ActiveFeature, CragFeatureProperties, CragGroupFeatureProps } from '../TileTypes'
import { CragPanelContent } from './CragPanelContent'
import { CragGroupPanelContent } from './CragGroupPanelContent'

/**
* Area info panel
*/
export const AreaInfoDrawer: React.FC<{ feature: ActiveFeature | null, onClose?: () => void }> = ({ feature, onClose }) => {
if (feature == null) return null
let ContentComponent = null
switch (feature.type) {
case 'crags':
ContentComponent = <CragPanelContent {...(feature.data as CragFeatureProperties)} />
break
case 'crag-groups':
ContentComponent = <CragGroupPanelContent {...(feature.data as CragGroupFeatureProps)} />
}
return (
<Popover.Root open={feature != null}>
<Popover.Anchor className='absolute top-3 left-3 z-50' />
<Popover.Content align='start' className='hover:outline-none'>
{ContentComponent}
</Popover.Content>
</Popover.Root>
)
}
Loading

0 comments on commit 4404ab7

Please sign in to comment.