diff --git a/src/readers/insightProcessor.ts b/src/readers/insightProcessor.ts index 1fb06370d65960efea417fc87629bc9b2158a058..e4a39de7eee13871e09451025051189a9618d269 100644 --- a/src/readers/insightProcessor.ts +++ b/src/readers/insightProcessor.ts @@ -147,6 +147,23 @@ export const insightProcessor = async (frontendPublisher: RabbitMqBroker) => { html = await populateTemplate(html, result, visualizations); + if (html == 'No valid Template inputs') { + if (ENV == 'develop') { + const message: WsMessageBackend2Frontend = { + type: wsReturnKey.error, + callID: headers.callID, + status: 'success', + value: 'Html no valid', + }; + + frontendPublisher.publishMessageToFrontend(message, headers.routingKey, headers); + } + + log.error('Html is not valid'); + + return; + } + for (const recipient of insight.recipients) { if (mail == null) { log.warn('Mail is not configured. Insight processor will be disabled'); diff --git a/src/tests/insights/populateTemplate.test.ts b/src/tests/insights/populateTemplate.test.ts index ce8e70b01e58981bfc110d9f46eb6872d1f37ead..40bc5052def6de0605bebda607783cfbe2b1ac3a 100644 --- a/src/tests/insights/populateTemplate.test.ts +++ b/src/tests/insights/populateTemplate.test.ts @@ -51,4 +51,20 @@ describe('populateTemplate', () => { expect(result).toBe(expectedOutput); }); + + it('should replace visualization variables correctly', async () => { + const template = 'The mean value is {{ statistic:NodeTypeA • age • average }}.'; + const expectedOutput = 'The mean value is 42 .'; + const result = await populateTemplate(template, mockResult, []); + + expect(result).toBe(expectedOutput); + }); + + it('should handle wrong input parameters', async () => { + const template = ''; + const expectedOutput = 'Html no valid'; + const result = await populateTemplate(template, mockResult, []); + + expect(result).toBe(expectedOutput); + }); }); diff --git a/src/utils/insights.ts b/src/utils/insights.ts index 621474ed2a91624d3f4f6535288631a56fb405d7..1194307799f02104b79250c67f80a23d5dc27a2d 100644 --- a/src/utils/insights.ts +++ b/src/utils/insights.ts @@ -1,10 +1,10 @@ -import { scaleOrdinal, scaleQuantize } from "d3"; -import { JSDOM } from "jsdom"; -import svg2img from "svg2img"; -import "canvas"; -import { visualizationColors, type GraphQueryResultFromBackend, type GraphQueryResultMetaFromBackend } from "ts-common"; +import { scaleOrdinal, scaleQuantize } from 'd3'; +import { JSDOM } from 'jsdom'; +import svg2img from 'svg2img'; +import 'canvas'; +import { visualizationColors, type GraphQueryResultFromBackend, type GraphQueryResultMetaFromBackend } from 'ts-common'; -import { type PlotType } from "plotly.js"; +import { type PlotType } from 'plotly.js'; const dom = new JSDOM(); @@ -20,41 +20,41 @@ dom.window.HTMLCanvasElement.prototype.getContext = function () { return null; }; dom.window.URL.createObjectURL = function () { - return ""; + return ''; }; export enum VariableType { - statistic = "statistic", - visualization = "visualization", + statistic = 'statistic', + visualization = 'visualization', } async function replaceAllAsync(string: string, regexp: RegExp, replacerFunction: CallableFunction) { - const replacements = await Promise.all(Array.from(string.matchAll(regexp), (match) => replacerFunction(...match))); + const replacements = await Promise.all(Array.from(string.matchAll(regexp), match => replacerFunction(...match))); let i = 0; return string.replace(regexp, () => replacements[i++]); } export async function populateTemplate(html: string, result: GraphQueryResultMetaFromBackend, openVisualizationArray: any[]) { - const regex = / *?{\{ *?(\w*?):([\w ]*?) *?\}\} *?/gm; + const regex = / *?{\{ *?(\w*?):([\w • ]*?) *?\}\} *?/gm; return replaceAllAsync(html, regex, async (_: string, _type: string, name: string) => { const type = VariableType[_type as keyof typeof VariableType]; switch (type) { case VariableType.statistic: { - const [nodeType, feature, statistic] = name.split(" "); + const [nodeType, feature, statistic] = name.split(' '); const node = result.metaData.nodes.types[nodeType]; const attribute = node?.attributes[feature].statistics as any; - if (attribute == null) return ""; + if (attribute == null) return ''; const value = attribute[statistic]; return ` ${value} `; } case VariableType.visualization: { - const activeVisualization = openVisualizationArray.find((x) => x.name == name); // TODO: enforce type + const activeVisualization = openVisualizationArray.find(x => x.name == name); // TODO: enforce type if (!activeVisualization) { - throw new Error("Tried to render non-existing visualization"); + throw new Error('Tried to render non-existing visualization'); } const xAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.xAxisLabel!); let yAxisData: (string | number)[] = []; @@ -72,9 +72,9 @@ export async function populateTemplate(html: string, result: GraphQueryResultMet const stack = activeVisualization.stack; const showAxis = true; - const xAxisLabel = ""; - const yAxisLabel = ""; - const zAxisLabel = ""; + const xAxisLabel = ''; + const yAxisLabel = ''; + const zAxisLabel = ''; const plotType = activeVisualization.plotType; @@ -88,7 +88,7 @@ export async function populateTemplate(html: string, result: GraphQueryResultMet zAxisLabel, showAxis, groupBy, - stack + stack, ); const layout2 = { @@ -98,16 +98,18 @@ export async function populateTemplate(html: string, result: GraphQueryResultMet title: activeVisualization.title, }; - const { newPlot } = await import("plotly.js"); - const plot = await newPlot(dom.window.document.createElement("div"), plotData, layout2); - const svgString = plot.querySelector("svg")?.outerHTML; + const { newPlot } = await import('plotly.js'); + const plot = await newPlot(dom.window.document.createElement('div'), plotData, layout2); + const svgString = plot.querySelector('svg')?.outerHTML; if (!svgString) { - return ""; + return ''; } const dataURI = await svgToBase64(svgString); return `<img src="data:image/png;base64,${dataURI}" width="300" height="200" alt="${activeVisualization.title}" />`; } + default: + return 'No valid Template inputs'; } }); } @@ -116,19 +118,19 @@ const svgToBase64 = (svgString: string) => { return new Promise((resolve, reject) => { svg2img(svgString, (error: any, buffer: Buffer) => { if (error != null) reject(error); - resolve(buffer.toString("base64")); + resolve(buffer.toString('base64')); }); }); }; -export const plotTypeOptions = ["bar", "scatter", "line", "histogram", "pie"] as const; +export const plotTypeOptions = ['bar', 'scatter', 'line', 'histogram', 'pie'] as const; export type SupportedPlotType = (typeof plotTypeOptions)[number]; const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableData?: (string | number)[]) => { // Function to parse the date-time string into a JavaScript Date object const parseDate = (dateStr: string) => { // Remove nanoseconds part and use just the standard "YYYY-MM-DD HH:MM:SS" part - const cleanedDateStr = dateStr.split(".")[0]; + const cleanedDateStr = dateStr.split('.')[0]; return new Date(cleanedDateStr); }; @@ -137,15 +139,15 @@ const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableDat const date = parseDate(dateStr); let groupKey: string; - if (groupBy === "yearly") { + if (groupBy === 'yearly') { groupKey = date.getFullYear().toString(); // Group by year (e.g., "2012") - } else if (groupBy === "quarterly") { + } else if (groupBy === 'quarterly') { const month = date.getMonth() + 1; // Adjust month for zero-indexed months const quarter = Math.floor((month - 1) / 3) + 1; // Calculate quarter (Q1-Q4) groupKey = `${date.getFullYear()}-Q${quarter}`; - } else if (groupBy === "monthly") { + } else if (groupBy === 'monthly') { // Group by month, e.g., "2012-07" - groupKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}`; + groupKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`; } else { // Default case: group by year (or some other grouping logic) groupKey = date.getFullYear().toString(); @@ -154,7 +156,7 @@ const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableDat // Initialize the group if it doesn't exist if (!acc[groupKey]) { acc[groupKey] = additionalVariableData - ? typeof additionalVariableData[0] === "number" + ? typeof additionalVariableData[0] === 'number' ? 0 // Initialize sum for numbers : [] // Initialize array for strings : 0; // Initialize count for no additional data @@ -162,9 +164,9 @@ const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableDat // Aggregate additional variable if provided if (additionalVariableData) { - if (typeof additionalVariableData[index] === "number") { + if (typeof additionalVariableData[index] === 'number') { acc[groupKey] = (acc[groupKey] as number) + (additionalVariableData[index] as number); - } else if (typeof additionalVariableData[index] === "string") { + } else if (typeof additionalVariableData[index] === 'string') { acc[groupKey] = [...(acc[groupKey] as string[]), additionalVariableData[index] as string]; } } else { @@ -183,7 +185,7 @@ const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableDat }; const computeStringTickValues = (xValues: any[], maxTicks: number, maxLabelLength: number): any[] => { - const truncatedValues = xValues.map((label) => (label.length > maxLabelLength ? `${label.slice(0, maxLabelLength)}…` : label)); + const truncatedValues = xValues.map(label => (label.length > maxLabelLength ? `${label.slice(0, maxLabelLength)}…` : label)); return truncatedValues; }; @@ -198,17 +200,17 @@ export const preparePlotData = ( zAxisLabel?: string, showAxis = true, groupBy?: string, - stack?: boolean + stack?: boolean, ): { plotData: Partial<Plotly.PlotData>[]; layout: Partial<Plotly.Layout> } => { - const primaryColor = "#a2aab9"; // '--clr-sec--400' + const primaryColor = '#a2aab9'; // '--clr-sec--400' const lengthLabelsX = 7; // !TODO computed number of elements based const lengthLabelsY = 8; // !TODO computed number of elements based const mainColors = visualizationColors.GPCat.colors[14]; const sharedTickFont = { - family: "monospace", + family: 'monospace', size: 12, - color: "#374151", // !TODO get GP value + color: '#374151', // !TODO get GP value }; let xValues: (string | number)[] = []; @@ -218,19 +220,19 @@ export const preparePlotData = ( let colorDataZ: string[] = []; let colorbar: any = {}; - if (zAxisData && zAxisData.length > 0 && typeof zAxisData[0] === "number") { + if (zAxisData && zAxisData.length > 0 && typeof zAxisData[0] === 'number') { const mainColorsSeq = visualizationColors.GPSeq.colors[9]; - const numericZAxisData = zAxisData.filter((item): item is number => typeof item === "number"); + const numericZAxisData = zAxisData.filter((item): item is number => typeof item === 'number'); const zMin = numericZAxisData.reduce((min, val) => (val < min ? val : min), zAxisData[0]); const zMax = numericZAxisData.reduce((max, val) => (val > max ? val : max), zAxisData[0]); // !TODO: option to have a linear or quantize scale colorScale = scaleQuantize<string>().domain([zMin, zMax]).range(mainColorsSeq); - colorDataZ = zAxisData?.map((item) => colorScale(item) || primaryColor); + colorDataZ = zAxisData?.map(item => colorScale(item) || primaryColor); colorbar = { - title: "Color Legend", + title: 'Color Legend', tickvals: [zMin, zMax], ticktext: [`${zMin}`, `${zMax}`], }; @@ -240,13 +242,13 @@ export const preparePlotData = ( if (zAxisData && uniqueZAxisData) { colorScale = scaleOrdinal<string>().domain(uniqueZAxisData.map(String)).range(mainColors); - colorDataZ = zAxisData?.map((item) => colorScale(String(item)) || primaryColor); + colorDataZ = zAxisData?.map(item => colorScale(String(item)) || primaryColor); const sortedDomain = uniqueZAxisData.sort(); colorbar = { - title: "Color Legend", + title: 'Color Legend', tickvals: sortedDomain, - ticktext: sortedDomain.map((val) => String(val)), - tickmode: "array", + ticktext: sortedDomain.map(val => String(val)), + tickmode: 'array', }; } } @@ -286,17 +288,17 @@ export const preparePlotData = ( let truncatedYLabels: string[] = []; let yAxisRange: number[] = []; - if (typeof xValues[0] === "string") { + if (typeof xValues[0] === 'string') { truncatedXLabels = computeStringTickValues(xValues, 2, lengthLabelsX); } - if (typeof yValues[0] === "string" && (plotType === "scatter" || plotType === "line")) { + if (typeof yValues[0] === 'string' && (plotType === 'scatter' || plotType === 'line')) { truncatedYLabels = computeStringTickValues(yValues, 2, lengthLabelsY); } const plotData = (() => { switch (plotType) { - case "bar": - if (typeof xAxisData[0] === "string" && groupBy == undefined) { + case 'bar': + if (typeof xAxisData[0] === 'string' && groupBy == undefined) { const frequencyMap = xAxisData.reduce((acc, item) => { acc[item] = (acc[item] || 0) + 1; return acc; @@ -315,36 +317,36 @@ export const preparePlotData = ( return [ { - type: "bar" as PlotType, + type: 'bar' as PlotType, x: xValues, y: yValues, marker: { color: colorDataZ?.length != 0 ? colorDataZ : primaryColor, }, customdata: sortedLabels, - hovertemplate: "<b>%{customdata}</b>: %{y}<extra></extra>", + hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>', }, ]; } else { return [ { - type: "bar" as PlotType, + type: 'bar' as PlotType, x: xValues, y: yValues, marker: { color: primaryColor }, customdata: xValues, - hovertemplate: "<b>%{customdata}</b>: %{y}<extra></extra>", + hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>', }, ]; } - case "scatter": + case 'scatter': return [ { - type: "scatter" as PlotType, + type: 'scatter' as PlotType, x: xValues, y: yValues, - mode: "markers" as const, + mode: 'markers' as const, marker: { color: zAxisData && zAxisData.length > 0 ? colorDataZ : primaryColor, size: 7, @@ -352,30 +354,30 @@ export const preparePlotData = ( }, customdata: xValues.length === 0 - ? yValues.map((y) => `Y: ${y}`) + ? yValues.map(y => `Y: ${y}`) : yValues.length === 0 - ? xValues.map((x) => `X: ${x}`) + ? xValues.map(x => `X: ${x}`) : xValues.map((x, index) => { const zValue = zAxisData && zAxisData.length > 0 ? zAxisData[index] : null; return zValue ? `X: ${x} | Y: ${yValues[index]} | Color: ${zValue}` : `X: ${x} | Y: ${yValues[index]}`; }), - hovertemplate: "<b>%{customdata}</b><extra></extra>", + hovertemplate: '<b>%{customdata}</b><extra></extra>', }, ]; - case "line": + case 'line': return [ { - type: "scatter" as PlotType, + type: 'scatter' as PlotType, x: xValues, y: yValues, - mode: "lines" as const, + mode: 'lines' as const, line: { color: primaryColor }, - customdata: xValues.map((label) => (label === "undefined" || label === "null" || label === "" ? "nonData" : "")), - hovertemplate: "<b>%{customdata}</b><extra></extra>", + customdata: xValues.map(label => (label === 'undefined' || label === 'null' || label === '' ? 'nonData' : '')), + hovertemplate: '<b>%{customdata}</b><extra></extra>', }, ]; - case "histogram": - if (typeof xAxisData[0] === "string") { + case 'histogram': + if (typeof xAxisData[0] === 'string') { if (zAxisData && zAxisData?.length > 0) { const frequencyMap = xAxisData.reduce( (acc, item, index) => { @@ -394,7 +396,7 @@ export const preparePlotData = ( acc[item].colors.push(color); acc[item].zValues.push(zAxisData[index].toString()); // Group and count zValues - const zValue = zAxisData[index] || "(Empty)"; + const zValue = zAxisData[index] || '(Empty)'; acc[item].zValueCounts[zValue] = (acc[item].zValueCounts[zValue] || 0) + 1; return acc; @@ -407,7 +409,7 @@ export const preparePlotData = ( zValues: string[]; zValueCounts: Record<string, number>; // To store grouped counts } - > + >, ); const colorToLegendName = new Map(); const sortedCategories = Object.entries(frequencyMap).sort((a, b) => b[1].count - a[1].count); @@ -430,13 +432,13 @@ export const preparePlotData = ( }); }); - sortedLabels = sortedCategories.map((element) => element[0]); + sortedLabels = sortedCategories.map(element => element[0]); const traces = Array.from(colorToLegendName.entries()).map(([color, legendName]) => { const colorData = tracesByColor[color]; const categoryCountMap: Record<string, number> = {}; - sortedLabels.forEach((label) => { + sortedLabels.forEach(label => { categoryCountMap[label] = frequencyMap[label].count; }); const yValues = colorData.x.map((label, idx) => { @@ -446,20 +448,20 @@ export const preparePlotData = ( }); const customdata = colorData.x.map((label, idx) => { - const colorTranslation = colorToLegendName.get(color) === " " ? "(Empty)" : colorToLegendName.get(color); + const colorTranslation = colorToLegendName.get(color) === ' ' ? '(Empty)' : colorToLegendName.get(color); const percentage = ((100 * frequencyMap[label].zValueCounts[colorTranslation]) / frequencyMap[label].count).toFixed(1); - return [label, !stack ? frequencyMap[label]?.zValueCounts[colorTranslation] || 0 : percentage, colorTranslation || " "]; + return [label, !stack ? frequencyMap[label]?.zValueCounts[colorTranslation] || 0 : percentage, colorTranslation || ' ']; }); return { x: colorData.x, y: yValues, - type: "bar" as PlotType, + type: 'bar' as PlotType, name: legendName, marker: { color: color }, customdata: customdata, hovertemplate: - "<b>X: %{customdata[0]}</b><br>" + "<b>Y: %{customdata[1]}</b><br>" + "<b>Color: %{customdata[2]}</b><extra></extra>", - ...(stack ? { stackgroup: "one" } : {}), + '<b>X: %{customdata[0]}</b><br>' + '<b>Y: %{customdata[1]}</b><br>' + '<b>Color: %{customdata[2]}</b><extra></extra>', + ...(stack ? { stackgroup: 'one' } : {}), }; }); @@ -477,19 +479,19 @@ export const preparePlotData = ( return [ { - type: "bar" as PlotType, + type: 'bar' as PlotType, x: sortedLabels, y: sortedFrequencies, marker: { color: primaryColor }, customdata: sortedLabels, - hovertemplate: "<b>%{customdata}</b>: %{y}<extra></extra>", + hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>', }, ]; } } else { if (zAxisData && zAxisData?.length > 0) { const binCount = 20; // Number of bins (you can make this configurable) - const numericXAxisData = xAxisData.map((val) => Number(val)).filter((val) => !isNaN(val)); + const numericXAxisData = xAxisData.map(val => Number(val)).filter(val => !isNaN(val)); const xMin = numericXAxisData.reduce((min, val) => Math.min(min, val), Infinity); const xMax = numericXAxisData.reduce((max, val) => Math.max(max, val), -Infinity); @@ -505,7 +507,7 @@ export const preparePlotData = ( // Assign data points to bins numericXAxisData.forEach((xValue, index) => { - const zValue = zAxisData ? zAxisData[index] || "(Empty)" : "(Empty)"; + const zValue = zAxisData ? zAxisData[index] || '(Empty)' : '(Empty)'; const binIndex = Math.floor((xValue - xMin) / binSize); const bin = bins[Math.min(binIndex, bins.length - 1)]; // Ensure the last value falls into the final bin @@ -539,23 +541,23 @@ export const preparePlotData = ( const colorData = tracesByColor[color]; const customdata = colorData.x.map((binLabel, idx) => { const countForColor = colorData.y[idx]; - const percentage = stack ? countForColor.toFixed(1) + "%" : countForColor.toFixed(0); + const percentage = stack ? countForColor.toFixed(1) + '%' : countForColor.toFixed(0); return [binLabel, countForColor, percentage, legendName]; }); return { x: colorData.x, y: colorData.y, - type: "bar" as PlotType, + type: 'bar' as PlotType, name: legendName, marker: { color }, customdata, autobinx: true, hovertemplate: - "<b>Bin: %{customdata[0]}</b><br>" + - "<b>Count/Percentage: %{customdata[2]}</b><br>" + - "<b>Group: %{customdata[3]}</b><extra></extra>", - ...(stack ? { stackgroup: "one" } : {}), + '<b>Bin: %{customdata[0]}</b><br>' + + '<b>Count/Percentage: %{customdata[2]}</b><br>' + + '<b>Group: %{customdata[3]}</b><extra></extra>', + ...(stack ? { stackgroup: 'one' } : {}), }; }); @@ -564,7 +566,7 @@ export const preparePlotData = ( // No zAxisData, simple histogram logic return [ { - type: "histogram" as PlotType, + type: 'histogram' as PlotType, x: xAxisData, marker: { color: primaryColor }, customdata: xAxisData, @@ -572,10 +574,10 @@ export const preparePlotData = ( ]; } } - case "pie": + case 'pie': return [ { - type: "pie" as PlotType, + type: 'pie' as PlotType, labels: xValues.map(String), values: xAxisData, marker: { colors: mainColors }, @@ -587,22 +589,22 @@ export const preparePlotData = ( })(); const layout: Partial<Plotly.Layout> = { - barmode: "stack", + barmode: 'stack', xaxis: { title: { - text: showAxis ? (xAxisLabel ? xAxisLabel : "") : "", + text: showAxis ? (xAxisLabel ? xAxisLabel : '') : '', standoff: 30, }, tickfont: sharedTickFont, showgrid: false, visible: showAxis, - ...(typeof xAxisData[0] === "string" || (plotType === "histogram" && sortedLabels.length > 0) - ? { type: "category", categoryarray: sortedLabels, categoryorder: "array" } + ...(typeof xAxisData[0] === 'string' || (plotType === 'histogram' && sortedLabels.length > 0) + ? { type: 'category', categoryarray: sortedLabels, categoryorder: 'array' } : {}), showline: true, zeroline: false, - tickvals: typeof xValues[0] == "string" ? xValues : undefined, - ticktext: typeof xValues[0] == "string" ? truncatedXLabels : undefined, + tickvals: typeof xValues[0] == 'string' ? xValues : undefined, + ticktext: typeof xValues[0] == 'string' ? truncatedXLabels : undefined, }, yaxis: { @@ -612,24 +614,24 @@ export const preparePlotData = ( zeroline: false, tickfont: sharedTickFont, title: { - text: showAxis ? (yAxisLabel ? yAxisLabel : "") : "", + text: showAxis ? (yAxisLabel ? yAxisLabel : '') : '', standoff: 30, }, - tickvals: typeof yValues[0] === "string" && (plotType === "scatter" || plotType === "line") ? yValues : undefined, - ticktext: typeof yValues[0] === "string" && (plotType === "scatter" || plotType === "line") ? truncatedYLabels : undefined, + tickvals: typeof yValues[0] === 'string' && (plotType === 'scatter' || plotType === 'line') ? yValues : undefined, + ticktext: typeof yValues[0] === 'string' && (plotType === 'scatter' || plotType === 'line') ? truncatedYLabels : undefined, }, font: { - family: "Inter", + family: 'Inter', size: 12, - color: "#374151", + color: '#374151', }, hoverlabel: { - bgcolor: "rgba(255, 255, 255, 0.8)", - bordercolor: "rgba(0, 0, 0, 0.2)", + bgcolor: 'rgba(255, 255, 255, 0.8)', + bordercolor: 'rgba(0, 0, 0, 0.2)', font: { - family: "monospace", + family: 'monospace', size: 14, - color: "#374151", + color: '#374151', }, }, }; @@ -639,21 +641,21 @@ export const preparePlotData = ( export const getAttributeValues = ( query: GraphQueryResultFromBackend, selectedEntity: string, - attributeKey: string | number | undefined + attributeKey: string | number | undefined, ): any[] => { if (!selectedEntity || !attributeKey) { return []; } - if (attributeKey == " ") { + if (attributeKey == ' ') { return []; } return query.nodes - .filter((item) => item.label === selectedEntity) - .map((item) => { + .filter(item => item.label === selectedEntity) + .map(item => { // Check if the attribute exists, return its value if it does, or an empty string otherwise - return item.attributes && attributeKey in item.attributes && item.attributes[attributeKey] != "" + return item.attributes && attributeKey in item.attributes && item.attributes[attributeKey] != '' ? item.attributes[attributeKey] - : "NoData"; + : 'NoData'; }); };