CHART_COLORS = [
'#020A3E', // 1. Primary Navy
'#0D83FF', // 2. Secondary Blue
'#47B5FF', // 3. Light Blue
'#94A3B8', // 4. Slate 400
'#CBD5E1', // 5. Slate 300
'#F1F5F9', // 6. Slate 100
'#EAB308', // 7. Gold (New)
'#B45309', // 8. Ochre (New)
'#F97316' // 9. Muted Orange (New)
];
// 1. LOAD DATA & HYDRATE STRINGS
lookup_raw = FileAttachment("string_lookup.csv").csv({typed: true})
lookup = new Map(lookup_raw.map(d => [d.id, d.text]))
data_raw = FileAttachment("inman_survey_cube_final.csv").csv({typed: true})
data = data_raw.map(d => {
const dt = new Date(d.Date);
dt.setUTCDate(1);
const volRaw = lookup.get(d.Volume);
const volMapped = volRaw === "High Volume" ? "High-volume agents" : (volRaw === "Lower Volume" ? "Lower-volume agents" : volRaw);
const brokRaw = lookup.get(d.Brokerage);
const brokMapped = brokRaw === "Independent brokerage, publicly traded" ? "Large non-franchise" :
(brokRaw === "Independent brokerage, privately held" ? "Private indie" : brokRaw);
return {
...d,
Date: dt,
Region: lookup.get(d.Region),
Brokerage: brokMapped,
Volume: volMapped,
Question: lookup.get(d.Question),
Response: lookup.get(d.Response),
Track: lookup.get(d.Track)
};
})
// 2. STATE
mutable selectedQuestion = null
// 3. TRACK SELECTOR
viewof selectedTrack = {
const container = html`<div class="track-toggle">
<button class="track-btn active" value="Agents">AGENTS</button>
<button class="track-btn" value="Brokerage Leaders">LEADERS</button>
</div>`;
const buttons = container.querySelectorAll("button");
buttons.forEach(btn => {
btn.onclick = (e) => {
buttons.forEach(b => { b.classList.remove("active"); });
btn.classList.add("active");
container.value = btn.value;
container.dispatchEvent(new CustomEvent("input"));
};
});
container.value = "Agents";
return container;
}
// Reset filters when track changes
{
selectedTrack;
const modeButtons = document.querySelectorAll("#grouping-mode-container .segment-btn");
if (modeButtons.length > 0) {
modeButtons[0].click(); // Reset to "Total"
}
}
getSubgroups = (mode) => {
if (mode === "Region") return ["South", "West", "Midwest", "Northeast"];
if (mode === "Brokerage") return ["Franchise", "Large non-franchise", "Private indie"];
if (mode === "Volume") return ["High-volume agents", "Lower-volume agents"];
return [];
}
viewof groupingMode = {
const options = selectedTrack === "Agents"
? [{label: "TOTAL", value: "Total"}, {label: "BY REGION", value: "Region"}, {label: "BY BROKERAGE", value: "Brokerage"}, {label: "BY VOLUME", value: "Volume"}]
: [{label: "TOTAL", value: "Total"}, {label: "BY REGION", value: "Region"}, {label: "BY BROKERAGE", value: "Brokerage"}];
const container = html`<div class="segmented-control ml-2">
${options.map(opt => html`<button class="segment-btn ${opt.value === "Total" ? 'active' : ''}" value="${opt.value}">${opt.label}</button>`)}
</div>`;
const buttons = container.querySelectorAll("button");
buttons.forEach(btn => {
btn.onclick = () => {
buttons.forEach(b => b.classList.remove("active"));
btn.classList.add("active");
container.value = btn.value;
container.dispatchEvent(new CustomEvent("input"));
};
});
container.value = "Total";
return container;
}
viewof snapshotSubgroups = {
const mode = groupingMode;
const container = html`<div class="flex flex-wrap gap-x-5 gap-y-2"></div>`;
if (mode === "Total" || !mode) {
container.value = [];
return container;
}
// Get unique subgroups for the active dimension
const groups = getSubgroups(mode);
const selected = new Set(groups);
const render = () => {
container.innerHTML = "";
groups.forEach((g, i) => {
const isSelected = selected.has(g);
const color = CHART_COLORS[i % CHART_COLORS.length];
const btn = html`
<div class="flex items-center cursor-pointer transition-all hover:translate-y-[-1px] ${isSelected ? 'opacity-100' : 'opacity-25'}" style="gap: 8px;">
<div style="width: 14px; height: 14px; border-radius: 3px; background-color: ${color}; box-shadow: 0 1px 2px rgba(0,0,0,0.1)"></div>
<span class="text-[0.8rem] font-medium text-slate-600 tracking-tight select-none">${g}</span>
</div>`;
btn.onclick = () => {
if (selected.has(g)) {
if (selected.size > 1) selected.delete(g);
} else {
selected.add(g);
}
render();
container.value = Array.from(selected);
container.dispatchEvent(new CustomEvent("input"));
};
container.append(btn);
});
};
render();
container.value = Array.from(selected);
return container;
}
uniqueDates = Array.from(new Set(data.map(d => d.Date.getTime())))
.sort()
.reverse()
.map(ts => new Date(ts))
viewof dateFilter = {
const CalendarIcon = () => html`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>`;
const form = html`
<div class="flex items-center space-x-3 bg-white/10 px-3 py-1.5 rounded border border-white/20">
<span class="text-blue-200">${CalendarIcon()}</span>
<select class="bg-transparent text-white font-medium text-sm focus:outline-none cursor-pointer">
${uniqueDates.map(d => html`
<option value="${d.getTime()}" class="text-slate-800">
${d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })}
</option>
`)}
</select>
</div>
`;
const select = form.querySelector("select");
form.value = uniqueDates[0];
select.onchange = () => {
form.value = new Date(parseInt(select.value));
form.dispatchEvent(new CustomEvent("input"));
};
return form;
}
// 5. DOM INJECTION
{
const inject = () => {
const monthEl = document.getElementById('month-selector-header');
const trackEl = document.getElementById('track-toggle-container');
const modeEl = document.getElementById('grouping-mode-container');
const legendEl = document.getElementById('snapshot-legend-container');
const legendWrapper = document.getElementById('snapshot-legend-wrapper');
if (monthEl) { monthEl.innerHTML = ""; monthEl.append(viewof dateFilter); }
if (trackEl) { trackEl.innerHTML = ""; trackEl.append(viewof selectedTrack); }
if (modeEl) { modeEl.innerHTML = ""; modeEl.append(viewof groupingMode); }
if (legendEl && groupingMode !== "Total") {
legendEl.innerHTML = "";
legendEl.append(viewof snapshotSubgroups);
if (legendWrapper) legendWrapper.style.display = "block";
} else if (legendWrapper) {
legendWrapper.style.display = "none";
}
};
inject();
setTimeout(inject, 100);
}
questionDataSnapshot = snapshotDataFiltered.filter(d => d.Question === selectedQuestion)
snapshotData = {
const currentSnapshotDate = dateFilter ? dateFilter.getTime() : 0;
// 0. Base Filter: Only look at the currently selected month
const qDataCurrent = questionDataSnapshot
.filter(d => d.Date.getTime() === currentSnapshotDate)
.map(d => ({
...d,
Percentage: d.Total_N > 0 ? d.Count / d.Total_N : 0
}));
const mode = groupingMode;
const isComparison = mode !== "Total";
// 1. DUCT TAPE FIX for Compass question
const targetQ = "You mentioned your brokerage has been acquired by Compass. Has your brokerage leadership given you any indication regarding changes to commission splits or fees?";
let base = qDataCurrent;
if (selectedQuestion === targetQ) {
const fixResponses = [
"We have been told to expect the agent’s net compensation to increase.",
"We have been told to expect the agent’s net compensation to decrease."
];
// Find unique groupings available for this specific date
const combos = Array.from(new Set(base.map(d => JSON.stringify({[mode]: d[mode], Total_N: d.Total_N}))));
const extra = [];
combos.forEach(cStr => {
const c = JSON.parse(cStr);
fixResponses.forEach(r => {
if (!base.find(d => d.Response === r && d[mode] === c[mode])) {
extra.push({
Date: new Date(currentSnapshotDate),
[mode]: c[mode],
Question: targetQ,
Response: r,
Count: 0,
Percentage: 0,
Total_N: c.Total_N,
Track: selectedTrack,
Region: isComparison && mode === 'Region' ? c[mode] : 'All',
Brokerage: isComparison && mode === 'Brokerage' ? c[mode] : 'All',
Volume: isComparison && mode === 'Volume' ? c[mode] : 'All'
});
}
});
});
base = [...base, ...extra];
}
// 2. GENERAL SUBGROUP PADDING
if (isComparison) {
// Get master list of all responses that appeared for this question on THIS DATE
const responses = Array.from(new Set(base.map(d => d.Response)));
const activeSubgroups = snapshotSubgroups;
const padding = [];
activeSubgroups.forEach(sub => {
// Find Total_N for this specific subgroup on THIS DATE
const subSample = base.find(d => d[mode] === sub);
const subN = subSample ? subSample.Total_N : 0;
responses.forEach(resp => {
const match = base.find(d => d[mode] === sub && d.Response === resp);
if (!match) {
padding.push({
Date: new Date(currentSnapshotDate),
Question: selectedQuestion,
Response: resp,
[mode]: sub,
Count: 0,
Percentage: 0,
Total_N: subN,
Track: selectedTrack,
Region: mode === 'Region' ? sub : 'All',
Brokerage: mode === 'Brokerage' ? sub : 'All',
Volume: mode === 'Volume' ? sub : 'All'
});
}
});
});
return [...base, ...padding];
}
return base;
}
questionDataTrend = trendDataFixed.filter(d => d.Question === selectedQuestion)
chartDataTrend = {
const qData = trendDataFixed.filter(d => d.Question === selectedQuestion);
const responses = Array.from(new Set(qData.map(d => d.Response)));
const dates = Array.from(new Set(qData.map(d => d.Date.getTime()))).sort().map(t => new Date(t));
const expanded = [];
dates.forEach(date => {
responses.forEach(resp => {
const match = qData.find(d => d.Date.getTime() === date.getTime() && d.Response === resp);
if (match) {
expanded.push({
...match,
Percentage: match.Total_N > 0 ? match.Count / match.Total_N : 0
});
} else {
// Find Total_N for this date to maintain consistency
const dateSample = qData.find(d => d.Date.getTime() === date.getTime());
expanded.push({
Date: date,
Response: resp,
Question: selectedQuestion,
Count: 0,
Percentage: 0,
Total_N: dateSample ? dateSample.Total_N : 0
});
}
});
});
return expanded.sort((a, b) => a.Date - b.Date);
}
// 11. CHARTS
// Helper to get available width in the main area (subtract padding)
mainWidth = {
const isMobile = width < 768;
const sidebarW = isMobile ? 0 : 340;
const mainPadding = isMobile ? 48 : 64; // p-6 (48px) vs p-8 (64px)
const cardPadding = 48; // p-6 on cards
return width - sidebarW - mainPadding - cardPadding;
}
wrap = (text, width = 35) => {
if (text === null || text === undefined) return "";
const str = String(text);
const words = str.split(/\s+/);
let line = [], length = 0, lines = [];
for (const word of words) {
if (length + word.length > width) { lines.push(line.join(" ")); line = []; length = 0; }
line.push(word); length += word.length + 1;
}
if (line.length) lines.push(line.join(" "));
return lines.join("\n");
}
// Interactive Trend Legend Selection (Remains dynamic for FIXED trend data)
allResponses = Array.from(new Set(chartDataTrend.map(d => d.Response)))
viewof trendSelection = {
// Reset selection when the question changes
selectedQuestion;
const container = html`<div class="flex flex-wrap gap-x-5 gap-y-2"></div>`;
const selected = new Set(allResponses);
const render = () => {
container.innerHTML = "";
allResponses.forEach((resp, i) => {
const isSelected = selected.has(resp);
const color = CHART_COLORS[i % CHART_COLORS.length];
const btn = html`
<div class="flex items-center cursor-pointer transition-all hover:translate-y-[-1px] ${isSelected ? 'opacity-100' : 'opacity-25'}" style="gap: 8px;">
<div style="width: 14px; height: 14px; border-radius: 3px; background-color: ${color}; box-shadow: 0 1px 2px rgba(0,0,0,0.1)"></div>
<span class="text-[0.8rem] font-medium text-slate-600 tracking-tight select-none">${resp}</span>
</div>`;
btn.onclick = () => {
if (selected.has(resp)) {
if (selected.size > 1) selected.delete(resp);
} else {
selected.add(resp);
}
render();
container.value = Array.from(selected);
container.dispatchEvent(new CustomEvent("input"));
};
container.append(btn);
});
};
render();
container.value = Array.from(selected);
return container;
}
trendDataFiltered = chartDataTrend.filter(d => trendSelection.includes(d.Response))
snapshotPlot = {
const isMobile = width < 768;
const labelWrap = isMobile ? 25 : 35;
const mode = groupingMode;
const isComparison = mode !== "Total";
const marginL = isMobile ? 140 : 200;
const currentSnapshot = snapshotData.filter(d =>
d.Date.toISOString().split('T')[0] === (dateFilter ? dateFilter.toISOString().split('T')[0] : "")
);
if (currentSnapshot.length === 0) return html`<div class="flex items-center justify-center h-[400px] text-slate-400 font-medium italic">No data available for this selection.</div>`;
if (isComparison) {
// Grouped Bar Chart for Comparison
return Plot.plot({
width: mainWidth,
marginLeft: marginL,
height: Math.max(500, d3.sum(currentSnapshot, d => wrap(d.Response, labelWrap).split("\n").length) * 12 + (currentSnapshot.length * 8)),
color: { range: CHART_COLORS, domain: getSubgroups(mode) },
x: { label: "Percentage", tickFormat: "%", nice: true, grid: true },
y: {
label: null,
axis: null,
domain: getSubgroups(mode)
},
fy: {
label: null,
axis: "left",
domain: Array.from(new Set(currentSnapshot.map(d => d.Response))),
tickFormat: d => wrap(d, labelWrap)
},
marks: [
Plot.barX(currentSnapshot, {
x: "Percentage",
y: mode,
fill: mode,
fy: "Response",
title: d => `${d[mode]}\n${(d.Percentage * 100).toFixed(1)}% (N=${d.Total_N})`,
tip: true
})
]
});
}
// Standard Bar Chart for Single Selection (Total)
return Plot.plot({
width: mainWidth,
marginLeft: marginL,
height: Math.max(400, d3.sum(currentSnapshot, d => wrap(d.Response, labelWrap).split("\n").length) * 14 + (currentSnapshot.length * 10)),
color: {range: CHART_COLORS, domain: allResponses},
x: {label: "Percentage", tickFormat: "%", nice: true, grid: true},
y: {
label: null,
domain: currentSnapshot.sort((a,b) => b.Percentage - a.Percentage).map(d => d.Response),
tickFormat: d => wrap(d, labelWrap),
padding: 0.2
},
marks: [
Plot.barX(currentSnapshot, {
x: "Percentage",
y: "Response",
fill: "Response",
tip: true,
title: d => `${d.Response}\n${(d.Percentage * 100).toFixed(1)}%`
}),
Plot.text(currentSnapshot, {
x: "Percentage",
y: "Response",
text: d => `${(d.Percentage * 100).toFixed(0)}%`,
dx: 5,
textAnchor: "start",
fontWeight: "bold",
fill: "#020A3E"
})
]
})
}
trendPlot = Plot.plot({
width: mainWidth,
marginLeft: 50, height: 400,
color: {range: CHART_COLORS, domain: allResponses},
x: {
label: null,
grid: true
},
y: {label: "Share", tickFormat: "%", grid: true},
marks: [
Plot.areaY(trendDataFiltered, {
x: "Date",
y: "Percentage",
fill: "Response",
order: "sum",
curve: "monotone-x",
tip: true,
title: d => d.Percentage === 0 ? null : `${d.Date.toLocaleDateString('en-US', {month: 'long', year: 'numeric', timeZone: 'UTC'})}\n${d.Response}: ${(d.Percentage * 100).toFixed(1)}%`
}),
Plot.ruleY([0])
]
})
// 12. RENDERER: SNAPSHOT
{
const sCont = document.getElementById('snapshot-chart-container');
if (sCont) {
sCont.innerHTML = "";
try {
sCont.append(snapshotPlot);
} catch (e) {
console.error("Snapshot Plot Error:", e);
sCont.innerHTML = `<div class="p-4 text-red-500 text-xs">Error rendering chart: ${e.message}</div>`;
}
}
}