From b5be84bbe4c8cf051c9eaa5ba580bcfb9a763e74 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 3 Oct 2021 15:36:10 +0200 Subject: [PATCH] Make mapbox map more reusable + add coordinates to exif --- api/graphql/generated.go | 206 +++++++++++++++--- api/graphql/models/generated.go | 7 + api/graphql/models/media_exif.go | 11 + api/graphql/schema.graphql | 11 +- ui/src/Pages/PlacesPage/MapClusterMarker.tsx | 12 - ui/src/Pages/PlacesPage/MapPresentMarker.tsx | 3 + ui/src/Pages/PlacesPage/PlacesPage.tsx | 168 ++++++-------- .../PlacesPage/__generated__/mediaGeoJson.ts | 15 ++ .../PlacesPage/mapboxHelperFunctions.tsx | 80 ------- ui/src/components/mapbox/MapboxMap.tsx | 74 +++++++ .../mapbox/__generated__/mapboxToken.ts | 19 ++ .../mapbox/mapboxHelperFunctions.tsx | 93 ++++++++ 12 files changed, 481 insertions(+), 218 deletions(-) create mode 100644 ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts delete mode 100644 ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx create mode 100644 ui/src/components/mapbox/MapboxMap.tsx create mode 100644 ui/src/components/mapbox/__generated__/mapboxToken.ts create mode 100644 ui/src/components/mapbox/mapboxHelperFunctions.tsx diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 910e6488..08d80d2a 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -75,6 +75,11 @@ type ComplexityRoot struct { Token func(childComplexity int) int } + Coordinates struct { + Latitude func(childComplexity int) int + Longitude func(childComplexity int) int + } + FaceGroup struct { ID func(childComplexity int) int ImageFaceCount func(childComplexity int) int @@ -122,6 +127,7 @@ type ComplexityRoot struct { MediaExif struct { Aperture func(childComplexity int) int Camera func(childComplexity int) int + Coordinates func(childComplexity int) int DateShot func(childComplexity int) int Exposure func(childComplexity int) int ExposureProgram func(childComplexity int) int @@ -473,6 +479,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AuthorizeResult.Token(childComplexity), true + case "Coordinates.latitude": + if e.complexity.Coordinates.Latitude == nil { + break + } + + return e.complexity.Coordinates.Latitude(childComplexity), true + + case "Coordinates.longitude": + if e.complexity.Coordinates.Longitude == nil { + break + } + + return e.complexity.Coordinates.Longitude(childComplexity), true + case "FaceGroup.id": if e.complexity.FaceGroup.ID == nil { break @@ -695,6 +715,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.MediaExif.Camera(childComplexity), true + case "MediaEXIF.coordinates": + if e.complexity.MediaExif.Coordinates == nil { + break + } + + return e.complexity.MediaExif.Coordinates(childComplexity), true + case "MediaEXIF.dateShot": if e.complexity.MediaExif.DateShot == nil { break @@ -1745,7 +1772,7 @@ type Query { "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! @isAuthorized "Get the mapbox api token, returns null if mapbox is not enabled" - mapboxToken: String @isAuthorized + mapboxToken: String shareToken(credentials: ShareTokenCredentials!): ShareToken! shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean! @@ -1955,8 +1982,6 @@ type Album { path: [Album!]! shares: [ShareToken!]! - - #coverID: Int } type MediaURL { @@ -2029,6 +2054,14 @@ type MediaEXIF { flash: Int "An index describing the mode for adjusting the exposure of the image" exposureProgram: Int + coordinates: Coordinates +} + +type Coordinates { + "GPS latitude in degrees" + latitude: Float! + "GPS longitude in degrees" + longitude: Float! } type VideoMetadata { @@ -3459,6 +3492,76 @@ func (ec *executionContext) _AuthorizeResult_token(ctx context.Context, field gr return ec.marshalOString2ᚖstring(ctx, field.Selections, res) } +func (ec *executionContext) _Coordinates_latitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Coordinates", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Latitude, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + +func (ec *executionContext) _Coordinates_longitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Coordinates", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Longitude, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + func (ec *executionContext) _FaceGroup_id(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -4853,6 +4956,38 @@ func (ec *executionContext) _MediaEXIF_exposureProgram(ctx context.Context, fiel return ec.marshalOInt2ᚖint64(ctx, field.Selections, res) } +func (ec *executionContext) _MediaEXIF_coordinates(ctx context.Context, field graphql.CollectedField, obj *models.MediaEXIF) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MediaEXIF", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Coordinates(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.Coordinates) + fc.Result = res + return ec.marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx, field.Selections, res) +} + func (ec *executionContext) _MediaURL_url(ctx context.Context, field graphql.CollectedField, obj *models.MediaURL) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -7283,28 +7418,8 @@ func (ec *executionContext) _Query_mapboxToken(ctx context.Context, field graphq ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - directive0 := func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().MapboxToken(rctx) - } - directive1 := func(ctx context.Context) (interface{}, error) { - if ec.directives.IsAuthorized == nil { - return nil, errors.New("directive isAuthorized is not implemented") - } - return ec.directives.IsAuthorized(ctx, nil, directive0) - } - - tmp, err := directive1(rctx) - if err != nil { - return nil, graphql.ErrorOnPath(ctx, err) - } - if tmp == nil { - return nil, nil - } - if data, ok := tmp.(*string); ok { - return data, nil - } - return nil, fmt.Errorf(`unexpected type %T from directive, should be *string`, tmp) + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().MapboxToken(rctx) }) if err != nil { ec.Error(ctx, err) @@ -10429,6 +10544,38 @@ func (ec *executionContext) _AuthorizeResult(ctx context.Context, sel ast.Select return out } +var coordinatesImplementors = []string{"Coordinates"} + +func (ec *executionContext) _Coordinates(ctx context.Context, sel ast.SelectionSet, obj *models.Coordinates) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, coordinatesImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Coordinates") + case "latitude": + out.Values[i] = ec._Coordinates_latitude(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "longitude": + out.Values[i] = ec._Coordinates_longitude(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var faceGroupImplementors = []string{"FaceGroup"} func (ec *executionContext) _FaceGroup(ctx context.Context, sel ast.SelectionSet, obj *models.FaceGroup) graphql.Marshaler { @@ -10824,6 +10971,8 @@ func (ec *executionContext) _MediaEXIF(ctx context.Context, sel ast.SelectionSet out.Values[i] = ec._MediaEXIF_flash(ctx, field, obj) case "exposureProgram": out.Values[i] = ec._MediaEXIF_exposureProgram(ctx, field, obj) + case "coordinates": + out.Values[i] = ec._MediaEXIF_coordinates(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -12875,6 +13024,13 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return graphql.MarshalBoolean(*v) } +func (ec *executionContext) marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx context.Context, sel ast.SelectionSet, v *models.Coordinates) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Coordinates(ctx, sel, v) +} + func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v interface{}) (*float64, error) { if v == nil { return nil, nil diff --git a/api/graphql/models/generated.go b/api/graphql/models/generated.go index 84eaf076..e1922df7 100644 --- a/api/graphql/models/generated.go +++ b/api/graphql/models/generated.go @@ -15,6 +15,13 @@ type AuthorizeResult struct { Token *string `json:"token"` } +type Coordinates struct { + // GPS latitude in degrees + Latitude float64 `json:"latitude"` + // GPS longitude in degrees + Longitude float64 `json:"longitude"` +} + type MediaDownload struct { Title string `json:"title"` MediaURL *MediaURL `json:"mediaUrl"` diff --git a/api/graphql/models/media_exif.go b/api/graphql/models/media_exif.go index dec9d67b..37af1e47 100644 --- a/api/graphql/models/media_exif.go +++ b/api/graphql/models/media_exif.go @@ -28,3 +28,14 @@ func (MediaEXIF) TableName() string { func (exif *MediaEXIF) Media() *Media { panic("not implemented") } + +func (exif *MediaEXIF) Coordinates() *Coordinates { + if exif.GPSLatitude == nil || exif.GPSLongitude == nil { + return nil + } + + return &Coordinates{ + Latitude: *exif.GPSLatitude, + Longitude: *exif.GPSLongitude, + } +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 166b435a..36a2df7d 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -76,7 +76,7 @@ type Query { "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! @isAuthorized "Get the mapbox api token, returns null if mapbox is not enabled" - mapboxToken: String @isAuthorized + mapboxToken: String shareToken(credentials: ShareTokenCredentials!): ShareToken! shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean! @@ -358,6 +358,15 @@ type MediaEXIF { flash: Int "An index describing the mode for adjusting the exposure of the image" exposureProgram: Int + "GPS coordinates of where the image was taken" + coordinates: Coordinates +} + +type Coordinates { + "GPS latitude in degrees" + latitude: Float! + "GPS longitude in degrees" + longitude: Float! } type VideoMetadata { diff --git a/ui/src/Pages/PlacesPage/MapClusterMarker.tsx b/ui/src/Pages/PlacesPage/MapClusterMarker.tsx index 47d0eb8e..4ec4423b 100644 --- a/ui/src/Pages/PlacesPage/MapClusterMarker.tsx +++ b/ui/src/Pages/PlacesPage/MapClusterMarker.tsx @@ -52,14 +52,6 @@ const MapClusterMarker = ({ const thumbnail = JSON.parse(marker.thumbnail) const presentMedia = () => { - // presentMarkerClicked({ - // dispatchMedia: dispatchMarkerMedia, - // mediaState: markerMediaState, - // marker: { - // cluster: !!marker.cluster, - // id: marker.cluster ? marker.cluster_id : marker.media_id, - // }, - // }) dispatchMarkerMedia({ type: 'replacePresentMarker', marker: { @@ -67,10 +59,6 @@ const MapClusterMarker = ({ id: marker.cluster ? marker.cluster_id : marker.media_id, }, }) - // setPresentMarker({ - // cluster: !!marker.cluster, - // id: marker.cluster ? marker.cluster_id : marker.media_id, - // }) } return ( diff --git a/ui/src/Pages/PlacesPage/MapPresentMarker.tsx b/ui/src/Pages/PlacesPage/MapPresentMarker.tsx index 2ffb15ef..c4b4be49 100644 --- a/ui/src/Pages/PlacesPage/MapPresentMarker.tsx +++ b/ui/src/Pages/PlacesPage/MapPresentMarker.tsx @@ -80,6 +80,9 @@ type MapPresetMarkerProps = { dispatchMarkerMedia: React.Dispatch } +/** + * Full-screen present-view that works with PlacesState + */ const MapPresentMarker = ({ map, markerMediaState, diff --git a/ui/src/Pages/PlacesPage/PlacesPage.tsx b/ui/src/Pages/PlacesPage/PlacesPage.tsx index 1d150057..b41f4328 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.tsx +++ b/ui/src/Pages/PlacesPage/PlacesPage.tsx @@ -1,30 +1,24 @@ import { gql, useQuery } from '@apollo/client' import type mapboxgl from 'mapbox-gl' -import React, { useEffect, useReducer, useRef, useState } from 'react' +import React, { useReducer } from 'react' import { Helmet } from 'react-helmet' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import Layout from '../../components/layout/Layout' -import { makeUpdateMarkers } from './mapboxHelperFunctions' -import MapPresentMarker from './MapPresentMarker' +import { registerMediaMarkers } from '../../components/mapbox/mapboxHelperFunctions' +import useMapboxMap from '../../components/mapbox/MapboxMap' import { urlPresentModeSetupHook } from '../../components/photoGallery/photoGalleryReducer' -import { placesReducer } from './placesReducer' - -import 'mapbox-gl/dist/mapbox-gl.css' +import MapPresentMarker from './MapPresentMarker' +import { PlacesAction, placesReducer } from './placesReducer' +import { mediaGeoJson } from './__generated__/mediaGeoJson' const MapWrapper = styled.div` width: 100%; height: calc(100vh - 120px); ` -const MapContainer = styled.div` - width: 100%; - height: 100%; -` - const MAPBOX_DATA_QUERY = gql` - query placePageMapboxToken { - mapboxToken + query mediaGeoJson { myMediaGeoJson } ` @@ -37,10 +31,9 @@ export type PresentMarker = { const MapPage = () => { const { t } = useTranslation() - const [mapboxLibrary, setMapboxLibrary] = useState() - const mapContainer = useRef(null) - const map = useRef(null) - // const [presentMarker, setPresentMarker] = useState(null) + const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY, { + fetchPolicy: 'cache-first', + }) const [markerMediaState, dispatchMarkerMedia] = useReducer(placesReducer, { presenting: false, @@ -48,76 +41,21 @@ const MapPage = () => { media: [], }) - const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY) + const { mapContainer, mapboxMap, mapboxToken } = useMapboxMap({ + configureMapbox: configureMapbox({ mapboxData, dispatchMarkerMedia }), + }) - useEffect(() => { - async function loadMapboxLibrary() { - const mapbox = (await import('mapbox-gl')).default - - setMapboxLibrary(mapbox) - } - loadMapboxLibrary() - }, []) - - useEffect(() => { - if ( - mapboxLibrary == null || - mapContainer.current == null || - mapboxData == null || - map.current != null - ) { - return - } - - mapboxLibrary.accessToken = mapboxData.mapboxToken - - map.current = new mapboxLibrary.Map({ - container: mapContainer.current, - style: 'mapbox://styles/mapbox/streets-v11', - zoom: 1, - }) - - // Add map navigation control - map.current.addControl(new mapboxLibrary.NavigationControl()) - - map.current.on('load', () => { - if (map.current == null) { - console.error('ERROR: map is null') - return - } - - map.current.addSource('media', { - type: 'geojson', - data: mapboxData.myMediaGeoJson, - cluster: true, - clusterRadius: 50, - clusterProperties: { - thumbnail: ['coalesce', ['get', 'thumbnail'], false], - }, + urlPresentModeSetupHook({ + dispatchMedia: dispatchMarkerMedia, + openPresentMode: event => { + dispatchMarkerMedia({ + type: 'openPresentMode', + activeIndex: event.state.activeIndex, }) + }, + }) - // Add dummy layer for features to be queryable - map.current.addLayer({ - id: 'media-points', - type: 'circle', - source: 'media', - filter: ['!', true], - }) - - const updateMarkers = makeUpdateMarkers({ - map: map.current, - mapboxLibrary, - dispatchMarkerMedia, - }) - - map.current.on('move', updateMarkers) - map.current.on('moveend', updateMarkers) - map.current.on('sourcedata', updateMarkers) - updateMarkers() - }) - }, [mapContainer, mapboxLibrary, mapboxData]) - - if (mapboxData && mapboxData.mapboxToken == null) { + if (mapboxData && mapboxToken == null) { return (

Mapbox token is not set

@@ -134,34 +72,64 @@ const MapPage = () => { ) } - urlPresentModeSetupHook({ - dispatchMedia: dispatchMarkerMedia, - openPresentMode: event => { - dispatchMarkerMedia({ - type: 'openPresentMode', - activeIndex: event.state.activeIndex, - }) - }, - }) - return ( {/* */} {/* */} - - - + {mapContainer} ) } +const configureMapbox = + ({ + mapboxData, + dispatchMarkerMedia, + }: { + mapboxData?: mediaGeoJson + dispatchMarkerMedia: React.Dispatch + }) => + (map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl) => { + // Add map navigation control + map.addControl(new mapboxLibrary.NavigationControl()) + + map.on('load', () => { + if (map == null) { + console.error('ERROR: map is null') + return + } + + map.addSource('media', { + type: 'geojson', + data: mapboxData?.myMediaGeoJson, + cluster: true, + clusterRadius: 50, + clusterProperties: { + thumbnail: ['coalesce', ['get', 'thumbnail'], false], + }, + }) + + // Add dummy layer for features to be queryable + map.addLayer({ + id: 'media-points', + type: 'circle', + source: 'media', + filter: ['!', true], + }) + + registerMediaMarkers({ + map: map, + mapboxLibrary, + dispatchMarkerMedia, + }) + }) + } + export default MapPage diff --git a/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts b/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts new file mode 100644 index 00000000..5f6d5da5 --- /dev/null +++ b/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts @@ -0,0 +1,15 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: mediaGeoJson +// ==================================================== + +export interface mediaGeoJson { + /** + * Get media owned by the logged in user, returned in GeoJson format + */ + myMediaGeoJson: any +} diff --git a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx deleted file mode 100644 index dad9147c..00000000 --- a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type mapboxgl from 'mapbox-gl' -import type geojson from 'geojson' -import React from 'react' -import ReactDOM from 'react-dom' -import MapClusterMarker from './MapClusterMarker' -import { MediaMarker } from './MapPresentMarker' -import { PlacesAction } from './placesReducer' - -const markers: { [key: string]: mapboxgl.Marker } = {} -let markersOnScreen: typeof markers = {} - -type makeUpdateMarkersArgs = { - map: mapboxgl.Map - mapboxLibrary: typeof mapboxgl - dispatchMarkerMedia: React.Dispatch - // setPresentMarker: React.Dispatch> -} - -export const makeUpdateMarkers = ({ - map, - mapboxLibrary, - dispatchMarkerMedia, -}: makeUpdateMarkersArgs) => () => { - const newMarkers: typeof markers = {} - const features = map.querySourceFeatures('media') - - // for every media on the screen, create an HTML marker for it (if we didn't yet), - // and add it to the map if it's not there already - for (const feature of features) { - const point = feature.geometry as geojson.Point - const coords = point.coordinates as [number, number] - const props = feature.properties as MediaMarker - if (props == null) { - console.warn('WARN: geojson feature had no properties', feature) - continue - } - - const id = props.cluster - ? `cluster_${props.cluster_id}` - : `media_${props.media_id}` - - let marker = markers[id] - if (!marker) { - const el = createClusterPopupElement(props, { - dispatchMarkerMedia, - }) - marker = markers[id] = new mapboxLibrary.Marker({ - element: el, - }).setLngLat(coords) - } - newMarkers[id] = marker - - if (!markersOnScreen[id]) marker.addTo(map) - } - // for every marker we've added previously, remove those that are no longer visible - for (const id in markersOnScreen) { - if (!newMarkers[id]) markersOnScreen[id].remove() - } - markersOnScreen = newMarkers -} - -function createClusterPopupElement( - geojsonProps: MediaMarker, - { - dispatchMarkerMedia, - }: { - dispatchMarkerMedia: React.Dispatch - } -) { - // setPresentMarker: React.Dispatch> - const el = document.createElement('div') - ReactDOM.render( - , - el - ) - return el -} diff --git a/ui/src/components/mapbox/MapboxMap.tsx b/ui/src/components/mapbox/MapboxMap.tsx new file mode 100644 index 00000000..d996e1c4 --- /dev/null +++ b/ui/src/components/mapbox/MapboxMap.tsx @@ -0,0 +1,74 @@ +import React, { useState, useRef, useEffect } from 'react' +import { gql, useQuery } from '@apollo/client' +import type mapboxgl from 'mapbox-gl' +import styled from 'styled-components' + +import 'mapbox-gl/dist/mapbox-gl.css' +import { mapboxToken } from './__generated__/mapboxToken' + +const MAPBOX_TOKEN_QUERY = gql` + query mapboxToken { + mapboxToken + myMediaGeoJson + } +` + +const MapContainer = styled.div` + width: 100%; + height: 100%; +` + +type MapboxMapProps = { + configureMapbox(map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl): void + readonly initialZoom?: number +} + +const useMapboxMap = ({ configureMapbox, initialZoom = 1 }: MapboxMapProps) => { + const [mapboxLibrary, setMapboxLibrary] = useState() + const mapContainer = useRef(null) + const map = useRef(null) + + const { data: mapboxData } = useQuery(MAPBOX_TOKEN_QUERY, { + fetchPolicy: 'cache-first', + }) + + useEffect(() => { + async function loadMapboxLibrary() { + const mapbox = (await import('mapbox-gl')).default + + setMapboxLibrary(mapbox) + } + loadMapboxLibrary() + }, []) + + useEffect(() => { + if ( + mapboxLibrary == null || + mapContainer.current == null || + mapboxData == null || + map.current != null + ) { + return + } + + if (mapboxData.mapboxToken) + mapboxLibrary.accessToken = mapboxData.mapboxToken + + map.current = new mapboxLibrary.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/streets-v11', + zoom: initialZoom, + }) + + configureMapbox(map.current, mapboxLibrary) + }, [mapContainer, mapboxLibrary, mapboxData]) + + return { + mapContainer: , + mapboxMap: map.current, + mapboxLibrary, + mapboxToken: mapboxData?.mapboxToken || null, + } +} + +export default useMapboxMap diff --git a/ui/src/components/mapbox/__generated__/mapboxToken.ts b/ui/src/components/mapbox/__generated__/mapboxToken.ts new file mode 100644 index 00000000..644bbae6 --- /dev/null +++ b/ui/src/components/mapbox/__generated__/mapboxToken.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: mapboxToken +// ==================================================== + +export interface mapboxToken { + /** + * Get the mapbox api token, returns null if mapbox is not enabled + */ + mapboxToken: string | null + /** + * Get media owned by the logged in user, returned in GeoJson format + */ + myMediaGeoJson: any +} diff --git a/ui/src/components/mapbox/mapboxHelperFunctions.tsx b/ui/src/components/mapbox/mapboxHelperFunctions.tsx new file mode 100644 index 00000000..6ea3a206 --- /dev/null +++ b/ui/src/components/mapbox/mapboxHelperFunctions.tsx @@ -0,0 +1,93 @@ +import type mapboxgl from 'mapbox-gl' +import type geojson from 'geojson' +import React from 'react' +import ReactDOM from 'react-dom' +import MapClusterMarker from '../../Pages/PlacesPage/MapClusterMarker' +import { MediaMarker } from '../../Pages/PlacesPage/MapPresentMarker' +import { PlacesAction } from '../../Pages/PlacesPage/placesReducer' + +const markers: { [key: string]: mapboxgl.Marker } = {} +let markersOnScreen: typeof markers = {} + +type registerMediaMarkersArgs = { + map: mapboxgl.Map + mapboxLibrary: typeof mapboxgl + dispatchMarkerMedia: React.Dispatch +} + +/** + * Add appropriate event handlers to the map, to render and update media markers + * Expects the provided mapbox map to contain geojson source of media + */ +export const registerMediaMarkers = (args: registerMediaMarkersArgs) => { + const updateMarkers = makeUpdateMarkers(args) + + args.map.on('move', updateMarkers) + args.map.on('moveend', updateMarkers) + args.map.on('sourcedata', updateMarkers) + updateMarkers() +} + +/** + * Make a function that can be passed to Mapbox to tell it how to render and update the image markers + */ +const makeUpdateMarkers = + ({ map, mapboxLibrary, dispatchMarkerMedia }: registerMediaMarkersArgs) => + () => { + const newMarkers: typeof markers = {} + const features = map.querySourceFeatures('media') + + // for every media on the screen, create an HTML marker for it (if we didn't yet), + // and add it to the map if it's not there already + for (const feature of features) { + const point = feature.geometry as geojson.Point + const coords = point.coordinates as [number, number] + const props = feature.properties as MediaMarker + if (props == null) { + console.warn('WARN: geojson feature had no properties', feature) + continue + } + + const id = props.cluster + ? `cluster_${props.cluster_id}` + : `media_${props.media_id}` + + let marker = markers[id] + if (!marker) { + const el = createClusterPopupElement(props, { + dispatchMarkerMedia, + }) + marker = markers[id] = new mapboxLibrary.Marker({ + element: el, + }).setLngLat(coords) + } + newMarkers[id] = marker + + if (!markersOnScreen[id]) marker.addTo(map) + } + // for every marker we've added previously, remove those that are no longer visible + for (const id in markersOnScreen) { + if (!newMarkers[id]) markersOnScreen[id].remove() + } + markersOnScreen = newMarkers + } + +function createClusterPopupElement( + geojsonProps: MediaMarker, + { + dispatchMarkerMedia, + }: { + dispatchMarkerMedia: React.Dispatch + } +) { + // setPresentMarker: React.Dispatch> + const el = document.createElement('div') + ReactDOM.render( + , + el + ) + return el +}