diff --git a/package-lock.json b/package-lock.json index 678b43d..e07be02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@code4ro/reusable-components", - "version": "0.1.42", + "version": "0.1.43", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 33b5cf6..88e0e8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@code4ro/reusable-components", - "version": "0.1.42", + "version": "0.1.43", "description": "Component library for code4ro", "keywords": [ "code4ro", diff --git a/src/components/ElectionMap/ElectionMap.tsx b/src/components/ElectionMap/ElectionMap.tsx index 7223690..a638a12 100644 --- a/src/components/ElectionMap/ElectionMap.tsx +++ b/src/components/ElectionMap/ElectionMap.tsx @@ -3,7 +3,7 @@ import { ElectionMapScope, ElectionMapWinner, ElectionScopeIncomplete } from ".. import { mergeClasses, themable } from "../../hooks/theme"; import RomaniaMap from "../../assets/romania-map.svg"; import { useDimensions } from "../../hooks/useDimensions"; -import { HereMap, romaniaMapBounds, worldMapBounds } from "../HereMap/HereMap"; +import { bucharestCenteredWorldZoom, HereMap, romaniaMapBounds } from "../HereMap/HereMap"; import { electionMapOverlayUrl } from "../../constants/servers"; import cssClasses from "./ElectionMap.module.scss"; import { ElectionMapAPI } from "../../util/electionApi"; @@ -175,13 +175,19 @@ export const ElectionMap = themable( className={classes.hereMap} width={width} height={height} - scopeType={scope.type} - initialBounds={ - scope.type === "diaspora" || scope.type === "diaspora_country" ? worldMapBounds : romaniaMapBounds + initialTransform={ + scope.type === "diaspora" || scope.type === "diaspora_country" + ? bucharestCenteredWorldZoom + : romaniaMapBounds } + overlayLoadTransform={ + scope.type === "diaspora" || scope.type === "diaspora_country" ? bucharestCenteredWorldZoom : "bounds" + } + allowZoomAndPan={scope.type === "diaspora" || scope.type === "diaspora_country"} overlayUrl={overlayUrl} maskOverlayUrl={maskUrl} selectedFeature={selectedFeature} + centerOnSelectedFeatureBounds={scope.type === "diaspora_country"} onFeatureSelect={onFeatureSelect} getFeatureColor={getFeatureColor} renderFeatureTooltip={renderFeatureTooltip} diff --git a/src/components/HereMap/HereMap.module.scss b/src/components/HereMap/HereMap.module.scss index b876fb0..a689033 100644 --- a/src/components/HereMap/HereMap.module.scss +++ b/src/components/HereMap/HereMap.module.scss @@ -2,6 +2,13 @@ .root { position: relative; + -webkit-touch-callout:none; + -webkit-user-select:none; + -khtml-user-select:none; + -moz-user-select:none; + -ms-user-select:none; + user-select:none; + -webkit-tap-highlight-color:rgba(0,0,0,0); } .tooltip { @@ -9,7 +16,7 @@ left: 0; top: 0; transform: translate(-50%, calc(-100% - 8px)); - font-size: 12rem / 16; + font-size: 1rem; color: #fff; border-radius: 0.25rem; padding: 0.5rem; diff --git a/src/components/HereMap/HereMap.tsx b/src/components/HereMap/HereMap.tsx index f6b2e5e..1326c50 100644 --- a/src/components/HereMap/HereMap.tsx +++ b/src/components/HereMap/HereMap.tsx @@ -1,16 +1,28 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React, { createContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { themable } from "../../hooks/theme"; +import { themable, ThemedComponentProps } from "../../hooks/theme"; import cssClasses from "./HereMap.module.scss"; import Color from "color"; type OnFeatureSelect = (featureId: number) => unknown; type RenderFeatureTooltip = (id: number, featureProps: any) => Node | string | null; +export type HereMapRect = { + top: number; // Latitude + left: number; // Longitude + bottom: number; // Latitude + right: number; // Longitude +}; + +export type HereMapTransform = { + center?: { lat: number; lng: number }; + zoom?: number; + bounds?: HereMapRect; +}; + type Props = { className?: string; - scopeType: string; width: number; height: number; overlayUrl?: string; @@ -20,23 +32,28 @@ type Props = { renderFeatureTooltip?: RenderFeatureTooltip; // Returns a HTML element or string selectedFeature?: number | null | undefined; onFeatureSelect?: OnFeatureSelect; - initialBounds?: { - top: number; // Latitude - left: number; // Longitude - bottom: number; // Latitude - right: number; // Longitude - }; - centerOnOverlayBounds?: boolean; // Default true + initialTransform?: HereMapTransform; + overlayLoadTransform?: HereMapTransform | "bounds" | false; // Defaults to "bounds": the bounds of the loaded overlay + allowZoomAndPan?: boolean; + centerOnSelectedFeatureBounds?: boolean; +}; + +export const bucharestCenteredWorldZoom = { + center: { lat: 44.4268, lng: 26.1025 }, + zoom: 2, }; -export const worldMapBounds = { top: 90, left: 0, bottom: -90, right: 180 }; export const romaniaMapBounds = { - top: 48.26534497800004, - bottom: 43.618995545000075, - left: 20.261959895000075, - right: 29.715232741000037, + bounds: { + top: 48.26534497800004, + bottom: 43.618995545000075, + left: 20.261959895000075, + right: 29.715232741000037, + }, }; +const makeRect = (H: HereMapsAPI, r: HereMapRect): H.geo.Rect => new H.geo.Rect(r.top, r.left, r.bottom, r.right); + const loadJS = (src: string) => new Promise((resolve, reject) => { const script = document.createElement("script"); @@ -107,9 +124,10 @@ type InstanceVars = { tooltipClassName: string | null; tooltipTop: number; tooltipLeft: number; - centerOnOverlayBounds: boolean; updateFeatureStyle: (feature: H.map.Polygon, selected: boolean, hover: boolean) => void; renderFeatureTooltip: RenderFeatureTooltip | undefined; + overlayLoadTransform: HereMapTransform | "bounds" | false; + centeredFeature: number | null; }; const stylesFromColor = (H: HereMapsAPI, color: string, featureSelectedDarken: number, featureHoverDarken: number) => { @@ -132,6 +150,15 @@ const stylesFromColor = (H: HereMapsAPI, color: string, featureSelectedDarken: n }; }; +const setTooltipPosition = (self: InstanceVars, x: number, y: number) => { + self.tooltipLeft = x; + self.tooltipTop = y; + if (self.tooltipEl) { + self.tooltipEl.style.left = `${x}px`; + self.tooltipEl.style.top = `${y}px`; + } +}; + export const HereMap = themable( "HereMap", cssClasses, @@ -149,13 +176,21 @@ export const HereMap = themable( renderFeatureTooltip, selectedFeature, onFeatureSelect, - initialBounds = worldMapBounds, - centerOnOverlayBounds = true, - scopeType, - }) => { + initialTransform = romaniaMapBounds, + overlayLoadTransform = "bounds", + allowZoomAndPan = true, + centerOnSelectedFeatureBounds = false, + }: ThemedComponentProps) => { const H = useHereMaps(); const mapRef = useRef(null); - const [map, setMap] = useState(null); + const [mapObjects, setMapObjects] = useState(null); + + const map = mapObjects?.map; const { featureDefaultColor, selectedFeatureColor, featureSelectedDarken, featureHoverDarken } = constants; @@ -193,9 +228,10 @@ export const HereMap = themable( tooltipClassName: classes.tooltip ?? null, tooltipTop: 0, tooltipLeft: 0, - centerOnOverlayBounds, updateFeatureStyle: updateFeatureStyle, renderFeatureTooltip: renderFeatureTooltip, + overlayLoadTransform: overlayLoadTransform, + centeredFeature: (centerOnSelectedFeatureBounds ? selectedFeature : null) ?? null, }); useLayoutEffect(() => { @@ -207,39 +243,73 @@ export const HereMap = themable( const blankLayer = new H.map.layer.Layer(); const hMap = new H.Map(mapRef.current, blankLayer, { - bounds: new H.geo.Rect(initialBounds.top, initialBounds.left, initialBounds.bottom, initialBounds.right), + bounds: initialTransform.bounds ? makeRect(H, initialTransform.bounds) : undefined, + center: initialTransform.center, + zoom: initialTransform.zoom, noWrap: true, pixelRatio: window.devicePixelRatio || 1, }); - setMap(hMap); - new H.mapevents.Behavior(new H.mapevents.MapEvents(hMap)); - new H.ui.UI(hMap, { zoom: { alignment: H.ui.LayoutAlignment.RIGHT_BOTTOM } }); + const hZoomControl = new H.ui.ZoomControl({ alignment: H.ui.LayoutAlignment.RIGHT_BOTTOM }); + const hBehaviour = new H.mapevents.Behavior(new H.mapevents.MapEvents(hMap)); + hBehaviour.disable(); + const hUI = new H.ui.UI(hMap); + + setMapObjects({ + map: hMap, + zoomControl: hZoomControl, + behaviour: hBehaviour, + ui: hUI, + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any hMap.addEventListener("pointermove", (evt: any) => { - const { offsetX = 0, offsetY = 0 } = evt.originalEvent; - self.tooltipLeft = offsetX; - self.tooltipTop = offsetY; - if (self.tooltipEl) { - self.tooltipEl.style.left = `${offsetX}px`; - self.tooltipEl.style.top = `${offsetY}px`; - } + const { offsetX = 0, offsetY = 0, pointerType } = evt.originalEvent; + if (pointerType === "touch") return; + setTooltipPosition(self, offsetX, offsetY); }); return () => { hMap.dispose(); (hMap as any).disposed = true; // eslint-disable-line @typescript-eslint/no-explicit-any - setMap((state) => (state === hMap ? null : state)); + setMapObjects((state) => (state?.map === hMap ? null : state)); }; }, [H, mapRef]); + useLayoutEffect(() => { + if (!H || !allowZoomAndPan || !mapObjects || (mapObjects.map as any).disposed) return; + const { ui, zoomControl, behaviour } = mapObjects; + + behaviour.enable(); + ui.addControl("zoomControl", zoomControl); + + return () => { + behaviour.disable(); + ui.removeControl("zoomControl"); + }; + }, [H, allowZoomAndPan, mapObjects]); + useLayoutEffect(() => { if (map) { map.getViewPort().resize(); } }, [width, height, map]); + useLayoutEffect(() => { + const self = inst.current; + self.centeredFeature = (centerOnSelectedFeatureBounds ? selectedFeature : null) ?? null; + + if (!map) return; + + const { features, centeredFeature } = self; + if (!features || centeredFeature == null) return; + + const feature = features.get(centeredFeature); + if (!feature) return; + + map.getViewModel().setLookAtData({ bounds: feature.getBoundingBox() }, true); + }, [centerOnSelectedFeatureBounds, selectedFeature, map]); + // Whenever selectedFeature changes useLayoutEffect(() => { const self = inst.current; @@ -268,8 +338,8 @@ export const HereMap = themable( }, [onFeatureSelect]); useLayoutEffect(() => { - inst.current.centerOnOverlayBounds = centerOnOverlayBounds; - }, [centerOnOverlayBounds]); + inst.current.overlayLoadTransform = overlayLoadTransform; + }, [overlayLoadTransform]); useLayoutEffect(() => { const self = inst.current; @@ -340,6 +410,12 @@ export const HereMap = themable( if (id == null) return; if (self.hoveredFeature?.id !== id) setHoveredFeature(id, data); self.updateFeatureStyle(mapObject, id === self.selectedFeature, true); + + if ((evt as any)?.originalEvent?.pointerType === "touch") { + const bounds = mapObject.getBoundingBox(); + const { x, y } = map.geoToScreen({ lat: bounds.getTop(), lng: bounds.getCenter().lng }); + setTooltipPosition(self, x, y - 5); + } } }; @@ -405,14 +481,25 @@ export const HereMap = themable( group.addEventListener("tap", onTap); map.addObject(group); - if (self.centerOnOverlayBounds) { - const lookAtDataOptions: any = { - // bounds: group.getBoundingBox() - }; - if (scopeType === "diaspora" || scopeType === "diaspora_country") { - lookAtDataOptions.zoom = 2; + if (self.centeredFeature != null) { + const feature = features.get(self.centeredFeature); + if (feature) { + map.getViewModel().setLookAtData({ bounds: feature.getBoundingBox() }, true); + } + } else { + const newTransform = self.overlayLoadTransform; + if (newTransform) { + map.getViewModel().setLookAtData( + newTransform === "bounds" + ? { bounds: group.getBoundingBox() } + : { + bounds: newTransform.bounds ? makeRect(H, newTransform.bounds) : undefined, + position: newTransform.center, + zoom: newTransform.zoom, + }, + true, + ); } - map.getViewModel().setLookAtData(lookAtDataOptions, true); } }); reader.parse();