Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/1258 extend feature info support #1262

Merged
merged 8 commits into from
Jan 19, 2023
82 changes: 22 additions & 60 deletions new-client/src/components/FeatureInfo/FeatureInfoContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import propTypes from "prop-types";
import { styled } from "@mui/material/styles";
import ArrowLeftIcon from "@mui/icons-material/ArrowLeft";
import ArrowRightIcon from "@mui/icons-material/ArrowRight";
import FeaturePropsParsing from "./FeaturePropsParsing";
// import Diagram from "../Diagram";
// import HajkTable from "../Table";
import {
Table,
TableContainer,
Expand All @@ -18,6 +15,11 @@ import {
Grid,
} from "@mui/material";

import FeaturePropsParsing from "./FeaturePropsParsing";
import { getInfoClickInfoFromLayerConfig } from "utils/InfoClickHelpers.js";
// import Diagram from "../Diagram";
// import HajkTable from "../Table";

const InfoContainer = styled(Grid)(() => ({
height: "100%",
cursor: "auto",
Expand Down Expand Up @@ -189,25 +191,6 @@ class FeatureInfoContainer extends React.PureComponent {
// }
// }

getMarkdownFromLocalInfoBox = (feature, layer, markdown) => {
// Same goes for infobox, I'm shortening the code significantly using the optional chaining.
// Features coming from search result have infobox set on Feature instead of Layer due to
// different features sharing same vector layer.
return (
feature?.infobox ||
feature.layer?.layersInfo?.[layer]?.infobox ||
markdown
);
};

getAGSCompatibleLayer = (feature) => {
return Object.keys(feature.layer.layersInfo).find((id) => {
const fid = feature.getId().split(".")[0];
const layerId = id.split(":").length === 2 ? id.split(":")[1] : id;
return fid === layerId;
});
};

getFeatureProperties = (feature) => {
let properties = feature.getProperties();
properties = this.featurePropsParsing.extractPropertiesFromJson(properties);
Expand All @@ -216,40 +199,18 @@ class FeatureInfoContainer extends React.PureComponent {
};

async updateFeatureInformation(newIndex) {
let feature = this.props.features[newIndex];
const layerInfo = feature.layer.get("layerInfo");

// Get current id of feature and find out if it occurs in the layersInfo array.
// Remove the unique identifier after the last dot, since there can be dots that is part of the ID.
const featureId = feature.getId().split(".").slice(0, -1).join(".");
const layersInfo = layerInfo.layersInfo;
const layerId = Object.keys(layersInfo).find(
(key) => featureId === layersInfo[key].id
);

let markdown = layerInfo?.information,
caption = layerInfo?.caption,
layer,
shortcodes = [];

//Problem with geojson returned from AGS - Missing id on feature - how to handle?
if (feature.layer.layersInfo && feature.getId()) {
layer = this.getAGSCompatibleLayer(feature);
}

// Deal with layer groups that have a caption on sublayer. Layer groups will
// have a 'layersInfo' (NB pluralis on layerSInfo), and if it exists,
// let's overwrite the previously saved caption.
// Below I'm using the new optional chaining operator (
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining),
// which will return the new caption, if exists, or a falsy value. If falsy value is returned,
// just fall back to the previous value of caption.
caption =
feature.layer?.layersInfo?.[layer]?.caption ||
layersInfo[layerId]?.caption ||
caption;
markdown = this.getMarkdownFromLocalInfoBox(feature, layer, markdown);

// Let's display to the user that we're working on something...
this.setState({ loading: true });
// We're gonna need the current feature...
const feature = this.props.features[newIndex];
// ...and the layer that the feature origins from.
const { layer } = feature;
// With the feature and it's layer we can grab information needed to create
// an informative feature-info.
const { displayName: caption, infoclickDefinition: markdown } =
getInfoClickInfoFromLayerConfig(feature, layer);
// TODO: shortCodes, remove?
const shortcodes = [];
// Disabled shortcodes for now as they mess with Markdown tags
// for Links and Imgs that use "[" and "]".
// if (markdown) {
Expand All @@ -260,11 +221,12 @@ class FeatureInfoContainer extends React.PureComponent {
// }
// }

this.setState({ loading: true });

let properties = this.getFeatureProperties(feature);
// When we've grabbed the markdown-definition for the layer, we can create the
// information that we want to display to the user by combining the definition with
// the feature properties.
const properties = this.getFeatureProperties(feature);
const value = await this.getValue(markdown, properties, caption);

// Finally, we'll update the state, and highlight the feature in the map.
this.setState(
{
value: value,
Expand Down
119 changes: 9 additions & 110 deletions new-client/src/models/MapClickModel.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { hfetch } from "utils/FetchWrapper";

import GeoJSON from "ol/format/GeoJSON";
import WMSGetFeatureInfo from "ol/format/WMSGetFeatureInfo";
import TileLayer from "ol/layer/Tile";
Expand All @@ -9,7 +7,10 @@ import VectorSource from "ol/source/Vector";
import { Style, Icon, Fill, Stroke, Circle } from "ol/style";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import AppModel from "../models/AppModel.js";

import AppModel from "models/AppModel";
import { hfetch } from "utils/FetchWrapper";
import { getInfoClickInfoFromLayerConfig } from "utils/InfoClickHelpers.js";

const convertRGBAtoString = (color) => {
if (
Expand Down Expand Up @@ -126,25 +127,6 @@ export default class MapClickModel {
}
});
}
/**
* @summary Determine the sublayer's name by looking at the feature's id
* @description We must know which of the queried sublayers a given feature comes
* from and the best way to determine that is by looking at the feature ID (FID).
* It looks like WMS services set the FID using this formula:
* [<workspaceName>:]<layerName>.<numericFeatureId>
* where the part inside "[" and "]" is optional (not used by GeoServer nor QGIS,
* but other WMSes might use it).
* @param {Feature} feature
* @param {Layer} layer
* @return {string} layerName
*/
#getLayerNameFromFeatureAndLayer = (feature, layer) => {
return Object.keys(layer.layersInfo).find((id) => {
const fid = feature.getId().split(".")[0];
const layerId = id.split(":").length === 2 ? id.split(":")[1] : id;
return fid === layerId;
});
};
/**
* @summary Get the name of a layer by taking a look at the first part of a feature's name.
*
Expand Down Expand Up @@ -218,94 +200,17 @@ export default class MapClickModel {

// Next, loop through the features (if we managed to parse any).
for (const feature of olFeatures) {
// First we need the sublayer's name in order to grab
// the relevant caption, infobox definition, etc.
// The only way to get it now is by looking into
// the feature id, because it includes the layer's
// name as a part of the id itself.
let layerName = this.#getLayerNameFromFeatureAndLayer(
// We're gonna need a lot of information from each layer that each feature
// is coming from. Let's extract all that information
const infoClickInformation = getInfoClickInfoFromLayerConfig(
feature,
response.value.layer
);

// Special case that can happen occur for WMS raster responses,
// see also #1090.
if (
layerName === undefined &&
feature.getId() === "" &&
response.value.layer.subLayers.length === 1
) {
// Let's assume that the layer's name is the name of the first layer
layerName = response.value.layer.subLayers[0];

// Make sure to set a feature ID - without it we won't be able to
// set/unset selected feature later on (an absolut requirement to
// properly render components that follow such as Pagination, Markdown).
feature.setId("fakeFeatureIdIssue1090");
}

// Having just the layer's name as an ID is not safe - multiple
// WFS's may use the same name for two totally different layers.
// So we need something more. Luckily, we can use the UID property
// of our OL layer.
const layerId =
layerName +
(response.value.layer?.ol_uid &&
"." + response.value.layer?.ol_uid);

// Get layer for this dataset.
const layer = response.value.layer;

// Get the feature's ID and remove the unique identifier after the last dot, since there can be dots that is part of the ID.
// If the featureId is equal to the corresponding layersInfo object entry's ID, then set the id variable.
const featureId = feature.getId().split(".").slice(0, -1).join(".");
const layersInfo = layer.layersInfo;
const id = Object.keys(layersInfo).find(
(key) => featureId === layersInfo[key].id
);

// Get caption for this dataset
// If there are layer groups, we get the display name from the layer's caption.
const displayName =
layersInfo[id]?.caption ||
response.value.layer?.get("caption") ||
"Unnamed dataset";

// Get infoclick definition for this dataset
const infoclickDefinition =
response.value.layer?.layersInfo?.[layerName]?.infobox || "";

// Prepare the infoclick icon string
const infoclickIcon =
response.value.layer?.layersInfo?.[layerName]?.infoclickIcon ||
"";

// Prepare displayFields, shortDisplayFields and secondaryLabelFields.
// We need them to determine what should be displayed
// in the features list view.
const displayFields =
response.value.layer?.layersInfo?.[layerName]?.searchDisplayName
?.split(",")
.map((df) => df.trim()) || [];
const shortDisplayFields =
response.value.layer?.layersInfo?.[
layerName
]?.searchShortDisplayName
?.split(",")
.map((df) => df.trim()) || [];
const secondaryLabelFields =
response.value.layer?.layersInfo?.[
layerName
]?.secondaryLabelFields
?.split(",")
.map((df) => df.trim()) || [];

// Before we create the feature collection, ensure that
// it doesn't exist already.
const existingLayer = getFeatureInfoResults.find(
(f) => f.layerId === layerId
(f) => f.layerId === infoClickInformation.layerId
);

// If it exists…
if (existingLayer) {
// …push the current feature…
Expand All @@ -316,16 +221,10 @@ export default class MapClickModel {
// If this is the first feature from this layer…
// …prepare the return object…
const r = {
layerId: layerId,
type: "GetFeatureInfoResults",
features: [feature],
numHits: 1,
displayName,
infoclickDefinition,
infoclickIcon,
displayFields,
shortDisplayFields,
secondaryLabelFields,
...infoClickInformation,
};
// …and push onto the array.
getFeatureInfoResults.push(r);
Expand Down
118 changes: 118 additions & 0 deletions new-client/src/utils/InfoClickHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @summary Get name of sublayer that the supplied feature is coming from feature's id
* @description We must know which of the queried sublayers a given feature comes
* from and the best way to determine that is by looking at the feature ID (FID).
* It looks like WMS services set the FID using this formula:
* [<workspaceName>:]<layerName>.[<numericFeatureId>].<numericFeatureId>
* where the part inside "[" and "]" is optional (not used by GeoServer nor QGIS,
* but other WMSes might use it).
* The solution below is not pretty, and one might think that we're doing some unnecessary work, but remember
* that the actual layerName might contain "." as well, making the matching problem kind of tedious.
* Expects:
* - (OL feature) parsed from getFeatureInfo-response.
* - (OL layer) that has been clicked.
* Returns:
* - (string) The name of the sub-layer from which the supplied feature comes from
*/
function getSubLayerNameFromFeatureId(feature, layer) {
let subLayerName = Object.keys(layer.layersInfo).find((id) => {
// First, the layerName from config needs some cleaning, since different service providers want different layerNames.
const layerName = id.split(":").length === 2 ? id.split(":")[1] : id;
// Once that is done, we have to check if the featureId contains this layerName.
// To make things more interesting, OL's featureIds can be constructed in some different ways depending on the response from
// the service provider. Below are some examples of how the OL featureId might look:
// layerName.<some-feature-id>
// layerName.<some-feature-id>.<some-feature-id>
// As the examples above suggests, we cannot be sure that we get the actual layerName by only removing the last part of the featureId.
// Instead, we split the featureId on dots (since the layerName and featureId-number(s) will always be separated by dots), and try to
// create the layerName by combining the parts one by one.
// First we'll split the featureId into it's parts...
const fidArray = feature?.getId()?.split?.(".") || [];
// ...then we'll loop over the parts...
for (let i = fidArray.length - 1; i >= 0; i--) {
// ...and create a layerName that can be matched against the layerName from config!
if (fidArray.slice(0, i).join(".") === layerName) {
// If the constructed string matches the layerName from config, we've found our layer!
return true;
}
}
// The layerName from the feature could not be matched against the layerId from config...
return false;
});

if (
subLayerName === undefined &&
feature.getId() === "" &&
layer.subLayers.length === 1
) {
// Let's assume that the layer's name is the name of the first layer
subLayerName = layer.subLayers[0];
// Make sure to set a feature ID - without it we won't be able to
// set/unset selected feature later on (an absolut requirement to
// properly render components that follow such as Pagination, Markdown).
feature.setId("fakeFeatureIdIssue1090");
}

return subLayerName;
}

/**
* @summary Get information needed to properly render the FeatureInfo-window
* @description Will get the sub-layer from which the supplied feature comes from and
* extract all necessary information needed to properly render the FeatureInfo-window.
* Expects:
* - OL feature parsed from getFeatureInfo-response.
* - OL layer that has been clicked.
* Returns:
* - An object containing information needed to properly render the FeatureInfo-window. All information is parsed from the layer config.
*/
export function getInfoClickInfoFromLayerConfig(feature, layer) {
if (!feature || !layer || !layer.layersInfo) {
console.error(
`getInfoClickInfoFromLayerConfig was called with bad parameters. Got feature: ${feature}, and layer: ${layer}`
);
return {};
}
const subLayerName = getSubLayerNameFromFeatureId(feature, layer);
// Having just the layer's name as an ID is not safe - multiple
// WFS's may use the same name for two totally different layers.
// So we need something more. Luckily, we can use the UID property
// of our OL layer.
const layerId = subLayerName + (layer?.ol_uid && "." + layer?.ol_uid);
// Get caption for this dataset
// If there are layer groups, we get the display name from the layer's caption.
const displayName =
layer?.layersInfo[subLayerName]?.caption ||
layer?.get("caption") ||
"Unnamed dataset";
// Get infoclick definition for this dataset
const infoclickDefinition = layer?.layersInfo?.[subLayerName]?.infobox || "";
// Prepare the infoclick icon string
const infoclickIcon = layer?.layersInfo?.[subLayerName]?.infoclickIcon || "";
// Prepare displayFields, shortDisplayFields and secondaryLabelFields.
// We need them to determine what should be displayed
// in the features list view.
const displayFields =
layer?.layersInfo?.[subLayerName]?.searchDisplayName
?.split(",")
.map((df) => df.trim()) || [];
const shortDisplayFields =
layer?.layersInfo?.[subLayerName]?.searchShortDisplayName
?.split(",")
.map((df) => df.trim()) || [];
const secondaryLabelFields =
layer?.layersInfo?.[subLayerName]?.secondaryLabelFields
?.split(",")
.map((df) => df.trim()) || [];

return {
subLayerName,
layerId,
displayName,
infoclickDefinition,
infoclickIcon,
displayFields,
shortDisplayFields,
secondaryLabelFields,
};
}