In this article, I visualise the most recent year of Australian residential aged care star ratings. Using a Sankey chart, I show the flow of star ratings between quarters. The width of each path represents the number of homes that transitioned from one star rating to another between quarters. Additionally, I graph the number of changed versus unchanged star ratings, increasing and decreasing ratings, and the sizes of changes.
The Australian Government Department of Health and Aged Care releases aged care star ratings quarterly. The available data spans from May 2023 to May 2024, covering quality measures from Q2 FY22-23 to Q2 FY23-24. For simplicity, I refer to these quarters as Q2’23 to Q2’24 in the charts below. Note that all the graphs are based on joining data extracts adjacent in time on service name, provider name, and state. Observations that were not successfully joined are omitted.
categories = [ {name:"Overall",value:"overall_star_rating"}, {name:"Residents' Experience",value:"residents_experience_rating"}, {name:"Compliance",value:"compliance_rating"}, {name:"Quality Measures",value:"quality_measures_rating"}, {name:"Staffing",value:"staffing_rating"}]// Create the Observable inputviewof category_select = Inputs.select(categories, {label:"",format: (t) => t.name,value: categories.find((t) => t.name==="Quality Measures")})// Wrap the Observable input in a styled div and use the input's element directlyhtml`<h3>A Year of <div class="dropdown-container">${viewof category_select}</div> Star Ratings</h3>`
functioncreateSankeyChart(data) {// Specify the dimensions of the chart.const width =1000;const height =600;const format = d3.format(",.0f");const nodeWidth =15;// Create a SVG container.const svg = d3.create("svg").attr("width", width).attr("height", height).attr("viewBox", [0,0, width, height]).attr("style","display: block; margin: auto; max-width: 100%; height: auto; font: 10px sans-serif;");// Constructs and configures a Sankey generator.const sankey = d3.sankey().nodeId(d => d.name).nodeSort(null).nodeWidth(nodeWidth).nodePadding(10).extent([[1,5], [width -1, height -5]]);// Applies it to the data. We make a copy of the nodes and links objects// so as to avoid mutating the original.const {nodes, links} =sankey({nodes: data.nodes.map(d =>Object.assign({}, d)),links: data.links.map(d =>Object.assign({}, d)) });// Defines a color scale.// const color = d3.scaleOrdinal(["#D7191C","#FDAE61","#FFFFBF","#A6D96A","#1A9641"]);const color = d3.scaleOrdinal(d3.range(1,6).map(d => d3.interpolateRdYlGn((5- d) /4)));// Creates the rects that represent the nodes.const rect = svg.append("g").attr("stroke","#000").selectAll().data(nodes).join("rect").attr("x", d => d.x0).attr("y", d => d.y0).attr("height", d => d.y1- d.y0).attr("width", d => d.x1- d.x0).attr("fill", d =>color(d.name.slice(-1)));// Adds a title on the nodes. rect.append("title").text(d =>`${d.name.slice(-1)} star${d.name.slice(-1)!=1?'s':''}: ${format(d.value)}`);// Creates the paths that represent the links.const link = svg.append("g").attr("fill","none").attr("stroke-opacity",0.5).selectAll().data(links).join("g").style("mix-blend-mode","multiply");// Creates a gradient, if necessary, for the source-target color option.if (link_select.value==="source-target") {const gradient = link.append("linearGradient").attr("id", d => (d.uid= DOM.uid("link")).id).attr("gradientUnits","userSpaceOnUse").attr("x1", d => d.source.x1).attr("x2", d => d.target.x0); gradient.append("stop").attr("offset","0%").attr("stop-color", d =>color(d.source.name.slice(-1))); gradient.append("stop").attr("offset","100%").attr("stop-color", d =>color(d.target.name.slice(-1))); } link.append("path").attr("d", d3.sankeyLinkHorizontal()).attr("stroke", link_select.value==="source-target"? (d) => d.uid: link_select.value==="source"? (d) =>color(d.source.name.slice(-1)): link_select.value==="target"? (d) =>color(d.target.name.slice(-1)) : link_select.value).attr("stroke-width", d =>Math.max(1, d.width)); link.append("title").text(d =>`${d.source.name.slice(-1)} star${d.source.name.slice(-1)!=1?'s':''} → ${d.target.name.slice(-1)} star${d.target.name.slice(-1)!=1?'s':''}\nCount: ${format(d.value)}`);// Adds labels on the nodes. svg.append("g").selectAll().data(nodes).join("text").filter(d => d.depth===0|| d.depth=== d3.max(nodes, n => n.depth)).attr("x", d => d.x0< width /2? d.x1+6: d.x0-6).attr("y", d => (d.y1+ d.y0) /2).attr("dy","0.35em").attr("font-size","25pt").attr("text-anchor", d => d.x0< width /2?"start":"end").text(d =>"\u2605".repeat(d.name.slice(-1))).attr("fill","#4a4268")// Adds titles for each layer.const layers = d3.group(nodes, d => d.depth); layers.forEach((nodes, depth) => {const title = nodes[0].name.slice(0,-3);// Get common part of the nameconst parts = title.split(" ");const quarter = parts[0];// Extract the quarter part (e.g., "Q2")const year = parts[1].slice(5,7);// Extract the last two digits of the year (e.g., "22")const formattedTitle =`${quarter}'${year}`; svg.append("text").attr("x", nodes[0].x0< width /2? nodes[0].x0+ nodeWidth +5: nodes[0].x0-5).attr("text-anchor", nodes[0].x0< width /2?"start":"end").attr("y",30) // Position at the top of the chart.attr("font-size","20pt").text(formattedTitle).attr("fill","black"); });return svg.node();}// Reactive chart cellviewof chart = {const svgNode =createSankeyChart(data);return svgNode;}