Skip to content

Instantly share code, notes, and snippets.

@asielen
Last active October 12, 2022 06:19
Show Gist options
  • Save asielen/4d81bc4c450e1f4c4fa914edea8c0d71 to your computer and use it in GitHub Desktop.
Save asielen/4d81bc4c450e1f4c4fa914edea8c0d71 to your computer and use it in GitHub Desktop.
Pacing Chart
height: 500
scrolling: yes
license: mit

Pacing Charts

A reusable and configurable chart to track performance against targets. Based on the common bullet chart, this simplified presentation is easily digestible for presentations and regular kpi updates. Can also be used as a more data-to-ink dense replacement of a dial chart.

Features:

  • Flexibly track multiple breakdowns of targets and results
  • Colors and lables are easily styled in CSS
  • Supports multiple charts rendered in flexbox for easy layout
  • Built for reusability based on the concept outlined in Mike Bostock's blog post Towards Reusable Charts.

Supports D3 v6 and v7

Installation

  1. Host pacingchart.js, pacingchart-base.css (optional), pacingchart-starter.css (optional)
  2. Install d3 or reference it through the cdn in your html head: eg: <script src="https://d3js.org/d3.v6.min.js"></script>
  3. Add to your html head: <script src="pacingchart.js" charset="utf-8"></script> <link rel="stylesheet" type="text/css" href="demos/pacingchart-starter.css">

The css is not strictly required. It provides the base styles for the demo and is a useful starting point for your own charts. pacingchart-starter.css

  1. Prepare your data (see below)
  2. Initialize the charts (see below)

Preparing your Data

  • Data must be formatted as a map prior to passing it to the constructor. See the included example for how to load data from a csv and prepare it for the chart.

Each row of data creates a single subchart. There is no limit on the number of subcharts that you can render at once. Each one will be treated as a flex-box div within a parent flexbox div for all the subcharts.

Required columns

  • A few column types are required:
    • At least one Title Column (must be unique)
    • At least one Targets Column
    • At least one Results Column

NOTE: There is no required naming for the columns, they can be named anything you wish, and you can also include a human-readable name when loading the data.

Optional columns:

  • Subtitle column, optional secondary classification for each subchart.
  • You can have any number of target columns and results columns, but generally it is best to stick to no more than 4 if not fewer.
  • You can also specify any number of target markers and results markers, these are rendered as lines on the targets and results bars.

Configuring the chart

The makePacingChart() function takes an object map. The following data is required:

  • data - The data map as outlined in the first section
  • selector - STRING - The css selector for where the charts should be created
  • titleCols - ARRAY - At least one Column Name/Key to be used as the identifier for each subchart. This is not the value of the columns but just the key/column name used to look up the title values.
    • You can also include a second Column Name in this array. That will be used as a subtitle. *Any additional values are ignored.
  • targetsCols - ARRAY - At least one Column Name/Key to be used to identify the targets.
    • You can also supply a human-readable version of the identifier that can be used in the tool tip. For example: targetCols = [["col1","Initial Target"],["col2","Final Target"]]
    • *If no human-readable form is provided, the provided key will be used for the tooltip.
  • resultsCols - ARRAY - At least one Column Name/Key to be used to identify the results.
    • Like targetsCols, this also supports a human-readable string.
  • targetsMarkersCols - ARRAY - At least one Column Name/Key to be used to identify the target markers. Can be null.
    • Like targetsCols, this also supports a human-readable string.
  • resultsMarkersCols - ARRAY - At least one Column Name/Key to be used to identify the target markers. Can be null.
    • Like targetsCols, this also supports a human-readable string.
  • linkCol - STRING - The column that contains a link for each subchart.

Formatting options:

  • chartWidth - Default: 450 - How wide in pixels should the subcharts be. This includes padding and titles.
  • barHeight - Default: 30 - How tall in pixels should each bar be. Note, at a minimum each subchart is 2x this number (30px for targets + 30px for results). Up to 4x this number depending on other options.
  • titlePadding - Default: 75 - How much space should be allocated for the title for each chart in pixels.
  • lowerSummaryPadding - Default: 20 - How much space below each subchart should be allocated for summary text.
  • minWidthForPercent - Default: 100 - How wide in pixes must a results bar be before showing the percent to target
  • barRadiusTargets - Default: {t:{l:4,i:0,r:4},b:{l:0,i:0,r:0},hang:true}
    • t = top | b = bottom | l = left | i = inside | r = right
    • This map object describes the border radius for the bars. For example if t.l = 4 then the top left corner of the bar will be curved 4 pixels
    • If hang=true then bars that hang out over other bars are given equal top and bottom corner radiuses, even if only one of top or bottom is supplied.
  • barRadiusResults - See above

Templatizing Settings using Chart.set({settings})

If rendering multiple charts from similar data, you can templatize the settings to keep the initialization cleaner. Using the "set" method allows you to update any chart settings after initialization.

Example - Without settings template:

let chart_1 = makePacingChart({
          data:dataset1,
          selector: '#chart-pacing-1',
          targetsCols: [['RED_THRESHOLD','Red'],['YELLOW_THRESHOLD','Yellow'],'TARGET'],
          targetsMarkersCols: ['PREV_PROJECTED_AMOUNT'],
          resultsCols: ['AMOUNT', ['PROJECTED_AMOUNT','Projected'],'FINAL_PROJECTED_AMOUNT'],
          resultMarkersCols: ['PREV_AMOUNT'],
          titleCols: ['Type'], 
        }).render();
let chart_2 = makePacingChart({
          data:dataset2,
          selector: '#chart-pacing-2',
          targetsCols: [['RED_THRESHOLD','Red'],['YELLOW_THRESHOLD','Yellow'],'TARGET'],
          targetsMarkersCols: ['PREV_PROJECTED_AMOUNT'],
          resultsCols: ['AMOUNT', ['PROJECTED_AMOUNT','Projected'],'FINAL_PROJECTED_AMOUNT'],
          resultMarkersCols: ['PREV_AMOUNT'],
          titleCols: ['Type'], 
        }).render();

Example - With Template Settings:

let settings = {
        targetsCols: [['RED_THRESHOLD','Red'],['YELLOW_THRESHOLD','Yellow'],'TARGET'],
        targetsMarkersCols: ['PREV_PROJECTED_AMOUNT'],
        resultsCols: ['AMOUNT', ['PROJECTED_AMOUNT','Projected'],'FINAL_PROJECTED_AMOUNT'],
        resultMarkersCols: ['PREV_AMOUNT'],
        titleCols: ['Type'] 
}
let chart_1 = makePacingChart(settings)
        .set({data:dataset1,selector:'#chart-pacing-1'})
        .render();
let chart_2 = makePacingChart(settings)
        .set({data:dataset2,selector:'#chart-pacing-2'})
        .render();

Any of the settings parameters can be updated and functions modified (see the tooltips and formatting sections) up until render() is called.

CSS configuration

Almost every element of these charts can be styled using css.

Classes:

  • Subcharts are tagged with the title and subtitle of the subchart
  • Each subchart is also given a semi-random id
  • Metrics are tagged with their kind: target, result, marker
  • Every target, result, and marker is tagged with both the column name and also display name of the metric. NOTE: names that are not "css safe" are converted. Spaces are turned into underscores, underscores are turned into hyphens. Non-ascii elements are removed.
  • Targets and result bars are tagged with their width rounded to the nearest 25 pixels. Can be set to different amounts using the w_threshold setting.
    • This is useful for styling bars based on their width or hiding text if the bars are too small
  • Results bars and markers are tagged with their percentage of total target to the nearest 10%. Can be set to different amounts using the p_threshold setting.
    • This is useful for styling bars based on their percent to goal. Such as coloring everything under 50% red

Example:

<svg class="performance fy23q3" id="g0-09bdc25fd13fb">
    // classes match title + subtitle
    <g>
        <text class="title">Performance</text>
        <text class="subtitle">FY23Q3</text>
    </g>
    <g class="targets">
        // Object types are organized into their own <g> elements
        <svg class="target s0 w0 w25 w50 w75 w100 w125 w150 w175 w200 w225 red-threshold red">
            <rect class="target s0 w0 w25 w50 w75 w100 w125 w150 w175 w200 w225 red-threshold red"></rect>
            <text class="target text s0 red-threshold red">17.8M</text>
        </svg>
    </g>
    <g class="results">
        <svg class="result s0 w0 p0 amount"> 
            // result = type 
            // s0 = first bar 
            // w0 = 0+ pixels 
            // p0 = 0% width (less than 10%)
            // amount = name of bar
            <rect class="result s0 w0 p0 amount"></rect>
            <text class="result text s0 amount">1.8M</text>
        </svg>
        <svg class="result s1 w0 w25 p0 p10 projected-amount projected">
            // result = type 
            // s1 = first bar
            // w0 = 0+ pixels 
            // w25 = 25+ pixels
            // p0 = 0% width 
            // p10 = 10%+ width (less than 20%)
            // projected-amount = column name
            // projected = display name
            <rect class="result s1 w0 w25 p0 p10 projected-amount projected" width="100%" height="100%"></rect>
            <text class="result text s1 projected-amount projected">5.1M</text>
        </svg>
    <g class="results-markers">
        <line class="marker s0 p0 prev-amount"></line>
    </g>
</svg>

Other configurations

Cumulative or additive targets and results.

By default, targets and results are cumulative. This means that the largest target or result encompasses all the smaller ones.

Example:

  • Targets: 10M, 20M, 30M
  • 30M is the total target

You can set targets and results to additive which then means all the metrics are added on top of each other:

Example:

  • Targets: 10M, 20M, 30M
  • 60M is the total target

Setting results to additive is useful if you want to show multiple elements that make up the total performance. Such as the performance of two different teams added against a single goal.

Summaries

Summary bars can be added above the target or results bars. This is a single bar that is the full width of the total target or result. It makes it easier to interpret performance when multiple elements are combined into a single result or target.

Tooltips

The tooltip format can be modified by providing a custom chart.tooltipGenerator() function. This function can be overwritten:

chart.tooltipGenerator = function(chartObj,eventTarget) {return chartObj.title}

The function takes two parameters:

  1. chartObj = the data for the subchart
  2. eventTarget = the data that the cursor is specifically pointing at within the subchart

And returns a text string that is injected into the tooltip div on hover.

Text Formatting

Pacing chart provides three formatting functions:

  1. Main number format (chart.formatterValue)
    • How the numbers are presented on the bars
  2. Percentage format (chart.formatterPercent)
    • How percentages are presented on the bars
  3. Tooltip number format (chart.formatterValueToolTip)
    • How the numbers are presented in the tool tip

All three of these can be overwritten before rendering the chart. They each take a single parameter, a number to format and return a formatted string.

YEAR TYPE TARGET YELLOW_THRESHOLD RED_THRESHOLD AMOUNT PROJECTED_AMOUNT PREV_AMOUNT PREV_PROJECTED_AMOUNT
2022 Value 10000000.00 7500000.00 3750000.00 8540000 12103000 5060345 9000334
2022 Count 100.00 75.00 37.50 45 85 23.00 40.00
YEAR TYPE TARGET BLUE_AMOUNT RED_AMOUNT
2022 Value 6000 300 2000
2022 Count 56 20 30
YEAR TYPE TARGET AMOUNT PROJECTED_AMOUNT PREV_AMOUNT PREV_PROJECTED_AMOUNT URL
2022 Value 14500000 21800000 22750000 540000 25103000 https://gist.github.com/asielen/4d81bc4c450e1f4c4fa914edea8c0d71
2022 Count 100 5 25 20 40 https://www.realityprose.com
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
<link rel="stylesheet" type="text/css" href="pacingchart-starter.css">
<script src="https://d3js.org/d3.v6.min.js"></script>
</head>
<body>
<p>Three milestones within the target, red yellow green. Also shows data with a target and result marker and border radius hang=false</p>
<div class="chart-wrapper" id="chart-pacing-1"></div>
<p>Single target, but two results bars that are additive. Summary bar turned on and custom format functions were set.</p>
<div class="chart-wrapper" id="chart-pacing-2"></div>
<p>Scaled results with summary text showing because the size of the final bar is too short.</p>
<div class="chart-wrapper" id="chart-pacing-3"></div>
<script src="./pacingchart.js" type="text/javascript"></script>
<script type="text/javascript">
let settings_1 = {
selector: '#chart-pacing-1',
targetsCols: [['RED_THRESHOLD','Red'],['YELLOW_THRESHOLD','Yellow'],'TARGET'], // Either a list of strings that is the name of the column or a list of arrays that is [name of col, friendly name of column]
targetsMarkersCols: ['PREV_PROJECTED_AMOUNT'],
resultsCols: ['AMOUNT', ['PROJECTED_AMOUNT','Projected']],
resultMarkersCols: ['PREV_AMOUNT'],
titleCols: ['TYPE','YEAR'], // First one is main, second one is subtitle, any others are ignored
summarizeTargets: true,
barRadiusTargets: {t:{l:4,i:0,r:4},b:{l:0,i:0,r:0},hang:false}
}
d3.csv('data-sample1.csv', function(data) {
data.TARGET = +data.TARGET; // Numerical data should be converted to numbers. This is easily achieved by prepending "+"
data.YELLOW_THRESHOLD = +data.YELLOW_THRESHOLD;
data.RED_THRESHOLD = +data.RED_THRESHOLD;
data.AMOUNT = +data.AMOUNT;
data.PROJECTED_AMOUNT = +data.PROJECTED_AMOUNT;
data.PREV_AMOUNT = +data.PREV_AMOUNT;
data.PREV_PROJECTED_AMOUNT = +data.PREV_PROJECTED_AMOUNT;
return data;
}).then( function(data) {return makePacingChart(settings_1).set({data:data}); }
).then( function(chart) {chart.render()});
function noDecimalFormat(n) {
return d3.format(",.0f")(n);
}
function noDecimalFormatPercent(n) {
return d3.format(",.0%")(n);
}
let settings_2 = {
selector: '#chart-pacing-2',
targetsCols: ['TARGET'],
resultsCols: ['BLUE_AMOUNT','RED_AMOUNT'],
titleCols: ['TYPE','YEAR'],
cumulativeResults: false,
summarizeResults: true,
}
d3.csv('data-sample2.csv', function(data) {
data.TARGET = +data.TARGET;
data.BLUE_AMOUNT = +data.BLUE_AMOUNT;
data.RED_AMOUNT = +data.RED_AMOUNT;
return data;
}).then( function(data) {return makePacingChart(settings_2).set({data:data});}
).then(function(chart) {return chart.set({formatterValue: noDecimalFormat,formatterPercent :noDecimalFormatPercent})}
).then( function(chart) {console.log(chart); chart.render()});
let settings_3 = {
selector: '#chart-pacing-3',
targetsCols: ['TARGET'], // Either a list of strings that is the name of the column or a list of arrays that is [name of col, friendly name of column]
resultsCols: ['AMOUNT', ['PROJECTED_AMOUNT','Projected']],
resultMarkersCols: [['PREV_AMOUNT','Previous Amount'],['PREV_PROJECTED_AMOUNT','Previous Projection']],
titleCols: ['TYPE','YEAR'], // First one is main, second one is subtitle, any others are ignored
linkCol: 'URL'
}
d3.csv('data-sample3.csv', function(data) {
data.TARGET = +data.TARGET; // Numerical data should be converted to numbers. This is easily achieved by prepending "+"
data.AMOUNT = +data.AMOUNT;
data.PROJECTED_AMOUNT = +data.PROJECTED_AMOUNT;
data.PREV_AMOUNT = +data.PREV_AMOUNT;
data.PREV_PROJECTED_AMOUNT = +data.PREV_PROJECTED_AMOUNT;
return data;
}).then( function(data) {return makePacingChart(settings_3).set({data:data}); }
).then( function(chart) {chart.render()});
</script>
</body>
</html>
/*
* Basic styles to get you started.
*
*
* A note on html class structure.
* The "pace-chart" class is appended to the element identified by the selector,
* as such, it can be used as the generic parent for all pace-chart related elements
*
* Inside the pace-chart, there are two main divs:
* .inner-box - the main container/flex parent of the sub-chartObjects
* .tooltip - rendered on hover. ALl html within this div is provided by the tooltipGenerator function
*
* Chart Objects (within the inner-box)
* Each subchart has it's own div with class "chart-area"
* Within that div, the svg element for the subchart is created.
* - This svg takes on the title and subtitle as classes as well as a unique random id
*
* Chart SVG
* The elements within the svg element are organized in g elements by type:
* - .title / .subtitle
* - .targets
* - .results
* - .targets-markers
* - .results-markers
*/
/*
* Hide all text initially and then un-hide specifics as needed
*/
.pace-chart text {
font-family: sans-serif;
display: none;
}
/*
* Setting overflow to visible for the parent svg allows us to have the shadow just on the bar elements (not on titles)
* and not have it cut off by the edge of the parent svg element
*/
.pace-chart .chart-area svg {
overflow: visible;
}
.pace-chart .chart-area .bars {
filter: drop-shadow( 4px 4px 2px rgba(0, 0, 0, .2));
}
/*
* Hover state to let people know it is clickable
*/
.pace-chart a:hover {
filter: brightness(1.3);
}
/*
* Show and style the title and subtitle for each subchart object
*/
.pace-chart .title {
font-size: 14px;
font-weight: bold;
display: initial;
}
.pace-chart .subtitle {
fill: #999;
font-size: 11px;
display: initial;
}
/*
* Un-hide text based on width, Since anything that is greater than w25 will
* have the w25 class, this first one un-hides everything.
* -- w25, w50 correspond to pixel width, 25px, 50px.
* Note: Everything below 25 px is still hidden in this starter css.
* To show text in bars shorter than 25px, update the w_threshold to something smaller than 25
* and then create teh style for that size. For example if w_threshold = 10, you could then
* show and style text on bars down to 10px wide.
* - Anything
*/
.pace-chart .targets .w25 text {
display: initial; /* Show all text in classes larger than 25px */
font-size: 7px;
font-weight: bold;
fill: white;
}
.pace-chart .targets .w50 text {
font-size: 12px;
}
.pace-chart .targets .w100 text {
font-size: 22px;
}
/*
* More gradations for results sizes to allow space for percent to goal
*/
.pace-chart .results .w25 text {
display: initial;
font-size: 7px;
font-weight: bold;
fill: white;
}
.pace-chart .results .w50 text {
font-size: 10px;
}
.pace-chart .results .w100 text {
font-size: 12px;
}
.pace-chart .results .w150 text {
font-size: 16px;
}
.pace-chart .results .w250 text {
font-size: 20px;
}
/*
* All target boxes contain the target class.
* The example code below uses yellow and red as examples that come from
* the initialization of the targets. These are not default class values.
*/
.pace-chart .target {
fill: #47653F;
}
.pace-chart .target.yellow {
fill: #F7B801;
}
.pace-chart .target.red {
fill: #C44900;
}
/*
* All target boxes contain the target class.
* This example shows using the default s0, s1 etc classes.
* These are default index classes provided by the render function
*/
.pace-chart .result.s0 {
fill: #294868;
}
.pace-chart .result.s1 {
fill: #38618C;
}
.pace-chart .result.s2 {
fill: #80CED7;
}
/*Demo specific*/
.pace-chart .result.red-amount {
fill: #8c3861;
}
.pace-chart .result.blue-amount {
fill: #38618C;
}
/*
* Markers are just lines, they can be styled however you wish
*/
.pace-chart .marker {
stroke: #887e7e;
opacity: 0.9;
stroke-dasharray: 1, 2;
stroke-width: 2px;
}
/*
* For convenience, the total of the results is also rendered (but hidden)
* Below the results bar. Uncomment this to display.
* This is useful if your results bar is too narrow to show the full result
* but you still want to show it.
*/
/*
.pace-chart .results text.summary {
display: initial;
}
*/
/* For the demo, only the 3rd chart shows the summary text */
.pace-chart#chart-pacing-3 .results text.summary {
display: initial;
font-size: 12px;
}
/*
* If rendering summary bars, you will need to provide a fill and un-hide the text
*/
.pace-chart .results-summary path {
fill: #cdb2dc;
}
.pace-chart .results-summary text {
display: initial;
font-weight: bold;
}
.pace-chart .targets-summary path {
fill: #cdb2dc;
}
.pace-chart .targets-summary text {
display: initial;
font-weight: bold;
}
/*
* Everything in the tooltip definition comes from the tooltipGenerator function.
*
*/
/* Display styles, important no matter what the tooltip contains*/
.pace-chart div.tooltip {
position: absolute;
text-align: left;
pointer-events: none;
}
/* Tooltip specific styling*/
.pace-chart div.tooltip {
padding: 5px;
background: #fff;
box-shadow: 5px 5px 11px -5px rgba(0,0,0,0.67);
border: 0;
border-radius: 6px;
opacity: 0.8;
font: 12px sans-serif;
}
.pace-chart div.tooltip .chart.title {
font-size: 14px;
}
.pace-chart div.tooltip .title, .chart-wrapper div.tooltip .title {
font-weight: bold;
}
.pace-chart div.tooltip .selected {
font-style: italic;
background: wheat;
font-size: 14px;
}
/**
* @fileOverview A D3 based chart for tracking progress against goals. Variation of a bullet chart.
* @version 1.02
* Tested on d3 v6 and v7
*/
/**
* Creates a pacing chart
* @param settings Configuration options for the base plot
* @param settings.data The data for the plot
* @param {string} settings.selector The selector string for the main chart div
* @param settings.targetsCol The name of the columns used to define the targets. It can be defined as an array of strings or an array of arrays where each subarray includes the column name and a display name
* @param [settings.targetsMarkersCols] The name of the columns used to define the target markers. It can be defined as an array of strings or an array of arrays where each subarray includes the column name and a display name
* @param settings.resultsCols The name of the columns used to define the results. It can be defined as an array of strings or an array of arrays where each subarray includes the column name and a display name
* @param [settings.resultsMarkersCols] The name of the columns used to define the results markers. It can be defined as an array of strings or an array of arrays where each subarray includes the column name and a display name
* @param [settings.titleCols] Array where the first column name is used as a title, the second (optional) one is used as a subtitle.
* @param [settings.linkCol]=null A string of the column name containing urls. Each subchart can link to a single url
* @param settings.chartWidth=450 The Max width of each chart within the collection of charts
* @param settings.barHeight=30 The height of each bar, each chart is two stacked charts to this value x2 is the total height of each subchart
* @param settings.titlePadding=75 How much space to the left of the charts should be allocated to the title. The bar chart portion is adjusted down to the remaining space
* @param settings.lowerSummaryPadding=20 How much space to add below the charts to allow for results if the results exceed the end of the chart
* @param settings.minWidthForPercent=100 The minimum numbers of pixels a result bar will be for the percent to be shown. Below this threshold, only the value is rendered
* @param settings.respChartCols=2 The number of columns to keep together as the screen size changes. If 0, then the charts will not scale at all. If 1, they will only scale when the width is less than the chartWidth
* @param settings.cumulativeTargets=true If true, the targets are subsets of each other ie. the largest target is the total target. If false, the total target is the sum of all the targets.
* @param settings.cumulativeResults=true If true, the results are subsets of each other ie. the largest result is the total result. If false, the total result is the sum of all the results.
* @param settings.summarizeTargets=false If true, show a separate bar above the targets that is a sum of all the individual targets, mostly useful when paired with cumulativeTargets=false
* @param settings.summarizeResults=false If true, show a separate bar above the results that is a sum of all the individual results, mostly useful when paired with cumulativeResults=false
* @param settings.barRadiusTargets The corner radiuses for the targets bars. An object in the following format: {t:{l:4,i:0,r:4},b:{l:0,i:0,r:0},hang:true}
* @param settings.barRadiusResults The corner radiuses for the results bars.
* @param settings.w_threshold=25 The increment of the "w" classes on targets and results. Every n pixels gets wn as a class. etc. =25 -> w25, w50, w75 etc
* @param settings.p_threshold=0.1 The increment of the "p" classes on results. As a decimal percent. 0.1 = 10%
* @returns {object} A chart object
*/
function makePacingChart(settings) {
let chart = {};
// Defaults
chart.settings = {
data: null,
selector: null,
targetsCols: [],
targetsMarkersCols: [],
resultsCols: [],
resultMarkersCols: [],
titleCols: [],
linkCol: null,
chartWidth: 450,
barHeight: 30,
titlePadding: 75,
lowerSummaryPadding: 20,
minWidthForPercent: 100,
respChartCols: 2,
cumulativeTargets: true,
cumulativeResults: true,
summarizeTargets: false,
summarizeResults: false,
barRadiusTargets: {t:{l:4,i:0,r:4},b:{l:0,i:0,r:0},hang:true}, // top [left, inside, right], bottom [left, inside, right], if hang = true, then curve top and bottom with the same radius even if it isn't specified
barRadiusResults: {t:{l:0,i:0,r:0},b:{l:4,i:0,r:4},hang:true}, // top [left, inside, right], bottom [left, inside, right]
w_threshold: 25,
p_threshold: .1,
_constrainToTarget: false, // Not implemented
};
chart.groupObjs = {};
chart.objs = {mainDiv: null, chartDiv: null, g: null};
/**
* Allows settings to be updated by calling chart.set after initialization but before update.
* Useful if some settings are templatized (the same multiple places).
* Built so that the functions that can be overridden, can also be defined here.
* ie. chart.set(formatterValue=newFunct) This way you can chain the settings update calls.
* This also means that these functions can be set at initialization since that also calls chart.set
* @param settings_map A key:value map of settings.
* @return the chart object so it can be chained
*/
chart.set = (settings_map) => {
for (let setting in settings_map) {
chart.settings[setting] = settings_map[setting]
if (setting === 'data') {chart.data = chart.settings.data;}
if (['formatterValue','formatterValueToolTip','formatterPercent','tooltipGenerator'].includes(setting) && typeof settings_map[setting] === 'function'){
// If it is one of the pre-defined functions, update that function
chart[setting] = settings_map[setting];
}
}
// Update base layout settings
chart.width = chart.settings.chartWidth;
chart.height = (chart.settings.barHeight * (2 + chart.settings.summarizeTargets + chart.settings.summarizeResults) + chart.settings.lowerSummaryPadding)
chart.barWidth = chart.settings.chartWidth - chart.settings.titlePadding;
chart.barHeight = chart.settings.barHeight;
chart.currentChartWidth = chart.settings.chartWidth;
return chart;
}
/**
* Read and prepare the raw data (no calculations based on ranges as those could change).
*/
function prepareData() {
/**
* Return num rounded to the nearest 10.
* 11 -> 10, 26 -> 30 etc
* For tagging the bars with classes based on each 10%
*/
function roundUpNearest10(num) {
return Math.round(Math.ceil(num / 10) * 10);
}
let valueSort = (a, b) => {
if (a.value < b.value) return -1;
if (a.value > b.value) return 1;
return 0;
}
/**
* Parse the data based on the column names provided
*/
let parseValues = (columnNames, current_row, is_cumulative) => {
let metricsObj = [];
for (const column of columnNames) {
let ref = column;
let name = column;
if (typeof column != 'string') {
ref = column[0];
name = column[1];
}
metricsObj.push({column: ref, name: name, value: chart.data[current_row][ref]});
// If the targets or results are additive, not cumulative. The order presented in the setup will be kept.
// Otherwise, they will be sorted by value.
if (!is_cumulative) {metricsObj.sort(valueSort)}
}
return metricsObj;
}
/**
* Each ChartObj is one of the "subcharts". This corresponds to one row of data
*/
function makechartObj(row, index){
let chartObj = {
title: "",
subtitle: null,
index: 0,
classes: [],
unique_id: "",
svg: {parent:null,title:null,subtitle:null,targets:null,results:null,targetsMarkers:null,resultsMarkers:null},
metrics : {},
link: ""
}
chartObj.index = index;
chartObj.unique_id = "g"+index+"-"+Math.random().toString(16).slice(2)
row.unique_id = chartObj.unique_id;
if (typeof chart.settings.titleCols != 'string') {
chartObj.title = row[chart.settings.titleCols[0]];
chartObj.subtitle = row[chart.settings.titleCols[1]];
} else {
chartObj.title = row[chart.settings.titleCols];
}
if (chart.settings.linkCol) {
chartObj.link = row[chart.settings.linkCol];
}
chartObj.metrics.targets = parseValues(chart.settings.targetsCols, index, chart.settings.cumulativeTargets);
chartObj.metrics.targetsMarkers = parseValues(chart.settings.targetsMarkersCols, index, chart.settings.cumulativeTargets);
chartObj.metrics.targetsLastIndex = chartObj.metrics.targets.length - 1
chartObj.metrics.results = parseValues(chart.settings.resultsCols, index, chart.settings.cumulativeResults);
chartObj.metrics.resultsMarkers = parseValues(chart.settings.resultMarkersCols, index, chart.settings.cumulativeResults);
chartObj.metrics.resultsLastIndex = chartObj.metrics.results.length - 1
// Depending on the settings, these are used to identify the max width of the bars
// The standard is that the largest value across all measures is the max width.
chartObj.metrics.targetsMax = (!chart.settings.cumulativeTargets ? chartObj.metrics.targets.map(o => +o.value).reduce((a,b)=>a+b) : Math.max(...chartObj.metrics.targets.map(o => o.value)))||0; // The largest target is used as the main target. Should this be more flexible?
chartObj.metrics.targetsMarkersMax = (Math.max(...chartObj.metrics.targetsMarkers.map(o => o.value)))||0;
chartObj.metrics.resultsMax = (!chart.settings.cumulativeResults ? chartObj.metrics.results.map(o => +o.value).reduce((a,b)=>a+b) : Math.max(...chartObj.metrics.results.map(o => o.value)))||0;
chartObj.metrics.resultsMin = (Math.min(...chartObj.metrics.results.map(o => o.value)))||0;
chartObj.metrics.resultsMarkersMax = (Math.max(...chartObj.metrics.resultsMarkers.map(o => o.value)))||0;
chartObj.metrics.resultsMarkersMin = (Math.min(...chartObj.metrics.resultsMarkers.map(o => o.value)))||0;
chartObj.metrics.metricsMax = Math.max(chartObj.metrics.targetsMax, chartObj.metrics.resultsMax, chartObj.metrics.targetsMarkersMax, chartObj.metrics.resultsMarkersMax);
// If 0 target and results are within 5%, if 1, target is larger, if -1, results is larger.
// for bar radius calculation. Calculated with the xscale method in the methods function.
chartObj.metrics.targetGreater = 0;
// Calculate percent of max target for results
// Used to tag with classes for css formatting
chartObj.metrics.results.forEach(result => {
result.percent_to_target = result.value / chartObj.metrics.targetsMax;
});
// Calculate percent of max target for results markers
chartObj.metrics.resultsMarkers.forEach(result => {
result.percent_to_target = result.value / chartObj.metrics.targetsMax;
});
// Also the chart object itself gets a class append for the target to results
chartObj.classes.push("p"+(roundUpNearest10((chartObj.metrics.resultsMax/chartObj.metrics.targetsMax)*100)).toString())
return chartObj;
}
let current_obj = null;
// Create objects for each row in the data
for (let current_row = 0; current_row < chart.data.length; current_row++) {
current_obj = makechartObj(chart.data[current_row], current_row);
chart.groupObjs[current_obj.unique_id] = current_obj;
}
}
// These three formatter functions can be overwritten before rendering
/**
* Main formatter function used for display of values in bars
*/
chart.formatterValue = (d) => {
// If no decimals then format without the decimals
let dmod = Math.ceil(Math.log10(d + 1)) % 3;
if (dmod === 0) {
// If there are decimal points
return d3.format(".3s")(d);
} else {
return d3.format("."+(dmod+1)+"s")(d);
}
}
/**
* Main formatter function used for display of values in the tool tip
*/
chart.formatterValueToolTip = (d) => {
// Always return at least 1 decimal in abbreviated view
if (!d) {d = 0};
let dmod = Math.ceil(Math.log10(d + 1))%3;
if (dmod === 0) {
// If there are decimal points
return d3.format(".5s")(d);
} else {
return d3.format("."+(dmod+2)+"s")(d);
}
}
/**
* Main formatter function used for display of percentages in bars and the tooltip
*/
chart.formatterPercent = (d) => {
// If no decimals then format without the decimals
if ((d*100) % 1 !== 0) {
return d3.format(",.2%")(d);
} else {
return d3.format(",.0%")(d);
}
}
/**
* An example tooltip generator that takes the object the cursor is
* hovering over as a parameter and returns a text string.
* This method can be customized and overwritten.
* @param groupObj the subchart object with all properties of the subchart
* @param event the data of the specific object that is being hovered over, which is a subelement of the subchart
* @returns an html string that will be injected into the tooltip
*/
chart.tooltipGenerator = function(groupObj, event){
let tooltipString = '<span class="chart title">'+groupObj.title;
if (groupObj.subtitle) {
tooltipString += " | "+groupObj.subtitle;
}
let selected = "";
tooltipString += '</span><hr>Targets:'
let target_total = 0;
let target_string = ""
for (const target of groupObj.metrics.targets) {
if (event.name === target.name) {selected = "selected"}
if (chart.settings.cumulativeTargets) {
if (target.value > target_total) {target_total = target.value}
} else {
target_total += target.value
}
target_string += "<span class='"+selected+"'><span class='target title'>"+target.name+"</span> : <span class='target value '>"+chart.formatterValueToolTip(target.value)+"</span></span><br \>"
selected = ""
}
if (groupObj.metrics.targets.length > 1) {
tooltipString += " "+chart.formatterValueToolTip(target_total)+"<br \>"
} else {
tooltipString += "<br \>"
}
tooltipString += target_string
for (const target of groupObj.metrics.targetsMarkers) {
if (event.name === target.name) {selected = "selected"}
tooltipString += "<span class='"+selected+"'><span class='target-metrics title '>> "+target.name+"</span> : <span class='target-metrics value '>"+chart.formatterValueToolTip(target.value)+"</span></span><br \>"
selected = ""
}
tooltipString += '<hr>Results:'
let result_total = 0;
let result_string = ""
for (const result of groupObj.metrics.results) {
if (event.name === result.name) {selected = "selected"}
if (chart.settings.cumulativeResults) {
if (result.value > result_total) {result_total = +result.value}
} else {
result_total += +result.value
}
result_string += "<span class='"+selected+"'><span class='result title '>"+result.name+"</span> : <span class='result value '>"+chart.formatterValueToolTip(result.value)+" | "+chart.formatterPercent(result.percent_to_target)+"</span></span><br \>"
selected = ""
}
if (groupObj.metrics.results.length > 1) {
tooltipString += " "+chart.formatterValueToolTip(result_total)+"<br \>"
} else {
tooltipString += "<br \>"
}
tooltipString += result_string
for (const result of groupObj.metrics.resultsMarkers) {
if (event.name === result.name) {selected = "selected"}
tooltipString += "<span class='"+selected+"'><span class='result-marketer title '>> "+result.name+"</span> : <span class='result-marker value '>"+chart.formatterValueToolTip(result.value)+" | "+chart.formatterPercent(result.percent_to_target)+"</span></span><br \>"
selected = ""
}
return tooltipString
}
/**
* Renders the tooltip defined in the tooltip Generator.
*/
function tooltipRender(groupObj, event) {
return function () {
chart.objs.tooltip.transition().duration(200);
chart.objs.tooltip.html(chart.tooltipGenerator(groupObj, event))
};
}
/**
* Takes a string and makes it css class safe.
* @param name a text string.
* @returns {string} A text string that cab be used as a css class
*/
function makeSafeForCSS(name) {
// Modified from https://stackoverflow.com/a/7627603
// Spaces and _ are replaces with -
// Special characters are replaced with _
// Uppercase is replaced with lowercase
// If starts with a number, append an underscore
if (!name) {return ''}
name = name.replace(/[^a-z0-9-]/g, function(s) {
var c = s.charCodeAt(0);
if (c == 32 || c == 95) return '-';
if (c >= 65 && c <= 90) return s.toLowerCase();
return '_';
});
if (name.match(/^[0-9]/g)) {
// css can't start with a number
name = "_"+name
}
return name
}
chart.resize = function(event) {
if (chart.settings.respChartCols < 1) return;
let width = parseInt(chart.objs.mainDiv.style("width"), 10);
let chartWidth = Math.max(chart.settings.chartWidth * chart.settings.respChartCols, chart.settings.chartWidth) // Number of charts to show with a minimum of 1
if(width < chartWidth || (width > chart.currentChartWidth * chart.settings.respChartCols && width < chartWidth)) {
chart.currentChartWidth = width/chart.settings.respChartCols
chart.objs.g.attr("width",chart.currentChartWidth);
}
}
/**
* For each chartObj, calculate the relevant metrics that are affected by the size of the chart
* Range, width etc
*/
chart.update = function () {
function calcMethods(metrics) {
//These are the methods to convert raw data to position
let methods = {
xScale: null,
widthCalc: null,
calcTargetWidth: null,
calcResultWidth: null
};
if (!chart.settings._constrainToTarget) {
methods.xScale = d3.scaleLinear()
.domain([0, metrics.metricsMax])
.range([0, chart.barWidth]);
} else { // NOTE: Not Implemented
// We want to keep all ranges at 100%, use the max target as 100%.
// If this is the case, we may have ranges go over and will need to clamp the data.
// with the constrainToTargetAdj setting, the max of the bar can be reduced by a percentage to give some space for data larger than the target
methods.xScale = d3.scaleLinear()
.domain([0, metrics.targetsMax]) // * (1+chart.settings.constrainToTargetAdj)
.range([0, chart.barWidth]);
}
// Calculate the difference from minScale (=0) to a number
methods.calcWidth = (n) => {
//n||0 converts null to 0
return Math.abs(methods.xScale(n||0) - methods.xScale(0));
}
/**
* Calculates the width of a bar while taking into consideration the width of previous bars.
*/
const calcWidthLessPrevious = (values, cumulative) => {
return (n,i) => {
if (i > 0 && i <= values.length - 1 && cumulative) {
return Math.abs(methods.xScale(n.value) - methods.xScale(values[i - 1].value));
} else {
return methods.calcWidth(n.value);
}
};
}
methods.calcTargetWidth = calcWidthLessPrevious(metrics.targets, chart.settings.cumulativeTargets);
methods.calcResultWidth = calcWidthLessPrevious(metrics.results, chart.settings.cumulativeResults);
// For markers, position is simplified since we don't need to take into account other markers
methods.calcTargetMarkerXPos = (n) => {return methods.calcWidth(n.value)+chart.settings.titlePadding};
methods.calcResultMarkerXPos = (n) => {return methods.calcWidth(n.value)+chart.settings.titlePadding};
/**
* If the metrics is not cumulative, this sums all previous widths. If it is cumulative, it only gets the immediate predecessor.
*/
const calcXLessPrevious = (values, cumulative) => {
return function(d,i) {
let x = chart.settings.titlePadding;
if (i > 0 && i <= values.length - 1) {
for (let j = i-1; j >= 0; j--) {
x += methods.xScale(values[j].value)
if (cumulative) {break}
}
}
return x;
};
}
methods.calcTargetX = calcXLessPrevious(metrics.targets,chart.settings.cumulativeTargets);
methods.calcResultX = calcXLessPrevious(metrics.results,chart.settings.cumulativeResults);
metrics.targetGreater = methods.calcWidth(metrics.targetsMax) - methods.calcWidth(metrics.resultsMax);
/**
* Creates the rectangle for each target and result with rounded corners.
* This is done with path objects because svg rectangles do not support rounded corners on just specific corners.
* @param width_func The function that returns the width of the bar. Results or Targets
* @param radius The radius definition from settings
* @param last_index The value of the last index of the metric type. Used to identify if the bar is the last of its type or is a middle bar.
* @param hang_check The value of targetGreater. If positive, this bar "hangs" out from the one above or below it and the corners are adjusted.
* @param hasSummary True/False - If true, the summary bar is also being generated above this bar. (Note this is false for the summary bar itself)
* @param isSummary True/False - The current bar being generated is a summary bar
* @return {function(*, *): string}
*/
const pathFactory = (width_func, radius, last_index, hang_check, hasSummary, isSummary) => {
return function(d,i) {
let r = {t:{l:0,i:0,r:0},b:{l:0,i:0,r:0},hang:true} // radius = top [left, inside, right], bottom [left, inside, right]
let w = width_func(d, i)
, h = chart.barHeight
// Left side
if (i !== 0) {
r.t.l = radius.t.i;
r.b.l = radius.b.i;
} else {
r.t.l = hasSummary ? 0 : radius.t.l;
r.b.l = isSummary ? 0 : radius.b.l;
}
// Right Side
if (i !== last_index) {
r.t.r = radius.t.i;
r.b.r = radius.b.i;
} else {
/**
* - Get the largest of the top and bottom right radius from settings.
* - If the bar is hanging (as defined as larger than the one above or below) and hang is true
* -- Set the top and bottom radiuses to the same value, even if one of them wasn't initially set.
* --- For example, if the Target is smaller than the Result and the target has only a top radius set, then only the top radius will be rendered
* --- If the Target is greater than the result and only has a top radius set, both top and bottom radiuses will be rendered
* -- If the gap between the bars is less than the radius in settings, adjust down the radius to match the gap
* - If hang=false, it will not auto-adjust any of these settings and will only follow the radius settings
*/
let hr = Math.max(radius.b.r,radius.t.r);
if (radius.hang && hang_check > 0 && (radius.b.r === 0 || radius.t.r === 0)) {
if (hang_check < hr) {
r.t.r = hasSummary ? 0 : radius.t.r > 0 ? hr : hang_check; // If there is a summary, set to 0, else if the radius should be greater than 0, set to the max of top and bottom, else set the difference between the two bars (so if one bar overhangs the other by 2 px but the radius is 5px, the radius will be set to 2 px
r.b.r = radius.b.r > 0 ? hr : hang_check;
} else {
r.t.r = hasSummary ? 0 : hr;
r.b.r = isSummary ? 0 : hr;
}
} else {
r.t.r = hasSummary ? 0 : radius.t.r;
r.b.r = isSummary ? 0 : radius.b.r;
}
}
// If the width is less than the radiuses
if (w < (r.t.l + r.t.r) || w < (r.b.r + r.b.l)) {
let rt = w/2;
r.t.l = r.t.l ? rt : 0;
r.t.r = r.t.r ? rt : 0;
r.b.l = r.b.l ? rt : 0;
r.b.r = r.b.r ? rt : 0;
}
let top = Math.max(w - r.t.l - r.t.r,0) // top width = base_width - top radiuses
, right = h - r.t.r - r.b.r
, bottom = Math.max(w - r.b.r - r.b.l,0)
, left = h - r.b.l - r.t.l
// t=top, b=bottom, l=left, r=right, i=inside (between boxes)
// With line breaks for clarity. Without for minifying
// let path_string = `M${r.t.l},0
// h${top}
// a${r.t.r} ${r.t.r}, 0, 0, 1, ${r.t.r} ${r.t.r}
// v${right}
// a${r.b.r} ${r.b.r}, 0, 0, 1, -${r.b.r} ${r.b.r}
// h-${bottom}
// a${r.b.l} ${r.b.l}, 0, 0, 1, -${r.b.l} -${r.b.l}
// v-${left}
// a${r.t.l} ${r.t.l}, 0, 0, 1, ${r.t.l} -${r.t.l}
// z`
let path_string = `M${r.t.l},0 h${top} a${r.t.r} ${r.t.r}, 0, 0, 1, ${r.t.r} ${r.t.r} v${right} a${r.b.r} ${r.b.r}, 0, 0, 1, -${r.b.r} ${r.b.r} h-${bottom} a${r.b.l} ${r.b.l}, 0, 0, 1, -${r.b.l} -${r.b.l} v-${left} a${r.t.l} ${r.t.l}, 0, 0, 1, ${r.t.l} -${r.t.l} z`
return path_string
}
}
methods.resultsPath = pathFactory(methods.calcResultWidth, chart.settings.barRadiusResults, metrics.resultsLastIndex, -metrics.targetGreater, chart.settings.summarizeResults, false);
methods.targetsPath = pathFactory(methods.calcTargetWidth, chart.settings.barRadiusTargets, metrics.targetsLastIndex, metrics.targetGreater, chart.settings.summarizeTargets, false);
methods.resultsSummaryPath = pathFactory(methods.calcResultWidth, chart.settings.barRadiusResults, 0, -metrics.targetGreater, false, true);
methods.targetsSummaryPath = pathFactory(methods.calcTargetWidth, chart.settings.barRadiusTargets, 0, metrics.targetGreater, false, true);
// Formatting Methods
/**
* Generated classes for the targets bars
* @param d - chartObj
* @param i - index
* @return {string}
*/
methods.targetBarFormat = (d,i) => {
let return_text = "target s" + i; // Bar Index
// Width classes, every 25 pixels prepended with w
let width = methods.calcTargetWidth(d, i);
for (let i = 0; i <= Math.round(width); i+=chart.settings.w_threshold) {
return_text += " w"+`${i}`
}
// Target name, human-readable and raw
return_text += " "+makeSafeForCSS(d.column);
if (d.column !== d.name) {
return_text += " " + makeSafeForCSS(d.name);
}
if (d.classes && d.classes.length) {
return_text += d.classes.join(" ")
}
return return_text;
}
/**
* Wrapper function to keep the API the same and so custom formatters don't need to call d.value just d
*/
methods.targetTextLabel = (d, i) => {
return chart.formatterValue(d.value);
}
/**
* If targets are being summarized, that shifts everything down one barHeight
* @param d - chartObj
* @param i - index
* @return y position
*/
methods.targetsYPos = (d,i) => {
let y = 0;
if (chart.settings.summarizeTargets) {y+=chart.barHeight}
return y
}
/**
* Generated classes for the results bars
* @param d - chartObj
* @param i - index
* @return {string}
*/
methods.resultBarFormat = (d,i) => {
let return_text = "result s" + i; // Bar Index
// Width classes, every 25 pixels prepended with w
let width = methods.calcResultWidth(d, i);
for (let i = 0; i <= Math.round(width) ; i+=chart.settings.w_threshold) {
return_text += " w"+`${i}`
}
// Percent to target classes, every 10 percent prepended with p
for (let i = 0; i <= d.percent_to_target; i+=chart.settings.p_threshold) {
return_text += " p"+`${Math.round(i*100)}`
}
// Call out the last one for easy targeting
if (i === metrics.resultsLastIndex) {
return_text += " last";
}
// Target name, human-readable and raw
return_text += " "+makeSafeForCSS(d.column);
if (d.column !== d.name) {
return_text += " " + makeSafeForCSS(d.name);
}
if (d.classes && d.classes.length) {
return_text += d.classes.join(" ")
}
return return_text;
}
/**
* Change what text is shown on the bar depending on the size of the results bar.
* If the width is less than ~75, don't show the percentage
* The minimum width at which to show the precentage is a setting: chart.settings.minWidthForPercent
* @param d - chartObj
* @param i - index
* @return {string}
*/
methods.resultTextLabel = (d, i) => {
let return_text = chart.formatterValue(d.value);
let width = methods.calcResultWidth(d, i);
if (width >= chart.settings.minWidthForPercent) {
// Append percentage if there is room
return_text += " (" + chart.formatterPercent(d.percent_to_target) + ")";
}
return return_text;
}
/**
* If targets are being summarized or targets and results are summarized, that shifts everything down one or two barHeight
* @param d - chartObj
* @param i - index
* @return y position
*/
methods.resultsYPos = (d,i) => {
let y = chart.barHeight;
if (chart.settings.summarizeTargets) {y+=chart.barHeight}
if (chart.settings.summarizeResults) {y+=chart.barHeight}
return y
}
/**
* Generated classes for the target markers
* @param d - chartObj
* @param i - index
* @return {string}
*/
methods.targetMarkerFormat = (d, i) => {
let return_text = "marker s" + i;
if (d.classes && d.classes.length) {
return_text += d.classes.join(" ")
}
return_text += " "+makeSafeForCSS(d.column);
if (d.column !== d.name) {
return_text += " " + makeSafeForCSS(d.name);
}
return return_text;
};
/**
* Generated classes for the results markers
* @param d - chartObj
* @param i - index
* @return {string}
*/
methods.resultMarkerFormat = (d, i) => {
let return_text = "marker s" + i;
for (let i = 0; i <= d.percent_to_target; i+=chart.settings.p_threshold) {
return_text += " p"+`${Math.round(i*100)}`
}
if (d.classes && d.classes.length) {
return_text += d.classes.join(" ")
}
return_text += " "+makeSafeForCSS(d.column);
if (d.column !== d.name) {
return_text += " " + makeSafeForCSS(d.name);
}
return return_text;
};
return methods;
}
/**
* Build all the svg elements for each sub-chart object
*/
function buildChartObj(chartObj) {
if (chartObj.link) {
chartObj.g.node().parentNode.href=chartObj.link;
}
chartObj.svg.bars = chartObj.g.append("g").attr("class","bars");
chartObj.svg.targets = chartObj.svg.bars.append("g").attr("class","targets");
// Parent target bar svg
let g = chartObj.svg.targets.selectAll("svg")
.data(chartObj.metrics.targets)
.enter()
.append("svg")
.attr("class", chartObj.methods.targetBarFormat)
.attr("width", chartObj.methods.calcTargetWidth)
.attr("height", chart.barHeight)
.attr("y", chartObj.methods.targetsYPos)
.attr("x", chartObj.methods.calcTargetX)
g.append("path")
.attr("d", chartObj.methods.targetsPath)
g.append("text")
.attr("dy", '.1em')
.attr("y","50%")
.attr("x","50%")
.attr("dominant-baseline","middle")
.style("text-anchor", "middle")
.text(function(d, i) {
return chartObj.methods.targetTextLabel(d);
});
let xtEnd = chartObj.svg.targets.node().getBBox().width + chart.settings.titlePadding;
chartObj.svg.results = chartObj.svg.bars.append("g").attr("class","results");
let r = chartObj.svg.results.selectAll("svg")
.data(chartObj.metrics.results)
.enter()
.append("svg")
.attr("class", chartObj.methods.resultBarFormat)
.attr("width", chartObj.methods.calcResultWidth)
.attr("height", chart.barHeight)
.attr("x", chartObj.methods.calcResultX)
.attr("y",chartObj.methods.resultsYPos)
r.append("path")
.attr("d", chartObj.methods.resultsPath)
r.append("text")
.attr("dy", '.1em')
.attr("y","50%")
.attr("x","50%")
.attr("dominant-baseline","middle")
.attr("text-anchor", "middle")
.text(function(d, i) {
return chartObj.methods.resultTextLabel(d,i);
});
// If there is less than 75 pixels at the end of the bar, display the text summary below the bar
// otherwise display it at the end of the bar.
let xEnd = chartObj.svg.results.node().getBBox().width + chart.settings.titlePadding;
if (xEnd > chart.barWidth - 75) {
chartObj.svg.results.append("text")
.attr("class", "summary")
.attr("dy", '.1em')
.attr("y", function(d, i) {return (chartObj.methods.resultsYPos(d,i)+chart.barHeight) + 4 }) // Halfway through the second bar (results bar)
.attr("x", xEnd)
.attr("dominant-baseline", "hanging")
.attr("text-anchor", "end")
.text(chart.formatterValue(chartObj.metrics.resultsMax) + " (" + chart.formatterPercent(chartObj.metrics.resultsMax / chartObj.metrics.targetsMax) + ")")
} else {
chartObj.svg.results.append("text")
.attr("class", "summary")
.attr("dy", '.1em')
.attr("y", function(d, i) {return (chartObj.methods.resultsYPos(d,i)+chart.barHeight/2)}) // Halfway through the second bar (results bar)
.attr("x", xEnd + 5)
.attr("dominant-baseline", "middle")
.attr("text-anchor", "start")
.text(chart.formatterValue(chartObj.metrics.resultsMax) + " (" + chart.formatterPercent(chartObj.metrics.resultsMax / chartObj.metrics.targetsMax) + ")")
}
// Update the marker lines.
chartObj.svg.targetsMarkers = chartObj.svg.bars.append("g").attr("class","targets-markers");
let tm = chartObj.svg.targetsMarkers.selectAll("svg")
.data(chartObj.metrics.targetsMarkers)
.enter()
.append("line")
.attr("class", chartObj.methods.targetMarkerFormat)
.attr("x1", chartObj.methods.calcTargetMarkerXPos)
.attr("x2", chartObj.methods.calcTargetMarkerXPos)
.attr("y1", chartObj.methods.targetsYPos)
.attr("y2", function(d,i) {
return chartObj.methods.targetsYPos(d,i)+chart.barHeight
})
chartObj.svg.resultsMarkers = chartObj.svg.bars.append("g").attr("class","results-markers");
let rm = chartObj.svg.resultsMarkers.selectAll("svg")
.data(chartObj.metrics.resultsMarkers)
.enter()
.append("line")
.attr("class", chartObj.methods.resultMarkerFormat)
.attr("x1", chartObj.methods.calcResultMarkerXPos)
.attr("x2", chartObj.methods.calcResultMarkerXPos)
.attr("y1", chartObj.methods.resultsYPos)
.attr("y2", function(d,i) {
return chartObj.methods.resultsYPos(d,i)+chart.barHeight
})
// If the summary settings are activated build those boxes
if (chart.settings.summarizeTargets) {
chartObj.svg.targetsSummary = chartObj.svg.bars.append("g").attr("class", "targets-summary");
let ts = chartObj.svg.targetsSummary.append("svg")
.attr("class", "summary")
.attr("width", xtEnd - chart.settings.titlePadding)
.attr("height", chart.barHeight)
.attr("x", chart.settings.titlePadding)
.attr("y", "0")
ts.append("path")
.attr("class","target summary")
.attr("d", function() {return chartObj.methods.targetsSummaryPath({value:chartObj.metrics.targetsMax,name:'targetsSummary'},0)})
if ((xtEnd - chart.settings.titlePadding) <= chart.settings.minWidthForPercent) {
// If the length of the bar won't fit the full percent metrics, put the label at the end
chartObj.svg.resultsSummary.append("text")
.attr("class", "summary")
.attr("dy", '.1em')
.attr("y", chart.barHeight * .5)
.attr("x", xtEnd + 5)
.attr("dominant-baseline", "middle")
.attr("text-anchor", "start")
.text(chart.formatterValue(chartObj.metrics.targetsMax))
} else {
ts.append("text")
.attr("dy", '.1em')
.attr("y", "50%")
.attr("x", "50%")
.attr("dominant-baseline", "middle")
.attr("text-anchor", "middle")
.text(chart.formatterValue(chartObj.metrics.targetsMax))
}
}
if (chart.settings.summarizeResults) {
chartObj.svg.resultsSummary = chartObj.svg.bars.append("g").attr("class","results-summary");
let rs = chartObj.svg.resultsSummary.append("svg")
.attr("class", "summary")
.attr("width", xEnd - chart.settings.titlePadding)
.attr("height", chart.barHeight)
.attr("x", chart.settings.titlePadding)
.attr("y",function(d,i) {return chartObj.methods.targetsYPos(1,1)+chart.barHeight})
rs.append("path")
.attr("class", "result summary")
.attr("d", function() {return chartObj.methods.resultsSummaryPath({value:chartObj.metrics.resultsMax,name:'resultsSummary'},0)})
if ((xEnd-chart.settings.titlePadding) <= chart.settings.minWidthForPercent) {
// If the length of the bar won't fit the full percent metrics, put the label at the end
chartObj.svg.resultsSummary.append("text")
.attr("class","summary")
.attr("dy", '.1em')
.attr("y",function(d,i) {return chartObj.methods.targetsYPos(1,1)+(chart.barHeight*1.5)})
.attr("x", xEnd+5)
.attr("dominant-baseline", "middle")
.attr("text-anchor", "start")
.text(chart.formatterValue(chartObj.metrics.resultsMax) + " (" + chart.formatterPercent(chartObj.metrics.resultsMax / chartObj.metrics.targetsMax) + ")")
} else {
rs.append("text")
.attr("dy", '.1em')
.attr("y", "50%")
.attr("x", "50%")
.attr("dominant-baseline", "middle")
.attr("text-anchor", "middle")
.text(chart.formatterValue(chartObj.metrics.resultsMax) + " (" + chart.formatterPercent(chartObj.metrics.resultsMax / chartObj.metrics.targetsMax) + ")")
}
}
return chartObj;
}
for (const p in chart.groupObjs) {
chart.groupObjs[p].methods = calcMethods(chart.groupObjs[p].metrics);
buildChartObj(chart.groupObjs[p]);
}
}
chart.set(settings);
/**
* Prepare the chart html elements
*/
chart.render = function() {
prepareData()
// Build main div and chart div
chart.objs.mainDiv = d3.select(chart.settings.selector);
chart.objs.mainDiv.node().classList.add("pace-chart");
// Add all the divs to make it centered and responsive
chart.objs.mainDiv.append("div").attr("class", "inner-box").style("display","flex").style("flex-wrap","wrap");
// Capture the inner div for the chart (where the chart actually is)
chart.selector = chart.settings.selector + " .inner-box";
chart.objs.chartDiv = d3.select(chart.selector);
// Create the svg
chart.objs.g = chart.objs.chartDiv.selectAll("div.chart-area")
.data(chart.data)
.enter()
.append("div")
.attr("class", "group chart-area")
// If a link col was provided. Wrap each chart in "a" tags
if (chart.settings.linkCol) {
chart.objs.g = chart.objs.g
.append("a")
.attr("target","_blank")
.attr("rel","noreferrer noopener")
}
chart.objs.g = chart.objs.g
.append("svg")
.attr("width", chart.width)
.attr("height", chart.height)
.attr("viewBox","0 0 "+chart.width+" "+chart.height);
chart.objs.titles = chart.objs.g.append("g")
.style("text-anchor", "end")
.attr("class", "titles")
chart.objs.titles.append("text")
.attr("class", "title")
.attr("x",chart.settings.titlePadding-5)
.attr("y",(chart.barHeight * (2 + chart.settings.summarizeTargets + chart.settings.summarizeResults))/2)
.text(function(d) {
return d[chart.settings.titleCols[0]];
});
chart.objs.titles.append("text")
.attr("class", "subtitle")
.attr("dy", "1em")
.attr("x",chart.settings.titlePadding-5)
.attr("y",(chart.barHeight * (2 + chart.settings.summarizeTargets + chart.settings.summarizeResults))/2)
.text(function(d) {
return d[chart.settings.titleCols[1]];
});
// Resize update hook
// If chart.settings.respChartCols = 0, no responsiveness
if (chart.settings.respChartCols > 0) {
d3.select(window).on('resize.' + chart.selector, function (d) {chart.resize(d)});
}
// Create tooltip div
chart.objs.tooltip = chart.objs.mainDiv.append('div').attr('class', 'tooltip');
// Create each chart divs
chart.objs.g.each(
function(g,i) {
for (let unique_id in chart.groupObjs) {
if (unique_id === g['unique_id']) {
// To make the dom elements easier to reference, add them to the chartObjects object
chart.groupObjs[unique_id].g = d3.select(this)
chart.groupObjs[unique_id].g.attr("class", makeSafeForCSS(chart.groupObjs[unique_id].title) + " " + makeSafeForCSS(chart.groupObjs[unique_id].subtitle));
chart.groupObjs[unique_id].g.attr("id", chart.groupObjs[unique_id].unique_id);
// Add the mouseover
chart.groupObjs[unique_id].g.on("mouseover", function (event, d) {
chart.objs.tooltip
.style("display", null)
.style("left", (event.pageX) + "px")
.style("top", (event.pageY - 28) + "px");
}).on("mouseout", function () {
chart.objs.tooltip.style("display", "none");
}).on("mousemove", function (event, d) {
chart.objs.tooltip
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 10) + "px");
tooltipRender(chart.groupObjs[unique_id], event.target.__data__)()
})
}
}
}
);
chart.update();
return chart;
};
return chart;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment