Monthly Snapshot
Toronto Shelter System — current month at a glance
Interactive monthly snapshot of Toronto’s shelter system — actively homeless counts, flow metrics, and population group breakdowns for any month from January 2018 onward.
Author
Miriam Marling
MON = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
fmtN = d => d.toLocaleString()
// Build month dropdown in descending order (most recent first).
// Display: "March 2026" Return value: "2026-03-01" (the YYYY-MM-DD from DB)
reportingMonthOptions = data
.filter(d => d.population_group === "All Population")
.sort((a, b) => b.flow_date.localeCompare(a.flow_date))
.map(d => {
const [y, m] = d.flow_date.split("-").map(Number)
return {
label: new Date(y, m - 1, 15).toLocaleString("default", {month: "long", year: "numeric"}),
value: d.flow_date
}
})snapshot = data.find(d => d.flow_date === reportingDate.value && d.population_group === "All Population")
chronicRow = data.find(d => d.flow_date === reportingDate.value && d.population_group === "Chronic")
// KPI values
activelyHomeless = snapshot ? snapshot.actively_homeless : 0
inflowTotal = snapshot ? snapshot.newly_identified + snapshot.returned_from_housing + snapshot.returned_to_shelter : 0
outflowTotal = snapshot ? snapshot.moved_to_housing + snapshot.became_inactive : 0
netChange = inflowTotal - outflowTotal
// For YTD charts
reportingYear = +reportingDate.value.slice(0, 4)
reportingMonth = +reportingDate.value.slice(5, 7)html`<div class="kpi-row">
<div class="kpi-card" style="background:#FBE4EB;">
<div class="kpi-label">Shelter System Inflow</div>
<div class="kpi-value" style="color:#C2185B;">+${inflowTotal.toLocaleString()}</div>
</div>
<div class="kpi-card" style="background:${netChange > 0 ? '#FFEBEE' : netChange < 0 ? '#E8F5E9' : '#E8E8E8'};">
<div class="kpi-label">Change (Inflow − Outflow)</div>
<div class="kpi-value" style="color:${netChange > 0 ? '#C62828' : netChange < 0 ? '#2E7D32' : '#333333'};">
${netChange >= 0 ? '+' : ''}${netChange.toLocaleString()}
</div>
</div>
<div class="kpi-card" style="background:#E8F5E9;">
<div class="kpi-label">Shelter System Outflow</div>
<div class="kpi-value" style="color:#2E7D32;">−${outflowTotal.toLocaleString()}</div>
</div>
</div>`Chronic Homelessness
Note: Oracle APEX renders this as a donut chart. Observable Plot does not support pie/donut natively — shown here as a percentage bar with the same data and colors.
chronicPct = chronicRow ? chronicRow.population_group_pct / 100 : 0
chronicData = [
{label: "Chronic", pct: chronicPct, fill: "#5856D6"},
{label: "Non-Chronic", pct: 1 - chronicPct, fill: "#CCCCCC"}
]
Plot.plot({
width,
height: 90,
marginLeft: 10,
marginRight: 10,
x: {domain: [0, 1], axis: null},
y: {axis: null},
color: {domain: ["Chronic","Non-Chronic"], range: ["#5856D6","#CCCCCC"], legend: true},
marks: [
Plot.barX(chronicData, Plot.stackX({x: "pct", y: () => "", fill: "label", inset: 0})),
Plot.text(chronicData, Plot.stackX({
x: "pct",
y: () => "",
text: d => d.pct >= 0.04 ? `${d.label}\n${(d.pct * 100).toFixed(1)}%` : "",
fill: d => d.label === "Chronic" ? "white" : "#333",
textAnchor: "middle",
lineWidth: 8,
fontSize: 13,
fontWeight: "600"
}))
]
})Cumulative YTD comparison: Newly Identified
ytdComputed = {
const rows = data
.filter(d => d.population_group === "All Population")
.filter(d => {
const y = +d.flow_date.slice(0, 4)
const m = +d.flow_date.slice(5, 7)
return (y === reportingYear || y === reportingYear - 1) && m <= reportingMonth
})
.sort((a, b) => a.flow_date.localeCompare(b.flow_date))
const accNI = {}, accMH = {}
return rows.map(d => {
const y = +d.flow_date.slice(0, 4)
const m = +d.flow_date.slice(5, 7)
accNI[y] = (accNI[y] || 0) + d.newly_identified
accMH[y] = (accMH[y] || 0) + d.moved_to_housing
return {
year: y, month: m,
monthLabel: MON[m - 1],
yearLabel: String(y),
isCurrentYear: y === reportingYear,
ytd_ni: accNI[y],
ytd_mh: accMH[y]
}
})
}
// Sort: by month, then prior year first (so bars appear: prior ↑ current ↓ within each month)
ytdNI = ytdComputed
.sort((a, b) => a.month - b.month || a.year - b.year)
.map(d => ({...d, barLabel: `${d.monthLabel} ${d.yearLabel}`}))
ytdNIDomain = ytdNI.map(d => d.barLabel)
Plot.plot({
width,
height: 60 + ytdNI.length * 22,
marginLeft: 100,
marginRight: 80,
x: {label: "Cumulative newly identified (YTD)", grid: true, tickFormat: fmtN},
y: {label: null, domain: ytdNIDomain},
color: {
domain: [String(reportingYear - 1), String(reportingYear)],
range: ["#FFB3C1", "#FF2D55"],
legend: true
},
marks: [
Plot.ruleX([0]),
Plot.barX(ytdNI, {x: "ytd_ni", y: "barLabel", fill: "yearLabel", tip: true,
insetTop: 1, insetBottom: 1}),
Plot.text(ytdNI, {
x: "ytd_ni", y: "barLabel",
text: d => d.ytd_ni.toLocaleString(),
textAnchor: "start", dx: 5, fontSize: 11
})
]
})Cumulative YTD comparison: Moved to Permanent Housing
ytdMH = ytdComputed
.sort((a, b) => a.month - b.month || a.year - b.year)
.map(d => ({...d, barLabel: `${d.monthLabel} ${d.yearLabel}`}))
Plot.plot({
width,
height: 60 + ytdMH.length * 22,
marginLeft: 100,
marginRight: 80,
x: {label: "Cumulative moved to permanent housing (YTD)", grid: true, tickFormat: fmtN},
y: {label: null, domain: ytdMH.map(d => d.barLabel)},
color: {
domain: [String(reportingYear - 1), String(reportingYear)],
range: ["#AED5AE", "#5BA75B"],
legend: true
},
marks: [
Plot.ruleX([0]),
Plot.barX(ytdMH, {x: "ytd_mh", y: "barLabel", fill: "yearLabel", tip: true,
insetTop: 1, insetBottom: 1}),
Plot.text(ytdMH, {
x: "ytd_mh", y: "barLabel",
text: d => d.ytd_mh.toLocaleString(),
textAnchor: "start", dx: 5, fontSize: 11
})
]
})Inflow breakdown
inflowBreakdown = snapshot ? [
{label: "Newly Identified", value: snapshot.newly_identified, fill: "#E63946"},
{label: "Returned from Permanent Housing", value: snapshot.returned_from_housing, fill: "#FFD600"},
{label: "Returned to Shelter", value: snapshot.returned_to_shelter, fill: "#FB8C00"}
].sort((a, b) => b.value - a.value) : []
inflowMax = d3.max(inflowBreakdown, d => d.value) || 1
{
const isMobile = width < 650
const colorOpts = isMobile ? {
color: {
domain: inflowBreakdown.map(d => d.label),
range: inflowBreakdown.map(d => d.fill),
legend: true
}
} : {}
return Plot.plot({
width,
height: isMobile ? 190 : 130,
marginLeft: isMobile ? 10 : 240,
marginRight: 80,
x: {label: "People", grid: true, tickFormat: fmtN, domain: [0, inflowMax * 1.15]},
y: {label: null, domain: inflowBreakdown.map(d => d.label), axis: isMobile ? null : "left"},
...colorOpts,
marks: [
Plot.ruleX([0]),
Plot.barX(inflowBreakdown, {
x: "value", y: "label",
fill: isMobile ? "label" : "fill",
insetTop: 3, insetBottom: 3
}),
Plot.text(inflowBreakdown, {
x: "value", y: "label",
text: d => d.value.toLocaleString(),
textAnchor: "start", dx: 6, fontSize: 12
})
]
})
}Outflow breakdown
outflowBreakdown = snapshot ? [
{label: "Moved to Permanent Housing", value: snapshot.moved_to_housing, fill: "#66CC66"},
{label: "Became Inactive", value: snapshot.became_inactive, fill: "#512DA8"}
].sort((a, b) => b.value - a.value) : []
outflowMax = d3.max(outflowBreakdown, d => d.value) || 1
{
const isMobile = width < 650
const colorOpts = isMobile ? {
color: {
domain: outflowBreakdown.map(d => d.label),
range: outflowBreakdown.map(d => d.fill),
legend: true
}
} : {}
return Plot.plot({
width,
height: isMobile ? 160 : 100,
marginLeft: isMobile ? 10 : 220,
marginRight: 80,
x: {label: "People", grid: true, tickFormat: fmtN, domain: [0, outflowMax * 1.15]},
y: {label: null, domain: outflowBreakdown.map(d => d.label), axis: isMobile ? null : "left"},
...colorOpts,
marks: [
Plot.ruleX([0]),
Plot.barX(outflowBreakdown, {
x: "value", y: "label",
fill: isMobile ? "label" : "fill",
insetTop: 3, insetBottom: 3
}),
Plot.text(outflowBreakdown, {
x: "value", y: "label",
text: d => d.value.toLocaleString(),
textAnchor: "start", dx: 6, fontSize: 12
})
]
})
}Age of people actively homeless in the last 3 months
ageBandDefs2 = [
{key: "age_under_16", label: "Under 16"},
{key: "age_16_24", label: "16–24"},
{key: "age_25_34", label: "25–34"},
{key: "age_35_44", label: "35–44"},
{key: "age_45_54", label: "45–54"},
{key: "age_55_64", label: "55–64"},
{key: "age_65_over", label: "65+"}
]
ageSnapshotData = (() => {
if (!snapshot) return []
const total = ageBandDefs2.reduce((s, b) => s + snapshot[b.key], 0)
return ageBandDefs2.map(b => ({
band: b.label,
pct: total > 0 ? snapshot[b.key] / total : 0
}))
})()
ageSnapMax = d3.max(ageSnapshotData, d => d.pct) || 0.4
Plot.plot({
width,
height: 340,
marginLeft: 60,
marginTop: 30,
x: {label: null, domain: ageBandDefs2.map(b => b.label)},
y: {label: null, axis: null, domain: [0, ageSnapMax * 1.4]},
color: {domain: ageBandDefs2.map(b => b.label), legend: true},
marks: [
Plot.barY(ageSnapshotData, {x: "band", y: "pct", fill: "band"}),
Plot.text(ageSnapshotData, {
x: "band", y: "pct",
text: d => (d.pct * 100).toFixed(1) + "%",
dy: -10, fontSize: 13, fontWeight: "600", textAnchor: "middle"
}),
Plot.ruleY([0])
]
})Gender of people actively homeless in the last 3 months
genderDefs2 = [
{key: "gender_male", label: "Men"},
{key: "gender_female", label: "Women"},
{key: "gender_trans_nb_two_spirit", label: "Transgender, Non-Binary, or Two-Spirit"}
]
genderSnapshotData = (() => {
if (!snapshot) return []
const total = genderDefs2.reduce((s, g) => s + snapshot[g.key], 0)
return genderDefs2
.map(g => ({gender: g.label, pct: total > 0 ? snapshot[g.key] / total : 0}))
.sort((a, b) => b.pct - a.pct)
})()
genderSnapMax = d3.max(genderSnapshotData, d => d.pct) || 1
{
const isMobile = width < 650
return Plot.plot({
width,
height: isMobile ? 160 : 130,
marginLeft: isMobile ? 10 : 260,
marginRight: 80,
x: {label: "Share of gender total", grid: true,
tickFormat: d => (d * 100).toFixed(0) + "%",
domain: [0, genderSnapMax * 1.15]},
y: {label: null, domain: genderSnapshotData.map(d => d.gender), axis: isMobile ? null : "left"},
color: {legend: true},
marks: [
Plot.ruleX([0]),
Plot.barX(genderSnapshotData, {x: "pct", y: "gender", fill: "gender",
insetTop: 3, insetBottom: 3}),
Plot.text(genderSnapshotData, {
x: "pct", y: "gender",
text: d => (d.pct * 100).toFixed(1) + "%",
textAnchor: "start", dx: 6, fontSize: 12
})
]
})
}Explore other dashboards:
This dashboard is also available as an Oracle APEX dashboard built on the same database — useful if you want to see how the same data renders in Oracle’s native low-code platform, or if you’re curious about the SQL and APEX configuration behind it.