From eba9d28dba3c90a0a3d4634af2db6ecb998999ee Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 27 Nov 2019 17:25:36 +0100 Subject: [PATCH 01/11] feat: step 3 (events) --- .../line/reusable-line-chart/index.stories.js | 507 ++--- .../reusable-line-chart.js | 1669 +++++++++-------- 2 files changed, 1117 insertions(+), 1059 deletions(-) diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index 627416a..67d105d 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -4,290 +4,315 @@ import {reusableLineChart} from './reusable-line-chart'; export default {title: 'Reusable Line Chart'}; -const firstData = [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3500000, - "status": "Site Handed - Over to Contractor" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": null - } -]; - -const secondData = [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": null, - "total_spent_in_quarter": null, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": null, - "total_spent_in_quarter": null, - "total_estimated_project_cost": 3500000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": "Tender" - }, - { - "date": "2019-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3650000, - "status": "Other - Packaged Ongoing Project" - } -]; - -const thirdData = [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": null, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": null, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": null, - "status": null - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": "Tender" - } -]; - - -const closelyLocatedData = [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 2900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 3640000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 4009000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3500000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": null - } -]; - -const minifiedData = [{ - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 90000, - "total_spent_in_quarter": 35000, - "total_estimated_project_cost": 320000, - "status": "Feasibility" -}, { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 166900, - "total_spent_in_quarter": 68900, - "total_estimated_project_cost": 365000, - "status": "Feasibility" -}, { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 166900, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 350000, - "status": "Tender" -}, { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 276900, - "total_spent_in_quarter": 110000, - "total_estimated_project_cost": 365000, - "status": null -}]; +const firstData = { + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3500000, + "status": "Site Handed - Over to Contractor" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": null + } + ], + events: [ + { + "date": "2018-09-20", + "label": "Project Start Date" + }, + { + "date": "2019-03-15", + "label": "Estimated construction end date" + } + ] +}; + +const secondData = { + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": null, + "total_spent_in_quarter": null, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": null, + "total_spent_in_quarter": null, + "total_estimated_project_cost": 3500000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": "Tender" + }, + { + "date": "2019-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3650000, + "status": "Other - Packaged Ongoing Project" + } + ] +}; + +const thirdData = { + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": null, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": null, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": null, + "status": null + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": "Tender" + } + ] +}; + + +const closelyLocatedData = { + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 2900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 3640000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 4009000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3500000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": null + }] +}; + +const minifiedData = { + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 90000, + "total_spent_in_quarter": 35000, + "total_estimated_project_cost": 320000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 166900, + "total_spent_in_quarter": 68900, + "total_estimated_project_cost": 365000, + "status": "Feasibility" + }, + { + + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 166900, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 350000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 276900, + "total_spent_in_quarter": 110000, + "total_estimated_project_cost": 365000, + "status": null + } + ] +}; export const MockupData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(firstData)); + select(container) + .call(myChart.data(firstData)); - return container; + return container; }; export const MissingSpentData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(secondData)); + select(container) + .call(myChart.data(secondData)); - return container; + return container; }; export const MissingCostData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(thirdData)); + select(container) + .call(myChart.data(thirdData)); - return container; + return container; }; export const SinglePointData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data([firstData[0]])); + select(container) + .call(myChart.data([firstData[0]])); - return container; + return container; }; export const HundredThousandsData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(minifiedData)); + select(container) + .call(myChart.data(minifiedData)); - return container; + return container; }; export const SmallWidthHeight = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart().width(320).height(450); + const myChart = reusableLineChart().width(320).height(450); - select(container) - .call(myChart.data(firstData)); + select(container) + .call(myChart.data(firstData)); - return container; + return container; }; export const CloselyLocatedPointsData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(closelyLocatedData)); + select(container) + .call(myChart.data(closelyLocatedData)); - return container; + return container; }; diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index 7a22970..7227076 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -9,831 +9,864 @@ import {format} from 'd3-format'; import {line} from 'd3-shape'; import d3Tip from "d3-tip"; -const margin = {top: 50, right: 50, bottom: 80, left: 60}; +const margin = {top: 50, right: 50, bottom: 180, left: 60}; const SYMBOL_WIDTH = 7.5; -function processRawData(data) { - return data.map(d => { - return Object.assign(d, { - date: new Date(d.date) - }) - }) +function transformStringDatesToObjects(data) { + return data.map(d => { + return Object.assign(d, { + date: new Date(d.date) + }) + }) } export function reusableLineChart() { - let initialConfiguration = { - width: 850, - height: 450, - spentCircleTooltipFormatter: (d) => { - return `
Total spent:  ${d.data.total_spent_to_date ? "R" + format(",d")(d.data.total_spent_to_date) : 0}
+ let initialConfiguration = { + width: 850, + height: 550, + spentCircleTooltipFormatter: (d) => { + return `
Total spent:  ${d.data.total_spent_to_date ? "R" + format(",d")(d.data.total_spent_to_date) : 0}
Spent in quarter:  ${d.data.total_spent_in_quarter ? "R" + format(",d")(d.data.total_spent_in_quarter) : 0}
`; - }, - totalCostCircleTooltipFormatter: (d) => { - return `
Total project cost: ${d.data.total_estimated_project_cost ? "R" + format('.3s')(d.data.total_estimated_project_cost) : 0}
`; - }, - statusLabelTooltipFormatter: (d) => { - return `
${d.label}
`; - } - }; - - let width = initialConfiguration.width, - height = initialConfiguration.height, - data = [], - spentCircleTooltipFormatter = initialConfiguration.spentCircleTooltipFormatter, - totalCostCircleTooltipFormatter = initialConfiguration.totalCostCircleTooltipFormatter, - statusLabelTooltipFormatter = initialConfiguration.statusLabelTooltipFormatter; - let updateData = null; - let correspondingSpentLineCircle, correspondingTotalCostCircle = null; - - function chart(selection) { - selection.each(function () { - let xDomainValues = getXDomainValues(data); - let minimalXDomainValue = min(xDomainValues); - let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); - let yDomainValues = getYDomainValues(data); - const xScaleLength = width - margin.right - margin.left; - const yScaleLength = height - margin.bottom - margin.top; - - const xScale = scaleTime() - .domain([newMinXDomainValue, max(xDomainValues)]) - .range([margin.left, width - margin.right]); - - const yScale = scaleLinear() - .domain([0, max(yDomainValues)]) - .range([height - margin.bottom, margin.top]) - .nice(); - - const svg = selection.append("svg") - .attr("width", width) - .attr("height", height) - .append("g"); - - const spentCircleTooltip = d3Tip() - .attr("class", "d3-tip") - .direction(function (d) { - return d.direction; - }) - .offset(function (d) { - return d.direction === 'n' ? [-8, 0] : [8, 0]; - }) - .html(spentCircleTooltipFormatter); - - const totalCostCircleTooltip = d3Tip() - .attr("class", "d3-tip") - .direction(function (d) { - return d.direction; - }) - .offset(function (d) { - return d.direction === 'n' ? [-8, 0] : [8, 0]; - }) - .html(totalCostCircleTooltipFormatter); - - const statusLabelTooltip = d3Tip() - .attr("class", "d3-tip") - .offset([-8, 0]) - .html(statusLabelTooltipFormatter); - - svg.call(spentCircleTooltip); - svg.call(totalCostCircleTooltip); - svg.call(statusLabelTooltip); - - const backgroundRectanglesGroup = svg.append("g") - .attr("class", "background-rectangles"); - - backgroundRectanglesGroup.selectAll("rect") - .data(data) - .enter() - .append("rect") - .attr("class", "background-rectangle") - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength) - .attr("fill", 'none') - .on("mouseover", function (d) { - showTooltip(d); - }) - .on("mouseout", function () { - hideTooltip(); - }); - - const statusLineElementsGroup = svg.append("g") - .attr("class", "status-line-elements"); - - statusLineElementsGroup.selectAll("path") - .data(getStatusLineData(data)) - .enter() - .append("path") - .attr("class", "status-line-path") - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null) - .style("fill", "none"); - - statusLineElementsGroup.selectAll('text') - .data(getStatusLabelsData(data)) - .enter() - .append("text") - .attr("class", "status-line-label") - .attr("fill", "black") - .attr("text-anchor", "middle") - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)) - .on("mouseover", function (d) { - if (isStatusLabelTruncated(d)) { - statusLabelTooltip.show(d, this); - } - }) - .on("mouseout", function () { - statusLabelTooltip.hide(); - }); - - const spentLineElementsGroup = svg.append("g") - .attr("class", "spent-line-elements"); - - spentLineElementsGroup.selectAll("path") - .data(getTotalSpentLineData(data)) - .enter() - .append("path") - .attr("class", "spent-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("fill", "none"); - - spentLineElementsGroup.selectAll("circle") - .data(getTotalSpentCircleData(data)) - .enter() - .append("circle") - .attr("class", "spent-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)) - .attr("r", 5) - .attr("fill", '#333333') - .on("mouseover", function (d) { - spentCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - spentCircleTooltip.hide(); - }); - - const totalCostElementsGroup = svg.append("g") - .attr("class", "total-cost-line-elements"); - - totalCostElementsGroup.selectAll("path") - .data(getTotalСostLineData(data)) - .enter() - .append("path") - .attr("class", "total-cost-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", "4 5") - .style("stroke-miterlimit", 16) - .style("fill", "none"); - - totalCostElementsGroup.selectAll("circle") - .data(getTotalСostCircleData(data)) - .enter() - .append("circle") - .attr("class", "total-cost-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) - .attr("r", 5) - .attr("fill", 'none') - .on("mouseover", function (d) { - totalCostCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - totalCostCircleTooltip.hide(); - }); - - const xAxis = axisBottom(xScale) - .tickValues(xDomainValues) - .tickFormat('') - .tickSize(7) - .tickSizeOuter(0); - - const gXAxis = svg.append("g") - .attr("class", "x axis") - .attr("transform", `translate(0,${(height - margin.bottom)})`) - .call(xAxis); - applyAxisStyle(gXAxis); - - gXAxis.selectAll('.axis-tick-label') - .data(data) - .enter() - .append("text") - .attr("class", "axis-tick-label") - .attr("fill", "black") - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .text(d => d.quarter_label); - - gXAxis.selectAll('.axis-tick-year-label') - .data(data) - .enter() - .append("text") - .attr("class", "axis-tick-year-label") - .attr("fill", "#bcbcbc") - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .text(d => d.financial_year_label); - - const yAxisGrid = axisLeft(yScale) - .ticks(4) - .tickFormat('') - .tickSize(-width + margin.left + margin.right); - - const yAxis = axisLeft(yScale) - .ticks(4) - .tickFormat(d => d !== 0 ? `R${format('~s')(d)}` : '') - .tickSizeInner(5) - .tickSizeOuter(0); - - const gYAxisGrid = svg.append("g") - .attr("class", "grid") - .attr("transform", `translate(${margin.left},0)`) - .call(yAxisGrid); - - const gYAxis = svg.append("g") - .attr("class", "y axis") - .attr("transform", `translate(${margin.left},0)`) - .call(yAxis); - - applyGridStyle(gYAxisGrid); - applyAxisStyle(gYAxis, true); - - const legend = svg - .selectAll("legend") - .data([ - {label: 'actual quaterly spend', 'stroke-dasharray': '4 5'}, - {label: 'total estimated project cost'} - ]) - .enter() - .append('g') - .attr("class", "legend") - .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - 30})`); - - legend.call(appendLegendItem); - - - function appendLegendItem(selection) { - selection.append('rect') - .attr("x", 0) - .attr("y", 0) - .attr("width", 20) - .attr("height", 20); - - selection.append('line') - .attr('x1', 0) - .attr('y1', 10) - .attr('x2', 20) - .attr('y2', 10) - .attr('stroke-dasharray', d => d['stroke-dasharray'] ? d['stroke-dasharray'] : null); - - selection.append('text') - .attr("x", 25) - .attr("y", 15) - .text(d => d.label) - .style("text-anchor", "start"); - - } - - function getXDomainValues(data) { - if (data && data.length > 0) { - return data.map(group => new Date(group.date)).sort((a, b) => a - b); - } else { - return [new Date(), new Date()] - } - } - - function getYDomainValues(data) { - return data.map(group => [group.total_spent_to_date, group.total_estimated_project_cost]).flat() - .sort((a, b) => a - b); - } - - function getTotalSpentLineData(data) { - const result = []; - if (data && data.length > 0) { - for (let i = 0; i < data.length - 1; i++) { - if (data[i].total_spent_to_date && data[i + 1].total_spent_to_date) { - result.push([data[i], data[i + 1]]); - } - } - return result; - } else { - return []; - } - } - - function getStatusLineData(data) { - let result = []; - if (data && data.length > 0) { - const allStatuses = data.filter(d => !!d.status).map(d => d.status); - const uniqueStatuses = []; - allStatuses.forEach(status => { - if (uniqueStatuses.indexOf(status) === -1) { - uniqueStatuses.push(status); - } - }); - uniqueStatuses.forEach(status => { - const currentStatusPoints = data.filter(d => d.status === status); - result.push([ - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - dotted: true, - start: true - }, - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - dotted: true, - end: true - } - ]); - result.push([ - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - start: true - }, - { - date: currentStatusPoints[currentStatusPoints.length - 1].date, - }, - { - date: currentStatusPoints[currentStatusPoints.length - 1].date, - end: true - } - ]); - }); - return result; - } else { - return []; - } - } - - function getStatusLabelsData(data) { - let result = []; - if (data && data.length > 0) { - const allStatuses = data.filter(d => !!d.status).map(d => d.status); - const uniqueStatuses = []; - allStatuses.forEach(status => { - if (uniqueStatuses.indexOf(status) === -1) { - uniqueStatuses.push(status); - } - }); - uniqueStatuses.forEach(status => { - const currentStatusPoints = data.filter(d => d.status === status); - result.push({ - startDate: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - endDate: currentStatusPoints[currentStatusPoints.length - 1].date, - label: status - }); - }); - return result; - } else { - return []; - } - } - - function getStatusLabelText(d) { - const placeholderLength = xScale(d.endDate) - xScale(d.startDate) - 20; - return placeholderLength > d.label.length * SYMBOL_WIDTH - ? d.label - : `${d.label.slice(0, placeholderLength / SYMBOL_WIDTH)}...` - } - - function isStatusLabelTruncated(d) { - return xScale(d.endDate) - xScale(d.startDate) - 20 < d.label.length * SYMBOL_WIDTH; - } - - function getTotalSpentCircleData(data) { - return data.filter(d => d.total_spent_to_date !== null); - } - - function getTotalСostCircleData(data) { - return data.filter(d => d.total_estimated_project_cost !== null); - } - - function getTotalСostLineData(data) { - const result = []; - if (data && data.length > 0) { - const firstNonNullPointIndex = data.findIndex(d => d.total_estimated_project_cost !== null); - const firstPoint = { - date: xScale.domain()[0], - total_estimated_project_cost: data[firstNonNullPointIndex].total_estimated_project_cost - }; - result.push([firstPoint, data[firstNonNullPointIndex]]); - for (let i = firstNonNullPointIndex; i < data.length - 1; i++) { - if (data[i + 1].total_estimated_project_cost !== null) { - if (data[i + 1].total_estimated_project_cost !== data[i].total_estimated_project_cost) { - const middlePoint = { - date: data[i].date, - total_estimated_project_cost: data[i + 1].total_estimated_project_cost - }; - result.push([data[i], middlePoint]); - result.push([middlePoint, data[i + 1]]); - } else { - result.push([data[i], data[i + 1]]); - } - } - } - return result; - } else { - return []; - } - } - - function applyAxisStyle(gAxis, yAxis) { - gAxis.selectAll('line') - .style('fill', 'none') - .style('stroke-width', '1') - .style('stroke', yAxis ? 'black' : 'rgba(0, 0, 0, 0.1)') - .style('shape-rendering', 'crispEdges'); - - gAxis.select('path') - .style('fill', 'none') - .style('stroke', 'black') - .style('stroke-width', '1') - .style('shape-rendering', 'crispEdges'); - if (yAxis) { - select(gYAxis.selectAll(".tick").nodes()[0]).attr("visibility", "hidden"); - } - } - - function applyGridStyle(gAxis) { - gAxis.selectAll('line') - .style('fill', 'none') - .style('stroke-width', '1') - .style('stroke', 'rgba(0, 0, 0, 0.1)') - .style('shape-rendering', 'crispEdges'); - - gAxis.select('path') - .style('fill', 'none') - .style('color', 'transparent'); - } - - function showTooltip(d) { - correspondingSpentLineCircle = spentLineElementsGroup.selectAll('circle') - .filter(function (circleData) { - return d.date === circleData.date; - }).nodes()[0]; - correspondingTotalCostCircle = totalCostElementsGroup.selectAll('circle') - .filter(function (circleData) { - return d.date === circleData.date; - }).nodes()[0]; - - let totalCostCircleDirection = 'n'; - let spentLineCircleDirection = 'n'; - if (correspondingSpentLineCircle && correspondingTotalCostCircle) { - const spentLineCircleCY = parseFloat(select(correspondingSpentLineCircle).attr('cy')); - const totalCostCircle = parseFloat(select(correspondingTotalCostCircle).attr('cy')); - if (Math.abs(spentLineCircleCY - totalCostCircle) < 50) { - if (spentLineCircleCY < totalCostCircle) { - totalCostCircleDirection = 's'; - } else { - spentLineCircleDirection = 's'; - } - } - } - - if (correspondingSpentLineCircle) { - select(correspondingSpentLineCircle).attr('fill', 'rgb(0, 137, 123)'); - spentCircleTooltip.show({ - data: d, - direction: spentLineCircleDirection - }, correspondingSpentLineCircle); - } - if (correspondingTotalCostCircle) { - select(correspondingTotalCostCircle).attr('fill', 'rgb(0, 137, 123)'); - totalCostCircleTooltip.show({ - data: d, - direction: totalCostCircleDirection - }, correspondingTotalCostCircle); - } - } - - function hideTooltip() { - if (correspondingSpentLineCircle) { - select(correspondingSpentLineCircle).attr('fill', '#333333'); - spentCircleTooltip.hide(); - } - if (correspondingTotalCostCircle) { - totalCostCircleTooltip.hide(); - select(correspondingTotalCostCircle).attr("fill", 'none'); - } - correspondingSpentLineCircle = null; - correspondingTotalCostCircle = null; - } - - updateData = function () { - xDomainValues = getXDomainValues(data); - let minimalXDomainValue = min(xDomainValues); - let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); - - xScale.domain([newMinXDomainValue, max(xDomainValues)]); - xAxis.scale(xScale).tickValues(xDomainValues); - - yDomainValues = getYDomainValues(data); - yScale.domain([0, max(yDomainValues)]).nice(); - yAxis.scale(yScale); - yAxisGrid.scale(yScale); - - const t = transition() - .duration(750); - - gXAxis.transition(t) - .call(xAxis); - - gYAxis.transition(t) - .call(yAxis); - - gYAxisGrid.transition(t) - .call(yAxisGrid); - - applyAxisStyle(gXAxis); - applyAxisStyle(gYAxis, true); - applyGridStyle(gYAxisGrid); - - const updatedSpentLineCircles = spentLineElementsGroup.selectAll('circle').data(getTotalSpentCircleData(data)); - const updatedTotalCostCircles = totalCostElementsGroup.selectAll('circle').data(getTotalСostCircleData(data)); - const updatedAxisLabels = gXAxis.selectAll('.axis-tick-label').data(data); - const updatedAxisYearLabels = gXAxis.selectAll('.axis-tick-year-label').data(data); - const updatedSpentLine = spentLineElementsGroup.selectAll(".spent-line-path").data(getTotalSpentLineData(data)); - const updatedTotalCostLine = totalCostElementsGroup.selectAll(".total-cost-line-path").data(getTotalСostLineData(data)); - const updatedStatusLine = statusLineElementsGroup.selectAll(".status-line-path").data(getStatusLineData(data)); - const updatedStatusLabels = statusLineElementsGroup.selectAll(".status-line-label").data(getStatusLabelsData(data)); - const updatedBackgroundRectangles = backgroundRectanglesGroup.selectAll(".background-rectangle").data(data); - - updatedSpentLine - .enter() - .append("path") - .attr("class", "spent-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("fill", "none"); - - updatedSpentLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ); - - updatedSpentLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedTotalCostLine - .enter() - .append("path") - .attr("class", "total-cost-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", "4 5") - .style("stroke-miterlimit", 16) - .style("fill", "none"); - - updatedTotalCostLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ); - - updatedTotalCostLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedStatusLine - .enter() - .append("path") - .attr("class", "status-line-path") - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null) - .style("fill", "none"); - - updatedStatusLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null); - - updatedStatusLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedStatusLabels - .enter() - .append("text") - .attr("class", "status-line-label") - .attr("fill", "black") - .attr("text-anchor", "middle") - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)) - .on("mouseover", function (d) { - if (isStatusLabelTruncated(d)) { - statusLabelTooltip.show(d, this); - } - }) - .on("mouseout", function () { - statusLabelTooltip.hide(); - }); - - updatedStatusLabels - .transition() - .duration(1000) - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)); - - updatedStatusLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedSpentLineCircles.enter() - .append("circle") - .attr("class", "spent-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)) - .attr("r", 5) - .attr("fill", '#333333') - .on("mouseover", function (d) { - spentCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - spentCircleTooltip.hide(); - }); - - updatedSpentLineCircles - .transition() - .ease(easeLinear) - .duration(750) - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)); - - updatedSpentLineCircles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedTotalCostCircles.enter() - .append("circle") - .attr("class", "total-cost-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) - .attr("r", 5) - .attr("fill", 'none') - .on("mouseover", function (d) { - totalCostCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - totalCostCircleTooltip.hide(); - }); - - updatedTotalCostCircles - .transition() - .ease(easeLinear) - .duration(750) - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)); - - updatedTotalCostCircles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedAxisLabels.enter() - .append("text") - .attr("class", "axis-tick-label") - .attr("fill", "black") - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .text(d => d.quarter_label); - - updatedAxisLabels - .transition() - .ease(easeLinear) - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .duration(750) - .text(d => d.quarter_label); - - updatedAxisLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedAxisYearLabels.enter() - .append("text") - .attr("class", "axis-tick-year-label") - .attr("fill", "#bcbcbc") - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .text(d => d.financial_year_label); - - updatedAxisYearLabels - .transition() - .ease(easeLinear) - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .duration(750) - .text(d => d.financial_year_label); - - updatedAxisYearLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedBackgroundRectangles - .enter() - .append("rect") - .attr("class", "background-rectangle") - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength) - .attr("fill", 'none') - .on("mouseover", function (d) { - showTooltip(d); - }) - .on("mouseout", function () { - hideTooltip(); - }); - - updatedBackgroundRectangles - .transition() - .ease(easeLinear) - .duration(750) - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength); - - updatedBackgroundRectangles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - }; - - }) - } - - chart.width = function (value) { - if (!arguments.length) return width; - width = value; - return chart; - }; - - chart.height = function (value) { - if (!arguments.length) return height; - height = value; - return chart; - }; - - chart.data = function (value) { - if (!arguments.length) return data; - data = processRawData(value); - if (typeof updateData === 'function') updateData(); - return chart; - }; - - return chart; + }, + totalCostCircleTooltipFormatter: (d) => { + return `
Total project cost: ${d.data.total_estimated_project_cost ? "R" + format('.3s')(d.data.total_estimated_project_cost) : 0}
`; + }, + statusLabelTooltipFormatter: (d) => { + return `
${d.label}
`; + }, + eventTooltipFormatter: (d) => { + return `
${d.label}
`; + } + }; + + let width = initialConfiguration.width, + height = initialConfiguration.height, + data = [], + events = [], + spentCircleTooltipFormatter = initialConfiguration.spentCircleTooltipFormatter, + totalCostCircleTooltipFormatter = initialConfiguration.totalCostCircleTooltipFormatter, + statusLabelTooltipFormatter = initialConfiguration.statusLabelTooltipFormatter, + eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; + let updateData = null; + let correspondingSpentLineCircle, correspondingTotalCostCircle = null; + + function chart(selection) { + selection.each(function () { + let xDomainValues = getXDomainValues(data); + let minimalXDomainValue = min(xDomainValues); + let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); + let yDomainValues = getYDomainValues(data); + const xScaleLength = width - margin.right - margin.left; + const yScaleLength = height - margin.bottom - margin.top; + + const xScale = scaleTime() + .domain([newMinXDomainValue, max(xDomainValues)]) + .range([margin.left, width - margin.right]); + + const yScale = scaleLinear() + .domain([0, max(yDomainValues)]) + .range([height - margin.bottom, margin.top]) + .nice(); + + const svg = selection.append("svg") + .attr("width", width) + .attr("height", height) + .append("g"); + + const spentCircleTooltip = d3Tip() + .attr("class", "d3-tip") + .direction(function (d) { + return d.direction; + }) + .offset(function (d) { + return d.direction === 'n' ? [-8, 0] : [8, 0]; + }) + .html(spentCircleTooltipFormatter); + + const totalCostCircleTooltip = d3Tip() + .attr("class", "d3-tip") + .direction(function (d) { + return d.direction; + }) + .offset(function (d) { + return d.direction === 'n' ? [-8, 0] : [8, 0]; + }) + .html(totalCostCircleTooltipFormatter); + + const statusLabelTooltip = d3Tip() + .attr("class", "d3-tip") + .offset([-8, 0]) + .html(statusLabelTooltipFormatter); + + const eventTooltip = d3Tip() + .attr("class", "d3-tip") + .offset([-8, 0]) + .html(eventTooltipFormatter); + + svg.call(spentCircleTooltip); + svg.call(totalCostCircleTooltip); + svg.call(statusLabelTooltip); + + const backgroundRectanglesGroup = svg.append("g") + .attr("class", "background-rectangles"); + + backgroundRectanglesGroup.selectAll("rect") + .data(data) + .enter() + .append("rect") + .attr("class", "background-rectangle") + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength) + .attr("fill", 'none') + .on("mouseover", function (d) { + showTooltip(d); + }) + .on("mouseout", function () { + hideTooltip(); + }); + + const statusLineElementsGroup = svg.append("g") + .attr("class", "status-line-elements"); + + statusLineElementsGroup.selectAll("path") + .data(getStatusLineData(data)) + .enter() + .append("path") + .attr("class", "status-line-path") + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null) + .style("fill", "none"); + + statusLineElementsGroup.selectAll('text') + .data(getStatusLabelsData(data)) + .enter() + .append("text") + .attr("class", "status-line-label") + .attr("fill", "black") + .attr("text-anchor", "middle") + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)) + .on("mouseover", function (d) { + if (isStatusLabelTruncated(d)) { + statusLabelTooltip.show(d, this); + } + }) + .on("mouseout", function () { + statusLabelTooltip.hide(); + }); + + const spentLineElementsGroup = svg.append("g") + .attr("class", "spent-line-elements"); + + spentLineElementsGroup.selectAll("path") + .data(getTotalSpentLineData(data)) + .enter() + .append("path") + .attr("class", "spent-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("fill", "none"); + + spentLineElementsGroup.selectAll("circle") + .data(getTotalSpentCircleData(data)) + .enter() + .append("circle") + .attr("class", "spent-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)) + .attr("r", 5) + .attr("fill", '#333333') + .on("mouseover", function (d) { + spentCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + spentCircleTooltip.hide(); + }); + + const totalCostElementsGroup = svg.append("g") + .attr("class", "total-cost-line-elements"); + + totalCostElementsGroup.selectAll("path") + .data(getTotalСostLineData(data)) + .enter() + .append("path") + .attr("class", "total-cost-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", "4 5") + .style("stroke-miterlimit", 16) + .style("fill", "none"); + + totalCostElementsGroup.selectAll("circle") + .data(getTotalСostCircleData(data)) + .enter() + .append("circle") + .attr("class", "total-cost-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) + .attr("r", 5) + .attr("fill", 'none') + .on("mouseover", function (d) { + totalCostCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + totalCostCircleTooltip.hide(); + }); + + const eventsElementsGroup = svg.append("g") + .attr("class", "events-elements") + .attr("transform", `translate(0,${(height - margin.bottom + 100)})`); + + eventsElementsGroup + .selectAll("rect") + .data(events) + .enter() + .append("rect") + .attr('class', 'event-rect') + .attr("x", d => xScale(d.date)) + .attr("y", 0) + .attr("rx", 5) + .attr("ry", 5) + .attr("width", 100) + .attr("height", d => 20) + .attr("fill", (d, i) => 'rgb(51, 51, 51)') + .on("mouseover", function (d) { + }) + .on("mouseout", function () { + }); + + const xAxis = axisBottom(xScale) + .tickValues(xDomainValues) + .tickFormat('') + .tickSize(7) + .tickSizeOuter(0); + + const gXAxis = svg.append("g") + .attr("class", "x axis") + .attr("transform", `translate(0,${(height - margin.bottom)})`) + .call(xAxis); + applyAxisStyle(gXAxis); + + gXAxis.selectAll('.axis-tick-label') + .data(data) + .enter() + .append("text") + .attr("class", "axis-tick-label") + .attr("fill", "black") + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .text(d => d.quarter_label); + + gXAxis.selectAll('.axis-tick-year-label') + .data(data) + .enter() + .append("text") + .attr("class", "axis-tick-year-label") + .attr("fill", "#bcbcbc") + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .text(d => d.financial_year_label); + + const yAxisGrid = axisLeft(yScale) + .ticks(4) + .tickFormat('') + .tickSize(-width + margin.left + margin.right); + + const yAxis = axisLeft(yScale) + .ticks(4) + .tickFormat(d => d !== 0 ? `R${format('~s')(d)}` : '') + .tickSizeInner(5) + .tickSizeOuter(0); + + const gYAxisGrid = svg.append("g") + .attr("class", "grid") + .attr("transform", `translate(${margin.left},0)`) + .call(yAxisGrid); + + const gYAxis = svg.append("g") + .attr("class", "y axis") + .attr("transform", `translate(${margin.left},0)`) + .call(yAxis); + + applyGridStyle(gYAxisGrid); + applyAxisStyle(gYAxis, true); + + const legend = svg + .selectAll("legend") + .data([ + {label: 'actual quaterly spend', 'stroke-dasharray': '4 5'}, + {label: 'total estimated project cost'} + ]) + .enter() + .append('g') + .attr("class", "legend") + .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - margin.bottom + 50})`); + + legend.call(appendLegendItem); + + + function appendLegendItem(selection) { + selection.append('rect') + .attr("x", 0) + .attr("y", 0) + .attr("width", 20) + .attr("height", 20); + + selection.append('line') + .attr('x1', 0) + .attr('y1', 10) + .attr('x2', 20) + .attr('y2', 10) + .attr('stroke-dasharray', d => d['stroke-dasharray'] ? d['stroke-dasharray'] : null); + + selection.append('text') + .attr("x", 25) + .attr("y", 15) + .text(d => d.label) + .style("text-anchor", "start"); + + } + + function getXDomainValues(data) { + if (data && data.length > 0) { + return data.map(group => new Date(group.date)).sort((a, b) => a - b); + } else { + return [new Date(), new Date()] + } + } + + function getYDomainValues(data) { + return data.map(group => [group.total_spent_to_date, group.total_estimated_project_cost]).flat() + .sort((a, b) => a - b); + } + + function getTotalSpentLineData(data) { + const result = []; + if (data && data.length > 0) { + for (let i = 0; i < data.length - 1; i++) { + if (data[i].total_spent_to_date && data[i + 1].total_spent_to_date) { + result.push([data[i], data[i + 1]]); + } + } + return result; + } else { + return []; + } + } + + function getStatusLineData(data) { + let result = []; + if (data && data.length > 0) { + const allStatuses = data.filter(d => !!d.status).map(d => d.status); + const uniqueStatuses = []; + allStatuses.forEach(status => { + if (uniqueStatuses.indexOf(status) === -1) { + uniqueStatuses.push(status); + } + }); + uniqueStatuses.forEach(status => { + const currentStatusPoints = data.filter(d => d.status === status); + result.push([ + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + dotted: true, + start: true + }, + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + dotted: true, + end: true + } + ]); + result.push([ + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + start: true + }, + { + date: currentStatusPoints[currentStatusPoints.length - 1].date, + }, + { + date: currentStatusPoints[currentStatusPoints.length - 1].date, + end: true + } + ]); + }); + return result; + } else { + return []; + } + } + + function getStatusLabelsData(data) { + let result = []; + if (data && data.length > 0) { + const allStatuses = data.filter(d => !!d.status).map(d => d.status); + const uniqueStatuses = []; + allStatuses.forEach(status => { + if (uniqueStatuses.indexOf(status) === -1) { + uniqueStatuses.push(status); + } + }); + uniqueStatuses.forEach(status => { + const currentStatusPoints = data.filter(d => d.status === status); + result.push({ + startDate: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + endDate: currentStatusPoints[currentStatusPoints.length - 1].date, + label: status + }); + }); + return result; + } else { + return []; + } + } + + function getStatusLabelText(d) { + const placeholderLength = xScale(d.endDate) - xScale(d.startDate) - 20; + return placeholderLength > d.label.length * SYMBOL_WIDTH + ? d.label + : `${d.label.slice(0, placeholderLength / SYMBOL_WIDTH)}...` + } + + function isStatusLabelTruncated(d) { + return xScale(d.endDate) - xScale(d.startDate) - 20 < d.label.length * SYMBOL_WIDTH; + } + + function getTotalSpentCircleData(data) { + return data.filter(d => d.total_spent_to_date !== null); + } + + function getTotalСostCircleData(data) { + return data.filter(d => d.total_estimated_project_cost !== null); + } + + function getTotalСostLineData(data) { + const result = []; + if (data && data.length > 0) { + const firstNonNullPointIndex = data.findIndex(d => d.total_estimated_project_cost !== null); + const firstPoint = { + date: xScale.domain()[0], + total_estimated_project_cost: data[firstNonNullPointIndex].total_estimated_project_cost + }; + result.push([firstPoint, data[firstNonNullPointIndex]]); + for (let i = firstNonNullPointIndex; i < data.length - 1; i++) { + if (data[i + 1].total_estimated_project_cost !== null) { + if (data[i + 1].total_estimated_project_cost !== data[i].total_estimated_project_cost) { + const middlePoint = { + date: data[i].date, + total_estimated_project_cost: data[i + 1].total_estimated_project_cost + }; + result.push([data[i], middlePoint]); + result.push([middlePoint, data[i + 1]]); + } else { + result.push([data[i], data[i + 1]]); + } + } + } + return result; + } else { + return []; + } + } + + function applyAxisStyle(gAxis, yAxis) { + gAxis.selectAll('line') + .style('fill', 'none') + .style('stroke-width', '1') + .style('stroke', yAxis ? 'black' : 'rgba(0, 0, 0, 0.1)') + .style('shape-rendering', 'crispEdges'); + + gAxis.select('path') + .style('fill', 'none') + .style('stroke', 'black') + .style('stroke-width', '1') + .style('shape-rendering', 'crispEdges'); + if (yAxis) { + select(gYAxis.selectAll(".tick").nodes()[0]).attr("visibility", "hidden"); + } + } + + function applyGridStyle(gAxis) { + gAxis.selectAll('line') + .style('fill', 'none') + .style('stroke-width', '1') + .style('stroke', 'rgba(0, 0, 0, 0.1)') + .style('shape-rendering', 'crispEdges'); + + gAxis.select('path') + .style('fill', 'none') + .style('color', 'transparent'); + } + + function showTooltip(d) { + correspondingSpentLineCircle = spentLineElementsGroup.selectAll('circle') + .filter(function (circleData) { + return d.date === circleData.date; + }).nodes()[0]; + correspondingTotalCostCircle = totalCostElementsGroup.selectAll('circle') + .filter(function (circleData) { + return d.date === circleData.date; + }).nodes()[0]; + + let totalCostCircleDirection = 'n'; + let spentLineCircleDirection = 'n'; + if (correspondingSpentLineCircle && correspondingTotalCostCircle) { + const spentLineCircleCY = parseFloat(select(correspondingSpentLineCircle).attr('cy')); + const totalCostCircle = parseFloat(select(correspondingTotalCostCircle).attr('cy')); + if (Math.abs(spentLineCircleCY - totalCostCircle) < 50) { + if (spentLineCircleCY < totalCostCircle) { + totalCostCircleDirection = 's'; + } else { + spentLineCircleDirection = 's'; + } + } + } + + if (correspondingSpentLineCircle) { + select(correspondingSpentLineCircle).attr('fill', 'rgb(0, 137, 123)'); + spentCircleTooltip.show({ + data: d, + direction: spentLineCircleDirection + }, correspondingSpentLineCircle); + } + if (correspondingTotalCostCircle) { + select(correspondingTotalCostCircle).attr('fill', 'rgb(0, 137, 123)'); + totalCostCircleTooltip.show({ + data: d, + direction: totalCostCircleDirection + }, correspondingTotalCostCircle); + } + } + + function hideTooltip() { + if (correspondingSpentLineCircle) { + select(correspondingSpentLineCircle).attr('fill', '#333333'); + spentCircleTooltip.hide(); + } + if (correspondingTotalCostCircle) { + totalCostCircleTooltip.hide(); + select(correspondingTotalCostCircle).attr("fill", 'none'); + } + correspondingSpentLineCircle = null; + correspondingTotalCostCircle = null; + } + + updateData = function () { + xDomainValues = getXDomainValues(data); + let minimalXDomainValue = min(xDomainValues); + let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); + + xScale.domain([newMinXDomainValue, max(xDomainValues)]); + xAxis.scale(xScale).tickValues(xDomainValues); + + yDomainValues = getYDomainValues(data); + yScale.domain([0, max(yDomainValues)]).nice(); + yAxis.scale(yScale); + yAxisGrid.scale(yScale); + + const t = transition() + .duration(750); + + gXAxis.transition(t) + .call(xAxis); + + gYAxis.transition(t) + .call(yAxis); + + gYAxisGrid.transition(t) + .call(yAxisGrid); + + applyAxisStyle(gXAxis); + applyAxisStyle(gYAxis, true); + applyGridStyle(gYAxisGrid); + + const updatedSpentLineCircles = spentLineElementsGroup.selectAll('circle').data(getTotalSpentCircleData(data)); + const updatedTotalCostCircles = totalCostElementsGroup.selectAll('circle').data(getTotalСostCircleData(data)); + const updatedAxisLabels = gXAxis.selectAll('.axis-tick-label').data(data); + const updatedAxisYearLabels = gXAxis.selectAll('.axis-tick-year-label').data(data); + const updatedSpentLine = spentLineElementsGroup.selectAll(".spent-line-path").data(getTotalSpentLineData(data)); + const updatedTotalCostLine = totalCostElementsGroup.selectAll(".total-cost-line-path").data(getTotalСostLineData(data)); + const updatedStatusLine = statusLineElementsGroup.selectAll(".status-line-path").data(getStatusLineData(data)); + const updatedStatusLabels = statusLineElementsGroup.selectAll(".status-line-label").data(getStatusLabelsData(data)); + const updatedBackgroundRectangles = backgroundRectanglesGroup.selectAll(".background-rectangle").data(data); + + updatedSpentLine + .enter() + .append("path") + .attr("class", "spent-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("fill", "none"); + + updatedSpentLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ); + + updatedSpentLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedTotalCostLine + .enter() + .append("path") + .attr("class", "total-cost-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", "4 5") + .style("stroke-miterlimit", 16) + .style("fill", "none"); + + updatedTotalCostLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ); + + updatedTotalCostLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedStatusLine + .enter() + .append("path") + .attr("class", "status-line-path") + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null) + .style("fill", "none"); + + updatedStatusLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null); + + updatedStatusLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedStatusLabels + .enter() + .append("text") + .attr("class", "status-line-label") + .attr("fill", "black") + .attr("text-anchor", "middle") + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)) + .on("mouseover", function (d) { + if (isStatusLabelTruncated(d)) { + statusLabelTooltip.show(d, this); + } + }) + .on("mouseout", function () { + statusLabelTooltip.hide(); + }); + + updatedStatusLabels + .transition() + .duration(1000) + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)); + + updatedStatusLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedSpentLineCircles.enter() + .append("circle") + .attr("class", "spent-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)) + .attr("r", 5) + .attr("fill", '#333333') + .on("mouseover", function (d) { + spentCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + spentCircleTooltip.hide(); + }); + + updatedSpentLineCircles + .transition() + .ease(easeLinear) + .duration(750) + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)); + + updatedSpentLineCircles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedTotalCostCircles.enter() + .append("circle") + .attr("class", "total-cost-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) + .attr("r", 5) + .attr("fill", 'none') + .on("mouseover", function (d) { + totalCostCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + totalCostCircleTooltip.hide(); + }); + + updatedTotalCostCircles + .transition() + .ease(easeLinear) + .duration(750) + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)); + + updatedTotalCostCircles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedAxisLabels.enter() + .append("text") + .attr("class", "axis-tick-label") + .attr("fill", "black") + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .text(d => d.quarter_label); + + updatedAxisLabels + .transition() + .ease(easeLinear) + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .duration(750) + .text(d => d.quarter_label); + + updatedAxisLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedAxisYearLabels.enter() + .append("text") + .attr("class", "axis-tick-year-label") + .attr("fill", "#bcbcbc") + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .text(d => d.financial_year_label); + + updatedAxisYearLabels + .transition() + .ease(easeLinear) + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .duration(750) + .text(d => d.financial_year_label); + + updatedAxisYearLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedBackgroundRectangles + .enter() + .append("rect") + .attr("class", "background-rectangle") + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength) + .attr("fill", 'none') + .on("mouseover", function (d) { + showTooltip(d); + }) + .on("mouseout", function () { + hideTooltip(); + }); + + updatedBackgroundRectangles + .transition() + .ease(easeLinear) + .duration(750) + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength); + + updatedBackgroundRectangles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + }; + + }) + } + + chart.width = function (value) { + if (!arguments.length) return width; + width = value; + return chart; + }; + + chart.height = function (value) { + if (!arguments.length) return height; + height = value; + return chart; + }; + + chart.data = function (value) { + if (!arguments.length) return data; + data = value && value.snapshots ? transformStringDatesToObjects(value.snapshots) : []; + events = value && value.events ? transformStringDatesToObjects(value.events) : []; + if (typeof updateData === 'function') updateData(); + return chart; + }; + + return chart; } From 5f5139ec0968ae23675dea6a6f86939baae352a3 Mon Sep 17 00:00:00 2001 From: Andrey Zolotin Date: Wed, 27 Nov 2019 22:34:26 +0100 Subject: [PATCH 02/11] feat: events icons --- .storybook/preview-head.html | 1 + .../line/reusable-line-chart/index.stories.js | 514 ++--- .../reusable-line-chart.js | 1724 +++++++++-------- .../reusable-line-chart/stories.styles.css | 6 + 4 files changed, 1145 insertions(+), 1100 deletions(-) create mode 100644 .storybook/preview-head.html diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..13a47b1 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1 @@ + diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index 67d105d..57dd730 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -5,314 +5,326 @@ import {reusableLineChart} from './reusable-line-chart'; export default {title: 'Reusable Line Chart'}; const firstData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3500000, - "status": "Site Handed - Over to Contractor" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": null - } - ], - events: [ - { - "date": "2018-09-20", - "label": "Project Start Date" - }, - { - "date": "2019-03-15", - "label": "Estimated construction end date" - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3500000, + "status": "Site Handed - Over to Contractor" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": null + } + ], + events: [ + { + "date": "2018-07-20", + "label": "Project Start Date" + }, + { + "date": "2018-09-20", + "label": "Estimated Construction Start Date" + }, + { + "date": "2018-11-20", + "label": "Estimated Project Completion Date" + }, + { + "date": "2019-03-15", + "label": "Contracted Construction End Date" + }, + { + "date": "2019-06-15", + "label": "Estimated Construction End Date" + } + ] }; const secondData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": null, - "total_spent_in_quarter": null, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": null, - "total_spent_in_quarter": null, - "total_estimated_project_cost": 3500000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": "Tender" - }, - { - "date": "2019-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3650000, - "status": "Other - Packaged Ongoing Project" - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": null, + "total_spent_in_quarter": null, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": null, + "total_spent_in_quarter": null, + "total_estimated_project_cost": 3500000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": "Tender" + }, + { + "date": "2019-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3650000, + "status": "Other - Packaged Ongoing Project" + } + ] }; const thirdData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": null, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": null, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": null, - "status": null - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": "Tender" - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": null, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": null, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": null, + "status": null + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": "Tender" + } + ] }; const closelyLocatedData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 2900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 3640000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 4009000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3500000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": null - }] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 2900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 3640000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 4009000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3500000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": null + }] }; const minifiedData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 90000, - "total_spent_in_quarter": 35000, - "total_estimated_project_cost": 320000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 166900, - "total_spent_in_quarter": 68900, - "total_estimated_project_cost": 365000, - "status": "Feasibility" - }, - { - - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 166900, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 350000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 276900, - "total_spent_in_quarter": 110000, - "total_estimated_project_cost": 365000, - "status": null - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 90000, + "total_spent_in_quarter": 35000, + "total_estimated_project_cost": 320000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 166900, + "total_spent_in_quarter": 68900, + "total_estimated_project_cost": 365000, + "status": "Feasibility" + }, + { + + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 166900, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 350000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 276900, + "total_spent_in_quarter": 110000, + "total_estimated_project_cost": 365000, + "status": null + } + ] }; export const MockupData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(firstData)); + select(container) + .call(myChart.data(firstData)); - return container; + return container; }; export const MissingSpentData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(secondData)); + select(container) + .call(myChart.data(secondData)); - return container; + return container; }; export const MissingCostData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(thirdData)); + select(container) + .call(myChart.data(thirdData)); - return container; + return container; }; export const SinglePointData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data([firstData[0]])); + select(container) + .call(myChart.data([firstData[0]])); - return container; + return container; }; export const HundredThousandsData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(minifiedData)); + select(container) + .call(myChart.data(minifiedData)); - return container; + return container; }; export const SmallWidthHeight = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart().width(320).height(450); + const myChart = reusableLineChart().width(320).height(450); - select(container) - .call(myChart.data(firstData)); + select(container) + .call(myChart.data(firstData)); - return container; + return container; }; export const CloselyLocatedPointsData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(closelyLocatedData)); + select(container) + .call(myChart.data(closelyLocatedData)); - return container; + return container; }; diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index 7227076..6748af4 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -13,860 +13,886 @@ const margin = {top: 50, right: 50, bottom: 180, left: 60}; const SYMBOL_WIDTH = 7.5; function transformStringDatesToObjects(data) { - return data.map(d => { - return Object.assign(d, { - date: new Date(d.date) - }) - }) + return data.map(d => { + return Object.assign(d, { + date: new Date(d.date) + }) + }) } export function reusableLineChart() { - let initialConfiguration = { - width: 850, - height: 550, - spentCircleTooltipFormatter: (d) => { - return `
Total spent:  ${d.data.total_spent_to_date ? "R" + format(",d")(d.data.total_spent_to_date) : 0}
+ let initialConfiguration = { + width: 850, + height: 550, + spentCircleTooltipFormatter: (d) => { + return `
Total spent:  ${d.data.total_spent_to_date ? "R" + format(",d")(d.data.total_spent_to_date) : 0}
Spent in quarter:  ${d.data.total_spent_in_quarter ? "R" + format(",d")(d.data.total_spent_in_quarter) : 0}
`; - }, - totalCostCircleTooltipFormatter: (d) => { - return `
Total project cost: ${d.data.total_estimated_project_cost ? "R" + format('.3s')(d.data.total_estimated_project_cost) : 0}
`; - }, - statusLabelTooltipFormatter: (d) => { - return `
${d.label}
`; - }, - eventTooltipFormatter: (d) => { - return `
${d.label}
`; - } - }; - - let width = initialConfiguration.width, - height = initialConfiguration.height, - data = [], - events = [], - spentCircleTooltipFormatter = initialConfiguration.spentCircleTooltipFormatter, - totalCostCircleTooltipFormatter = initialConfiguration.totalCostCircleTooltipFormatter, - statusLabelTooltipFormatter = initialConfiguration.statusLabelTooltipFormatter, - eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; - let updateData = null; - let correspondingSpentLineCircle, correspondingTotalCostCircle = null; - - function chart(selection) { - selection.each(function () { - let xDomainValues = getXDomainValues(data); - let minimalXDomainValue = min(xDomainValues); - let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); - let yDomainValues = getYDomainValues(data); - const xScaleLength = width - margin.right - margin.left; - const yScaleLength = height - margin.bottom - margin.top; - - const xScale = scaleTime() - .domain([newMinXDomainValue, max(xDomainValues)]) - .range([margin.left, width - margin.right]); - - const yScale = scaleLinear() - .domain([0, max(yDomainValues)]) - .range([height - margin.bottom, margin.top]) - .nice(); - - const svg = selection.append("svg") - .attr("width", width) - .attr("height", height) - .append("g"); - - const spentCircleTooltip = d3Tip() - .attr("class", "d3-tip") - .direction(function (d) { - return d.direction; - }) - .offset(function (d) { - return d.direction === 'n' ? [-8, 0] : [8, 0]; - }) - .html(spentCircleTooltipFormatter); - - const totalCostCircleTooltip = d3Tip() - .attr("class", "d3-tip") - .direction(function (d) { - return d.direction; - }) - .offset(function (d) { - return d.direction === 'n' ? [-8, 0] : [8, 0]; - }) - .html(totalCostCircleTooltipFormatter); - - const statusLabelTooltip = d3Tip() - .attr("class", "d3-tip") - .offset([-8, 0]) - .html(statusLabelTooltipFormatter); - - const eventTooltip = d3Tip() - .attr("class", "d3-tip") - .offset([-8, 0]) - .html(eventTooltipFormatter); - - svg.call(spentCircleTooltip); - svg.call(totalCostCircleTooltip); - svg.call(statusLabelTooltip); - - const backgroundRectanglesGroup = svg.append("g") - .attr("class", "background-rectangles"); - - backgroundRectanglesGroup.selectAll("rect") - .data(data) - .enter() - .append("rect") - .attr("class", "background-rectangle") - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength) - .attr("fill", 'none') - .on("mouseover", function (d) { - showTooltip(d); - }) - .on("mouseout", function () { - hideTooltip(); - }); - - const statusLineElementsGroup = svg.append("g") - .attr("class", "status-line-elements"); - - statusLineElementsGroup.selectAll("path") - .data(getStatusLineData(data)) - .enter() - .append("path") - .attr("class", "status-line-path") - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null) - .style("fill", "none"); - - statusLineElementsGroup.selectAll('text') - .data(getStatusLabelsData(data)) - .enter() - .append("text") - .attr("class", "status-line-label") - .attr("fill", "black") - .attr("text-anchor", "middle") - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)) - .on("mouseover", function (d) { - if (isStatusLabelTruncated(d)) { - statusLabelTooltip.show(d, this); - } - }) - .on("mouseout", function () { - statusLabelTooltip.hide(); - }); - - const spentLineElementsGroup = svg.append("g") - .attr("class", "spent-line-elements"); - - spentLineElementsGroup.selectAll("path") - .data(getTotalSpentLineData(data)) - .enter() - .append("path") - .attr("class", "spent-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("fill", "none"); - - spentLineElementsGroup.selectAll("circle") - .data(getTotalSpentCircleData(data)) - .enter() - .append("circle") - .attr("class", "spent-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)) - .attr("r", 5) - .attr("fill", '#333333') - .on("mouseover", function (d) { - spentCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - spentCircleTooltip.hide(); - }); - - const totalCostElementsGroup = svg.append("g") - .attr("class", "total-cost-line-elements"); - - totalCostElementsGroup.selectAll("path") - .data(getTotalСostLineData(data)) - .enter() - .append("path") - .attr("class", "total-cost-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", "4 5") - .style("stroke-miterlimit", 16) - .style("fill", "none"); - - totalCostElementsGroup.selectAll("circle") - .data(getTotalСostCircleData(data)) - .enter() - .append("circle") - .attr("class", "total-cost-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) - .attr("r", 5) - .attr("fill", 'none') - .on("mouseover", function (d) { - totalCostCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - totalCostCircleTooltip.hide(); - }); - - const eventsElementsGroup = svg.append("g") - .attr("class", "events-elements") - .attr("transform", `translate(0,${(height - margin.bottom + 100)})`); - - eventsElementsGroup - .selectAll("rect") - .data(events) - .enter() - .append("rect") - .attr('class', 'event-rect') - .attr("x", d => xScale(d.date)) - .attr("y", 0) - .attr("rx", 5) - .attr("ry", 5) - .attr("width", 100) - .attr("height", d => 20) - .attr("fill", (d, i) => 'rgb(51, 51, 51)') - .on("mouseover", function (d) { - }) - .on("mouseout", function () { - }); - - const xAxis = axisBottom(xScale) - .tickValues(xDomainValues) - .tickFormat('') - .tickSize(7) - .tickSizeOuter(0); - - const gXAxis = svg.append("g") - .attr("class", "x axis") - .attr("transform", `translate(0,${(height - margin.bottom)})`) - .call(xAxis); - applyAxisStyle(gXAxis); - - gXAxis.selectAll('.axis-tick-label') - .data(data) - .enter() - .append("text") - .attr("class", "axis-tick-label") - .attr("fill", "black") - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .text(d => d.quarter_label); - - gXAxis.selectAll('.axis-tick-year-label') - .data(data) - .enter() - .append("text") - .attr("class", "axis-tick-year-label") - .attr("fill", "#bcbcbc") - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .text(d => d.financial_year_label); - - const yAxisGrid = axisLeft(yScale) - .ticks(4) - .tickFormat('') - .tickSize(-width + margin.left + margin.right); - - const yAxis = axisLeft(yScale) - .ticks(4) - .tickFormat(d => d !== 0 ? `R${format('~s')(d)}` : '') - .tickSizeInner(5) - .tickSizeOuter(0); - - const gYAxisGrid = svg.append("g") - .attr("class", "grid") - .attr("transform", `translate(${margin.left},0)`) - .call(yAxisGrid); - - const gYAxis = svg.append("g") - .attr("class", "y axis") - .attr("transform", `translate(${margin.left},0)`) - .call(yAxis); - - applyGridStyle(gYAxisGrid); - applyAxisStyle(gYAxis, true); - - const legend = svg - .selectAll("legend") - .data([ - {label: 'actual quaterly spend', 'stroke-dasharray': '4 5'}, - {label: 'total estimated project cost'} - ]) - .enter() - .append('g') - .attr("class", "legend") - .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - margin.bottom + 50})`); - - legend.call(appendLegendItem); - - - function appendLegendItem(selection) { - selection.append('rect') - .attr("x", 0) - .attr("y", 0) - .attr("width", 20) - .attr("height", 20); - - selection.append('line') - .attr('x1', 0) - .attr('y1', 10) - .attr('x2', 20) - .attr('y2', 10) - .attr('stroke-dasharray', d => d['stroke-dasharray'] ? d['stroke-dasharray'] : null); - - selection.append('text') - .attr("x", 25) - .attr("y", 15) - .text(d => d.label) - .style("text-anchor", "start"); - - } - - function getXDomainValues(data) { - if (data && data.length > 0) { - return data.map(group => new Date(group.date)).sort((a, b) => a - b); - } else { - return [new Date(), new Date()] - } - } - - function getYDomainValues(data) { - return data.map(group => [group.total_spent_to_date, group.total_estimated_project_cost]).flat() - .sort((a, b) => a - b); - } - - function getTotalSpentLineData(data) { - const result = []; - if (data && data.length > 0) { - for (let i = 0; i < data.length - 1; i++) { - if (data[i].total_spent_to_date && data[i + 1].total_spent_to_date) { - result.push([data[i], data[i + 1]]); - } - } - return result; - } else { - return []; - } - } - - function getStatusLineData(data) { - let result = []; - if (data && data.length > 0) { - const allStatuses = data.filter(d => !!d.status).map(d => d.status); - const uniqueStatuses = []; - allStatuses.forEach(status => { - if (uniqueStatuses.indexOf(status) === -1) { - uniqueStatuses.push(status); - } - }); - uniqueStatuses.forEach(status => { - const currentStatusPoints = data.filter(d => d.status === status); - result.push([ - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - dotted: true, - start: true - }, - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - dotted: true, - end: true - } - ]); - result.push([ - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - start: true - }, - { - date: currentStatusPoints[currentStatusPoints.length - 1].date, - }, - { - date: currentStatusPoints[currentStatusPoints.length - 1].date, - end: true - } - ]); - }); - return result; - } else { - return []; - } - } - - function getStatusLabelsData(data) { - let result = []; - if (data && data.length > 0) { - const allStatuses = data.filter(d => !!d.status).map(d => d.status); - const uniqueStatuses = []; - allStatuses.forEach(status => { - if (uniqueStatuses.indexOf(status) === -1) { - uniqueStatuses.push(status); - } - }); - uniqueStatuses.forEach(status => { - const currentStatusPoints = data.filter(d => d.status === status); - result.push({ - startDate: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - endDate: currentStatusPoints[currentStatusPoints.length - 1].date, - label: status - }); - }); - return result; - } else { - return []; - } - } - - function getStatusLabelText(d) { - const placeholderLength = xScale(d.endDate) - xScale(d.startDate) - 20; - return placeholderLength > d.label.length * SYMBOL_WIDTH - ? d.label - : `${d.label.slice(0, placeholderLength / SYMBOL_WIDTH)}...` - } - - function isStatusLabelTruncated(d) { - return xScale(d.endDate) - xScale(d.startDate) - 20 < d.label.length * SYMBOL_WIDTH; - } - - function getTotalSpentCircleData(data) { - return data.filter(d => d.total_spent_to_date !== null); - } - - function getTotalСostCircleData(data) { - return data.filter(d => d.total_estimated_project_cost !== null); - } - - function getTotalСostLineData(data) { - const result = []; - if (data && data.length > 0) { - const firstNonNullPointIndex = data.findIndex(d => d.total_estimated_project_cost !== null); - const firstPoint = { - date: xScale.domain()[0], - total_estimated_project_cost: data[firstNonNullPointIndex].total_estimated_project_cost - }; - result.push([firstPoint, data[firstNonNullPointIndex]]); - for (let i = firstNonNullPointIndex; i < data.length - 1; i++) { - if (data[i + 1].total_estimated_project_cost !== null) { - if (data[i + 1].total_estimated_project_cost !== data[i].total_estimated_project_cost) { - const middlePoint = { - date: data[i].date, - total_estimated_project_cost: data[i + 1].total_estimated_project_cost - }; - result.push([data[i], middlePoint]); - result.push([middlePoint, data[i + 1]]); - } else { - result.push([data[i], data[i + 1]]); - } - } - } - return result; - } else { - return []; - } - } - - function applyAxisStyle(gAxis, yAxis) { - gAxis.selectAll('line') - .style('fill', 'none') - .style('stroke-width', '1') - .style('stroke', yAxis ? 'black' : 'rgba(0, 0, 0, 0.1)') - .style('shape-rendering', 'crispEdges'); - - gAxis.select('path') - .style('fill', 'none') - .style('stroke', 'black') - .style('stroke-width', '1') - .style('shape-rendering', 'crispEdges'); - if (yAxis) { - select(gYAxis.selectAll(".tick").nodes()[0]).attr("visibility", "hidden"); - } - } - - function applyGridStyle(gAxis) { - gAxis.selectAll('line') - .style('fill', 'none') - .style('stroke-width', '1') - .style('stroke', 'rgba(0, 0, 0, 0.1)') - .style('shape-rendering', 'crispEdges'); - - gAxis.select('path') - .style('fill', 'none') - .style('color', 'transparent'); - } - - function showTooltip(d) { - correspondingSpentLineCircle = spentLineElementsGroup.selectAll('circle') - .filter(function (circleData) { - return d.date === circleData.date; - }).nodes()[0]; - correspondingTotalCostCircle = totalCostElementsGroup.selectAll('circle') - .filter(function (circleData) { - return d.date === circleData.date; - }).nodes()[0]; - - let totalCostCircleDirection = 'n'; - let spentLineCircleDirection = 'n'; - if (correspondingSpentLineCircle && correspondingTotalCostCircle) { - const spentLineCircleCY = parseFloat(select(correspondingSpentLineCircle).attr('cy')); - const totalCostCircle = parseFloat(select(correspondingTotalCostCircle).attr('cy')); - if (Math.abs(spentLineCircleCY - totalCostCircle) < 50) { - if (spentLineCircleCY < totalCostCircle) { - totalCostCircleDirection = 's'; - } else { - spentLineCircleDirection = 's'; - } - } - } - - if (correspondingSpentLineCircle) { - select(correspondingSpentLineCircle).attr('fill', 'rgb(0, 137, 123)'); - spentCircleTooltip.show({ - data: d, - direction: spentLineCircleDirection - }, correspondingSpentLineCircle); - } - if (correspondingTotalCostCircle) { - select(correspondingTotalCostCircle).attr('fill', 'rgb(0, 137, 123)'); - totalCostCircleTooltip.show({ - data: d, - direction: totalCostCircleDirection - }, correspondingTotalCostCircle); - } - } - - function hideTooltip() { - if (correspondingSpentLineCircle) { - select(correspondingSpentLineCircle).attr('fill', '#333333'); - spentCircleTooltip.hide(); - } - if (correspondingTotalCostCircle) { - totalCostCircleTooltip.hide(); - select(correspondingTotalCostCircle).attr("fill", 'none'); - } - correspondingSpentLineCircle = null; - correspondingTotalCostCircle = null; - } - - updateData = function () { - xDomainValues = getXDomainValues(data); - let minimalXDomainValue = min(xDomainValues); - let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); - - xScale.domain([newMinXDomainValue, max(xDomainValues)]); - xAxis.scale(xScale).tickValues(xDomainValues); - - yDomainValues = getYDomainValues(data); - yScale.domain([0, max(yDomainValues)]).nice(); - yAxis.scale(yScale); - yAxisGrid.scale(yScale); - - const t = transition() - .duration(750); - - gXAxis.transition(t) - .call(xAxis); - - gYAxis.transition(t) - .call(yAxis); - - gYAxisGrid.transition(t) - .call(yAxisGrid); - - applyAxisStyle(gXAxis); - applyAxisStyle(gYAxis, true); - applyGridStyle(gYAxisGrid); - - const updatedSpentLineCircles = spentLineElementsGroup.selectAll('circle').data(getTotalSpentCircleData(data)); - const updatedTotalCostCircles = totalCostElementsGroup.selectAll('circle').data(getTotalСostCircleData(data)); - const updatedAxisLabels = gXAxis.selectAll('.axis-tick-label').data(data); - const updatedAxisYearLabels = gXAxis.selectAll('.axis-tick-year-label').data(data); - const updatedSpentLine = spentLineElementsGroup.selectAll(".spent-line-path").data(getTotalSpentLineData(data)); - const updatedTotalCostLine = totalCostElementsGroup.selectAll(".total-cost-line-path").data(getTotalСostLineData(data)); - const updatedStatusLine = statusLineElementsGroup.selectAll(".status-line-path").data(getStatusLineData(data)); - const updatedStatusLabels = statusLineElementsGroup.selectAll(".status-line-label").data(getStatusLabelsData(data)); - const updatedBackgroundRectangles = backgroundRectanglesGroup.selectAll(".background-rectangle").data(data); - - updatedSpentLine - .enter() - .append("path") - .attr("class", "spent-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("fill", "none"); - - updatedSpentLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ); - - updatedSpentLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedTotalCostLine - .enter() - .append("path") - .attr("class", "total-cost-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", "4 5") - .style("stroke-miterlimit", 16) - .style("fill", "none"); - - updatedTotalCostLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ); - - updatedTotalCostLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedStatusLine - .enter() - .append("path") - .attr("class", "status-line-path") - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null) - .style("fill", "none"); - - updatedStatusLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null); - - updatedStatusLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedStatusLabels - .enter() - .append("text") - .attr("class", "status-line-label") - .attr("fill", "black") - .attr("text-anchor", "middle") - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)) - .on("mouseover", function (d) { - if (isStatusLabelTruncated(d)) { - statusLabelTooltip.show(d, this); - } - }) - .on("mouseout", function () { - statusLabelTooltip.hide(); - }); - - updatedStatusLabels - .transition() - .duration(1000) - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)); - - updatedStatusLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedSpentLineCircles.enter() - .append("circle") - .attr("class", "spent-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)) - .attr("r", 5) - .attr("fill", '#333333') - .on("mouseover", function (d) { - spentCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - spentCircleTooltip.hide(); - }); - - updatedSpentLineCircles - .transition() - .ease(easeLinear) - .duration(750) - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)); - - updatedSpentLineCircles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedTotalCostCircles.enter() - .append("circle") - .attr("class", "total-cost-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) - .attr("r", 5) - .attr("fill", 'none') - .on("mouseover", function (d) { - totalCostCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - totalCostCircleTooltip.hide(); - }); - - updatedTotalCostCircles - .transition() - .ease(easeLinear) - .duration(750) - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)); - - updatedTotalCostCircles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedAxisLabels.enter() - .append("text") - .attr("class", "axis-tick-label") - .attr("fill", "black") - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .text(d => d.quarter_label); - - updatedAxisLabels - .transition() - .ease(easeLinear) - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .duration(750) - .text(d => d.quarter_label); - - updatedAxisLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedAxisYearLabels.enter() - .append("text") - .attr("class", "axis-tick-year-label") - .attr("fill", "#bcbcbc") - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .text(d => d.financial_year_label); - - updatedAxisYearLabels - .transition() - .ease(easeLinear) - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .duration(750) - .text(d => d.financial_year_label); - - updatedAxisYearLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedBackgroundRectangles - .enter() - .append("rect") - .attr("class", "background-rectangle") - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength) - .attr("fill", 'none') - .on("mouseover", function (d) { - showTooltip(d); - }) - .on("mouseout", function () { - hideTooltip(); - }); - - updatedBackgroundRectangles - .transition() - .ease(easeLinear) - .duration(750) - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength); - - updatedBackgroundRectangles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - }; - - }) - } - - chart.width = function (value) { - if (!arguments.length) return width; - width = value; - return chart; - }; - - chart.height = function (value) { - if (!arguments.length) return height; - height = value; - return chart; - }; - - chart.data = function (value) { - if (!arguments.length) return data; - data = value && value.snapshots ? transformStringDatesToObjects(value.snapshots) : []; - events = value && value.events ? transformStringDatesToObjects(value.events) : []; - if (typeof updateData === 'function') updateData(); - return chart; - }; - - return chart; + }, + totalCostCircleTooltipFormatter: (d) => { + return `
Total project cost: ${d.data.total_estimated_project_cost ? "R" + format('.3s')(d.data.total_estimated_project_cost) : 0}
`; + }, + statusLabelTooltipFormatter: (d) => { + return `
${d.label}
`; + }, + eventTooltipFormatter: (d) => { + return `
${d.label}
`; + } + }; + + let width = initialConfiguration.width, + height = initialConfiguration.height, + data = [], + events = [], + spentCircleTooltipFormatter = initialConfiguration.spentCircleTooltipFormatter, + totalCostCircleTooltipFormatter = initialConfiguration.totalCostCircleTooltipFormatter, + statusLabelTooltipFormatter = initialConfiguration.statusLabelTooltipFormatter, + eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; + let updateData = null; + let correspondingSpentLineCircle, correspondingTotalCostCircle = null; + + function chart(selection) { + selection.each(function () { + let xDomainValues = getXDomainValues(data); + let minimalXDomainValue = min(xDomainValues); + let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); + let yDomainValues = getYDomainValues(data); + const xScaleLength = width - margin.right - margin.left; + const yScaleLength = height - margin.bottom - margin.top; + + const xScale = scaleTime() + .domain([newMinXDomainValue, max(xDomainValues)]) + .range([margin.left, width - margin.right]); + + const yScale = scaleLinear() + .domain([0, max(yDomainValues)]) + .range([height - margin.bottom, margin.top]) + .nice(); + + const svg = selection.append("svg") + .attr("width", width) + .attr("height", height) + .append("g"); + + const spentCircleTooltip = d3Tip() + .attr("class", "d3-tip") + .direction(function (d) { + return d.direction; + }) + .offset(function (d) { + return d.direction === 'n' ? [-8, 0] : [8, 0]; + }) + .html(spentCircleTooltipFormatter); + + const totalCostCircleTooltip = d3Tip() + .attr("class", "d3-tip") + .direction(function (d) { + return d.direction; + }) + .offset(function (d) { + return d.direction === 'n' ? [-8, 0] : [8, 0]; + }) + .html(totalCostCircleTooltipFormatter); + + const statusLabelTooltip = d3Tip() + .attr("class", "d3-tip") + .offset([-8, 0]) + .html(statusLabelTooltipFormatter); + + const eventTooltip = d3Tip() + .attr("class", "d3-tip") + .offset([-8, 0]) + .html(eventTooltipFormatter); + + svg.call(spentCircleTooltip); + svg.call(totalCostCircleTooltip); + svg.call(statusLabelTooltip); + + const backgroundRectanglesGroup = svg.append("g") + .attr("class", "background-rectangles"); + + backgroundRectanglesGroup.selectAll("rect") + .data(data) + .enter() + .append("rect") + .attr("class", "background-rectangle") + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength) + .attr("fill", 'none') + .on("mouseover", function (d) { + showTooltip(d); + }) + .on("mouseout", function () { + hideTooltip(); + }); + + const statusLineElementsGroup = svg.append("g") + .attr("class", "status-line-elements"); + + statusLineElementsGroup.selectAll("path") + .data(getStatusLineData(data)) + .enter() + .append("path") + .attr("class", "status-line-path") + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null) + .style("fill", "none"); + + statusLineElementsGroup.selectAll('text') + .data(getStatusLabelsData(data)) + .enter() + .append("text") + .attr("class", "status-line-label") + .attr("fill", "black") + .attr("text-anchor", "middle") + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)) + .on("mouseover", function (d) { + if (isStatusLabelTruncated(d)) { + statusLabelTooltip.show(d, this); + } + }) + .on("mouseout", function () { + statusLabelTooltip.hide(); + }); + + const spentLineElementsGroup = svg.append("g") + .attr("class", "spent-line-elements"); + + spentLineElementsGroup.selectAll("path") + .data(getTotalSpentLineData(data)) + .enter() + .append("path") + .attr("class", "spent-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("fill", "none"); + + spentLineElementsGroup.selectAll("circle") + .data(getTotalSpentCircleData(data)) + .enter() + .append("circle") + .attr("class", "spent-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)) + .attr("r", 5) + .attr("fill", '#333333') + .on("mouseover", function (d) { + spentCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + spentCircleTooltip.hide(); + }); + + const totalCostElementsGroup = svg.append("g") + .attr("class", "total-cost-line-elements"); + + totalCostElementsGroup.selectAll("path") + .data(getTotalСostLineData(data)) + .enter() + .append("path") + .attr("class", "total-cost-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", "4 5") + .style("stroke-miterlimit", 16) + .style("fill", "none"); + + totalCostElementsGroup.selectAll("circle") + .data(getTotalСostCircleData(data)) + .enter() + .append("circle") + .attr("class", "total-cost-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) + .attr("r", 5) + .attr("fill", 'none') + .on("mouseover", function (d) { + totalCostCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + totalCostCircleTooltip.hide(); + }); + + const eventsElements = svg.append("g") + .attr("class", "events-elements") + .attr("transform", `translate(0,${(height - margin.bottom + 100)})`); + + const eventsElementsGroups = eventsElements + .selectAll("g") + .data(events) + .enter() + .append("g") + .attr("transform", (d) => `translate(${xScale(d.date)},0)`) + .attr('class', 'event-elements-group'); + + eventsElementsGroups.selectAll('rect') + .data(d => [d]) + .enter() + .append('rect') + .attr('class', 'event-rect') + .attr("x", 0) + .attr("y", 0) + .attr("rx", 5) + .attr("ry", 5) + .attr("width", 60) + .attr("height", d => 20) + .attr("fill", (d, i) => 'rgb(51, 51, 51)') + .on("mouseover", function (d) { + }) + .on("mouseout", function () { + }); + + eventsElementsGroups.selectAll('text') + .data(d => [d]) + .enter() + .append('text') + .attr('class', 'event-text') + .attr("x", 30) + .attr("y", 15) + .attr('text-anchor', 'middle') + .attr("color", (d, i) => 'white') + .text(function (d) { + return '~ \uf04b \uf6e3 \uf0c8 \uf304' + }) + .style("font-weight", 900); + + // .on("mouseover", function (d) { + // }) + // .on("mouseout", function () { + // }); + + const xAxis = axisBottom(xScale) + .tickValues(xDomainValues) + .tickFormat('') + .tickSize(7) + .tickSizeOuter(0); + + const gXAxis = svg.append("g") + .attr("class", "x axis") + .attr("transform", `translate(0,${(height - margin.bottom)})`) + .call(xAxis); + applyAxisStyle(gXAxis); + + gXAxis.selectAll('.axis-tick-label') + .data(data) + .enter() + .append("text") + .attr("class", "axis-tick-label") + .attr("fill", "black") + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .text(d => d.quarter_label); + + gXAxis.selectAll('.axis-tick-year-label') + .data(data) + .enter() + .append("text") + .attr("class", "axis-tick-year-label") + .attr("fill", "#bcbcbc") + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .text(d => d.financial_year_label); + + const yAxisGrid = axisLeft(yScale) + .ticks(4) + .tickFormat('') + .tickSize(-width + margin.left + margin.right); + + const yAxis = axisLeft(yScale) + .ticks(4) + .tickFormat(d => d !== 0 ? `R${format('~s')(d)}` : '') + .tickSizeInner(5) + .tickSizeOuter(0); + + const gYAxisGrid = svg.append("g") + .attr("class", "grid") + .attr("transform", `translate(${margin.left},0)`) + .call(yAxisGrid); + + const gYAxis = svg.append("g") + .attr("class", "y axis") + .attr("transform", `translate(${margin.left},0)`) + .call(yAxis); + + applyGridStyle(gYAxisGrid); + applyAxisStyle(gYAxis, true); + + const legend = svg + .selectAll("legend") + .data([ + {label: 'actual quaterly spend', 'stroke-dasharray': '4 5'}, + {label: 'total estimated project cost'} + ]) + .enter() + .append('g') + .attr("class", "legend") + .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - margin.bottom + 50})`); + + legend.call(appendLegendItem); + + + function appendLegendItem(selection) { + selection.append('rect') + .attr("x", 0) + .attr("y", 0) + .attr("width", 20) + .attr("height", 20); + + selection.append('line') + .attr('x1', 0) + .attr('y1', 10) + .attr('x2', 20) + .attr('y2', 10) + .attr('stroke-dasharray', d => d['stroke-dasharray'] ? d['stroke-dasharray'] : null); + + selection.append('text') + .attr("x", 25) + .attr("y", 15) + .text(d => d.label) + .style("text-anchor", "start"); + + } + + function getXDomainValues(data) { + if (data && data.length > 0) { + return data.map(group => new Date(group.date)).sort((a, b) => a - b); + } else { + return [new Date(), new Date()] + } + } + + function getYDomainValues(data) { + return data.map(group => [group.total_spent_to_date, group.total_estimated_project_cost]).flat() + .sort((a, b) => a - b); + } + + function getTotalSpentLineData(data) { + const result = []; + if (data && data.length > 0) { + for (let i = 0; i < data.length - 1; i++) { + if (data[i].total_spent_to_date && data[i + 1].total_spent_to_date) { + result.push([data[i], data[i + 1]]); + } + } + return result; + } else { + return []; + } + } + + function getStatusLineData(data) { + let result = []; + if (data && data.length > 0) { + const allStatuses = data.filter(d => !!d.status).map(d => d.status); + const uniqueStatuses = []; + allStatuses.forEach(status => { + if (uniqueStatuses.indexOf(status) === -1) { + uniqueStatuses.push(status); + } + }); + uniqueStatuses.forEach(status => { + const currentStatusPoints = data.filter(d => d.status === status); + result.push([ + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + dotted: true, + start: true + }, + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + dotted: true, + end: true + } + ]); + result.push([ + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + start: true + }, + { + date: currentStatusPoints[currentStatusPoints.length - 1].date, + }, + { + date: currentStatusPoints[currentStatusPoints.length - 1].date, + end: true + } + ]); + }); + return result; + } else { + return []; + } + } + + function getStatusLabelsData(data) { + let result = []; + if (data && data.length > 0) { + const allStatuses = data.filter(d => !!d.status).map(d => d.status); + const uniqueStatuses = []; + allStatuses.forEach(status => { + if (uniqueStatuses.indexOf(status) === -1) { + uniqueStatuses.push(status); + } + }); + uniqueStatuses.forEach(status => { + const currentStatusPoints = data.filter(d => d.status === status); + result.push({ + startDate: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + endDate: currentStatusPoints[currentStatusPoints.length - 1].date, + label: status + }); + }); + return result; + } else { + return []; + } + } + + function getStatusLabelText(d) { + const placeholderLength = xScale(d.endDate) - xScale(d.startDate) - 20; + return placeholderLength > d.label.length * SYMBOL_WIDTH + ? d.label + : `${d.label.slice(0, placeholderLength / SYMBOL_WIDTH)}...` + } + + function isStatusLabelTruncated(d) { + return xScale(d.endDate) - xScale(d.startDate) - 20 < d.label.length * SYMBOL_WIDTH; + } + + function getTotalSpentCircleData(data) { + return data.filter(d => d.total_spent_to_date !== null); + } + + function getTotalСostCircleData(data) { + return data.filter(d => d.total_estimated_project_cost !== null); + } + + function getTotalСostLineData(data) { + const result = []; + if (data && data.length > 0) { + const firstNonNullPointIndex = data.findIndex(d => d.total_estimated_project_cost !== null); + const firstPoint = { + date: xScale.domain()[0], + total_estimated_project_cost: data[firstNonNullPointIndex].total_estimated_project_cost + }; + result.push([firstPoint, data[firstNonNullPointIndex]]); + for (let i = firstNonNullPointIndex; i < data.length - 1; i++) { + if (data[i + 1].total_estimated_project_cost !== null) { + if (data[i + 1].total_estimated_project_cost !== data[i].total_estimated_project_cost) { + const middlePoint = { + date: data[i].date, + total_estimated_project_cost: data[i + 1].total_estimated_project_cost + }; + result.push([data[i], middlePoint]); + result.push([middlePoint, data[i + 1]]); + } else { + result.push([data[i], data[i + 1]]); + } + } + } + return result; + } else { + return []; + } + } + + function applyAxisStyle(gAxis, yAxis) { + gAxis.selectAll('line') + .style('fill', 'none') + .style('stroke-width', '1') + .style('stroke', yAxis ? 'black' : 'rgba(0, 0, 0, 0.1)') + .style('shape-rendering', 'crispEdges'); + + gAxis.select('path') + .style('fill', 'none') + .style('stroke', 'black') + .style('stroke-width', '1') + .style('shape-rendering', 'crispEdges'); + if (yAxis) { + select(gYAxis.selectAll(".tick").nodes()[0]).attr("visibility", "hidden"); + } + } + + function applyGridStyle(gAxis) { + gAxis.selectAll('line') + .style('fill', 'none') + .style('stroke-width', '1') + .style('stroke', 'rgba(0, 0, 0, 0.1)') + .style('shape-rendering', 'crispEdges'); + + gAxis.select('path') + .style('fill', 'none') + .style('color', 'transparent'); + } + + function showTooltip(d) { + correspondingSpentLineCircle = spentLineElementsGroup.selectAll('circle') + .filter(function (circleData) { + return d.date === circleData.date; + }).nodes()[0]; + correspondingTotalCostCircle = totalCostElementsGroup.selectAll('circle') + .filter(function (circleData) { + return d.date === circleData.date; + }).nodes()[0]; + + let totalCostCircleDirection = 'n'; + let spentLineCircleDirection = 'n'; + if (correspondingSpentLineCircle && correspondingTotalCostCircle) { + const spentLineCircleCY = parseFloat(select(correspondingSpentLineCircle).attr('cy')); + const totalCostCircle = parseFloat(select(correspondingTotalCostCircle).attr('cy')); + if (Math.abs(spentLineCircleCY - totalCostCircle) < 50) { + if (spentLineCircleCY < totalCostCircle) { + totalCostCircleDirection = 's'; + } else { + spentLineCircleDirection = 's'; + } + } + } + + if (correspondingSpentLineCircle) { + select(correspondingSpentLineCircle).attr('fill', 'rgb(0, 137, 123)'); + spentCircleTooltip.show({ + data: d, + direction: spentLineCircleDirection + }, correspondingSpentLineCircle); + } + if (correspondingTotalCostCircle) { + select(correspondingTotalCostCircle).attr('fill', 'rgb(0, 137, 123)'); + totalCostCircleTooltip.show({ + data: d, + direction: totalCostCircleDirection + }, correspondingTotalCostCircle); + } + } + + function hideTooltip() { + if (correspondingSpentLineCircle) { + select(correspondingSpentLineCircle).attr('fill', '#333333'); + spentCircleTooltip.hide(); + } + if (correspondingTotalCostCircle) { + totalCostCircleTooltip.hide(); + select(correspondingTotalCostCircle).attr("fill", 'none'); + } + correspondingSpentLineCircle = null; + correspondingTotalCostCircle = null; + } + + updateData = function () { + xDomainValues = getXDomainValues(data); + let minimalXDomainValue = min(xDomainValues); + let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); + + xScale.domain([newMinXDomainValue, max(xDomainValues)]); + xAxis.scale(xScale).tickValues(xDomainValues); + + yDomainValues = getYDomainValues(data); + yScale.domain([0, max(yDomainValues)]).nice(); + yAxis.scale(yScale); + yAxisGrid.scale(yScale); + + const t = transition() + .duration(750); + + gXAxis.transition(t) + .call(xAxis); + + gYAxis.transition(t) + .call(yAxis); + + gYAxisGrid.transition(t) + .call(yAxisGrid); + + applyAxisStyle(gXAxis); + applyAxisStyle(gYAxis, true); + applyGridStyle(gYAxisGrid); + + const updatedSpentLineCircles = spentLineElementsGroup.selectAll('circle').data(getTotalSpentCircleData(data)); + const updatedTotalCostCircles = totalCostElementsGroup.selectAll('circle').data(getTotalСostCircleData(data)); + const updatedAxisLabels = gXAxis.selectAll('.axis-tick-label').data(data); + const updatedAxisYearLabels = gXAxis.selectAll('.axis-tick-year-label').data(data); + const updatedSpentLine = spentLineElementsGroup.selectAll(".spent-line-path").data(getTotalSpentLineData(data)); + const updatedTotalCostLine = totalCostElementsGroup.selectAll(".total-cost-line-path").data(getTotalСostLineData(data)); + const updatedStatusLine = statusLineElementsGroup.selectAll(".status-line-path").data(getStatusLineData(data)); + const updatedStatusLabels = statusLineElementsGroup.selectAll(".status-line-label").data(getStatusLabelsData(data)); + const updatedBackgroundRectangles = backgroundRectanglesGroup.selectAll(".background-rectangle").data(data); + + updatedSpentLine + .enter() + .append("path") + .attr("class", "spent-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("fill", "none"); + + updatedSpentLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ); + + updatedSpentLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedTotalCostLine + .enter() + .append("path") + .attr("class", "total-cost-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", "4 5") + .style("stroke-miterlimit", 16) + .style("fill", "none"); + + updatedTotalCostLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ); + + updatedTotalCostLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedStatusLine + .enter() + .append("path") + .attr("class", "status-line-path") + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null) + .style("fill", "none"); + + updatedStatusLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null); + + updatedStatusLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedStatusLabels + .enter() + .append("text") + .attr("class", "status-line-label") + .attr("fill", "black") + .attr("text-anchor", "middle") + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)) + .on("mouseover", function (d) { + if (isStatusLabelTruncated(d)) { + statusLabelTooltip.show(d, this); + } + }) + .on("mouseout", function () { + statusLabelTooltip.hide(); + }); + + updatedStatusLabels + .transition() + .duration(1000) + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)); + + updatedStatusLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedSpentLineCircles.enter() + .append("circle") + .attr("class", "spent-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)) + .attr("r", 5) + .attr("fill", '#333333') + .on("mouseover", function (d) { + spentCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + spentCircleTooltip.hide(); + }); + + updatedSpentLineCircles + .transition() + .ease(easeLinear) + .duration(750) + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)); + + updatedSpentLineCircles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedTotalCostCircles.enter() + .append("circle") + .attr("class", "total-cost-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) + .attr("r", 5) + .attr("fill", 'none') + .on("mouseover", function (d) { + totalCostCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + totalCostCircleTooltip.hide(); + }); + + updatedTotalCostCircles + .transition() + .ease(easeLinear) + .duration(750) + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)); + + updatedTotalCostCircles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedAxisLabels.enter() + .append("text") + .attr("class", "axis-tick-label") + .attr("fill", "black") + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .text(d => d.quarter_label); + + updatedAxisLabels + .transition() + .ease(easeLinear) + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .duration(750) + .text(d => d.quarter_label); + + updatedAxisLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedAxisYearLabels.enter() + .append("text") + .attr("class", "axis-tick-year-label") + .attr("fill", "#bcbcbc") + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .text(d => d.financial_year_label); + + updatedAxisYearLabels + .transition() + .ease(easeLinear) + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .duration(750) + .text(d => d.financial_year_label); + + updatedAxisYearLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedBackgroundRectangles + .enter() + .append("rect") + .attr("class", "background-rectangle") + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength) + .attr("fill", 'none') + .on("mouseover", function (d) { + showTooltip(d); + }) + .on("mouseout", function () { + hideTooltip(); + }); + + updatedBackgroundRectangles + .transition() + .ease(easeLinear) + .duration(750) + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength); + + updatedBackgroundRectangles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + }; + + }) + } + + chart.width = function (value) { + if (!arguments.length) return width; + width = value; + return chart; + }; + + chart.height = function (value) { + if (!arguments.length) return height; + height = value; + return chart; + }; + + chart.data = function (value) { + if (!arguments.length) return data; + data = value && value.snapshots ? transformStringDatesToObjects(value.snapshots) : []; + events = value && value.events ? transformStringDatesToObjects(value.events) : []; + if (typeof updateData === 'function') updateData(); + return chart; + }; + + return chart; } diff --git a/src/charts/line/reusable-line-chart/stories.styles.css b/src/charts/line/reusable-line-chart/stories.styles.css index 9cb4f12..319635a 100644 --- a/src/charts/line/reusable-line-chart/stories.styles.css +++ b/src/charts/line/reusable-line-chart/stories.styles.css @@ -125,3 +125,9 @@ font-family: sans-serif; font-size:11px; } + +.event-text{ + font-family: "Font Awesome 5 Free"; + font-size:11px; + fill:white; +} From 21994915c3838ac5acc74dcdfc187de5753f3b63 Mon Sep 17 00:00:00 2001 From: Andrey Zolotin Date: Wed, 27 Nov 2019 22:55:27 +0100 Subject: [PATCH 03/11] feat: event icons --- .../reusable-line-chart.js | 21 +++++++++++-------- .../reusable-line-chart/stories.styles.css | 1 + 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index 6748af4..ce97aeb 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -12,6 +12,14 @@ import d3Tip from "d3-tip"; const margin = {top: 50, right: 50, bottom: 180, left: 60}; const SYMBOL_WIDTH = 7.5; +const LabelToSymbolMap = { + 'project start date': '\uf04b', + 'estimated construction start date': '~ \uf6e3 \uf04b', + 'estimated project completion date': '~ \uf6e3 \uf0c8', + 'contracted construction end date': '\uf304 \uf6e3 \uf0c8', + 'estimated construction end date': '~ \uf6e3 \uf0c8', +}; + function transformStringDatesToObjects(data) { return data.map(d => { return Object.assign(d, { @@ -107,6 +115,7 @@ export function reusableLineChart() { svg.call(spentCircleTooltip); svg.call(totalCostCircleTooltip); svg.call(statusLabelTooltip); + svg.call(eventTooltip); const backgroundRectanglesGroup = svg.append("g") .attr("class", "background-rectangles"); @@ -254,8 +263,10 @@ export function reusableLineChart() { .attr("height", d => 20) .attr("fill", (d, i) => 'rgb(51, 51, 51)') .on("mouseover", function (d) { + eventTooltip.show(d, this); }) .on("mouseout", function () { + eventTooltip.hide(); }); eventsElementsGroups.selectAll('text') @@ -266,17 +277,9 @@ export function reusableLineChart() { .attr("x", 30) .attr("y", 15) .attr('text-anchor', 'middle') - .attr("color", (d, i) => 'white') - .text(function (d) { - return '~ \uf04b \uf6e3 \uf0c8 \uf304' - }) + .text(d => LabelToSymbolMap[d.label.toLowerCase()]) .style("font-weight", 900); - // .on("mouseover", function (d) { - // }) - // .on("mouseout", function () { - // }); - const xAxis = axisBottom(xScale) .tickValues(xDomainValues) .tickFormat('') diff --git a/src/charts/line/reusable-line-chart/stories.styles.css b/src/charts/line/reusable-line-chart/stories.styles.css index 319635a..31aba28 100644 --- a/src/charts/line/reusable-line-chart/stories.styles.css +++ b/src/charts/line/reusable-line-chart/stories.styles.css @@ -130,4 +130,5 @@ font-family: "Font Awesome 5 Free"; font-size:11px; fill:white; + pointer-events: none; } From 95f792c6e1cd28a2852c3dc49ccf7d4fcf776d5f Mon Sep 17 00:00:00 2001 From: Andrey Zolotin Date: Thu, 28 Nov 2019 01:13:51 +0100 Subject: [PATCH 04/11] feat: event overlapping --- .../line/reusable-line-chart/index.stories.js | 4 +-- .../reusable-line-chart.js | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index 57dd730..88ab509 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -53,11 +53,11 @@ const firstData = { "label": "Estimated Construction Start Date" }, { - "date": "2018-11-20", + "date": "2018-10-01", "label": "Estimated Project Completion Date" }, { - "date": "2019-03-15", + "date": "2018-10-19", "label": "Contracted Construction End Date" }, { diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index ce97aeb..ac19af5 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -280,6 +280,18 @@ export function reusableLineChart() { .text(d => LabelToSymbolMap[d.label.toLowerCase()]) .style("font-weight", 900); + eventsElements.selectAll('.event-elements-group').each(function (d) { + const event = select(this); + const allEventsOnTheLeft = eventsElements.selectAll('.event-elements-group') + .filter(function (eventData) { + return d.date > eventData.date; + }); + while (isOverlapping(allEventsOnTheLeft, event)) { + const currentEventTransformation = getTransformation(event.node()); + event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) + } + }); + const xAxis = axisBottom(xScale) .tickValues(xDomainValues) .tickFormat('') @@ -370,6 +382,27 @@ export function reusableLineChart() { } + function isOverlapping(allEventsOnTheLeft, currentEvent) { + let result = false; + allEventsOnTheLeft.each(function (filteredEventData) { + const currentEventTransformation = getTransformation(currentEvent.node()); + const leftEventTransformation = getTransformation(select(this).node()); + if (currentEventTransformation.translateY === leftEventTransformation.translateY + && currentEventTransformation.translateX - leftEventTransformation.translateX < 60) { + result = true; + } + }); + return result; + } + + function getTransformation(node) { + const matrix = node.transform.baseVal.consolidate().matrix; + return { + translateX: matrix.e, + translateY: matrix.f, + }; + } + function getXDomainValues(data) { if (data && data.length > 0) { return data.map(group => new Date(group.date)).sort((a, b) => a - b); From 3ad81b1c093f68bf9dcb463fb7b3e08809a69641 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 28 Nov 2019 08:09:45 +0100 Subject: [PATCH 05/11] feat: step 3 (events) --- .../line/reusable-line-chart/index.stories.js | 2 +- .../reusable-line-chart.js | 1828 +++++++++-------- .../reusable-line-chart/stories.styles.css | 10 +- 3 files changed, 930 insertions(+), 910 deletions(-) diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index 88ab509..43a894b 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -61,7 +61,7 @@ const firstData = { "label": "Contracted Construction End Date" }, { - "date": "2019-06-15", + "date": "2019-05-15", "label": "Estimated Construction End Date" } ] diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index ac19af5..ad127c1 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -13,922 +13,934 @@ const margin = {top: 50, right: 50, bottom: 180, left: 60}; const SYMBOL_WIDTH = 7.5; const LabelToSymbolMap = { - 'project start date': '\uf04b', - 'estimated construction start date': '~ \uf6e3 \uf04b', - 'estimated project completion date': '~ \uf6e3 \uf0c8', - 'contracted construction end date': '\uf304 \uf6e3 \uf0c8', - 'estimated construction end date': '~ \uf6e3 \uf0c8', + 'project start date': '\uf04b', + 'estimated construction start date': '~ \uf6e3 \uf04b', + 'estimated project completion date': '~ \uf6e3 \uf0c8', + 'contracted construction end date': '\uf304 \uf6e3 \uf0c8', + 'estimated construction end date': '~ \uf6e3 \uf0c8', }; function transformStringDatesToObjects(data) { - return data.map(d => { - return Object.assign(d, { - date: new Date(d.date) - }) - }) + return data.map(d => { + return Object.assign(d, { + date: new Date(d.date) + }) + }) } export function reusableLineChart() { - let initialConfiguration = { - width: 850, - height: 550, - spentCircleTooltipFormatter: (d) => { - return `
Total spent:  ${d.data.total_spent_to_date ? "R" + format(",d")(d.data.total_spent_to_date) : 0}
+ let initialConfiguration = { + width: 850, + height: 550, + spentCircleTooltipFormatter: (d) => { + return `
Total spent:  ${d.data.total_spent_to_date ? "R" + format(",d")(d.data.total_spent_to_date) : 0}
Spent in quarter:  ${d.data.total_spent_in_quarter ? "R" + format(",d")(d.data.total_spent_in_quarter) : 0}
`; - }, - totalCostCircleTooltipFormatter: (d) => { - return `
Total project cost: ${d.data.total_estimated_project_cost ? "R" + format('.3s')(d.data.total_estimated_project_cost) : 0}
`; - }, - statusLabelTooltipFormatter: (d) => { - return `
${d.label}
`; - }, - eventTooltipFormatter: (d) => { - return `
${d.label}
`; - } - }; - - let width = initialConfiguration.width, - height = initialConfiguration.height, - data = [], - events = [], - spentCircleTooltipFormatter = initialConfiguration.spentCircleTooltipFormatter, - totalCostCircleTooltipFormatter = initialConfiguration.totalCostCircleTooltipFormatter, - statusLabelTooltipFormatter = initialConfiguration.statusLabelTooltipFormatter, - eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; - let updateData = null; - let correspondingSpentLineCircle, correspondingTotalCostCircle = null; - - function chart(selection) { - selection.each(function () { - let xDomainValues = getXDomainValues(data); - let minimalXDomainValue = min(xDomainValues); - let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); - let yDomainValues = getYDomainValues(data); - const xScaleLength = width - margin.right - margin.left; - const yScaleLength = height - margin.bottom - margin.top; - - const xScale = scaleTime() - .domain([newMinXDomainValue, max(xDomainValues)]) - .range([margin.left, width - margin.right]); - - const yScale = scaleLinear() - .domain([0, max(yDomainValues)]) - .range([height - margin.bottom, margin.top]) - .nice(); - - const svg = selection.append("svg") - .attr("width", width) - .attr("height", height) - .append("g"); - - const spentCircleTooltip = d3Tip() - .attr("class", "d3-tip") - .direction(function (d) { - return d.direction; - }) - .offset(function (d) { - return d.direction === 'n' ? [-8, 0] : [8, 0]; - }) - .html(spentCircleTooltipFormatter); - - const totalCostCircleTooltip = d3Tip() - .attr("class", "d3-tip") - .direction(function (d) { - return d.direction; - }) - .offset(function (d) { - return d.direction === 'n' ? [-8, 0] : [8, 0]; - }) - .html(totalCostCircleTooltipFormatter); - - const statusLabelTooltip = d3Tip() - .attr("class", "d3-tip") - .offset([-8, 0]) - .html(statusLabelTooltipFormatter); - - const eventTooltip = d3Tip() - .attr("class", "d3-tip") - .offset([-8, 0]) - .html(eventTooltipFormatter); - - svg.call(spentCircleTooltip); - svg.call(totalCostCircleTooltip); - svg.call(statusLabelTooltip); - svg.call(eventTooltip); - - const backgroundRectanglesGroup = svg.append("g") - .attr("class", "background-rectangles"); - - backgroundRectanglesGroup.selectAll("rect") - .data(data) - .enter() - .append("rect") - .attr("class", "background-rectangle") - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength) - .attr("fill", 'none') - .on("mouseover", function (d) { - showTooltip(d); - }) - .on("mouseout", function () { - hideTooltip(); - }); - - const statusLineElementsGroup = svg.append("g") - .attr("class", "status-line-elements"); - - statusLineElementsGroup.selectAll("path") - .data(getStatusLineData(data)) - .enter() - .append("path") - .attr("class", "status-line-path") - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null) - .style("fill", "none"); - - statusLineElementsGroup.selectAll('text') - .data(getStatusLabelsData(data)) - .enter() - .append("text") - .attr("class", "status-line-label") - .attr("fill", "black") - .attr("text-anchor", "middle") - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)) - .on("mouseover", function (d) { - if (isStatusLabelTruncated(d)) { - statusLabelTooltip.show(d, this); - } - }) - .on("mouseout", function () { - statusLabelTooltip.hide(); - }); - - const spentLineElementsGroup = svg.append("g") - .attr("class", "spent-line-elements"); - - spentLineElementsGroup.selectAll("path") - .data(getTotalSpentLineData(data)) - .enter() - .append("path") - .attr("class", "spent-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("fill", "none"); - - spentLineElementsGroup.selectAll("circle") - .data(getTotalSpentCircleData(data)) - .enter() - .append("circle") - .attr("class", "spent-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)) - .attr("r", 5) - .attr("fill", '#333333') - .on("mouseover", function (d) { - spentCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - spentCircleTooltip.hide(); - }); - - const totalCostElementsGroup = svg.append("g") - .attr("class", "total-cost-line-elements"); - - totalCostElementsGroup.selectAll("path") - .data(getTotalСostLineData(data)) - .enter() - .append("path") - .attr("class", "total-cost-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", "4 5") - .style("stroke-miterlimit", 16) - .style("fill", "none"); - - totalCostElementsGroup.selectAll("circle") - .data(getTotalСostCircleData(data)) - .enter() - .append("circle") - .attr("class", "total-cost-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) - .attr("r", 5) - .attr("fill", 'none') - .on("mouseover", function (d) { - totalCostCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - totalCostCircleTooltip.hide(); - }); - - const eventsElements = svg.append("g") - .attr("class", "events-elements") - .attr("transform", `translate(0,${(height - margin.bottom + 100)})`); - - const eventsElementsGroups = eventsElements - .selectAll("g") - .data(events) - .enter() - .append("g") - .attr("transform", (d) => `translate(${xScale(d.date)},0)`) - .attr('class', 'event-elements-group'); - - eventsElementsGroups.selectAll('rect') - .data(d => [d]) - .enter() - .append('rect') - .attr('class', 'event-rect') - .attr("x", 0) - .attr("y", 0) - .attr("rx", 5) - .attr("ry", 5) - .attr("width", 60) - .attr("height", d => 20) - .attr("fill", (d, i) => 'rgb(51, 51, 51)') - .on("mouseover", function (d) { - eventTooltip.show(d, this); - }) - .on("mouseout", function () { - eventTooltip.hide(); - }); - - eventsElementsGroups.selectAll('text') - .data(d => [d]) - .enter() - .append('text') - .attr('class', 'event-text') - .attr("x", 30) - .attr("y", 15) - .attr('text-anchor', 'middle') - .text(d => LabelToSymbolMap[d.label.toLowerCase()]) - .style("font-weight", 900); - - eventsElements.selectAll('.event-elements-group').each(function (d) { - const event = select(this); - const allEventsOnTheLeft = eventsElements.selectAll('.event-elements-group') - .filter(function (eventData) { - return d.date > eventData.date; - }); - while (isOverlapping(allEventsOnTheLeft, event)) { - const currentEventTransformation = getTransformation(event.node()); - event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) - } - }); - - const xAxis = axisBottom(xScale) - .tickValues(xDomainValues) - .tickFormat('') - .tickSize(7) - .tickSizeOuter(0); - - const gXAxis = svg.append("g") - .attr("class", "x axis") - .attr("transform", `translate(0,${(height - margin.bottom)})`) - .call(xAxis); - applyAxisStyle(gXAxis); - - gXAxis.selectAll('.axis-tick-label') - .data(data) - .enter() - .append("text") - .attr("class", "axis-tick-label") - .attr("fill", "black") - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .text(d => d.quarter_label); - - gXAxis.selectAll('.axis-tick-year-label') - .data(data) - .enter() - .append("text") - .attr("class", "axis-tick-year-label") - .attr("fill", "#bcbcbc") - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .text(d => d.financial_year_label); - - const yAxisGrid = axisLeft(yScale) - .ticks(4) - .tickFormat('') - .tickSize(-width + margin.left + margin.right); - - const yAxis = axisLeft(yScale) - .ticks(4) - .tickFormat(d => d !== 0 ? `R${format('~s')(d)}` : '') - .tickSizeInner(5) - .tickSizeOuter(0); - - const gYAxisGrid = svg.append("g") - .attr("class", "grid") - .attr("transform", `translate(${margin.left},0)`) - .call(yAxisGrid); - - const gYAxis = svg.append("g") - .attr("class", "y axis") - .attr("transform", `translate(${margin.left},0)`) - .call(yAxis); - - applyGridStyle(gYAxisGrid); - applyAxisStyle(gYAxis, true); - - const legend = svg - .selectAll("legend") - .data([ - {label: 'actual quaterly spend', 'stroke-dasharray': '4 5'}, - {label: 'total estimated project cost'} - ]) - .enter() - .append('g') - .attr("class", "legend") - .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - margin.bottom + 50})`); - - legend.call(appendLegendItem); - - - function appendLegendItem(selection) { - selection.append('rect') - .attr("x", 0) - .attr("y", 0) - .attr("width", 20) - .attr("height", 20); - - selection.append('line') - .attr('x1', 0) - .attr('y1', 10) - .attr('x2', 20) - .attr('y2', 10) - .attr('stroke-dasharray', d => d['stroke-dasharray'] ? d['stroke-dasharray'] : null); - - selection.append('text') - .attr("x", 25) - .attr("y", 15) - .text(d => d.label) - .style("text-anchor", "start"); - - } - - function isOverlapping(allEventsOnTheLeft, currentEvent) { - let result = false; - allEventsOnTheLeft.each(function (filteredEventData) { - const currentEventTransformation = getTransformation(currentEvent.node()); - const leftEventTransformation = getTransformation(select(this).node()); - if (currentEventTransformation.translateY === leftEventTransformation.translateY - && currentEventTransformation.translateX - leftEventTransformation.translateX < 60) { - result = true; - } - }); - return result; - } - - function getTransformation(node) { - const matrix = node.transform.baseVal.consolidate().matrix; - return { - translateX: matrix.e, - translateY: matrix.f, - }; - } - - function getXDomainValues(data) { - if (data && data.length > 0) { - return data.map(group => new Date(group.date)).sort((a, b) => a - b); - } else { - return [new Date(), new Date()] - } - } - - function getYDomainValues(data) { - return data.map(group => [group.total_spent_to_date, group.total_estimated_project_cost]).flat() - .sort((a, b) => a - b); - } - - function getTotalSpentLineData(data) { - const result = []; - if (data && data.length > 0) { - for (let i = 0; i < data.length - 1; i++) { - if (data[i].total_spent_to_date && data[i + 1].total_spent_to_date) { - result.push([data[i], data[i + 1]]); - } - } - return result; - } else { - return []; - } - } - - function getStatusLineData(data) { - let result = []; - if (data && data.length > 0) { - const allStatuses = data.filter(d => !!d.status).map(d => d.status); - const uniqueStatuses = []; - allStatuses.forEach(status => { - if (uniqueStatuses.indexOf(status) === -1) { - uniqueStatuses.push(status); - } - }); - uniqueStatuses.forEach(status => { - const currentStatusPoints = data.filter(d => d.status === status); - result.push([ - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - dotted: true, - start: true - }, - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - dotted: true, - end: true - } - ]); - result.push([ - { - date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - start: true - }, - { - date: currentStatusPoints[currentStatusPoints.length - 1].date, - }, - { - date: currentStatusPoints[currentStatusPoints.length - 1].date, - end: true - } - ]); - }); - return result; - } else { - return []; - } - } - - function getStatusLabelsData(data) { - let result = []; - if (data && data.length > 0) { - const allStatuses = data.filter(d => !!d.status).map(d => d.status); - const uniqueStatuses = []; - allStatuses.forEach(status => { - if (uniqueStatuses.indexOf(status) === -1) { - uniqueStatuses.push(status); - } - }); - uniqueStatuses.forEach(status => { - const currentStatusPoints = data.filter(d => d.status === status); - result.push({ - startDate: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), - endDate: currentStatusPoints[currentStatusPoints.length - 1].date, - label: status - }); - }); - return result; - } else { - return []; - } - } - - function getStatusLabelText(d) { - const placeholderLength = xScale(d.endDate) - xScale(d.startDate) - 20; - return placeholderLength > d.label.length * SYMBOL_WIDTH - ? d.label - : `${d.label.slice(0, placeholderLength / SYMBOL_WIDTH)}...` - } - - function isStatusLabelTruncated(d) { - return xScale(d.endDate) - xScale(d.startDate) - 20 < d.label.length * SYMBOL_WIDTH; - } - - function getTotalSpentCircleData(data) { - return data.filter(d => d.total_spent_to_date !== null); - } - - function getTotalСostCircleData(data) { - return data.filter(d => d.total_estimated_project_cost !== null); - } - - function getTotalСostLineData(data) { - const result = []; - if (data && data.length > 0) { - const firstNonNullPointIndex = data.findIndex(d => d.total_estimated_project_cost !== null); - const firstPoint = { - date: xScale.domain()[0], - total_estimated_project_cost: data[firstNonNullPointIndex].total_estimated_project_cost - }; - result.push([firstPoint, data[firstNonNullPointIndex]]); - for (let i = firstNonNullPointIndex; i < data.length - 1; i++) { - if (data[i + 1].total_estimated_project_cost !== null) { - if (data[i + 1].total_estimated_project_cost !== data[i].total_estimated_project_cost) { - const middlePoint = { - date: data[i].date, - total_estimated_project_cost: data[i + 1].total_estimated_project_cost - }; - result.push([data[i], middlePoint]); - result.push([middlePoint, data[i + 1]]); - } else { - result.push([data[i], data[i + 1]]); - } - } - } - return result; - } else { - return []; - } - } - - function applyAxisStyle(gAxis, yAxis) { - gAxis.selectAll('line') - .style('fill', 'none') - .style('stroke-width', '1') - .style('stroke', yAxis ? 'black' : 'rgba(0, 0, 0, 0.1)') - .style('shape-rendering', 'crispEdges'); - - gAxis.select('path') - .style('fill', 'none') - .style('stroke', 'black') - .style('stroke-width', '1') - .style('shape-rendering', 'crispEdges'); - if (yAxis) { - select(gYAxis.selectAll(".tick").nodes()[0]).attr("visibility", "hidden"); - } - } - - function applyGridStyle(gAxis) { - gAxis.selectAll('line') - .style('fill', 'none') - .style('stroke-width', '1') - .style('stroke', 'rgba(0, 0, 0, 0.1)') - .style('shape-rendering', 'crispEdges'); - - gAxis.select('path') - .style('fill', 'none') - .style('color', 'transparent'); - } - - function showTooltip(d) { - correspondingSpentLineCircle = spentLineElementsGroup.selectAll('circle') - .filter(function (circleData) { - return d.date === circleData.date; - }).nodes()[0]; - correspondingTotalCostCircle = totalCostElementsGroup.selectAll('circle') - .filter(function (circleData) { - return d.date === circleData.date; - }).nodes()[0]; - - let totalCostCircleDirection = 'n'; - let spentLineCircleDirection = 'n'; - if (correspondingSpentLineCircle && correspondingTotalCostCircle) { - const spentLineCircleCY = parseFloat(select(correspondingSpentLineCircle).attr('cy')); - const totalCostCircle = parseFloat(select(correspondingTotalCostCircle).attr('cy')); - if (Math.abs(spentLineCircleCY - totalCostCircle) < 50) { - if (spentLineCircleCY < totalCostCircle) { - totalCostCircleDirection = 's'; - } else { - spentLineCircleDirection = 's'; - } - } - } - - if (correspondingSpentLineCircle) { - select(correspondingSpentLineCircle).attr('fill', 'rgb(0, 137, 123)'); - spentCircleTooltip.show({ - data: d, - direction: spentLineCircleDirection - }, correspondingSpentLineCircle); - } - if (correspondingTotalCostCircle) { - select(correspondingTotalCostCircle).attr('fill', 'rgb(0, 137, 123)'); - totalCostCircleTooltip.show({ - data: d, - direction: totalCostCircleDirection - }, correspondingTotalCostCircle); - } - } - - function hideTooltip() { - if (correspondingSpentLineCircle) { - select(correspondingSpentLineCircle).attr('fill', '#333333'); - spentCircleTooltip.hide(); - } - if (correspondingTotalCostCircle) { - totalCostCircleTooltip.hide(); - select(correspondingTotalCostCircle).attr("fill", 'none'); - } - correspondingSpentLineCircle = null; - correspondingTotalCostCircle = null; - } - - updateData = function () { - xDomainValues = getXDomainValues(data); - let minimalXDomainValue = min(xDomainValues); - let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); - - xScale.domain([newMinXDomainValue, max(xDomainValues)]); - xAxis.scale(xScale).tickValues(xDomainValues); - - yDomainValues = getYDomainValues(data); - yScale.domain([0, max(yDomainValues)]).nice(); - yAxis.scale(yScale); - yAxisGrid.scale(yScale); - - const t = transition() - .duration(750); - - gXAxis.transition(t) - .call(xAxis); - - gYAxis.transition(t) - .call(yAxis); - - gYAxisGrid.transition(t) - .call(yAxisGrid); - - applyAxisStyle(gXAxis); - applyAxisStyle(gYAxis, true); - applyGridStyle(gYAxisGrid); - - const updatedSpentLineCircles = spentLineElementsGroup.selectAll('circle').data(getTotalSpentCircleData(data)); - const updatedTotalCostCircles = totalCostElementsGroup.selectAll('circle').data(getTotalСostCircleData(data)); - const updatedAxisLabels = gXAxis.selectAll('.axis-tick-label').data(data); - const updatedAxisYearLabels = gXAxis.selectAll('.axis-tick-year-label').data(data); - const updatedSpentLine = spentLineElementsGroup.selectAll(".spent-line-path").data(getTotalSpentLineData(data)); - const updatedTotalCostLine = totalCostElementsGroup.selectAll(".total-cost-line-path").data(getTotalСostLineData(data)); - const updatedStatusLine = statusLineElementsGroup.selectAll(".status-line-path").data(getStatusLineData(data)); - const updatedStatusLabels = statusLineElementsGroup.selectAll(".status-line-label").data(getStatusLabelsData(data)); - const updatedBackgroundRectangles = backgroundRectanglesGroup.selectAll(".background-rectangle").data(data); - - updatedSpentLine - .enter() - .append("path") - .attr("class", "spent-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("fill", "none"); - - updatedSpentLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_spent_to_date)) - ); - - updatedSpentLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedTotalCostLine - .enter() - .append("path") - .attr("class", "total-cost-line-path") - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", "4 5") - .style("stroke-miterlimit", 16) - .style("fill", "none"); - - updatedTotalCostLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.total_estimated_project_cost)) - ); - - updatedTotalCostLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedStatusLine - .enter() - .append("path") - .attr("class", "status-line-path") - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .attr("stroke", 'black') - .style("stroke-width", 1) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null) - .style("fill", "none"); - - updatedStatusLine - .transition() - .duration(1000) - .attr("d", line() - .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) - .y((d) => d.end && !d.dotted ? 25 : 10) - ) - .style("stroke-dasharray", d => d[0].dotted ? 2 : null); - - updatedStatusLine.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedStatusLabels - .enter() - .append("text") - .attr("class", "status-line-label") - .attr("fill", "black") - .attr("text-anchor", "middle") - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)) - .on("mouseover", function (d) { - if (isStatusLabelTruncated(d)) { - statusLabelTooltip.show(d, this); - } - }) - .on("mouseout", function () { - statusLabelTooltip.hide(); - }); - - updatedStatusLabels - .transition() - .duration(1000) - .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) - .text(d => getStatusLabelText(d)); - - updatedStatusLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedSpentLineCircles.enter() - .append("circle") - .attr("class", "spent-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)) - .attr("r", 5) - .attr("fill", '#333333') - .on("mouseover", function (d) { - spentCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - spentCircleTooltip.hide(); - }); - - updatedSpentLineCircles - .transition() - .ease(easeLinear) - .duration(750) - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_spent_to_date)); - - updatedSpentLineCircles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedTotalCostCircles.enter() - .append("circle") - .attr("class", "total-cost-line-circle") - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) - .attr("r", 5) - .attr("fill", 'none') - .on("mouseover", function (d) { - totalCostCircleTooltip.show({data: d, direction: 'n'}, this); - }) - .on("mouseout", function () { - totalCostCircleTooltip.hide(); - }); - - updatedTotalCostCircles - .transition() - .ease(easeLinear) - .duration(750) - .attr("cx", (datum) => xScale(datum.date)) - .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)); - - updatedTotalCostCircles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedAxisLabels.enter() - .append("text") - .attr("class", "axis-tick-label") - .attr("fill", "black") - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .text(d => d.quarter_label); - - updatedAxisLabels - .transition() - .ease(easeLinear) - .attr("transform", d => `translate(${xScale(d.date)},20)`) - .duration(750) - .text(d => d.quarter_label); - - updatedAxisLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedAxisYearLabels.enter() - .append("text") - .attr("class", "axis-tick-year-label") - .attr("fill", "#bcbcbc") - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .text(d => d.financial_year_label); - - updatedAxisYearLabels - .transition() - .ease(easeLinear) - .attr("transform", d => `translate(${xScale(d.date)},40)`) - .duration(750) - .text(d => d.financial_year_label); - - updatedAxisYearLabels.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - updatedBackgroundRectangles - .enter() - .append("rect") - .attr("class", "background-rectangle") - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength) - .attr("fill", 'none') - .on("mouseover", function (d) { - showTooltip(d); - }) - .on("mouseout", function () { - hideTooltip(); - }); - - updatedBackgroundRectangles - .transition() - .ease(easeLinear) - .duration(750) - .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) - .attr("y", yScale.range()[1]) - .attr("width", xScaleLength / data.length) - .attr("height", () => yScaleLength); - - updatedBackgroundRectangles.exit() - .transition() - .ease(easeLinear) - .duration(100) - .remove(); - - }; - - }) - } - - chart.width = function (value) { - if (!arguments.length) return width; - width = value; - return chart; - }; - - chart.height = function (value) { - if (!arguments.length) return height; - height = value; - return chart; - }; - - chart.data = function (value) { - if (!arguments.length) return data; - data = value && value.snapshots ? transformStringDatesToObjects(value.snapshots) : []; - events = value && value.events ? transformStringDatesToObjects(value.events) : []; - if (typeof updateData === 'function') updateData(); - return chart; - }; - - return chart; + }, + totalCostCircleTooltipFormatter: (d) => { + return `
Total project cost: ${d.data.total_estimated_project_cost ? "R" + format('.3s')(d.data.total_estimated_project_cost) : 0}
`; + }, + statusLabelTooltipFormatter: (d) => { + return `
${d.label}
`; + }, + eventTooltipFormatter: (d) => { + return `
${d.label}
`; + } + }; + + let width = initialConfiguration.width, + height = initialConfiguration.height, + data = [], + events = [], + spentCircleTooltipFormatter = initialConfiguration.spentCircleTooltipFormatter, + totalCostCircleTooltipFormatter = initialConfiguration.totalCostCircleTooltipFormatter, + statusLabelTooltipFormatter = initialConfiguration.statusLabelTooltipFormatter, + eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; + let updateData = null; + let correspondingSpentLineCircle, correspondingTotalCostCircle = null; + + function chart(selection) { + selection.each(function () { + let xDomainValues = getXDomainValues(data); + let minimalXDomainValue = min(xDomainValues); + let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); + let yDomainValues = getYDomainValues(data); + const xScaleLength = width - margin.right - margin.left; + const yScaleLength = height - margin.bottom - margin.top; + + const xScale = scaleTime() + .domain([newMinXDomainValue, max(xDomainValues)]) + .range([margin.left, width - margin.right]); + + const yScale = scaleLinear() + .domain([0, max(yDomainValues)]) + .range([height - margin.bottom, margin.top]) + .nice(); + + const svg = selection.append("svg") + .attr("width", width) + .attr("height", height) + .append("g"); + + const spentCircleTooltip = d3Tip() + .attr("class", "d3-tip") + .direction(function (d) { + return d.direction; + }) + .offset(function (d) { + return d.direction === 'n' ? [-8, 0] : [8, 0]; + }) + .html(spentCircleTooltipFormatter); + + const totalCostCircleTooltip = d3Tip() + .attr("class", "d3-tip") + .direction(function (d) { + return d.direction; + }) + .offset(function (d) { + return d.direction === 'n' ? [-8, 0] : [8, 0]; + }) + .html(totalCostCircleTooltipFormatter); + + const statusLabelTooltip = d3Tip() + .attr("class", "d3-tip") + .offset([-8, 0]) + .html(statusLabelTooltipFormatter); + + const eventTooltip = d3Tip() + .attr("class", "d3-tip") + .offset([-8, 0]) + .html(eventTooltipFormatter); + + svg.call(spentCircleTooltip); + svg.call(totalCostCircleTooltip); + svg.call(statusLabelTooltip); + svg.call(eventTooltip); + + const backgroundRectanglesGroup = svg.append("g") + .attr("class", "background-rectangles"); + + backgroundRectanglesGroup.selectAll("rect") + .data(data) + .enter() + .append("rect") + .attr("class", "background-rectangle") + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength) + .attr("fill", 'none') + .on("mouseover", function (d) { + showTooltip(d); + }) + .on("mouseout", function () { + hideTooltip(); + }); + + const statusLineElementsGroup = svg.append("g") + .attr("class", "status-line-elements"); + + statusLineElementsGroup.selectAll("path") + .data(getStatusLineData(data)) + .enter() + .append("path") + .attr("class", "status-line-path") + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null) + .style("fill", "none"); + + statusLineElementsGroup.selectAll('text') + .data(getStatusLabelsData(data)) + .enter() + .append("text") + .attr("class", "status-line-label") + .attr("fill", "black") + .attr("text-anchor", "middle") + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)) + .on("mouseover", function (d) { + if (isStatusLabelTruncated(d)) { + statusLabelTooltip.show(d, this); + } + }) + .on("mouseout", function () { + statusLabelTooltip.hide(); + }); + + const spentLineElementsGroup = svg.append("g") + .attr("class", "spent-line-elements"); + + spentLineElementsGroup.selectAll("path") + .data(getTotalSpentLineData(data)) + .enter() + .append("path") + .attr("class", "spent-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("fill", "none"); + + spentLineElementsGroup.selectAll("circle") + .data(getTotalSpentCircleData(data)) + .enter() + .append("circle") + .attr("class", "spent-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)) + .attr("r", 5) + .attr("fill", '#333333') + .on("mouseover", function (d) { + spentCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + spentCircleTooltip.hide(); + }); + + const totalCostElementsGroup = svg.append("g") + .attr("class", "total-cost-line-elements"); + + totalCostElementsGroup.selectAll("path") + .data(getTotalСostLineData(data)) + .enter() + .append("path") + .attr("class", "total-cost-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", "4 5") + .style("stroke-miterlimit", 16) + .style("fill", "none"); + + totalCostElementsGroup.selectAll("circle") + .data(getTotalСostCircleData(data)) + .enter() + .append("circle") + .attr("class", "total-cost-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) + .attr("r", 5) + .attr("fill", 'none') + .on("mouseover", function (d) { + totalCostCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + totalCostCircleTooltip.hide(); + }); + + const eventsElements = svg.append("g") + .attr("class", "events-elements") + .attr("transform", `translate(0,${(height - margin.bottom + 50)})`); + + const eventsElementsGroups = eventsElements + .selectAll("g") + .data(events) + .enter() + .append("g") + .attr("transform", (d) => `translate(${xScale(d.date)},0)`) + .attr('class', 'event-elements-group'); + + eventsElementsGroups.selectAll('rect') + .data(d => [d]) + .enter() + .append('rect') + .attr('class', 'event-rect') + .attr("x", 0) + .attr("y", 0) + .attr("rx", 5) + .attr("ry", 5) + .attr("width", 60) + .attr("height", d => 20) + .attr("fill", (d, i) => 'rgb(51, 51, 51)') + .on("mouseover", function (d) { + eventTooltip.show(d, this); + }) + .on("mouseout", function () { + eventTooltip.hide(); + }); + + eventsElementsGroups.selectAll('text') + .data(d => [d]) + .enter() + .append('text') + .attr('class', 'event-text') + .attr("x", 30) + .attr("y", 15) + .attr('text-anchor', 'middle') + .text(d => LabelToSymbolMap[d.label.toLowerCase()]) + .style("font-weight", 900); + + eventsElements.selectAll('.event-elements-group').each(function (d) { + const event = select(this); + const allEventsOnTheLeft = eventsElements.selectAll('.event-elements-group') + .filter(function (eventData) { + return d.date > eventData.date; + }); + while (isOverlapping(allEventsOnTheLeft, event)) { + const currentEventTransformation = getTransformation(event.node()); + event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) + } + }); + + eventsElementsGroups.selectAll('line') + .data(d => [d]) + .enter() + .append('line') + .attr('x1', 30) + .attr('y1', 0) + .attr('x2', 30) + .attr('y2', function(){ + const gTransformation = getTransformation(select(this).node().parentNode); + return -(height - margin.bottom + 50-margin.top + gTransformation.translateY); + }); + + const xAxis = axisBottom(xScale) + .tickValues(xDomainValues) + .tickFormat('') + .tickSize(7) + .tickSizeOuter(0); + + const gXAxis = svg.append("g") + .attr("class", "x axis") + .attr("transform", `translate(0,${(height - margin.bottom)})`) + .call(xAxis); + applyAxisStyle(gXAxis); + + gXAxis.selectAll('.axis-tick-label') + .data(data) + .enter() + .append("text") + .attr("class", "axis-tick-label") + .attr("fill", "black") + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .text(d => d.quarter_label); + + gXAxis.selectAll('.axis-tick-year-label') + .data(data) + .enter() + .append("text") + .attr("class", "axis-tick-year-label") + .attr("fill", "#bcbcbc") + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .text(d => d.financial_year_label); + + const yAxisGrid = axisLeft(yScale) + .ticks(4) + .tickFormat('') + .tickSize(-width + margin.left + margin.right); + + const yAxis = axisLeft(yScale) + .ticks(4) + .tickFormat(d => d !== 0 ? `R${format('~s')(d)}` : '') + .tickSizeInner(5) + .tickSizeOuter(0); + + const gYAxisGrid = svg.append("g") + .attr("class", "grid") + .attr("transform", `translate(${margin.left},0)`) + .call(yAxisGrid); + + const gYAxis = svg.append("g") + .attr("class", "y axis") + .attr("transform", `translate(${margin.left},0)`) + .call(yAxis); + + applyGridStyle(gYAxisGrid); + applyAxisStyle(gYAxis, true); + + const legend = svg + .selectAll("legend") + .data([ + {label: 'actual quaterly spend', 'stroke-dasharray': '4 5'}, + {label: 'total estimated project cost'} + ]) + .enter() + .append('g') + .attr("class", "legend") + .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - 50})`); + + legend.call(appendLegendItem); + + + function appendLegendItem(selection) { + selection.append('rect') + .attr("x", 0) + .attr("y", 0) + .attr("width", 20) + .attr("height", 20); + + selection.append('line') + .attr('x1', 0) + .attr('y1', 10) + .attr('x2', 20) + .attr('y2', 10) + .attr('stroke-dasharray', d => d['stroke-dasharray'] ? d['stroke-dasharray'] : null); + + selection.append('text') + .attr("x", 25) + .attr("y", 15) + .text(d => d.label) + .style("text-anchor", "start"); + + } + + function isOverlapping(allEventsOnTheLeft, currentEvent) { + let result = false; + allEventsOnTheLeft.each(function (filteredEventData) { + const currentEventTransformation = getTransformation(currentEvent.node()); + const leftEventTransformation = getTransformation(select(this).node()); + if (currentEventTransformation.translateY === leftEventTransformation.translateY + && currentEventTransformation.translateX - leftEventTransformation.translateX < 60) { + result = true; + } + }); + return result; + } + + function getTransformation(node) { + const matrix = node.transform.baseVal.consolidate().matrix; + return { + translateX: matrix.e, + translateY: matrix.f, + }; + } + + function getXDomainValues(data) { + if (data && data.length > 0) { + return data.map(group => new Date(group.date)).sort((a, b) => a - b); + } else { + return [new Date(), new Date()] + } + } + + function getYDomainValues(data) { + return data.map(group => [group.total_spent_to_date, group.total_estimated_project_cost]).flat() + .sort((a, b) => a - b); + } + + function getTotalSpentLineData(data) { + const result = []; + if (data && data.length > 0) { + for (let i = 0; i < data.length - 1; i++) { + if (data[i].total_spent_to_date && data[i + 1].total_spent_to_date) { + result.push([data[i], data[i + 1]]); + } + } + return result; + } else { + return []; + } + } + + function getStatusLineData(data) { + let result = []; + if (data && data.length > 0) { + const allStatuses = data.filter(d => !!d.status).map(d => d.status); + const uniqueStatuses = []; + allStatuses.forEach(status => { + if (uniqueStatuses.indexOf(status) === -1) { + uniqueStatuses.push(status); + } + }); + uniqueStatuses.forEach(status => { + const currentStatusPoints = data.filter(d => d.status === status); + result.push([ + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + dotted: true, + start: true + }, + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + dotted: true, + end: true + } + ]); + result.push([ + { + date: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + start: true + }, + { + date: currentStatusPoints[currentStatusPoints.length - 1].date, + }, + { + date: currentStatusPoints[currentStatusPoints.length - 1].date, + end: true + } + ]); + }); + return result; + } else { + return []; + } + } + + function getStatusLabelsData(data) { + let result = []; + if (data && data.length > 0) { + const allStatuses = data.filter(d => !!d.status).map(d => d.status); + const uniqueStatuses = []; + allStatuses.forEach(status => { + if (uniqueStatuses.indexOf(status) === -1) { + uniqueStatuses.push(status); + } + }); + uniqueStatuses.forEach(status => { + const currentStatusPoints = data.filter(d => d.status === status); + result.push({ + startDate: new Date(currentStatusPoints[0].date).setMonth(currentStatusPoints[0].date.getMonth() - 3), + endDate: currentStatusPoints[currentStatusPoints.length - 1].date, + label: status + }); + }); + return result; + } else { + return []; + } + } + + function getStatusLabelText(d) { + const placeholderLength = xScale(d.endDate) - xScale(d.startDate) - 20; + return placeholderLength > d.label.length * SYMBOL_WIDTH + ? d.label + : `${d.label.slice(0, placeholderLength / SYMBOL_WIDTH)}...` + } + + function isStatusLabelTruncated(d) { + return xScale(d.endDate) - xScale(d.startDate) - 20 < d.label.length * SYMBOL_WIDTH; + } + + function getTotalSpentCircleData(data) { + return data.filter(d => d.total_spent_to_date !== null); + } + + function getTotalСostCircleData(data) { + return data.filter(d => d.total_estimated_project_cost !== null); + } + + function getTotalСostLineData(data) { + const result = []; + if (data && data.length > 0) { + const firstNonNullPointIndex = data.findIndex(d => d.total_estimated_project_cost !== null); + const firstPoint = { + date: xScale.domain()[0], + total_estimated_project_cost: data[firstNonNullPointIndex].total_estimated_project_cost + }; + result.push([firstPoint, data[firstNonNullPointIndex]]); + for (let i = firstNonNullPointIndex; i < data.length - 1; i++) { + if (data[i + 1].total_estimated_project_cost !== null) { + if (data[i + 1].total_estimated_project_cost !== data[i].total_estimated_project_cost) { + const middlePoint = { + date: data[i].date, + total_estimated_project_cost: data[i + 1].total_estimated_project_cost + }; + result.push([data[i], middlePoint]); + result.push([middlePoint, data[i + 1]]); + } else { + result.push([data[i], data[i + 1]]); + } + } + } + return result; + } else { + return []; + } + } + + function applyAxisStyle(gAxis, yAxis) { + gAxis.selectAll('line') + .style('fill', 'none') + .style('stroke-width', '1') + .style('stroke', yAxis ? 'black' : 'rgba(0, 0, 0, 0.1)') + .style('shape-rendering', 'crispEdges'); + + gAxis.select('path') + .style('fill', 'none') + .style('stroke', 'black') + .style('stroke-width', '1') + .style('shape-rendering', 'crispEdges'); + if (yAxis) { + select(gYAxis.selectAll(".tick").nodes()[0]).attr("visibility", "hidden"); + } + } + + function applyGridStyle(gAxis) { + gAxis.selectAll('line') + .style('fill', 'none') + .style('stroke-width', '1') + .style('stroke', 'rgba(0, 0, 0, 0.1)') + .style('shape-rendering', 'crispEdges'); + + gAxis.select('path') + .style('fill', 'none') + .style('color', 'transparent'); + } + + function showTooltip(d) { + correspondingSpentLineCircle = spentLineElementsGroup.selectAll('circle') + .filter(function (circleData) { + return d.date === circleData.date; + }).nodes()[0]; + correspondingTotalCostCircle = totalCostElementsGroup.selectAll('circle') + .filter(function (circleData) { + return d.date === circleData.date; + }).nodes()[0]; + + let totalCostCircleDirection = 'n'; + let spentLineCircleDirection = 'n'; + if (correspondingSpentLineCircle && correspondingTotalCostCircle) { + const spentLineCircleCY = parseFloat(select(correspondingSpentLineCircle).attr('cy')); + const totalCostCircle = parseFloat(select(correspondingTotalCostCircle).attr('cy')); + if (Math.abs(spentLineCircleCY - totalCostCircle) < 50) { + if (spentLineCircleCY < totalCostCircle) { + totalCostCircleDirection = 's'; + } else { + spentLineCircleDirection = 's'; + } + } + } + + if (correspondingSpentLineCircle) { + select(correspondingSpentLineCircle).attr('fill', 'rgb(0, 137, 123)'); + spentCircleTooltip.show({ + data: d, + direction: spentLineCircleDirection + }, correspondingSpentLineCircle); + } + if (correspondingTotalCostCircle) { + select(correspondingTotalCostCircle).attr('fill', 'rgb(0, 137, 123)'); + totalCostCircleTooltip.show({ + data: d, + direction: totalCostCircleDirection + }, correspondingTotalCostCircle); + } + } + + function hideTooltip() { + if (correspondingSpentLineCircle) { + select(correspondingSpentLineCircle).attr('fill', '#333333'); + spentCircleTooltip.hide(); + } + if (correspondingTotalCostCircle) { + totalCostCircleTooltip.hide(); + select(correspondingTotalCostCircle).attr("fill", 'none'); + } + correspondingSpentLineCircle = null; + correspondingTotalCostCircle = null; + } + + updateData = function () { + xDomainValues = getXDomainValues(data); + let minimalXDomainValue = min(xDomainValues); + let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); + + xScale.domain([newMinXDomainValue, max(xDomainValues)]); + xAxis.scale(xScale).tickValues(xDomainValues); + + yDomainValues = getYDomainValues(data); + yScale.domain([0, max(yDomainValues)]).nice(); + yAxis.scale(yScale); + yAxisGrid.scale(yScale); + + const t = transition() + .duration(750); + + gXAxis.transition(t) + .call(xAxis); + + gYAxis.transition(t) + .call(yAxis); + + gYAxisGrid.transition(t) + .call(yAxisGrid); + + applyAxisStyle(gXAxis); + applyAxisStyle(gYAxis, true); + applyGridStyle(gYAxisGrid); + + const updatedSpentLineCircles = spentLineElementsGroup.selectAll('circle').data(getTotalSpentCircleData(data)); + const updatedTotalCostCircles = totalCostElementsGroup.selectAll('circle').data(getTotalСostCircleData(data)); + const updatedAxisLabels = gXAxis.selectAll('.axis-tick-label').data(data); + const updatedAxisYearLabels = gXAxis.selectAll('.axis-tick-year-label').data(data); + const updatedSpentLine = spentLineElementsGroup.selectAll(".spent-line-path").data(getTotalSpentLineData(data)); + const updatedTotalCostLine = totalCostElementsGroup.selectAll(".total-cost-line-path").data(getTotalСostLineData(data)); + const updatedStatusLine = statusLineElementsGroup.selectAll(".status-line-path").data(getStatusLineData(data)); + const updatedStatusLabels = statusLineElementsGroup.selectAll(".status-line-label").data(getStatusLabelsData(data)); + const updatedBackgroundRectangles = backgroundRectanglesGroup.selectAll(".background-rectangle").data(data); + + updatedSpentLine + .enter() + .append("path") + .attr("class", "spent-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("fill", "none"); + + updatedSpentLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_spent_to_date)) + ); + + updatedSpentLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedTotalCostLine + .enter() + .append("path") + .attr("class", "total-cost-line-path") + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", "4 5") + .style("stroke-miterlimit", 16) + .style("fill", "none"); + + updatedTotalCostLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.total_estimated_project_cost)) + ); + + updatedTotalCostLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedStatusLine + .enter() + .append("path") + .attr("class", "status-line-path") + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .attr("stroke", 'black') + .style("stroke-width", 1) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null) + .style("fill", "none"); + + updatedStatusLine + .transition() + .duration(1000) + .attr("d", line() + .x((d) => d.start && !d.dotted || d.end && d.dotted ? xScale(d.date) + 20 : xScale(d.date)) + .y((d) => d.end && !d.dotted ? 25 : 10) + ) + .style("stroke-dasharray", d => d[0].dotted ? 2 : null); + + updatedStatusLine.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedStatusLabels + .enter() + .append("text") + .attr("class", "status-line-label") + .attr("fill", "black") + .attr("text-anchor", "middle") + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)) + .on("mouseover", function (d) { + if (isStatusLabelTruncated(d)) { + statusLabelTooltip.show(d, this); + } + }) + .on("mouseout", function () { + statusLabelTooltip.hide(); + }); + + updatedStatusLabels + .transition() + .duration(1000) + .attr("transform", d => `translate(${(xScale(d.startDate) + xScale(d.endDate)) / 2 + 10},22)`) + .text(d => getStatusLabelText(d)); + + updatedStatusLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedSpentLineCircles.enter() + .append("circle") + .attr("class", "spent-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)) + .attr("r", 5) + .attr("fill", '#333333') + .on("mouseover", function (d) { + spentCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + spentCircleTooltip.hide(); + }); + + updatedSpentLineCircles + .transition() + .ease(easeLinear) + .duration(750) + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_spent_to_date)); + + updatedSpentLineCircles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedTotalCostCircles.enter() + .append("circle") + .attr("class", "total-cost-line-circle") + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)) + .attr("r", 5) + .attr("fill", 'none') + .on("mouseover", function (d) { + totalCostCircleTooltip.show({data: d, direction: 'n'}, this); + }) + .on("mouseout", function () { + totalCostCircleTooltip.hide(); + }); + + updatedTotalCostCircles + .transition() + .ease(easeLinear) + .duration(750) + .attr("cx", (datum) => xScale(datum.date)) + .attr("cy", (datum) => yScale(datum.total_estimated_project_cost)); + + updatedTotalCostCircles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedAxisLabels.enter() + .append("text") + .attr("class", "axis-tick-label") + .attr("fill", "black") + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .text(d => d.quarter_label); + + updatedAxisLabels + .transition() + .ease(easeLinear) + .attr("transform", d => `translate(${xScale(d.date)},20)`) + .duration(750) + .text(d => d.quarter_label); + + updatedAxisLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedAxisYearLabels.enter() + .append("text") + .attr("class", "axis-tick-year-label") + .attr("fill", "#bcbcbc") + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .text(d => d.financial_year_label); + + updatedAxisYearLabels + .transition() + .ease(easeLinear) + .attr("transform", d => `translate(${xScale(d.date)},40)`) + .duration(750) + .text(d => d.financial_year_label); + + updatedAxisYearLabels.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + updatedBackgroundRectangles + .enter() + .append("rect") + .attr("class", "background-rectangle") + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength) + .attr("fill", 'none') + .on("mouseover", function (d) { + showTooltip(d); + }) + .on("mouseout", function () { + hideTooltip(); + }); + + updatedBackgroundRectangles + .transition() + .ease(easeLinear) + .duration(750) + .attr("x", (datum) => xScale(datum.date) - xScaleLength / data.length) + .attr("y", yScale.range()[1]) + .attr("width", xScaleLength / data.length) + .attr("height", () => yScaleLength); + + updatedBackgroundRectangles.exit() + .transition() + .ease(easeLinear) + .duration(100) + .remove(); + + }; + + }) + } + + chart.width = function (value) { + if (!arguments.length) return width; + width = value; + return chart; + }; + + chart.height = function (value) { + if (!arguments.length) return height; + height = value; + return chart; + }; + + chart.data = function (value) { + if (!arguments.length) return data; + data = value && value.snapshots ? transformStringDatesToObjects(value.snapshots) : []; + events = value && value.events ? transformStringDatesToObjects(value.events) : []; + if (typeof updateData === 'function') updateData(); + return chart; + }; + + return chart; } diff --git a/src/charts/line/reusable-line-chart/stories.styles.css b/src/charts/line/reusable-line-chart/stories.styles.css index 31aba28..c3394fd 100644 --- a/src/charts/line/reusable-line-chart/stories.styles.css +++ b/src/charts/line/reusable-line-chart/stories.styles.css @@ -1,7 +1,7 @@ .container { border: 1px solid black; width: 850px; - height: 450px; + height: fit-content; margin-top: 50px; margin-left: 50px; } @@ -132,3 +132,11 @@ fill:white; pointer-events: none; } + + +.event-elements-group > line{ + stroke: rgba(0, 0, 0, 0.3); + stroke-width: 1px; + stroke-dasharray: 3; +} + From a95a64d40c31b56937c68178068db6e02a273e88 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 28 Nov 2019 08:31:14 +0100 Subject: [PATCH 06/11] feat: step 3 (events) --- .../line/reusable-line-chart/index.stories.js | 22 +++ .../reusable-line-chart.js | 155 +++++++++--------- 2 files changed, 102 insertions(+), 75 deletions(-) diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index 43a894b..ec36733 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -114,6 +114,28 @@ const secondData = { "total_estimated_project_cost": 3650000, "status": "Other - Packaged Ongoing Project" } + ], + events: [ + { + "date": "2018-07-20", + "label": "Project Start Date" + }, + { + "date": "2018-10-20", + "label": "Estimated Construction Start Date" + }, + { + "date": "2018-12-21", + "label": "Estimated Project Completion Date" + }, + { + "date": "2019-03-19", + "label": "Contracted Construction End Date" + }, + { + "date": "2019-05-15", + "label": "Estimated Construction End Date" + } ] }; diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index ad127c1..66976b4 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -9,7 +9,6 @@ import {format} from 'd3-format'; import {line} from 'd3-shape'; import d3Tip from "d3-tip"; -const margin = {top: 50, right: 50, bottom: 180, left: 60}; const SYMBOL_WIDTH = 7.5; const LabelToSymbolMap = { @@ -58,6 +57,7 @@ export function reusableLineChart() { eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; let updateData = null; let correspondingSpentLineCircle, correspondingTotalCostCircle = null; + const margin = {top: 50, right: 50, bottom: 100, left: 60, extraBottom: 0}; function chart(selection) { selection.each(function () { @@ -66,17 +66,12 @@ export function reusableLineChart() { let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); let yDomainValues = getYDomainValues(data); const xScaleLength = width - margin.right - margin.left; - const yScaleLength = height - margin.bottom - margin.top; + const yScaleLength = height - margin.bottom - margin.extraBottom - margin.top; const xScale = scaleTime() .domain([newMinXDomainValue, max(xDomainValues)]) .range([margin.left, width - margin.right]); - const yScale = scaleLinear() - .domain([0, max(yDomainValues)]) - .range([height - margin.bottom, margin.top]) - .nice(); - const svg = selection.append("svg") .attr("width", width) .attr("height", height) @@ -117,6 +112,80 @@ export function reusableLineChart() { svg.call(statusLabelTooltip); svg.call(eventTooltip); + const eventsElements = svg.append("g") + .attr("class", "events-elements") + .attr("transform", `translate(0,${(height - margin.bottom + 50)})`); + + const eventsElementsGroups = eventsElements + .selectAll("g") + .data(events) + .enter() + .append("g") + .attr("transform", (d) => `translate(${xScale(d.date)},0)`) + .attr('class', 'event-elements-group'); + + eventsElementsGroups.selectAll('rect') + .data(d => [d]) + .enter() + .append('rect') + .attr('class', 'event-rect') + .attr("x", 0) + .attr("y", 0) + .attr("rx", 5) + .attr("ry", 5) + .attr("width", 60) + .attr("height", d => 20) + .attr("fill", (d, i) => 'rgb(51, 51, 51)') + .on("mouseover", function (d) { + eventTooltip.show(d, this); + }) + .on("mouseout", function () { + eventTooltip.hide(); + }); + + eventsElementsGroups.selectAll('text') + .data(d => [d]) + .enter() + .append('text') + .attr('class', 'event-text') + .attr("x", 30) + .attr("y", 15) + .attr('text-anchor', 'middle') + .text(d => LabelToSymbolMap[d.label.toLowerCase()]) + .style("font-weight", 900); + + eventsElements.selectAll('.event-elements-group').each(function (d) { + const event = select(this); + const allEventsOnTheLeft = eventsElements.selectAll('.event-elements-group') + .filter(function (eventData) { + return d.date > eventData.date; + }); + while (isOverlapping(allEventsOnTheLeft, event)) { + const currentEventTransformation = getTransformation(event.node()); + event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) + margin.extraBottom = Math.max(margin.extraBottom, currentEventTransformation.translateY + 21); + } + }); + + eventsElementsGroups.selectAll('line') + .data(d => [d]) + .enter() + .append('line') + .attr('x1', 30) + .attr('y1', 0) + .attr('x2', 30) + .attr('y2', function () { + const gTransformation = getTransformation(select(this).node().parentNode); + return -(height - margin.bottom + 50 - margin.top + gTransformation.translateY); + }); + + eventsElements.attr("transform", `translate(0,${(height - margin.bottom - margin.extraBottom + 50)})`); + + const yScale = scaleLinear() + .domain([0, max(yDomainValues)]) + .range([height - margin.bottom - margin.extraBottom, margin.top]) + .nice(); + const backgroundRectanglesGroup = svg.append("g") .attr("class", "background-rectangles"); @@ -238,72 +307,6 @@ export function reusableLineChart() { totalCostCircleTooltip.hide(); }); - const eventsElements = svg.append("g") - .attr("class", "events-elements") - .attr("transform", `translate(0,${(height - margin.bottom + 50)})`); - - const eventsElementsGroups = eventsElements - .selectAll("g") - .data(events) - .enter() - .append("g") - .attr("transform", (d) => `translate(${xScale(d.date)},0)`) - .attr('class', 'event-elements-group'); - - eventsElementsGroups.selectAll('rect') - .data(d => [d]) - .enter() - .append('rect') - .attr('class', 'event-rect') - .attr("x", 0) - .attr("y", 0) - .attr("rx", 5) - .attr("ry", 5) - .attr("width", 60) - .attr("height", d => 20) - .attr("fill", (d, i) => 'rgb(51, 51, 51)') - .on("mouseover", function (d) { - eventTooltip.show(d, this); - }) - .on("mouseout", function () { - eventTooltip.hide(); - }); - - eventsElementsGroups.selectAll('text') - .data(d => [d]) - .enter() - .append('text') - .attr('class', 'event-text') - .attr("x", 30) - .attr("y", 15) - .attr('text-anchor', 'middle') - .text(d => LabelToSymbolMap[d.label.toLowerCase()]) - .style("font-weight", 900); - - eventsElements.selectAll('.event-elements-group').each(function (d) { - const event = select(this); - const allEventsOnTheLeft = eventsElements.selectAll('.event-elements-group') - .filter(function (eventData) { - return d.date > eventData.date; - }); - while (isOverlapping(allEventsOnTheLeft, event)) { - const currentEventTransformation = getTransformation(event.node()); - event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) - } - }); - - eventsElementsGroups.selectAll('line') - .data(d => [d]) - .enter() - .append('line') - .attr('x1', 30) - .attr('y1', 0) - .attr('x2', 30) - .attr('y2', function(){ - const gTransformation = getTransformation(select(this).node().parentNode); - return -(height - margin.bottom + 50-margin.top + gTransformation.translateY); - }); - const xAxis = axisBottom(xScale) .tickValues(xDomainValues) .tickFormat('') @@ -312,7 +315,7 @@ export function reusableLineChart() { const gXAxis = svg.append("g") .attr("class", "x axis") - .attr("transform", `translate(0,${(height - margin.bottom)})`) + .attr("transform", `translate(0,${(height - margin.bottom - margin.extraBottom)})`) .call(xAxis); applyAxisStyle(gXAxis); @@ -367,7 +370,7 @@ export function reusableLineChart() { .enter() .append('g') .attr("class", "legend") - .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - 50})`); + .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - 20})`); legend.call(appendLegendItem); @@ -650,6 +653,8 @@ export function reusableLineChart() { yDomainValues = getYDomainValues(data); yScale.domain([0, max(yDomainValues)]).nice(); + console.log(margin.extraBottom); + yScale.range([height - margin.bottom - margin.extraBottom, margin.top]); yAxis.scale(yScale); yAxisGrid.scale(yScale); From 17bbd26b73376719d1ac1fffa6141116ef9245c0 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 28 Nov 2019 08:51:00 +0100 Subject: [PATCH 07/11] feat: step 3 dynamic height --- .../reusable-line-chart.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index 66976b4..0e7e109 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -31,7 +31,7 @@ export function reusableLineChart() { let initialConfiguration = { width: 850, - height: 550, + height: 450, spentCircleTooltipFormatter: (d) => { return `
Total spent:  ${d.data.total_spent_to_date ? "R" + format(",d")(d.data.total_spent_to_date) : 0}
Spent in quarter:  ${d.data.total_spent_in_quarter ? "R" + format(",d")(d.data.total_spent_in_quarter) : 0}
`; @@ -65,8 +65,6 @@ export function reusableLineChart() { let minimalXDomainValue = min(xDomainValues); let newMinXDomainValue = new Date(minimalXDomainValue).setMonth(minimalXDomainValue.getMonth() - 3); let yDomainValues = getYDomainValues(data); - const xScaleLength = width - margin.right - margin.left; - const yScaleLength = height - margin.bottom - margin.extraBottom - margin.top; const xScale = scaleTime() .domain([newMinXDomainValue, max(xDomainValues)]) @@ -75,7 +73,6 @@ export function reusableLineChart() { const svg = selection.append("svg") .attr("width", width) .attr("height", height) - .append("g"); const spentCircleTooltip = d3Tip() .attr("class", "d3-tip") @@ -114,7 +111,7 @@ export function reusableLineChart() { const eventsElements = svg.append("g") .attr("class", "events-elements") - .attr("transform", `translate(0,${(height - margin.bottom + 50)})`); + .attr("transform", `translate(0,${(height - 30)})`); const eventsElementsGroups = eventsElements .selectAll("g") @@ -179,12 +176,15 @@ export function reusableLineChart() { return -(height - margin.bottom + 50 - margin.top + gTransformation.translateY); }); - eventsElements.attr("transform", `translate(0,${(height - margin.bottom - margin.extraBottom + 50)})`); + eventsElements.attr("transform", `translate(0,${(height - margin.bottom + 50)})`); + svg.attr("height", height + margin.extraBottom); const yScale = scaleLinear() .domain([0, max(yDomainValues)]) - .range([height - margin.bottom - margin.extraBottom, margin.top]) + .range([height - margin.bottom, margin.top]) .nice(); + const xScaleLength = width - margin.right - margin.left; + const yScaleLength = height - margin.bottom - margin.top; const backgroundRectanglesGroup = svg.append("g") .attr("class", "background-rectangles"); @@ -315,7 +315,7 @@ export function reusableLineChart() { const gXAxis = svg.append("g") .attr("class", "x axis") - .attr("transform", `translate(0,${(height - margin.bottom - margin.extraBottom)})`) + .attr("transform", `translate(0,${(height - margin.bottom)})`) .call(xAxis); applyAxisStyle(gXAxis); @@ -370,7 +370,7 @@ export function reusableLineChart() { .enter() .append('g') .attr("class", "legend") - .attr("transform", (d, i) => `translate(${i * 200 + 60},${height - 20})`); + .attr("transform", (d, i) => `translate(${i * 200 + 60},${height + margin.extraBottom - 20})`); legend.call(appendLegendItem); @@ -394,7 +394,6 @@ export function reusableLineChart() { .attr("y", 15) .text(d => d.label) .style("text-anchor", "start"); - } function isOverlapping(allEventsOnTheLeft, currentEvent) { From 9d99961c54a625d6b916c3b30d1f76fc727d869d Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 28 Nov 2019 09:15:15 +0100 Subject: [PATCH 08/11] feat: step 3 dynamic height --- .../line/reusable-line-chart/index.stories.js | 570 +++++++++--------- .../reusable-line-chart.js | 15 +- 2 files changed, 294 insertions(+), 291 deletions(-) diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index ec36733..2d27ab2 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -5,348 +5,348 @@ import {reusableLineChart} from './reusable-line-chart'; export default {title: 'Reusable Line Chart'}; const firstData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3500000, - "status": "Site Handed - Over to Contractor" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": null - } - ], - events: [ - { - "date": "2018-07-20", - "label": "Project Start Date" - }, - { - "date": "2018-09-20", - "label": "Estimated Construction Start Date" - }, - { - "date": "2018-10-01", - "label": "Estimated Project Completion Date" - }, - { - "date": "2018-10-19", - "label": "Contracted Construction End Date" - }, - { - "date": "2019-05-15", - "label": "Estimated Construction End Date" - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3500000, + "status": "Site Handed - Over to Contractor" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": null + } + ], + events: [ + { + "date": "2018-07-20", + "label": "Project Start Date" + }, + { + "date": "2018-09-20", + "label": "Estimated Construction Start Date" + }, + { + "date": "2018-10-01", + "label": "Estimated Project Completion Date" + }, + { + "date": "2018-10-19", + "label": "Contracted Construction End Date" + }, + { + "date": "2019-05-15", + "label": "Estimated Construction End Date" + } + ] }; const secondData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": null, - "total_spent_in_quarter": null, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": null, - "total_spent_in_quarter": null, - "total_estimated_project_cost": 3500000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": "Tender" - }, - { - "date": "2019-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3650000, - "status": "Other - Packaged Ongoing Project" - } - ], - events: [ - { - "date": "2018-07-20", - "label": "Project Start Date" - }, - { - "date": "2018-10-20", - "label": "Estimated Construction Start Date" - }, - { - "date": "2018-12-21", - "label": "Estimated Project Completion Date" - }, - { - "date": "2019-03-19", - "label": "Contracted Construction End Date" - }, - { - "date": "2019-05-15", - "label": "Estimated Construction End Date" - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": null, + "total_spent_in_quarter": null, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": null, + "total_spent_in_quarter": null, + "total_estimated_project_cost": 3500000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": "Tender" + }, + { + "date": "2019-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3650000, + "status": "Other - Packaged Ongoing Project" + } + ], + events: [ + { + "date": "2018-07-20", + "label": "Project Start Date" + }, + { + "date": "2018-10-20", + "label": "Estimated Construction Start Date" + }, + { + "date": "2018-12-21", + "label": "Estimated Project Completion Date" + }, + { + "date": "2019-03-19", + "label": "Contracted Construction End Date" + }, + { + "date": "2019-05-15", + "label": "Estimated Construction End Date" + } + ] }; const thirdData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": null, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": null, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 1669000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": null, - "status": null - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": "Tender" - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": null, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": null, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 1669000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": null, + "status": null + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": "Tender" + } + ] }; const closelyLocatedData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 2900000, - "total_spent_in_quarter": 350000, - "total_estimated_project_cost": 3200000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 3640000, - "total_spent_in_quarter": 689000, - "total_estimated_project_cost": 3650000, - "status": "Feasibility" - }, - { - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 4009000, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 3500000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 2769000, - "total_spent_in_quarter": 1100000, - "total_estimated_project_cost": 3650000, - "status": null - }] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 2900000, + "total_spent_in_quarter": 350000, + "total_estimated_project_cost": 3200000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 3640000, + "total_spent_in_quarter": 689000, + "total_estimated_project_cost": 3650000, + "status": "Feasibility" + }, + { + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 4009000, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 3500000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 2769000, + "total_spent_in_quarter": 1100000, + "total_estimated_project_cost": 3650000, + "status": null + }] }; const minifiedData = { - snapshots: [ - { - "date": "2018-09-30", - "quarter_label": "END Q2", - "financial_year_label": "2018-19", - "total_spent_to_date": 90000, - "total_spent_in_quarter": 35000, - "total_estimated_project_cost": 320000, - "status": "Feasibility" - }, - { - "date": "2018-12-31", - "quarter_label": "END Q3", - "financial_year_label": "", - "total_spent_to_date": 166900, - "total_spent_in_quarter": 68900, - "total_estimated_project_cost": 365000, - "status": "Feasibility" - }, - { - - "date": "2019-03-31", - "quarter_label": "END Q4", - "financial_year_label": "", - "total_spent_to_date": 166900, - "total_spent_in_quarter": 0, - "total_estimated_project_cost": 350000, - "status": "Tender" - }, - { - "date": "2019-06-30", - "quarter_label": "END Q1", - "financial_year_label": "2019-20", - "total_spent_to_date": 276900, - "total_spent_in_quarter": 110000, - "total_estimated_project_cost": 365000, - "status": null - } - ] + snapshots: [ + { + "date": "2018-09-30", + "quarter_label": "END Q2", + "financial_year_label": "2018-19", + "total_spent_to_date": 90000, + "total_spent_in_quarter": 35000, + "total_estimated_project_cost": 320000, + "status": "Feasibility" + }, + { + "date": "2018-12-31", + "quarter_label": "END Q3", + "financial_year_label": "", + "total_spent_to_date": 166900, + "total_spent_in_quarter": 68900, + "total_estimated_project_cost": 365000, + "status": "Feasibility" + }, + { + + "date": "2019-03-31", + "quarter_label": "END Q4", + "financial_year_label": "", + "total_spent_to_date": 166900, + "total_spent_in_quarter": 0, + "total_estimated_project_cost": 350000, + "status": "Tender" + }, + { + "date": "2019-06-30", + "quarter_label": "END Q1", + "financial_year_label": "2019-20", + "total_spent_to_date": 276900, + "total_spent_in_quarter": 110000, + "total_estimated_project_cost": 365000, + "status": null + } + ] }; export const MockupData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(firstData)); + select(container) + .call(myChart.data(firstData)); - return container; + return container; }; export const MissingSpentData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(secondData)); + select(container) + .call(myChart.data(secondData)); - return container; + return container; }; export const MissingCostData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(thirdData)); + select(container) + .call(myChart.data(thirdData)); - return container; + return container; }; export const SinglePointData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data([firstData[0]])); + select(container) + .call(myChart.data({snapshots: [firstData.snapshots[0]]})); - return container; + return container; }; export const HundredThousandsData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(minifiedData)); + select(container) + .call(myChart.data(minifiedData)); - return container; + return container; }; export const SmallWidthHeight = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart().width(320).height(450); + const myChart = reusableLineChart().width(320).height(450); - select(container) - .call(myChart.data(firstData)); + select(container) + .call(myChart.data(firstData)); - return container; + return container; }; export const CloselyLocatedPointsData = () => { - const container = document.createElement("div"); - container.classList.add("container"); + const container = document.createElement("div"); + container.classList.add("container"); - const myChart = reusableLineChart(); + const myChart = reusableLineChart(); - select(container) - .call(myChart.data(closelyLocatedData)); + select(container) + .call(myChart.data(closelyLocatedData)); - return container; + return container; }; diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index 0e7e109..c20905a 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -11,6 +11,8 @@ import d3Tip from "d3-tip"; const SYMBOL_WIDTH = 7.5; +const EVENTS_ROW_HEIGHT = 25; + const LabelToSymbolMap = { 'project start date': '\uf04b', 'estimated construction start date': '~ \uf6e3 \uf04b', @@ -57,7 +59,7 @@ export function reusableLineChart() { eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; let updateData = null; let correspondingSpentLineCircle, correspondingTotalCostCircle = null; - const margin = {top: 50, right: 50, bottom: 100, left: 60, extraBottom: 0}; + const margin = {top: 50, right: 50, bottom: 70, left: 60, extraBottom: 0}; function chart(selection) { selection.each(function () { @@ -72,7 +74,7 @@ export function reusableLineChart() { const svg = selection.append("svg") .attr("width", width) - .attr("height", height) + .attr("height", height); const spentCircleTooltip = d3Tip() .attr("class", "d3-tip") @@ -109,6 +111,9 @@ export function reusableLineChart() { svg.call(statusLabelTooltip); svg.call(eventTooltip); + if (events && events.length > 0) { + margin.extraBottom = EVENTS_ROW_HEIGHT; + } const eventsElements = svg.append("g") .attr("class", "events-elements") .attr("transform", `translate(0,${(height - 30)})`); @@ -160,7 +165,7 @@ export function reusableLineChart() { while (isOverlapping(allEventsOnTheLeft, event)) { const currentEventTransformation = getTransformation(event.node()); event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) - margin.extraBottom = Math.max(margin.extraBottom, currentEventTransformation.translateY + 21); + margin.extraBottom = Math.max(margin.extraBottom, EVENTS_ROW_HEIGHT + currentEventTransformation.translateY + 21); } }); @@ -374,7 +379,6 @@ export function reusableLineChart() { legend.call(appendLegendItem); - function appendLegendItem(selection) { selection.append('rect') .attr("x", 0) @@ -652,8 +656,7 @@ export function reusableLineChart() { yDomainValues = getYDomainValues(data); yScale.domain([0, max(yDomainValues)]).nice(); - console.log(margin.extraBottom); - yScale.range([height - margin.bottom - margin.extraBottom, margin.top]); + yScale.range([height - margin.bottom, margin.top]); yAxis.scale(yScale); yAxisGrid.scale(yScale); From b6e2fea432c27b857187e0657dca35487778eb8e Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 28 Nov 2019 10:06:47 +0100 Subject: [PATCH 09/11] feat: step 3 transition --- .../reusable-line-chart.js | 114 ++++++++++++++++-- 1 file changed, 104 insertions(+), 10 deletions(-) diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index c20905a..163cb64 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -157,16 +157,7 @@ export function reusableLineChart() { .style("font-weight", 900); eventsElements.selectAll('.event-elements-group').each(function (d) { - const event = select(this); - const allEventsOnTheLeft = eventsElements.selectAll('.event-elements-group') - .filter(function (eventData) { - return d.date > eventData.date; - }); - while (isOverlapping(allEventsOnTheLeft, event)) { - const currentEventTransformation = getTransformation(event.node()); - event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) - margin.extraBottom = Math.max(margin.extraBottom, EVENTS_ROW_HEIGHT + currentEventTransformation.translateY + 21); - } + resolveEventOverlappin(); }); eventsElementsGroups.selectAll('line') @@ -413,6 +404,21 @@ export function reusableLineChart() { return result; } + function resolveEventOverlappin() { + eventsElements.selectAll('.event-elements-group').each(function (d) { + const event = select(this); + const allEventsOnTheLeft = eventsElements.selectAll('.event-elements-group') + .filter(function (eventData) { + return d.date >= eventData.date && d.label !== eventData.label; + }); + while (isOverlapping(allEventsOnTheLeft, event)) { + const currentEventTransformation = getTransformation(event.node()); + event.attr("transform", (d) => `translate(${currentEventTransformation.translateX},${currentEventTransformation.translateY + 21})`) + margin.extraBottom = Math.max(margin.extraBottom, EVENTS_ROW_HEIGHT + currentEventTransformation.translateY + 21); + } + }); + } + function getTransformation(node) { const matrix = node.transform.baseVal.consolidate().matrix; return { @@ -654,6 +660,94 @@ export function reusableLineChart() { xScale.domain([newMinXDomainValue, max(xDomainValues)]); xAxis.scale(xScale).tickValues(xDomainValues); + + if (events && events.length > 0) { + margin.extraBottom = EVENTS_ROW_HEIGHT; + } else { + margin.extraBottom = 0; + } + + const eventsElementsGroups = eventsElements.selectAll('.event-elements-group').data(events); + + eventsElementsGroups + .enter() + .append("g") + .attr("transform", (d) => `translate(${xScale(d.date)},0)`) + .attr('class', 'event-elements-group'); + + eventsElementsGroups + .attr("transform", (d) => `translate(${xScale(d.date)},0)`); + + eventsElementsGroups.exit() + .remove(); + + const eventsRectangles = eventsElements.selectAll('.event-elements-group').selectAll('rect').data(d => [d]); + + eventsRectangles.enter() + .append('rect') + .attr('class', 'event-rect') + .attr("x", 0) + .attr("y", 0) + .attr("rx", 5) + .attr("ry", 5) + .attr("width", 60) + .attr("height", d => 20) + .attr("fill", (d, i) => 'rgb(51, 51, 51)') + .on("mouseover", function (d) { + eventTooltip.show(d, this); + }) + .on("mouseout", function () { + eventTooltip.hide(); + }); + eventsRectangles.exit() + .remove(); + + const eventsText = eventsElements.selectAll('.event-elements-group').selectAll('text').data(d => [d]); + + eventsText.enter() + .append('text') + .attr('class', 'event-text') + .attr("x", 30) + .attr("y", 15) + .attr('text-anchor', 'middle') + .text(d => LabelToSymbolMap[d.label.toLowerCase()]) + .style("font-weight", 900); + + eventsText + .text(d => LabelToSymbolMap[d.label.toLowerCase()]); + + eventsText.exit() + .remove(); + + eventsElements.selectAll('.event-elements-group').each(function (d) { + resolveEventOverlappin(); + }); + + const eventsLine = eventsElements.selectAll('.event-elements-group').selectAll('line').data(d => [d]); + + eventsLine.enter() + .append('line') + .attr('x1', 30) + .attr('y1', 0) + .attr('x2', 30) + .attr('y2', function () { + const gTransformation = getTransformation(select(this).node().parentNode); + return -(height - margin.bottom + 50 - margin.top + gTransformation.translateY); + }); + + eventsLine + .attr('y2', function () { + const gTransformation = getTransformation(select(this).node().parentNode); + return -(height - margin.bottom + 50 - margin.top + gTransformation.translateY); + }); + + eventsLine.exit() + .remove(); + + eventsElements.attr("transform", `translate(0,${(height - margin.bottom + 50)})`); + svg.attr("height", height + margin.extraBottom); + legend.attr("transform", (d, i) => `translate(${i * 200 + 60},${height + margin.extraBottom - 20})`); + yDomainValues = getYDomainValues(data); yScale.domain([0, max(yDomainValues)]).nice(); yScale.range([height - margin.bottom, margin.top]); From 6643723a8a5a99dfcc22a62b4190417d44799243 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 28 Nov 2019 10:14:30 +0100 Subject: [PATCH 10/11] feat: step 3 transition --- src/charts/line/reusable-line-chart/index.stories.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index 2d27ab2..17aec10 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -53,11 +53,11 @@ const firstData = { "label": "Estimated Construction Start Date" }, { - "date": "2018-10-01", + "date": "2018-10-10", "label": "Estimated Project Completion Date" }, { - "date": "2018-10-19", + "date": "2018-10-30", "label": "Contracted Construction End Date" }, { From 243d55f3a746e90aaa26e8eb3b4506b81ffe9eb2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 28 Nov 2019 11:04:27 +0100 Subject: [PATCH 11/11] feat: customizable event tooltip formatter --- .../line/reusable-line-chart/index.stories.js | 4 +++- .../line/reusable-line-chart/reusable-line-chart.js | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/charts/line/reusable-line-chart/index.stories.js b/src/charts/line/reusable-line-chart/index.stories.js index 17aec10..b0fcdd0 100644 --- a/src/charts/line/reusable-line-chart/index.stories.js +++ b/src/charts/line/reusable-line-chart/index.stories.js @@ -282,7 +282,9 @@ export const MissingSpentData = () => { const myChart = reusableLineChart(); select(container) - .call(myChart.data(secondData)); + .call(myChart + .data(secondData) + .eventTooltipFormatter(d => `
Event: ${d.label}
`)); return container; }; diff --git a/src/charts/line/reusable-line-chart/reusable-line-chart.js b/src/charts/line/reusable-line-chart/reusable-line-chart.js index 163cb64..affc9d6 100644 --- a/src/charts/line/reusable-line-chart/reusable-line-chart.js +++ b/src/charts/line/reusable-line-chart/reusable-line-chart.js @@ -1041,6 +1041,19 @@ export function reusableLineChart() { return chart; }; + chart.eventTooltipFormatter = function (value) { + if (!arguments.length) { + return eventTooltipFormatter + } else { + if (value == null) { + eventTooltipFormatter = initialConfiguration.eventTooltipFormatter; + } else { + eventTooltipFormatter = value; + } + return chart; + } + }; + return chart; }