International College of Digital Innovation, CMU
September 28, 2025
Accurate and Timely Decision-Making
Data visualization enables executives to quickly understand market trends, revenue, profits, and customer behavior.
Performance Monitoring
Dashboards can be used to display sales, costs, profit margins, and other key business factors.
Customer Analysis
Analyze demographic data, behavior, and customer segmentation to develop effective marketing strategies.
Economic Trend Forecasting
Line charts and heat maps help visualize economic conditions such as inflation, GDP growth, and unemployment rates.
Policy Analysis
Governments and economists can use data visualization to design policies that effectively address economic challenges.
Cross-Country/Regional Comparisons
Maps and bar charts can be used to compare economic growth across countries or regions.
Stock Market Trend Monitoring
Bar and line charts help investors understand stock market behavior, indices, and various assets.
⚖ Risk Management
Use Box Plots or Histograms to analyze risk-related data.
Portfolio Analysis
Visualize profits, losses, and asset allocation within an investment portfolio.
Disease Monitoring and Epidemiology
Use heatmaps or bubble charts to track disease outbreaks and trends.
Medical Resource Management
Analyze hospital bed availability, patient volumes, and medication usage rates.
Treatment Development
Use scatter plots or violin plots to analyze data from clinical trials and research studies.
Analyzing Academic Achievement
Use data visualization to monitor students’ academic performance.
Enhancing Teaching and Learning Systems
Analyze learner behavior through Learning Analytics Dashboards.
Global Education Comparison
Use maps or bar charts to evaluate the quality of education across countries.
Experimental Data Analysis
Use box plots or scatter plots to examine relationships between variables.
Visualizing Complex Data Patterns
Use PCA (Principal Component Analysis) or heatmaps to better understand high-dimensional data.
Communicating Research Findings Effectively
Graphs and visual aids help scientists clearly explain their study results.
Data visualization comes in many forms, each suited to different types of analysis. Let’s explore the main categories everyone should know.
Used to Observe Data Trends or Changes Over Time
Line Chart: Displays data trends over time, such as monthly sales or stock prices.
Area Chart: Similar to a line chart but emphasizes the area under the curve to show accumulated volume.
Example Use Cases: Tracking trends in GDP, inflation rates, or service user volumes.
Used to Show the Patterns and Spread of Data
Histogram: Visualizes the distribution of values, e.g., population income.
Box Plot (Box-and-Whisker Plot): Shows median, minimum, maximum, and outliers.
Density Plot: Displays the statistical distribution of data.
Example Use Cases: Analyzing customer spending or student exam scores.
Example: Income Comparison Between Males and Females
Used to Compare Data Across Different Categories
Bar Chart: Used to compare values across categories, e.g., sales by product.
Stacked Bar Chart: Used to compare parts of a whole, such as market share by company.
Horizontal Bar Chart: Useful when category labels are long or when comparing many items.
Example Use Cases: Comparing company revenues or the number of customers in each group.
Used to Analyze the Relationship Between Two or More Variables
Scatter Plot: Shows the relationship between two variables, such as housing price vs. land size.
Bubble Plot: Similar to a scatter plot but uses the size of the points to represent a third variable.
Used to Visualize Data Related to Maps or Geographic Coordinates
Heat Map: Visualizes data density or distribution over a map, such as population density.
Choropleth Map: Displays values by region, such as average income per province.
Bubble Map: Uses the position and size of bubbles to show specific values over geographic areas.
Example Use Case: Analyzing sales distribution across regions.
Used to Visualize Hierarchical Structures or Network Relationships
Tree Diagram: Represents tree-like structures such as organizational charts.
Sunburst Chart: A circular version of the tree diagram, ideal for nested data.
Network Graph: Shows relationships and connections between entities, such as social networks.
Example Use Cases: Analyzing corporate structure or relationships in a social network.
Used to Show How Different Parts Make Up a Whole
Pie Chart: Used to display proportions of categories.
Donut Chart: Similar to a pie chart but with a blank center.
Treemap: A better alternative to pie charts when there are many categories.
Choose Based on Your Analytical Purpose
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 is a type of bar chart used to represent the distribution of data.
Each bar shows the frequency of data points falling within a specific range or “bin”.
This makes it easier to observe the shape of the distribution or identify clusters and gaps in the data.
X-axis: Represents the range of values (bins), which are divided into specified intervals, such as age ranges, test scores, numerical sizes, or time periods.
Y-axis: Represents the frequency — the number of data points that fall within each bin.
Bars: The height of each bar corresponds to the number of observations in that bin. The taller the bar, the more data falls within that interval.
Histograms are often used to analyze data distribution, such as:
Examining the distribution of test scores
Analyzing the age distribution of a population
Observing the frequency of customer visits across different time periods
Exploring numerical data in statistical analysis
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: "แยกกลุ่ม"})The incomes of Group A and Group B are normally distributed as follows:
Group A ~\(N(\mu_1, \sigma_1^2)\)or\(N(\)2)
Group B ~\(N(\mu_2, \sigma_2^2)\)or\(N(\)2) respectively.
Clear View of Data Distribution:
Histograms reveal how data is spread—whether it’s concentrated or dispersed.
Detect Anomalies:
They help identify unusual values or outliers in the dataset.
Easy Comparison:
You can quickly compare data frequencies across intervals or between groups.
You will create histograms using the Excel file: histogram.xlsx
Each column represents data from a specific 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.xlsxx1 to x6)Open Jamovi
Go to Open → This PC → Load histogram.xlsx
Navigate to the Exploration tab → select Descriptives
Drag variables x1 to x6 into the Variables panel
In the right panel:
Enable Plots → Histogram
Optionally enable Density for smoother comparison
Click “OK” to generate the plots
x1 (Normal): bell-shaped, symmetric
x2 (t-distribution): similar to normal but with heavier tails
x3 and x5 (F and Chi-squared): typically skewed right
x4 (Beta) and x6 (Gamma): shapes depend on parameters, often skewed
(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;
})()Note: For the names and properties of probability distributions, you can search on Google or consult statistics textbooks.
Bar Chart
A bar chart uses rectangular bars to represent quantitative data. The X-axis typically displays categories or groups, while the Y-axis shows numerical values such as frequency, quantity, or percentage.
Key Features of a Bar Chart
Used to compare values across different categories
Can be vertical or horizontal
Ideal for comparing group data such as monthly sales or number of customers by branch
Grouped Bar Chart → Used to compare values across different groups side-by-side (in parallel).
Stacked Bar Chart → Used to visualize the composition or proportion of each group stacked on top of one another.
Used to display the proportion of each category within a group, in a way that makes it easier to compare the overall structure across groups.
Horizontal Bar Chart → Useful when category names are long or when horizontal orientation improves readability.
You can copy the data from this slide and paste it into Excel to create all 4 types of bar charts.
Table-format data cannot be directly used to create a bar chart in Excel.
| category | group | value |
|---|---|---|
| A | X | 10 |
| A | Y | 15 |
| B | X | 20 |
| B | Y | 25 |
| C | X | 30 |
| C | Y | 35 |
\[\rightarrow\]
You must first restructure the data into the format below before you can create a bar chart in Excel:
| X | Y | |
|---|---|---|
| A | 10 | 15 |
| B | 20 | 25 |
| C | 30 | 35 |
Line Chart
A line chart is used to display trends or changes in data 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\]
However, you cannot create a proper line chart until the data is restructured like this:
| 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 is a chart used to show the relationship between two variables, X and Y, by representing each data point as a dot on the graph.
Applying a log scale (logarithmic axis) can make it easier to observe patterns when data spans several orders of magnitude — especially in skewed distributions or exponential growth.
A Bubble Plot is an extension of a scatter plot that uses the size of the bubbles to represent an additional variable. This allows you to visualize data in three dimensions (X, Y, and bubble size), or even more.
Components of a Bubble Plot
X-axis: A quantitative (numerical) variable
Y-axis: A quantitative (numerical) variable
Bubble Size: Represents the value of a third variable (e.g., population, sales)
Bubble Color (Optional): Can be used to represent categories or groups (e.g., country, product type)
Example Use Cases for Bubble Plot
Economics: Display GDP (X) vs. Unemployment Rate (Y), with bubble size representing population.
Business: Display Sales (X) vs. Profit (Y), with bubble size representing number of customers.
Public Health: Display Life Expectancy (X) vs. Average Income (Y), with bubble size representing population.
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;
})()