questionDataSnapshot = snapshotDataFiltered.filter(d => d.Question=== selectedQuestion)snapshotData = {const currentSnapshotDate = dateFilter ? dateFilter.getTime() :0;// 0. Base Filter: Only look at the currently selected monthconst 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 questionconst 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 dateconst combos =Array.from(newSet(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:newDate(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 PADDINGif (isComparison) {// Get master list of all responses that appeared for this question on THIS DATEconst responses =Array.from(newSet(base.map(d => d.Response)));const activeSubgroups = snapshotSubgroups;const padding = []; activeSubgroups.forEach(sub => {// Find Total_N for this specific subgroup on THIS DATEconst 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:newDate(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(newSet(qData.map(d => d.Response)));const dates =Array.from(newSet(qData.map(d => d.Date.getTime()))).sort().map(t =>newDate(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 consistencyconst 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// --- Layout constants, in px unless noted -----------------------------------------// LINE_H was measured off the rendered chart, not guessed: axis text is 10px with// ~10.1px of ink per line. The top/bottom margins are set explicitly on every plot// rather than left to Plot's defaults, so the band step we compute here is exactly the// step that gets rendered and the label geometry needs no fudge factor.CHART_LAYOUT = ({LINE_H:12,// line box per wrapped line of axis textBAND_PAD:14,// breathing room between one label block and the nextMIN_BAND:28,// floor on row height, so short-label charts don't look crampedMARGIN_TOP:20,// set explicitly on every plot (see note above)MARGIN_BOTTOM:40,// room for the x-axis ticks and the "Percentage" labelCHROME:60,// MARGIN_TOP + MARGIN_BOTTOMLINE_CAP:6,// wrapped lines shown before we ellipsize (see note in layoutLabels)MAX_H:1200,// soft ceiling on total chart heightMIN_GUTTER:120,MAX_GUTTER:320,MIN_BAR_AREA:160,// bars never get squeezed below thisGUTTER_FRAC:0.45,// label gutter never takes more than this share of the chartTICK_PAD:12,// gap Plot leaves between tick text and the axis lineMOBILE_BP:640,// below this, labels move above their barsBAR_H:18,// bar thickness in the mobile stacked layoutMAX_BAR_H:26,// tall rows shouldn't produce comically thick barsBAR_GAP:7})// Two widths, deliberately.//// `live` tracks the container every animation frame and drives the SVG width, so bars and// the x-axis follow the window continuously while it is being dragged.//// `settled` updates only once the drag pauses, and drives everything TEXTUAL — the gutter// width, the word wrap, the line count and therefore the row height. Deriving those from// the live width made the type shimmer: at 60fps a word hops between lines, the gutter// resizes and every row changes height, many times a second. Pinning them to a settled// width keeps the type perfectly still while the plot area stretches around it.//// (Observing the real container also replaced Quarto's reactive `width`, which reported// the whole page: Plot sized the SVG to that and the browser then CSS-scaled it down to// fit — 0.327x at a 375px viewport, i.e. the 10px axis font drawn at ~3.3px.)chartWidths = Generators.observe(notify => { domReady;const L = CHART_LAYOUT;const el =document.getElementById('snapshot-chart-container');if (!el) { notify({live:800,settled:800});return; }const read = () =>Math.max(240,Math.round(el.getBoundingClientRect().width));const SETTLE_MS =140;const MAX_DRIFT =180;// how stale the text layout may get before we re-wrap mid-draglet live =read(), settled = live, raf =null, tail =null;const emit = () =>notify({live, settled});const onResize = () => {if (raf ===null) raf =requestAnimationFrame(() => { raf =null;const w =read();if (w === live) return; live = w;// Re-wrap mid-drag only when leaving the text badly out of date: crossing the// narrow/wide breakpoint switches layout entirely, and drifting too far would let// a stale gutter crowd the bars out.if ((live < L.MOBILE_BP) !== (settled < L.MOBILE_BP) ||Math.abs(live - settled) > MAX_DRIFT) settled = live;emit(); });clearTimeout(tail); tail =setTimeout(() => { live =read(); settled = live;emit(); }, SETTLE_MS); };const ro =newResizeObserver(onResize); ro.observe(el);emit();return () => { clearTimeout(tail);if (raf !==null) cancelAnimationFrame(raf); ro.disconnect(); };})// Pixel-accurate text measurement, replacing the old character-counting wrap().// Counting characters meant "35 chars" spanned anywhere from ~120px to ~200px in a// proportional font, so the fixed gutter was sometimes wasted and sometimes too tight.// 10px system-ui matches Plot 0.6.x's default axis text.measureText = {const ctx =document.createElement("canvas").getContext("2d"); ctx.font="10px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";const cache =newMap();return (s) => {let w = cache.get(s);if (w ===undefined) { w = ctx.measureText(s).width; cache.set(s, w); }return w; };}// Wrap `text` to `maxPx`, at most `maxLines` lines, ellipsizing if it doesn't fit.// Returns {lines, truncated} so callers can attach a tooltip to whatever got cut.wrapPx = (text, maxPx, maxLines) => {const str = (text ===null|| text ===undefined) ?"":String(text);if (!str.trim()) return {lines: [""],truncated:false};// Hard-break any single token wider than the gutter (a long URL, an unspaced// em-dash run) so no label can ever bleed into the plot area.const tokens = [];for (const word of str.split(/\s+/).filter(Boolean)) {if (measureText(word) <= maxPx) { tokens.push(word);continue; }let chunk ="";for (const ch of word) {if (chunk &&measureText(chunk + ch) > maxPx) { tokens.push(chunk); chunk =""; } chunk += ch; }if (chunk) tokens.push(chunk); }const lines = [];let line ="";for (const t of tokens) {const candidate = line ? line +" "+ t : t;if (line &&measureText(candidate) > maxPx) { lines.push(line); line = t; }else line = candidate; }if (line) lines.push(line);if (!lines.length) lines.push("");if (lines.length<= maxLines) return {lines,truncated:false};// Over the cap: keep maxLines and trim the last one until it plus the ellipsis fits.const kept = lines.slice(0, maxLines);let last = kept[maxLines -1];while (last.length&&measureText(last +"…") > maxPx) last = last.slice(0,-1); kept[maxLines -1] = last.replace(/[\s,;:.–—-]+$/,"") +"…";return {lines: kept,truncated:true};}// Axis tick density, derived from the SETTLED width rather than the live one.//// Plot picks tick count from the width it is handed, so on a live width it re-quantises// mid-drag — measured jumping between 3, 6 and 11 ticks across one drag — and the// gridlines and axis labels visibly pop even though the response labels are pinned.// Deriving the count from the settled width holds the gridlines still too.tickCount = (plotW) =>Math.max(2,Math.min(12,Math.round(plotW /90)))// Choose a gutter width, wrap the labels to it, and derive ONE uniform band step.//// The old height formula budgeted from the SUM of wrapped lines, i.e. an average// allocation per row, but label collision is a pairwise MAX constraint between// neighbours. Two ways that failed: a `Math.max(400, ...)` floor clamped the height so// the step fell below what the tallest label needed, and even above the floor a single// long option among short ones overran its neighbours. Sizing every band to the tallest// label makes overlap impossible by construction, and keeps bars and labels equidistant.layoutLabels = (responses, chartW, L) => {const n =Math.max(1, responses.length);const gutterCap =Math.max( L.MIN_GUTTER,Math.min(L.MAX_GUTTER, chartW * L.GUTTER_FRAC, chartW - L.MIN_BAR_AREA) );const textCap =Math.max(40, gutterCap - L.TICK_PAD);// If every label fits on one line we shrink the gutter and hand the width back to the// bars; if anything wraps we take the full allowance, because a wider gutter means// fewer lines means a shorter chart. That is the whole dynamic-contraction behaviour,// and it is bounded at both ends.const widest =Math.max(0,...responses.map(measureText));const textW = widest > textCap ? textCap :Math.max(40, widest);const gutter =Math.max(L.MIN_GUTTER,Math.min(gutterCap, textW + L.TICK_PAD));// With a uniform step one tall label inflates every row, so if we'd blow past the// height ceiling we tighten the line cap rather than letting the chart run away.let cap = L.LINE_CAP, wrapped, step;for (;;) { wrapped =newMap(responses.map(r => [r,wrapPx(r, textW, cap)]));const maxLines =Math.max(1,...[...wrapped.values()].map(w => w.lines.length)); step =Math.max(L.MIN_BAND, maxLines * L.LINE_H+ L.BAND_PAD);if (cap <=2|| n * step + L.CHROME<= L.MAX_H) break; cap -=1; }const maxLines =Math.max(1,...[...wrapped.values()].map(w => w.lines.length));return {gutter, textW, wrapped, step, maxLines, cap,height: n * step + L.CHROME};}// Same idea for the narrow layout, where the label sits above its own bar and gets the// full chart width instead of a gutter.layoutStacked = (responses, chartW, L) => {const n =Math.max(1, responses.length);const textW =Math.max(120, chartW -44);// leave room for the % value at the bar endlet cap = L.LINE_CAP, wrapped, labelH, step;for (;;) { wrapped =newMap(responses.map(r => [r,wrapPx(r, textW, cap)]));const maxLines =Math.max(1,...[...wrapped.values()].map(w => w.lines.length)); labelH = maxLines * L.LINE_H; step = labelH + L.BAR_GAP+ L.BAR_H+ L.BAR_GAP;if (cap <=2|| n * step + L.CHROME<= L.MAX_H) break; cap -=1; }return {textW, wrapped, labelH, step, cap,height: n * step + L.CHROME};}// Interactive Trend Legend Selection (Remains dynamic for FIXED trend data)allResponses =Array.from(newSet(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 =newSet(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(newCustomEvent("input")); }; container.append(btn); }); };render(); container.value=Array.from(selected);return container;}trendDataFiltered = chartDataTrend.filter(d => trendSelection.includes(d.Response))// The selected month's slice, shared by the chart, the truncation map and the sample-size// readout. Hoisted into its own cell for two reasons: it does not depend on the width, so a// resize no longer re-filters the data, and the comparison is numeric. The old// toISOString().split() ran once per row in three separate cells — thousands of calls per// redraw, and almost the entire cost of a resize.currentSnapshot = {const t = dateFilter ?+dateFilter :NaN;return snapshotData.filter(d =>+d.Date=== t);}snapshotPlot = {const L = CHART_LAYOUT;const chartW = chartWidths.live;// SVG width: follows the window every frameconst layoutW = chartWidths.settled;// wrap / gutter / row height: only once it pausesconst isNarrow = layoutW < L.MOBILE_BP;const mode = groupingMode;const isComparison = mode !=="Total";if (currentSnapshot.length===0) returnhtml`<div class="flex items-center justify-center h-[400px] text-slate-400 font-medium italic">No data available for this selection.</div>`;// One entry per response option, in display order. The old code measured line counts// over currentSnapshot directly, which in comparison mode holds one row per// Response x subgroup — so every label's line count was multiplied by the number of// subgroups and the chart came out several times taller than it needed to be.const responses = isComparison?Array.from(newSet(currentSnapshot.map(d => d.Response))): currentSnapshot.slice().sort((a, b) => b.Percentage- a.Percentage).map(d => d.Response);// The renderer needs to know which labels got ellipsized so it can hang a tooltip on// them. Carrying that out on the element beats recomputing the whole wrap in a second// width-dependent cell, which would double what OJS propagates on every resize frame.let lay =null;const build = () => {const pctTitle = d =>`${d.Response}\n${(d.Percentage*100).toFixed(1)}%`;const cmpTitle = d =>`${d[mode]}\n${(d.Percentage*100).toFixed(1)}% (N=${d.Total_N})`;// ---- Narrow: label above its own bar, using the full width instead of a gutter ----if (isNarrow) { lay =layoutStacked(responses, layoutW, L);const rows = responses.map(r => ({Response: r}));const labelMark = opts => Plot.text(rows,Object.assign({textAnchor:"start",lineAnchor:"top",dx:2,lineHeight:1.2,text: d => lay.wrapped.get(d.Response).lines.join("\n"),fill:"#334155",fontWeight:500 }, opts));if (!isComparison) {return Plot.plot({width: chartW,marginLeft:4,marginRight:42,marginTop: L.MARGIN_TOP,marginBottom: L.MARGIN_BOTTOM,height: lay.height,color: {range: CHART_COLORS,domain: allResponses},x: {label:"Percentage",tickFormat:"%",nice:true,grid:true,ticks:tickCount(layoutW -46)},y: {label:null,axis:null,domain: responses,padding:0},marks: [// bottom-anchored: a one-line caption then sits as close to its bar as a// four-line one, and the slack falls above the block where it reads as spacinglabelMark({y:"Response",frameAnchor:"left",lineAnchor:"bottom",dy:-lay.step/2+ lay.labelH}), Plot.barX(currentSnapshot, {x:"Percentage",y:"Response",fill:"Response",insetTop: lay.labelH+ L.BAR_GAP,insetBottom: L.BAR_GAP,tip:true,title: pctTitle }), Plot.text(currentSnapshot, {x:"Percentage",y:"Response",text: d =>`${(d.Percentage*100).toFixed(0)}%`,dx:5,dy: lay.labelH/2,// ride the bar, not the label block above ittextAnchor:"start",fontWeight:"bold",fill:"#020A3E" }) ] }); }// Comparison: keep the facets, but hang each response label in the gap above its// facet rather than in a left gutter there is no room for.const k =Math.max(1,getSubgroups(mode).length);const barsH = k * (L.BAR_H-4) +8;const gap = lay.labelH+ L.BAR_GAP;const fstep = gap + barsH;return Plot.plot({width: chartW,marginLeft:4,marginRight:42,marginTop: gap +6,marginBottom: L.MARGIN_BOTTOM,height: responses.length* fstep + gap +6+ L.MARGIN_BOTTOM,color: {range: CHART_COLORS,domain:getSubgroups(mode)},x: {label:"Percentage",tickFormat:"%",nice:true,grid:true,ticks:tickCount(layoutW -46)},y: {label:null,axis:null,domain:getSubgroups(mode)},fy: {label:null,axis:null,domain: responses,padding:Math.min(0.6, gap / fstep)},marks: [labelMark({fy:"Response",frameAnchor:"top-left",lineAnchor:"bottom",dy:-5}), Plot.barX(currentSnapshot, {x:"Percentage",y: mode,fill: mode,fy:"Response",tip:true,title: cmpTitle }) ] }); }// ---- Wide: labels in an adaptive left gutter ---- lay =layoutLabels(responses, layoutW, L);const tickText = d => lay.wrapped.get(d).lines.join("\n");// the gutter was measured against layoutW, so never let a stale one crowd out the// bars when the live width has shrunk faster than the text has been re-wrappedconst gutter =Math.min(lay.gutter,Math.max(60, chartW - L.MIN_BAR_AREA));if (isComparison) {const k =Math.max(1,getSubgroups(mode).length);const facetH =Math.max(lay.step, k *16+12);// clear the label AND fit k barsreturn Plot.plot({width: chartW,marginLeft: gutter,marginRight:24,marginTop: L.MARGIN_TOP,marginBottom: L.MARGIN_BOTTOM,height: responses.length* facetH + L.CHROME,color: {range: CHART_COLORS,domain:getSubgroups(mode)},x: {label:"Percentage",tickFormat:"%",nice:true,grid:true,ticks:tickCount(layoutW - lay.gutter-24)},y: {label:null,axis:null,domain:getSubgroups(mode)},fy: {label:null,axis:"left",domain: responses,tickFormat: tickText},marks: [ Plot.barX(currentSnapshot, {x:"Percentage",y: mode,fill: mode,fy:"Response",tip:true,title: cmpTitle }) ] }); }// Tall rows would otherwise give absurdly thick bars, so inset them to a sane height.const barInset =Math.max(2, (lay.step- L.MAX_BAR_H) /2);return Plot.plot({width: chartW,marginLeft: gutter,marginRight:42,marginTop: L.MARGIN_TOP,marginBottom: L.MARGIN_BOTTOM,height: lay.height,color: {range: CHART_COLORS,domain: allResponses},x: {label:"Percentage",tickFormat:"%",nice:true,grid:true,ticks:tickCount(layoutW - lay.gutter-42)},y: {label:null,domain: responses,tickFormat: tickText,padding:0},marks: [ Plot.barX(currentSnapshot, {x:"Percentage",y:"Response",fill:"Response",insetTop: barInset,insetBottom: barInset,tip:true,title: pctTitle }), Plot.text(currentSnapshot, {x:"Percentage",y:"Response",text: d =>`${(d.Percentage*100).toFixed(0)}%`,dx:5,textAnchor:"start",fontWeight:"bold",fill:"#020A3E" }) ] }); };const el =build();if (lay) el.__labels= lay.wrapped;return el;}trendPlot = Plot.plot({width: chartWidths.live,marginLeft:50,height:400,color: {range: CHART_COLORS,domain: allResponses},x: {label:null,grid:true,ticks:tickCount(chartWidths.settled-50) // settled, so the date ticks hold still too },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{ domReady;const sCont =document.getElementById('snapshot-chart-container');if (sCont) { sCont.innerHTML="";try { sCont.append(snapshotPlot);// Options are shown in full wherever they fit. The few that still don't — write-in// verbatims, up to 846 chars — get an ellipsis plus the full text on hover and on// tap. <title> covers hover and screen readers but never fires on touch, hence the// explicit pointer handler. This lives in the renderer, not the plot cell, because// Plot builds a fresh SVG on every re-render.const truncated =newMap();for (const [full, w] of (snapshotPlot.__labels|| []))if (w.truncated) truncated.set(w.lines.join(""), full);// key = the SVG's textContentif (truncated.size) {let bubble =null;const hide = () => { if (bubble) { bubble.remove(); bubble =null; } };for (const t of sCont.querySelectorAll('text')) {const full = truncated.get(t.textContent);if (!full) continue; t.style.cursor="help";const title =document.createElementNS("http://www.w3.org/2000/svg","title"); title.textContent= full; t.append(title); t.addEventListener("pointerdown", ev => {if (ev.pointerType==="mouse") return;// the <title> already covers hover ev.preventDefault();hide(); bubble =document.createElement("div"); bubble.className="label-bubble"; bubble.textContent= full;document.body.append(bubble);const r = t.getBoundingClientRect();const w =Math.min(300,window.innerWidth-24); bubble.style.width= w +"px"; bubble.style.left=Math.max(12,Math.min(window.innerWidth- w -12, r.left)) +"px"; bubble.style.top= (window.scrollY+ r.bottom+6) +"px";setTimeout(() =>document.addEventListener("pointerdown", hide, {once:true}),0); }); } } } 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>`; } }}
{ domReady;const tCont =document.getElementById('trend-chart-container');const tLegendCont =document.getElementById('trend-legend-container');const trendCard =document.getElementById('trend-card');if (tCont && trendCard) { const q = selectedQuestion;const months = chartDataTrend ?Array.from(newSet(chartDataTrend.map(d =>+d.Date))) : [];const uniqueMonths = months.length;// Gate the trend chart on whether history actually exists, NOT on the// new/recurring flag. That flag means "absent from the immediately preceding// month," which is not the same as "never asked before": a question that// skipped a month, or that alternates (e.g. the AI battery asked in Dec 2024,// Jul 2025, Aug 2025 and Apr 2026), was being listed as new AND having its// trend chart suppressed while the cube held several months of comparable// history for it. chartDataTrend is already built from the full cube history.if (uniqueMonths >1) { trendCard.style.display="block";if (tLegendCont) { tLegendCont.innerHTML=""; tLegendCont.append(viewof trendSelection); } tCont.innerHTML="";try { tCont.append(trendPlot); } catch (e) {console.error("Trend Plot Error:", e); tCont.innerHTML=`<div class="p-4 text-red-500 text-xs">Error rendering chart: ${e.message}</div>`; } } else { trendCard.style.display="none"; } }}