Merge pull request #262 from dod-ccpo/ui/cumulative-budget-chart

Ui/cumulative budget chart
This commit is contained in:
andrewdds 2018-09-12 08:51:02 -04:00 committed by GitHub
commit 776796beee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 450 additions and 17 deletions

View File

@ -196,3 +196,11 @@ class Reports:
"projects": project_totals, "projects": project_totals,
"workspace": workspace_totals, "workspace": workspace_totals,
} }
@classmethod
def cumulative_budget(cls, alternate):
return {
"months": CUMULATIVE_BUDGET_BELUGA
if alternate
else CUMULATIVE_BUDGET_AARDVARK
}

View File

@ -87,6 +87,7 @@ def workspace_reports(workspace_id):
return render_template( return render_template(
"workspaces/reports/index.html", "workspaces/reports/index.html",
cumulative_budget=Reports.cumulative_budget(alternate_reports),
workspace_totals=Reports.workspace_totals(alternate_reports), workspace_totals=Reports.workspace_totals(alternate_reports),
monthly_totals=Reports.monthly_totals(alternate_reports), monthly_totals=Reports.monthly_totals(alternate_reports),
current_month=current_month, current_month=current_month,

View File

@ -0,0 +1,135 @@
import { format } from 'date-fns'
import { abbreviateDollars, formatDollars } from '../../lib/dollars'
const TOP_OFFSET = 20
const BOTTOM_OFFSET = 60
const CHART_HEIGHT = 360
export default {
name: 'budget-chart',
props: {
currentMonth: String,
months: Object,
budget: String
},
data: function () {
const heightScale = this.budget / (CHART_HEIGHT - TOP_OFFSET - BOTTOM_OFFSET)
return {
numMonths: 10,
focusedMonthPosition: 4,
height: CHART_HEIGHT,
heightScale,
budgetHeight: CHART_HEIGHT - BOTTOM_OFFSET - (this.budget / heightScale),
baseHeight: CHART_HEIGHT - BOTTOM_OFFSET,
width: 0,
displayedMonths: [],
spendPath: '',
projectedPath: '',
displayBudget: formatDollars(parseFloat(this.budget))
}
},
mounted: function () {
this._setDisplayedMonths()
this._setMetrics()
addEventListener('resize', this._setMetrics)
},
methods: {
_setMetrics: function () {
this.width = this.$refs.panel.clientWidth
this.spendPath = ''
this.projectedPath = ''
let lastSpend = 0
let lastSpendPoint = ''
for (let i = 0; i < this.numMonths; i++) {
const { metrics, budget } = this.displayedMonths[i]
const blockWidth = (this.width / this.numMonths)
const blockX = blockWidth * i
const spend = budget
? budget.spend || lastSpend
: 0
const cumulative = budget
? budget.cumulative || budget.projected
: 0
const barHeight = spend / this.heightScale
lastSpend = spend
const cumulativeY = this.height - (cumulative / this.heightScale) - BOTTOM_OFFSET
const cumulativeX = blockX + blockWidth/2
const cumulativePoint = `${cumulativeX} ${cumulativeY}`
this.displayedMonths[i].metrics = Object.assign(metrics, {
blockWidth,
blockX,
barHeight,
barWidth: 30,
barX: blockX + (blockWidth/2 - 15),
barY: this.height - barHeight - BOTTOM_OFFSET,
cumulativeR: 2.5,
cumulativeY,
cumulativeX
})
if (budget && budget.spend) {
this.spendPath += this.spendPath === '' ? 'M' : ' L'
this.spendPath += cumulativePoint
lastSpendPoint = cumulativePoint
}
if (budget && budget.projected) {
this.projectedPath += this.projectedPath === '' ? `M${lastSpendPoint} L` : ' L'
this.projectedPath += cumulativePoint
}
}
},
_setDisplayedMonths: function () {
const [month, year] = this.currentMonth.split('/')
const monthsRange = []
const monthsBack = this.focusedMonthPosition
const monthsForward = this.numMonths - this.focusedMonthPosition - 1
const start = new Date(year, month - 1 - monthsBack)
let previousAmount = 0
for (let i = 0; i < this.numMonths; i++) {
const date = new Date(start.getFullYear(), start.getMonth() + i)
const index = format(date, 'MM/YYYY')
const budget = this.months[index] || null
const spendAmount = budget ? budget.spend || previousAmount : 0
const cumulativeAmount = budget ? budget.cumulative || budget.projected : 0
previousAmount = spendAmount
monthsRange.push({
budget,
spendAmount: formatDollars(spendAmount),
abbreviatedSpend: abbreviateDollars(spendAmount),
cumulativeAmount: formatDollars(cumulativeAmount),
abbreviatedCumulative: abbreviateDollars(cumulativeAmount),
date: {
monthIndex: format(date, 'M'),
month: format(date, 'MMM'),
year: format(date,'YYYY')
},
isHighlighted: this.currentMonth === index,
metrics: {
blockWidth: 0,
blockX: 0,
barHeight: 0,
barWidth: 0,
barX: 0,
barY: 0,
cumulativeY: 0,
cumulativeX: 0,
cumulativeR: 0
}
})
}
this.displayedMonths = monthsRange
}
}
}

View File

@ -1,3 +1,4 @@
import 'svg-innerhtml'
import 'babel-polyfill' import 'babel-polyfill'
import classes from '../styles/atat.scss' import classes from '../styles/atat.scss'
@ -14,6 +15,7 @@ import toggler from './components/toggler'
import NewProject from './components/forms/new_project' import NewProject from './components/forms/new_project'
import Modal from './mixins/modal' import Modal from './mixins/modal'
import selector from './components/selector' import selector from './components/selector'
import BudgetChart from './components/charts/budget_chart'
Vue.use(VTooltip) Vue.use(VTooltip)
@ -30,7 +32,8 @@ const app = new Vue({
poc, poc,
financial, financial,
NewProject, NewProject,
selector selector,
BudgetChart
}, },
mounted: function() { mounted: function() {
const modalOpen = document.querySelector("#modalOpen") const modalOpen = document.querySelector("#modalOpen")

12
js/lib/dollars.js Normal file
View File

@ -0,0 +1,12 @@
export const formatDollars = value => `$${value.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,')}`
export const abbreviateDollars = (value, decimals = 1) => {
if (value === null) { return null } // terminate early
if (value === 0) { return '0' } // terminate early
var b = (value).toPrecision(2).split("e"), // get power
k = b.length === 1 ? 0 : Math.floor(Math.min(b[1].slice(1), 14) / 3), // floor at decimals, ceiling at trillions
c = k < 1 ? value.toFixed(0 + decimals) : (value / Math.pow(10, k * 3) ).toFixed(decimals), // divide by power
d = c < 0 ? c : Math.abs(c), // enforce -0 is 0
e = d + ['', 'k', 'M', 'B', 'T'][k]; // append power
return e;
}

View File

@ -13,8 +13,10 @@
"dependencies": { "dependencies": {
"autoprefixer": "^9.1.3", "autoprefixer": "^9.1.3",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"date-fns": "^1.29.0",
"npm": "^6.0.1", "npm": "^6.0.1",
"parcel": "^1.9.7", "parcel": "^1.9.7",
"svg-innerhtml": "^1.1.0",
"text-mask-addons": "^3.8.0", "text-mask-addons": "^3.8.0",
"uswds": "^1.6.3", "uswds": "^1.6.3",
"v-tooltip": "^2.0.0-rc.33", "v-tooltip": "^2.0.0-rc.33",

View File

@ -34,7 +34,7 @@
@import 'components/search_bar'; @import 'components/search_bar';
@import 'components/forms'; @import 'components/forms';
@import 'components/selector'; @import 'components/selector';
@import 'components/budget_chart';
@import 'sections/login'; @import 'sections/login';
@import 'sections/request_approval'; @import 'sections/request_approval';

View File

@ -0,0 +1,130 @@
.budget-chart {
.budget-chart__header {
border-bottom: 1px solid $color-gray-light;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.budget-chart__legend {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
dl {
margin: 0 0 0 ($gap * 2);
> div {
margin: 0;
display: flex;
flex-direction: row-reverse;
align-items: center;
dt {
@include small-label;
}
.budget-chart__legend__dot {
width: $gap;
height: $gap;
border-radius: $gap / 2;
margin: 0 $gap;
&.accumulated {
background-color: $color-gold;
}
&.monthly {
background-color: $color-blue;
}
}
.budget-chart__legend__line {
height: 2px;
width: $gap * 3;
border-top-width: 2px;
border-top-style: dashed;
margin: $gap;
&.spend {
border-color: $color-blue;
}
&.accumulated {
border-color: $color-gold;
}
}
}
}
}
}
.budget-chart__block {
fill: $color-white;
cursor: pointer;
&--highlighted {
fill: $color-aqua-lightest;
}
&:hover {
fill: $color-aqua-lightest;
}
}
svg {
display: block;
a {
text-decoration: none;
&:focus {
outline: none;
stroke: $color-gray-light;
stroke-dasharray: 2px;
}
}
}
.budget-chart__bar {
fill: $color-blue;
&--projected {
fill: transparent;
stroke-width: 2px;
stroke: $color-blue;
stroke-dasharray: 4px;
}
}
.budget-chart__cumulative__dot {
fill: $color-gold;
}
.budget-chart__projected-path {
stroke-width: 1px;
stroke: $color-gold;
stroke-dasharray: 4px;
fill: none;
}
.budget-chart__spend-path {
stroke-width: 1px;
stroke: $color-gold;
fill: none;
}
.budget-chart__budget-line {
stroke-width: 2px;
stroke: $color-gray-light;
stroke-dasharray: 4px;
}
.budget-chart__label {
@include small-label;
fill: $color-gray;
&--strong {
fill: $color-black;
}
}
}

View File

@ -42,5 +42,6 @@
&.col--grow { &.col--grow {
flex: 1; flex: 1;
flex-grow: 1; flex-grow: 1;
overflow: auto
} }
} }

View File

@ -62,13 +62,13 @@
} }
.panel__heading { .panel__heading {
margin: $gap * 2; padding: $gap * 2;
@include media($medium-screen) { @include media($medium-screen) {
margin: $gap * 4; padding: $gap * 4;
} }
&--tight { &--tight {
margin: $gap*2; padding: $gap*2;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {

View File

@ -68,3 +68,9 @@ dl {
margin-bottom: $gap * 2; margin-bottom: $gap * 2;
} }
} }
@mixin small-label {
font-size: $h6-font-size;
font-weight: $font-bold;
color: $color-black;
}

View File

@ -134,16 +134,16 @@
{% endcall %} {% endcall %}
<div is='toggler' default-visible class='block-list project-list-item'> <div is='toggler' default-visible class='block-list project-list-item'>
<template slot-scope='{ isVisible, toggle }'> <template slot-scope='props'>
<header class='block-list__header'> <header class='block-list__header'>
<button v-on:click='toggle' class='icon-link icon-link--large icon-link--default spend-table__project__toggler'> <button type='button' v-on:click='props.toggle' class='icon-link icon-link--large icon-link--default spend-table__project__toggler'>
<template v-if='isVisible'>{{ Icon('caret_down') }}</template> <template v-if='props.isVisible'>{{ Icon('caret_down') }}</template>
<template v-else>{{ Icon('caret_right') }}</template> <template v-else>{{ Icon('caret_right') }}</template>
<h3 class="block-list__title">Code.mil</h3> <h3 class="block-list__title">Code.mil</h3>
</button> </button>
<span><a href="#" class="icon-link icon-link--danger">revoke all access</a></span> <span><a href="#" class="icon-link icon-link--danger">revoke all access</a></span>
</header> </header>
<ul v-show='isVisible'> <ul v-show='props.isVisible'>
<li class='block-list__item project-list-item__environment'> <li class='block-list__item project-list-item__environment'>
<span class='project-list-item__environment'> <span class='project-list-item__environment'>
Development Development
@ -173,16 +173,16 @@
</div> </div>
<div is="toggler" class='block-list project-list-item'> <div is="toggler" class='block-list project-list-item'>
<template slot-scope='{ isVisible, toggle }'> <template slot-scope='props'>
<header class='block-list__header'> <header class='block-list__header'>
<button v-on:click='toggle' class='icon-link icon-link--large icon-link--default spend-table__project__toggler'> <button type='button' v-on:click='props.toggle' class='icon-link icon-link--large icon-link--default spend-table__project__toggler'>
<template v-if='isVisible'>{{ Icon('caret_down') }}</template> <template v-if='props.isVisible'>{{ Icon('caret_down') }}</template>
<template v-else>{{ Icon('caret_right') }}</template> <template v-else>{{ Icon('caret_right') }}</template>
<h3 class="block-list__title">Digital Dojo</h3> <h3 class="block-list__title">Digital Dojo</h3>
</button> </button>
<span class="label">no access</span> <span class="label">no access</span>
</header> </header>
<ul v-show='isVisible'> <ul v-show='props.isVisible'>
<li class='block-list__item project-list-item__environment'> <li class='block-list__item project-list-item__environment'>
<span class='project-list-item__environment'> <span class='project-list-item__environment'>
Development Development

View File

@ -93,6 +93,133 @@
{% set current_month_index = current_month.strftime('%m/%Y') %} {% set current_month_index = current_month.strftime('%m/%Y') %}
{% set prev_month_index = prev_month.strftime('%m/%Y') %} {% set prev_month_index = prev_month.strftime('%m/%Y') %}
{% set two_months_ago_index = two_months_ago.strftime('%m/%Y') %} {% set two_months_ago_index = two_months_ago.strftime('%m/%Y') %}
{% set reports_url = url_for("workspaces.workspace_reports", workspace_id=workspace.id) %}
<budget-chart budget={{ budget }} current-month='{{ current_month_index }}' v-bind:months='{{ cumulative_budget.months | tojson }}' inline-template>
<div class='budget-chart panel' ref='panel'>
<header class='budget-chart__header panel__heading panel__heading--tight'>
<h2 class='h3'>Cumulative Budget</h2>
<div class='budget-chart__legend'>
<dl class='budget-chart__legend__spend'>
<div>
<dt>Monthly Spend</dt>
<dd class='budget-chart__legend__dot monthly'><span class='usa-sr-only'>Monthly spend visual key</span></dd>
</div>
<div>
<dt>Accumulated Spend</dt>
<dd class='budget-chart__legend__dot accumulated'><span class='usa-sr-only'>Accumulated spend visual key</span></dd>
</div>
</dl>
<dl class='budget-chart__legend__projected'>
<div>
<dt>Projected</dt>
<dd>
<div class='budget-chart__legend__line spend'><span class='usa-sr-only'>Projected monthly spend visual key</span></div>
<div class='budget-chart__legend__line accumulated'><span class='usa-sr-only'>Projected accumulated spend visual key</span></div>
</dd>
</div>
</dl>
</div>
</header>
<svg v-bind:height='height' v-bind:width='width'>
<g v-for='month in displayedMonths' >
{# make this clickable to focus on that month #}
<a v-bind:href='"{{ reports_url }}?month=" + month.date.monthIndex + "&year=" + month.date.year'>
<title>
<span v-html='month.date.month + " " + month.date.year'></span>&nbsp;|&nbsp;<!--
--><template v-if='month.budget'><!--
--><template v-if='month.budget.spend'>Spend:</template><!--
--><template v-if='month.budget.projected'>Projected Spend:</template><!--
--><span v-html='month.spendAmount'></span><!--
-->&nbsp;|&nbsp;<!--
--><template v-if='month.budget.cumulative'>Total:</template><!--
--><template v-if='month.budget.projected'>Projected Total:</template><!--
--><span v-html='month.cumulativeAmount'></span><!--
--></template><!--
--><template v-else>No spend for this month</template>
</title>
{# container block #}
<rect
class='budget-chart__block'
v-bind:class='{ "budget-chart__block--highlighted": month.isHighlighted }'
v-bind:width='month.metrics.blockWidth'
v-bind:x='month.metrics.blockX'
v-bind:height='height'></rect>
{# budget bar #}
<rect
v-if='month.budget'
class='budget-chart__bar'
v-bind:class='{ "budget-chart__bar--projected": month.budget.projected }'
v-bind:width='month.metrics.barWidth'
v-bind:height='month.metrics.barHeight'
v-bind:x='month.metrics.barX'
v-bind:y='month.metrics.barY'></rect>
{# cumulative dot #}
<circle
v-if='month.budget'
class='budget-chart__cumulative__dot'
v-bind:r='month.metrics.cumulativeR'
v-bind:cx='month.metrics.cumulativeX'
v-bind:cy='month.metrics.cumulativeY'></circle>
{# abbreviated cumulative label #}
<text
v-if='month.budget'
v-bind:x='month.metrics.cumulativeX'
v-bind:y='month.metrics.cumulativeY - 10'
text-anchor='middle'
class='budget-chart__label'
v-html='month.abbreviatedCumulative'></text>
{# abbreviated spend label #}
<text
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 20'
text-anchor='middle'
class='budget-chart__label'
v-html='"+" + month.abbreviatedSpend'></text>
{# month label #}
<text
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 40'
text-anchor='middle'
class='budget-chart__label budget-chart__label--strong'
v-html='month.date.month'></text>
</g>
</a>
{# spend/projected budget path lines #}
<path class='budget-chart__projected-path' v-bind:d='projectedPath'></path>
<path class='budget-chart__spend-path' v-bind:d='spendPath'></path>
{# max budget line #}
<line
class='budget-chart__budget-line'
x1='0'
v-bind:x2='width'
v-bind:y1='budgetHeight'
v-bind:y2='budgetHeight'></line>
<text
x='20'
v-bind:y='budgetHeight + 20'
class='budget-chart__label'>Total Budget</text>
<text
x='20'
v-bind:y='budgetHeight + 40'
class='budget-chart__label'
v-html='displayBudget'></text>
</svg>
</div>
</budget-chart>
<div class='spend-table responsive-table-wrapper'> <div class='spend-table responsive-table-wrapper'>
<div class='spend-table__header'> <div class='spend-table__header'>
@ -126,11 +253,11 @@
{% for project_name, project_totals in monthly_totals['projects'].items() %} {% for project_name, project_totals in monthly_totals['projects'].items() %}
<tbody is='toggler' class='spend-table__project'> <tbody is='toggler' class='spend-table__project'>
<template slot-scope='{ isVisible, toggle }'> <template slot-scope='props'>
<tr> <tr>
<th scope='rowgroup'> <th scope='rowgroup'>
<button v-on:click='toggle' class='icon-link icon-link--large spend-table__project__toggler'> <button v-on:click='props.toggle' class='icon-link icon-link--large spend-table__project__toggler'>
<template v-if='isVisible'>{{ Icon('caret_down') }}</template> <template v-if='props.isVisible'>{{ Icon('caret_down') }}</template>
<template v-else>{{ Icon('caret_right') }}</template> <template v-else>{{ Icon('caret_right') }}</template>
{{ project_name }} {{ project_name }}
</button> </button>
@ -145,7 +272,7 @@
</tr> </tr>
{% for env_name, env_totals in monthly_totals['environments'][project_name].items() %} {% for env_name, env_totals in monthly_totals['environments'][project_name].items() %}
<tr v-show='isVisible'> <tr v-show='props.isVisible'>
<th scope='rowgroup'><a href='#' class='icon-link spend-table__project__env'>{{ Icon('link') }} {{ env_name }}</a></th> <th scope='rowgroup'><a href='#' class='icon-link spend-table__project__env'>{{ Icon('link') }} {{ env_name }}</a></th>
<td class='table-cell--align-right previous-month'>{{ env_totals.get(two_months_ago_index, 0) | dollars }}</td> <td class='table-cell--align-right previous-month'>{{ env_totals.get(two_months_ago_index, 0) | dollars }}</td>
<td class='table-cell--align-right previous-month'>{{ env_totals.get(prev_month_index, 0) | dollars }}</td> <td class='table-cell--align-right previous-month'>{{ env_totals.get(prev_month_index, 0) | dollars }}</td>

View File

@ -1811,6 +1811,10 @@ dashdash@^1.12.0:
dependencies: dependencies:
assert-plus "^1.0.0" assert-plus "^1.0.0"
date-fns@^1.29.0:
version "1.29.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6"
date-now@^0.1.4: date-now@^0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
@ -6271,6 +6275,10 @@ supports-color@^5.3.0, supports-color@^5.4.0:
dependencies: dependencies:
has-flag "^3.0.0" has-flag "^3.0.0"
svg-innerhtml@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/svg-innerhtml/-/svg-innerhtml-1.1.0.tgz#5e5d1efbc32596479e73a1e8e221d1222678b678"
svgo@^0.7.0: svgo@^0.7.0:
version "0.7.2" version "0.7.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5" resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5"