diff --git a/pages/camera-view.tsx b/pages/camera-view.tsx new file mode 100644 index 000000000..255924eaf --- /dev/null +++ b/pages/camera-view.tsx @@ -0,0 +1,19 @@ +import { GetServerSideProps } from 'next'; +import { views } from '../src/services/landmarks/views'; + +export const Config = () => null; + +export const getServerSideProps: GetServerSideProps = async ({ + res, + query, +}) => { + res.setHeader('Content-Type', 'application/json'); + + const responseBody = views[`${query.shortId}`] ?? null; + res.write(JSON.stringify(responseBody)); + res.end(); + + return { props: {} }; +}; + +export default Config; diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 6f8e73c60..e52a21a8a 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -1,4 +1,4 @@ -import React, { Ref, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import Cookies from 'js-cookie'; import nextCookies from 'next-cookies'; @@ -59,16 +59,25 @@ export const getMapViewFromHash = (): View | undefined => { }; const useUpdateViewFromFeature = () => { - const { feature } = useFeatureContext(); - const { setView } = useMapStateContext(); + const { feature, accessMethod } = useFeatureContext(); + const { setView, setLandmarkPitch, setLandmarkBearing } = + useMapStateContext(); React.useEffect(() => { - if (!feature?.center) return; + if (!feature) return; + if (!feature.center) return; if (getMapViewFromHash()) return; + if (accessMethod === 'click') return; + + const { landmarkView, center } = feature; + + const [lon, lat] = center.map((deg) => deg.toFixed(4)); + const zoom = landmarkView?.zoom ?? 17; + setView([`${zoom}`, lat, lon]); - const [lon, lat] = feature.center.map((deg) => deg.toFixed(4)); - setView(['17.00', lat, lon]); - }, [feature, setView]); + setLandmarkBearing(landmarkView?.bearing); + setLandmarkPitch(landmarkView?.pitch); + }, [feature, setView, setLandmarkBearing, setLandmarkPitch, accessMethod]); }; const useUpdateViewFromHash = () => { diff --git a/src/components/FeaturePanel/FeaturePanelFooter.tsx b/src/components/FeaturePanel/FeaturePanelFooter.tsx index 026983b4f..8ccaac2e7 100644 --- a/src/components/FeaturePanel/FeaturePanelFooter.tsx +++ b/src/components/FeaturePanel/FeaturePanelFooter.tsx @@ -7,6 +7,7 @@ import Coordinates from './Coordinates'; import { t } from '../../services/intl'; import { ObjectsAround } from './ObjectsAround'; import React from 'react'; +import { RecommendedView } from './RecommendedView'; type Props = { advanced: boolean; @@ -31,6 +32,12 @@ export const FeaturePanelFooter = ({ + {feature.landmarkView && ( + <> +
+ + + )}
{osmappLink}
diff --git a/src/components/FeaturePanel/RecommendedView.tsx b/src/components/FeaturePanel/RecommendedView.tsx new file mode 100644 index 000000000..f3e996fc5 --- /dev/null +++ b/src/components/FeaturePanel/RecommendedView.tsx @@ -0,0 +1,42 @@ +import styled from '@emotion/styled'; +import { Button } from '@mui/material'; +import { useFeatureContext } from '../utils/FeatureContext'; +import { getGlobalMap } from '../../services/mapStorage'; +import isNumber from 'lodash/isNumber'; +import { t } from '../../services/intl'; + +const SubtleButton = styled(Button)(({ theme }) => ({ + textTransform: 'none', + backgroundColor: 'transparent', + color: theme.palette.text.primary, + border: `1px solid ${theme.palette.divider}`, + padding: '4px 8px', + margin: '4px 0', + fontSize: '0.75rem', + '&:hover': { + backgroundColor: theme.palette.action.hover, + borderColor: theme.palette.text.primary, + }, +})); + +export const RecommendedView = () => { + const { feature } = useFeatureContext(); + return ( + { + const { landmarkView } = feature; + const { zoom, pitch, bearing } = landmarkView; + const [lng, lat] = feature.center; + // flyTo breaks the map + getGlobalMap()?.jumpTo({ + center: { lng, lat }, + ...(isNumber(zoom) ? { zoom } : {}), + ...(isNumber(pitch) ? { pitch } : {}), + ...(isNumber(bearing) ? { bearing } : {}), + }); + }} + > + {t('featurepanel.recommended_view')} + + ); +}; diff --git a/src/components/Map/BrowserMap.tsx b/src/components/Map/BrowserMap.tsx index 72d25e241..8adba8427 100644 --- a/src/components/Map/BrowserMap.tsx +++ b/src/components/Map/BrowserMap.tsx @@ -15,9 +15,10 @@ import { useUpdateStyle } from './behaviour/useUpdateStyle'; import { useInitMap } from './behaviour/useInitMap'; import { Translation } from '../../services/intl'; import { useToggleTerrainControl } from './behaviour/useToggleTerrainControl'; -import { webglSupported } from './helpers'; +import { supports3d, webglSupported } from './helpers'; import { useOnMapLongPressed } from './behaviour/useOnMapLongPressed'; import { useAddTopRightControls } from './useAddTopRightControls'; +import isNumber from 'lodash/isNumber'; const useOnMapLoaded = createMapEventHook<'load', [MapEventHandler<'load'>]>( (_, onMapLoaded) => ({ @@ -26,13 +27,22 @@ const useOnMapLoaded = createMapEventHook<'load', [MapEventHandler<'load'>]>( }), ); -const useUpdateMap = createMapEffectHook<[View]>((map, viewForMap) => { - const center: [number, number] = [ - parseFloat(viewForMap[2]), - parseFloat(viewForMap[1]), - ]; - map.jumpTo({ center, zoom: parseFloat(viewForMap[0]) }); -}); +const useUpdateMap = createMapEffectHook<[View, number, number, string[]]>( + (map, viewForMap, pitch, bearing, activeLayers) => { + const center: [number, number] = [ + parseFloat(viewForMap[2]), + parseFloat(viewForMap[1]), + ]; + + // flyTo makes the map unusable + map.jumpTo({ + center, + zoom: parseFloat(viewForMap[0]), + ...(supports3d(activeLayers) && isNumber(pitch) ? { pitch } : {}), + ...(supports3d(activeLayers) && isNumber(bearing) ? { bearing } : {}), + }); + }, +); const NotSupportedMessage = () => ( ( const BrowserMap = () => { const { userLayers } = useMapStateContext(); const mobileMode = useMobileMode(); - const { setFeature } = useFeatureContext(); + const { setFeature, setAccessMethod } = useFeatureContext(); const { mapLoaded, setMapLoaded } = useMapStateContext(); const [map, mapRef] = useInitMap(); useAddTopRightControls(map, mobileMode); - useOnMapClicked(map, setFeature); + useOnMapClicked(map, setFeature, setAccessMethod); useOnMapLongPressed(map, setFeature); useOnMapLoaded(map, setMapLoaded); useFeatureMarker(map); - const { viewForMap, setViewFromMap, setBbox, activeLayers } = - useMapStateContext(); + const { + viewForMap, + setViewFromMap, + setBbox, + activeLayers, + landmarkPitch, + landmarkBearing, + } = useMapStateContext(); useUpdateViewOnMove(map, setViewFromMap, setBbox); useToggleTerrainControl(map); - useUpdateMap(map, viewForMap); + useUpdateMap(map, viewForMap, landmarkPitch, landmarkBearing, activeLayers); useUpdateStyle(map, activeLayers, userLayers, mapLoaded); return
; diff --git a/src/components/Map/behaviour/useOnMapClicked.tsx b/src/components/Map/behaviour/useOnMapClicked.tsx index 843d4bee0..be7d84021 100644 --- a/src/components/Map/behaviour/useOnMapClicked.tsx +++ b/src/components/Map/behaviour/useOnMapClicked.tsx @@ -8,7 +8,7 @@ import { convertMapIdToOsmId, getIsOsmObject } from '../helpers'; import { maptilerFix } from './maptilerFix'; import { Feature, LonLat } from '../../../services/types'; import { createCoordsFeature, pushFeatureToRouter } from './utils'; -import { SetFeature } from '../../utils/FeatureContext'; +import { AccessMethod, SetFeature } from '../../utils/FeatureContext'; import { Map } from 'maplibre-gl'; const isSameOsmId = (feature: Feature, skeleton: Feature) => @@ -52,35 +52,43 @@ const coordsClicked = (map: Map, coords: LonLat, setFeature: SetFeature) => { }); }; -export const useOnMapClicked = createMapEventHook<'click', [SetFeature]>( - (map, setFeature) => ({ - eventType: 'click', - eventHandler: async ({ point }) => { - const coords = map.unproject(point).toArray(); - const features = map.queryRenderedFeatures(point); - if (!features.length) { - coordsClicked(map, coords, setFeature); - return; - } +export const useOnMapClicked = createMapEventHook< + 'click', + [SetFeature, (accessMethod: AccessMethod) => void] +>((map, setFeature, setAccessMethod) => ({ + eventType: 'click', + eventHandler: async ({ point }) => { + const coords = map.unproject(point).toArray(); + const features = map.queryRenderedFeatures(point); + if (!features.length) { + coordsClicked(map, coords, setFeature); + return; + } - const skeleton = getSkeleton(features[0], coords); - console.log(`clicked map feature (id=${features[0].id}): `, features[0]); // eslint-disable-line no-console - publishDbgObject('last skeleton', skeleton); + const skeleton = getSkeleton(features[0], coords); + // eslint-disable-next-line no-console + console.log( + `clicked map feature (id=${features[0].id}): `, + features[0], + 'shortId:', + getShortId(skeleton.osmMeta), + ); // eslint-disable-line no-console + publishDbgObject('last skeleton', skeleton); - if (skeleton.nonOsmObject) { - coordsClicked(map, coords, setFeature); - return; - } + if (skeleton.nonOsmObject) { + coordsClicked(map, coords, setFeature); + return; + } - // router wouldnt overwrite the skeleton if same url is already loaded - setFeature((feature) => - isSameOsmId(feature, skeleton) ? feature : skeleton, - ); + setAccessMethod('click'); + // router wouldnt overwrite the skeleton if same url is already loaded + setFeature((feature) => + isSameOsmId(feature, skeleton) ? feature : skeleton, + ); - const result = await maptilerFix(features[0], skeleton, features[0].id); - addFeatureCenterToCache(getShortId(skeleton.osmMeta), skeleton.center); // for ways/relations we dont receive center from OSM API - addFeatureCenterToCache(getShortId(result.osmMeta), skeleton.center); - pushFeatureToRouter(result); - }, - }), -); + const result = await maptilerFix(features[0], skeleton, features[0].id); + addFeatureCenterToCache(getShortId(skeleton.osmMeta), skeleton.center); // for ways/relations we dont receive center from OSM API + addFeatureCenterToCache(getShortId(result.osmMeta), skeleton.center); + pushFeatureToRouter(result); + }, +})); diff --git a/src/components/Map/helpers.ts b/src/components/Map/helpers.ts index 4550fc0c5..721f5dcb6 100644 --- a/src/components/Map/helpers.ts +++ b/src/components/Map/helpers.ts @@ -80,3 +80,6 @@ const isWebglSupported = () => { }; export const webglSupported = isBrowser() ? isWebglSupported() : true; + +export const supports3d = (activeLayers: string[]) => + activeLayers.includes('basic') || activeLayers.includes('outdoor'); diff --git a/src/components/SearchBox/AutocompleteInput.tsx b/src/components/SearchBox/AutocompleteInput.tsx index 8d4386016..4110069b7 100644 --- a/src/components/SearchBox/AutocompleteInput.tsx +++ b/src/components/SearchBox/AutocompleteInput.tsx @@ -68,7 +68,7 @@ export const AutocompleteInput: React.FC = ({ autocompleteRef, setOverpassLoading, }) => { - const { setFeature, setPreview } = useFeatureContext(); + const { setFeature, setPreview, setAccessMethod } = useFeatureContext(); const { bbox } = useMapStateContext(); const { showToast } = useSnackbar(); const mapCenter = useMapCenter(); @@ -93,6 +93,7 @@ export const AutocompleteInput: React.FC = ({ showToast, setOverpassLoading, router, + setAccessMethod, })} onHighlightChange={onHighlightFactory(setPreview)} getOptionDisabled={(o) => o.type === 'loader'} diff --git a/src/components/SearchBox/onSelectedFactory.ts b/src/components/SearchBox/onSelectedFactory.ts index b26c87a38..64d4c761c 100644 --- a/src/components/SearchBox/onSelectedFactory.ts +++ b/src/components/SearchBox/onSelectedFactory.ts @@ -18,6 +18,7 @@ import { StarOption, } from './types'; import { osmOptionSelected } from './options/openstreetmap'; +import { AccessMethod } from '../utils/FeatureContext'; const overpassOptionSelected = ( option: OverpassOption | PresetOption, @@ -67,6 +68,7 @@ type SetFeature = (feature: Feature | null) => void; const geocoderOptionSelected = ( option: GeocoderOption, setFeature: SetFeature, + setAccessMethod: (accessMethod: AccessMethod) => void, ) => { if (!option?.geocoder.geometry?.coordinates) return; @@ -75,6 +77,7 @@ const geocoderOptionSelected = ( addFeatureCenterToCache(getShortId(skeleton.osmMeta), skeleton.center); + setAccessMethod('search'); setFeature(skeleton); fitBounds(option); Router.push(`/${getUrlOsmId(skeleton.osmMeta)}`); @@ -87,6 +90,7 @@ type OnSelectedFactoryProps = { showToast: ShowToast; setOverpassLoading: React.Dispatch>; router: NextRouter; + setAccessMethod: (accessMethod: AccessMethod) => void; }; export const onSelectedFactory = @@ -97,6 +101,7 @@ export const onSelectedFactory = showToast, setOverpassLoading, router, + setAccessMethod, }: OnSelectedFactoryProps) => (_: never, option: Option) => { setPreview(null); // it could be stuck from onHighlight @@ -110,7 +115,7 @@ export const onSelectedFactory = overpassOptionSelected(option, setOverpassLoading, bbox, showToast); break; case 'geocoder': - geocoderOptionSelected(option, setFeature); + geocoderOptionSelected(option, setFeature, setAccessMethod); break; case 'osm': osmOptionSelected(option, router); diff --git a/src/components/utils/FeatureContext.tsx b/src/components/utils/FeatureContext.tsx index 395ad5c45..d1dc1d820 100644 --- a/src/components/utils/FeatureContext.tsx +++ b/src/components/utils/FeatureContext.tsx @@ -12,6 +12,8 @@ import { useBoolState } from '../helpers'; import { publishDbgObject } from '../../utils'; import { setLastFeature } from '../../services/lastFeatureStorage'; +export type AccessMethod = 'click' | 'search' | 'link'; + export type FeatureContextType = { feature: Feature | null; featureShown: boolean; @@ -24,6 +26,8 @@ export type FeatureContextType = { persistShowHomepage: () => void; preview: Feature | null; setPreview: (feature: Feature | null) => void; + accessMethod: AccessMethod | null; + setAccessMethod: (method: AccessMethod | null) => void; }; export const FeatureContext = createContext(undefined); @@ -41,6 +45,7 @@ export const FeatureProvider = ({ }: Props) => { const [preview, setPreview] = useState(null); const [feature, setFeature] = useState(featureFromRouter); + const [accessMethod, setAccessMethod] = useState(null); const featureShown = feature != null; useEffect(() => { @@ -72,7 +77,12 @@ export const FeatureProvider = ({ const value: FeatureContextType = { feature, featureShown, - setFeature, + setFeature: (feature) => { + if (!feature) { + setAccessMethod(null); + } + return setFeature(feature); + }, homepageShown, showHomepage, hideHomepage, @@ -80,6 +90,8 @@ export const FeatureProvider = ({ persistHideHomepage, preview, setPreview, + accessMethod, + setAccessMethod, }; return ( {children} diff --git a/src/components/utils/MapStateContext.tsx b/src/components/utils/MapStateContext.tsx index fd0b7bf07..d2a5732bc 100644 --- a/src/components/utils/MapStateContext.tsx +++ b/src/components/utils/MapStateContext.tsx @@ -43,6 +43,10 @@ type MapStateContextType = { setUserLayers: Setter; mapLoaded: boolean; setMapLoaded: () => void; + landmarkPitch: number | undefined; + setLandmarkPitch: Setter; + landmarkBearing: number | undefined; + setLandmarkBearing: Setter; }; export const MapStateContext = createContext(undefined); @@ -65,6 +69,12 @@ export const MapStateProvider: React.FC<{ initialMapView: View }> = ({ 'userLayerIndex', [], ); + const [landmarkBearing, setLandmarkBearing] = useState( + undefined, + ); + const [landmarkPitch, setLandmarkPitch] = useState( + undefined, + ); const [mapLoaded, setMapLoaded, setNotLoaded] = useBoolState(true); useEffect(setNotLoaded, [setNotLoaded]); @@ -87,6 +97,10 @@ export const MapStateProvider: React.FC<{ initialMapView: View }> = ({ setUserLayers, mapLoaded, setMapLoaded, + landmarkBearing, + setLandmarkBearing, + landmarkPitch, + setLandmarkPitch, }; return ( diff --git a/src/locales/de.js b/src/locales/de.js index bd5de2a3c..b8419bb34 100644 --- a/src/locales/de.js +++ b/src/locales/de.js @@ -113,6 +113,7 @@ export default { 'featurepanel.objects_around': 'Orte in der Nähe', 'featurepanel.more_in_openplaceguide': 'Weitere Information auf __instanceName__', 'featurepanel.climbing_restriction': 'Kletter Einschränkungen', + 'featurepanel.recommended_view': 'Empfohlnene Ansicht ansehen', 'opening_hours.all_day': '24 Stunden', 'opening_hours.open': 'Geöffnet: __todayTime__', diff --git a/src/locales/vocabulary.js b/src/locales/vocabulary.js index 1d09cc7be..559733658 100644 --- a/src/locales/vocabulary.js +++ b/src/locales/vocabulary.js @@ -134,6 +134,7 @@ export default { 'featurepanel.more_in_openplaceguide': 'More information on __instanceName__', 'featurepanel.climbing_restriction': 'Climbing restriction', 'featurepanel.login': 'Login', + 'featurepanel.recommended_view': 'Go to recommended view', 'opening_hours.all_day': '24 hours', 'opening_hours.open': 'Open: __todayTime__', diff --git a/src/services/landmarks/README.md b/src/services/landmarks/README.md new file mode 100644 index 000000000..a27021e07 --- /dev/null +++ b/src/services/landmarks/README.md @@ -0,0 +1,15 @@ +# Landmark views + +Some important landmarks have predefined views, e.g. the Brandenburg Gate. + +To contribute some yourself you need to add the relevant entries into `views.ts`, please take inspiration from the values already defined. + +- **Key**: The shortId of a feature, you can see it in the console after clicking on a feature. +- **bearing**: Which direction points upwards on the map, a value of `90` will make east up +- **pitch**: The tilt angle of the map, 0 is a flat view from straight above and the maximum is 60 +- **zoom**: The zoom, A higher value is a zoomed in more then a low value. The maximum is 24 + +`bearing`, `pitch` and `zoom` are all optional; leaving one out will simply not explicitly set that value + +Feel free to open a pr and ask for help +Thanks for wanting to contribute :) diff --git a/src/services/landmarks/addLandmarkView.ts b/src/services/landmarks/addLandmarkView.ts new file mode 100644 index 000000000..d6d3cb831 --- /dev/null +++ b/src/services/landmarks/addLandmarkView.ts @@ -0,0 +1,35 @@ +import { isServer } from '../../components/helpers'; +import { fetchJson } from '../fetch'; +import { getShortId, prod } from '../helpers'; +import { Feature } from '../types'; +import { LandmarkView } from './views'; + +const getViewUrl = (shortId: string) => { + const end = `/camera-view?shortId=${shortId}`; + if (!isServer()) { + return end; + } + if (prod) { + return `https://osmapp.org${end}`; + } + // TODO: Find the *right* url also for preview deployments + return `http://localhost:3000${end}`; +}; + +const getView = async ({ osmMeta }: Feature) => { + const shortId = getShortId(osmMeta); + const cameraViewUrl = getViewUrl(shortId); + return fetchJson(cameraViewUrl); +}; + +export const addLandmarkView = async (feature: Feature): Promise => { + try { + const view = await getView(feature); + return { + ...feature, + landmarkView: view, + }; + } catch { + return feature; + } +}; diff --git a/src/services/landmarks/views.ts b/src/services/landmarks/views.ts new file mode 100644 index 000000000..55809fe0e --- /dev/null +++ b/src/services/landmarks/views.ts @@ -0,0 +1,82 @@ +export type LandmarkView = { + zoom?: number; + bearing?: number; + pitch?: number; +}; + +export const views: Record = { + //////////// + //// USA /// + //////////// + // Statue of liberty + w32965412: { bearing: 330, pitch: 60, zoom: 18.5 }, + // White House + w238241022: { bearing: 0, pitch: 60, zoom: 18 }, + // Lincoln Memorial + w398769543: { bearing: 270, pitch: 60, zoom: 18.75 }, + // United States Capitol + w66418809: { bearing: 270, pitch: 60, zoom: 17.5 }, + // Space needle + w12903132: { pitch: 30, zoom: 17.25 }, + + //////////////// + //// GERMANY /// + //////////////// + // Elbe Philharmonic Hall + w24981342: { bearing: 45, pitch: 50, zoom: 17.25 }, + // Cologne Cathedral + w4532022: { bearing: 90, pitch: 55, zoom: 17.1 }, + // Brandenburg gate + w518071791: { bearing: 263, pitch: 60, zoom: 19 }, + // Reichstags Building + r2201742: { bearing: 90, pitch: 55, zoom: 17.5 }, + // Soviet War Memorial Tiergarten + w41368167: { bearing: 353, pitch: 60, zoom: 19 }, + // Siegessäule + n1097191894: { pitch: 45, zoom: 18.5 }, + // Berlin Cathedral + w313670734: { bearing: 22, pitch: 55, zoom: 17.75 }, + // Alte Oper Frankfurt + w384153099: { bearing: 20, pitch: 55, zoom: 18 }, + + /////////// + // Italy // + /////////// + // Colosseum + r1834818: { bearing: 70, pitch: 50, zoom: 17.5 }, + + //////////// + // France // + //////////// + // Eiffel Tower + w5013364: { bearing: 315, pitch: 15, zoom: 17.5 }, + + //////////////////// + // United Kingdom // + //////////////////// + // Big Ben & Elizabeth tower + n1802652184: { bearing: 225, pitch: 50, zoom: 17.5 }, + w123557148: { bearing: 225, pitch: 50, zoom: 17.5 }, + // Buckingham palace + r5208404: { bearing: 235, pitch: 60, zoom: 17.6 }, + + //////////////////// + // Czech republic // + //////////////////// + // Prague Castle + r3312247: { bearing: 51, pitch: 60, zoom: 16.5 }, + // National museum + r85291: { bearing: 140, pitch: 50, zoom: 18 }, + + ///////////// + // Iceland // + ///////////// + // Hallgrímskirkja + r6184378: { bearing: 150, pitch: 40, zoom: 18 }, + + /////////// + // India // + /////////// + // Taj Mahal + w375257537: { bearing: 0, pitch: 50, zoom: 18 }, +}; diff --git a/src/services/osmApi.ts b/src/services/osmApi.ts index 41df21115..53b883201 100644 --- a/src/services/osmApi.ts +++ b/src/services/osmApi.ts @@ -19,6 +19,7 @@ import { publishDbgObject, } from '../utils'; import { getOverpassUrl } from './overpassSearch'; +import { addLandmarkView } from './landmarks/addLandmarkView'; type GetOsmUrl = (object: OsmId) => string; @@ -308,7 +309,8 @@ export const fetchFeature = async (apiId: OsmId): Promise => { } const feature = await fetchFeatureWithCenter(apiId); - const finalFeature = await addMembersAndParents(feature); + const featureWithLandmarkView = await addLandmarkView(feature); + const finalFeature = await addMembersAndParents(featureWithLandmarkView); return finalFeature; } catch (e) { diff --git a/src/services/types.ts b/src/services/types.ts index 72aba63f9..bb94e721a 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -1,4 +1,5 @@ import type Vocabulary from '../locales/vocabulary'; +import { LandmarkView } from './landmarks/views'; import type { getSchemaForFeature } from './tagging/idTaggingScheme'; export type OsmType = 'node' | 'way' | 'relation'; @@ -114,6 +115,7 @@ export interface Feature { error?: 'network' | 'unknown' | '404' | '500'; // etc. deleted?: boolean; schema?: ReturnType; // undefined means error + landmarkView?: LandmarkView | null; // skeleton layer?: { id: string };