import { aq, op } from '@uwdata/arquero'
import { Plot } from "@observablehq/plot"
// Import time parser
d3 = require("d3@7")
parser = d3.utcParse("%Y-%m-%d");
// Transpose data to make it useable in ojs inputs
datastore_transposed = transpose(datastore)
viewof search = Inputs.search(datastore_transposed, {placeholder: "Type to find a service",
width: 150,
height: 10})
// Create drop down menu of service names
viewof items = Inputs.table(search, {
columns: ["service"],
header: {service: "Select:"},
width: 300,
height: 250,
required: true,
value: search,
multiple: false})
function return_name() {
var name = "Select a service"
if(items === null) {
return name
} else {
return items.service
}
}
service_selected_name = return_name()
// Create function to output row of selected service
function filtered_df() {
var name = "Select a service"
if(items === null) {
return name
} else {
return items
}
}
service_selected = filtered_df()
This dashboard is not affiliated with or supported by the Care Inspectorate. It contains public sector information licensed under the Open Government Licence v3.0.
Data sourcemytable = aq.table(datastore)
// Create filtered table for same service type for comparison calculations
selected_service_compare = mytable
.params({
// define parameters
selected_CareService: service_selected.CareService,
selected_Subtype: service_selected.Subtype
})
// filter to only include services of the same type
.filter((d, $) => op.includes($.selected_CareService, d.CareService) &&
(
$.selected_Subtype == null || $.selected_Subtype == "NA" || d.Subtype == null || d.Subtype == "NA"
? true // If either selected or record Subtype is NA/null, include it
: op.includes($.selected_Subtype, d.Subtype)
))
Service type
service_name_check = {
const subtype = service_selected?.Subtype;
if (subtype === undefined || subtype === null || subtype === "NA") {
return service_selected.CareService;
} else {
return (service_selected.CareService + " - " + service_selected.Subtype);
}
}
// SELECTED SERVICE TYPE
selected_serv_type = {
if (service_selected.CareService) {
return service_name_check;
} else {
return "Select a service";
}
}
number_services_same_type = selected_service_compare
.rollup({
count: op.count()
})
.objects()[0].count
// NUMBERS OF SERVICES WITH NO GRADE
number_services_same_type_no_grade = selected_service_compare
.filter(d => op.includes("No grade yet", d.MinGrade))
.rollup({
count: op.count()
})
.objects()[0].count
// TEXT STRING SUBTITLE
selected_serv_type_subtitle = {
if (service_selected.CareService) {
return ( "There are " + number_services_same_type + " of these services, " + number_services_same_type_no_grade + " have not yet received grades" );
} else {
return "";
}
}
Lowest grade
// SELECTED SERVICE TYPE
selected_serv_grade = {
if (service_selected.CareService) {
return (service_selected.MinGrade);
} else {
return "Select a service";
}
}
perc_services_same_grade = selected_service_compare
.params({
// define parameters
selected_MinGrade: service_selected.MinGrade
})
// first filter out those that don't have any grades
.filter(d => d.MinGrade_numeric >= 1)
// group by each grade
.groupby("MinGrade")
// calculate counts of services with each mingrade
.rollup({
count: op.count()
})
// create new column for proportion of services with each grade
.derive({ percent: d => op.round((d.count / op.sum(d.count) || 0)*100) }) // include `|| 0` in case sum is zero
// filter out match selected grade
.filter((d, $) => op.includes($.selected_MinGrade, d.MinGrade))
// pull out percent value
.objects()[0].percent
// PERCENTAGE OF SERVICES WITH HIGHER GRADE
perc_services_higher_grade = selected_service_compare
.params({
// define parameters
selected_MinGrade_numeric: service_selected.MinGrade_numeric
})
// first filter out those that don't have any grades
.filter(d => d.MinGrade_numeric >= 1)
// group by each grade
.groupby("MinGrade_numeric")
// calculate counts of services with each mingrade
.rollup({
count: op.count()
})
// create new column for proportion of services with each grade
.derive({ percent: d => op.round((d.count / op.sum(d.count) || 0)*100) }) // include `|| 0` in case sum is zero
// Ungroup by minimum grade
.ungroup()
// filter out match selected grade
.filter((d, $) => d.MinGrade_numeric > $.selected_MinGrade_numeric)
.derive({total: d => op.sum(d.percent)})
// pull out total value
.object(1)?.total ?? 0
// TEXT STRING SUBTITLE
selected_serv_grade_subtitle = {
if (service_selected.CareService) {
return ( "Same as " + perc_services_same_grade + "% of services of the same type, whilst " + perc_services_higher_grade + "% have a higher minimum grade" );
} else {
return "";
}
}
Last inspected
inspection_check = {
if(service_selected.months_since_inspection >=0) {
return (service_selected.months_since_inspection + " month(s) since last inspection");
} else {
return "No inspection yet";
}
}
// SELECTED SERVICE LAST INSPECTION
selected_serv_last_inspection = {
if (service_selected.CareService) {
return inspection_check;
} else {
return "Select a service";
}
}
median_time_last_inspection = selected_service_compare
// filter out those that haven't yet been inspected
.filter(d => d.months_since_inspection >= 0)
.rollup({
count: op.count(),
median_months_since_inspection: op.median('months_since_inspection')
})
.objects()[0].median_months_since_inspection
// TEXT STRING SUBTITLE
selected_serv_last_inspection_subtitle = {
if (service_selected.CareService) {
return "The average (median) time since the last inspection for services of this type is " + median_time_last_inspection + " month(s)";
} else{
return "";
}
}
Number of staff
staff_check = {
if(service_selected.staff_per_place >=0) {
return (service_selected.staff_per_place.toFixed(2) + " staff per registered place");
} else {
return "No staff recorded";
}
}
// SELECTED SERVICE LAST INSPECTION
staff_per_place = {
if (service_selected.CareService) {
return staff_check;
} else {
return "Select a service";
}
}
median_staff = selected_service_compare
.filter(d => d.staff_per_place > 0)
.rollup({
count: op.count(),
median_staff_per_place: op.median('staff_per_place')
})
.objects()[0]?.median_staff_per_place ?? "No staff recorded"
// First check for those that are undefined as all services have had no complaints
staff_undefined_check = {
if(median_staff !== "No staff recorded"){
return "The average (median) number of staff per registered place for services of this type is " + median_staff.toFixed(2);
} else {
return "No services of this type had any staff recorded";
}
}
// TEXT STRING SUBTITLE
median_staff_subtitle = {
if (service_selected.CareService) {
return staff_undefined_check;
} else{
return "";
}
}
Upheld complaints
complaints_check = {
if(service_selected.Complaints_upheld_2425 >=0) {
return (service_selected.Complaints_upheld_2425 + " upheld complaint(s) between 1 April 2024 and 31 March 2025");
} else {
return "No upheld complaints between 1 April 2024 and 31 March 2025";
}
}
// SELECTED SERVICE COMPLAINTS
complaints = {
if (service_selected.CareService) {
return complaints_check ;
} else {
return "Select a service";
}
}
perc_services_complaint = selected_service_compare
// group by each grade
.groupby("Complaints_upheld_2425")
// calculate counts of services with all the varying numbers of upheld complaints
.rollup({
count: op.count()
})
// create new column for proportion of services with each number of complaints
.derive({ percent: d => op.round((d.count / op.sum(d.count) || 0)*100) }) // include `|| 0` in case sum is zero
// Ungroup by number of complaints
.ungroup()
// filter out anything less than 1
.filter((d) => d.Complaints_upheld_2425 >= 1)
.derive({total: d => op.sum(d.percent)})
// pull out total value
.objects()[0]?.total ?? 0
// First check for those that are undefined as all services have had no complaints
complaints_undefined_check = {
if(perc_services_complaint !== 0){
return perc_services_complaint + "% of services of this type had at least one upheld complaint between 1 April 2024 and 31 March 2025";
} else {
return "No services of this type had at least one upheld complaint between 1 April 2024 and 31 March 2025";
}
}
// TEXT STRING SUBTITLE
complaints_subtitle = {
if (service_selected.CareService) {
return complaints_undefined_check;
} else{
return "";
}
}
Requirements from inspection
requirements_check = {
if(service_selected.any_requirements_2425 === "Yes") {
return ("This service had requirement(s) from inspections between 1 April 2024 and 31 March 2025");
} else if(service_selected.any_requirements_2425 === "Inspected but no requirements") {
return "This service had no requirement(s) from inspections between 1 April 2024 and 31 March 2025";
} else {
return "This service was not inspected between 1 April 2024 and 31 March 2025"
}
}
// SELECTED SERVICE REQUIREMENTS
requirements = {
if (service_selected.CareService) {
return requirements_check ;
} else {
return "Select a service";
}
}
perc_services_requirement = selected_service_compare
// First filter out those that have not been inspected (i.e. blank field)
.filter((d) => d.any_requirements_2425 !== "")
// group by each grade
.groupby("any_requirements_2425")
// calculate counts of services with all the varying categories of requirements (yes, inspected but none, and blank)
.rollup({
count: op.count()
})
// create new column for proportion of services with each number of requirements
.derive({ percent: d => op.round((d.count / op.sum(d.count) || 0)*100) }) // include `|| 0` in case sum is zero
// Ungroup by number of requirements
.ungroup()
// filter only those that are "Yes"
.filter((d) => d.any_requirements_2425 === "Yes")
.derive({total: d => op.sum(d.percent)})
// pull out total value
.objects()[0]?.total ?? 0
// First check for those that are undefined as all services have had no complaints
requirements_undefined_check = {
if(perc_services_requirement !== 0){
return perc_services_requirement + "% of services of this type that had been inspected between 1 April 2024 and 31 March 2025 had at least one requirement";
} else {
return "No services of this type that had been inspected between 1 April 2024 and 31 March 2025 had any requirements";
}
}
// TEXT STRING SUBTITLE
requirements_subtitle = {
if (service_selected.CareService) {
return requirements_undefined_check;
} else{
return "";
}
}
grades = ["Unsatisfactory", "Weak", "Adequate", "Good", "Very good", "Excellent", "No grade yet"]
// --- 1. Selected Service Plot Data ---
selected_wrangled = aq.from([service_selected]) // Wrap in array because service_selected is one row
.fold(Object.keys(service_selected).filter(d => d.startsWith("KQ")), { as: ["kq", "grade"] })
.groupby("kq", "grade")
.rollup({ count: op.count() })
.ungroup("grade")
// Mapping KQ codes to readable labels
.derive({
kq_label: (d) => op.recode(d.kq, { 'KQ_Support_Wellbeing': 'How well do we support people’s wellbeing?','KQ_Staff_Team': 'How good is our staff team?', 'KQ_Setting': 'How good is our setting?', 'KQ_Leadership': 'How good is our leadership?', 'KQ_Care_Play_and_Learning': 'How good is our care, play and learning?', 'KQ_Care_and_Support_Planning': 'How well is our care and support planned?'}, '?')
})
// --- 2. Comparison Group Data ---
comparison_wrangled = selected_service_compare
.params({
// define parameters
selected_KQ_Care_and_Support_Planning: service_selected.KQ_Care_and_Support_Planning,
selected_KQ_Support_Wellbeing: service_selected.KQ_Support_Wellbeing,
selected_KQ_Staff_Team: service_selected.KQ_Staff_Team,
selected_KQ_Setting: service_selected.KQ_Setting,
selected_KQ_Leadership: service_selected.KQ_Leadership,
selected_KQ_Care_Play_and_Learning: service_selected.KQ_Care_Play_and_Learning
})
.fold(Object.keys(service_selected).filter(d => d.startsWith("KQ")), { as: ["kq", "grade"] })
.groupby("kq", "grade")
.rollup({ count: op.count() })
.filter((d) => d.grade !== "No grade yet")
.ungroup()
// Mapping KQ codes to readable labels
.derive({
kq_label: (d) => op.recode(d.kq, { 'KQ_Support_Wellbeing': 'How well do we support people’s wellbeing?','KQ_Staff_Team': 'How good is our staff team?', 'KQ_Setting': 'How good is our setting?', 'KQ_Leadership': 'How good is our leadership?', 'KQ_Care_Play_and_Learning': 'How good is our care, play and learning?', 'KQ_Care_and_Support_Planning': 'How well is our care and support planned?'}, '?')
})
// Create numbered equivalent of grades for orderby later
.derive({
grade_value: (d) => op.recode(d.grade, { 'Unsatisfactory': 1,'Weak': 2, 'Adequate': 3, 'Good': 4, 'Very good': 5, 'Excellent': 6}, '?')
})
.groupby("kq")
.derive({
proportion: d => d.count / op.sum(d.count)
})
.orderby("kq_label", "grade_value")
.derive({
// Calculate cumsum
cumsum: aq.rolling(d => op.sum(d.proportion))
})
// calculate proportion of services GREATER THAN so it's 1 minus the cumsum
.derive({
oneminus_cumsum: d => 1 - d.cumsum
})
.derive({
comparison_text: d => "Same as " + (op.round(d.proportion*100)) + "% of services of the same type, whilst " + (op.round(d.oneminus_cumsum*100)) + "% have a higher grade"
})
.filter((d, $) =>
(d.kq === "KQ_Care_and_Support_Planning" && d.grade === $.selected_KQ_Care_and_Support_Planning) ||
(d.kq === "KQ_Support_Wellbeing" && d.grade === $.selected_KQ_Support_Wellbeing) ||
(d.kq === "KQ_Staff_Team" && d.grade === $.selected_KQ_Staff_Team) ||
(d.kq === "KQ_Setting" && d.grade === $.selected_KQ_Setting) ||
(d.kq === "KQ_Leadership" && d.grade === $.selected_KQ_Leadership) ||
(d.kq === "KQ_Care_Play_and_Learning" && d.grade === $.selected_KQ_Care_Play_and_Learning)
)
// centred plot with minimal whitespace
Plot.plot({
x: {
domain: [0, 1],
axis: null,
label: null,
tickSize: 0
}, // removes x-axis
y: {
axis: null,
tickSize: 0,
label: null
}, // removes y-axis
marks: [
// Big colored dots
Plot.dot(selected_wrangled.objects().filter(d => d.kq === "KQ_Care_Play_and_Learning"), {
y: "kq_label",
x: () => 0.5,
fill: "grade",
r: 42,
inset: 0 // remove extra padding around the dot
}),
// Grade label inside dot
Plot.text(selected_wrangled.objects().filter(d => d.kq === "KQ_Care_Play_and_Learning"), {
y: "kq_label",
x: () => 0.5,
text: "grade",
fill: "white",
fontWeight: "bold",
fontSize: 12
})],
x: {
domain: [0, 1],
axis: null,
label: null,
tickFormat: ""
},
color: {
domain: grades,
range: ["#d73027", "#fc8d59", "#fdcc3f", "#c3e648", "#91cf60", "#1a9850", "grey"],
legend: false
},
style: {
fontSize: "20px",
fontWeight: "bold",
labelAnchor: "left"
},
width: 85,
height: 85,
margin: 0 // removes all outer whitespace
})
// centred plot with minimal whitespace
Plot.plot({
x: {
domain: [0, 1],
axis: null,
label: null,
tickSize: 0
}, // removes x-axis
y: {
axis: null,
tickSize: 0,
label: null
}, // removes y-axis
marks: [
// Big colored dots
Plot.dot(selected_wrangled.objects().filter(d => d.kq === "KQ_Leadership"), {
y: "kq_label",
x: () => 0.5,
fill: "grade",
r: 42,
inset: 0 // remove extra padding around the dot
}),
// Grade label inside dot
Plot.text(selected_wrangled.objects().filter(d => d.kq === "KQ_Leadership"), {
y: "kq_label",
x: () => 0.5,
text: "grade",
fill: "white",
fontWeight: "bold",
fontSize: 12
})],
x: {
domain: [0, 1],
axis: null,
label: null,
tickFormat: ""
},
color: {
domain: grades,
range: ["#d73027", "#fc8d59", "#fdcc3f", "#c3e648", "#91cf60", "#1a9850", "grey"],
legend: false
},
style: {
fontSize: "20px",
fontWeight: "bold",
labelAnchor: "left"
},
width: 85,
height: 85,
margin: 0 // removes all outer whitespace
})
// centred plot with minimal whitespace
Plot.plot({
x: {
domain: [0, 1],
axis: null,
label: null,
tickSize: 0
}, // removes x-axis
y: {
axis: null,
tickSize: 0,
label: null
}, // removes y-axis
marks: [
// Big colored dots
Plot.dot(selected_wrangled.objects().filter(d => d.kq === "KQ_Setting"), {
y: "kq_label",
x: () => 0.5,
fill: "grade",
r: 42,
inset: 0 // remove extra padding around the dot
}),
// Grade label inside dot
Plot.text(selected_wrangled.objects().filter(d => d.kq === "KQ_Setting"), {
y: "kq_label",
x: () => 0.5,
text: "grade",
fill: "white",
fontWeight: "bold",
fontSize: 12
})],
x: {
domain: [0, 1],
axis: null,
label: null,
tickFormat: ""
},
color: {
domain: grades,
range: ["#d73027", "#fc8d59", "#fdcc3f", "#c3e648", "#91cf60", "#1a9850", "grey"],
legend: false
},
style: {
fontSize: "20px",
fontWeight: "bold",
labelAnchor: "left"
},
width: 85,
height: 85,
margin: 0 // removes all outer whitespace
})
// centred plot with minimal whitespace
Plot.plot({
x: {
domain: [0, 1],
axis: null,
label: null,
tickSize: 0
}, // removes x-axis
y: {
axis: null,
tickSize: 0,
label: null
}, // removes y-axis
marks: [
// Big colored dots
Plot.dot(selected_wrangled.objects().filter(d => d.kq === "KQ_Staff_Team"), {
y: "kq_label",
x: () => 0.5,
fill: "grade",
r: 42,
inset: 0 // remove extra padding around the dot
}),
// Grade label inside dot
Plot.text(selected_wrangled.objects().filter(d => d.kq === "KQ_Staff_Team"), {
y: "kq_label",
x: () => 0.5,
text: "grade",
fill: "white",
fontWeight: "bold",
fontSize: 12
})],
x: {
domain: [0, 1],
axis: null,
label: null,
tickFormat: ""
},
color: {
domain: grades,
range: ["#d73027", "#fc8d59", "#fdcc3f", "#c3e648", "#91cf60", "#1a9850", "grey"],
legend: false
},
style: {
fontSize: "20px",
fontWeight: "bold",
labelAnchor: "left"
},
width: 85,
height: 85,
margin: 0 // removes all outer whitespace
})
// centred plot with minimal whitespace
Plot.plot({
x: {
domain: [0, 1],
axis: null,
label: null,
tickSize: 0
}, // removes x-axis
y: {
axis: null,
tickSize: 0,
label: null
}, // removes y-axis
marks: [
// Big colored dots
Plot.dot(selected_wrangled.objects().filter(d => d.kq === "KQ_Support_Wellbeing"), {
y: "kq_label",
x: () => 0.5,
fill: "grade",
r: 42,
inset: 0 // remove extra padding around the dot
}),
// Grade label inside dot
Plot.text(selected_wrangled.objects().filter(d => d.kq === "KQ_Support_Wellbeing"), {
y: "kq_label",
x: () => 0.5,
text: "grade",
fill: "white",
fontWeight: "bold",
fontSize: 12
})],
x: {
domain: [0, 1],
axis: null,
label: null,
tickFormat: ""
},
color: {
domain: grades,
range: ["#d73027", "#fc8d59", "#fdcc3f", "#c3e648", "#91cf60", "#1a9850", "grey"],
legend: false
},
style: {
fontSize: "20px",
fontWeight: "bold",
labelAnchor: "left"
},
width: 85,
height: 85,
margin: 0 // removes all outer whitespace
})
// centred plot with minimal whitespace
Plot.plot({
x: {
domain: [0, 1],
axis: null,
label: null,
tickSize: 0
}, // removes x-axis
y: {
axis: null,
tickSize: 0,
label: null
}, // removes y-axis
marks: [
// Big colored dots
Plot.dot(selected_wrangled.objects().filter(d => d.kq === "KQ_Care_and_Support_Planning"), {
y: "kq_label",
x: () => 0.5,
fill: "grade",
r: 42,
inset: 0 // remove extra padding around the dot
}),
// Grade label inside dot
Plot.text(selected_wrangled.objects().filter(d => d.kq === "KQ_Care_and_Support_Planning"), {
y: "kq_label",
x: () => 0.5,
text: "grade",
fill: "white",
fontWeight: "bold",
fontSize: 12
})],
x: {
domain: [0, 1],
axis: null,
label: null,
tickFormat: ""
},
color: {
domain: grades,
range: ["#d73027", "#fc8d59", "#fdcc3f", "#c3e648", "#91cf60", "#1a9850", "grey"],
legend: false
},
style: {
fontSize: "20px",
fontWeight: "bold",
labelAnchor: "left"
},
width: 85,
height: 85,
margin: 0 // removes all outer whitespace
})
inspection_history = aq.table(inspection_series)
// Create filtered table for selected service inspection history
inspection_history_table = inspection_history
.params({
// define parameters
selected_CSNumber: service_selected.CSNumber,
})
// filter to only include services of the same type
.filter((d, $) => d.CSNumber === $.selected_CSNumber)
selected_likert_table = aq.from(inspection_history_table)
.fold(inspection_history_table.columnNames().slice(4, 17), {
as: ['Question', 'Grade']
})
.derive({
Grade_descr: (d) => op.recode(d.Grade, { '1':'Unsatisfactory', '2':'Weak', '3':'Adequate', '4': 'Good', '5': 'Very good', '6': 'Excellent'}, '?')
})
.filter(d => d.Grade != null)
.derive({
Question: d => op.replace(d.Question, 'Quality_of_', ' ')
})
.derive({
Question: d => op.replace(d.Question, 'KQ_', ' ')
})
.derive({
Question: d => op.replace(d.Question, /_/g, ' ')
})
.groupby("CSNumber", "ServiceName", "Publication_of_Latest_Grading", "Last_inspection_Date", "URL","Grade", "Grade_descr")
.pivot("Question", "Question")
.derive({
// Dynamic column selection not permitted so concatenate all rows then filter after
Themes: d => op.compact(op.values(op.row_object()))
})
.derive({
// replace the first 7 columns worth of data so only left with concatenated themes
Themes: d => op.replace(d.Themes, /(.+?,){7}/, ' '),
})
.ungroup()
// Now need to pivot longer to get back in the right format
.fold(aq.not('CSNumber', 'ServiceName', 'Publication_of_Latest_Grading', 'Last_inspection_Date', 'URL', 'Grade', 'Grade_descr', 'Themes'))
.filter(d => d.value != null)
.groupby('Last_inspection_Date', 'Grade_descr')
.derive({
count: aq.op.count()
})
.ungroup()
.dedupe(['Last_inspection_Date', 'Grade_descr'])
.derive({ date_parsed: aq.escape(d => parser(d.Last_inspection_Date)) })
// --- 2. Calculate proportions of each grade ---
calc_selected_likert_table = selected_likert_table
.groupby('Last_inspection_Date')
.derive({
total: aq.op.sum('count')
})
.derive({
proportion: d => d.count / d.total
})
.objects()
Plot.plot({
marginBottom: 80,
x: {
tickFormat: "",
interval: "month",
type: "band",
label: "Date of inspection"
},
marks: [
Plot.barY(calc_selected_likert_table, {
x: "date_parsed",
y: "proportion",
fill: "Grade_descr",
order: "Grade",
channels: {
'Proportion of all grades': b => `${(b.proportion*100).toFixed(0)}%`,
'Date of inspection': "date_parsed",
'Date grades published': "Publication_of_Latest_Grading",
'Themes': "Themes"},
tip: true
}),
Plot.axisY({ tickFormat: d => `${Math.round(d)}%`, label: ""}),
Plot.axisX({ tickRotate: 0, label: "Inspections" })
],
y: {
grid: true,
label: "Proportion of all grades",
percent: true
},
color: {
label: "Grade awarded",
domain: ["Unsatisfactory", "Weak", "Adequate", "Good", "Very good", "Excellent"],
range: ["#d73027", "#fc8d59", "#fdcc3f", "#c3e648", "#91cf60", "#1a9850"],
legend: true,
legendPosition: "top"
},
style: {
fontSize: "14px"
},
width: 1000,
height: 400
})