วิทยาลัยนานาชาตินวัตกรรมดิจิทัล มหาวิทยาลัยเชียงใหม่
14 พฤศจิกายน 2568
items = [
{key: "bread", label: "Bread", emoji: "🥖"},
{key: "rice", label: "Rice", emoji: "🍚"},
{key: "milk", label: "Milk", emoji: "🥛"},
{key: "beer", label: "Beer", emoji: "🍺"},
{key: "eggs", label: "Eggs", emoji: "🥚"},
{key: "cheese", label: "Cheese", emoji: "🧀"},
{key: "coffee", label: "Coffee", emoji: "☕"}
]
itemMap = new Map(items.map(d => [d.key, d]))// Effect: 1 click = 1 action (no re-declare)
{
// Add basket
if (addCartBtn > lastAddClick) {
mutable carts = [...carts, []];
mutable lastAddClick = addCartBtn;
}
// Clear all
if (typeof clearAllBtn !== "undefined" && clearAllBtn > lastClearClick) {
mutable carts = [[], [], []];
mutable lastClearClick = clearAllBtn;
}
// Add sample baskets
if (typeof sampleBtn !== "undefined" && sampleBtn > lastSampleClick) {
mutable carts = [
["bread","milk"],
["bread","beer","milk"],
["rice","milk"],
["bread","rice"],
["beer","milk"],
["bread","beer"],
["bread","rice","milk"],
["rice","beer"],
["bread"],
["milk"]
];
mutable lastSampleClick = sampleBtn;
}
}palette = {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.gap = "12px";
wrap.style.flexWrap = "wrap";
for (const it of items) {
const b = document.createElement("div");
b.textContent = it.emoji;
b.draggable = true;
b.title = it.label;
b.style.fontSize = "36px";
b.style.cursor = "grab";
b.addEventListener("dragstart", e => {
e.dataTransfer.setData("text/plain", it.key);
});
wrap.appendChild(b);
}
return wrap;
}viewof baskets = {
const container = document.createElement("div");
container.style.display = "grid";
container.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))";
container.style.gap = "14px";
function render() {
container.innerHTML = "";
carts.forEach((basket, idx) => {
const card = document.createElement("div");
card.style.border = "1px solid #ccc";
card.style.borderRadius = "14px";
card.style.padding = "10px";
card.style.background = "#fff";
const title = document.createElement("div");
title.innerHTML = `<strong>🧺 Basket #${idx+1}</strong>`;
title.style.marginBottom = "8px";
card.appendChild(title);
const zone = document.createElement("div");
zone.style.border = "2px dashed #bbb";
zone.style.minHeight = "80px";
zone.style.padding = "8px";
zone.style.borderRadius = "10px";
zone.style.transition = "0.15s ease";
// DnD highlight
zone.addEventListener("dragenter", () => {
zone.style.borderColor = "#4f46e5";
zone.style.background = "#eef2ff";
});
zone.addEventListener("dragleave", () => {
zone.style.borderColor = "#bbb";
zone.style.background = "";
});
zone.addEventListener("dragover", e => e.preventDefault());
zone.addEventListener("drop", e => {
e.preventDefault();
const key = e.dataTransfer.getData("text/plain");
if (!itemMap.has(key)) return;
const copy = carts.map(d => d.slice());
if (!copy[idx].includes(key)) copy[idx].push(key); // prevent duplicates in the same basket
mutable carts = copy;
zone.style.borderColor = "#bbb";
zone.style.background = "";
render();
});
// items display
for (const key of basket) {
const chip = document.createElement("span");
chip.textContent = itemMap.get(key).emoji;
chip.style.fontSize = "28px";
chip.style.padding = "2px 6px";
chip.style.cursor = "pointer";
chip.title = `Click to remove ${itemMap.get(key).label}`;
chip.addEventListener("click", () => {
const copy = carts.map(d => d.slice());
const id = copy[idx].indexOf(key);
if (id>=0) copy[idx].splice(id,1);
mutable carts = copy;
render();
});
zone.appendChild(chip);
}
card.appendChild(zone);
container.appendChild(card);
});
}
render();
return container;
}function unique(arr){ return Array.from(new Set(arr)); }
function combos(arr, k){
const res=[]; const n=arr.length;
function rec(start, path){
if (path.length===k){ res.push(path.slice()); return; }
for (let i=start;i<n;i++) rec(i+1, path.concat(arr[i]));
}
rec(0,[]); return res;
}
function apriori(transactions, minsup=0.2, minconf=0.5){
const N = transactions.length;
if (!N) return [];
const supCount = new Map();
const allItems = unique(transactions.flat());
function support(itemset){
const key = itemset.slice().sort().join("|");
if (supCount.has(key)) return supCount.get(key)/N;
let c=0; outer: for (const t of transactions){
for (const it of itemset) if (!t.includes(it)) continue outer;
c++;
}
supCount.set(key, c);
return c/N;
}
// frequent 1-itemsets
let L = allItems.map(i=>[i]).filter(s=>support(s) >= minsup);
const Lall = [...L];
// frequent k-itemsets
for (let k=2; L.length>0; k++){
const cand = combos(unique(L.flat()), k)
.filter(s => combos(s, k-1).every(sub => support(sub) >= minsup));
L = cand.filter(s => support(s) >= minsup);
Lall.push(...L);
}
// association rules from all non-empty proper subsets
const rules = [];
for (const S of Lall){
if (S.length < 2) continue;
const supS = support(S);
for (let i=1; i<S.length; i++){
for (const A of combos(S, i)){
const B = S.filter(x => !A.includes(x));
const supA = support(A);
const supB = support(B);
const conf = supS / (supA || 1e-12);
const lift = conf / (supB || 1e-12);
if (conf >= minconf){
rules.push({
A: A.slice().sort(),
B: B.slice().sort(),
sup: supS,
conf: conf,
lift: lift
});
}
}
}
}
return rules;
}// Format results and sort by lift
rulesResFormatted = rulesRes
.map(r => ({
rule: `${r.A.map(k => itemMap.get(k).emoji).join(" ")} → ${r.B.map(k => itemMap.get(k).emoji).join(" ")}`,
A: r.A.join(", "),
B: r.B.join(", "),
support: r.sup.toFixed(2),
confidence: r.conf.toFixed(2),
lift: r.lift.toFixed(2)
}))
.sort((a, b) => +b.lift - +a.lift)// Download transactions as CSV
downloadTx = {
const csv = d3.csvFormat(txRows);
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = html`<a href=${url} download="transactions.csv">Download transactions.csv</a>`;
invalidation.then(() => URL.revokeObjectURL(url));
return a;
}1. เบียร์🍺 และ ผ้าอ้อม👶
การค้นพบ: จากข้อมูลการซื้อสินค้าของซูเปอร์มาร์เก็ต พบว่า
“ลูกค้าที่ซื้อผ้าอ้อมมักจะซื้อเบียร์ด้วยเช่นกัน”
เหตุผล: ลูกค้ากลุ่มนี้มักเป็นคุณพ่อที่ซื้อผ้าอ้อมให้ลูก และอาจซื้อเบียร์กลับไปดื่มเพื่อผ่อนคลายที่บ้าน
การประยุกต์ใช้:
2. ขนมปัง🍞 และ เนย🧈
การค้นพบ: ลูกค้าที่ซื้อ ขนมปัง🍞 มักจะซื้อ เนย🧈 ด้วยเช่นกัน
เหตุผล: สินค้าทั้งสองมักถูกบริโภคร่วมกัน โดยเฉพาะในมื้อเช้าหรือของว่าง
การประยุกต์ใช้:
3. นม🥛 และ ซีเรียล🌾
การค้นพบ: ลูกค้าที่ซื้อ นม🥛 มักจะซื้อ ซีเรียล🌾 ด้วยเช่นกัน
เหตุผล: สินค้าทั้งสองมักถูกบริโภคร่วมกันเป็นอาหารเช้า
การประยุกต์ใช้:
4. กาแฟ☕ และ น้ำตาล🍬
การค้นพบ: ลูกค้าที่ซื้อ กาแฟ☕ มักจะซื้อ น้ำตาล🍬 หรือ ครีมเทียม🧋 ด้วยเช่นกัน
เหตุผล: สินค้าเหล่านี้มักถูกใช้ร่วมกันในการชงกาแฟ☕
การประยุกต์ใช้:
จัดโปรโมชั่นชุดรวม เช่น กาแฟ☕ + น้ำตาล🍬 + ครีมเทียม🧋
วางสินค้ากลุ่มนี้ไว้ใกล้กันเพื่อความสะดวกของลูกค้า
5. มันฝรั่งทอด🍟 และ น้ำอัดลม🥤
การค้นพบ: ลูกค้าที่ซื้อ มันฝรั่งทอด🍟 มักจะซื้อ น้ำอัดลม🥤 ด้วยเช่นกัน
เหตุผล: สินค้าเหล่านี้มักถูกบริโภคร่วมกันในระหว่างดูหนังหรืองานเลี้ยงสังสรรค์
การประยุกต์ใช้:
“ซื้อมันฝรั่งทอด🍟 2 ถุง แถมน้ำอัดลม🥤 1 กระป๋อง”
6. แปรงสีฟัน🪥 และ ยาสีฟัน🧴
การค้นพบ: ลูกค้าที่ซื้อ แปรงสีฟัน🪥 มักจะซื้อ ยาสีฟัน🧴 ด้วยเช่นกัน
เหตุผล: เป็นของใช้ประจำวันจำเป็นที่มักถูกใช้คู่กันเสมอ
การประยุกต์ใช้:
“ซื้อแปรงสีฟัน🪥 แถมยาสีฟัน🧴ฟรี”
ตัวอย่าง:
ใช้ตรวจจับพฤติกรรมการทำธุรกรรมที่ผิดปกติเพื่อป้องกันการฉ้อโกง
ตัวอย่าง:
วิเคราะห์ความสัมพันธ์ระหว่างอาการและโรค เพื่อช่วยสนับสนุนการวินิจฉัยทางการแพทย์
ตัวอย่าง:
วิเคราะห์รูปแบบการสั่งซื้อสินค้าเพื่อวางแผนการจัดเก็บสินค้าอย่างมีประสิทธิภาพ
ตัวอย่าง:
แนะนำเนื้อหาที่เกี่ยวข้องในเว็บไซต์ข่าว บล็อก หรือแพลตฟอร์มสื่อ
ตัวอย่าง:
วิเคราะห์พฤติกรรมการเรียนรู้ของผู้เรียนเพื่อแนะนำเนื้อหาที่เหมาะสม
ตัวอย่าง:
วิเคราะห์ความสัมพันธ์ของเนื้อหาเพื่อระบุผู้มีอิทธิพลในตลาด
ตัวอย่าง:
\[ X \Rightarrow Y \] โดยที่:
ความหมาย: หากมีการซื้อสินค้า \(X\) ก็มีแนวโน้มว่าจะมีการซื้อสินค้า \(Y\) ด้วยเช่นกัน
ตัวอย่าง: หากลูกค้าซื้อขนมปัง ก็มีแนวโน้มว่าจะซื้อเนยด้วย
สูตร:
\[ \begin{aligned} Support(X) &= \frac{\text{จำนวนธุรกรรมที่มี } X}{\text{จำนวนธุรกรรมทั้งหมด}} \\ Support(X \Rightarrow Y) &= \frac{\text{จำนวนธุรกรรมที่มีทั้ง } X \text{ และ } Y}{\text{จำนวนธุรกรรมทั้งหมด}} \end{aligned} \]
สูตร:
\[ Confidence(X \Rightarrow Y) = \frac{\text{จำนวนธุรกรรมที่มีทั้ง } X \text{ และ } Y}{\text{จำนวนธุรกรรมที่มี } X} \]
\[ Confidence(Y \Rightarrow X) = \frac{\text{จำนวนธุรกรรมที่มีทั้ง } X \text{ และ } Y}{\text{จำนวนธุรกรรมที่มี } Y} \]
สูตร:
\[ \begin{aligned} Lift(X \Rightarrow Y) &= \frac{Support(X \Rightarrow Y)}{Support(X) \times Support(Y)} \\ &= \frac{Confidence(X \Rightarrow Y)}{Support(Y)} \end{aligned} \]
\(Lift > 1\) → ความสัมพันธ์เชิงบวก ระหว่าง X และ Y
\(Lift = 1\) → ไม่มีความสัมพันธ์
\(Lift < 1\) → ความสัมพันธ์เชิงลบ ระหว่าง X และ Y
หากกฎความสัมพันธ์คือ {เบเกิล} \(\Rightarrow\) {ครีมชีส} และค่า Lift = 2 นั่นหมายความว่า หากลูกค้าซื้อเบเกิล โอกาสที่จะซื้อครีมชีสจะสูงกว่าปกติถึงสองเท่า
จากจำนวนธุรกรรมทั้งหมด 100 รายการ:
ค่า Support (S): \[ Support(Bread \Rightarrow Butter) = \frac{20}{100} = 0.2 \]
ค่า Confidence (C): \[ Confidence(Bread \Rightarrow Butter) = \frac{20}{40} = 0.5 \]
ค่า Lift (L): \[\begin{aligned} &Lift(Bread \Rightarrow Butter) \\ &= \dfrac{Support(Bread \Rightarrow Butter)}{Support(Bread)\times Support(Butter)}\\ &= \dfrac{0.20}{0.4\times 0.5}=\frac{0.20}{0.20} = 1 \end{aligned}\]
ดังนั้น เนื่องจาก Lift = 1 การซื้อขนมปัง ไม่ได้เพิ่มโอกาส ในการซื้อเนยแต่อย่างใด
ตัวอย่างข้อมูลธุรกรรมจำนวน 10 รายการที่ใช้สร้างตารางใน Excel และคำนวณค่า Support, Confidence, และ Lift โดยใช้ฟังก์ชัน SUMIF() ใน Excel
| Transaction ID | Item 1 | Item 2 | Item 3 |
|---|---|---|---|
| 1 | Bread | Butter | Milk |
| 2 | Bread | Jam | |
| 3 | Bread | Butter | |
| 4 | Bread | Milk | |
| 5 | Butter | Jam | |
| 6 | Bread | Butter | Jam |
| 7 | Milk | Butter | |
| 8 | Bread | Milk | Butter |
| 9 | Bread | Jam | Butter |
| 10 | Milk |
| Transaction ID | Bread | Butter | Milk | Jam |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 0 |
| 2 | 1 | 0 | 0 | 1 |
| 3 | 1 | 1 | 0 | 0 |
| 4 | 1 | 0 | 1 | 0 |
| 5 | 0 | 1 | 0 | 1 |
| 6 | 1 | 1 | 0 | 1 |
| 7 | 0 | 1 | 1 | 0 |
| 8 | 1 | 1 | 1 | 0 |
| 9 | 1 | 1 | 0 | 1 |
| 10 | 0 | 0 | 1 | 0 |
SUMIF ใน Excel เพื่อคำนวณ Support, Confidence และ Lift2. การคำนวณค่า Confidence
ใช้ชุดข้อมูลนี้เพื่อตอบคำถามต่อไปนี้: Google Drive
ค่าของ Support สำหรับสินค้าแต่ละชนิดคือเท่าไร?
คู่สินค้าคู่ใดมีค่า Confidence สูงที่สุด?
มีบางคู่สินค้าที่มีค่า Lift < 1
Milk และ Bread อาจปรากฏร่วมกันบ่อย เพราะทั้งสองเป็นสินค้าที่นิยมซื้อ ไม่ใช่เพราะการซื้อนมทำให้ต้องซื้อขนมปัง| ค่า Lift | ความหมาย | แนวทางการตัดสินใจ |
|---|---|---|
| > 1 | ความสัมพันธ์เชิงบวก | ใช้สำหรับการขายพ่วง (Cross-selling), จัดชุดสินค้า (Bundling), และการจัดวางสินค้า (Placement) |
| ≈ 1 | ไม่มีความสัมพันธ์ | ไม่เหมาะสำหรับใช้ในการตัดสินใจทางการตลาดโดยตรง |
| < 1 | ความสัมพันธ์เชิงลบ | หลีกเลี่ยงการขายพ่วง และปรับกลยุทธ์ทางการตลาดใหม่ |
(async () => {
// ===== Create an isolated Shadow DOM (no CSS bleed) =====
const host = html`<div style="display:block;"></div>`;
const root = host.attachShadow({mode: "open"});
// ============= Root markup inside Shadow =============
const box = document.createElement("div");
box.innerHTML = `
<style>
/* All styles are scoped to this shadow root only */
:host{all:initial}
.wrap{max-width:1220px;font:14px system-ui, -apple-system, Segoe UI, Roboto, sans-serif;color:#0b5aa2}
.grid-ctrl{display:grid;grid-template-columns:repeat(3,minmax(260px,1fr));gap:12px;margin-bottom:12px}
.grid-editor{display:grid;grid-template-columns:1fr 2fr;gap:14px}
.panel{padding:10px;border:1px solid #bcd0e5;border-radius:10px;background:#fff}
.panel.soft{background:#f7fbff}
.headerline{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
.title{font-weight:700}
.subtle{font-size:12px;color:#2a6ebb}
.muted{color:#666}
/* Cerulean-ish buttons (scoped) */
.btn{padding:6px 12px;border:1px solid #bcd0e5;background:#e9f2fb;color:#0b5aa2;border-radius:8px;cursor:pointer;transition:all .12s ease}
.btn:hover{background:#dcecff}
.btn-primary{background:#2fa4e7;color:#fff;border-color:#1992d4}
.btn-primary:hover{background:#1992d4}
.btn-warning{background:#fff3cd;color:#8a5a00;border-color:#ffe19a}
.btn-warning:hover{background:#ffe8a6}
.btn-danger{background:#fde2e1;color:#8c1e1e;border-color:#f5b5b3}
.btn-danger:hover{background:#ffc9c7}
.chip{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border:1px solid #cfe2ff;border-radius:9999px;background:#fff;user-select:none;box-shadow:0 1px 0 rgba(0,0,0,.03)}
.chip .x{cursor:pointer;color:#999}
.basket{min-height:44px;padding:8px;border:2px dashed #cbe1f7;border-radius:10px;background:#fbfdff}
.basket.dragover{background:#eef6ff}
.row{display:flex;gap:8px;align-items:flex-start}
.rowHead{width:120px;text-align:right;margin-top:8px;color:#2a6ebb}
.badge{display:inline-block;padding:4px 10px;border-radius:9999px;background:#e9f2fb;color:#0b5aa2;border:1px solid #cfe2ff;font-size:12px}
table.tbl{width:100%;border-collapse:collapse;font-size:13px}
table.tbl th, table.tbl td{border-bottom:1px solid #eee;padding:6px 8px}
table.tbl th{border-bottom:1px solid #bcd0e5;background:#f7fbff;cursor:pointer;user-select:none}
table.tbl th.nosort{cursor:default}
.tdc{text-align:center}
.tdr{text-align:right}
.toast{position:fixed;right:18px;bottom:18px;background:#2fa4e7;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px;z-index:999999}
.th-ind::after{content:""; margin-left:6px; border:4px solid transparent; display:inline-block; transform:translateY(-1px)}
.th-ind.asc::after{border-bottom-color:#2a6ebb}
.th-ind.desc::after{border-top-color:#2a6ebb}
.math{
font:13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background:#f7fbff; border:1px solid #cfe2ff; color:#0b5aa2;
border-radius:6px; padding:2px 6px; display:inline-block;
margin:2px 6px 0 0; white-space:nowrap;
}
</style>
<div class="wrap">
<div id="ctrl" class="grid-ctrl"></div>
<div class="grid-editor">
<div class="panel soft">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<b>Product Palette</b>
<span class="subtle">Drag an emoji into a basket (≤4 items per basket)</span>
</div>
<div id="palette" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
</div>
<div class="panel">
<div class="headerline">
<b class="title">Baskets</b>
<button id="btnAdd" class="btn">Add Random Basket</button>
<button id="btnClearAll" class="btn btn-danger">🧹 Clear All</button>
<button id="btnRecomp" class="btn">Recompute</button>
<button id="btnCopy" class="btn btn-primary">Copy One-Hot CSV</button>
</div>
<div id="baskets"></div>
</div>
</div>
<div id="out" style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px;">
<div id="allk" class="panel"></div>
<div id="rules" class="panel"></div>
</div>
<div id="tables" style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px;">
<div id="txTable" class="panel"></div>
<div id="onehotTbl" class="panel" style="overflow:auto;"></div>
</div>
</div>
`;
root.appendChild(box);
// ====== Grab refs from Shadow ======
const ctrl = root.getElementById("ctrl");
const paletteDiv = root.getElementById("palette");
const basketsDiv = root.getElementById("baskets");
const btnAdd = root.getElementById("btnAdd");
const btnClearAll= root.getElementById("btnClearAll");
const btnRecomp = root.getElementById("btnRecomp");
const btnCopy = root.getElementById("btnCopy");
const allkEl = root.getElementById("allk");
const rulesEl = root.getElementById("rules");
const txTableEl = root.getElementById("txTable");
const onehotEl = root.getElementById("onehotTbl");
// ========= Controls (mount Observable Inputs inside Shadow) =========
const nInp = Inputs.range([2, 20], {label:"Number of products (n)", value:10, step:1});
const minSupInp = Inputs.range([0.00, 0.60], {label:"Min Support", value:0.10, step:0.01});
const minConfInp= Inputs.range([0.00, 1.00], {label:"Min Confidence", value:0.50, step:0.05});
const kMaxInp = Inputs.range([2, 4], {label:"Max itemset size (k_max)", value:4, step:1});
const titleBox = html`<div class="panel" style="padding:8px 10px;">
<div><b>How to:</b> Drag an emoji into a basket or click “Add Random Basket”. Each basket can contain at most 4 distinct items. Click × on a chip to remove it.</div>
</div>`;
ctrl.append(nInp, minSupInp, minConfInp, kMaxInp, titleBox);
// ================= Helpers =================
const EMO_ALL = [
"🥛","🍞","🍎","🧀","🥚","🥤","🍫","🧻","🧴","🍪",
"🍺","🍜","🍗","🧂","☕️","🍌","🥦","🍊","🥔","🍚",
"🍇","🍖","🧈","🍯","🧃","🥨","🍦","🍟","🍔","🍣",
"🍤","🥫","🍷","🥟","🍍","🥕","🧅","🧄","🍅","🥬"
];
const pct = x => `${(x*100).toFixed(1)}%`;
function rngSeed(s){ return function(){ let t=s+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296 } }
function pickK(arr, k, rng){
const idx = arr.map((_,i)=>i);
for (let i=idx.length-1;i>0;i--){ const j=Math.floor((rng?rng():Math.random())*(i+1)); [idx[i],idx[j]]=[idx[j],idx[i]]; }
return idx.slice(0,Math.min(k,idx.length)).map(i=>arr[i]);
}
function showToast(msg){
const t = document.createElement("div");
t.className = "toast";
t.textContent = msg;
root.appendChild(t);
setTimeout(()=>{ t.remove(); }, 1600);
}
function makeSortable(table){
if (!table) return;
const thead = table.querySelector("thead");
if (!thead) return;
const ths = [...thead.querySelectorAll("th")].filter(th=>!th.classList.contains("nosort"));
ths.forEach((th, idx)=>{
th.classList.add("th-ind");
th.dataset.order = "desc";
th.addEventListener("click", ()=>{
const type = th.dataset.type || "text";
const tbody = table.querySelector("tbody");
const rows = [...tbody.querySelectorAll("tr")];
const current = th.dataset.order === "asc" ? "desc" : "asc";
[...thead.querySelectorAll("th")].forEach(x=>x.classList.remove("asc","desc"));
th.classList.add(current);
th.dataset.order = current;
const dir = current === "asc" ? 1 : -1;
rows.sort((a,b)=>{
const A = a.children[idx].innerText.trim();
const B = b.children[idx].innerText.trim();
if ((th.dataset.type||"") === "num"){
const aNum = parseFloat(A.replace(/[%]/g,""));
const bNum = parseFloat(B.replace(/[%]/g,""));
return (aNum - bNum) * dir;
} else {
return A.localeCompare(B) * dir;
}
});
rows.forEach(r=>tbody.appendChild(r));
});
});
}
function combos(arr, k){
const res = [];
function dfs(start, path){
if (path.length === k){ res.push(path.slice()); return; }
for (let i=start;i<arr.length;i++) dfs(i+1, path.concat(arr[i]));
}
dfs(0, []);
return res;
}
// ================= State =================
let products = EMO_ALL.slice(0, nInp.value);
let txns = []; // [["🥛","🍞"], ["🍎"], ...]
let countsAll = {}; // k -> Map("a,b"->count)
const MAX_PER_BASKET = 4;
// ================= UI: Palette =================
function renderPalette(){
products = EMO_ALL.slice(0, nInp.value);
paletteDiv.innerHTML = "";
for (const it of products){
const chip = document.createElement("div");
chip.className = "chip";
chip.draggable = true;
chip.textContent = it;
chip.title = "Drag into a Basket";
chip.dataset.item = it;
chip.addEventListener("dragstart", e=>{
e.dataTransfer.setData("text/plain", it);
});
paletteDiv.appendChild(chip);
}
}
// ================= UI: Baskets =================
function renderBaskets(){
basketsDiv.innerHTML = "";
if (txns.length === 0) addRandomBasket();
txns.forEach((items, idx)=>{
const row = document.createElement("div");
row.className = "row";
const head = document.createElement("div");
head.className = "rowHead";
head.innerHTML = `🧺 <span class="badge">Basket ${idx+1}</span><br><span class="muted" style="font-size:12px;">(${items.length} item${items.length!==1?"s":""})</span>`;
const drop = document.createElement("div");
drop.className = "basket";
drop.dataset.index = idx;
// DnD handlers
drop.addEventListener("dragover", e=>{ e.preventDefault(); drop.classList.add("dragover"); });
drop.addEventListener("dragleave", ()=> drop.classList.remove("dragover"));
drop.addEventListener("drop", e=>{
e.preventDefault();
drop.classList.remove("dragover");
const it = e.dataTransfer.getData("text/plain");
if (!products.includes(it)) return;
if (txns[idx].includes(it)) return; // unique per basket
if (txns[idx].length >= MAX_PER_BASKET){ showToast("This shop allows up to 4 items per basket."); return; }
txns[idx].push(it);
renderBaskets();
});
// chips
const chipWrap = document.createElement("div");
chipWrap.style.display = "flex";
chipWrap.style.gap = "8px";
chipWrap.style.flexWrap = "wrap";
chipWrap.style.direction = "ltr";
for (const it of items){
const c = document.createElement("div");
c.className = "chip"; c.draggable = false;
c.innerHTML = `${it} <span class="x" title="remove">×</span>`;
c.querySelector(".x").addEventListener("click", ()=>{
txns[idx] = txns[idx].filter(z=>z!==it);
renderBaskets();
});
chipWrap.appendChild(c);
}
// actions
const actions = document.createElement("div");
actions.style.display = "flex"; actions.style.gap = "8px"; actions.style.alignItems = "center"; actions.style.marginLeft = "8px";
const btnDel = document.createElement("button"); btnDel.className = "btn btn-danger"; btnDel.textContent = "🗑️ Delete";
btnDel.title = "Remove this basket";
btnDel.addEventListener("click", ()=>{ txns.splice(idx,1); renderBaskets(); recompute(); });
const btnClearRow = document.createElement("button"); btnClearRow.className = "btn btn-warning"; btnClearRow.textContent = "🧹 Clear";
btnClearRow.title = "Clear items in this basket";
btnClearRow.addEventListener("click", ()=>{ txns[idx] = []; renderBaskets(); recompute(); });
actions.appendChild(btnDel); actions.appendChild(btnClearRow);
drop.appendChild(chipWrap);
row.appendChild(head); row.appendChild(drop); row.appendChild(actions);
basketsDiv.appendChild(row);
});
}
function addRandomBasket(){
const maxPick = Math.min(MAX_PER_BASKET, products.length);
const size = Math.max(1, Math.floor(Math.random()*maxPick) + 1); // 1..maxPick
const picked = pickK(products, size, null).sort((a,b)=> products.indexOf(a)-products.indexOf(b));
txns.push(picked);
}
// ====== Init with 5 random baskets (≤4 items each) ======
function seedFiveRandom(){
txns = [];
const rng = rngSeed(2025);
for (let b=0;b<5;b++){
const maxPick = Math.min(MAX_PER_BASKET, products.length);
const size = Math.max(1, Math.floor(rng()*maxPick) + 1); // 1..maxPick
const picked = pickK(products, size, rng).sort((a,b)=> products.indexOf(a)-products.indexOf(b));
txns.push(picked);
}
}
// ================= Counting / Frequent / Rules =================
function countAllItemsets(kmax){
countsAll = {};
for (let k=1;k<=kmax;k++) countsAll[k] = new Map();
const N = txns.length;
const MAX_UPDATES = 200000;
let updates = 0;
for (const t of txns){
const set = Array.from(new Set(t)).sort((a,b)=> products.indexOf(a)-products.indexOf(b));
for (let k=1;k<=kmax;k++){
if (set.length < k) break;
const cs = combos(set, k);
const m = countsAll[k];
for (const c of cs){
const key = c.join(",");
m.set(key, (m.get(key)||0)+1);
if (++updates > MAX_UPDATES){
showToast("Counting truncated (too many combinations). Raise min support or lower k_max.");
return {N, countsAll};
}
}
}
}
return {N, countsAll};
}
function buildFrequentAll(minSup, kmax){
const N = txns.length;
const all = [];
for (let k=1;k<=kmax;k++){
const m = countsAll[k];
const arr = [...m.entries()].map(([kstr,c])=>({
k, items: kstr.split(","), count:c, support: c / N
}));
const filtered = arr.filter(r=> r.support >= minSup).sort((a,b)=> b.support - a.support);
all.push({k, rows: filtered});
}
return {N, all};
}
function allAssociationRules(minSup, minConf, kmax){
const N = txns.length;
const rules = [];
const supportOf = (key)=>{
const len = key ? key.split(",").length : 0;
if (len===0) return 0;
const m = countsAll[len];
return (m && m.get(key) ? m.get(key)/N : 0);
};
for (let k=2;k<=kmax;k++){
const m = countsAll[k];
if (!m) continue;
for (const [key, cnt] of m.entries()){
const S = key.split(",");
const suppS = cnt / N;
if (suppS < minSup) continue;
for (let aSize = 1; aSize <= S.length-1; aSize++){
const As = combos(S, aSize);
for (const A of As){
const B = S.filter(x => !A.includes(x));
const Akey = A.join(","), Bkey = B.join(",");
const suppA = supportOf(Akey);
const suppB = supportOf(Bkey);
if (suppA <= 0 || suppB <= 0) continue;
const conf = suppS / suppA;
if (conf < minConf) continue;
const lift = conf / suppB;
rules.push({ rule: `${A.join(" ")} → ${B.join(" ")}`, support: suppS, confidence: conf, lift });
}
}
}
}
rules.sort((x,y)=> (y.lift - x.lift) || (y.confidence - x.confidence) || (y.support - x.support));
return rules.slice(0, 200);
}
// ================= One-Hot & CSV =================
function oneHotMatrix(){
const cols = products.slice();
const rows = txns.map(t => {
const set = new Set(t);
return cols.map(c => set.has(c) ? 1 : 0);
});
return {cols, rows};
}
function copyOneHotCSV(){
const {cols, rows} = oneHotMatrix();
const header = ["Basket", ...cols];
const lines = [header.join(",")];
for (let i=0;i<rows.length;i++){
const line = [ `#${i+1}`, ...rows[i] ].join(",");
lines.push(line);
}
const csv = lines.join("\n");
// Clipboard in Shadow still uses global navigator
if (navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(csv).then(()=> showToast("Copied One-Hot CSV to clipboard"));
} else {
const ta = document.createElement("textarea"); ta.value = csv; root.appendChild(ta);
ta.select(); document.execCommand("copy"); ta.remove();
showToast("Copied One-Hot CSV to clipboard");
}
}
// ================= Rendering =================
function renderTxTable(){
const rows = txns.map((t,i)=>({id:i+1, size:t.length, items:t.join(" ")}));
txTableEl.innerHTML = `
<b>Transaction Table</b>
<div class="muted" style="font-size:12px;margin-top:2px;">Each row is a basket; “Size” = number of items in that basket (max 4).</div>
<table class="tbl" style="margin-top:6px;">
<thead><tr>
<th class="tdr nosort">Basket</th>
<th class="tdr nosort">Size</th>
<th class="nosort">Items</th>
</tr></thead>
<tbody>
${rows.map(r=>`
<tr><td class="tdr">#${r.id}</td><td class="tdr">${r.size}</td><td>${r.items || '<span class="muted">—</span>'}</td></tr>
`).join("")}
</tbody>
</table>
<div class="muted" style="margin-top:8px;font-size:12px;">
<b>Formula:</b> <span class="math">basket_size = count(distinct items in basket)</span>
</div>
`;
}
function renderOneHotTable(){
const {cols, rows} = oneHotMatrix();
const header = cols.map(c=>`<th class="tdc nosort" title="product">${c}</th>`).join("");
const body = rows.map((r,i)=>`<tr><td class="tdr">#${i+1}</td>${r.map(x=>`<td class="tdc">${x}</td>`).join("")}</tr>`).join("");
onehotEl.innerHTML = `
<b>One-Hot Encoding (0/1)</b>
<div class="muted" style="font-size:12px;margin-top:2px;">Rows = baskets (max 4 items), Columns = products (emoji)</div>
<div style="overflow:auto;margin-top:6px;">
<table class="tbl" style="min-width:${Math.max(400, 60*(cols.length+1))}px">
<thead><tr><th class="tdr nosort">Basket</th>${header}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>
<div class="muted" style="margin-top:8px;font-size:12px;">
<b>Formula:</b>
<span class="math">x<sub>ij</sub> = 1</span> if product <span class="math">j</span> is in basket <span class="math">i</span>;
otherwise <span class="math">x<sub>ij</sub> = 0</span>.
</div>
`;
}
function renderAllKPanel(N, allGroups){
let sections = "";
for (const group of allGroups){
const k = group.k;
const rows = group.rows;
const tableId = `tbl-k-${k}`;
sections += `
<div style="margin-top:${k===1? '6px':'12px'};font-weight:600;">${k}-itemsets</div>
<table class="tbl" id="${tableId}" style="margin-top:4px;">
<thead><tr>
<th data-type="text">${k===1?'Item':'Items'}</th>
<th class="tdr" data-type="num">Support</th>
<th class="tdr" data-type="num">Count</th>
</tr></thead>
<tbody>
${
rows.length
? rows.map(r=>`
<tr>
<td>${r.items.join(" ")}</td>
<td class="tdr">${pct(r.support)}</td>
<td class="tdr">${r.count}</td>
</tr>`).join("")
: `<tr><td colspan="3"><span class="muted">No ${k}-itemsets meet the threshold.</span></td></tr>`
}
</tbody>
</table>
`;
}
allkEl.innerHTML = `
<b>All k-itemsets</b>
<div class="muted" style="font-size:12px;">
Total Transactions (N) = <b>${N}</b> • Min support = <b>${pct(minSupInp.value)}</b> • k from 1 to <b>${Math.min(kMaxInp.value, 4)}</b>
</div>
${sections}
<div class="muted" style="margin-top:8px;font-size:12px;">
<b>Formula:</b>
<span class="math">Support = count(itemset) / N</span>
<span class="math">Count = # supporting transactions</span>
</div>
`;
// <<— ทำหลังจาก set innerHTML แล้วค่อยผูกการ sort แต่ละ table
for (const group of allGroups){
const tbl = allkEl.querySelector(`#tbl-k-${group.k}`);
makeSortable(tbl);
}
}
function renderRules(N, rules){
rulesEl.innerHTML = `
<b>Association Rules (all sizes)</b>
<div class="muted" style="font-size:12px;">
Filters: min support = <b>${pct(minSupInp.value)}</b>,
min confidence = <b>${pct(minConfInp.value)}</b>;
Total Transactions (N) = <b>${N}</b>
</div>
<table class="tbl" id="tbl-rules" style="margin-top:6px;">
<thead><tr>
<th data-type="text">Rule</th>
<th class="tdr" data-type="num">Support</th>
<th class="tdr" data-type="num">Confidence</th>
<th class="tdr" data-type="num">Lift</th>
</tr></thead>
<tbody>
${rules.map(r=>`
<tr>
<td>${r.rule}</td>
<td class="tdr">${pct(r.support)}</td>
<td class="tdr">${pct(r.confidence)}</td>
<td class="tdr">${r.lift.toFixed(2)}</td>
</tr>
`).join("") || `<tr><td colspan="4"><span class="muted">No rules pass the thresholds.</span></td></tr>`}
</tbody>
</table>
<div class="muted" style="margin-top:8px;font-size:12px;">
<b>Formulas:</b>
<span class="math">support(A→B) = support(A∪B)</span>
<span class="math">confidence(A→B) = support(A∪B) / support(A)</span>
<span class="math">lift(A→B) = confidence(A→B) / support(B)</span>
</div>
`;
makeSortable(rulesEl.querySelector("#tbl-rules"));
}
// ================= Orchestration =================
function recompute(){
const kmax = Math.min(kMaxInp.value, 4, products.length);
countAllItemsets(kmax);
const {N, all} = buildFrequentAll(minSupInp.value, kmax);
renderAllKPanel(N, all);
const rules = allAssociationRules(minSupInp.value, minConfInp.value, kmax);
renderRules(N, rules);
renderTxTable();
renderOneHotTable();
}
// ================= Events =================
function resetAll(){
products = EMO_ALL.slice(0, nInp.value);
seedFiveRandom();
renderPalette();
renderBaskets();
recompute();
}
btnAdd.addEventListener("click", ()=>{ addRandomBasket(); renderBaskets(); recompute(); });
btnClearAll.addEventListener("click", ()=>{ txns = []; renderBaskets(); recompute(); });
btnRecomp.addEventListener("click", recompute);
btnCopy.addEventListener("click", copyOneHotCSV);
nInp.addEventListener("input", resetAll);
minSupInp.addEventListener("input", recompute);
minConfInp.addEventListener("input", recompute);
kMaxInp.addEventListener("input", recompute);
// ===== init =====
renderPalette();
seedFiveRandom();
renderBaskets();
recompute();
// Mount the isolated app
return host;
})();You need to install the Associate Add-on to use this feature.
(async () => {
// ----- Plot (fallback) -----
let Plot;
try { Plot = await require("@observablehq/plot@0.6.17"); }
catch { const m = await import("https://esm.sh/@observablehq/plot@0.6?bundle"); Plot = m.default || m; }
// ----- Shell -----
const box = html`<div style="max-width:1100px;margin:0 auto;font:14px system-ui;">
<style>
.layout{display:grid;grid-template-columns:380px 1fr;gap:16px;align-items:start}
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:12px}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.label{font-weight:700}
.hint{font-size:12px;color:#6b7280}
select,input[type=number],input[type=range]{padding:6px 8px;border:1px solid #d1d5db;border-radius:8px}
.btn{border:1px solid #d1d5db;background:#f9fafb;border-radius:8px;padding:6px 10px;cursor:pointer}
.btn:active{transform:translateY(1px)}
.mono{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}
table.tbl{width:100%;border-collapse:collapse}
table.tbl th,table.tbl td{border-bottom:1px solid #eee;padding:6px 8px;text-align:left}
table.tbl th{border-bottom:1px solid #ddd}
.emopt{font-size:16px}
@media(max-width:980px){.layout{grid-template-columns:1fr}}
.math { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
</style>
<div class="layout">
<!-- LEFT: Controls -->
<div class="card">
<div class="label">Choose items (Emoji)</div>
<div class="row">
<div><div class="hint">Item A</div><select id="selA" class="emopt"></select></div>
<div><div class="hint">Item B</div><select id="selB" class="emopt"></select></div>
<div><div class="hint">Item C</div><select id="selC" class="emopt"></select></div>
</div>
<div class="row" style="margin-top:6px">
<div>
<div class="hint">Preset (realistic association)</div>
<select id="preset" class="emopt">
<option value="cafe">☕️ ⇒ 🥐 (Cafe set)</option>
<option value="burger">🍔 ⇒ 🍟 (Burger set)</option>
<option value="pizza">🍕 ⇒ 🥤 (Pizza set)</option>
<option value="breakfast">🥛 ⇒ 🥣 (Breakfast set)</option>
<option value="sushi">🍣 ⇒ 🧂 (Sushi + soy sauce)</option>
<option value="none" selected>— Manual —</option>
</select>
</div>
<button id="applyPreset" class="btn">Apply preset</button>
</div>
<div class="label" style="margin-top:10px">Transactions (N)</div>
<div class="row">
<input id="Nnum" type="number" min="50" max="20000" step="50" value="1000"/>
<input id="Nrng" type="range" min="50" max="20000" step="50" value="1000" style="width:55%"/>
</div>
<div class="label" style="margin-top:10px">Base probabilities</div>
<div class="row">
<span class="hint" style="width:74px">P(A)</span><input id="pA" type="number" min="0" max="1" step="0.01" value="0.40"/>
<span class="hint" style="width:74px">P(B)</span><input id="pB" type="number" min="0" max="1" step="0.01" value="0.35"/>
<span class="hint" style="width:74px">P(C)</span><input id="pC" type="number" min="0" max="1" step="0.01" value="0.25"/>
</div>
<div class="label" style="margin-top:10px">Pattern injection (A ⇒ B)</div>
<div class="row">
<span class="hint" style="width:120px">P(B | A)</span>
<input id="pBgivenA" type="number" min="0" max="1" step="0.01" value="0.70"/>
</div>
<label class="row" style="gap:8px;margin-top:6px">
<input id="keepMarg" type="checkbox" checked/>
<span class="hint">Keep marginal P(B) ≈ base (solve P(B|¬A))</span>
</label>
<div class="row" style="margin-top:10px">
<button id="build" class="btn">Generate</button>
<button id="copyTX" class="btn">Copy CSV (transactions)</button>
<button id="copyOH" class="btn">Copy CSV (one-hot)</button>
<span id="status" class="hint"></span>
</div>
</div>
<!-- RIGHT: Results -->
<div class="card">
<div class="label">Metrics for rule <span id="ruleName" class="mono"></span></div>
<div id="metrics" class="mono" style="margin-top:6px; white-space:pre;"></div>
<div id="explain" class="hint" style="margin-top:6px"></div>
<div class="row" style="margin-top:10px"><span class="label">Supports</span></div>
<div id="plot"></div>
<div class="row" style="margin-top:10px"><span class="label">Sample transactions</span></div>
<div id="table"></div>
<div class="row" style="margin-top:12px"><span class="label">Definitions</span></div>
<div class="math" style="white-space:pre; font-size:13px; line-height:1.5;">
Supp(A) = P(A) = #{ t : A ∈ t } / N
Supp(A ⇒ B) = Supp(A ∩ B) = P(A ∩ B)
Con(A ⇒ B) = Confidence(A ⇒ B) = P(B | A) = Supp(A ∩ B) / Supp(A)
Lift(A ⇒ B) = P(B | A) / P(B) = Supp(A ∩ B) / (Supp(A) · Supp(B))
</div>
</div>
</div>
</div>`;
// ----- Emoji options -----
const EMOJI = [
"☕️ coffee","🥐 croissant","🧁 cupcake","🍰 cake","🍩 donut","🍪 cookie",
"🍔 burger","🍟 fries","🌭 hotdog","🍕 pizza","🥤 soda","🍗 chicken",
"🍣 sushi","🧂 soy-sauce","🍚 rice","🥗 salad","🌮 taco","🌯 burrito",
"🥛 milk","🥣 cereal","🍞 bread","🧀 cheese","🍎 apple","🍌 banana","🍇 grapes",
"🍺 beer","🍷 wine","🥃 whisky","🧻 tissue","🧴 shampoo"
];
function fillEmojiSelect(sel){
sel.innerHTML = "";
EMOJI.forEach(s=>{
const o = document.createElement("option");
o.textContent = s.split(" ")[0] + " " + s.split(" ").slice(1).join(" ");
o.value = s.split(" ")[0]; // pure emoji
sel.append(o);
});
}
// ----- Refs -----
const selA = box.querySelector("#selA");
const selB = box.querySelector("#selB");
const selC = box.querySelector("#selC");
const preset = box.querySelector("#preset");
const applyPreset = box.querySelector("#applyPreset");
const Nnum = box.querySelector("#Nnum");
const Nrng = box.querySelector("#Nrng");
const pA = box.querySelector("#pA");
const pB = box.querySelector("#pB");
const pC = box.querySelector("#pC");
const pBgivenA = box.querySelector("#pBgivenA");
const keepMarg = box.querySelector("#keepMarg");
const buildBtn = box.querySelector("#build");
const copyTX = box.querySelector("#copyTX");
const copyOH = box.querySelector("#copyOH");
const status = box.querySelector("#status");
const ruleName = box.querySelector("#ruleName");
const metrics = box.querySelector("#metrics");
const explain = box.querySelector("#explain");
const plotWrap = box.querySelector("#plot");
const tableWrap= box.querySelector("#table");
fillEmojiSelect(selA); fillEmojiSelect(selB); fillEmojiSelect(selC);
selA.value = "☕️"; selB.value = "🥐"; selC.value = "🧁";
// Preset mapping
const PRESETS = {
cafe: {A:"☕️", B:"🥐", C:"🧁", pA:.45, pB:.35, pC:.20, pBgivenA:.75},
burger: {A:"🍔", B:"🍟", C:"🥤", pA:.35, pB:.30, pC:.40, pBgivenA:.70},
pizza: {A:"🍕", B:"🥤", C:"🍺", pA:.28, pB:.50, pC:.22, pBgivenA:.68},
breakfast: {A:"🥛", B:"🥣", C:"🍌", pA:.30, pB:.25, pC:.22, pBgivenA:.65},
sushi: {A:"🍣", B:"🧂", C:"🍺", pA:.22, pB:.18, pC:.30, pBgivenA:.80}
};
function applyPresetValues(){
const key = preset.value;
if (key==="none") return;
const s = PRESETS[key];
selA.value = s.A; selB.value = s.B; selC.value = s.C;
pA.value = s.pA; pB.value = s.pB; pC.value = s.pC;
pBgivenA.value = s.pBgivenA;
rebuild();
}
applyPreset.addEventListener("click", applyPresetValues);
// sync N slider
Nnum.addEventListener("input", ()=>{ Nrng.value = Nnum.value; });
Nrng.addEventListener("input", ()=>{ Nnum.value = Nrng.value; });
// show rule label
function updateRuleLabel(){ ruleName.textContent = `${selA.value} ⇒ ${selB.value}`; }
[selA, selB].forEach(el=> el.addEventListener("change", updateRuleLabel));
updateRuleLabel();
// RNG + helpers
const bern = (p)=> Math.random() < p;
const clamp01 = (x)=> Math.max(0, Math.min(1, x));
// fallback picker to avoid empty transactions
function pickOneFallback(pA0, pB0, pC0){
const s = pA0 + pB0 + pC0 || 1;
const r = Math.random();
const a = pA0 / s, b = pB0 / s;
if (r < a) return [true,false,false];
if (r < a + b) return [false,true,false];
return [false,false,true];
}
// Generate N transactions with A⇒B, NO empty rows
function generate(N, A, B, C, pA0, pB0, pC0, qBA, keepMargB){
// solve P(B|¬A) to preserve marginal P(B): pB0 = qBA*pA0 + qBnotA*(1-pA0)
let qBnotA = pB0;
if (keepMargB && pA0 < 1 - 1e-12){
qBnotA = clamp01((pB0 - qBA*pA0)/(1 - pA0));
}
const rows = [];
let nA=0, nB=0, nAB=0;
for (let i=0;i<N;i++){
let hasA = bern(pA0);
let hasB = hasA ? bern(qBA) : bern(keepMargB ? qBnotA : pB0);
let hasC = bern(pC0);
// enforce at least one item
if (!hasA && !hasB && !hasC){
const [fa, fb, fc] = pickOneFallback(pA0, pB0, pC0);
hasA = fa; hasB = fb; hasC = fc;
}
const items = [];
if (hasA) items.push(A);
if (hasB) items.push(B);
if (hasC) items.push(C);
rows.push(items);
if (hasA) nA++;
if (hasB) nB++;
if (hasA && hasB) nAB++;
}
return {rows, counts:{nA,nB,nAB,N}, qBnotA};
}
function calcMetrics({nA,nB,nAB,N}){
const sA = nA/N, sB = nB/N, sAB = nAB/N;
const conf = nA? sAB/sA : 0; // P(B|A)
const lift = sB? conf/sB : 0; // P(B|A)/P(B)
const indep = sA*sB; // expected if independent
return {sA,sB,sAB,conf,lift,indep};
}
function fmt(x,d=3){ return (isFinite(x)? x.toFixed(d) : "—"); }
function renderPlot(m, A, B){
plotWrap.innerHTML = "";
const data = [
{k:`support(${A})`, v:m.sA},
{k:`support(${B})`, v:m.sB},
{k:`support(${A} ∩ ${B})`, v:m.sAB},
{k:`expected under independence`, v:m.indep}
];
const fig = Plot.plot({
width: plotWrap.clientWidth || 650,
height: 240,
marginLeft: 200,
x: {label:"value", domain:[0,1]},
y: {domain:data.map(d=>d.k)},
marks: [
Plot.barX(data, {x:"v", y:"k", fill:"#60a5fa"}),
Plot.text(data, {x:"v", y:"k", text:d=>fmt(d.v,3), dx:6, textAnchor:"start"})
]
});
plotWrap.append(fig);
}
function renderTable(rows){
tableWrap.innerHTML = "";
const tbl = html`<table class="tbl">
<thead><tr><th>#</th><th>items (emoji)</th></tr></thead>
<tbody></tbody>
</table>`;
const tb = tbl.querySelector("tbody");
const show = Math.min(20, rows.length);
for (let i=0;i<show;i++){
tb.insertAdjacentHTML("beforeend",
`<tr><td class="mono">${i+1}</td><td class="mono">${rows[i].join(" ")}</td></tr>`);
}
if (rows.length>show){
tb.insertAdjacentHTML("beforeend", `<tr><td colspan="2" class="hint">… ${rows.length-show} more transactions</td></tr>`);
}
tableWrap.append(tbl);
}
// CSV makers
function toCSVTransactions(rows){
// comma-separated emoji items per transaction, no empties by construction
return rows.map(r => r.join(",")).join("\n");
}
function toCSVOneHot(rows, items){ // items: [A,B,C]
const header = items.join(",");
const body = rows.map(r => items.map(it => r.includes(it) ? 1 : 0).join(",")).join("\n");
return `${header}\n${body}`;
}
// main
let lastRows = [];
function rebuild(){
const A = selA.value, B = selB.value, C = selC.value;
const N = +Nnum.value || 1000;
const pa = clamp01(+pA.value||0), pb = clamp01(+pB.value||0), pc = clamp01(+pC.value||0);
const q = clamp01(+pBgivenA.value||0);
const keep = !!keepMarg.checked;
const sim = generate(N, A,B,C, pa,pb,pc, q, keep);
lastRows = sim.rows;
const m = calcMetrics(sim.counts);
ruleName.textContent = `${A} ⇒ ${B}`;
metrics.textContent =
`N = ${sim.counts.N}
support(${A}) = ${fmt(m.sA)}
support(${B}) = ${fmt(m.sB)}
support(${A}∩${B}) = ${fmt(m.sAB)}
confidence(${A}⇒${B}) = ${fmt(m.conf)}
lift(${A}⇒${B}) = ${fmt(m.lift)}
P(${B}|¬${A}) used = ${fmt(sim.qBnotA,3)} (keep marginal: ${keep ? "yes" : "no"})`;
explain.innerHTML = `
<b>Confidence</b> = P(${B}|${A}) = support(${A}∩${B}) / support(${A}) → “Given a customer buys ${A}, how likely they also buy ${B}.”<br>
<b>Lift</b> = P(${B}|${A}) / P(${B}) → >1 positive association, ≈1 independent, <1 negative.<br>
Use high Lift to justify cross-promotions of ${B} with ${A}.
`;
renderPlot(m, A, B);
renderTable(lastRows);
status.textContent = "Generated";
setTimeout(()=> status.textContent="", 800);
}
// events
buildBtn.addEventListener("click", rebuild);
[selA,selB,selC,Nnum,Nrng,pA,pB,pC,pBgivenA,keepMarg].forEach(el=>{
el.addEventListener("input", ()=>{ if (el===Nrng) Nnum.value = Nrng.value; });
});
// Copy actions (no download button)
copyTX.addEventListener("click", async ()=>{
if (!lastRows.length) return;
const csv = toCSVTransactions(lastRows);
try { await navigator.clipboard.writeText(csv); copyTX.textContent = "Copied!"; setTimeout(()=>copyTX.textContent="Copy CSV (transactions)",900); }
catch { alert(csv); }
});
copyOH.addEventListener("click", async ()=>{
if (!lastRows.length) return;
const A = selA.value, B = selB.value, C = selC.value;
const csv = toCSVOneHot(lastRows, [A,B,C]);
try { await navigator.clipboard.writeText(csv); copyOH.textContent = "Copied!"; setTimeout(()=>copyOH.textContent="Copy CSV (one-hot)",900); }
catch { alert(csv); }
});
// first build (preset cafe)
const presetSelect = box.querySelector("#preset");
presetSelect.value = "cafe";
applyPresetValues(); // this will call rebuild()
return box;
})()Agrawal, R., & Srikant, R. (1994). Fast algorithms for mining association rules. Proceedings of the 20th International Conference on Very Large Data Bases (VLDB), 487–499.
Han, J., Kamber, M., & Pei, J. (2011). Data mining: Concepts and techniques (3rd ed.). Morgan Kaufmann.
Tan, P.-N., Steinbach, M., & Kumar, V. (2018). Introduction to data mining (2nd ed.). Pearson.