diff --git a/app/css/style.css b/app/css/style.css new file mode 100644 index 0000000..254e300 --- /dev/null +++ b/app/css/style.css @@ -0,0 +1,135 @@ +#panels.div-panels { + height: 100%; + width: 500px; +} + +#selectors.div-selectors { + margin-bottom: 13px; +} + +body { + font-family: 'Fira Sans', sans-serif; + font-size: 13px; + fill: #2c2825; +} + +.selector { + font-family: 'Fira Sans', sans-serif; + font-size: 13px; + fill: #2c2825; +} + +.title { + font-family: 'Fira Sans', sans-serif; + font-size: 16px; + fill: #2c2825; + font-weight: bold; +} + +.bar rect { + shape-rendering: crispEdges; +} + +.bar text { + fill: #2c2825; +} + +.axis path, +.axis line { + fill: none; + stroke: #686f73; + shape-rendering: crispEdges; +} + +.axis text { + font-family: 'Fira Sans', sans-serif; + font-size: 13px; + fill: #686f73; +} + +svg.amb, +svg.walkin, +svg.daycase_n, +svg.emer_n, +svg.emer_bds, +svg.ordelec_n, +svg.ordelec_bds, +svg.surg_first, +svg.surg_fup, +svg.surg_proc, +svg.non-surg_first, +svg.non-surg_fup, +svg.non-surg_proc { + background-color: #f2f2f2; + border: 1px solid #686f73; + margin: 2px; +} + +div.tooltip { + position: absolute; + text-align: center; + vertical-align: middle; + line-height: normal; + width: 40px; + height: 20px; + padding: 10px; + font-family: 'Fira Sans', sans-serif; + font-size: 13px; + background: #686f73; + border: 0px; + border-radius: 8px; + pointer-events: none; + z-index: 1001; + color: #f2f2f2; +} + +/* health status checkbox */ +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #c2c5c7; + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 10px; +} + +.toggle:before { + position: absolute; + content: ''; + height: 16px; + width: 18px; + left: 2px; + bottom: 2px; + top: 2px; + background-color: #ffffff; + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 50%; +} + +input:checked + .toggle { + background-color: #686f73; +} + +input:checked + .toggle:before { + -webkit-transform: translateX(18px); + -ms-transform: translateX(18px); + transform: translateX(18px); +} diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..e00bfd2 --- /dev/null +++ b/app/index.html @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + +
+

Choose an area

+ + +

Choose a service type

+ + +

Choose a model horizon

+ + + +

Choose a projection variant

+ +

Show modelled change without adjustment for health-status

+ +
+ +
+ + + + diff --git a/app/js/index.js b/app/js/index.js new file mode 100644 index 0000000..7a5adf7 --- /dev/null +++ b/app/js/index.js @@ -0,0 +1,75 @@ +/* import variables */ +import { plotTitleSpacer, margin, width, height, transDur, blobDir, filePrefix, fileExt } from './main.js' +/* import d3 functions */ +import { d3 } from './main.js' +import { xScale, colorPal, tooltip } from './main.js' +/* import udf functions */ +import { mouseOver, mouseOut } from './main.js' +import { plotHsaGrps } from './main.js' +import { updatePlots } from './main.js' +import { switchPod } from './main.js' +import { switchArea } from './main.js' +import { toggleHsa } from './main.js' + +const selectedArea = 'E08000026' +const selectedProjVar = '1' +const selectedHorizon = '2025' +const selectedPod = 'aae' + +const data = await d3.csv(blobDir + filePrefix + selectedArea + fileExt) + +const grpDat = d3.group( + data, + (d) => d.proj_id, + (d) => d.end_year, + (d) => d.pod +) + +const projDat = grpDat + .get(selectedProjVar) + .get(selectedHorizon) + .get(selectedPod) + +plotHsaGrps(projDat) + +/* hack */ +d3.selectAll('.circle').style('opacity', 0) + +/* when the area dropdown changes, run switchArea() with the new value */ +d3.select('#selectArea').on('change', function () { + let selectedArea = d3.select(this).property('value') + let selectedProjVar = d3.select('#selectProjVar').property('value') + let selectedHorizon = d3.select('#selectHorizon').property('value') + let selectedPod = d3.select('#selectPod').property('value') + switchArea(selectedArea, selectedProjVar, selectedHorizon, selectedPod) +}) + +/* when the pod dropdown changes, run switchPod() with the new value */ +d3.select('#selectPod').on('change', function () { + let selectedPod = d3.select(this).property('value') + let selectedProjVar = d3.select('#selectProjVar').property('value') + let selectedHorizon = d3.select('#selectHorizon').property('value') + switchPod(data, selectedProjVar, selectedHorizon, selectedPod) +}) + +/* when the model horizon dropdown changes, run updatePlots() with the new value */ +d3.select('#selectHorizon').on('change', function () { + let selectedHorizon = d3.select(this).property('value') + let selectedProjVar = d3.select('#selectProjVar').property('value') + let selectedPod = d3.select('#selectPod').property('value') + updatePlots(grpDat, selectedProjVar, selectedHorizon, selectedPod) +}) + +/* when the projection variant dropdown changes, run updatePlots() with the new value */ +d3.select('#selectProjVar').on('change', function () { + let selectedProjVar = d3.select(this).property('value') + let selectedHorizon = d3.select('#selectHorizon').property('value') + let selectedPod = d3.select('#selectPod').property('value') + updatePlots(grpDat, selectedProjVar, selectedHorizon, selectedPod) +}) + +/* when the health status toggle changes show/hide circles */ +d3.select('#toggleHsa').on('change', function () { + const opacity = this.checked ? 1 : 0 + toggleHsa(opacity) +}) diff --git a/app/js/main.js b/app/js/main.js new file mode 100644 index 0000000..2464601 --- /dev/null +++ b/app/js/main.js @@ -0,0 +1,332 @@ +/* load D3 v7 from jsDeliver */ +import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm' + +/* d3.histogram() function computes the binning and returns the coordinates of +each bar. Bars can then be drawn using a classic .append("rect") */ + +/* set dimensions and margins for small multiple plots */ +const plotTitleSpacer = 20 +const margin = { top: 20 + plotTitleSpacer, left: 20, right: 20, bottom: 10 } +const width = 500 - margin.left - margin.right +const height = 200 - margin.top - margin.bottom + +/* set file path */ +const blobDir = + 'https://strategyunit.blob.core.windows.net/population-aging-app/' +const filePrefix = 'test_results_' +const fileExt = '.csv' + +/* set transition duration for updatePlots fn */ +const transDur = 1800 + +/* define axes - a 1D histogram only requires an x-axis */ +const xScale = d3.scaleLinear().range([margin.left, width]) + +/* define a color palette (histogram bins) */ +/* color scale maps to counts of values in bins; domain([min, median, max]) */ +const colorPal = d3 + .scaleDiverging((d) => d3.interpolateViridis(1 - d)) + .domain([0, 40, 80]) + +/* define a tooltip */ +const tooltip = d3 + .select('body') + .append('div') + .attr('class', 'tooltip') + .attr('id', 'tooltip-hsa') + .style('opacity', 0) + +/* fn: mouseover for tooltip */ +function mouseOver(event, d) { + if (d3.select(this.parentNode).style('opacity') != 0) { + const svg = d3.select(this.parentNode.parentNode) + const [x, y] = d3.pointer(event, svg) + const text = d3.select('#tooltip-hsa') + text.text(d3.format(',.2f')(d.x) + '%') + tooltip + .style('left', x + 'px') + .style('top', y + 'px') + .transition() + .duration(1000) + .style('opacity', 1) + d3.select(this).transition().duration(100).attr('r', 8) + } +} + +/* fn: mouseout for tooltip */ +function mouseOut() { + d3.select(this).transition().duration(1000).attr('r', 4) + tooltip.transition().delay(1000).style('opacity', 0) +} + +/* fn: plot panel of histograms for hsagrps in a pod */ +function plotHsaGrps(projDat) { + /* data to plot */ + let hsaGrps = Array.from( + d3.group(projDat, (d) => d.hsagrp), + ([key, value]) => ({ key, value }) + ) + + /* create a separate svg object for each hsagrp and + use hsagrp name to set the class of each svg */ + d3.select('.div-panels') + .selectAll('svg') + .data(hsaGrps) + .enter() + .append('svg') + .attr('class', function (d) { + return d.value[0].hsagrp + }) + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + + /* iterate over data array and create plot elements (x-axis, bars, and circles) */ + hsaGrps.forEach(function (d) { + /* set xScale domain based on range of end_p values in each hsagrp */ + let maxEndp = d3.max(d.value.map((d) => Number(d.end_p))) + xScale.domain([0, maxEndp]).nice(5) + + /* define x-axis for each hsagrp */ + let xAxis = d3.axisBottom().scale(xScale) + + /* attempt to find optimal number of bins for each hsagrp */ + let binDat = d3 + .bin() + .thresholds(30) + .value((d) => Number(d.end_p))(d.value) + + /* obtain coordinates for plotting circles */ + let cx = d.value.map((d) => Number(d.end_p_nohsa))[0] + let cy = height / 1.66 / 2 + let circleDat = [{ x: cx, y: cy }] + + /* select the correct svg for each hsagrp */ + let svg = d3.select('svg.' + d.value[0].hsagrp) + + /* set bars group */ + let bars = svg + .selectAll('rect') + .data(binDat) + .enter() + .append('g') + .attr('class', 'bar') + .attr('id', 'end-p-bar') + + /* set circles group */ + let circles = svg + .selectAll('circle') + .data(circleDat) + .enter() + .append('g') + .attr('class', 'circle') + .attr('id', 'nohsa-circle') + + /* draw bars */ + bars + .append('rect') + .attr('transform', function (d) { + return 'translate(' + xScale(d.x1) + ',' + margin.top + ')' + }) + .attr('x', 0) + .attr('width', function (d) { + return xScale(d.x1) - xScale(d.x0) + }) + .attr('height', height / 1.66) + .style('fill', function (d) { + return colorPal(d.length) + }) + + /* draw circles */ + circles + .append('circle') + .attr('transform', 'translate(' + 0 + ',' + margin.top + ')') + .attr('cx', function (d) { + return xScale(d.x) + }) + .attr('cy', function (d) { + return d.y + }) + .attr('r', 4) + .style('fill', '#fd484e') + .on('mouseover', mouseOver) + .on('mouseout', mouseOut) + + /* add x-axis */ + svg + .append('g') + .attr('class', 'axis') + .attr('id', 'x-axis') + .attr('transform', `translate(${0}, ${margin.top + height / 1.66})`) + .call(xAxis) + + /* add title */ + svg + .append('text') + .attr('class', 'title') + .attr('id', 'hsagrp-title') + .attr('transform', `translate(${margin.left}, ${plotTitleSpacer})`) + .text(d.value[0].hsagrp_lab) + .style('text-anchor', 'left') + + /* add x-axis label */ + svg + .append('text') + .attr('class', 'axis-label') + .attr('transform', `translate(${margin.left}, ${margin.top})`) + .text('Per cent change') + .style('text-anchor', 'left') + }) +} + +/* fn: update the plots when the data (area), projection variant or model horizon changes */ +function updatePlots(grpDat, selectedProjVar, selectedHorizon, selectedPod) { + /* get the new data */ + let projDat = grpDat + .get(selectedProjVar) + .get(selectedHorizon) + .get(selectedPod) + + /* data to plot */ + let hsaGrps = Array.from( + d3.group(projDat, (d) => d.hsagrp), + ([key, value]) => ({ key, value }) + ) + + /* iterate over data array and create plot elements (x-axis, bars, and circles) */ + hsaGrps.forEach(function (d) { + /* set xScale domain based on range of end_p values in each hsagrp */ + let maxEndp = d3.max(d.value.map((d) => Number(d.end_p))) + xScale.domain([0, maxEndp]).nice(5) + + /* define x-axis for each hsagrp */ + let xAxis = d3.axisBottom().scale(xScale) + + /* attempt to find optimal number of bins for each hsagrp */ + let binDat = d3 + .bin() + .thresholds(30) + .value((d) => Number(d.end_p))(d.value) + + /* select the correct svg for each hsagrp */ + let svg = d3.select('svg.' + d.value[0].hsagrp) + + /* transition bars */ + svg + .selectAll('rect') + .data(binDat) + .join('rect') + .transition() + .duration(transDur) + .attr('transform', function (d) { + return 'translate(' + xScale(d.x1) + ',' + margin.top + ')' + }) + .attr('x', 0) + .attr('width', function (d) { + return xScale(d.x1) - xScale(d.x0) + }) + .attr('height', height / 1.66) + .style('fill', function (d) { + return colorPal(d.length) + }) + + svg.selectAll('#end-p-bar').exit().remove() + + /* transition circles */ + let cx = d.value.map((d) => Number(d.end_p_nohsa))[0] + let cy = height / 1.66 / 2 + let circleDat = [{ x: cx, y: cy }] + + svg + .selectAll('circle') + .data(circleDat) + .join('circle') + .transition() + .duration(transDur) + .attr('transform', 'translate(' + 0 + ',' + margin.top + ')') + .attr('cx', function (d) { + return xScale(d.x) + }) + .attr('cy', function (d) { + return d.y + }) + .attr('r', 4) + .style('fill', '#fd484e') + + /* hack to make sure circles appear on top of bars */ + svg.selectAll('.circle').raise() + + /* transition x-axis */ + svg.selectAll('#x-axis').transition().duration(transDur).call(xAxis) + }) +} + +/* fn: switch the plots when pod changes */ +function switchPod(data, selectedProjVar, selectedHorizon, selectedPod) { + /* group the data */ + let grpDat = d3.group( + data, + (d) => d.proj_id, + (d) => d.end_year, + (d) => d.pod + ) + /* get the new data */ + let projDat = grpDat + .get(selectedProjVar) + .get(selectedHorizon) + .get(selectedPod) + /* remove plots */ + d3.select('#panels').selectAll('svg').remove() + /* reset hsa toggle */ + d3.select('#toggleHsa').property('checked', false) + plotHsaGrps(projDat) + /* hack */ + d3.selectAll('.circle').style('opacity', 0) +} + +/* fn: update the plots when the area dropdown changes */ +function switchArea( + selectedArea, + selectedProjVar, + selectedHorizon, + selectedPod +) { + /* get name of csv file from area dropdown */ + let url = blobDir + filePrefix + selectedArea + fileExt + d3.csv(url).then(function (data) { + /* group the new data */ + let grpDat = d3.group( + data, + (d) => d.proj_id, + (d) => d.end_year, + (d) => d.pod + ) + updatePlots(grpDat, selectedProjVar, selectedHorizon, selectedPod) + }) +} + +/* fn: update the plots when the health status toggle changes */ +function toggleHsa(opacity) { + d3.selectAll('.circle').style('opacity', opacity) +} + +/* export variables */ +export { + plotTitleSpacer, + margin, + width, + height, + transDur, + blobDir, + filePrefix, + fileExt +} +/* export d3 functions */ +export { d3 } +export { xScale, colorPal, tooltip } +/* export udf functions */ +export { mouseOver, mouseOut } +export { plotHsaGrps } +export { updatePlots } +export { switchPod } +export { switchArea } +export { toggleHsa }