Hi,
I've been trying to mix two functionnalities. One is about displaying attached images in popup. And the other is about clustering.
Most of the code below behaves as expected except for browsing cluster's features.
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no"/>
<title>Parking clusters</title>
<link rel="stylesheet" href="https://js.arcgis.com/4.26/esri/themes/light/main.css"/>
<style>
html,
body,
#viewDiv {
padding: 0;
margin: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
</style>
<script src="https://js.arcgis.com/4.26/"></script>
<script>
require([
"esri/config",
"esri/portal/Portal",
"esri/WebMap",
"esri/views/MapView",
"esri/widgets/LayerList",
"esri/widgets/Expand",
"esri/layers/GroupLayer",
"esri/smartMapping/labels/clusters",
"esri/smartMapping/popup/clusters",
"esri/core/reactiveUtils",
"esri/symbols/support/symbolUtils",
"esri/geometry/geometryEngine",
"esri/Graphic",
"esri/popup/FieldInfo",
"esri/popup/ExpressionInfo"
// Fonction de callback. Attention a passer les constructeurs et les fonctions en arguments dans le même ordre que celui des modules
], function(esriConfig, Portal, WebMap, MapView, LayerList, Expand, GroupLayer, clusterLabelCreator, clusterPopupCreator, reactiveUtils, symbolUtils, geometryEngine, Graphic, FieldInfo, ExpressionInfo) {
let layerAdmHorsCara;
let basePopupTemplate = [];
let webmapOperationalLayers;
let layerServParkingVelo;
let layerViewServParkingVelo;
esriConfig.portalUrl = "https://carto.zzzzzzzzzzz.fr/argis";
const view = new MapView({
container: "viewDiv"
});
const myPortal = new Portal();
const webmap = new WebMap({
portalItem: {
portal: myPortal,
id: "xxxyyyxxxyyyxxxyyyxxxyyyxxxyyyxx"
}
});
const groupLayerVtt = new GroupLayer({
title: "VTT",
visible: false
});
const groupLayerVel = new GroupLayer({
title: "Vélo",
visible: true
});
const groupLayerCor = new GroupLayer({
title: "Course d'orientation",
visible: false
});
const groupLayerEqu = new GroupLayer({
title: "Equestre",
visible: false
});
const groupLayerPed = new GroupLayer({
title: "Pédestre",
visible: false
});
const groupLayerServ = new GroupLayer({
title: "Services",
visible: false
});
webmap.load()
.then(function() {
return webmap.basemap.load();
})
.then(function() {
const layers = webmap.layers;
const promises = layers.map(function(layer) {
return layer.load();
});
return Promise.all(promises.toArray());
})
.then(function(layers) {
layers.forEach(function(element) {
element.title = element.title.slice(4);
});
return layers;
})
.then(function(layers) {
webmapOperationalLayers = layers;
layers.map(function(layer) {
if (layer.popupTemplate) {
basePopupTemplate[layer.id] = layer.popupTemplate;
}
});
const layerPedCircuit = layers[0];
const layerPedEquipement = layers[1];
const layerPedBorne = layers[2];
const layerEquParcours = layers[3];
const layerEquRevetement = layers[4];
const layerEquEquipement = layers[5];
const layerEquBorne = layers[6];
const layerEquAccesParcours = layers[7];
const layerCorEquipement = layers[8];
const layerCorBorne = layers[9];
const layerVttCircuit = layers[10];
const layerVttRevetement = layers[11];
const layerVttEquipement = layers[12];
const layerVttBorne = layers[13];
const layerVelCircuit = layers[14];
const layerVelEquipement = layers[15];
const layerVelBorne = layers[16];
layerAdmHorsCara = layers[17];
const layerServPiqueNique = layers[18];
const layerServEquipVelo = layers[19];
const layerServEauPotable = layers[20];
const layerServSanitaire = layers[21];
layerServParkingVelo = layers[22];
layerAdmHorsCara.listMode = "hide";
groupLayerVtt.addMany([layerVttCircuit, layerVttRevetement, layerVttEquipement, layerVttBorne]);
groupLayerVel.addMany([layerVelCircuit, layerVelEquipement, layerVelBorne]);
groupLayerCor.addMany([layerCorEquipement, layerCorBorne]);
groupLayerEqu.addMany([layerEquParcours, layerEquRevetement, layerEquEquipement, layerEquBorne, layerEquAccesParcours]);
groupLayerPed.addMany([layerPedCircuit, layerPedEquipement, layerPedBorne]);
groupLayerServ.addMany([layerServParkingVelo, layerServPiqueNique, layerServEquipVelo, layerServEauPotable, layerServSanitaire]);
})
.then(function(){
view.map = webmap;
});
view.when(function(){
webmap.removeAll();
webmap.addMany([layerAdmHorsCara, groupLayerPed, groupLayerEqu, groupLayerCor, groupLayerVtt, groupLayerVel, groupLayerServ]);
})
.then(function(){
const layerList = new LayerList({
container: document.createElement("llDiv"),
view: view,
selectionEnabled: false,
listItemCreatedFunction: function(event) {
const item = event.item;
if (item.layer.type != "group") { // don't show legend twice
item.panel = {
content: "legend",
open: false,
};
}
}
});
// Builds popup template for layers with attachments to be displayed in popup
function buildLayerPopupTemplate(f_objectId, layer) {
layer.queryAttachments({
attachmentTypes: ["image/jpeg", "image/png"],
objectIds: f_objectId
})
.then(function(attachmentsByFeatureId) {
if (!attachmentsByFeatureId) {
return;
}
if (["Parking_velo", "Pique-nique"].includes(layer.title)) {
let template = {title:basePopupTemplate[layer.id].title};
let content = [{
type: "fields",
fieldInfos: basePopupTemplate[layer.id].fieldInfos
}];
// If there are attached images
if (Object.keys(attachmentsByFeatureId).length > 0){
const attachments = Object.values(attachmentsByFeatureId)[0];
let mediaInfos = [];
if (attachments) {
attachments.forEach(function (attachment) {
const image_url = attachment.url;
const item = {
type: "image",
value: {
sourceURL:image_url,
linkURL: image_url
}
}
mediaInfos.push(item);
});
let typeMedia = {
type: "media",
}
typeMedia.mediaInfos = mediaInfos;
content.push(typeMedia);
}
};
template.content = content;
layer.popupTemplate = template;
}
})
.catch(function(error) {
console.log(error)
});
}
view.on("click", function(event) {
const opts = {
include: webmapOperationalLayers.filter(layer => {
return layer.type === "feature"
})
}
view.hitTest(event, opts)
.then(function (response) {
if (response.results.length) {
let graphic = response.results[0].graphic;
if (!graphic.attributes.objectid) {
return;
}
buildLayerPopupTemplate(graphic.attributes.objectid, graphic.layer);
}
})
.catch(function(error) {
console.log(error)
});
});
layerServParkingVelo.when()
.then(generateClusterConfig)
.then(async (featureReduction) => {
// sets generated cluster configuration on the layer
layerServParkingVelo.featureReduction = featureReduction;
// the layer view is needed for querying clusters
layerViewServParkingVelo = await view.whenLayerView(layerServParkingVelo);
})
.catch((error) => {
console.error(error);
});
async function generateClusterConfig(layer) {
// generates default popupTemplate
const popupTemplate = await clusterPopupCreator
.getTemplates({
layer
})
.then(
(popupTemplateResponse) =>
popupTemplateResponse.primaryTemplate.value
);
// Add actions for exploring the features of each cluster
popupTemplate.actions = [
{
title: "Convex hull",
id: "convex-hull",
className: "esri-icon-polygon"
},
{
title: "Show features",
id: "show-features",
className: "esri-icon-maps"
}
];
popupTemplate.title = "Groupe de {cluster_count} parkings vélo";
const clusterSumExpressionInfo = new ExpressionInfo({
name: "cluster-sum",
title: "somme des places",
expression: `
Expects($aggregatedFeatures, 'nombre_place')
Text(Sum($aggregatedFeatures, 'nombre_place'), '#,###')
`
//returnType: "string"
})
popupTemplate.expressionInfos.push(clusterSumExpressionInfo)
popupTemplate.content = [{
type: "text",
text: "Cela représente {expression/cluster-sum} places au total."
}, {
type: "text",
text: "Un zoom avant affichera plus en détail la localisation des parkings."
}
]
// generates default labelingInfo
const { labelingInfo, clusterMinSize } = await clusterLabelCreator
.getLabelSchemes({
layer,
view
})
.then((labelSchemes) => labelSchemes.primaryScheme);
labelingInfo[0].symbol.color = "rgba(0, 112, 255, 0.8)";
labelingInfo[0].symbol.haloSize = 0;
const clusterSymbol = {
type: "simple-marker",
style: "circle",
color: "rgba(255, 255, 255, 0.8)",
outline: {
color: "rgba(0, 112, 255, 0.8)",
width: 2
}
}
const clusterMaxSize = 40;
const clusterRadius = 80;
return {
type: "cluster",
popupTemplate,
labelingInfo,
clusterMinSize,
clusterMaxSize: clusterMaxSize,
clusterRadius: clusterRadius,
maxScale: 5000,
symbol: clusterSymbol
};
}
view.popup.on("trigger-action", (event) => {
clearViewGraphics();
const id = event.action.id;
if (id === "convex-hull") {
displayConvexHull(view.popup.selectedFeature);
}
if (id === "show-features") {
displayFeatures(view.popup.selectedFeature);
}
});
reactiveUtils.watch(
() => [view.scale, view.popup.selectedFeature, view.popup.visible],
([scale, selectedFeature, visible]) => {
clearViewGraphics()
if (selectedFeature) {
if (!selectedFeature.isAggregate) {
buildLayerPopupTemplate(selectedFeature.attributes.objectid, selectedFeature.layer);
}
}
}
);
let convexHullGraphic = null;
let clusterChildGraphics = [];
function clearViewGraphics() {
view.graphics.remove(convexHullGraphic);
view.graphics.removeMany(clusterChildGraphics);
}
// displays all features from a given cluster in the view
async function displayFeatures(graphic) {
processParams(graphic, layerViewServParkingVelo);
const query = layerViewServParkingVelo.createQuery();
query.aggregateIds = [graphic.getObjectId()];
const { features } = await layerViewServParkingVelo.queryFeatures(query);
features.forEach(async (feature) => {
const symbol = await symbolUtils.getDisplayedSymbol(feature);
feature.symbol = symbol;
view.graphics.add(feature);
});
clusterChildGraphics = features;
}
async function displayConvexHull(graphic) {
processParams(graphic, layerViewServParkingVelo);
const query = layerViewServParkingVelo.createQuery();
query.aggregateIds = [graphic.getObjectId()];
const { features } = await layerViewServParkingVelo.queryFeatures(query);
const geometries = features.map((feature) => feature.geometry);
const [convexHull] = geometryEngine.convexHull(geometries, true);
convexHullGraphic = new Graphic({
geometry: convexHull,
symbol: {
type: "simple-fill",
outline: {
width: 1.5,
color: [75, 75, 75, 1]
},
style: "none",
color: [0, 0, 0, 0.1]
}
});
view.graphics.add(convexHullGraphic);
}
function processParams(graphic, layerView) {
if (!graphic || !layerView) {
throw new Error("Graphic or layerView not provided.");
}
if (!graphic.isAggregate) {
throw new Error("Graphic must represent a cluster.");
}
}
const layerListExpand = new Expand({
expandIconClass: "esri-icon-layer-list",
expandTooltip: "Légende",
collapseTooltip: "Replier la légende",
view: view,
content: layerList.domNode,
group: "top-right",
expanded: true
});
view.ui.add([layerListExpand], "bottom-right");
});
});
</script>
</head>
<body>
<div id="viewDiv"></div>
</body>
</html>
When browsing clicked features (forward and backward) everything is fine.
Browsing forward cluster's features is fine too. But an error occurs when browsing those features backward.
At line 355 selectedFeature.layer is null.
I don't understand why and how to fix this issue.
Could you please help me ?
Solved! Go to Solution.
This is a bug in the API, particularly the PopupViewModel, which I'll try to summarize here, and then provide recommendations.
Basically, the PopupViewModel stores a reference to all the features in the vicinity that you clicked on the map. When clicking on a cluster, you can browse the cluster's features as shown in your screenshots. When you click the next button, it takes that corresponding feature's Graphic reference and adds it to the MapView's graphics collection, thus enabling you to temporarily see that particular feature on the map (i.e. in addition to the cluster graphic). If you click next again, it removes the current Graphic, and then adds the newly selected feature's Graphic. It is this adding and removing from the MapView's graphics that causes the Graphics' layer property to get severed.
My recommendation for you:
There are various ways to work around this,, and all will fall under the category of a hack. My recommendation is to apply the simplest one, and replace the reference to "layer" with the undocumented property "sourceLayer" like so:
buildLayerPopupTemplate(selectedFeature.attributes.objectid, selectedFeature.sourceLayer);
You can change this back if it ever gets fixed.
My recommendation for ESRI:
Since we have to wait three and a half weeks to download 4.27, I'm still looking at 4.26. The problem occurs in the PopupViewModel module, particularly in the "_selectedFeatureChange" function. When setting the "_selectedClusterFeature" property, this can be resolved by setting it to a clone of the graphic, rather than the graphic itself. That is, replace:
this._selectedClusterFeature=b
with:
this._selectedClusterFeature=b.clone()
This is a bug in the API, particularly the PopupViewModel, which I'll try to summarize here, and then provide recommendations.
Basically, the PopupViewModel stores a reference to all the features in the vicinity that you clicked on the map. When clicking on a cluster, you can browse the cluster's features as shown in your screenshots. When you click the next button, it takes that corresponding feature's Graphic reference and adds it to the MapView's graphics collection, thus enabling you to temporarily see that particular feature on the map (i.e. in addition to the cluster graphic). If you click next again, it removes the current Graphic, and then adds the newly selected feature's Graphic. It is this adding and removing from the MapView's graphics that causes the Graphics' layer property to get severed.
My recommendation for you:
There are various ways to work around this,, and all will fall under the category of a hack. My recommendation is to apply the simplest one, and replace the reference to "layer" with the undocumented property "sourceLayer" like so:
buildLayerPopupTemplate(selectedFeature.attributes.objectid, selectedFeature.sourceLayer);
You can change this back if it ever gets fixed.
My recommendation for ESRI:
Since we have to wait three and a half weeks to download 4.27, I'm still looking at 4.26. The problem occurs in the PopupViewModel module, particularly in the "_selectedFeatureChange" function. When setting the "_selectedClusterFeature" property, this can be resolved by setting it to a clone of the graphic, rather than the graphic itself. That is, replace:
this._selectedClusterFeature=b
with:
this._selectedClusterFeature=b.clone()
I have replied twice to this with a solution and my posts keep getting deleted almost immediately. Did you see either of those before they disappeared?
I've not seen anything. 😕
The third attempt seems to have worked; good thing I made a backup of the second attempt...
Haha, nope! I still don't see it. Maybe try contacting the community team.
Sure enough, it disappeared...I took a screenshot last time; maybe just posting that will work. Here goes:
Apologies on that, @JoelBennett!
Our automated spam filter isn't always as smart as it thinks it is and can occasionally make a mistake in what it quarantines for our team's review.
I've released your comment to this thread so that it's now public again.
Hi @OlivierLefevre -
I haven't been able to reproduce the issue you're experiencing with any of our clustering samples. We'd like to look into this issue. Would you be able to send over a CodePen with a simplified sample and let us know what type of data is in your webmap (e.g. feature, map image, etc.)?
Accessing the selectedFeature's layer for MapImageLayers does not work because the layer property is not exposed for graphics from a MapImageLayer. It doesn't seem like your data is from a map image service since you're checking for the "feature" type, but I would like to make sure that's not the case. I haven't seen this issue before with clustering so any extra information you can provide would be helpful!
Just note the sourceLayer property isn't an officially documented property so this might not always work.
Thanks!