// Whole SVG is rebuilt on every click (cheap at n=100) -- only the
// hover/click tooltip is kept out of the reactive graph (plain D3
// show/hide via closures), so hovering never triggers a rebuild. Once
// a figure is clicked (pinned), further hovering is ignored entirely
// until the user clicks empty space to unpin -- same locked-selection
// pattern used for the household dots on L2.
//
// Each figure gets its own invisible, generously-sized hit-circle
// (radius 17, well beyond the visible stick-figure's own thin lines)
// that pointer/click handlers are attached to instead of the visible
// shapes themselves -- otherwise the actual clickable area is only the
// handful of pixels covered by 2px-wide strokes, which is what made
// pointing feel overly sensitive before.
incomeSvgWrap = {
const maxRow = d3.max(incomeLayout, d => d.row);
const baseY = incomeTopPad + (maxRow + 1) * incomeRowHeight;
const axisY = baseY + 10;
const height = axisY + 32;
const outer = d3.create("div").attr("class", "income-pop-wrap");
const svg = outer.append("svg")
.attr("viewBox", `0 0 ${incomePlotWidth} ${height}`)
.attr("width", incomePlotWidth)
.attr("height", height)
.attr("class", "income-pop-svg");
const tip = outer.append("div").attr("class", "income-tip").style("display", "none");
let pinned = false;
function showTip(node, text) {
tip.text(text).style("display", "block");
const wrapRect = outer.node().getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const tipNode = tip.node();
let left = (nodeRect.left - wrapRect.left) + nodeRect.width / 2 - tipNode.offsetWidth / 2;
let top = (nodeRect.top - wrapRect.top) - tipNode.offsetHeight - 8;
if (left < 0) left = 0;
if (left + tipNode.offsetWidth > wrapRect.width) left = wrapRect.width - tipNode.offsetWidth;
if (top < 0) top = (nodeRect.bottom - wrapRect.top) + 8;
tip.style("left", `${left}px`).style("top", `${top}px`);
}
function hideTip() { if (!pinned) tip.style("display", "none"); }
// Axis line + hand-drawn ticks (short mark rising from the baseline,
// number beneath it), matching the convention used for every other
// number-line plot in this deck.
svg.append("line")
.attr("x1", incomeMarginLeft).attr("x2", incomePlotWidth - incomeMarginRight)
.attr("y1", axisY).attr("y2", axisY).attr("stroke", "#333").attr("stroke-width", 1);
incomeXScale.ticks(10).forEach(t => {
svg.append("line")
.attr("x1", incomeXScale(t)).attr("x2", incomeXScale(t))
.attr("y1", axisY).attr("y2", axisY + 5)
.attr("stroke", "#333").attr("stroke-width", 1);
svg.append("text")
.attr("x", incomeXScale(t)).attr("y", axisY + 19)
.attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "#555")
.text(t);
});
const muX = incomeXScale(incomeMu);
svg.append("line")
.attr("x1", muX).attr("x2", muX).attr("y1", 16).attr("y2", axisY)
.attr("stroke", "#000").attr("stroke-width", 2.5).attr("stroke-dasharray", "7,4");
svg.append("text")
.attr("x", muX).attr("y", 13)
.attr("text-anchor", "middle").attr("font-size", 17).attr("fill", "#000")
.text(`μ = ${svNum(incomeMu, 1)}`);
if (incomeSample) {
const xbarX = incomeXScale(incomeSample.xbar);
svg.append("line")
.attr("x1", xbarX).attr("x2", xbarX).attr("y1", 16).attr("y2", axisY)
.attr("stroke", "#dc2626").attr("stroke-width", 2.5);
svg.append("text")
.attr("x", xbarX).attr("y", height - 4)
.attr("text-anchor", "middle").attr("font-size", 17).attr("fill", "#dc2626")
.text(`X̄ = ${svNum(incomeSample.xbar, 1)}`);
}
// Legend
svg.append("circle").attr("cx", incomePlotWidth - 110).attr("cy", 16).attr("r", 6).attr("fill", "#dc2626");
svg.append("text").attr("x", incomePlotWidth - 98).attr("y", 21)
.attr("font-size", 14).attr("fill", "#333").text("= i urvalet");
incomeLayout.forEach(d => {
const g = svg.append("g")
.attr("transform", `translate(${d.x}, ${baseY - d.row * incomeRowHeight})`)
.style("cursor", "pointer");
const sampled = incomeSample && incomeSample.ids.has(d.id);
const color = sampled ? "#dc2626" : "#475569";
// Soft halo behind sampled figures, in addition to the color swap
// above -- makes the highlighted individuals unmistakable even from
// the back of a lecture hall.
if (sampled) {
g.append("circle").attr("cy", -11).attr("r", 19).attr("fill", "rgba(220, 38, 38, 0.18)");
}
g.append("circle").attr("cy", -25).attr("r", 5).attr("fill", color);
g.append("line").attr("x1", 0).attr("y1", -20).attr("x2", 0).attr("y2", -4).attr("stroke", color).attr("stroke-width", sampled ? 3 : 2);
g.append("line").attr("x1", -6).attr("y1", -14).attr("x2", 6).attr("y2", -14).attr("stroke", color).attr("stroke-width", sampled ? 3 : 2);
g.append("line").attr("x1", 0).attr("y1", -4).attr("x2", -6).attr("y2", 8).attr("stroke", color).attr("stroke-width", sampled ? 3 : 2);
g.append("line").attr("x1", 0).attr("y1", -4).attr("x2", 6).attr("y2", 8).attr("stroke", color).attr("stroke-width", sampled ? 3 : 2);
// Invisible, generously-sized hit target -- the actual pointer/click
// handlers live here, not on the thin visible strokes above.
const hit = g.append("circle")
.attr("cy", -11).attr("r", 17)
.attr("fill", "transparent")
.style("pointer-events", "all");
hit.on("pointerenter", (event) => {
if (event.pointerType === "touch" || pinned) return;
showTip(hit.node(), `Individ #${d.id}: ${svNum(d.income, 0)} tkr/månad`);
})
.on("pointerleave", (event) => {
if (event.pointerType === "touch") return;
hideTip();
})
.on("click", (event) => {
pinned = true;
showTip(hit.node(), `Individ #${d.id}: ${svNum(d.income, 0)} tkr/månad`);
event.stopPropagation();
});
});
svg.on("click", () => { pinned = false; tip.style("display", "none"); });
return outer.node();
}