Select to view content in your preferred language

Access layer features and data in Experience Builder Widget Development

2862
21
11-02-2023 10:01 AM
Labels (1)
diplonics
Emerging Contributor
Long background in Openlayers but taking on the Experience Builder Widget development challenge and so far okay. However can't fathom how to access a layers data after its added to the map. Could someone point me to a tutorial or resource on what I thought I could easily where I want to generate a UI element containing some of the layer attribute data after it loads. So I need to get the data and loop through it. I can get the data fine and display it on the map but can't seem to access it through the layer, view or layerView objects.
 
Code currently using to get the data is as follows:

 

const layer = new this.GeoJSONLayer({
	url: requestURL,
	customParameters: {
		request: this.state.bbox,
	},
	title: "New data",
	renderer: {
		type: "simple",
		symbol: {
			type: "simple-line",
			width: 2,
			Color: "blue"
		}
	}
});
JimuMapView.view.map.add(layer);
// After the layer is created try to build UI element from layer data.
layer.on('layerview-create', (event) => {
	try{
		const results = event.layerView.queryFeatures();
		const graphics = results.features;
		console.log(graphics);				//Just returns null
	}catch (error){
		console.error("query failed: ", error);
	}
})

 

0 Kudos
21 Replies
JeffreyThompson2
MVP Regular Contributor

There are two issues with your query.

  1. You need to define your query so it knows what you are looking for.
  2. You need to create a .then() statement, so that Javascript will wait for the query to run before moving on to the next lines of code.

Something like this example block.

view.on("pointer-move", function(event){
  let query = featureLayer.createQuery();
  query.geometry = view.toMap(event);  // the point location of the pointer
  query.distance = 2;
  query.units = "miles";
  query.spatialRelationship = "intersects";  // this is the default
  query.returnGeometry = true;
  query.outFields = [ "POPULATION" ];

  featureLayerView.queryFeatures(query)
    .then(function(response){
      // returns a feature set with features containing the
      // POPULATION attribute and each feature's geometry
    });
});

https://developers.arcgis.com/javascript/latest/api-reference/esri-views-layers-GeoJSONLayerView.htm...

GIS Developer
City of Arlington, Texas
0 Kudos
diplonics
Emerging Contributor

Thanks for the feedback, appreciate it, however I'm still struggling to grasp scope and get your example plugged into my code.
Below is my widget.tsx which may not be written pretty but works for my testing.
In my custom widget when the button is pressed the selected map view extent is sent to an api server which returns GeoJSON that gets added to the map and the featuretable.
2 days now and I still can't fathom how to capture in the widget when the new layer is selected in the map that I can get the selected features data and show it in the lineDetailsTab.
I've tried plugging in your code but scope is proving confusing to me as I don't know where to get the featurelayerview from and the view.toMap scope is wrong as it always return undefined!!

I have also being trying to get this approach integrated:
Solved: Get Selected Features in Custom Widget - Esri Community

but I don't understand how to capture the datasource dynamically, as my geojson data source doesn't exist until the widget is run.
So 2 different directions being worked on but maybe I'm just grasping things wrong, when I add new geojson data to the mapview I want my widget to pick up when a feature is selected from that layer so could really do with another pointer on which approach is best to take?

import { React, FormattedMessage, defaultMessages as jimuCoreDefaultMessage, type AllWidgetProps, css, jsx, styled } from 'jimu-core';
import { type IMConfig } from '../config';
import { Tabs, Tab, Button } from 'jimu-ui';
import { loadArcGISJSAPIModules, type JimuMapView, JimuMapViewComponent } from 'jimu-arcgis';
import * as projection from "@arcgis/core/geometry/projection.js";
import SpatialReference from "@arcgis/core/geometry/SpatialReference.js";
import esriRequest from "@arcgis/core/request.js";
import FeatureTable from "@arcgis/core/widgets/FeatureTable.js";
import LayerView from "@arcgis/core/views/layers/LayerView.js";
import defaultMessages from './translations/default';

interface IState {
	bbox: string
	latitude: string
	longitude: string
	scale: number
	zoom: number
	mapViewReady: boolean
	featureServiceUrlInput: string
	jimuMapView: JimuMapView
	linesLayer: geojson
}
projection.load();

export default class Widget extends React.PureComponent<AllWidgetProps<IMConfig>, IState> {
	GeoJSONLayer: typeof __esri.GeoJSONLayer
	SpatialReference: typeof __esri.SpatialReference
	state = {
		bbox: '',
		latitude: '',
		longitude: '',
		zoom: 0,
		scale: 0,
		mapViewReady: false,
		featureServiceUrlInput: '',
		jimuMapView: null,
		linesLayer: null
	}
	
	activeViewChangeHandler = (jmv: JimuMapView) => {
		if (jmv){
			this.setState({
				jimuMapView: jmv
			})
			/* When the extent moves, update the state with all the updated values.*/
			jmv.view.watch('extent', evt => {
				const cs1 = new SpatialReference({
					wkid: 4326 //WGS_84
				});
				/* EXTENT/BBOX conversion to Geometry Polygon:
					BBOX(xMin,yMin,xMax,yMax)
					POLYGON((xMin yMin,xMin yMax,xMax yMax,xMax yMin,xMin yMin))
				*/
				const extentClone = jmv.view.extent.clone();
				const newExtent = projection.project(extentClone, cs1);
				//console.log(JSON.stringify(newExtent.toJSON()));
				const mapBBOX = "POLYGON((" + newExtent.xmin.toFixed(4) + " " + newExtent.ymin.toFixed(4) + "," + newExtent.xmin.toFixed(4) + " " + newExtent.ymax.toFixed(4) + "," + newExtent.xmax.toFixed(4) + " " + newExtent.ymax.toFixed(4) + "," + newExtent.xmax.toFixed(4) + " " + newExtent.ymin.toFixed(4) + "," + newExtent.xmin.toFixed(4) + " " + newExtent.ymin.toFixed(4) + "))";
				this.setState({
					bbox: mapBBOX,
					latitude: jmv.view.center.latitude.toFixed(3),
					longitude: jmv.view.center.longitude.toFixed(3),
					scale: Math.round(jmv.view.scale * 1) / 1,
					zoom: jmv.view.zoom,
					mapViewReady: true
				})
			})
		 }
	}
	
	onLineListClick = (jmv: JimuMapView) => {
		const linePopupTemplate = {
			title: "Line details",
			content:[{
				type: "fields",
				fieldInfos:[
					{fieldName: "date_time", label: "Date:"},
					{fieldName: "title", label: "Title:"},
					{fieldName: "length", label: "Distance:"},
					{fieldName: "user_name", label: "Owner:"},
					{fieldName: "description", label: "Details:"},
				]
			}]
		}
		const requestURL = "https://api.example.com/route/api/"
		// Lazy-loading (only load if/when needed) the ArcGIS API for JavaScript modules
		// that we need - only once the "Get Lines" button is pressed.
		loadArcGISJSAPIModules([
			'esri/layers/GeoJSONLayer',
			'esri/geometry/SpatialReference'
		]).then((modules) => {
			[this.GeoJSONLayer, this.SpatialReference] = modules;
			if(this.state.linesLayer === null){
				const layer = new this.GeoJSONLayer({
					url: requestURL,
					customParameters: {
						auth: this.props.config.apiToken,
						request: this.state.bbox,
						type: 'geometry'
					},
					title: "API Lines",
					renderer: {
					  type: "simple",
					  symbol: {
						  type: "simple-line",
						  width: 2,
						  color: "blue"
					  }
					},
					popupTemplate: linePopupTemplate
				});
				this.state.jimuMapView.view.map.add(layer);
				this.setState({tracksLayer: layer}, () => {
					//console.log("In Set state.");
					//console.log(this.state);
				});
				// After the layer is created, create the feature table in the UI
				layer.on('layerview-create', (event) => {					
					// Create the feature table
					const featureTable = new FeatureTable({
						view: this.state.jimuMapView.view, // Required for feature highlight to work
						layer: layer,
						visibleElements: {
							// Autocast to VisibleElements
							menuItems: {
								clearSelection: true,
								refreshData: false,
								toggleColumns: true,
								selectedRecordsShowAllToggle: true,
								selectedRecordsShowSelectedToggle: true,
								zoomToSelection: true
							}
						},
						tableTemplate: {
							// Autocast to TableTemplate
							columnTemplates: [
								// Takes an array of FieldColumnTemplate and GroupColumnTemplate
								{
									// Autocast to FieldColumnTemplate.
									type: "field",
									fieldName: "title",
									label: "Title",
									direction: "asc"
								},
								{
									type: "field",
									fieldName: "date_time",
									label: "Date"
								},
								{
									type: "field",
									fieldName: "description",
									label: "Description"
								}
							]
						},
						container: document.getElementById("lineList")
					});
				})
			}else{
				this.state.tracksLayer.customParameters.request = this.state.bbox;
				this.state.tracksLayer.refresh();
			}
		})
	}		
	
	render () {
		const StyledButton = styled.button`
			color: white;
			background-color: #1074B2;
			transition: 0.15s ease-in all;
			&:hover {
				background-color: #579EC8;
			}
		`
		return <div className="widget-demo jimu-widget" style={{ overflow: 'auto' }}>
			<Tabs>
				<Tab id="listTab" title="tab1">
					<StyledButton onClick={this.onLineListClick}>Get Linework</StyledButton>
						{this.props.hasOwnProperty('useMapWidgetIds') && this.props.useMapWidgetIds && this.props.useMapWidgetIds.length === 1 &&
						(<JimuMapViewComponent useMapWidgetId={this.props.useMapWidgetIds?.[0]}	onActiveViewChange={this.activeViewChangeHandler}/>)
						}
					<div id="lineList" style={{ width: '100%', height: '600px', overflow: 'hidden' }}>
						{this.props.intl.formatMessage({ id: '_widgetLabel', defaultMessage: defaultMessages._widgetLabel })}
					</div>
				</Tab>
				<Tab id="lineDetailsTab" title="tab2">					
					<div id="lineDetailsContainer" style={{ width: '100%', height: '600px', overflow: 'hidden' }}>
						{lineDetails}
					</div>
				</Tab>
			</Tabs>
		</div>
	}
}
0 Kudos
JeffreyThompson2
MVP Regular Contributor

I don't think this current code is salvageable. It is time to start over. I could follow your code much better and this whole process should be easier if you could rewrite it in function based React.

So if I understand your goal, you want the user to click a button. Then the current map extents would be captured and sent to an API which would send back a GeoJSON of data that you would then add to your map. 

I don't know how to do all of that, but I am willing to try to help you through it.

Could you instead load the GeoJSON layer first maybe even include it in your webmap and then query the data in your extent area? This will likely be a much simpler task.

I think you are making this more complicated than it needs to be. Here is what a add/remove layer widget looks like in function based React. Maybe you can use it as a base for your widget.

import { React, AllWidgetProps } from 'jimu-core'
import { JimuMapViewComponent, JimuMapView } from 'jimu-arcgis'
import FeatureLayer from 'esri/layers/FeatureLayer'

const { useState } = React

const Widget = (props: AllWidgetProps<any>) => {
  const [jimuMapView, setJimuMapView] = useState<JimuMapView>()
  const item = new FeatureLayer({
      url: 'URL1'
    })
  const [layer

  const activeViewChangeHandler = (jmv: JimuMapView) => {
    if (jmv) {
      setJimuMapView(jmv)
    }
  }

  const formSubmit = (evt) => {
    evt.preventDefault()
    jimuMapView.view.map.add(layer)
  }
  
const formSubmit3 = (evt) => {
    evt.preventDefault()

    jimuMapView.view.map.remove(layer)
  }
  
  
  return (
    <div className="widget-starter jimu-widget">
      {
        props.useMapWidgetIds &&
        props.useMapWidgetIds.length === 1 && (
          <JimuMapViewComponent
            useMapWidgetId={props.useMapWidgetIds?.[0]}
            onActiveViewChange={activeViewChangeHandler}
          />
        )
      }

      <form onSubmit={formSubmit}>
        <div>
          <button>Add Layer</button>
        </div>
      </form>
	   <form onSubmit={formSubmit3}>
        <div>
          <button>Delete</button>
        </div>
      </form> 
	  
    </div>
  )
}

export default Widget
GIS Developer
City of Arlington, Texas
0 Kudos
diplonics
Emerging Contributor

Thanks again here for your input, helpful pointers and offer. Agree the current code is a mess but generally learning my way into stuff is messy but once I grasp the structures it all gets cleaned up. So yep, your example of functional react is much nicer and helpful as a first step, so I'll revisit my code and build to this style today.

"So if I understand your goal, you want the user to click a button. Then the current map extents would be captured and sent to an API which would send back a GeoJSON of data that you would then add to your map." Yes, currently the above code does this fine, I just can't get an on-map click event to report back to the widget what line feature was clicked on.

However,I think your point on the bigger picture of my data flow is very relevant too, so maybe a solution on pre-defining the data source in the widget settings would work better. Maybe the API data source gets added, in the background, as the widget loads and doesn't have to wait on the button click. The button click just views the data and filters it based on extent as in requirements below.

At the minute I only require the user to set a 'Select Map Widget' onto which my widget should draw/interact.

General requirements though are:

  • The layer shouldn't load/view unless the user turns it on.
  • It should only load data (lines) relevant to the map extents.
  • It is GeoJson data coming from an external API.
  • If the layer is loaded and a feature selected it should load into my widget the details of the feature selected but only for my geojson data not for any other layer a user might have setup or added.
  • If a user has built an EB dashboard with a standard set of widgets, that link and filter other data sources, then, that should all work as EB currently does. If my widget is included it does not need to interact in this way. Its use-case is to load a specific data source for a specific extent and hold that view regardless of what the rest of the dashboard interaction does. So if a user pans the map my widget will not reload or change its data dynamics, unless the user clicks the button to reload to the new extent.

Hopefully this helpful but as I say I'll rewrite functionally, post and see what you think.

Thanks again.

0 Kudos
JeffreyThompson2
MVP Regular Contributor

If the only piece you are missing is finding what the user clicked on, the mapView.hitTest() method will return a list of all the graphics at a single point.

https://developers.arcgis.com/javascript/latest/api-reference/esri-views-MapView.html#hitTest

Something like this example should do what you are looking for.

// get screenpoint from view's click event
view.on("click", function(event){
  // Search for all features only on included layers at the clicked location
  view.hitTest(event, {include: featureLayer}).then(function(response){
    // if features are returned from the featureLayer, do something with results
    if (response.results.length){
       // do something
       console.log(response.results.length, "features returned");
    }
  })
});

 

 

GIS Developer
City of Arlington, Texas
0 Kudos
diplonics
Emerging Contributor

Okay, still working on rewriting code to the functional pattern and get working what had been, quite close but not quite there...
The functional pattern is much clearer for me to grasp so thanks for the template.
Anyway couple of specific issues around scope again, below changes/additions to your template are working for my purposes currently but I'm having to set the bbox state to var instead of const to use the updated bbox outside the extent watch event.
var is not recommended I read but can't seem to find the correct pattern to update it and follow recommended forms...

const [	jimuMapView, setJimuMapView ] = useState<JimuMapView>();
var [ bbox, setBBOX ] = useState('');

const activeViewChangeHandler = (jmv: JimuMapView) => {
	if (jmv) {
		setJimuMapView(jmv)
		/* When the extent moves, update the state with all the updated values.*/
		jmv.view.watch('extent', evt => {
			const cs1 = new SpatialReference({
				wkid: 4326 //WGS_84
			});
			/* EXTENT/BBOX conversion to Geometry Polygon:
				BBOX(xMin,yMin,xMax,yMax)
				POLYGON((xMin yMin,xMin yMax,xMax yMax,xMax yMin,xMin yMin))
			*/
			const extentClone = jmv.view.extent.clone();
			const newExtent = projection.project(extentClone, cs1);
			const mapBBOX = "POLYGON((" + newExtent.xmin.toFixed(4) + " " + newExtent.ymin.toFixed(4) + "," + newExtent.xmin.toFixed(4) + " " + newExtent.ymax.toFixed(4) + "," + newExtent.xmax.toFixed(4) + " " + newExtent.ymax.toFixed(4) + "," + newExtent.xmax.toFixed(4) + " " + newExtent.ymin.toFixed(4) + "," + newExtent.xmin.toFixed(4) + " " + newExtent.ymin.toFixed(4) + "))";
			setBBOX(mapBBOX);
		})
	}
}

Other bit is as follows where the add layer functions work fine but I get a new layer for every button click where the extent has been change, scope again maybe but when I apply a layer refresh it doesn't refresh.
You should see below I'm trying to remove and re-add the layer but not quite working either and figure that's just a hack anyway..

var [ lineLayerAdded, setLineLayerAdded ] = useState(false);
const formSubmit = (evt) => {
	evt.preventDefault();
	//If layer has already been added to map then just refresh it.
	if(lineLayerAdded){
		console.log("In layer refresh");
		//Now update layer parames and get new data
		layer.customParameters.request = bbox;
		//This is not refreshing
		layer.refresh();
		
                //Other approach here was to try and remove
                //then re-add layer.
                //So trying to add layer again after new bbox request.
		//New layer works but now have 2+ layers now!!
		jimuMapView.view.map.remove(layer);
		jimuMapView.view.map.add(layer);
	}else{
		console.log("In layer add");
		jimuMapView.view.map.add(layer);
		setLineLayerAdded(true);
	}
}
0 Kudos
JeffreyThompson2
MVP Regular Contributor

So, that add/remove layer widget is code from a introduction to React I was writing. I was making a point in that section that if the reference to layer is not stored in state it will become lost on re-render. I think that is exactly why your layer will not delete.

https://community.esri.com/t5/experience-builder-tips-and-tricks/react-for-experience-builder-develo...

GIS Developer
City of Arlington, Texas
0 Kudos
diplonics
Emerging Contributor

Ahh, lots in that article to pour over and once probably won't do it for me either🙄
Anyway, got it working as I want but some changes as I originally thought

const [layer

was a typo, but trying to add it in is giving me an error on using the Widget in EB.
Seems something is trying to close off the brace and not sure where you are adding in the 'item' featureLayer to that structure.

***********************

Load module error. Error: Module parse failed: Identifier 'layer' has already been declared (60:11)
File was processed with these loaders:
* ./node_modules/ts-loader/index.js
* ./node_modules/thread-loader/dist/cjs.js
* ./webpack/update-webpack-public-path-loader.js
You may need an additional loader to handle the result of these loaders.
| popupTemplate: videoPopupTemplate
| });
> const [layer];
| const activeViewChangeHandler = (jmv) => {
| if (jmv) {
**************************

So I adapted to the other approach I was using and the following is working fine now to add layer on first click, then only update/refresh layer with new data on an extent change and subsequent new click.

const [layer, setLayer ] = useState<GeoJSONLayer>();
const lineLayer = new GeoJSONLayer({
	url: requestURL,
	customParameters: {
		request: bbox
	},
	title: "API Lines",
	renderer: {
		type: "simple",
		symbol: {
			type: "simple-line",
			width: 2,
			color: "blue"
		}
	},
	popupTemplate: linePopupTemplate
});
const formSubmit = (evt) => {
	evt.preventDefault();
	//If layer has already been added to map then just refresh it.
	if(lineLayerAdded){
		//Now get new data
		layer.customParameters.request = bbox;
		layer.refresh();
	}else{
		jimuMapView.view.map.add(lineLayer);
		setLineLayerAdded(true);
		setLayer(lineLayer);
	}
}

 

0 Kudos
diplonics
Emerging Contributor

Just added in your get feature clicked example and it works perfect, so lots of struggling to grasp react sorted so thank you @JeffreyThompson2. Onto the next bit, sort through clicked features returned and build some UI elements from it...

 

jmv.view.on("click", function(event){
	// Search for all features only on included layers at the clicked location
	jmv.view.hitTest(event, {include: layer}).then(function(response){
		// if features are returned from the featureLayer, do something with results
		if (response.results.length){
			// do something
			console.log(response.results.length, "features returned");
		}
	})
});

 

 

0 Kudos