in nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/status-history/status-history-chart/status-history-chart.component.ts [117:605]
private updateChart(selectedDescriptor: FieldDescriptor) {
const margin = {
top: 15,
right: 20,
bottom: 25,
left: 80
};
// -------------
// prep the data
// -------------
// available colors
const color = d3.scaleOrdinal(d3.schemeCategory10);
// determine the available instances
const instanceLabels = this.instances.map((instance) => instance.label);
// specify the domain based on the detected instances
color.domain(instanceLabels);
// data for the chart
const statusData = this.instances.map((instance) => {
// convert the model
return {
id: instance.id,
label: instance.label,
values: instance.snapshots.map((snapshot) => {
return {
timestamp: snapshot.timestamp,
value: snapshot.statusMetrics[selectedDescriptor.field]
};
}),
visible: this._visibleInstances[instance.id]
};
});
const customTimeFormat = (d: any) => {
if (d.getMilliseconds()) {
return d3.timeFormat(':%S.%L')(d);
} else if (d.getSeconds()) {
return d3.timeFormat(':%S')(d);
} else if (d.getMinutes() || d.getHours()) {
return d3.timeFormat('%H:%M')(d);
} else if (d.getDay() && d.getDate() !== 1) {
return d3.timeFormat('%a %d')(d);
} else if (d.getDate() !== 1) {
return d3.timeFormat('%b %d')(d);
} else if (d.getMonth()) {
return d3.timeFormat('%B')(d);
} else {
return d3.timeFormat('%Y')(d);
}
};
// ----------
// main chart
// ----------
// the container for the main chart
const chartContainer = document.getElementById('status-history-chart-container')!;
// clear out the dom for the chart
chartContainer.replaceChildren();
// determine the new width/height
const width = chartContainer.clientWidth - margin.left - margin.right;
const height = chartContainer.clientHeight - margin.top - margin.bottom;
// define the x axis for the main chart
const x = d3.scaleTime().range([0, width]);
const xAxis: any = d3.axisBottom(x).ticks(5).tickFormat(customTimeFormat);
// define the y axis
const y = d3.scaleLinear().range([height, 0]);
const yAxis: any = d3.axisLeft(y).tickFormat(this.formatters[selectedDescriptor.formatter]);
// status line
const line = d3
.line()
.curve(d3.curveMonotoneX)
.x((d: any) => x(d.timestamp))
.y((d: any) => y(d.value));
// build the chart svg
const chartSvg = d3
.select('#status-history-chart-container')
.append('svg')
.attr('style', 'pointer-events: none;')
.attr('width', chartContainer.clientWidth)
.attr('height', chartContainer.clientHeight);
// define a clip the path
const clipPath = chartSvg
.append('defs')
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', width)
.attr('height', height);
// build the chart
const chart = chartSvg.append('g').attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')');
// determine the min/max date
const minDate = d3.min(statusData, (d) => {
return d3.min(d.values, (s) => {
return s.timestamp;
});
});
const maxDate = d3.max(statusData, (d) => {
return d3.max(d.values, (s) => {
return s.timestamp;
});
});
// determine the x axis range
x.domain([minDate, maxDate]);
// determine the y axis range
y.domain([this.getMinValue(statusData), this.getMaxValue(statusData)]);
// build the x axis
chart
.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0, ' + height + ')')
.call(xAxis);
// build the y axis
chart
.append('g')
.attr('class', 'y axis')
.call(yAxis)
.append('text')
.attr('transform', 'rotate(-90)')
.attr('y', 6)
.attr('dy', '.71em')
.attr('text-anchor', 'end')
.text(selectedDescriptor.label);
// build the chart
const status = chart
.selectAll('.status')
.data(statusData)
.enter()
.append('g')
.attr('clip-path', 'url(#clip)')
.attr('class', 'status');
status
.append('path')
.attr('class', (d: any) => {
return 'chart-line chart-line-' + d.id;
})
.attr('d', (d: any) => {
return line(d.values);
})
.attr('stroke', (d: any) => {
return color(d.label);
})
.classed('hidden', (d: any) => {
return d.visible === false;
})
.append('title')
.text((d: any) => {
return d.label;
});
// draw the control points for each line
status.each((d, index, nodes) => {
// create a group for the control points
const markGroup = d3
.select(nodes[index])
.append('g')
.attr('class', function () {
return 'mark-group mark-group-' + d.id;
})
.classed('hidden', function (d: any) {
return d.visible === false;
});
// draw the control points
markGroup
.selectAll('circle.mark')
.data(d.values)
.enter()
.append('circle')
.attr('style', 'pointer-events: all;')
.attr('class', 'mark')
.attr('cx', (v) => {
return x(v.timestamp);
})
.attr('cy', (v) => {
return y(v.value);
})
.attr('fill', () => {
return color(d.label);
})
.attr('r', 1.5)
.append('title')
.text((v) => {
return d.label + ' -- ' + this.formatters[selectedDescriptor.formatter](v.value);
});
});
// update the size of the chart
const parentElement = chartContainer.parentElement;
if (parentElement) {
chartSvg.attr('width', parentElement.clientWidth).attr('height', chartContainer.clientHeight);
}
// update the size of the clipper
clipPath.attr('width', width).attr('height', height);
// update the position of the x axis
chart.select('.x.axis').attr('transform', 'translate(0, ' + height + ')');
// -------------
// control chart
// -------------
// the container for the main chart control
const chartControlContainer = document.getElementById('status-history-chart-control-container')!;
chartControlContainer.replaceChildren();
const controlHeight = chartControlContainer.clientHeight - margin.top - margin.bottom;
const xControl = d3.scaleTime().range([0, width]);
const xControlAxis = d3.axisBottom(xControl).ticks(5).tickFormat(customTimeFormat);
const yControl = d3.scaleLinear().range([controlHeight, 0]);
const yControlAxis = d3
.axisLeft(yControl)
.tickValues(y.domain())
.tickFormat(this.formatters[selectedDescriptor.formatter]);
// status line
const controlLine = d3
.line()
.curve(d3.curveMonotoneX)
.x(function (d: any) {
return xControl(d.timestamp);
})
.y(function (d: any) {
return yControl(d.value);
});
// build the svg
const controlChartSvg = d3
.select('#status-history-chart-control-container')
.append('svg')
.attr('width', chartContainer.clientWidth)
.attr('height', chartControlContainer.clientHeight);
// build the control chart
const control = controlChartSvg
.append('g')
.attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')');
// define the domain for the control chart
xControl.domain(x.domain());
yControl.domain(y.domain());
// build the control x axis
control
.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0, ' + controlHeight + ')')
.call(xControlAxis);
// build the control y axis
control.append('g').attr('class', 'y axis').call(yControlAxis);
// build the control chart
const controlStatus = control.selectAll('.status').data(statusData).enter().append('g').attr('class', 'status');
// draw the lines
controlStatus
.append('path')
.attr('class', (d: any) => {
return 'chart-line chart-line-' + d.id;
})
.attr('d', (d: any) => {
return controlLine(d.values);
})
.attr('stroke', (d: any) => {
return color(d.label);
})
.classed('hidden', (d: any) => {
return this.visibleInstances[d.id] === false;
})
.append('title')
.text(function (d) {
return d.label;
});
const updateAggregateStatistics = () => {
const xDomain = x.domain();
const yDomain = y.domain();
// locate the instances that have data points within the current brush
const withinBrush = statusData.map((d: any) => {
// update to only include values within the brush
const values = d.values.filter((s: any) => {
return (
s.timestamp >= xDomain[0].getTime() &&
s.timestamp <= xDomain[1] &&
s.value >= yDomain[0] &&
s.value <= yDomain[1]
);
});
return {
...d,
values
};
});
// consider visible nodes with data in the brush
const nodes: any[] = withinBrush.filter((d: any) => {
return d.id !== NIFI_NODE_CONFIG.nifiInstanceId && d.visible && d.values.length > 0;
});
const nodeMinValue =
nodes.length === 0 ? 'NA' : this.formatters[selectedDescriptor.formatter](this.getMinValue(nodes));
const nodeMeanValue =
nodes.length === 0 ? 'NA' : this.formatters[selectedDescriptor.formatter](this.getMeanValue(nodes));
const nodeMaxValue =
nodes.length === 0 ? 'NA' : this.formatters[selectedDescriptor.formatter](this.getMaxValue(nodes));
// update the currently displayed min/max/mean
this.nodeStats$.next({
min: nodeMinValue,
max: nodeMaxValue,
mean: nodeMeanValue,
nodes: nodes.map((n) => ({
id: n.id,
label: n.label,
color: color(n.label)
}))
});
// only consider the cluster with data in the brush
const cluster = withinBrush.filter((d) => {
return d.id === NIFI_NODE_CONFIG.nifiInstanceId && d.visible && d.values.length > 0;
});
// determine the cluster values
const clusterMinValue =
cluster.length === 0 ? 'NA' : this.formatters[selectedDescriptor.formatter](this.getMinValue(cluster));
const clusterMeanValue =
cluster.length === 0 ? 'NA' : this.formatters[selectedDescriptor.formatter](this.getMeanValue(cluster));
const clusterMaxValue =
cluster.length === 0 ? 'NA' : this.formatters[selectedDescriptor.formatter](this.getMaxValue(cluster));
// update the cluster min/max/mean
this.clusterStats$.next({
min: clusterMinValue,
max: clusterMaxValue,
mean: clusterMeanValue,
nodes: cluster.map((n) => ({
id: n.id,
label: n.label,
color: color(n.label)
}))
});
};
// -------------------
// configure the brush
// -------------------
/**
* Updates the axis for the main chart.
*
* @param {array} xDomain The new domain for the x axis
* @param {array} yDomain The new domain for the y axis
*/
const updateAxes = (xDomain: any[], yDomain: any[]) => {
x.domain(xDomain);
y.domain(yDomain);
// update the chart lines
status.selectAll('.chart-line').attr('d', (d: any) => {
return line(d.values);
});
status
.selectAll('circle.mark')
.attr('cx', (v: any) => {
return x(v.timestamp);
})
.attr('cy', (v: any) => {
return y(v.value);
})
.attr('r', function () {
return d3.brushSelection(brushNode.node()) === null ? 1.5 : 4;
});
// update the x axis
chart.select('.x.axis').call(xAxis);
chart.select('.y.axis').call(yAxis);
};
/**
* Handles brush events by updating the main chart according to the context window
* or the control domain if there is no context window.
*/
const brushed = () => {
this.brushSelection = d3.brushSelection(brushNode.node());
let xContextDomain: any[];
let yContextDomain: any[];
// determine the new x and y domains
if (this.brushSelection === null) {
// get the all visible instances
const visibleInstances: any[] = statusData.filter((d: any) => d.visible);
if (visibleInstances.length === 0) {
yContextDomain = yControl.domain();
} else {
yContextDomain = [
d3.min(visibleInstances, (d) => {
return d3.min(d.values, (s: any) => {
return s.value;
});
}),
d3.max(visibleInstances, (d) => {
return d3.max(d.values, (s: any) => {
return s.value;
});
})
];
}
xContextDomain = xControl.domain();
} else {
xContextDomain = [this.brushSelection[0][0], this.brushSelection[1][0]].map(xControl.invert, xControl);
yContextDomain = [this.brushSelection[1][1], this.brushSelection[0][1]].map(yControl.invert, yControl);
}
// update the axes accordingly
updateAxes(xContextDomain, yContextDomain);
// update the aggregate statistics according to the new domain
updateAggregateStatistics();
};
// build the brush
let brush: any = d3
.brush()
.extent([
[xControl.range()[0], yControl.range()[1]],
[xControl.range()[1], yControl.range()[0]]
])
.on('brush', brushed);
// context area
const brushNode: any = control.append('g').attr('class', 'brush').on('click', brushed).call(brush);
if (this.brushSelection) {
brush = brush.move(brushNode, this.brushSelection);
}
// add expansion to the extent
control
.select('rect.selection')
.attr('style', 'pointer-events: all;')
.on('dblclick', () => {
if (this.brushSelection !== null) {
// get the y range (this value does not change from the original y domain)
const yRange = yControl.range();
// expand the extent vertically
brush.move(brushNode, [
[this.brushSelection[0][0], yRange[1]],
[this.brushSelection[1][0], yRange[0]]
]);
}
});
// identify all nodes and sort
this.nodes = statusData
.filter((status) => {
return status.id !== NIFI_NODE_CONFIG.nifiInstanceId;
})
.sort((a: any, b: any) => {
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
});
brushed();
}