วิทยาลัยนานาชาตินวัตกรรมดิจิทัล มหาวิทยาลัยเชียงใหม่
13 พฤศจิกายน 2568
การตัดสินใจที่แม่นยำและทันท่วงที (Accurate and Timely Decision-Making)
การสร้างภาพข้อมูลช่วยให้ผู้บริหารเข้าใจแนวโน้มของตลาด รายได้ กำไร และพฤติกรรมของลูกค้าได้อย่างรวดเร็ว
. . .
การติดตามประสิทธิภาพ (Performance Monitoring)
แดชบอร์ดสามารถใช้แสดงยอดขาย ต้นทุน อัตรากำไร และปัจจัยทางธุรกิจที่สำคัญอื่น ๆ ได้
. . .
การวิเคราะห์ลูกค้า (Customer Analysis)
ใช้วิเคราะห์ข้อมูลประชากร พฤติกรรม และการแบ่งกลุ่มลูกค้า เพื่อพัฒนากลยุทธ์ทางการตลาดที่มีประสิทธิภาพ
การพยากรณ์แนวโน้มทางเศรษฐกิจ (Economic Trend Forecasting)
กราฟเส้นและแผนที่ความหนาแน่น (Heat Maps) ช่วยให้เห็นภาพแนวโน้มเศรษฐกิจ เช่น อัตราเงินเฟ้อ การเติบโตของ GDP และอัตราการว่างงาน
. . .
การวิเคราะห์นโยบาย (Policy Analysis)
หน่วยงานภาครัฐและนักเศรษฐศาสตร์สามารถใช้การสร้างภาพข้อมูล เพื่อออกแบบนโยบายที่ตอบโจทย์ปัญหาเศรษฐกิจได้อย่างมีประสิทธิภาพ
. . .
การเปรียบเทียบระหว่างประเทศหรือภูมิภาค (Cross-Country/Regional Comparisons)
สามารถใช้แผนที่และกราฟแท่งในการเปรียบเทียบการเติบโตทางเศรษฐกิจระหว่างประเทศหรือภูมิภาคต่าง ๆ ได้
การติดตามแนวโน้มตลาดหุ้น (Stock Market Trend Monitoring)
กราฟแท่งและกราฟเส้นช่วยให้นักลงทุนเข้าใจพฤติกรรมของตลาดหุ้น ดัชนี และสินทรัพย์ประเภทต่าง ๆ ได้ชัดเจน
. . .
การบริหารความเสี่ยง (Risk Management)
ใช้ Box Plot หรือ Histogram เพื่อวิเคราะห์ข้อมูลที่เกี่ยวข้องกับความเสี่ยง
. . .
การวิเคราะห์พอร์ตการลงทุน (Portfolio Analysis)
แสดงภาพผลกำไร ขาดทุน และการกระจายสินทรัพย์ภายในพอร์ตการลงทุน
การติดตามโรคและระบาดวิทยา (Disease Monitoring and Epidemiology)
ใช้ Heatmap หรือ Bubble Chart เพื่อติดตามการแพร่ระบาดของโรคและแนวโน้มของจำนวนผู้ป่วย
. . .
การจัดการทรัพยากรทางการแพทย์ (Medical Resource Management)
วิเคราะห์จำนวนเตียงในโรงพยาบาล ปริมาณผู้ป่วย และอัตราการใช้ยาเพื่อการบริหารจัดการที่มีประสิทธิภาพ
. . .
การพัฒนาการรักษา (Treatment Development)
ใช้ Scatter Plot หรือ Violin Plot เพื่อวิเคราะห์ข้อมูลจากการทดลองทางคลินิกและงานวิจัย
การวิเคราะห์ผลสัมฤทธิ์ทางการเรียน (Analyzing Academic Achievement)
ใช้การสร้างภาพข้อมูลเพื่อติดตามและประเมินผลการเรียนของนักศึกษา
. . .
การพัฒนาระบบการสอนและการเรียนรู้ (Enhancing Teaching and Learning Systems)
วิเคราะห์พฤติกรรมของผู้เรียนผ่าน Learning Analytics Dashboards เพื่อปรับปรุงการเรียนการสอนให้มีประสิทธิภาพมากขึ้น
. . .
การเปรียบเทียบการศึกษาระดับโลก (Global Education Comparison)
ใช้แผนที่หรือกราฟแท่งเพื่อประเมินคุณภาพการศึกษาระหว่างประเทศต่าง ๆ
การวิเคราะห์ข้อมูลการทดลอง (Experimental Data Analysis)
ใช้ Box Plot หรือ Scatter Plot เพื่อสำรวจความสัมพันธ์ระหว่างตัวแปรต่าง ๆ
. . .
การสร้างภาพลวดลายข้อมูลที่ซับซ้อน (Visualizing Complex Data Patterns)
ใช้เทคนิค PCA (Principal Component Analysis) หรือ Heatmap เพื่อทำความเข้าใจข้อมูลที่มีมิติสูงได้อย่างมีประสิทธิภาพ
. . .
การสื่อสารผลการวิจัยอย่างมีประสิทธิภาพ (Communicating Research Findings Effectively)
กราฟและสื่อภาพช่วยให้นักวิทยาศาสตร์สามารถอธิบายผลการศึกษาของตนได้อย่างชัดเจนและเข้าใจง่าย
การสร้างภาพข้อมูลมีหลายรูปแบบ โดยแต่ละแบบเหมาะกับการวิเคราะห์ประเภทต่าง ๆ หมวดหมู่หลักของการสร้างภาพข้อมูล ที่ทุกคนควรรู้
ใช้เพื่อสังเกตแนวโน้มของข้อมูลหรือการเปลี่ยนแปลงตามช่วงเวลา
กราฟเส้น: แสดงแนวโน้มของข้อมูลตามเวลา เช่น ยอดขายรายเดือน หรือราคาหุ้น
กราฟพื้นที่: คล้ายกับกราฟเส้น แต่เน้นพื้นที่ใต้เส้นโค้งเพื่อแสดงปริมาณสะสม
ตัวอย่างการใช้งาน (Example Use Cases): การติดตามแนวโน้ม GDP, อัตราเงินเฟ้อ หรือจำนวนผู้ใช้บริการ
ใช้เพื่อแสดงรูปแบบและการกระจายของข้อมูล
ฮิสโตแกรม (Histogram): แสดงการกระจายของค่าตัวแปร เช่น รายได้ของประชากร
กราฟกล่อง (Box Plot หรือ Box-and-Whisker Plot): แสดงค่ามัธยฐาน ค่าต่ำสุด ค่าสูงสุด และค่าผิดปกติ (Outliers)
กราฟความหนาแน่น (Density Plot): แสดงการกระจายทางสถิติของข้อมูลอย่างต่อเนื่อง
ตัวอย่างการใช้งาน: การวิเคราะห์การใช้จ่ายของลูกค้า หรือคะแนนสอบของนักศึกษา
ตัวอย่าง: การเปรียบเทียบรายได้ระหว่างเพศชายและเพศหญิง
ใช้เพื่อเปรียบเทียบข้อมูลระหว่างหมวดหมู่ต่าง ๆ
กราฟแท่ง (Bar Chart): ใช้เปรียบเทียบค่าระหว่างหมวดหมู่ เช่น ยอดขายตามประเภทสินค้า
กราฟแท่งซ้อน (Stacked Bar Chart): ใช้แสดงส่วนประกอบของข้อมูลรวม เช่น ส่วนแบ่งทางการตลาดของแต่ละบริษัท
กราฟแท่งแนวนอน (Horizontal Bar Chart): เหมาะเมื่อชื่อหมวดหมู่ยาว หรือมีหลายรายการให้เปรียบเทียบ
ตัวอย่างการใช้งาน: การเปรียบเทียบรายได้ของบริษัท หรือจำนวนลูกค้าในแต่ละกลุ่ม
ใช้เพื่อวิเคราะห์ความสัมพันธ์ระหว่างตัวแปรสองตัวหรือมากกว่า
กราฟกระจาย (Scatter Plot): แสดงความสัมพันธ์ระหว่างตัวแปรสองตัว เช่น ราคาบ้านเทียบกับขนาดที่ดิน
กราฟฟองอากาศ (Bubble Plot): คล้ายกับกราฟกระจาย แต่ใช้ขนาดของจุดเพื่อแสดงค่าของตัวแปรที่สาม
ใช้ในการแสดงข้อมูลที่เกี่ยวข้องกับแผนที่หรือพิกัดทางภูมิศาสตร์
Heat Map (แผนที่ความหนาแน่น): แสดงความหนาแน่นหรือการกระจายของข้อมูลบนแผนที่ เช่น ความหนาแน่นของประชากร
Choropleth Map (แผนที่เชิงพื้นที่ตามค่า): แสดงค่าข้อมูลตามพื้นที่ เช่น รายได้เฉลี่ยต่อจังหวัด
Bubble Map (แผนที่ฟองอากาศ): ใช้ตำแหน่งและขนาดของฟองเพื่อแสดงค่าของข้อมูลในพื้นที่ต่าง ๆ
ตัวอย่างการใช้งาน: การวิเคราะห์การกระจายยอดขายตามภูมิภาค
ใช้เพื่อแสดงโครงสร้างแบบลำดับชั้นหรือความสัมพันธ์ในเครือข่าย
Tree Diagram (แผนภาพต้นไม้): แสดงโครงสร้างลำดับชั้น เช่น sơผังองค์กร (Organizational Chart)
Sunburst Chart (แผนภูมิวงกลมแบบซ้อน): เป็นเวอร์ชันแบบวงกลมของ Tree Diagram เหมาะสำหรับข้อมูลแบบซ้อนหลายชั้น
Network Graph (กราฟเครือข่าย): แสดงความสัมพันธ์และการเชื่อมโยงระหว่างหน่วยต่าง ๆ เช่น เครือข่ายสังคมออนไลน์
ตัวอย่างการใช้งาน: การวิเคราะห์โครงสร้างองค์กร หรือความสัมพันธ์ในเครือข่ายสังคม
ใช้เพื่อแสดงให้เห็นว่าส่วนต่าง ๆ รวมกันเป็นภาพรวมได้อย่างไร
กราฟวงกลม (Pie Chart): ใช้แสดงสัดส่วนของแต่ละหมวดหมู่เมื่อเทียบกับทั้งหมด
กราฟโดนัท (Donut Chart): คล้ายกับกราฟวงกลม แต่มีช่องว่างตรงกลางเพื่อเพิ่มความชัดเจนในการแสดงข้อมูล
Treemap (แผนผังต้นไม้แบบพื้นที่): ทางเลือกที่ดีกว่ากราฟวงกลมเมื่อต้องแสดงข้อมูลที่มีหลายหมวดหมู่
เลือกรูปแบบให้เหมาะสมกับวัตถุประสงค์ในการวิเคราะห์
การกระจายของข้อมูล (Distribution of Data) ➝ Histogram, Box Plot
การเปรียบเทียบข้อมูล (Data Comparison) ➝ Bar Chart
แนวโน้มและการเปลี่ยนแปลงตามเวลา (Trends and Changes Over Time) ➝ Line Chart
ความสัมพันธ์ระหว่างตัวแปร (Variable Relationships) ➝ Scatter Plot
การแสดงข้อมูลเชิงพื้นที่ (Geospatial Data Display) ➝ Heat Map, Choropleth
ฮิสโตแกรม (Histogram) เป็นกราฟแท่งชนิดหนึ่งที่ใช้แสดง การกระจายของข้อมูล (Distribution) แต่ละแท่งแสดง ความถี่ (Frequency) ของข้อมูลที่อยู่ภายในช่วง หรือ “bin” (ช่วงข้อมูล) ที่กำหนดไว้
กราฟนี้ช่วยให้สามารถมองเห็นลักษณะของการกระจายข้อมูล รวมถึงการมีอยู่ของกลุ่ม (Clusters) หรือช่องว่าง (Gaps) ในข้อมูลได้ง่ายขึ้น
แกน X (X-axis): แสดงช่วงของค่าข้อมูล (bins) ซึ่งแบ่งออกเป็นช่วงย่อย เช่น ช่วงอายุ คะแนนสอบ ขนาดเชิงตัวเลข หรือช่วงเวลา
แกน Y (Y-axis): แสดงความถี่ (Frequency) — จำนวนข้อมูลที่อยู่ภายในแต่ละช่วง (bin)
แท่งกราฟ (Bars): ความสูงของแต่ละแท่งแสดงจำนวนข้อมูลในช่วงนั้น ๆ แท่งที่สูงกว่าหมายถึงมีข้อมูลอยู่ในช่วงนั้นมากกว่า
ฮิสโตแกรมมักถูกใช้เพื่อวิเคราะห์การกระจายของข้อมูล เช่น:
ตรวจสอบการกระจายของคะแนนสอบ
วิเคราะห์การกระจายอายุของประชากร
สังเกตความถี่ของการเข้าชมของลูกค้าในช่วงเวลาต่าง ๆ
สำรวจข้อมูลเชิงตัวเลขในการวิเคราะห์ทางสถิติ
viewof n2 = Inputs.range([200, 1000], {step:50 , label: "n ="})
viewof mean1 = Inputs.range([15000, 30000], {step:500 , label: "mean1 ="})
viewof sd1 = Inputs.range([2000, 4000], {step:1 , label: "sd1 ="})
viewof mean2 = Inputs.range([17000, 35000], {step:500 , label: "mean2 ="})
viewof sd2 = Inputs.range([3000, 5000], {step:1 , label: "sd2 ="})
viewof merge2 = Inputs.radio(["Yes", "No"], {value: "No" , label: "แยกกลุ่ม"})รายได้ของกลุ่ม A และกลุ่ม B มีการกระจายแบบปกติ (Normally Distributed) ดังนี้:
Group A ~\(N(\mu_1, \sigma_1^2)\)or\(N(\)2)
Group B ~\(N(\mu_2, \sigma_2^2)\)or\(N(\)2) ตามลำดับ
มองเห็นการกระจายของข้อมูลได้ชัดเจน: ฮิสโตแกรมช่วยแสดงให้เห็นว่าข้อมูลกระจุกตัวหรือกระจายตัวมากน้อยเพียงใด
ตรวจจับค่าผิดปกติได้ง่าย (Detect Anomalies): ช่วยระบุค่าที่ผิดปกติหรือ outliers ในชุดข้อมูลได้อย่างรวดเร็ว
เปรียบเทียบข้อมูลได้สะดวก (Easy Comparison): สามารถเปรียบเทียบความถี่ของข้อมูลในแต่ละช่วง หรือระหว่างกลุ่มต่าง ๆ ได้อย่างง่ายดาย
การสร้างฮิสโตแกรมจากไฟล์ข้อมูล
คุณจะได้สร้างฮิสโตแกรมโดยใช้ไฟล์ Excel: histogram.xlsx
ในแต่ละคอลัมน์จะแทนข้อมูลจากการแจกแจงความน่าจะเป็น (Probability Distribution) แบบต่าง ๆ ดังนี้:
| ตัวแปร (Variable) | การแจกแจง (Distribution) | แหล่งอ้างอิง (Reference Link) |
|---|---|---|
x1 |
การแจกแจงปกติ (Normal distribution) | Wikipedia (TH) |
x2 |
การแจกแจงที (t-distribution) | Wikipedia |
x3 |
การแจกแจงเอฟ (F-distribution) | Wikipedia |
x4 |
การแจกแจงเบต้า (Beta distribution) | Wikipedia |
x5 |
การแจกแจงไคสแควร์ (Chi-squared distribution) | Wikipedia |
x6 |
การแจกแจงแกมมา (Gamma distribution) | Wikipedia |
เปิดไฟล์ histogram.xlsx
ไปที่แท็บ Insert (แทรก) → เลือก Histogram (ฮิสโตแกรม) จากส่วน Charts
เลือกข้อมูลของแต่ละตัวแปร (x1 ถึง x6)
คลิกขวาที่แกน X → เลือก Format Axis (จัดรูปแบบแกน) → ปรับค่า Bin width (ความกว้างของช่วงข้อมูล) ตามต้องการ
สามารถสร้างกราฟแยกสำหรับแต่ละการแจกแจงได้
เปิดโปรแกรม Jamovi
ไปที่เมนู Open → This PC → โหลดไฟล์ histogram.xlsx
ไปที่แท็บ Exploration (การสำรวจข้อมูล) → เลือก Descriptives (สถิติเชิงพรรณนา)
ลากตัวแปร x1 ถึง x6 ไปยังช่อง Variables
ในแถบด้านขวา:
เปิดใช้งาน Plots → Histogram
สามารถเปิด Density เพื่อเปรียบเทียบเส้นความหนาแน่นของข้อมูลได้อย่างราบรื่นมากขึ้น
คลิก “OK” เพื่อสร้างกราฟฮิสโตแกรมของแต่ละการแจกแจง
x1 (การแจกแจงปกติ — Normal): มีลักษณะเป็นรูประฆัง (bell-shaped) และสมมาตร
x2 (การแจกแจงที — t-distribution): คล้ายกับการแจกแจงปกติแต่มีหางยาวกว่า (heavier tails)
x3 และ x5 (การแจกแจง F และไคสแควร์ — F และ Chi-squared): มักมีลักษณะเบ้ไปทางขวา (right-skewed)
x4 (การแจกแจงเบต้า — Beta) และ x6 (การแจกแจงแกมมา — Gamma): รูปร่างขึ้นอยู่กับค่าพารามิเตอร์ โดยทั่วไปมักมีการเบ้ของข้อมูล
(async () => {
// ===== Skeleton =====
const box = html`<div style="max-width:980px;font:14px system-ui;">
<style>
.gd-side {font:12px system-ui; max-width:360px}
.gd-side .row {display:grid; grid-template-columns: 1fr; gap:10px}
.gd-side .ctrl {display:flex; flex-direction:column; gap:6px}
.gd-side input[type="number"]{width:110px; padding:2px 6px}
.gd-side input[type="range"]{width:220px}
.gd-side .oi-radio {display:flex; flex-direction:column; gap:4px}
.gd-side .oi-radio label {font-weight:400; font-size:12px}
.gd-side button[disabled] {opacity:.5; cursor:not-allowed}
.topbar {display:flex; justify-content:flex-end; align-items:center; gap:10px; margin-top:8px}
.topbar .grp {display:flex; align-items:center; gap:10px}
</style>
<div id="ctrl" class="gd-side">
<div class="row"></div>
</div>
<div id="topbar" class="topbar"></div>
<div id="plots" style="margin-top:10px;"></div>
<div id="note" style="margin-top:8px;color:#444"></div>
</div>`;
const ctrlRow = box.querySelector("#ctrl .row");
const topbar = box.querySelector("#topbar");
const plots = box.querySelector("#plots");
const note = box.querySelector("#note");
// ===== Inputs =====
const nI = Inputs.range([100, 5000], {label:"Sample size (n)", value:400, step:50});
const binWidthI = Inputs.number({label:"Bin width", value:0.2, step:0.01, min:0.000001});
const newBtn = Inputs.button("🎲 New sample");
const guessI = Inputs.radio(
["Normal","Uniform","Exponential","Laplace","Lognormal","t","F","Gamma","Chi^2","Beta"],
{ label:"Your guess", value:"Normal", columns:1 }
);
const checkBtn= Inputs.button("Check");
// Left controls (sidebar)
ctrlRow.append(
html`<div class="ctrl">${nI}</div>`,
html`<div class="ctrl">${binWidthI}</div>`,
html`<div class="ctrl">${newBtn}</div>`
);
// Top-right controls (above plot)
const guessWrap = html`<div class="grp" style="gap:16px;">
<div>${guessI}</div>
<div>${checkBtn}</div>
</div>`;
topbar.append(guessWrap);
// ===== State =====
const state = {
round: 0,
checked: false,
answer: "Normal",
sample: []
};
// ===== Helpers =====
function stats2(sample){
const n = sample.length;
if (!n) return {mean:NaN, median:NaN, sd:NaN, var:NaN, skew:NaN, kurt:NaN};
const mean = d3.mean(sample);
const sorted = Float64Array.from(sample).sort();
const median = (n%2 ? sorted[(n-1)/2] : 0.5*(sorted[n/2-1]+sorted[n/2]));
let m2=0, m3=0, m4=0;
for (const x of sample){
const d = x - mean;
const d2 = d*d;
m2 += d2;
m3 += d2*d;
m4 += d2*d2;
}
const varS = n>1 ? (m2/(n-1)) : 0;
const s = Math.sqrt(varS || 0); // sd
const skew = (s>0 && n>2) ? (n/((n-1)*(n-2)))*(m3/(s*s*s)) : 0;
const kurt_excess = (n>3 && s>0)
? ((n*(n+1))/((n-1)*(n-2)*(n-3)))*(m4/(s*s*s*s)) - (3*((n-1)*(n-1))/((n-2)*(n-3)))
: 0;
return {mean, median, sd: s, var: varS, skew, kurt: kurt_excess};
}
function autoBinWidth(arr){
const n = arr.length || 1;
const sorted = Float64Array.from(arr).sort();
const q1 = d3.quantileSorted(sorted, 0.25) ?? d3.min(arr);
const q3 = d3.quantileSorted(sorted, 0.75) ?? d3.max(arr);
const iqr = Math.max(0, (q3 - q1));
let h = 2 * iqr * Math.pow(n, -1/3); // Freedman–Diaconis
if (!(isFinite(h) && h > 0)) {
const sigma = d3.deviation(arr) ?? 0;
h = 3.5 * sigma * Math.pow(n, -1/3); // Scott
}
if (!(isFinite(h) && h > 0)) {
const minX = d3.min(arr), maxX = d3.max(arr);
const range = (isFinite(minX) && isFinite(maxX)) ? (maxX - minX) : 1;
const k = Math.ceil(Math.log2(n)) + 1; // Sturges bins
h = range / Math.max(1, k);
}
const minH = 1e-6;
return Math.max(minH, h);
}
function computeThresholds() {
const arr = state.sample;
if (!arr.length) return 10;
const bw0 = +binWidthI.value;
const bw = Math.max(1e-6, isFinite(bw0) && bw0>0 ? bw0 : 0.1);
let minX = d3.min(arr), maxX = d3.max(arr);
if (!(isFinite(minX) && isFinite(maxX))) return 10;
if (minX === maxX) { minX -= 5*bw; maxX += 5*bw; }
const start = Math.floor(minX / bw) * bw;
const end = Math.ceil(maxX / bw) * bw;
return d3.range(start, end + bw*0.5, bw);
}
// ===== Round generator (Math.random, no seed) =====
function makeRound() {
const rng = Math.random;
const types = ["Normal","Uniform","Exponential","Laplace","Lognormal","t","F","Gamma","Chi^2","Beta"];
const type = types[Math.floor(rng()*types.length)];
let sample;
if (type === "Normal") {
const mu = rng()*2 - 1;
const sd = 0.3 + rng()*0.9;
const norm = d3.randomNormal.source(rng)(mu, sd);
sample = Array.from({length:nI.value}, () => norm());
} else if (type === "Uniform") {
const a = rng()*1.5 - 1.0;
const w = 0.5 + rng()*1.5;
sample = Array.from({length:nI.value}, () => a + rng()*w);
} else if (type === "Exponential") {
const lambda = 0.5 + rng()*1.5;
sample = Array.from({length:nI.value}, () => -Math.log(1 - rng())/lambda);
} else if (type === "Laplace") {
const mu = rng()*1.5 - 0.75;
const b = 0.3 + rng()*0.7;
sample = Array.from({length:nI.value}, () =>
(rng()<0.5 ? mu + b*Math.log(Math.max(1e-12, rng()))
: mu - b*Math.log(Math.max(1e-12, rng())))
);
} else if (type === "Lognormal") {
const mu = rng()*0.5;
const sigma = 0.4 + rng()*0.6;
const norm = d3.randomNormal.source(rng)(mu, sigma);
sample = Array.from({length:nI.value}, () => Math.exp(norm()));
} else if (type === "t") {
const nu = 1 + rng()*14; // dof in (1,15]
const z = d3.randomNormal.source(rng)(0, 1);
const chi= d3.randomGamma.source(rng)(nu/2, 2); // Chi-square(nu)
sample = Array.from({length:nI.value}, () => z() / Math.sqrt(chi()/nu));
} else if (type === "F") {
const df1 = 1 + rng()*14, df2 = 1 + rng()*14;
const chi1 = d3.randomGamma.source(rng)(df1/2, 2);
const chi2 = d3.randomGamma.source(rng)(df2/2, 2);
sample = Array.from({length:nI.value}, () => (chi1()/df1) / (chi2()/df2));
} else if (type === "Gamma") {
const k = 0.5 + rng()*4.5; // shape
const theta = 0.3 + rng()*1.7; // scale
const gam = d3.randomGamma.source(rng)(k, theta);
sample = Array.from({length:nI.value}, () => gam());
} else if (type === "Chi^2") {
const nu = 1 + rng()*14;
const chi = d3.randomGamma.source(rng)(nu/2, 2);
sample = Array.from({length:nI.value}, () => chi());
} else if (type === "Beta") {
const a = 0.5 + rng()*4.5;
const b = 0.5 + rng()*4.5;
const g1 = d3.randomGamma.source(rng)(a, 1);
const g2 = d3.randomGamma.source(rng)(b, 1);
sample = Array.from({length:nI.value}, () => {
const x = g1(), y = g2(); return x/(x + y); // ∈ (0,1)
});
}
// ===== Support-aware transform =====
const posOnly = new Set(["Exponential","Gamma","Chi^2","F","Lognormal"]);
if (type === "Beta") {
state.sample = sample; // keep (0,1)
} else if (posOnly.has(type)) {
const scale = 0.5 + rng()*1.5; // > 0
state.sample = sample.map(x => scale * x);
} else {
const scale = 0.5 + rng()*1.5;
const shift = -1 + rng()*2;
state.sample = sample.map(x => shift + scale * x);
}
state.answer = type;
// ===== Auto bin width EVERY new sample =====
if (state.sample.length > 1) {
const h = autoBinWidth(state.sample);
binWidthI.value = +h.toFixed(4);
}
}
// ===== Render =====
function draw() {
plots.innerHTML = "";
const st = stats2(state.sample);
const txt = `mean: ${st.mean.toFixed(2)}
median: ${st.median.toFixed(2)}
sd: ${st.sd.toFixed(2)}
var: ${st.var.toFixed(2)}
skewness: ${st.skew.toFixed(2)}
kurtosis: ${st.kurt.toFixed(2)}`;
const thresholdsOpt = computeThresholds();
const fig = Plot.plot({
marks: [
Plot.rectY(state.sample, Plot.binX(
{y: "count"},
{
x: d => d,
thresholds: thresholdsOpt,
inset: 0.5,
fill: "seagreen",
stroke: "white",
strokeWidth: 1
}
)),
Plot.ruleY([0]),
// stats text at top-right in the frame
Plot.text([txt], {
frameAnchor: "top-right",
dx: -10, dy: 14,
fontVariantNumeric: "tabular-nums",
lineHeight: 1.2,
text: d => d,
fill: "#222",
fontSize: 12,
textAnchor: "end"
})
],
width: 1200, height: 520, grid: true,
x: {label: "value"},
y: {label: "count"}
});
plots.append(fig);
if (state.checked) {
const correct = (guessI.value === state.answer);
note.innerHTML = correct
? `✅ <b>Correct!</b> It was <b>${state.answer}</b>.`
: `❌ <b>Not quite.</b> The answer is <b>${state.answer}</b>.`;
} else {
note.innerHTML = `🧪 Make a guess and press <b>Check</b>.`;
}
checkBtn.disabled = !!state.checked;
}
// ===== Events =====
newBtn.addEventListener("click", () => {
newBtn.disabled = true;
setTimeout(() => { newBtn.disabled = false; }, 600);
state.round++;
state.checked = false;
makeRound(); // auto binwidth recalculated here
draw();
});
checkBtn.addEventListener("click", () => {
state.checked = true;
draw();
});
[nI, binWidthI, guessI].forEach(el =>
el.addEventListener("input", () => { draw(); })
);
// init
makeRound(); // also auto-sets bin width initially
draw();
return box;
})()หมายเหตุ: สำหรับชื่อและคุณสมบัติของการแจกแจงความน่าจะเป็น (Probability Distributions) คุณสามารถค้นหาได้จาก Google หรือศึกษาเพิ่มเติมจากตำราสถิติทั่วไป
กราฟแท่ง (Bar Chart)
กราฟแท่งใช้แท่งสี่เหลี่ยมผืนผ้าในการแสดงข้อมูลเชิงปริมาณ โดยทั่วไป แกน X จะแสดง หมวดหมู่หรือกลุ่มข้อมูล ส่วนแกน Y จะแสดง ค่าตัวเลข เช่น ความถี่ ปริมาณ หรือร้อยละ
ลักษณะสำคัญของกราฟแท่ง (Key Features of a Bar Chart)
ใช้สำหรับเปรียบเทียบค่าระหว่างหมวดหมู่ต่าง ๆ
สามารถแสดงในรูปแบบ แนวตั้ง (Vertical) หรือ แนวนอน (Horizontal)
เหมาะสำหรับเปรียบเทียบข้อมูลระหว่างกลุ่ม เช่น ยอดขายรายเดือน หรือ จำนวนลูกค้าแยกตามสาขา
Grouped Bar Chart → ใช้สำหรับเปรียบเทียบค่าของข้อมูลระหว่างหลายกลุ่ม โดยแสดงแท่งของแต่ละกลุ่มเรียง เคียงกัน (side-by-side) เพื่อให้เห็นความแตกต่างได้อย่างชัดเจน
Stacked Bar Chart → ใช้สำหรับแสดง องค์ประกอบหรือสัดส่วนของแต่ละกลุ่ม โดยซ้อนข้อมูลของแต่ละหมวดหมู่ไว้บนกันในแท่งเดียว เพื่อให้เห็นภาพรวมและสัดส่วนภายในแต่ละกลุ่มได้อย่างชัดเจน
ใช้สำหรับแสดง สัดส่วนของแต่ละหมวดหมู่ภายในกลุ่ม โดยทำให้สามารถเปรียบเทียบ โครงสร้างโดยรวมของแต่ละกลุ่ม ได้ง่ายขึ้น แม้ว่าค่ารวมของแต่ละกลุ่มจะแตกต่างกันก็ตาม
Horizontal Bar Chart → เหมาะสำหรับกรณีที่ชื่อหมวดหมู่มีความยาว หรือเมื่อการจัดวางในแนวนอนช่วยให้ อ่านและเปรียบเทียบข้อมูลได้ง่ายขึ้น
คุณสามารถคัดลอกข้อมูลจากสไลด์นี้แล้ววางใน Excel เพื่อสร้างกราฟแท่งทั้ง 4 ประเภทได้
ข้อมูลในรูปแบบ ตาราง (Table-format data) ไม่สามารถนำไปสร้างกราฟแท่งใน Excel ได้โดยตรง
| category | group | value |
|---|---|---|
| A | X | 10 |
| A | Y | 15 |
| B | X | 20 |
| B | Y | 25 |
| C | X | 30 |
| C | Y | 35 |
\[\rightarrow\]
คุณต้องทำการ ปรับโครงสร้างข้อมูล (Restructure the Data) ให้อยู่ในรูปแบบด้านล่างนี้ก่อน จึงจะสามารถสร้างกราฟแท่งใน Excel ได้
| X | Y | |
|---|---|---|
| A | 10 | 15 |
| B | 20 | 25 |
| C | 30 | 35 |
Line Chart กราฟเส้นใช้สำหรับแสดง แนวโน้ม (Trends) หรือ การเปลี่ยนแปลงของข้อมูลตามเวลา (Changes Over Time)
Data Table (Copy and Paste into Excel):
| year | group | value |
|---|---|---|
| 2000 | A | 5 |
| 2000 | B | 7 |
| 2001 | A | 8 |
| 2001 | B | 12 |
| 2002 | A | 15 |
| 2002 | B | 18 |
| 2003 | A | 20 |
| 2003 | B | 25 |
| 2004 | A | 28 |
| 2004 | B | 30 |
| 2005 | A | 35 |
| 2005 | B | 40 |
\[\rightarrow\]
อย่างไรก็ตาม คุณ ไม่สามารถสร้างกราฟเส้นที่ถูกต้องได้ จนกว่าจะได้ทำการ ปรับโครงสร้างข้อมูล (Restructure the Data) ให้อยู่ในรูปแบบดังนี้:
| year | A | B |
|---|---|---|
| 2000 | 5 | 7 |
| 2001 | 8 | 12 |
| 2002 | 15 | 18 |
| 2003 | 20 | 25 |
| 2004 | 28 | 30 |
| 2005 | 35 | 40 |
(async () => {
// ========= Container & Styles =========
const box = html`<div style="width:100%; font:14px system-ui;">
<style>
.topbar { display:flex; flex-direction:column; gap:8px; margin: 6px 0; width:100%; }
.row { display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
.row.nowrap { flex-wrap: nowrap; } /* บังคับ Start/End ให้อยู่บรรทัดเดียว */
.lbl { font-weight:600; }
#plots {
display:flex; flex-direction:column; gap:12px;
align-items:center; justify-content:center; width:100%;
}
.btn { padding:6px 10px; border:1px solid #ccc; border-radius:6px; background:#f7f7f7; cursor:pointer; }
.btn:hover { background:#eee; }
</style>
<div class="topbar" id="topbar"></div>
<div id="plots"></div>
</div>`;
const topbar = box.querySelector("#topbar");
const plots = box.querySelector("#plots");
// ========= Controls =========
const widthI = Inputs.number({label:"Width (px)", value: 1200, step: 50, min: 600});
const heightI = Inputs.number({label:"Price height (px)", value: 520, step: 20, min: 320});
const volHgtI = Inputs.number({label:"Volume height (px)", value: 160, step: 10, min: 100});
const ma20I = Inputs.toggle({label:"MA20", value: true});
const ma50I = Inputs.toggle({label:"MA50", value: true});
const ma200I = Inputs.toggle({label:"MA200", value: false});
const startDateI = Inputs.date({label: "Start", value: new Date()});
const endDateI = Inputs.date({label: "End", value: new Date()});
// ปุ่มซูม
const zoomInBtn = html`<button class="btn">Zoom In</button>`;
const zoomOutBtn = html`<button class="btn">Zoom Out</button>`;
const resetBtn = html`<button class="btn">Reset</button>`;
// Layout: แถว 1 = ขนาดกราฟ, แถว 2 = Date range (Start/End บรรทัดเดียว), แถว 3 = MA, แถว 4 = Zoom controls
const row1 = html`<div class="row">
<div>${widthI}</div>
<div>${heightI}</div>
<div>${volHgtI}</div>
</div>`;
const row2 = html`<div class="row nowrap">
<div class="lbl">Date range:</div>
<div>${startDateI}</div>
<div>${endDateI}</div>
</div>`;
const row3 = html`<div class="row">
<div>${ma20I}</div>
<div>${ma50I}</div>
<div>${ma200I}</div>
</div>`;
const row4 = html`<div class="row">
<div class="lbl">Zoom:</div>
<div>${zoomInBtn}</div>
<div>${zoomOutBtn}</div>
<div>${resetBtn}</div>
<div style="color:#666;">Tip: หมุนล้อเมาส์เพื่อซูม (ซูมอิงตำแหน่งเมาส์), ดับเบิลคลิกเพื่อรีเซ็ต</div>
</div>`;
topbar.append(row1, row2, row3, row4);
// ========= Sample Data (Synthetic OHLC) =========
function genSyntheticOHLC(n = 420, start = new Date(2024, 0, 2), startPrice = 120) {
const data = [];
let prevClose = startPrice;
const randRet = d3.randomNormal(0, 0.012);
const randW = d3.randomNormal(0, 0.006);
const randV = d3.randomNormal(0, 1.5e6);
for (let i = 0; i < n; i++) {
const d = new Date(+start + i * 24*3600*1000); // daily
const ret = randRet();
const open = prevClose;
const close = Math.max(1e-6, open * (1 + ret));
const high = Math.max(open, close) * (1 + Math.abs(randW()));
const low = Math.min(open, close) * (1 - Math.abs(randW()));
const volume = Math.max(0, Math.round(5e6 + Math.abs(randV())));
data.push({date: d, open, high, low, close, volume});
prevClose = close;
}
return data;
}
// Load data (synthetic by default). Replace with CSV loader if needed.
let raw = genSyntheticOHLC(420, new Date(2024, 0, 2), 120);
// // Example for real CSV (uncomment and attach CSV named data.csv to notebook):
// raw = (await d3.csv(FileAttachment("data.csv").url, d3.autoType))
// .map(d => ({
// date: new Date(d.Date),
// open: +d.Open, high: +d.High, low: +d.Low, close: +d.Close,
// volume: +d.Volume
// }))
// .filter(d => isFinite(+d.date) && isFinite(d.open) && isFinite(d.high) && isFinite(d.low) && isFinite(d.close))
// .sort((a,b) => +a.date - +b.date);
// Set default date range to full span
const fullMin = d3.min(raw, d => d.date);
const fullMax = d3.max(raw, d => d.date);
startDateI.value = fullMin;
endDateI.value = fullMax;
// ========= Helpers =========
const fmtDate = d3.utcFormat("%Y-%m-%d");
const colorUp = "#22a66f";
const colorDn = "#e25555";
function SMA(data, k) {
const out = [];
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data[i].close;
if (i >= k) sum -= data[i - k].close;
if (i >= k - 1) out.push({date: data[i].date, value: sum / k});
}
return out;
}
function subsetByDate(data, d0, d1) {
const t0 = +d0, t1 = +d1;
return data.filter(d => +d.date >= t0 && +d.date <= t1);
}
// Median step (ms) from raw dates
function medianStepMs(data) {
if (data.length < 2) return 24*3600*1000;
const diffs = [];
for (let i = 1; i < data.length; i++) diffs.push(+data[i].date - +data[i-1].date);
return d3.median(diffs) || diffs[0] || 24*3600*1000;
}
const baseStepMs = medianStepMs(raw);
// Candle body width from median step
function barWidthMS(data) {
const step = medianStepMs(data);
return step * 0.8; // 80% of slot
}
// Range utilities
function setRange(t0, t1) {
startDateI.value = new Date(t0);
endDateI.value = new Date(t1);
draw();
}
function clampToFull(t0, t1) {
let a = Math.max(+fullMin, Math.min(t0, t1));
let b = Math.min(+fullMax, Math.max(t0, t1));
// enforce minimum span ~= 3 steps
const minSpan = baseStepMs * 3;
if (b - a < minSpan) {
const c = (a + b) / 2;
a = c - minSpan / 2;
b = c + minSpan / 2;
// clamp again to full range
if (a < +fullMin) { b += (+fullMin - a); a = +fullMin; }
if (b > +fullMax) { a -= (b - +fullMax); b = +fullMax; }
}
return [a, b];
}
function zoomAround(centerMs, factor) {
const t0 = +new Date(startDateI.value);
const t1 = +new Date(endDateI.value);
const span = Math.max(baseStepMs, t1 - t0);
const newSpan = span * factor; // factor < 1 => zoom in, >1 => zoom out
let a = centerMs - newSpan / 2;
let b = centerMs + newSpan / 2;
[a, b] = clampToFull(a, b);
setRange(a, b);
}
// ========= Render =========
function draw() {
plots.innerHTML = "";
const W = +widthI.value;
const H = +heightI.value;
const HV = +volHgtI.value;
const sDate = new Date(startDateI.value);
const eDate = new Date(endDateI.value);
const data = subsetByDate(raw, sDate, eDate);
if (data.length < 2) {
plots.append(html`<div>Not enough data in selected range.</div>`);
return;
}
const bw = barWidthMS(data);
// Shared X domain for both charts
const xDomain = d3.extent(data, d => d.date);
// Y domains with padding
const priceMin = d3.min(data, d => d.low);
const priceMax = d3.max(data, d => d.high);
const padPct = 0.05;
const span = Math.max(1e-12, priceMax - priceMin);
const pad = span * padPct;
const yDomainPrice = [priceMin - pad, priceMax + pad];
const volMax = d3.max(data, d => d.volume) || 1;
const yDomainVol = [0, volMax * 1.15];
const fmtVol = d3.format(",");
const titleFn = d => `${fmtDate(d.date)}
O: ${d.open.toFixed(2)} H: ${d.high.toFixed(2)}
L: ${d.low.toFixed(2)} C: ${d.close.toFixed(2)}
Vol: ${fmtVol(d.volume)}`;
// Marks
const wick = Plot.ruleY(data, {
x: d => d.date,
y1: d => d.low,
y2: d => d.high,
stroke: d => (d.close >= d.open ? colorUp : colorDn),
strokeOpacity: 0.9,
tip: true,
title: titleFn
});
const body = Plot.rectY(data, {
x1: d => new Date(+d.date - bw/2),
x2: d => new Date(+d.date + bw/2),
y1: d => d.open,
y2: d => d.close,
fill: d => (d.close >= d.open ? colorUp : colorDn),
stroke: "currentColor",
tip: true,
title: titleFn
});
const marks = [wick, body];
if (ma20I.value) {
const ma20 = SMA(data, 20);
marks.push(Plot.line(ma20, {
x: "date", y: "value", stroke: "#2b6cb0", strokeWidth: 1.5,
tip: true, title: d => `MA20: ${d.value.toFixed(2)}\n${fmtDate(d.date)}`
}));
}
if (ma50I.value) {
const ma50 = SMA(data, 50);
marks.push(Plot.line(ma50, {
x: "date", y: "value", stroke: "#c05621", strokeWidth: 1.5,
tip: true, title: d => `MA50: ${d.value.toFixed(2)}\n${fmtDate(d.date)}`
}));
}
if (ma200I.value) {
const ma200 = SMA(data, 200);
marks.push(Plot.line(ma200, {
x: "date", y: "value", stroke: "#6b7280", strokeWidth: 1.8, strokeDasharray: "4,3",
tip: true, title: d => `MA200: ${d.value.toFixed(2)}\n${fmtDate(d.date)}`
}));
}
// Margins (ใช้สำหรับคำนวณ scale ในการซูมด้วยเมาส์)
const ML = 64, MR = 28;
// Price chart
const pricePlot = Plot.plot({
width: W,
height: H,
marginLeft: ML,
marginRight: MR,
marginTop: 20,
marginBottom: 30,
grid: true,
x: {type: "utc", domain: xDomain, label: "Date"},
y: {label: "Price", domain: yDomainPrice, nice: true},
marks
});
pricePlot.style.display = "block";
pricePlot.style.margin = "0 auto";
plots.append(pricePlot);
// Volume chart
const volPlot = Plot.plot({
width: W,
height: HV,
marginLeft: ML,
marginRight: MR,
marginTop: 0,
marginBottom: 30,
x: {type: "utc", domain: xDomain, label: "Date"},
y: {label: "Volume", domain: yDomainVol, nice: true, grid: true},
marks: [
Plot.rectY(data, {
x1: d => new Date(+d.date - bw/2),
x2: d => new Date(+d.date + bw/2),
y: "volume",
fill: d => (d.close >= d.open ? colorUp : colorDn),
fillOpacity: 0.55,
tip: true,
title: d => `${fmtDate(d.date)}\nVol: ${fmtVol(d.volume)}`
})
]
});
volPlot.style.display = "block";
volPlot.style.margin = "0 auto";
plots.append(volPlot);
// ====== Mouse wheel zoom (centered at cursor) & Double-click reset ======
const attachWheelZoom = (svg) => {
// scale สำหรับแปลงพิกัด x -> วันที่
const scaleX = d3.scaleUtc().domain(xDomain).range([ML, W - MR]);
const toDateAtX = (x) => {
const xx = Math.max(ML, Math.min(W - MR, x));
return +scaleX.invert(xx);
};
const onWheel = (e) => {
e.preventDefault();
const rect = svg.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
// กำหนด factor จาก deltaY (เล็กน้อยเพื่อซูมเนียน)
const factor = e.deltaY > 0 ? 1.15 : 0.87; // ลง = ซูมออก, ขึ้น = ซูมเข้า
const center = toDateAtX(mouseX);
zoomAround(center, factor);
};
const onDblClick = () => {
setRange(+fullMin, +fullMax);
};
svg.addEventListener("wheel", onWheel, {passive:false});
svg.addEventListener("dblclick", onDblClick);
};
attachWheelZoom(pricePlot);
attachWheelZoom(volPlot);
}
// ========= Events =========
[widthI, heightI, volHgtI, startDateI, endDateI, ma20I, ma50I, ma200I].forEach(el =>
el.addEventListener("input", draw)
);
// Zoom buttons
zoomInBtn.addEventListener("click", () => {
const t0 = +new Date(startDateI.value);
const t1 = +new Date(endDateI.value);
const center = (t0 + t1) / 2;
zoomAround(center, 0.8); // zoom in
});
zoomOutBtn.addEventListener("click", () => {
const t0 = +new Date(startDateI.value);
const t1 = +new Date(endDateI.value);
const center = (t0 + t1) / 2;
zoomAround(center, 1.25); // zoom out
});
resetBtn.addEventListener("click", () => setRange(+fullMin, +fullMax));
draw();
return box;
})()Scatter Plot คือกราฟที่ใช้แสดงความสัมพันธ์ระหว่างตัวแปรสองตัว คือ X และ Y โดยแต่ละจุดบนกราฟแทนค่าของข้อมูลหนึ่งคู่ (x, y) เพื่อช่วยให้มองเห็นรูปแบบ แนวโน้ม หรือความสัมพันธ์ระหว่างตัวแปรทั้งสองได้อย่างชัดเจน
การใช้สเกลแบบลอการิทึม (Logarithmic Axis) ช่วยให้มองเห็นรูปแบบของข้อมูลได้ชัดเจนยิ่งขึ้น โดยเฉพาะเมื่อข้อมูลมีค่าครอบคลุมหลายระดับขนาด (หลายลำดับของขนาด) เช่น ในกรณีที่ข้อมูลมีการเบ้ (Skewed Distributions) หรือมีการเติบโตแบบเอ็กซ์โพเนนเชียล (Exponential Growth)
Bubble Plot เป็นการขยายรูปแบบของกราฟกระจาย (Scatter Plot) โดยใช้ ขนาดของฟอง (Bubble Size) เพื่อแทนค่าของตัวแปรเพิ่มเติมอีกตัวหนึ่ง
กราฟนี้ช่วยให้สามารถแสดงข้อมูลใน สามมิติ (Three Dimensions) ได้แก่ แกน X, แกน Y และขนาดของฟอง หรือในบางกรณีอาจเพิ่มมิติอื่น เช่น สี เพื่อแสดงตัวแปรที่สี่ได้ด้วย
องค์ประกอบของกราฟฟองอากาศ
แกน X: ตัวแปรเชิงปริมาณ (Quantitative Variable)
แกน Y: ตัวแปรเชิงปริมาณ (Quantitative Variable)
ขนาดของฟอง (Bubble Size): แสดงค่าของตัวแปรที่สาม เช่น ประชากรหรือยอดขาย
สีของฟอง (Bubble Color – ตัวเลือกเพิ่มเติม): ใช้แสดงหมวดหมู่หรือกลุ่ม เช่น ประเทศ หรือประเภทสินค้า
ตัวอย่างการใช้งานของกราฟฟองอากาศ
เศรษฐศาสตร์ (Economics): แสดงความสัมพันธ์ระหว่าง GDP (แกน X) กับอัตราการว่างงาน (แกน Y) โดยให้ขนาดของฟองแทนจำนวนประชากร
ธุรกิจ (Business): แสดงยอดขาย (แกน X) เทียบกับกำไร (แกน Y) โดยให้ขนาดของฟองแทนจำนวนลูกค้า
สาธารณสุข (Public Health): แสดงอายุคาดเฉลี่ยของประชากร (Life Expectancy, แกน X) เทียบกับรายได้เฉลี่ย (Average Income, แกน Y) โดยให้ขนาดของฟองแทนจำนวนประชากร
Cairo, A. (2016). The truthful art: Data, charts, and maps for communication. New Riders.
Few, S. (2009). Now you see it: Simple visualization techniques for quantitative analysis. Analytics Press.
Knaflic, C. N. (2015). Storytelling with data: A data visualization guide for business professionals. Wiley.
Tufte, E. R. (2001). The visual display of quantitative information (2nd ed.). Graphics Press.
Wickham, H. (2016). ggplot2: Elegant graphics for data analysis (2nd ed.). Springer.
Wilke, C. O. (2019). Fundamentals of data visualization: A primer on making informative and compelling figures. O’Reilly Media.
(async () => {
// ------- Libs -------
let Plot;
try { Plot = await require("@observablehq/plot@0.6.17"); }
catch { Plot = (await import("https://esm.sh/@observablehq/plot@0.6?bundle")).default; }
const {csvParse, tsvParse, dsvFormat} = await import("https://esm.sh/d3-dsv@3?bundle");
const {timeParse} = await import("https://esm.sh/d3-time-format@4?bundle");
// ------- Sample data -------
function generateBigCSV(N = 2000) {
const cats = ["A","B","C","D","E","F"];
const regions = ["East","West","North","South"];
const start = new Date("2024-01-01");
const rows = [];
for (let i=0;i<N;i++){
const d = new Date(start.getTime() + 24*3600*1000 * (i % 120));
const cat = cats[i % cats.length];
const reg = regions[i % regions.length];
const base = 50 + (cats.indexOf(cat))*25;
const value = Math.round(base + 20*Math.sin(i/11) + 15*Math.random() + 10*Math.random()*Math.random());
const qty = Math.max(0, Math.round(6 + 2*Math.sin(i/7) + 3*Math.random()));
const score = Math.min(1, Math.max(0, Math.random()*0.9 + 0.05*Math.sin(i/13)));
rows.push(`${d.toISOString().slice(0,10)},${cat},${value},${reg},${qty},${score.toFixed(3)}`);
}
return "date,category,value,region,qty,score\n" + rows.join("\n");
}
// ------- UI -------
const box = html`<div style="max-width:1200px;margin:0 auto;font:14px system-ui;">
<style>
.stack { display:flex; flex-direction:column; gap:12px; }
.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}
input[type=file],select,input[type=number],input[type=color],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)}
.vars{display:flex;flex-wrap:wrap;gap:6px}
.pill{
display:inline-flex;gap:6px;align-items:center;border:1px solid #cbd5e1;border-radius:999px;
padding:2px 8px;background:#f8fafc;cursor:grab;font-size:12px; user-select:none;
}
.pill[data-type="number"]{border-color:#60a5fa;background:#eff6ff}
.pill[data-type="date"] {border-color:#34d399;background:#ecfdf5}
.pill[data-type="string"]{border-color:#f59e0b;background:#fffbeb}
.dz-row{display:grid;grid-template-columns:repeat(5,minmax(140px,1fr));gap:8px}
@media (max-width:1000px){ .dz-row{grid-template-columns:repeat(3,1fr)} }
@media (max-width:640px){ .dz-row{grid-template-columns:repeat(2,1fr)} }
.dropzone{
border:2px dashed #94a3b8;border-radius:10px;padding:6px 8px;min-height:38px;
display:flex;align-items:center;justify-content:space-between;gap:6px;background:#f8fafc
}
.slotname{font-weight:700;font-size:12px}
.dz-pill{flex:1; min-width:70px}
.clear{border:none;background:transparent;color:#ef4444;cursor:pointer;font-weight:700}
table.tbl{width:100%;border-collapse:collapse;margin-top:8px}
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}
/* Filters */
details.filter { border:1px dashed #cbd5e1; border-radius:10px; padding:8px 10px; background:#f8fafc }
.filter-grid { display:grid; grid-template-columns:repeat(3,minmax(220px,1fr)); gap:10px; align-items:start }
@media (max-width:1000px){ .filter-grid { grid-template-columns:repeat(2,1fr) } }
@media (max-width:640px){ .filter-grid { grid-template-columns:1fr } }
.filter-box { border:1px solid #e5e7eb; border-radius:10px; background:#fff; padding:8px; }
.filter-head { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:6px }
.filter-actions { display:flex; gap:6px; flex-wrap:wrap }
.chipset { max-height:180px; overflow:auto; display:grid; gap:6px }
.chip { display:flex; align-items:center; gap:6px; }
/* Legend grid wrapper */
.legend-layout {
display: grid;
grid-template-areas:
"top top"
"left center"
"bottom bottom";
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr auto;
gap: 8px 12px;
}
.legend-layout .top { grid-area: top; }
.legend-layout .left { grid-area: left; }
.legend-layout .bottom { grid-area: bottom; }
.legend-layout .center { grid-area: center; }
</style>
<div class="stack">
<div class="card">
<div class="row">
<span style="border:1px solid #ddd;border-radius:999px;padding:2px 8px;font-size:12px">Visual Builder + Categorical Filters</span>
</div>
<div class="row">
<div class="label">CSV</div>
<input id="file" type="file" accept=".csv,text/csv"/>
<button id="sampleBig" class="btn">Generate sample (2,000 rows)</button>
<button id="clearMaps" class="btn" title="Clear mappings">Clear mappings</button>
<button id="clearFilters" class="btn" title="Clear filters">Clear filters</button>
<label class="row" style="gap:6px;margin-left:auto"><input id="showTbl" type="checkbox"/> <span>Show table</span></label>
</div>
<details class="filter" id="filters">
<summary class="label">Filters (categorical columns)</summary>
<div id="filterGrid" class="filter-grid"></div>
</details>
<div class="label">Variables (drag or click to assign)</div>
<div id="vars" class="vars"></div>
<div class="label">Mappings</div>
<div class="dz-row">
<div id="dzX" class="dropzone"><span class="slotname">x</span><div class="dz-pill"></div><button class="clear" title="clear">×</button></div>
<div id="dzY" class="dropzone"><span class="slotname">y</span><div class="dz-pill"></div><button class="clear" title="clear">×</button></div>
<div id="dzColor" class="dropzone"><span class="slotname">color</span><div class="dz-pill"></div><button class="clear" title="clear">×</button></div>
<div id="dzFy" class="dropzone"><span class="slotname">facet (row)</span><div class="dz-pill"></div><button class="clear" title="clear">×</button></div>
<div id="dzFx" class="dropzone"><span class="slotname">facet (column)</span><div class="dz-pill"></div><button class="clear" title="clear">×</button></div>
</div>
<div class="row" style="margin-top:8px">
<div>
<div class="label">Chart</div>
<select id="chart">
<option>Histogram</option>
<option>Bar</option>
<option>Scatter</option>
<option>Line</option>
<option>Boxplot</option>
</select>
</div>
<div>
<div class="label">Aggregate</div>
<select id="agg">
<option>none</option>
<option>count</option>
<option>sum</option>
<option>mean</option>
<option>median</option>
</select>
</div>
<div id="binWrap">
<div class="label">Bins (Hist)</div>
<input id="bins" type="number" min="5" max="200" step="1" value="40"/>
</div>
<div id="opacityWrap">
<div class="label">Opacity (Hist)</div>
<input id="opacity" type="range" min="0.1" max="1" step="0.05" value="0.45"/>
</div>
<div id="colorPickerWrap" style="display:none">
<div class="label">Color</div>
<input id="colorPick" type="color" value="#4f46e5" />
</div>
<!-- Size + Orientation + Legend -->
<div>
<div class="label">Width (px)</div>
<input id="w" type="number" min="320" max="3000" step="10" value="960"/>
</div>
<div>
<div class="label">Height (px)</div>
<input id="h" type="number" min="180" max="2000" step="10" value="360"/>
</div>
<label class="row" style="gap:6px">
<input id="autoW" type="checkbox" checked/>
<span>Auto width</span>
</label>
<label class="row" style="gap:6px">
<input id="autoH" type="checkbox" checked/>
<span>Auto height (bar/box)</span>
</label>
<label class="row" style="gap:6px">
<input id="horiz" type="checkbox"/>
<span>Horizontal layout</span>
</label>
<div>
<div class="label">Legend position</div>
<select id="legendPos">
<option>right</option>
<option>top</option>
<option>bottom</option>
<option>left</option>
<option>none</option>
</select>
</div>
<button id="build" class="btn" style="margin-left:auto">Build</button>
<button id="savePNG" class="btn">Save PNG</button>
<span id="meta" class="hint"></span>
</div>
</div>
<div class="card">
<div id="plotWrap"></div>
<div id="tableWrap"></div>
</div>
</div>
</div>`;
// ------- Refs -------
const file = box.querySelector("#file");
const btnSampleBig = box.querySelector("#sampleBig");
const btnClear = box.querySelector("#clearMaps");
const btnClearFilters = box.querySelector("#clearFilters");
const showTbl = box.querySelector("#showTbl");
const varWrap = box.querySelector("#vars");
const chartSel = box.querySelector("#chart");
const aggSel = box.querySelector("#agg");
const binWrap = box.querySelector("#binWrap");
const bins = box.querySelector("#bins");
const opacityI = box.querySelector("#opacity");
const opacityWrap = box.querySelector("#opacityWrap");
const colorPickerWrap = box.querySelector("#colorPickerWrap");
const colorPick = box.querySelector("#colorPick");
const buildBtn = box.querySelector("#build");
const meta = box.querySelector("#meta");
const plotWrap = box.querySelector("#plotWrap");
const tableWrap = box.querySelector("#tableWrap");
const savePNG = box.querySelector("#savePNG");
const filterGrid = box.querySelector("#filterGrid");
const wI = box.querySelector("#w");
const hI = box.querySelector("#h");
const autoW = box.querySelector("#autoW");
const autoH = box.querySelector("#autoH");
const horiz = box.querySelector("#horiz");
const legendPos = box.querySelector("#legendPos");
const DZ = {
x: box.querySelector("#dzX"),
y: box.querySelector("#dzY"),
color: box.querySelector("#dzColor"),
fy: box.querySelector("#dzFy"),
fx: box.querySelector("#dzFx")
};
// ------- CSV parse & types -------
function detectDelimiter(txt){
const head = txt.slice(0, 20000);
const count = ch => (head.match(new RegExp(`\\${ch}`, "g")) || []).length;
const sc = [{d:",",n:count(",")},{d:";",n:count(";")},{d:"\t",n:count("\t")}].sort((a,b)=>b.n-a.n);
return sc[0].n>0 ? sc[0].d : ",";
}
function parseCSVflex(textRaw){
const text = textRaw.replace(/^\uFEFF/,"");
const d = detectDelimiter(text);
if (d === ",") return csvParse(text);
if (d === "\t") return tsvParse(text);
return dsvFormat(d).parse(text);
}
const parseYMD = timeParse("%Y-%m-%d");
const parseDMY = timeParse("%d/%m/%Y");
const NA = new Set(["", "na", "nan", "null", "undefined"]);
function tryDate(v){
const s = String(v ?? "").trim();
if (!s) return null;
return parseYMD(s) || parseDMY(s) || (Number.isFinite(Date.parse(s)) ? new Date(s) : null);
}
function tryNumber(v){ const n = +v; return Number.isFinite(n) ? n : null; }
function inferColType(values){
let num=0, dat=0, tot=0;
for (const v of values){
const s = String(v ?? "").trim();
if (NA.has(s.toLowerCase())) continue;
tot++;
if (tryNumber(s)!==null) num++;
else if (tryDate(s)!==null) dat++;
}
if (!tot) return "string";
if (num/tot > 0.8) return "number";
if (dat/tot > 0.6) return "date";
return "string";
}
function summarize(rows){
const cols = Object.keys(rows[0] || {});
const types = {};
for (const c of cols) types[c] = inferColType(rows.map(r=>r[c]));
const casted = rows.map(r=>{
const o={};
for (const c of cols){
const t = types[c];
if (t==="number") { const n=tryNumber(r[c]); o[c]=Number.isFinite(n)?n:null; }
else if (t==="date") { const d=tryDate(r[c]); o[c]=d||null; }
else { o[c] = (r[c]==null? null : String(r[c])); }
}
return o;
});
return {cols, types, rows:casted};
}
// ------- State -------
let dataset = {cols:[], types:{}, rows:[]};
const map = {x:null, y:null, color:null, fx:null, fy:null};
const catFilters = new Map();
// ------- Debounce render -------
let rafId=null, tId=null;
function debouncedRender(){
if (rafId) cancelAnimationFrame(rafId);
if (tId) clearTimeout(tId);
tId = setTimeout(()=>{ rafId = requestAnimationFrame(renderPlot); }, 80);
}
// ------- Variables list -------
function renderVars(){
varWrap.innerHTML = "";
for (const c of dataset.cols){
const t = dataset.types[c];
const pill = html`<div class="pill" draggable="true" data-col=${c} data-type=${t} title="${c} (${t})">
<span>${c}</span><small style="opacity:.7">${t}</small>
</div>`;
pill.addEventListener("dragstart", ev=>{
ev.dataTransfer.effectAllowed = "copy";
ev.dataTransfer.setData("text/plain", c);
});
pill.addEventListener("click", ()=>{
const order = ["x","y","color","fy","fx"];
for (const slot of order){
if (slot==="y" && t!=="number") continue;
if (!map[slot]) { map[slot]=c; updateDZVisual(); debouncedRender(); return; }
}
map.x = c; updateDZVisual(); debouncedRender();
});
varWrap.append(pill);
}
}
// ------- Dropzones -------
function initDropzones(){
for (const [name, dz] of Object.entries(DZ)){
const btn = dz.querySelector(".clear");
dz.addEventListener("dragover", ev=> { ev.preventDefault(); ev.dataTransfer.dropEffect = "copy"; });
dz.addEventListener("drop", ev=>{
ev.preventDefault();
const col = ev.dataTransfer.getData("text/plain");
if (!col) return;
if (name==="y" && dataset.types[col]!=="number"){ alert("y must be numeric"); return; }
map[name] = col; updateDZVisual(); debouncedRender();
});
btn.addEventListener("click", ()=>{ map[name]=null; updateDZVisual(); debouncedRender(); });
}
updateDZVisual();
}
function updateDZVisual(){
for (const [name, dz] of Object.entries(DZ)){
const slot = dz.querySelector(".dz-pill");
const col = map[name];
if (!col){
slot.innerHTML = `<span class="hint">drop ${name}</span>`;
} else {
const t = dataset.types[col];
slot.innerHTML = `<div class="pill" draggable="false" data-type="${t}"><span>${col}</span><small>${t}</small></div>`;
}
}
const ch = chartSel.value;
colorPickerWrap.style.display = (["Histogram","Bar"].includes(ch) && !map.color) ? "" : "none";
opacityWrap.style.display = (ch === "Histogram") ? "" : "none";
}
// ------- Filters (categorical) -------
function buildCatFiltersUI(){
filterGrid.innerHTML = "";
const cats = dataset.cols.filter(c => dataset.types[c] === "string");
if (!cats.length){
filterGrid.innerHTML = `<div class="hint">No categorical (string) columns found.</div>`;
return;
}
cats.forEach(col => {
const vals = Array.from(new Set(dataset.rows.map(r => r[col]).filter(v => v!=null))).slice(0, 300);
const wrap = html`<div class="filter-box" data-col=${col}>
<div class="filter-head">
<div class="label">${col}</div>
<div class="filter-actions">
<button class="btn btn-all" type="button">All</button>
<button class="btn btn-none" type="button">None</button>
<button class="btn btn-inv" type="button">Invert</button>
</div>
</div>
<div class="chipset"></div>
</div>`;
const chips = wrap.querySelector(".chipset");
vals.forEach(v=>{
const checked = catFilters.has(col) ? catFilters.get(col).has(v) : true;
const row = html`<label class="chip">
<input type="checkbox" data-col=${col} data-val=${v} ${checked?"checked":""}/>
<span>${v}</span>
</label>`;
chips.append(row);
});
wrap.querySelector(".btn-all").addEventListener("click", ()=>{
const set = new Set(vals); catFilters.set(col, set);
wrap.querySelectorAll('input[type=checkbox]').forEach(cb=>cb.checked=true);
debouncedRender(); renderTable();
});
wrap.querySelector(".btn-none").addEventListener("click", ()=>{
catFilters.set(col, new Set());
wrap.querySelectorAll('input[type=checkbox]').forEach(cb=>cb.checked=false);
debouncedRender(); renderTable();
});
wrap.querySelector(".btn-inv").addEventListener("click", ()=>{
const cur = catFilters.get(col) ?? new Set(vals);
const inv = new Set(vals.filter(v => !cur.has(v)));
catFilters.set(col, inv);
wrap.querySelectorAll('input[type=checkbox]').forEach(cb=>{
const v = cb.getAttribute("data-val");
cb.checked = inv.has(v);
});
debouncedRender(); renderTable();
});
wrap.querySelectorAll('input[type=checkbox]').forEach(cb=>{
cb.addEventListener("change", ()=>{
const v = cb.getAttribute("data-val");
const set = catFilters.get(col) ?? new Set(vals);
if (cb.checked) set.add(v); else set.delete(v);
catFilters.set(col, set);
debouncedRender(); renderTable();
});
});
filterGrid.append(wrap);
if (!catFilters.has(col)) catFilters.set(col, new Set(vals));
});
}
function clearAllFilters(){
catFilters.clear();
buildCatFiltersUI();
debouncedRender(); renderTable();
}
function getFilteredRows(){
if (!catFilters.size) return dataset.rows;
return dataset.rows.filter(r=>{
for (const [col, set] of catFilters.entries()){
if (!set.size) return false;
const val = r[col];
if (!set.has(val)) return false;
}
return true;
});
}
// ------- Table -------
function renderTable(){
tableWrap.innerHTML = "";
if (!showTbl.checked) { tableWrap.style.display="none"; return; }
tableWrap.style.display="";
if (!dataset.rows.length) return;
const rows = getFilteredRows();
const cols = dataset.cols;
const tbl = html`<table class="tbl">
<thead><tr>${cols.map(c=>`<th>${c}</th>`).join("")}</tr></thead>
<tbody></tbody>
</table>`;
const tb = tbl.querySelector("tbody");
rows.slice(0, 500).forEach(r=>{
tb.insertAdjacentHTML("beforeend", `<tr>${cols.map(c=>`<td>${dataset.types[c]==="date"&&r[c]? new Date(r[c]).toISOString().slice(0,10) : (r[c]??"")}</td>`).join("")}</tr>`);
});
if (rows.length > 500) {
tb.insertAdjacentHTML("beforeend", `<tr><td colspan="${cols.length}" class="hint">… ${rows.length-500} more rows not shown</td></tr>`);
}
tableWrap.append(tbl);
}
// ------- Helpers -------
function uniqueNonNull(arr){ return Array.from(new Set(arr.filter(v=>v!=null))); }
function aggregate(vals, fn){
const xs = vals.filter(v => typeof v === "number" && Number.isFinite(v));
if (!xs.length) return null;
if (fn==="sum") return xs.reduce((a,b)=>a+b,0);
if (fn==="mean") { const s=xs.reduce((a,b)=>a+b,0); return s/xs.length; }
if (fn==="median"){ const t=[...xs].sort((a,b)=>a-b); const n=t.length; return n%2?t[(n-1)/2]:(t[n/2-1]+t[n/2])/2; }
return xs.length;
}
function ensureHistogramX(){
const x = map.x;
if (!x || dataset.types[x] !== "number"){
const numericCol = dataset.cols.find(c => dataset.types[c]==="number");
if (numericCol){ map.x = numericCol; updateDZVisual(); }
}
}
// ------- Legend: build & place (manual) -------
function makeLegendByDomain(pos, domain){
if (!domain || !domain.length) return html`<div></div>`;
const opts = { color: {type: "categorical", domain} };
if (pos === "left" || pos === "right") opts.columns = 1; // 1 column at left/right
return Plot.legend(opts);
}
function commitFigure(fig, colorDomain){
fig.querySelectorAll(".plot-legend").forEach(x => x.remove());
const pos = legendPos.value || "right";
if (pos === "none" || !colorDomain?.length){
plotWrap.append(fig);
return;
}
if (pos === "right"){
const lg = makeLegendByDomain(pos, colorDomain);
const wrap = html`<div style="display:grid;grid-template-columns:1fr auto;gap:12px;align-items:start"></div>`;
wrap.append(fig, lg);
plotWrap.append(wrap);
return;
}
const lay = html`<div class="legend-layout">
<div class="legend top"></div>
<div class="legend left"></div>
<div class="center"></div>
<div class="legend bottom"></div>
</div>`;
const lg = makeLegendByDomain(pos, colorDomain);
lay.querySelector(".center").append(fig);
lay.querySelector("."+pos).append(lg);
plotWrap.append(lay);
}
// ------- Histogram helper (overlay per category) -------
function buildOverlayHist(rows, xcol, colorCol, nbins, fxCol, fyCol){
const xs = rows.map(r=>r[xcol]).filter(Number.isFinite);
if (!xs.length) return [];
const min = Math.min(...xs), max = Math.max(...xs);
const bins = Math.max(1, +nbins|0);
const w = (max - min) / bins || 1;
const key = d => [
fxCol ? d[fxCol] : "",
fyCol ? d[fyCol] : "",
colorCol ? d[colorCol] : ""
].join("||");
const g = new Map();
for (const d of rows){
const v = d[xcol];
if (!Number.isFinite(v)) continue;
const k = key(d);
if (!g.has(k)) g.set(k, []);
g.get(k).push(v);
}
const out = [];
g.forEach((arr, k)=>{
const [fxv, fyv, c] = k.split("||");
const counts = Array(bins).fill(0);
for (const v of arr){
let bi = Math.floor((v - min) / w);
if (bi < 0) bi = 0;
if (bi >= bins) bi = bins - 1;
counts[bi]++;
}
for (let i=0;i<bins;i++){
const x0 = min + i*w, x1 = x0 + w;
out.push({x0, x1, count:counts[i], c, fxv, fyv});
}
});
return out;
}
// ------- Plot (with Horizontal + manual legend) -------
function renderPlot(){
plotWrap.innerHTML = "";
if (!dataset.rows.length){ plotWrap.innerHTML = `<div class="hint">Load or generate a dataset first.</div>`; return; }
const ch = chartSel.value;
binWrap.style.display = (ch==="Histogram") ? "" : "none";
opacityWrap.style.display = (ch==="Histogram") ? "" : "none";
updateDZVisual();
if (ch==="Histogram") ensureHistogramX();
const isH = horiz.checked;
const W = autoW.checked ? (plotWrap.clientWidth || 960) : Math.max(100, +wI.value || 960);
const Hfixed = Math.max(120, +hI.value || 360);
const rowsAll = getFilteredRows();
const x = map.x, y = map.y, color = map.color, fx = map.fx, fy = map.fy;
const colorDomain = color ? uniqueNonNull(rowsAll.map(d=>d[color])) : null;
let fig;
// -------- Histogram (overlay per category + stroke ดำ) --------
if (ch==="Histogram"){
if (!x || dataset.types[x]!=="number"){ plotWrap.innerHTML=`<div class="hint">Histogram requires numeric x.</div>`; return; }
const rows = rowsAll.filter(r => Number.isFinite(r[x]));
const op = Math.max(0.05, Math.min(1, +opacityI.value || 0.45));
if (color){ // หลายฮิสโตแกรมซ้อนจริง ๆ
const hist = buildOverlayHist(rows, x, color, +bins.value, fx, fy);
if (!isH){
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(x)}, y:{label:"count"},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[
Plot.rectY(hist, {
x1:"x1", x2:"x0", y1:0, y2:"count",
fill:d=>d.c, fillOpacity: op,
stroke:"black", strokeWidth:0.8,
mixBlendMode:"multiply",
fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined
}),
Plot.ruleY([0])
]
});
} else {
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:"count"}, y:{label:String(x)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[
Plot.rectX(hist, {
y1:"x0", y2:"x1", x1:0, x2:"count",
fill:d=>d.c, fillOpacity: op,
stroke:"black", strokeWidth:0.8,
mixBlendMode:"multiply",
fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined
}),
Plot.ruleX([0])
]
});
}
} else {
// เดี่ยว (ยังโปร่งแสง + stroke ดำ)
const hist = buildOverlayHist(rows, x, null, +bins.value, fx, fy);
const solid = colorPick.value;
if (!isH){
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(x)}, y:{label:"count"},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false},
marks:[
Plot.rectY(hist, {
x1:"x1", x2:"x0", y1:0, y2:"count",
fill:solid, fillOpacity: op,
stroke:"black", strokeWidth:0.8,
fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined
}),
Plot.ruleY([0])
]
});
} else {
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:"count"}, y:{label:String(x)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false},
marks:[
Plot.rectX(hist, {
y1:"x0", y2:"x1", x1:0, x2:"count",
fill:solid, fillOpacity: op,
stroke:"black", strokeWidth:0.8,
fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined
}),
Plot.ruleX([0])
]
});
}
}
commitFigure(fig, colorDomain); meta.textContent = `Rows (filtered): ${rowsAll.length}`; return;
}
// -------- Boxplot --------
if (ch==="Boxplot"){
if (!x || !y || dataset.types[y]!=="number"){ plotWrap.innerHTML=`<div class="hint">Boxplot needs categorical x and numeric y.</div>`; return; }
const rows = rowsAll.filter(r => r[x]!=null && Number.isFinite(r[y]));
const fillSpec = color ? (d=>d[color]) : "#60a5fa";
const cats = uniqueNonNull(rows.map(r=>r[x]));
const hAuto = Math.max(260, Math.min(700, cats.length*20));
const H = (autoH.checked && isH) ? hAuto : (autoH.checked && !isH ? hAuto : Hfixed);
if (!isH){
fig = Plot.plot({
width: W, height: H, marginLeft:110,
x:{label:String(x)}, y:{label:String(y)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.boxY(rows, {x:d=>d[x], y:d=>d[y], fill: fillSpec, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined}) ]
});
} else {
fig = Plot.plot({
width: W, height: H, marginLeft:110,
x:{label:String(y)}, y:{label:String(x)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.boxX(rows, {y:d=>d[x], x:d=>d[y], fill: fillSpec, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined}) ]
});
}
commitFigure(fig, colorDomain); meta.textContent = `Rows (filtered): ${rowsAll.length}`; return;
}
// -------- Scatter --------
if (ch==="Scatter"){
if (!x || !y || dataset.types[y]!=="number"){ plotWrap.innerHTML=`<div class="hint">Scatter needs x (number/date) and y (number).</div>`; return; }
const rows = rowsAll.filter(r => r[x]!=null && Number.isFinite(r[y]));
const strokeSpec = color ? (d=>d[color]) : "#111";
const fillSpec = color ? (d=>d[color]) : "#4f46e5";
if (!isH){
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(x)}, y:{label:String(y)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.dot(rows, {x:d=>d[x], y:d=>d[y], stroke: strokeSpec, fill: fillSpec, r:3, opacity:0.85, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined}) ]
});
} else {
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(y)}, y:{label:String(x)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.dot(rows, {x:d=>d[y], y:d=>d[x], stroke: strokeSpec, fill: fillSpec, r:3, opacity:0.85, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined}) ]
});
}
commitFigure(fig, colorDomain); meta.textContent = `Rows (filtered): ${rowsAll.length}`; return;
}
// -------- Line --------
if (ch==="Line"){
if (!x || !y || dataset.types[y]!=="number"){ plotWrap.innerHTML=`<div class="hint">Line needs x (number/date) and y (number).</div>`; return; }
const rows = rowsAll.filter(r => r[x]!=null && Number.isFinite(r[y])).slice().sort((a,b)=> (a[x]>b[x]?1:-1));
const grpKey = color ? (d=>d[color]) : (()=>"__all__");
if (!isH){
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(x)}, y:{label:String(y)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[
Plot.line(rows, {x:d=>d[x], y:d=>d[y], stroke: grpKey, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined}),
Plot.dot(rows, {x:d=>d[x], y:d=>d[y], fill: grpKey, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined})
]
});
} else {
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(y)}, y:{label:String(x)},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[
Plot.line(rows, {x:d=>d[y], y:d=>d[x], stroke: grpKey, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined}),
Plot.dot(rows, {x:d=>d[y], y:d=>d[x], fill: grpKey, fx: fx? (d=>d[fx]) : undefined, fy: fy? (d=>d[fy]) : undefined})
]
});
}
commitFigure(fig, colorDomain); meta.textContent = `Rows (filtered): ${rowsAll.length}`; return;
}
// -------- Bar --------
if (!x){ plotWrap.innerHTML=`<div class="hint">Bar needs x. Optional y (with aggregation).</div>`; return; }
const all = rowsAll.filter(r => r[x]!=null);
function aggregateBy(keys, getVal){
const g = new Map();
for (const d of all){
const key = keys.map(k => (k? d[k] : "")).join("||");
if (!g.has(key)) g.set(key, []);
g.get(key).push(d);
}
const out = [];
g.forEach((arr, keyStr) => {
const parts = keyStr.split("||");
const rec = {};
keys.forEach((k, i) => { if (k) rec[k] = parts[i]; });
rec.value = getVal(arr);
out.push(rec);
});
return out;
}
const fillSpec = color ? (d=>d[color]) : colorPick.value;
if (!y){
const keys = [x, color, fx, fy];
let out = aggregateBy(keys, arr => arr.length).map(r => ({
x: r[x], y: r.value, c: color ? r[color] : null, fxv: fx ? r[fx] : null, fyv: fy ? r[fy] : null
}));
const cats = uniqueNonNull(out.map(d=>d.x));
const hAuto = Math.max(260, Math.min(700, cats.length*20));
const H = (autoH.checked && horiz.checked) ? hAuto : Hfixed;
if (!horiz.checked){
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(x), domain: cats}, y:{label:"count"},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.barY(out, {x:"x", y:"y", fill: fillSpec, fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined}) ]
});
} else {
fig = Plot.plot({
width: W, height: H, marginLeft:110,
x:{label:"count"}, y:{label:String(x), domain: cats},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.barX(out, {x:"y", y:"x", fill: fillSpec, fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined}) ]
});
}
commitFigure(fig, colorDomain); meta.textContent = `Rows (filtered): ${rowsAll.length}`; return;
} else {
const keys = [x, color, fx, fy];
let out = aggregateBy(keys, arr => {
const ys = arr.map(a=>a[y]);
if (aggSel.value==="none"){ const nums = ys.filter(Number.isFinite); return nums.length? nums.reduce((a,b)=>a+b,0)/nums.length : null; }
if (aggSel.value==="count") return ys.filter(Number.isFinite).length;
return aggregate(ys, aggSel.value);
}).map(r => ({
x: r[x], y: r.value, c: color ? r[color] : null, fxv: fx ? r[fx] : null, fyv: fy ? r[fy] : null
}));
const cats = uniqueNonNull(out.map(d=>d.x));
const hAuto = Math.max(260, Math.min(700, cats.length*20));
const H = (autoH.checked && horiz.checked) ? hAuto : Hfixed;
if (!horiz.checked){
fig = Plot.plot({
width: W, height: Hfixed, grid:true,
x:{label:String(x), domain: cats}, y:{label:`${aggSel.value==="none"?"mean":aggSel.value}(${y})`},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.barY(out, {x:"x", y:"y", fill: fillSpec, fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined}) ]
});
} else {
fig = Plot.plot({
width: W, height: H, marginLeft:110,
x:{label:`${aggSel.value==="none"?"mean":aggSel.value}(${y})`}, y:{label:String(x), domain: cats},
fx: fx ? {label:String(fx)} : undefined, fy: fy ? {label:String(fy)} : undefined,
color:{legend:false, domain: colorDomain || undefined},
marks:[ Plot.barX(out, {x:"y", y:"x", fill: fillSpec, fx: fx? "fxv" : undefined, fy: fy? "fyv" : undefined}) ]
});
}
commitFigure(fig, colorDomain); meta.textContent = `Rows (filtered): ${rowsAll.length}`; return;
}
}
// ------- CSV load -------
async function loadText(text){
const parsed = parseCSVflex(text);
dataset = summarize(parsed);
catFilters.clear();
renderVars(); buildCatFiltersUI(); updateDZVisual(); renderTable(); renderPlot();
}
async function loadFile(f){ if(!f) return; const t=await f.text(); await loadText(t); }
// ------- Events -------
function clearMappings(){ for (const k of Object.keys(map)) map[k]=null; updateDZVisual(); renderPlot(); }
file.addEventListener("change", ()=> loadFile(file.files[0]));
btnSampleBig.addEventListener("click", ()=> loadText(generateBigCSV(2000)));
btnClear.addEventListener("click", clearMappings);
btnClearFilters.addEventListener("click", clearAllFilters);
chartSel.addEventListener("change", ()=>{ updateDZVisual(); if(chartSel.value==="Histogram") ensureHistogramX(); debouncedRender(); });
aggSel.addEventListener("change", debouncedRender);
bins.addEventListener("input", debouncedRender);
opacityI.addEventListener("input", debouncedRender);
colorPick.addEventListener("input", debouncedRender);
buildBtn.addEventListener("click", debouncedRender);
showTbl.addEventListener("change", renderTable);
[wI, hI].forEach(el => el.addEventListener("input", debouncedRender));
[autoW, autoH, horiz, legendPos].forEach(el => el.addEventListener("change", debouncedRender));
window.addEventListener("resize", ()=>{ if (autoW.checked) debouncedRender(); });
// save PNG
savePNG.addEventListener("click", ()=>{
const svg = plotWrap.querySelector("svg"); if(!svg) return;
const s = new XMLSerializer().serializeToString(svg);
const blob = new Blob([s], {type:"image/svg+xml"}), url = URL.createObjectURL(blob);
const img = new Image();
img.onload = ()=>{
const w = svg.viewBox?.baseVal?.width || svg.getBoundingClientRect().width || 960;
const h = svg.viewBox?.baseVal?.height|| svg.getBoundingClientRect().height|| 360;
const canvas = document.createElement("canvas"); canvas.width=w; canvas.height=h;
const ctx = canvas.getContext("2d"); ctx.drawImage(img,0,0);
canvas.toBlob(b=>{ const a = document.createElement("a"); a.href=URL.createObjectURL(b); a.download="chart.png"; a.click(); }, "image/png");
URL.revokeObjectURL(url);
};
img.src = url;
});
// init (Show table default false)
showTbl.checked = false;
renderTable();
initDropzones();
await loadText(generateBigCSV(2000)); // internal data ready
return box;
})()