Regressionsanalys
\[deciliter = 2{,}366 \times cups\]
\[Fahrenheit = 32 + 1{,}8 \times Celsius\]





\[y = kx + m\]
\[y = a + bx\]
html`<div class="rel-buttons-wrap">
<div class="rel-buttons-title">Samband</div>
<div class="sd-buttons">
${Inputs.button("Positivt", { value: null, reduce: () => { mutable relType = "pos"; return null; } })}
${Inputs.button("Negativt", { value: null, reduce: () => { mutable relType = "neg"; return null; } })}
${Inputs.button("Inget", { value: null, reduce: () => { mutable relType = "none"; return null; } })}
</div>
</div>`function relLineFn(type, x) {
if (type === "pos") return 2 + 0.5 * x;
if (type === "neg") return 2 - 0.5 * x;
return 2;
}
function relColor(type) {
return type === "pos" ? "#2563eb" : type === "neg" ? "#dc2626" : "#6b7280";
}
function relEquation(type) {
if (type === "pos") return "Y = 2 + 0,5X";
if (type === "neg") return "Y = 2 − 0,5X";
return "Y = 2";
}// Built fresh from scratch whenever relType changes (cheap -- no
// animation to preserve across redraws, unlike the CLT/pizza figures
// elsewhere in this deck), so hover/tap handlers below are always
// closures over the *current* relType and line.
relSvg = {
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${relWidth} ${relHeight}`)
.attr("width", relWidth)
.attr("height", relHeight)
.attr("class", "rel-svg")
.style("touch-action", "none");
const xAxisY = relYScale(0);
const yAxisX = relXScale(0);
const gridG = svg.append("g");
for (const gx of relXTicks) {
gridG.append("line")
.attr("x1", relXScale(gx)).attr("x2", relXScale(gx))
.attr("y1", relMarginTop).attr("y2", relHeight - relMarginBottom)
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
for (const gy of relYTicks) {
gridG.append("line")
.attr("x1", relMarginLeft).attr("x2", relWidth - relMarginRight)
.attr("y1", relYScale(gy)).attr("y2", relYScale(gy))
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
svg.append("line")
.attr("x1", relXScale(relXDomain[0])).attr("x2", relXScale(relXDomain[1]))
.attr("y1", xAxisY).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
svg.append("line")
.attr("x1", yAxisX).attr("x2", yAxisX)
.attr("y1", relYScale(relYDomain[0])).attr("y2", relYScale(relYDomain[1]))
.attr("stroke", "#333").attr("stroke-width", 2);
const xTipX = relXScale(relXDomain[1]);
svg.append("path")
.attr("d", `M ${xTipX} ${xAxisY} L ${xTipX - 9} ${xAxisY - 5} L ${xTipX - 9} ${xAxisY + 5} Z`)
.attr("fill", "#333");
const yTipY = relYScale(relYDomain[1]);
svg.append("path")
.attr("d", `M ${yAxisX} ${yTipY} L ${yAxisX - 5} ${yTipY + 9} L ${yAxisX + 5} ${yTipY + 9} Z`)
.attr("fill", "#333");
svg.append("text")
.attr("x", xTipX).attr("y", xAxisY + 22)
.attr("text-anchor", "end").attr("font-size", 18).attr("font-weight", "bold")
.attr("fill", "#333").text("X");
svg.append("text")
.attr("x", yAxisX - 10).attr("y", yTipY + 4)
.attr("text-anchor", "end").attr("font-size", 18).attr("font-weight", "bold")
.attr("fill", "#333").text("Y");
// Numeric tick marks/labels along both axes. The x=0 label is
// skipped here since the y-axis tick loop already draws a single
// "0" right at the origin -- drawing it twice would just overlap.
const tickG = svg.append("g");
for (const gx of relXTicks) {
if (gx === 0) continue;
const px = relXScale(gx);
tickG.append("line")
.attr("x1", px).attr("x2", px)
.attr("y1", xAxisY).attr("y2", xAxisY + 5)
.attr("stroke", "#333").attr("stroke-width", 1);
tickG.append("text")
.attr("x", px).attr("y", xAxisY + 19)
.attr("text-anchor", "middle").attr("font-size", 13)
.attr("fill", "#555").text(svFmt(gx));
}
for (const gy of relYTicks) {
const py = relYScale(gy);
tickG.append("line")
.attr("x1", yAxisX - 5).attr("x2", yAxisX)
.attr("y1", py).attr("y2", py)
.attr("stroke", "#333").attr("stroke-width", 1);
// The origin's "0" sits diagonally down-left of the axis crossing
// instead of directly left, so it doesn't crowd either axis line.
if (gy === 0) {
tickG.append("text")
.attr("x", yAxisX - 10).attr("y", py + 16)
.attr("text-anchor", "end").attr("font-size", 13)
.attr("fill", "#555").text(svFmt(gy));
} else {
tickG.append("text")
.attr("x", yAxisX - 9).attr("y", py + 4)
.attr("text-anchor", "end").attr("font-size", 13)
.attr("fill", "#555").text(svFmt(gy));
}
}
if (relType) {
const x0 = relXDomain[0], x1 = relXDomain[1];
const y0 = relLineFn(relType, x0), y1 = relLineFn(relType, x1);
svg.append("line")
.attr("x1", relXScale(x0)).attr("y1", relYScale(y0))
.attr("x2", relXScale(x1)).attr("y2", relYScale(y1))
.attr("stroke", relColor(relType)).attr("stroke-width", 3);
const hoverG = svg.append("g").attr("display", "none");
// Dashed projection lines out to each axis, drawn first so the
// dot/glow/text sit visually on top of them.
const hoverGuideY = hoverG.append("line")
.attr("stroke", relColor(relType)).attr("stroke-opacity", 0.55)
.attr("stroke-width", 1).attr("stroke-dasharray", "4,3");
const hoverGuideX = hoverG.append("line")
.attr("stroke", relColor(relType)).attr("stroke-opacity", 0.55)
.attr("stroke-width", 1).attr("stroke-dasharray", "4,3");
hoverG.append("circle").attr("class", "rel-hover-glow").attr("r", 9)
.attr("fill", relColor(relType)).attr("fill-opacity", 0.25);
hoverG.append("circle").attr("class", "rel-hover-dot").attr("r", 4)
.attr("fill", relColor(relType));
const hoverText = hoverG.append("text")
.attr("text-anchor", "middle")
.attr("font-size", 14)
.attr("fill", relColor(relType));
function showHover(x, y) {
const px = relXScale(x), py = relYScale(y);
hoverG.attr("display", null);
// Out to the y-axis (horizontal) and down to the x-axis (vertical).
hoverGuideY.attr("x1", px).attr("y1", py).attr("x2", yAxisX).attr("y2", py);
hoverGuideX.attr("x1", px).attr("y1", py).attr("x2", px).attr("y2", xAxisY);
hoverG.select(".rel-hover-glow").attr("cx", px).attr("cy", py);
hoverG.select(".rel-hover-dot").attr("cx", px).attr("cy", py);
hoverText.attr("x", px).attr("y", py - 14).text(`X = ${svFmt(x)}, Y = ${svFmt(y)}`);
}
function hideHover() {
hoverG.attr("display", "none");
}
// Snap-candidates are the line's own points at each relGridStep
// x-step (the same spacing as the background grid). Pixel radius is
// derived from the actual on-screen spacing between them, kept
// just under half a step, so neighbouring points never fight over
// the same pointer position.
const pixelStep = Math.abs(relXScale(relGridStep) - relXScale(0));
const mouseRadius = pixelStep * 0.45;
const touchRadius = pixelStep * 0.48;
function nearestCandidate(mx, my) {
let best = null, bestDist = Infinity;
for (const gx of relXTicks) {
const gy = relLineFn(relType, gx);
const px = relXScale(gx), py = relYScale(gy);
const d = Math.hypot(px - mx, py - my);
if (d < bestDist) { bestDist = d; best = { x: gx, y: gy, dist: d }; }
}
return best;
}
const overlay = svg.append("rect")
.attr("x", relMarginLeft).attr("y", relMarginTop)
.attr("width", relWidth - relMarginLeft - relMarginRight)
.attr("height", relHeight - relMarginTop - relMarginBottom)
.attr("fill", "transparent")
.style("cursor", "pointer");
// Mouse/pen: continuous hover as the pointer moves.
overlay.on("pointermove", (event) => {
if (event.pointerType === "touch") return;
const [mx, my] = d3.pointer(event);
const cand = nearestCandidate(mx, my);
if (cand && cand.dist <= mouseRadius) showHover(cand.x, cand.y);
else hideHover();
});
overlay.on("pointerleave", () => hideHover());
// Touch (and mouse, harmlessly): tap to show, tap elsewhere to hide.
overlay.on("click", (event) => {
const [mx, my] = d3.pointer(event);
const cand = nearestCandidate(mx, my);
if (cand && cand.dist <= touchRadius) showHover(cand.x, cand.y);
else hideHover();
});
}
return svg.node();
}| Hushålls-ID | Inomhustemperatur | Månadsinkomst (i tusentals SEK) |
|---|---|---|
| 1 | 18,3 | 13,4 |
| 2 | 19,5 | 13,6 |
| 3 | 20,5 | 26,9 |
| 4 | 20,8 | 33,5 |
| 5 | 18,5 | 14,4 |
| 6 | 20,4 | 44,0 |
| 7 | 17,2 | 20,3 |
| 8 | 15,9 | 16,6 |
| 9 | 22,3 | 28,8 |
| 10 | 16,6 | 35,0 |
| 11 | 21,6 | 28,9 |
| 12 | 25,4 | 42,3 |
| 13 | 19,7 | 27,8 |
| 14 | 23,5 | 24,7 |
| 15 | 18,0 | 29,2 |
\[Y_i = a + b X_i + e_i\]
// One-time (non-reactive, no dependencies -- runs exactly once) global
// listener: clicking anywhere that isn't a data point or a Data-tab
// table row closes the currently open info box and returns to plain
// hover mode.
hhClickAwayListener = {
document.addEventListener("click", (event) => {
if (event.target.closest(".hh-dot, .hh-hit, .hh-row")) return;
mutable selectedHH = null;
});
return null;
}hhData = [
{id: 1, temp: 18.3, income: 13.4},
{id: 2, temp: 19.5, income: 13.6},
{id: 3, temp: 20.5, income: 26.9},
{id: 4, temp: 20.8, income: 33.5},
{id: 5, temp: 18.5, income: 14.4},
{id: 6, temp: 20.4, income: 44.0},
{id: 7, temp: 17.2, income: 20.3},
{id: 8, temp: 15.9, income: 16.6},
{id: 9, temp: 22.3, income: 28.8},
{id: 10, temp: 16.6, income: 35.0},
{id: 11, temp: 21.6, income: 28.9},
{id: 12, temp: 25.4, income: 42.3},
{id: 13, temp: 19.7, income: 27.8},
{id: 14, temp: 23.5, income: 24.7},
{id: 15, temp: 18.0, income: 29.2}
]// Swedish comma-decimal formatter for this figure specifically: always
// one decimal (e.g. "44,0", not "44"), so numbers line up visually with
// the printed table on the previous slide instead of dropping trailing
// zeros like svFmt does on the Positivt/Negativt figure.
function hhFmt(v) {
return v.toFixed(1).replace(".", ",");
}// Rebuilt whenever selectedHH or showRegLine changes (click on a dot,
// a jump from the Data tab's table, or the regression-line button) --
// cheap with only 15 points, so no need for the imperative-DOM-patching
// approach used on continuous-hover figures elsewhere in this deck.
hhSvg = {
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${hhWidth} ${hhHeight}`)
.attr("width", hhWidth)
.attr("height", hhHeight)
.attr("class", "hh-svg")
.style("touch-action", "none");
const xAxisY = hhYScale(hhYDomain[0]);
const yAxisX = hhXScale(hhXDomain[0]);
const gridG = svg.append("g");
for (const gx of hhXTicks) {
gridG.append("line")
.attr("x1", hhXScale(gx)).attr("x2", hhXScale(gx))
.attr("y1", hhMarginTop).attr("y2", xAxisY)
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
for (const gy of hhYTicks) {
gridG.append("line")
.attr("x1", yAxisX).attr("x2", hhWidth - hhMarginRight)
.attr("y1", hhYScale(gy)).attr("y2", hhYScale(gy))
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
svg.append("line")
.attr("x1", yAxisX).attr("x2", hhWidth - hhMarginRight)
.attr("y1", xAxisY).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
svg.append("line")
.attr("x1", yAxisX).attr("x2", yAxisX)
.attr("y1", hhMarginTop).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
// Tick marks every hhXStep/hhYStep, but numeric labels only every
// hhXLabelStep/hhYLabelStep -- so the grid is finer than what's
// actually labelled.
const tickG = svg.append("g");
for (const gx of hhXTicks) {
tickG.append("line")
.attr("x1", hhXScale(gx)).attr("x2", hhXScale(gx))
.attr("y1", xAxisY).attr("y2", xAxisY + 5)
.attr("stroke", "#333").attr("stroke-width", 1);
if (Math.round(gx) % hhXLabelStep === 0) {
tickG.append("text")
.attr("x", hhXScale(gx)).attr("y", xAxisY + 19)
.attr("text-anchor", "middle").attr("font-size", 14)
.attr("fill", "#555").text(String(gx));
}
}
for (const gy of hhYTicks) {
tickG.append("line")
.attr("x1", yAxisX - 5).attr("x2", yAxisX)
.attr("y1", hhYScale(gy)).attr("y2", hhYScale(gy))
.attr("stroke", "#333").attr("stroke-width", 1);
if (Math.round(gy) % hhYLabelStep === 0) {
tickG.append("text")
.attr("x", yAxisX - 10).attr("y", hhYScale(gy) + 5)
.attr("text-anchor", "end").attr("font-size", 14)
.attr("fill", "#555").text(String(gy));
}
}
// "Inkomst" sits near the right end of the x-axis (anchor="end" so
// it ends just inside the right edge instead of overflowing past
// it). "Temperatur" sits near the top of the y-axis, close to the
// "25" tick -- rotate(-90) around a point near the top plus
// anchor="end" makes the text extend *downward* from that point
// (not upward off the top edge of the figure).
svg.append("text")
.attr("x", hhWidth - hhMarginRight - 4)
.attr("y", xAxisY - 6)
.attr("text-anchor", "end").attr("font-size", 16).attr("font-weight", "bold")
.attr("fill", "#333").text("Inkomst");
const yLabelAnchorY = hhMarginTop + 10;
svg.append("text")
.attr("x", yAxisX + 18).attr("y", yLabelAnchorY)
.attr("transform", `rotate(-90, ${yAxisX + 18}, ${yLabelAnchorY})`)
.attr("text-anchor", "end").attr("font-size", 16).attr("font-weight", "bold")
.attr("fill", "#333").text("Temperatur");
// Regression line drawn before the dots (not after), so the data
// points always sit visually on top of it.
if (showRegLine) {
const rx0 = hhXDomain[0], rx1 = hhXDomain[1];
const ry0 = 16.43 + 0.13 * rx0, ry1 = 16.43 + 0.13 * rx1;
svg.append("line")
.attr("x1", hhXScale(rx0)).attr("y1", hhYScale(ry0))
.attr("x2", hhXScale(rx1)).attr("y2", hhYScale(ry1))
.attr("stroke", "#16a34a").attr("stroke-width", 2.5);
}
const dotsG = svg.append("g");
const infoG = svg.append("g");
function renderInfoBox(id) {
infoG.selectAll("*").remove();
const d = hhData.find(h => h.id === id);
if (!d) return;
const px = hhXScale(d.income), py = hhYScale(d.temp);
const boxW = 128, boxH = 58;
let bx = px + 10, by = py - boxH - 8;
if (bx + boxW > hhWidth - 2) bx = px - boxW - 10;
if (by < 2) by = py + 10;
infoG.append("rect")
.attr("x", bx).attr("y", by).attr("width", boxW).attr("height", boxH)
.attr("rx", 6).attr("fill", "white").attr("fill-opacity", 0.95)
.attr("stroke", "#999").attr("stroke-width", 1);
const lines = [
`Hushåll: ${d.id}`,
`Temperatur: ${hhFmt(d.temp)}`,
`Inkomst: ${hhFmt(d.income)}`
];
lines.forEach((line, i) => {
infoG.append("text")
.attr("x", bx + 8).attr("y", by + 18 + i * 16)
.attr("font-size", 12).attr("fill", "#333").text(line);
});
}
const circleSel = dotsG.selectAll("circle")
.data(hhData)
.join("circle")
.attr("class", "hh-dot")
.attr("cx", d => hhXScale(d.income))
.attr("cy", d => hhYScale(d.temp))
.attr("fill-opacity", 0.85);
function setDotStyle(id) {
circleSel
.attr("r", d => d.id === id ? 6 : 4)
.attr("fill", d => d.id === id ? "#dc2626" : "#2563eb");
}
// Initial state reflects the *persistent* selection only (a click, or
// a jump from the Data tab's table) -- this is the mutable-driven,
// reactive part, rebuilt whenever selectedHH changes.
setDotStyle(selectedHH);
renderInfoBox(selectedHH);
// Invisible, much larger hit-circles sit on top of the tiny visible
// dots and carry all the pointer/click handlers -- a 4-6px visible
// dot is far below the ~44px touch target Apple/Google recommend, so
// tapping it accurately on a phone was close to impossible.
const hitSel = dotsG.selectAll("circle.hh-hit")
.data(hhData)
.join("circle")
.attr("class", "hh-hit")
.attr("cx", d => hhXScale(d.income))
.attr("cy", d => hhYScale(d.temp))
.attr("r", 15)
.attr("fill", "transparent")
.style("cursor", "pointer");
// Hover (mouse only -- touch has no hover state, so it's handled by
// "click" below) is deliberately kept OUT of the mutable/reactive
// graph: setting `mutable selectedHH` on pointerenter would rebuild
// this whole SVG mid-hover, replacing the very circle the pointer is
// sitting on, which silently suppressed the browser's own
// pointerleave event and left the box stuck open. Direct DOM
// manipulation instead keeps the actual hovered element alive for
// its own pointerleave to fire normally, so moving the pointer away
// -- even to empty space, not just onto another dot -- always closes
// it. Once a selection is *locked in* (via a click, or a jump from
// the Data tab's table), hover is ignored entirely instead of
// transiently overriding it and then snapping back on pointerleave
// -- that snap-back read as flickery/distracting. A locked box only
// changes via a new click, or gets cleared by the page's click-away
// listener (which also fires when switching back to the Data tab,
// since that click isn't on a dot or a row either).
hitSel
.on("pointerenter", (event, d) => {
if (event.pointerType === "touch") return;
if (selectedHH !== null) return;
setDotStyle(d.id);
renderInfoBox(d.id);
})
.on("pointerleave", (event) => {
if (event.pointerType === "touch") return;
if (selectedHH !== null) return;
setDotStyle(null);
renderInfoBox(null);
})
.on("click", (event, d) => {
mutable selectedHH = d.id;
});
return svg.node();
}// Built with D3 (not a plain markdown table) so each row can get a real
// click listener -- clicking a row jumps to the Graf tab (by clicking
// its tab link, letting the tabby library handle the actual switch) and
// selects that household's point, popping its info box open.
hhTable = {
const wrap = d3.create("div").attr("class", "hh-table-wrap");
const table = wrap.append("table").attr("class", "hh-data-table");
table.append("thead").append("tr")
.selectAll("th")
.data(["Hushålls-ID", "Inomhustemperatur", "Månadsinkomst (i tusentals SEK)"])
.join("th")
.text(d => d);
const tbody = table.append("tbody");
hhData.forEach(d => {
const tr = tbody.append("tr").attr("class", "hh-row");
tr.append("td").text(d.id);
tr.append("td").text(hhFmt(d.temp));
tr.append("td").text(hhFmt(d.income));
tr.on("click", () => {
// The tab link's own (synthetic) click also bubbles to the
// document-level "click away" listener above, which would clear
// selectedHH right back out -- so it must run *before* we set
// the real value, not after.
const tabsetEl = document.querySelector(".hh-graph-tabset");
const firstLink = tabsetEl && tabsetEl.querySelector(".panel-tabset-tabby li:first-child a");
if (firstLink) firstLink.click();
mutable selectedHH = d.id;
});
});
return wrap.node();
}\[\hat{Y_i} = 16{,}43+0{,}13 \times X_i\]
// Three plausible-but-wrong alternative fits (none is the actual
// least-squares line), listed in hover-priority order: where two
// lines' hit areas overlap, the first one in this array wins.
cmpLines = [
{ a: 15.2, b: 0.16, color: "#2563eb", dash: "7,5" },
{ a: 18.1, b: 0.09, color: "#b45309", dash: "1.5,4" },
{ a: 14.8, b: 0.19, color: "#15803d", dash: null }
]// Reuses hhData (defined once, on the "Utforska data" slide) -- points
// here are plain and non-interactive (no per-observation info boxes,
// removed to keep this figure from getting too busy alongside the
// three line tooltips).
cmpSvg = {
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${cmpWidth} ${cmpHeight}`)
.attr("width", cmpWidth)
.attr("height", cmpHeight)
.attr("class", "cmp-svg")
.style("touch-action", "none");
const xAxisY = cmpYScale(cmpYDomain[0]);
const yAxisX = cmpXScale(cmpXDomain[0]);
const gridG = svg.append("g");
for (const gx of cmpXTicks) {
gridG.append("line")
.attr("x1", cmpXScale(gx)).attr("x2", cmpXScale(gx))
.attr("y1", cmpMarginTop).attr("y2", xAxisY)
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
for (const gy of cmpYTicks) {
gridG.append("line")
.attr("x1", yAxisX).attr("x2", cmpWidth - cmpMarginRight)
.attr("y1", cmpYScale(gy)).attr("y2", cmpYScale(gy))
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
svg.append("line")
.attr("x1", yAxisX).attr("x2", cmpWidth - cmpMarginRight)
.attr("y1", xAxisY).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
svg.append("line")
.attr("x1", yAxisX).attr("x2", yAxisX)
.attr("y1", cmpMarginTop).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
const tickG = svg.append("g");
for (const gx of cmpXTicks) {
tickG.append("line")
.attr("x1", cmpXScale(gx)).attr("x2", cmpXScale(gx))
.attr("y1", xAxisY).attr("y2", xAxisY + 5)
.attr("stroke", "#333").attr("stroke-width", 1);
if (Math.round(gx) % cmpXLabelStep === 0) {
tickG.append("text")
.attr("x", cmpXScale(gx)).attr("y", xAxisY + 19)
.attr("text-anchor", "middle").attr("font-size", 14)
.attr("fill", "#555").text(String(gx));
}
}
for (const gy of cmpYTicks) {
tickG.append("line")
.attr("x1", yAxisX - 5).attr("x2", yAxisX)
.attr("y1", cmpYScale(gy)).attr("y2", cmpYScale(gy))
.attr("stroke", "#333").attr("stroke-width", 1);
if (Math.round(gy) % cmpYLabelStep === 0) {
tickG.append("text")
.attr("x", yAxisX - 10).attr("y", cmpYScale(gy) + 5)
.attr("text-anchor", "end").attr("font-size", 14)
.attr("fill", "#555").text(String(gy));
}
}
svg.append("text")
.attr("x", cmpWidth - cmpMarginRight - 4)
.attr("y", xAxisY - 6)
.attr("text-anchor", "end").attr("font-size", 16).attr("font-weight", "bold")
.attr("fill", "#333").text("Inkomst");
const yLabelAnchorY = cmpMarginTop + 10;
svg.append("text")
.attr("x", yAxisX + 18).attr("y", yLabelAnchorY)
.attr("transform", `rotate(-90, ${yAxisX + 18}, ${yLabelAnchorY})`)
.attr("text-anchor", "end").attr("font-size", 16).attr("font-weight", "bold")
.attr("fill", "#333").text("Temperatur");
const lineEls = cmpLines.map(ln => {
const x0 = cmpXDomain[0], x1 = cmpXDomain[1];
const y0 = ln.a + ln.b * x0, y1 = ln.a + ln.b * x1;
const sel = svg.append("line")
.attr("x1", cmpXScale(x0)).attr("y1", cmpYScale(y0))
.attr("x2", cmpXScale(x1)).attr("y2", cmpYScale(y1))
.attr("stroke", ln.color).attr("stroke-width", 2.5);
if (ln.dash) sel.attr("stroke-dasharray", ln.dash);
return { ...ln, x1p: cmpXScale(x0), y1p: cmpYScale(y0), x2p: cmpXScale(x1), y2p: cmpYScale(y1) };
});
const dotsG = svg.append("g");
dotsG.selectAll("circle")
.data(hhData)
.join("circle")
.attr("cx", d => cmpXScale(d.income))
.attr("cy", d => cmpYScale(d.temp))
.attr("r", 4)
.attr("fill", "#6b7280")
.attr("fill-opacity", 0.8);
const tipG = svg.append("g").attr("display", "none");
const tipRect = tipG.append("rect")
.attr("rx", 6).attr("fill", "white").attr("fill-opacity", 0.95).attr("stroke-width", 1.5);
const tipText = tipG.append("text").attr("font-size", 13);
function showTip(mx, my, ln) {
const label = `Ŷ = ${cmpFmt(ln.a)} + ${cmpFmt(ln.b)} × X`;
tipText.text(label).attr("fill", ln.color);
const boxW = label.length * 7.6 + 18;
const boxH = 26;
let bx = mx + 12, by = my - boxH - 8;
if (bx + boxW > cmpWidth - 2) bx = mx - boxW - 12;
if (by < 2) by = my + 12;
tipRect.attr("x", bx).attr("y", by).attr("width", boxW).attr("height", boxH).attr("stroke", ln.color);
tipText.attr("x", bx + 9).attr("y", by + 17);
tipG.attr("display", null);
}
function hideTip() {
tipG.attr("display", "none");
}
// Priority order = cmpLines order: where two lines' hit areas
// overlap (near a crossing), the first match in the array wins.
// Wide enough for a fingertip on a touchscreen (a mouse cursor
// doesn't need nearly this much slack, but a 12px radius was almost
// impossible to land a tap on).
const hitRadius = 24;
function findLine(mx, my) {
for (const ln of lineEls) {
if (distToSegment(mx, my, ln.x1p, ln.y1p, ln.x2p, ln.y2p) <= hitRadius) return ln;
}
return null;
}
const overlay = svg.append("rect")
.attr("x", cmpMarginLeft).attr("y", cmpMarginTop)
.attr("width", cmpWidth - cmpMarginLeft - cmpMarginRight)
.attr("height", cmpHeight - cmpMarginTop - cmpMarginBottom)
.attr("fill", "transparent");
overlay.on("pointermove", (event) => {
if (event.pointerType === "touch") return;
const [mx, my] = d3.pointer(event);
const ln = findLine(mx, my);
if (ln) showTip(mx, my, ln); else hideTip();
});
overlay.on("pointerleave", () => hideTip());
overlay.on("click", (event) => {
const [mx, my] = d3.pointer(event);
const ln = findLine(mx, my);
if (ln) showTip(mx, my, ln); else hideTip();
});
return svg.node();
}\[\hat{e}_i = Y_i - \hat{Y_i}\]
// A raw <input type="range"> + our own <span> readout, built by hand
// instead of via Inputs.range: this slide needs *two* independent
// copies of each slider (one in the Graf tab, one in the Data tab, see
// residSliderSync below) kept in sync with each other, which is far
// simpler to reason about with plain DOM elements we fully control
// than by fighting Inputs' own internal markup for a second instance.
function makeSlider(labelText, min, max, step, initial) {
const wrap = document.createElement("div");
wrap.className = "resid-slider";
// Tells reveal.js's touch handler not to treat a drag that starts on
// this slider as a slide-swipe -- without it, dragging the handle on
// a touchscreen changes slides instead of moving the slider.
wrap.setAttribute("data-prevent-swipe", "");
const label = document.createElement("span");
label.className = "resid-slider-label";
label.textContent = labelText;
const input = document.createElement("input");
input.type = "range";
input.min = min; input.max = max; input.step = step; input.value = initial;
const out = document.createElement("span");
out.className = "resid-slider-value";
out.textContent = cmpFmt(initial);
wrap.append(input, out, label);
return { wrap, input, out };
}// Each pair is built once (no dependency on residA/residB), so
// dragging never tears down and rebuilds the input mid-gesture.
residSliderPairGraf = {
const a = makeSlider("Intercept (a)", 5, 30, 0.01, 16.43);
a.input.addEventListener("input", () => { mutable residA = Number(a.input.value); });
const b = makeSlider("Lutningskoefficient (b)", -0.2, 0.5, 0.01, 0.13);
b.input.addEventListener("input", () => { mutable residB = Number(b.input.value); });
return { a, b };
}residSliderPairData = {
const a = makeSlider("Intercept (a)", 5, 30, 0.01, 16.43);
a.input.addEventListener("input", () => { mutable residA = Number(a.input.value); });
const b = makeSlider("Lutningskoefficient (b)", -0.2, 0.5, 0.01, 0.13);
b.input.addEventListener("input", () => { mutable residB = Number(b.input.value); });
return { a, b };
}// Keeps *both* slider pairs' displayed position/text in sync with
// residA/residB, regardless of which pair (or the OLS button) caused
// the change -- this is what makes "what you change in one tab's
// sliders carries over to the other tab" actually true, since tabby
// only hides the inactive tab's DOM rather than destroying it (so an
// untouched copy would otherwise sit stale at its original value).
// Setting .value directly (not recreating the element) is safe to do
// even on the slider currently being dragged.
residSliderSync = {
for (const pair of [residSliderPairGraf, residSliderPairData]) {
if (Number(pair.a.input.value) !== residA) pair.a.input.value = residA;
pair.a.out.textContent = cmpFmt(residA);
if (Number(pair.b.input.value) !== residB) pair.b.input.value = residB;
pair.b.out.textContent = cmpFmt(residB);
}
return null;
}// Reuses hhData/hhFmt/hhXScale/hhYScale/hhXTicks/hhYTicks/hhWidth/
// hhHeight/hhMargin*/hhXDomain/hhYDomain/hhXLabelStep/hhYLabelStep from
// the "Utforska data" slide -- same axes, grid, and ticks by
// construction, not just by copying numbers. Rebuilt whenever residA,
// residB, or residSelected changes: the first two are cheap, desired
// full redraws (that's the whole point -- the line should visibly move
// as the sliders move), same reasoning as the "Andra möjliga linjer"
// slide's static lines. Hover (mouse) still avoids the mutable/reactive
// path -- see the "Utforska data" slide's own hhSvg for why: replacing
// the hovered circle mid-hover breaks the browser's own pointerleave
// event.
residSvg = {
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${hhWidth} ${hhHeight}`)
.attr("width", hhWidth)
.attr("height", hhHeight)
.attr("class", "resid-svg")
.style("touch-action", "none");
const xAxisY = hhYScale(hhYDomain[0]);
const yAxisX = hhXScale(hhXDomain[0]);
const gridG = svg.append("g");
for (const gx of hhXTicks) {
gridG.append("line")
.attr("x1", hhXScale(gx)).attr("x2", hhXScale(gx))
.attr("y1", hhMarginTop).attr("y2", xAxisY)
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
for (const gy of hhYTicks) {
gridG.append("line")
.attr("x1", yAxisX).attr("x2", hhWidth - hhMarginRight)
.attr("y1", hhYScale(gy)).attr("y2", hhYScale(gy))
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
svg.append("line")
.attr("x1", yAxisX).attr("x2", hhWidth - hhMarginRight)
.attr("y1", xAxisY).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
svg.append("line")
.attr("x1", yAxisX).attr("x2", yAxisX)
.attr("y1", hhMarginTop).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
const tickG = svg.append("g");
for (const gx of hhXTicks) {
tickG.append("line")
.attr("x1", hhXScale(gx)).attr("x2", hhXScale(gx))
.attr("y1", xAxisY).attr("y2", xAxisY + 5)
.attr("stroke", "#333").attr("stroke-width", 1);
if (Math.round(gx) % hhXLabelStep === 0) {
tickG.append("text")
.attr("x", hhXScale(gx)).attr("y", xAxisY + 19)
.attr("text-anchor", "middle").attr("font-size", 14)
.attr("fill", "#555").text(String(gx));
}
}
for (const gy of hhYTicks) {
tickG.append("line")
.attr("x1", yAxisX - 5).attr("x2", yAxisX)
.attr("y1", hhYScale(gy)).attr("y2", hhYScale(gy))
.attr("stroke", "#333").attr("stroke-width", 1);
if (Math.round(gy) % hhYLabelStep === 0) {
tickG.append("text")
.attr("x", yAxisX - 10).attr("y", hhYScale(gy) + 5)
.attr("text-anchor", "end").attr("font-size", 14)
.attr("fill", "#555").text(String(gy));
}
}
svg.append("text")
.attr("x", hhWidth - hhMarginRight - 4)
.attr("y", xAxisY - 6)
.attr("text-anchor", "end").attr("font-size", 16).attr("font-weight", "bold")
.attr("fill", "#333").text("Inkomst");
const yLabelAnchorY = hhMarginTop + 10;
svg.append("text")
.attr("x", yAxisX + 18).attr("y", yLabelAnchorY)
.attr("transform", `rotate(-90, ${yAxisX + 18}, ${yLabelAnchorY})`)
.attr("text-anchor", "end").attr("font-size", 16).attr("font-weight", "bold")
.attr("fill", "#333").text("Temperatur");
function predY(x) { return residA + residB * x; }
const rx0 = hhXDomain[0], rx1 = hhXDomain[1];
svg.append("line")
.attr("x1", hhXScale(rx0)).attr("y1", hhYScale(predY(rx0)))
.attr("x2", hhXScale(rx1)).attr("y2", hhYScale(predY(rx1)))
.attr("stroke", "#2563eb").attr("stroke-width", 2.5);
const dotsG = svg.append("g");
const residG = svg.append("g");
const infoG = svg.append("g");
function renderSelection(id) {
residG.selectAll("*").remove();
infoG.selectAll("*").remove();
const d = hhData.find(h => h.id === id);
if (!d) return;
const px = hhXScale(d.income);
const pyActual = hhYScale(d.temp);
const predicted = predY(d.income);
const pyPred = hhYScale(predicted);
const eHat = d.temp - predicted;
// Dashed vertical line between the observation and the *current*
// regression line -- this is what a residual looks like.
residG.append("line")
.attr("x1", px).attr("y1", pyActual)
.attr("x2", px).attr("y2", pyPred)
.attr("stroke", "#dc2626").attr("stroke-width", 1.5).attr("stroke-dasharray", "4,3");
const boxW = 128, boxH = 74;
let bx = px + 10, by = Math.min(pyActual, pyPred) - boxH - 8;
if (bx + boxW > hhWidth - 2) bx = px - boxW - 10;
if (by < 2) by = Math.max(pyActual, pyPred) + 10;
infoG.append("rect")
.attr("x", bx).attr("y", by).attr("width", boxW).attr("height", boxH)
.attr("rx", 6).attr("fill", "white").attr("fill-opacity", 0.95)
.attr("stroke", "#999").attr("stroke-width", 1);
const lines = [
`Hushåll: ${d.id}`,
`Temperatur: ${hhFmt(d.temp)}`,
`Inkomst: ${hhFmt(d.income)}`,
`ê: ${cmpFmt(eHat)}`
];
lines.forEach((line, i) => {
infoG.append("text")
.attr("x", bx + 8).attr("y", by + 18 + i * 16)
.attr("font-size", 12).attr("fill", "#333").text(line);
});
}
const circleSel = dotsG.selectAll("circle")
.data(hhData)
.join("circle")
.attr("class", "resid-dot")
.attr("cx", d => hhXScale(d.income))
.attr("cy", d => hhYScale(d.temp))
.attr("fill-opacity", 0.85);
function setDotStyle(id) {
circleSel
.attr("r", d => d.id === id ? 6 : 4)
.attr("fill", d => d.id === id ? "#dc2626" : "#2563eb");
}
setDotStyle(residSelected);
renderSelection(residSelected);
// Invisible, much larger hit-circles sit on top of the tiny visible
// dots and carry all the pointer/click handlers -- a 4-6px visible
// dot is far below the ~44px touch target Apple/Google recommend, so
// tapping it accurately on a phone was close to impossible.
const hitSel = dotsG.selectAll("circle.resid-hit")
.data(hhData)
.join("circle")
.attr("class", "resid-hit")
.attr("cx", d => hhXScale(d.income))
.attr("cy", d => hhYScale(d.temp))
.attr("r", 15)
.attr("fill", "transparent")
.style("cursor", "pointer");
hitSel
.on("pointerenter", (event, d) => {
if (event.pointerType === "touch") return;
setDotStyle(d.id);
renderSelection(d.id);
})
.on("pointerleave", (event) => {
if (event.pointerType === "touch") return;
setDotStyle(residSelected);
renderSelection(residSelected);
})
.on("click", (event, d) => {
mutable residSelected = d.id;
});
return svg.node();
}// Same sliders, same position (top of the tab, above the table) as the
// Graf tab -- kept in sync by residSliderSync above -- so switching
// tabs doesn't visually jump, and a change made here is still reflected
// back in the Graf tab's line/info box when you switch back.
html`<div class="resid-sliders-row">${residSliderPairData.a.wrap}${residSliderPairData.b.wrap}</div>`// Same button, same effect, as the Graf tab -- it only ever sets
// residA/residB, and residSliderSync above is what actually moves
// both slider pairs' displayed position, so a second Inputs.button
// instance here needs no extra wiring.
html`<div class="sd-buttons-wrap"><div class="sd-buttons">${
Inputs.button("Minstakvadratmetodens regressionslinje", {
value: null,
reduce: () => {
mutable residA = 16.43;
mutable residB = 0.13;
return null;
}
})
}</div></div>`// Recomputes ê and ê² for all 15 rows from the *current* slider
// values, plus a footer row totalling ê² -- i.e. exactly residSSE,
// reused rather than recomputed -- so this table always reflects
// whichever line is shown in the Graf tab.
residTable = {
const wrap = d3.create("div").attr("class", "resid-table-wrap");
const table = wrap.append("table").attr("class", "resid-data-table");
table.append("thead").append("tr")
.selectAll("th")
.data(["Hushålls-ID", "Inomhustemperatur", "Månadsinkomst (i tusentals SEK)", "ŷ", "ê", "ê²"])
.join("th")
.text(d => d);
const tbody = table.append("tbody");
hhData.forEach(d => {
const pred = residA + residB * d.income;
const eHat = d.temp - pred;
const tr = tbody.append("tr").attr("class", "resid-row");
tr.append("td").text(d.id);
tr.append("td").text(hhFmt(d.temp));
tr.append("td").text(hhFmt(d.income));
tr.append("td").text(cmpFmt(pred));
tr.append("td").text(cmpFmt(eHat));
tr.append("td").text(cmpFmt(eHat * eHat));
});
const tfoot = table.append("tfoot").append("tr");
tfoot.append("td")
.attr("colspan", 5)
.attr("class", "resid-sum-label")
.text("Summan av kvadrerade avvikelser, Σê² =");
tfoot.append("td").attr("class", "resid-sum-value").text(cmpFmt(residSSE));
return wrap.node();
}\[R^2 = 1 - \frac{RSS}{TSS} = 1 - \frac{\sum_{i = 1}^n(Y_i-\hat{Y_i})^2}{\sum_{i = 1}^n(Y_i-\bar{Y_i})^2}\]
\[\textit{Root-MSE} = \sqrt{\frac{RSS}{n-k}} = \sqrt{\frac{\sum_{i = 1}^n(Y_i-\hat{Y_i})^2}{n-k}}\]
\[b \pm t \times se(b)\]
\[0{,}13 \pm 1{,}96 \times 0{,}065 = 0{,}13 \pm 0{,}1274\]
\[0{,}13 \pm 2{,}58 \times 0{,}065 = 0{,}13 \pm 0{,}1677\]
| (1) | |
|---|---|
| Konstant | 16,43 |
| (1,858) | |
| Inkomst | 0,13 |
| (0,065) | |
| Observationer | 15 |
| \(R^2\) | 0,173 |
| (1) | (2) | |
|---|---|---|
| Konstant | 16,43 | … |
| (1,858) | (…) | |
| Inkomst | 0,13 | |
| (0,065) | ||
| Utbildningsår | … | |
| (…) | ||
| Observationer | 15 | … |
| \(R^2\) | 0,173 | … |
1. Linjäritet i parametrarna
2. Slumpmässigt urval
3. Nollvillkorat medelvärde (exogenitet)
4. Homoskedasticitet