@macrostrat/map-interface 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/helpers.ts ADDED
@@ -0,0 +1,171 @@
1
+ import {
2
+ useMapRef,
3
+ useMapEaseToCenter,
4
+ useMapDispatch,
5
+ useMapStatus,
6
+ } from "@macrostrat/mapbox-react";
7
+ import { useRef } from "react";
8
+ import { debounce } from "underscore";
9
+ import useResizeObserver from "use-resize-observer";
10
+
11
+ import { getMapPosition } from "@macrostrat/mapbox-utils";
12
+ import mapboxgl from "mapbox-gl";
13
+ import { useCallback, useEffect, useState } from "react";
14
+ import { getMapPadding, useMapMarker } from "./utils";
15
+
16
+ export function MapResizeManager({ containerRef }) {
17
+ const mapRef = useMapRef();
18
+
19
+ const debouncedResize = useRef(
20
+ debounce(() => {
21
+ mapRef.current?.resize();
22
+ }, 100)
23
+ );
24
+
25
+ useResizeObserver({
26
+ ref: containerRef,
27
+ onResize: debouncedResize.current,
28
+ });
29
+
30
+ return null;
31
+ }
32
+
33
+ interface MapPaddingManagerProps {
34
+ containerRef: React.RefObject<HTMLDivElement>;
35
+ parentRef: React.RefObject<HTMLDivElement>;
36
+ infoMarkerPosition: mapboxgl.LngLatLike;
37
+ }
38
+
39
+ export function MapPaddingManager({
40
+ containerRef,
41
+ parentRef,
42
+ infoMarkerPosition,
43
+ }: MapPaddingManagerProps) {
44
+ const mapRef = useMapRef();
45
+
46
+ const [padding, setPadding] = useState(
47
+ getMapPadding(containerRef, parentRef)
48
+ );
49
+
50
+ const updateMapPadding = useCallback(() => {
51
+ const newPadding = getMapPadding(containerRef, parentRef);
52
+ setPadding(newPadding);
53
+ }, [containerRef.current, parentRef.current]);
54
+
55
+ useEffect(() => {
56
+ const map = mapRef.current;
57
+ if (map == null) return;
58
+ // Update map padding on load
59
+ updateMapPadding();
60
+ }, [mapRef.current]);
61
+
62
+ useResizeObserver({
63
+ ref: parentRef,
64
+ onResize(sz) {
65
+ updateMapPadding();
66
+ },
67
+ });
68
+
69
+ useMapEaseToCenter(infoMarkerPosition, padding);
70
+
71
+ return null;
72
+ }
73
+
74
+ export function MapMovedReporter({ onMapMoved = null }) {
75
+ const mapRef = useMapRef();
76
+ const dispatch = useMapDispatch();
77
+
78
+ const mapMovedCallback = useCallback(() => {
79
+ const map = mapRef.current;
80
+ if (map == null) return;
81
+ const mapPosition = getMapPosition(map);
82
+ dispatch({ type: "map-moved", payload: mapPosition });
83
+ onMapMoved?.(mapPosition, map);
84
+ }, [mapRef.current, onMapMoved, dispatch]);
85
+
86
+ useEffect(() => {
87
+ // Get the current value of the map. Useful for gradually moving away
88
+ // from class component
89
+ const map = mapRef.current;
90
+ if (map == null) return;
91
+ // Update the URI when the map moves
92
+ mapMovedCallback();
93
+ const cb = debounce(mapMovedCallback, 100);
94
+ map.on("moveend", cb);
95
+ return () => {
96
+ map?.off("moveend", cb);
97
+ };
98
+ }, [mapMovedCallback]);
99
+ return null;
100
+ }
101
+
102
+ export function MapLoadingReporter({
103
+ ignoredSources,
104
+ onMapLoading = null,
105
+ onMapIdle = null,
106
+ mapIsLoading,
107
+ }) {
108
+ const mapRef = useMapRef();
109
+ const loadingRef = useRef(false);
110
+ const dispatch = useMapDispatch();
111
+
112
+ useEffect(() => {
113
+ const map = mapRef.current;
114
+ const mapIsLoading = loadingRef.current;
115
+ if (map == null) return;
116
+
117
+ let didSendLoading = false;
118
+
119
+ const loadingCallback = (evt) => {
120
+ if (ignoredSources.includes(evt.sourceId) || mapIsLoading) return;
121
+ if (didSendLoading) return;
122
+ onMapLoading?.(evt);
123
+ dispatch({ type: "set-loading", payload: true });
124
+ loadingRef.current = true;
125
+ didSendLoading = true;
126
+ };
127
+ const idleCallback = (evt) => {
128
+ if (!mapIsLoading) return;
129
+ dispatch({ type: "set-loading", payload: false });
130
+ loadingRef.current = false;
131
+ onMapIdle?.(evt);
132
+ };
133
+ map.on("sourcedataloading", loadingCallback);
134
+ map.on("idle", idleCallback);
135
+ return () => {
136
+ map?.off("sourcedataloading", loadingCallback);
137
+ map?.off("idle", idleCallback);
138
+ };
139
+ }, [ignoredSources, mapRef.current, mapIsLoading]);
140
+ return null;
141
+ }
142
+
143
+ export function MapMarker({ position, setPosition, centerMarker = true }) {
144
+ const mapRef = useMapRef();
145
+ const markerRef = useRef(null);
146
+
147
+ useMapMarker(mapRef, markerRef, position);
148
+
149
+ const handleMapClick = useCallback(
150
+ (event: mapboxgl.MapMouseEvent) => {
151
+ setPosition(event.lngLat, event, mapRef.current);
152
+ // We should integrate this with the "easeToCenter" hook
153
+ if (centerMarker) {
154
+ mapRef.current?.flyTo({ center: event.lngLat, duration: 800 });
155
+ }
156
+ }, // eslint-disable-next-line react-hooks/exhaustive-deps
157
+ [mapRef.current, setPosition]
158
+ );
159
+
160
+ useEffect(() => {
161
+ const map = mapRef.current;
162
+ if (map != null && setPosition != null) {
163
+ map.on("click", handleMapClick);
164
+ }
165
+ return () => {
166
+ map?.off("click", handleMapClick);
167
+ };
168
+ }, [mapRef.current, setPosition]);
169
+
170
+ return null;
171
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./context-panel";
2
+ export * from "./location-panel";
3
+ export * from "./dev";
4
+ export * from "./container";
5
+ export * from "./map-view";
@@ -0,0 +1,89 @@
1
+ import h from "@macrostrat/hyper";
2
+ import {
3
+ formatValue,
4
+ normalizeLng,
5
+ metersToFeet,
6
+ formatCoordForZoomLevel,
7
+ } from "./utils";
8
+
9
+ export function ValueWithUnit(props) {
10
+ const { value, unit } = props;
11
+ return h("span.value-with-unit", [
12
+ h("span.value", [value]),
13
+ h("span.spacer", [" "]),
14
+ h("span.unit", [unit]),
15
+ ]);
16
+ }
17
+
18
+ export function DegreeCoord(props) {
19
+ const { value, labels, precision = 3, format = formatValue } = props;
20
+ const direction = value < 0 ? labels[1] : labels[0];
21
+
22
+ return h(ValueWithUnit, {
23
+ value: format(Math.abs(value), precision) + "°",
24
+ unit: direction,
25
+ });
26
+ }
27
+
28
+ export interface LngLatProps {
29
+ /** Map position */
30
+ position: [number, number] | { lat: number; lng: number };
31
+ className?: string;
32
+ /** Zoom level (used to infer coordinate rounding if provided) */
33
+ zoom?: number | null;
34
+ /** Number of decimal places to round coordinates to */
35
+ precision: number | null;
36
+ /** Function to format coordinates */
37
+ format?: (val: number, precision: number) => string;
38
+ }
39
+
40
+ export function LngLatCoords(props: LngLatProps) {
41
+ /** Formatted geographic coordinates */
42
+ const { position, className, precision, zoom } = props;
43
+ let { format } = props;
44
+ if (position == null) {
45
+ return null;
46
+ }
47
+ let lat, lng;
48
+ if (Array.isArray(position)) {
49
+ [lng, lat] = position;
50
+ } else {
51
+ ({ lat, lng } = position);
52
+ }
53
+
54
+ if (zoom != null && format == null && precision == null) {
55
+ format = (val, _) => formatCoordForZoomLevel(val, zoom);
56
+ }
57
+
58
+ return h("div.lnglat-container", { className }, [
59
+ h("span.lnglat", [
60
+ h(DegreeCoord, {
61
+ value: lat,
62
+ labels: ["N", "S"],
63
+ precision,
64
+ format,
65
+ }),
66
+ ", ",
67
+ h(DegreeCoord, {
68
+ value: normalizeLng(lng),
69
+ labels: ["E", "W"],
70
+ precision,
71
+ format,
72
+ }),
73
+ ]),
74
+ ]);
75
+ }
76
+
77
+ export function Elevation(props) {
78
+ /** Renders an elevation value in meters and a parenthetical conversion to feet. */
79
+ const { elevation, className, includeFeet = true } = props;
80
+ if (elevation == null) return null;
81
+ return h("div.elevation", { className }, [
82
+ h(ValueWithUnit, { value: elevation, unit: "m" }),
83
+ h.if(includeFeet)("span.secondary", [
84
+ " (",
85
+ h(ValueWithUnit, { value: metersToFeet(elevation), unit: "ft" }),
86
+ ")",
87
+ ]),
88
+ ]);
89
+ }
@@ -0,0 +1,44 @@
1
+ import { format } from "d3-format";
2
+
3
+ export function formatCoordForZoomLevel(val: number, zoom: number): string {
4
+ if (zoom < 2) {
5
+ return fmt1(val);
6
+ } else if (zoom < 4) {
7
+ return fmt2(val);
8
+ } else if (zoom < 7) {
9
+ return fmt3(val);
10
+ }
11
+ return fmt4(val);
12
+ }
13
+
14
+ export function normalizeLng(lng) {
15
+ // via https://github.com/Leaflet/Leaflet/blob/32c9156cb1d1c9bd53130639ec4d8575fbeef5a6/src/core/Util.js#L87
16
+ return (((((lng - 180) % 360) + 360) % 360) - 180).toFixed(4);
17
+ }
18
+
19
+ export const fmt4 = format(".4~f");
20
+ export const fmt3 = format(".3~f");
21
+ export const fmt2 = format(".2~f");
22
+ export const fmt1 = format(".1~f");
23
+ export const fmtInt = format(".0f");
24
+
25
+ export function formatValue(val: number, precision: number = 0): string {
26
+ switch (precision) {
27
+ case 4:
28
+ return fmt4(val);
29
+ case 3:
30
+ return fmt3(val);
31
+ case 2:
32
+ return fmt2(val);
33
+ case 1:
34
+ return fmt1(val);
35
+ case 0:
36
+ return fmtInt(val);
37
+ default:
38
+ return fmt4(val);
39
+ }
40
+ }
41
+
42
+ export function metersToFeet(meters, precision = 0) {
43
+ return (meters * 3.28084).toFixed(precision);
44
+ }
@@ -0,0 +1,86 @@
1
+ import { Icon, Button } from "@blueprintjs/core";
2
+ import hyper from "@macrostrat/hyper";
3
+ import styles from "./main.module.sass";
4
+ import { useToaster } from "@macrostrat/ui-components";
5
+ import { LngLatCoords, Elevation } from "../location-info";
6
+ import {
7
+ LocationFocusButton,
8
+ useFocusState,
9
+ isCentered,
10
+ } from "@macrostrat/mapbox-react";
11
+
12
+ const h = hyper.styled(styles);
13
+
14
+ function PositionButton({ position }) {
15
+ const focusState = useFocusState(position);
16
+
17
+ return h("div.position-controls", [
18
+ h(LocationFocusButton, { location: position, focusState }, []),
19
+ isCentered(focusState) ? h(CopyLinkButton, { itemName: "position" }) : null,
20
+ ]);
21
+ }
22
+
23
+ function CopyLinkButton({ itemName, children, onClick, ...rest }) {
24
+ const toaster = useToaster();
25
+
26
+ let message = `Copied link`;
27
+ if (itemName != null) {
28
+ message += ` to ${itemName}`;
29
+ }
30
+ message += "!";
31
+
32
+ return h(
33
+ Button,
34
+ {
35
+ className: "copy-link-button",
36
+ rightIcon: h(Icon, { icon: "link", size: 12 }),
37
+ minimal: true,
38
+ small: true,
39
+ onClick() {
40
+ navigator.clipboard.writeText(window.location.href).then(
41
+ () => {
42
+ toaster?.show({
43
+ message,
44
+ intent: "success",
45
+ icon: "clipboard",
46
+ timeout: 1000,
47
+ });
48
+ onClick?.();
49
+ },
50
+ () => {
51
+ toaster?.show({
52
+ message: "Failed to copy link",
53
+ intent: "danger",
54
+ icon: "error",
55
+ timeout: 1000,
56
+ });
57
+ }
58
+ );
59
+ },
60
+ ...rest,
61
+ },
62
+ children ?? "Copy link"
63
+ );
64
+ }
65
+
66
+ interface InfoDrawerHeaderProps {
67
+ onClose: () => void;
68
+ position: mapboxgl.LngLat;
69
+ zoom?: number;
70
+ elevation?: number;
71
+ }
72
+
73
+ export function InfoDrawerHeader(props: InfoDrawerHeaderProps) {
74
+ const { onClose, position, zoom = 7, elevation } = props;
75
+
76
+ return h("header.location-panel-header", [
77
+ h(PositionButton, { position }),
78
+ h("div.spacer"),
79
+ h(LngLatCoords, { position, zoom, className: "infodrawer-header-item" }),
80
+ h.if(elevation != null)(Elevation, {
81
+ elevation,
82
+ className: "infodrawer-header-item",
83
+ }),
84
+ h(Button, { minimal: true, icon: "cross", onClick: onClose }),
85
+ ]);
86
+ }
@@ -0,0 +1,22 @@
1
+ import { Card } from "@blueprintjs/core";
2
+ import hyper from "@macrostrat/hyper";
3
+ import { InfoDrawerHeader } from "./header";
4
+ import classNames from "classnames";
5
+ import styles from "./main.module.sass";
6
+ import { ErrorBoundary } from "@macrostrat/ui-components";
7
+
8
+ const h = hyper.styled(styles);
9
+
10
+ export function InfoDrawerContainer(props) {
11
+ const className = classNames("infodrawer", props.className);
12
+ return h(Card, { ...props, className });
13
+ }
14
+
15
+ export function LocationPanel(props) {
16
+ const { children, className, loading = false, ...rest } = props;
17
+ const cls = classNames("location-panel", className, { loading });
18
+ return h(InfoDrawerContainer, { className: cls }, [
19
+ h(InfoDrawerHeader, rest),
20
+ h("div.infodrawer-body", h("div.infodrawer-contents", h(ErrorBoundary, null, children))),
21
+ ]);
22
+ }
@@ -0,0 +1,53 @@
1
+ .copy-link-button:global(.bp4-minimal.bp4-button)
2
+ color: var(--text-subtle-color)
3
+ svg
4
+ fill: var(--text-subtle-color)
5
+
6
+ .location-panel-header
7
+ padding: 5px
8
+ display: flex
9
+ flex-direction: row
10
+ align-items: center
11
+ gap: 1em
12
+ border-bottom: 1px solid var(--panel-rule-color)
13
+ .spacer
14
+ flex-grow: 1
15
+ .left-icon
16
+ padding: 7px
17
+
18
+ .position-controls :global(.bp4-button)
19
+ font-size: 12px !important
20
+
21
+ .infodrawer-header-item
22
+ font-size: 12px
23
+ :global(.secondary)
24
+ font-size: 0.9em
25
+ color: var(--text-subtle-color)
26
+
27
+ .infodrawer
28
+ pointer-events: all
29
+ max-height: 100%
30
+ max-width: 100%
31
+ height: fit-content
32
+ display: flex
33
+ flex-direction: column
34
+ overflow: hidden
35
+ &:global(.bp4-card)
36
+ padding: 0
37
+
38
+ &.loading
39
+ .infodrawer-body
40
+ overflow-y: hidden
41
+ min-height: 70px
42
+
43
+ .infodrawer-body
44
+ flex-shrink: 1
45
+ min-height: 0
46
+ transition: min-height 0.5s ease
47
+ overflow-y: scroll
48
+ position: relative
49
+
50
+ // TODO: remove this when we have a better way to handle card media queries
51
+ @media screen and (max-width: 768px)
52
+ .infodrawer
53
+ border-radius: var(--panel-border-radius, 0px)