diff --git a/index.html b/index.html index f22c6cb..13a1745 100644 --- a/index.html +++ b/index.html @@ -110,6 +110,8 @@ let _layerInfo; let _timeSlider; const _binRendererProps = {}; + let _polygonalAggLayerIds = []; + let _polygonalAggInfos = null; // ### Spatial Reference variables ### // These variables manage coordinate system transformations and spatial reference handling @@ -228,6 +230,8 @@ // ### Section visibility state objects ### const layersVisibility = { value: true }; + const polyAggVisibility = { value: true }; + const polyAggSettingsVisibility = { value: true }; const aggSettingsVisibility = { value: true }; const aggStyleVisibility = { value: false }; const aggBinsVisibility = { value: true }; @@ -244,6 +248,9 @@ on(dojo.byId("aggStyleToggle"), "click", createSectionToggle("aggStyleSection", "aggStyleToggle", aggStyleVisibility)); on(dojo.byId("aggBinsToggle"), "click", createSectionToggle("aggBinsSection", "aggBinsToggle", aggBinsVisibility)); on(dojo.byId("streamingModeToggle"), "click", createSectionToggle("streamingModeSection", "streamingModeToggle", streamingModeVisibility)); + on(dojo.byId("polyAggToggle"), "click", createSectionToggle("polyAggSection", "polyAggToggle", polyAggVisibility)); + on(dojo.byId("polyAggSettingsToggle"), "click", createSectionToggle("polyAggSettingsSection", "polyAggSettingsToggle", polyAggSettingsVisibility)); + on(dojo.byId("applyPolyAggButton"), "click", applyPolygonalAggregation); on(dojo.byId("aggLabelsToggle"), "click", createSectionToggle("aggLabelsMainSection", "aggLabelsToggle", aggLabelsVisibility)); on(dojo.byId("setLayerButton"), "click", setFeatureLayers); @@ -503,22 +510,6 @@ // ### Aggregation Labels Functions ### //############################################################################################# - /** - * Toggles the visibility of aggregation labels and their control panel - * @param {Event} evt - The change event from the checkbox - */ - function toggleLabels(evt) { - // Toggle label visibility and show/hide label controls - if (evt.target.id === "renderLabels") { - if (dojo.byId("renderLabels").checked === true) { - dojo.byId("aggLabelsSection").style.display = "block"; - } else { - dojo.byId("aggLabelsSection").style.display = "none"; - } - updateLayerFromUIChange(); - } - } - /** * Populates the statistical type select options based on the selected field * @param {Object} selectedOption - The selected field option @@ -577,10 +568,427 @@ _layerInfo = response; populateDijitSelectWithLayerFields(dijit.byId('statField'), false); statFieldChanged(dijit.byId('statField').value); // to init-populate the stat types control + buildPolygonalAggLayerToggles(response); } ); } + /** + * Extracts unique linkedFeatureLayerUri values from polygonalAggInfos + * and builds checkbox toggles in the Layers section + * @param {Object} layerInfo - The layer info response containing polygonalAggInfos + */ + function buildPolygonalAggLayerToggles(layerInfo) { + const container = dojo.byId("polygonalAggLayers"); + if (!container) return; + + // Clear previous entries + container.innerHTML = ""; + + // Remove any previously added polygonal agg layers from the map + if (_polygonalAggLayerIds) { + array.forEach(_polygonalAggLayerIds, function (layerId) { + const existingLayer = _map.getLayer(layerId); + if (existingLayer) { + _map.removeLayer(existingLayer); + } + }); + } + _polygonalAggLayerIds = []; + + if (!layerInfo || !layerInfo.polygonalAggInfos || !Array.isArray(layerInfo.polygonalAggInfos)) { + return; + } + + // Collect unique URIs with their associated field/level info + const uniqueUris = []; + const seenUris = {}; + array.forEach(layerInfo.polygonalAggInfos, function (aggInfo) { + if (!aggInfo.aggAttributeInfos || !Array.isArray(aggInfo.aggAttributeInfos)) return; + array.forEach(aggInfo.aggAttributeInfos, function (attrInfo) { + if (attrInfo.linkedFeatureLayerUri && !seenUris[attrInfo.linkedFeatureLayerUri]) { + seenUris[attrInfo.linkedFeatureLayerUri] = true; + uniqueUris.push({ + uri: attrInfo.linkedFeatureLayerUri, + fieldName: attrInfo.fieldName, + level: attrInfo.level, + aggType: aggInfo.aggType + }); + } + }); + }); + + // Populate the Polygonal Aggregations dropdowns + populatePolygonalAggDropdowns(layerInfo); + + if (uniqueUris.length === 0) return; + + // Build a checkbox row for each unique URI + array.forEach(uniqueUris, function (info, idx) { + const layerId = "polyAggLayer_" + idx; + _polygonalAggLayerIds.push(layerId); + + // Derive a short display name from the URI + const uriParts = info.uri.split("/"); + const serviceName = uriParts[uriParts.length - 3] || info.uri; + const displayName = serviceName.replace(/_/g, " "); + + var row = document.createElement("div"); + row.title = info.uri; + row.style.cssText = "display:flex; align-items:center; justify-content:space-between; width:100%; margin:3px 0;"; + + var label = document.createElement("label"); + label.setAttribute("for", layerId); + label.textContent = displayName + " (" + info.fieldName + ")"; + label.style.cssText = "flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:8px;"; + + var checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = layerId; + checkbox.setAttribute("data-uri", info.uri); + checkbox.setAttribute("data-layer-id", layerId); + checkbox.style.cssText = "flex:0 0 auto; margin:0;"; + + row.appendChild(label); + row.appendChild(checkbox); + container.appendChild(row); + + // Attach toggle handler + on(checkbox, "change", function () { + togglePolygonalAggLayer(this); + }); + }); + } + + /** + * Toggles a polygonal aggregation feature layer on/off based on checkbox state + * @param {HTMLInputElement} checkbox - The checkbox element with data-uri and data-layer-id attributes + */ + function togglePolygonalAggLayer(checkbox) { + const uri = checkbox.getAttribute("data-uri"); + const layerId = checkbox.getAttribute("data-layer-id"); + + const existingLayer = _map.getLayer(layerId); + if (existingLayer) { + // Layer already on the map – just toggle visibility + existingLayer.setVisibility(checkbox.checked); + console.log((checkbox.checked ? "Showed" : "Hid") + " polygonal aggregation layer: " + uri); + } else if (checkbox.checked) { + // First time – add the feature layer to the map + const featureLayer = new FeatureLayer(uri, { + id: layerId, + outFields: ["*"], + opacity: 0.6 + }); + _map.addLayer(featureLayer); + console.log("Added polygonal aggregation layer: " + uri); + } + } + + /** + * Populates the Polygonal Aggregations dropdowns from polygonalAggInfos + * - "Aggregation Type" dropdown from aggType values + * - "Aggregation Field" dropdown from the selected aggType's aggAttributeInfos fieldName entries + * @param {Object} layerInfo - The layer info response containing polygonalAggInfos + */ + function populatePolygonalAggDropdowns(layerInfo) { + const typeSelect = dojo.byId("polyAggType"); + const fieldSelect = dojo.byId("polyAggField"); + if (!typeSelect || !fieldSelect) return; + + // Clear existing options + typeSelect.innerHTML = ""; + fieldSelect.innerHTML = ""; + + if (!layerInfo || !layerInfo.polygonalAggInfos || !Array.isArray(layerInfo.polygonalAggInfos)) { + return; + } + + // Store the full polygonalAggInfos for use when type changes + _polygonalAggInfos = layerInfo.polygonalAggInfos; + + // Populate Aggregation Type dropdown with unique aggType values + array.forEach(_polygonalAggInfos, function (aggInfo, idx) { + const option = document.createElement("option"); + option.value = idx; + option.textContent = aggInfo.aggType; + typeSelect.appendChild(option); + }); + + // Populate Aggregation Field based on the first aggType + updatePolyAggFieldDropdown(); + + // Attach change handler for Aggregation Type + on(typeSelect, "change", updatePolyAggFieldDropdown); + } + + /** + * Updates the Aggregation Field dropdown based on the currently selected Aggregation Type + */ + function updatePolyAggFieldDropdown() { + const typeSelect = dojo.byId("polyAggType"); + const fieldSelect = dojo.byId("polyAggField"); + if (!typeSelect || !fieldSelect || !_polygonalAggInfos) return; + + fieldSelect.innerHTML = ""; + + const selectedIdx = parseInt(typeSelect.value); + if (isNaN(selectedIdx) || !_polygonalAggInfos[selectedIdx]) return; + + const aggInfo = _polygonalAggInfos[selectedIdx]; + if (!aggInfo.aggAttributeInfos || !Array.isArray(aggInfo.aggAttributeInfos)) return; + + array.forEach(aggInfo.aggAttributeInfos, function (attrInfo) { + const option = document.createElement("option"); + option.value = attrInfo.fieldName; + option.textContent = attrInfo.fieldName + " (level " + attrInfo.level + ")"; + fieldSelect.appendChild(option); + }); + } + + /** + * Sends a polygonal aggregation query, then joins results to the matching + * linked feature layer and applies a ClassBreaksRenderer. + * + * Flow: + * 1. Read selected aggType / fieldName from the UI + * 2. Query inputUrl with groupByFieldsForStatistics= + * 3. Find the linkedFeatureLayerUri that matches the selected field + * 4. Query the linked feature layer for its features + * 5. Join aggregation counts to linked features via the common field + * 6. Apply a ClassBreaksRenderer to visualise the result + */ + function applyPolygonalAggregation() { + if (!_polygonalAggInfos) { + console.warn("No polygonalAggInfos available."); + return; + } + + const typeSelect = dojo.byId("polyAggType"); + const fieldSelect = dojo.byId("polyAggField"); + if (!typeSelect || !fieldSelect) return; + + const selectedIdx = parseInt(typeSelect.value); + const fieldName = fieldSelect.value; + if (isNaN(selectedIdx) || !fieldName) return; + + const aggInfo = _polygonalAggInfos[selectedIdx]; + if (!aggInfo || !aggInfo.aggAttributeInfos) return; + + // Find the selected attrInfo and its level + let selectedAttrInfo = null; + array.forEach(aggInfo.aggAttributeInfos, function (attrInfo) { + if (attrInfo.fieldName === fieldName) { + selectedAttrInfo = attrInfo; + } + }); + if (!selectedAttrInfo) return; + + const selectedLevel = selectedAttrInfo.level; + + // Collect all field names from level 0 up to and including the selected level + // for groupByFieldsForStatistics + const groupByFields = []; + array.forEach(aggInfo.aggAttributeInfos, function (attrInfo) { + if (attrInfo.level <= selectedLevel) { + groupByFields.push(attrInfo.fieldName); + } + }); + // Sort by level to ensure correct order + groupByFields.sort(function (a, b) { + let levelA = 0, levelB = 0; + array.forEach(aggInfo.aggAttributeInfos, function (ai) { + if (ai.fieldName === a) levelA = ai.level; + if (ai.fieldName === b) levelB = ai.level; + }); + return levelA - levelB; + }); + + // Find linkedFeatureLayerUri: use the selected field's URI, + // or traverse up to lower levels to find the nearest parent with a URI + let linkedUri = null; + for (let lvl = selectedLevel; lvl >= 0; lvl--) { + array.forEach(aggInfo.aggAttributeInfos, function (attrInfo) { + if (attrInfo.level === lvl && attrInfo.linkedFeatureLayerUri && !linkedUri) { + linkedUri = attrInfo.linkedFeatureLayerUri; + } + }); + if (linkedUri) break; + } + + // Build the aggregation query against the source layer + const inputUrl = dojo.byId("inputUrl").value; + const groupByFieldsStr = groupByFields.join(","); + const outStatistics = JSON.stringify([{ + statisticType: "count", + onStatisticField: fieldName, + outStatisticFieldName: "agg_count" + }]); + const queryUrl = inputUrl + "/query?where=1%3D1" + + "&groupByFieldsForStatistics=" + encodeURIComponent(groupByFieldsStr) + + "&outStatistics=" + encodeURIComponent(outStatistics) + + "&returnGeometry=false&f=pjson"; + + console.log("Polygonal aggregation query: " + queryUrl); + + // Step 1: Query aggregation stats from the source layer + const aggRequest = esriRequest({ + url: queryUrl, + handleAs: "json", + callbackParamName: "callback" + }); + + aggRequest.then(function (aggResponse) { + const aggFeatures = Array.isArray(aggResponse.features) ? aggResponse.features : []; + if (aggFeatures.length === 0) { + console.warn("Aggregation query returned no features."); + return; + } + + // Log aggregation results + console.log("Aggregation query returned " + aggFeatures.length + " results."); + + // If no linked feature layer URI exists, stop here — just log the results + if (!linkedUri) { + console.log("No linkedFeatureLayerUri available for field '" + fieldName + "'. Aggregation results (no rendering):"); + array.forEach(aggFeatures, function (f) { + console.log(" " + JSON.stringify(f.attributes)); + }); + return; + } + + // Build a lookup: fieldValue -> agg_count + const aggLookup = {}; + let maxCount = 0; + array.forEach(aggFeatures, function (f) { + const key = f.attributes[fieldName]; + const count = f.attributes["agg_count"] || 0; + aggLookup[key] = count; + if (count > maxCount) maxCount = count; + }); + + // Step 2: Query the linked feature layer for geometries + const linkedQueryUrl = linkedUri + "/query?where=1%3D1" + + "&outFields=" + encodeURIComponent(fieldName) + + "&returnGeometry=true&outSR=" + _currentMapSR + "&f=pjson"; + + const linkedRequest = esriRequest({ + url: linkedQueryUrl, + handleAs: "json", + callbackParamName: "callback" + }); + + linkedRequest.then(function (linkedResponse) { + const linkedFeatures = Array.isArray(linkedResponse.features) ? linkedResponse.features : []; + if (linkedFeatures.length === 0) { + console.warn("Linked feature layer returned no features."); + return; + } + + // Remove previous polygonal aggregation result layer if it exists + const prevResultLayer = _map.getLayer("polyAggResult"); + if (prevResultLayer) { + _map.removeLayer(prevResultLayer); + } + + // Create a new client-side feature layer for the aggregation results + const resultLayerDef = { + geometryType: "esriGeometryPolygon", + fields: [ + { name: "objectid", type: "esriFieldTypeOID", alias: "objectid" }, + { name: fieldName, type: "esriFieldTypeString", alias: fieldName }, + { name: "agg_count", type: "esriFieldTypeInteger", alias: "Aggregation Count" } + ] + }; + const resultCollection = { + layerDefinition: resultLayerDef, + featureSet: { + features: [], + geometryType: "esriGeometryPolygon" + } + }; + const resultInfoTemplate = new InfoTemplate("Aggregation Result", "${*}"); + const resultLayer = new FeatureLayer(resultCollection, { + id: "polyAggResult", + objectIdField: "objectid", + showLabels: true, + infoTemplate: resultInfoTemplate, + outFields: ["*"] + }); + + // Build graphics by joining linked geometries with aggregation counts + const mapSR = new SpatialReference(_map.spatialReference.wkid); + const graphics = []; + array.forEach(linkedFeatures, function (feature) { + if (!feature.geometry) return; + feature.geometry.spatialReference = mapSR.toJson(); + const geometry = new Polygon(feature.geometry); + const key = feature.attributes[fieldName]; + const aggCount = aggLookup[key] || 0; + const attrs = {}; + attrs[fieldName] = key; + attrs["agg_count"] = aggCount; + const graphic = new Graphic(geometry, null, attrs, null); + graphics.push(graphic); + }); + + // Apply a ClassBreaksRenderer based on agg_count + const renderer = createPolyAggClassBreaksRenderer(maxCount); + resultLayer.setRenderer(renderer); + + _map.addLayer(resultLayer); + + resultLayer.on("load", function () { + resultLayer.applyEdits(graphics, null, null).then(function () { + resultLayer.refresh(); + console.log("Polygonal aggregation result layer added: " + graphics.length + " features."); + }); + }); + + }, function (error) { + console.log("Error querying linked feature layer: " + error.message); + }); + + }, function (error) { + console.log("Error querying polygonal aggregation: " + error.message); + }); + } + + /** + * Creates a ClassBreaksRenderer for polygonal aggregation results + * Uses the "agg_count" field with color ramp from light to dark + * @param {number} maxCount - The maximum aggregation count for scaling breaks + * @returns {ClassBreaksRenderer} The renderer instance + */ + function createPolyAggClassBreaksRenderer(maxCount) { + if (maxCount <= 0) maxCount = 1; + + function createSymbol(color) { + return new SimpleFillSymbol() + .setColor(color) + .setOutline( + new SimpleLineSymbol().setColor(new Color([99, 99, 99, 1])).setWidth(0.5) + ); + } + + const breakSize = maxCount / 7; + const renderer = new ClassBreaksRenderer({ + field: "agg_count", + defaultSymbol: createSymbol(new Color([150, 150, 150, 0.3])), + classBreakInfos: [ + { minValue: 0, maxValue: breakSize, symbol: createSymbol(new Color([254, 240, 217, 0.7])) }, + { minValue: breakSize, maxValue: breakSize * 2, symbol: createSymbol(new Color([253, 212, 158, 0.7])) }, + { minValue: breakSize * 2, maxValue: breakSize * 3, symbol: createSymbol(new Color([253, 187, 132, 0.8])) }, + { minValue: breakSize * 3, maxValue: breakSize * 4, symbol: createSymbol(new Color([252, 141, 89, 0.8])) }, + { minValue: breakSize * 4, maxValue: breakSize * 5, symbol: createSymbol(new Color([239, 101, 72, 0.9])) }, + { minValue: breakSize * 5, maxValue: breakSize * 6, symbol: createSymbol(new Color([215, 48, 31, 0.9])) }, + { minValue: breakSize * 6, maxValue: maxCount + 1, symbol: createSymbol(new Color([153, 0, 0, 1])) } + ] + }); + + return renderer; + } + function populateDijitSelectWithLayerFields(select, numericOnly) { // clear exiting options select.removeOption(select.getOptions()); @@ -600,30 +1008,6 @@ } } - /** - * Populates the select element with layer fields - * @param {Object} select - The select element to populate - * @param {boolean} numericOnly - Whether to include only numeric fields - */ - function populateSelectWithLayerFields(select, numericOnly) { - // clear exiting options - for (let i = select.options.length - 1; i >= 0; i--) { - select.remove(i); - } - - // populate options based on the layerFields array - let pos = 0; - for (let i = 0; i < layerFields.length; i++) { - const field = _layerInfo.fields[i]; - if (numericOnly) { - if (isFieldNumeric(field)) - select.options[pos++] = new Option(field.name, field.name); - } else { - if (isSupportedStatField(field)) - select.options[pos++] = new Option(field.name, field.name); - } - } - } /** * Checks if the field is numeric @@ -1708,6 +2092,42 @@

Layers

+ + + + + +
+
+

Polygonal Aggregation Layers

+
+ +
+
+
+
+
+ + +
+
+

Polygonal Aggregations

+
+ +
+
+
+
+ + +
+
+ + +
+
+ +