GeomapPanel.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import { css } from '@emotion/css';
  2. import { Global } from '@emotion/react';
  3. import { cloneDeep } from 'lodash';
  4. import { Collection, Map as OpenLayersMap, MapBrowserEvent, PluggableMap, View } from 'ol';
  5. import { FeatureLike } from 'ol/Feature';
  6. import Attribution from 'ol/control/Attribution';
  7. import ScaleLine from 'ol/control/ScaleLine';
  8. import Zoom from 'ol/control/Zoom';
  9. import { Coordinate } from 'ol/coordinate';
  10. import { createEmpty, extend, isEmpty } from 'ol/extent';
  11. import { defaults as interactionDefaults } from 'ol/interaction';
  12. import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
  13. import BaseLayer from 'ol/layer/Base';
  14. import VectorLayer from 'ol/layer/Vector';
  15. import { fromLonLat, toLonLat } from 'ol/proj';
  16. import React, { Component, ReactNode } from 'react';
  17. import { Subject, Subscription } from 'rxjs';
  18. import {
  19. DataFrame,
  20. DataHoverClearEvent,
  21. DataHoverEvent,
  22. FrameGeometrySourceMode,
  23. GrafanaTheme,
  24. MapLayerOptions,
  25. PanelData,
  26. PanelProps,
  27. } from '@grafana/data';
  28. import { config } from '@grafana/runtime';
  29. import { PanelContext, PanelContextRoot, stylesFactory } from '@grafana/ui';
  30. import { PanelEditExitedEvent } from 'app/types/events';
  31. import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
  32. import { GeomapTooltip } from './GeomapTooltip';
  33. import { DebugOverlay } from './components/DebugOverlay';
  34. import { GeomapHoverPayload, GeomapLayerHover } from './event';
  35. import { getGlobalStyles } from './globalStyles';
  36. import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer';
  37. import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from './layers/registry';
  38. import { ControlsOptions, GeomapPanelOptions, MapLayerState, MapViewConfig, TooltipMode } from './types';
  39. import { centerPointRegistry, MapCenterID } from './view';
  40. // Allows multiple panels to share the same view instance
  41. let sharedView: View | undefined = undefined;
  42. type Props = PanelProps<GeomapPanelOptions>;
  43. interface State extends OverlayProps {
  44. ttip?: GeomapHoverPayload;
  45. ttipOpen: boolean;
  46. legends: ReactNode[];
  47. }
  48. export interface GeomapLayerActions {
  49. selectLayer: (uid: string) => void;
  50. deleteLayer: (uid: string) => void;
  51. addlayer: (type: string) => void;
  52. reorder: (src: number, dst: number) => void;
  53. canRename: (v: string) => boolean;
  54. }
  55. export interface GeomapInstanceState {
  56. map?: OpenLayersMap;
  57. layers: MapLayerState[];
  58. selected: number;
  59. actions: GeomapLayerActions;
  60. }
  61. export class GeomapPanel extends Component<Props, State> {
  62. static contextType = PanelContextRoot;
  63. panelContext: PanelContext = {} as PanelContext;
  64. private subs = new Subscription();
  65. globalCSS = getGlobalStyles(config.theme2);
  66. mouseWheelZoom?: MouseWheelZoom;
  67. style = getStyles(config.theme);
  68. hoverPayload: GeomapHoverPayload = { point: {}, pageX: -1, pageY: -1 };
  69. readonly hoverEvent = new DataHoverEvent(this.hoverPayload);
  70. map?: OpenLayersMap;
  71. mapDiv?: HTMLDivElement;
  72. layers: MapLayerState[] = [];
  73. readonly byName = new Map<string, MapLayerState>();
  74. constructor(props: Props) {
  75. super(props);
  76. this.state = { ttipOpen: false, legends: [] };
  77. this.subs.add(
  78. this.props.eventBus.subscribe(PanelEditExitedEvent, (evt) => {
  79. if (this.mapDiv && this.props.id === evt.payload) {
  80. this.initMapRef(this.mapDiv);
  81. }
  82. })
  83. );
  84. }
  85. componentDidMount() {
  86. this.panelContext = this.context as PanelContext;
  87. }
  88. shouldComponentUpdate(nextProps: Props) {
  89. if (!this.map) {
  90. return true; // not yet initialized
  91. }
  92. // Check for resize
  93. if (this.props.height !== nextProps.height || this.props.width !== nextProps.width) {
  94. this.map.updateSize();
  95. }
  96. // External data changed
  97. if (this.props.data !== nextProps.data) {
  98. this.dataChanged(nextProps.data);
  99. }
  100. // Options changed
  101. if (this.props.options !== nextProps.options) {
  102. this.optionsChanged(nextProps.options);
  103. }
  104. return true; // always?
  105. }
  106. componentDidUpdate(prevProps: Props) {
  107. if (this.map && (this.props.height !== prevProps.height || this.props.width !== prevProps.width)) {
  108. this.map.updateSize();
  109. }
  110. }
  111. /** This function will actually update the JSON model */
  112. private doOptionsUpdate(selected: number) {
  113. const { options, onOptionsChange } = this.props;
  114. const layers = this.layers;
  115. onOptionsChange({
  116. ...options,
  117. basemap: layers[0].options,
  118. layers: layers.slice(1).map((v) => v.options),
  119. });
  120. // Notify the panel editor
  121. if (this.panelContext.onInstanceStateChange) {
  122. this.panelContext.onInstanceStateChange({
  123. map: this.map,
  124. layers: layers,
  125. selected,
  126. actions: this.actions,
  127. });
  128. }
  129. this.setState({ legends: this.getLegends() });
  130. }
  131. getNextLayerName = () => {
  132. let idx = this.layers.length; // since basemap is 0, this looks right
  133. while (true && idx < 100) {
  134. const name = `Layer ${idx++}`;
  135. if (!this.byName.has(name)) {
  136. return name;
  137. }
  138. }
  139. return `Layer ${Date.now()}`;
  140. };
  141. actions: GeomapLayerActions = {
  142. selectLayer: (uid: string) => {
  143. const selected = this.layers.findIndex((v) => v.options.name === uid);
  144. if (this.panelContext.onInstanceStateChange) {
  145. this.panelContext.onInstanceStateChange({
  146. map: this.map,
  147. layers: this.layers,
  148. selected,
  149. actions: this.actions,
  150. });
  151. }
  152. },
  153. canRename: (v: string) => {
  154. return !this.byName.has(v);
  155. },
  156. deleteLayer: (uid: string) => {
  157. const layers: MapLayerState[] = [];
  158. for (const lyr of this.layers) {
  159. if (lyr.options.name === uid) {
  160. this.map?.removeLayer(lyr.layer);
  161. } else {
  162. layers.push(lyr);
  163. }
  164. }
  165. this.layers = layers;
  166. this.doOptionsUpdate(0);
  167. },
  168. addlayer: (type: string) => {
  169. const item = geomapLayerRegistry.getIfExists(type);
  170. if (!item) {
  171. return; // ignore empty request
  172. }
  173. this.initLayer(
  174. this.map!,
  175. {
  176. type: item.id,
  177. name: this.getNextLayerName(),
  178. config: cloneDeep(item.defaultOptions),
  179. location: item.showLocation ? { mode: FrameGeometrySourceMode.Auto } : undefined,
  180. tooltip: true,
  181. },
  182. false
  183. ).then((lyr) => {
  184. this.layers = this.layers.slice(0);
  185. this.layers.push(lyr);
  186. this.map?.addLayer(lyr.layer);
  187. this.doOptionsUpdate(this.layers.length - 1);
  188. });
  189. },
  190. reorder: (startIndex: number, endIndex: number) => {
  191. const result = Array.from(this.layers);
  192. const [removed] = result.splice(startIndex, 1);
  193. result.splice(endIndex, 0, removed);
  194. this.layers = result;
  195. this.doOptionsUpdate(endIndex);
  196. // Add the layers in the right order
  197. const group = this.map?.getLayers()!;
  198. group.clear();
  199. this.layers.forEach((v) => group.push(v.layer));
  200. },
  201. };
  202. /**
  203. * Called when the panel options change
  204. *
  205. * NOTE: changes to basemap and layers are handled independently
  206. */
  207. optionsChanged(options: GeomapPanelOptions) {
  208. const oldOptions = this.props.options;
  209. console.log('options changed!', options);
  210. if (options.view !== oldOptions.view) {
  211. console.log('View changed');
  212. this.map!.setView(this.initMapView(options.view, this.map!.getLayers()));
  213. }
  214. if (options.controls !== oldOptions.controls) {
  215. console.log('Controls changed');
  216. this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
  217. }
  218. }
  219. /**
  220. * Called when PanelData changes (query results etc)
  221. */
  222. dataChanged(data: PanelData) {
  223. for (const state of this.layers) {
  224. if (state.handler.update) {
  225. state.handler.update(data);
  226. }
  227. }
  228. }
  229. initMapRef = async (div: HTMLDivElement) => {
  230. this.mapDiv = div;
  231. if (this.map) {
  232. this.map.dispose();
  233. }
  234. if (!div) {
  235. this.map = undefined as unknown as OpenLayersMap;
  236. return;
  237. }
  238. const { options } = this.props;
  239. const map = (this.map = new OpenLayersMap({
  240. view: this.initMapView(options.view, undefined),
  241. pixelRatio: 1, // or zoom?
  242. layers: [], // loaded explicitly below
  243. controls: [],
  244. target: div,
  245. interactions: interactionDefaults({
  246. mouseWheelZoom: false, // managed by initControls
  247. }),
  248. }));
  249. this.byName.clear();
  250. const layers: MapLayerState[] = [];
  251. try {
  252. layers.push(await this.initLayer(map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true));
  253. // Default layer values
  254. const layerOptions = options.layers ?? [defaultMarkersConfig];
  255. for (const lyr of layerOptions) {
  256. layers.push(await this.initLayer(map, lyr, false));
  257. }
  258. } catch (ex) {
  259. console.error('error loading layers', ex);
  260. }
  261. for (const lyr of layers) {
  262. map.addLayer(lyr.layer);
  263. }
  264. this.layers = layers;
  265. this.map = map; // redundant
  266. this.initViewExtent(map.getView(), options.view, map.getLayers());
  267. this.mouseWheelZoom = new MouseWheelZoom();
  268. this.map.addInteraction(this.mouseWheelZoom);
  269. this.initControls(options.controls);
  270. this.forceUpdate(); // first render
  271. // Tooltip listener
  272. this.map.on('singleclick', this.pointerClickListener);
  273. this.map.on('pointermove', this.pointerMoveListener);
  274. this.map.getViewport().addEventListener('mouseout', (evt) => {
  275. this.props.eventBus.publish(new DataHoverClearEvent());
  276. });
  277. // Notify the panel editor
  278. if (this.panelContext.onInstanceStateChange) {
  279. this.panelContext.onInstanceStateChange({
  280. map: this.map,
  281. layers: layers,
  282. selected: layers.length - 1, // the top layer
  283. actions: this.actions,
  284. });
  285. }
  286. this.setState({ legends: this.getLegends() });
  287. };
  288. clearTooltip = () => {
  289. if (this.state.ttip && !this.state.ttipOpen) {
  290. this.tooltipPopupClosed();
  291. }
  292. };
  293. tooltipPopupClosed = () => {
  294. this.setState({ ttipOpen: false, ttip: undefined });
  295. };
  296. pointerClickListener = (evt: MapBrowserEvent<UIEvent>) => {
  297. if (this.pointerMoveListener(evt)) {
  298. evt.preventDefault();
  299. evt.stopPropagation();
  300. this.mapDiv!.style.cursor = 'auto';
  301. this.setState({ ttipOpen: true });
  302. }
  303. };
  304. pointerMoveListener = (evt: MapBrowserEvent<UIEvent>) => {
  305. if (!this.map || this.state.ttipOpen) {
  306. return false;
  307. }
  308. const mouse = evt.originalEvent as any;
  309. const pixel = this.map.getEventPixel(mouse);
  310. const hover = toLonLat(this.map.getCoordinateFromPixel(pixel));
  311. const { hoverPayload } = this;
  312. hoverPayload.pageX = mouse.pageX;
  313. hoverPayload.pageY = mouse.pageY;
  314. hoverPayload.point = {
  315. lat: hover[1],
  316. lon: hover[0],
  317. };
  318. hoverPayload.data = undefined;
  319. hoverPayload.columnIndex = undefined;
  320. hoverPayload.rowIndex = undefined;
  321. hoverPayload.layers = undefined;
  322. const layers: GeomapLayerHover[] = [];
  323. const layerLookup = new Map<MapLayerState, GeomapLayerHover>();
  324. let ttip: GeomapHoverPayload = {} as GeomapHoverPayload;
  325. this.map.forEachFeatureAtPixel(
  326. pixel,
  327. (feature, layer, geo) => {
  328. const s: MapLayerState = (layer as any).__state;
  329. //match hover layer to layer in layers
  330. //check if the layer show tooltip is enabled
  331. //then also pass the list of tooltip fields if exists
  332. //this is used as the generic hover event
  333. if (!hoverPayload.data) {
  334. const props = feature.getProperties();
  335. const frame = props['frame'];
  336. if (frame) {
  337. hoverPayload.data = ttip.data = frame as DataFrame;
  338. hoverPayload.rowIndex = ttip.rowIndex = props['rowIndex'];
  339. }
  340. if (s?.mouseEvents) {
  341. s.mouseEvents.next(feature);
  342. }
  343. }
  344. if (s) {
  345. let h = layerLookup.get(s);
  346. if (!h) {
  347. h = { layer: s, features: [] };
  348. layerLookup.set(s, h);
  349. layers.push(h);
  350. }
  351. h.features.push(feature);
  352. }
  353. },
  354. {
  355. layerFilter: (l) => {
  356. const hoverLayerState = (l as any).__state as MapLayerState;
  357. return hoverLayerState.options.tooltip !== false;
  358. },
  359. }
  360. );
  361. this.hoverPayload.layers = layers.length ? layers : undefined;
  362. this.props.eventBus.publish(this.hoverEvent);
  363. this.setState({ ttip: { ...hoverPayload } });
  364. if (!layers.length) {
  365. // clear mouse events
  366. this.layers.forEach((layer) => {
  367. layer.mouseEvents.next(undefined);
  368. });
  369. }
  370. const found = layers.length ? true : false;
  371. this.mapDiv!.style.cursor = found ? 'pointer' : 'auto';
  372. return found;
  373. };
  374. private updateLayer = async (uid: string, newOptions: MapLayerOptions): Promise<boolean> => {
  375. if (!this.map) {
  376. return false;
  377. }
  378. const current = this.byName.get(uid);
  379. if (!current) {
  380. return false;
  381. }
  382. let layerIndex = -1;
  383. const group = this.map?.getLayers()!;
  384. for (let i = 0; i < group?.getLength(); i++) {
  385. if (group.item(i) === current.layer) {
  386. layerIndex = i;
  387. break;
  388. }
  389. }
  390. // Special handling for rename
  391. if (newOptions.name !== uid) {
  392. if (!newOptions.name) {
  393. newOptions.name = uid;
  394. } else if (this.byName.has(newOptions.name)) {
  395. return false;
  396. }
  397. console.log('Layer name changed', uid, '>>>', newOptions.name);
  398. this.byName.delete(uid);
  399. uid = newOptions.name;
  400. this.byName.set(uid, current);
  401. }
  402. // Type changed -- requires full re-initalization
  403. if (current.options.type !== newOptions.type) {
  404. // full init
  405. } else {
  406. // just update options
  407. }
  408. const layers = this.layers.slice(0);
  409. try {
  410. const info = await this.initLayer(this.map, newOptions, current.isBasemap);
  411. layers[layerIndex] = info;
  412. group.setAt(layerIndex, info.layer);
  413. // initialize with new data
  414. if (info.handler.update) {
  415. info.handler.update(this.props.data);
  416. }
  417. } catch (err) {
  418. console.warn('ERROR', err);
  419. return false;
  420. }
  421. // Just to trigger a state update
  422. this.setState({ legends: [] });
  423. this.layers = layers;
  424. this.doOptionsUpdate(layerIndex);
  425. return true;
  426. };
  427. async initLayer(map: PluggableMap, options: MapLayerOptions, isBasemap?: boolean): Promise<MapLayerState> {
  428. if (isBasemap && (!options?.type || config.geomapDisableCustomBaseLayer)) {
  429. options = DEFAULT_BASEMAP_CONFIG;
  430. }
  431. // Use default makers layer
  432. if (!options?.type) {
  433. options = {
  434. type: MARKERS_LAYER_ID,
  435. name: this.getNextLayerName(),
  436. config: {},
  437. };
  438. }
  439. const item = geomapLayerRegistry.getIfExists(options.type);
  440. if (!item) {
  441. return Promise.reject('unknown layer: ' + options.type);
  442. }
  443. const handler = await item.create(map, options, config.theme2);
  444. const layer = handler.init();
  445. if (handler.update) {
  446. handler.update(this.props.data);
  447. }
  448. if (!options.name) {
  449. options.name = this.getNextLayerName();
  450. }
  451. const UID = options.name;
  452. const state: MapLayerState<any> = {
  453. // UID, // unique name when added to the map (it may change and will need special handling)
  454. isBasemap,
  455. options,
  456. layer,
  457. handler,
  458. mouseEvents: new Subject<FeatureLike | undefined>(),
  459. getName: () => UID,
  460. // Used by the editors
  461. onChange: (cfg: MapLayerOptions) => {
  462. this.updateLayer(UID, cfg);
  463. },
  464. };
  465. this.byName.set(UID, state);
  466. (state.layer as any).__state = state;
  467. return state;
  468. }
  469. initMapView(config: MapViewConfig, layers?: Collection<BaseLayer>): View {
  470. let view = new View({
  471. center: [0, 0],
  472. zoom: 1,
  473. showFullExtent: true, // allows zooming so the full range is visible
  474. });
  475. // With shared views, all panels use the same view instance
  476. if (config.shared) {
  477. if (!sharedView) {
  478. sharedView = view;
  479. } else {
  480. view = sharedView;
  481. }
  482. }
  483. if (layers) {
  484. this.initViewExtent(view, config, layers);
  485. }
  486. return view;
  487. }
  488. initViewExtent(view: View, config: MapViewConfig, layers: Collection<BaseLayer>) {
  489. const v = centerPointRegistry.getIfExists(config.id);
  490. if (v) {
  491. let coord: Coordinate | undefined = undefined;
  492. if (v.lat == null) {
  493. if (v.id === MapCenterID.Coordinates) {
  494. coord = [config.lon ?? 0, config.lat ?? 0];
  495. } else if (v.id === MapCenterID.Fit) {
  496. var extent = layers
  497. .getArray()
  498. .filter((l) => l instanceof VectorLayer)
  499. .map((l) => (l as VectorLayer<any>).getSource().getExtent() ?? [])
  500. .reduce(extend, createEmpty());
  501. if (!isEmpty(extent)) {
  502. view.fit(extent, {
  503. padding: [30, 30, 30, 30],
  504. maxZoom: config.zoom ?? config.maxZoom,
  505. });
  506. }
  507. } else {
  508. console.log('TODO, view requires special handling', v);
  509. }
  510. } else {
  511. coord = [v.lon ?? 0, v.lat ?? 0];
  512. }
  513. if (coord) {
  514. view.setCenter(fromLonLat(coord));
  515. }
  516. }
  517. if (config.maxZoom) {
  518. view.setMaxZoom(config.maxZoom);
  519. }
  520. if (config.minZoom) {
  521. view.setMaxZoom(config.minZoom);
  522. }
  523. if (config.zoom && v?.id !== MapCenterID.Fit) {
  524. view.setZoom(config.zoom);
  525. }
  526. }
  527. initControls(options: ControlsOptions) {
  528. if (!this.map) {
  529. return;
  530. }
  531. this.map.getControls().clear();
  532. if (options.showZoom) {
  533. this.map.addControl(new Zoom());
  534. }
  535. if (options.showScale) {
  536. this.map.addControl(
  537. new ScaleLine({
  538. units: options.scaleUnits,
  539. minWidth: 100,
  540. })
  541. );
  542. }
  543. this.mouseWheelZoom!.setActive(Boolean(options.mouseWheelZoom));
  544. if (options.showAttribution) {
  545. this.map.addControl(new Attribution({ collapsed: true, collapsible: true }));
  546. }
  547. // Update the react overlays
  548. let topRight: ReactNode[] = [];
  549. if (options.showDebug) {
  550. topRight = [<DebugOverlay key="debug" map={this.map} />];
  551. }
  552. this.setState({ topRight });
  553. }
  554. getLegends() {
  555. const legends: ReactNode[] = [];
  556. for (const state of this.layers) {
  557. if (state.handler.legend) {
  558. legends.push(<div key={state.options.name}>{state.handler.legend}</div>);
  559. }
  560. }
  561. return legends;
  562. }
  563. render() {
  564. let { ttip, ttipOpen, topRight, legends } = this.state;
  565. const { options } = this.props;
  566. const showScale = options.controls.showScale;
  567. if (!ttipOpen && options.tooltip?.mode === TooltipMode.None) {
  568. ttip = undefined;
  569. }
  570. return (
  571. <>
  572. <Global styles={this.globalCSS} />
  573. <div className={this.style.wrap} onMouseLeave={this.clearTooltip}>
  574. <div className={this.style.map} ref={this.initMapRef}></div>
  575. <GeomapOverlay bottomLeft={legends} topRight={topRight} blStyle={{ bottom: showScale ? '35px' : '8px' }} />
  576. </div>
  577. <GeomapTooltip ttip={ttip} isOpen={ttipOpen} onClose={this.tooltipPopupClosed} />
  578. </>
  579. );
  580. }
  581. }
  582. const getStyles = stylesFactory((theme: GrafanaTheme) => ({
  583. wrap: css`
  584. position: relative;
  585. width: 100%;
  586. height: 100%;
  587. `,
  588. map: css`
  589. position: absolute;
  590. z-index: 0;
  591. width: 100%;
  592. height: 100%;
  593. `,
  594. }));