ChenyqThu/chenyqthu.github.io
Folders and files
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Repository files navigation
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Omada License 销量分析仪表板 (2024-2026)</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1800px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 32px;
margin-bottom: 10px;
font-weight: 700;
}
.header p {
font-size: 16px;
opacity: 0.9;
}
/* Filter Section */
.filter-section {
padding: 20px 30px;
background: #f8f9fa;
border-bottom: 2px solid #e9ecef;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 15px;
align-items: flex-start;
}
.filter-row:last-child {
margin-bottom: 0;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-group label {
font-size: 12px;
font-weight: 600;
color: #495057;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-checkboxes {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 8px 12px;
background: white;
border: 2px solid #dee2e6;
border-radius: 8px;
max-width: 600px;
}
.filter-checkbox-item {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
padding: 3px 6px;
border-radius: 4px;
transition: background 0.2s;
font-size: 12px;
}
.filter-checkbox-item:hover {
background: #f0f0f0;
}
.filter-checkbox-item input[type="checkbox"] {
cursor: pointer;
width: 14px;
height: 14px;
}
.filter-checkbox-item span {
font-weight: 500;
color: #495057;
}
.filter-group select {
padding: 8px 12px;
border: 2px solid #dee2e6;
border-radius: 8px;
font-size: 13px;
background: white;
cursor: pointer;
transition: all 0.3s;
font-weight: 500;
min-width: 150px;
}
.filter-group select:hover,
.filter-group select:focus {
border-color: #667eea;
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.filter-group button {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.3s;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.filter-group button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.filter-group button.secondary {
background: #6c757d;
}
.filter-group button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.quick-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.quick-filter-btn {
padding: 6px 12px;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 12px;
background: white;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.quick-filter-btn:hover {
border-color: #667eea;
background: #f8f9fa;
}
.quick-filter-btn.active {
border-color: #667eea;
background: #667eea;
color: white;
}
/* Stats Bar */
.stats-bar {
display: flex;
justify-content: space-around;
padding: 20px 30px;
background: white;
border-bottom: 1px solid #e9ecef;
flex-wrap: wrap;
gap: 15px;
}
.stat-item {
text-align: center;
flex: 1;
min-width: 140px;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: #667eea;
margin-bottom: 5px;
}
.stat-label {
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Charts */
.charts-container {
display: flex;
padding: 20px;
gap: 20px;
}
.chart-wrapper {
flex: 1;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
overflow: hidden;
}
.chart-wrapper.main {
flex: 2;
}
.chart-wrapper.pie {
flex: 1;
min-width: 380px;
}
.chart-title {
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 2px solid #e9ecef;
font-weight: 600;
color: #2c3e50;
font-size: 14px;
text-align: center;
}
#mainChart {
width: 100%;
height: 500px;
}
#pieChart {
width: 100%;
height: 500px;
}
/* Data Table */
.data-table-section {
padding: 20px;
background: white;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 15px;
}
.table-title {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
}
.table-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.table-controls button {
padding: 8px 16px;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 13px;
background: white;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.table-controls button:hover {
border-color: #667eea;
background: #f8f9fa;
}
.table-controls button.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
}
.table-controls button.primary:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
.table-controls input[type="text"] {
padding: 8px 12px;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 13px;
width: 200px;
}
.table-controls input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.table-info {
font-size: 13px;
color: #6c757d;
padding: 10px 0;
}
.table-info strong {
color: #667eea;
}
.table-container {
max-height: 500px;
overflow: auto;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table thead {
position: sticky;
top: 0;
z-index: 10;
}
.data-table th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 10px 8px;
text-align: left;
font-weight: 600;
white-space: nowrap;
border: none;
}
.data-table th:first-child {
text-align: center;
width: 40px;
}
.data-table td {
padding: 8px;
border-bottom: 1px solid #e9ecef;
white-space: nowrap;
}
.data-table td:first-child {
text-align: center;
}
.data-table tbody tr {
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: #f8f9fa;
}
.data-table tbody tr.unchecked {
background: #fff5f5;
color: #999;
text-decoration: line-through;
}
.data-table tbody tr.unchecked:hover {
background: #ffebeb;
}
.data-table input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
.data-table .number-cell {
text-align: right;
font-family: 'Courier New', monospace;
}
.region-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: white;
}
.region-badge.APAC { background: #3498db; }
.region-badge.EMEA { background: #2ecc71; }
.region-badge.Americas { background: #e74c3c; }
.type-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
background: #ecf0f1;
color: #2c3e50;
}
.channel-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
}
.channel-badge.Online { background: #d4edda; color: #155724; }
.channel-badge.Offline { background: #cce5ff; color: #004085; }
.channel-badge.Trial { background: #fff3cd; color: #856404; }
.footer {
padding: 20px 30px;
background: #f8f9fa;
text-align: center;
color: #6c757d;
font-size: 13px;
border-top: 1px solid #e9ecef;
}
@media (max-width: 1200px) {
.charts-container {
flex-direction: column;
}
.chart-wrapper.pie {
min-width: auto;
}
#mainChart,
#pieChart {
height: 400px;
}
}
@media (max-width: 768px) {
.filter-row {
flex-direction: column;
}
.stats-bar {
flex-direction: column;
gap: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Omada License 销量分析仪表板</h1>
<p>TP-Link 商用网络解决方案 | 数据范围: 2024.10 - 2026.01 | 多维度交互式数据分析</p>
</div>
<div class="filter-section">
<!-- Row 1: Time & Year Filters -->
<div class="filter-row">
<div class="filter-group">
<label>Year Filter</label>
<div class="filter-checkboxes" id="yearCheckboxes"></div>
</div>
<div class="filter-group">
<label>Time Dimension</label>
<select id="timeSelect">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div class="filter-group">
<label>Chart Type</label>
<select id="chartTypeSelect">
<option value="bar">Grouped Bar</option>
<option value="stack-bar">Stacked Bar</option>
<option value="line">Line</option>
<option value="area">Stacked Area</option>
</select>
</div>
<div class="filter-group">
<label>Group By</label>
<select id="groupBySelect">
<option value="region">Region</option>
<option value="channel">Purchase Channel</option>
<option value="licenseType">License Type</option>
<option value="licenseYear">License Year</option>
<option value="total">Total</option>
</select>
</div>
<div class="filter-group">
<label>Metric</label>
<select id="metricSelect">
<option value="license_count">License Count</option>
<option value="license_year_count">License Year Count</option>
</select>
</div>
<div class="filter-group">
<label>Export</label>
<button id="exportBtn">Export Chart</button>
</div>
</div>
<!-- Row 2: Dimension Filters -->
<div class="filter-row">
<div class="filter-group">
<label>Region Filter</label>
<div class="filter-checkboxes" id="regionCheckboxes"></div>
</div>
<div class="filter-group">
<label>Purchase Channel Filter</label>
<div class="filter-checkboxes" id="channelCheckboxes"></div>
</div>
</div>
<!-- Row 3: More Filters -->
<div class="filter-row">
<div class="filter-group">
<label>License Type Filter</label>
<div class="filter-checkboxes" id="licenseTypeCheckboxes"></div>
</div>
</div>
<!-- Row 4: Quick Filters -->
<div class="filter-row">
<div class="filter-group">
<label>Quick Analysis Presets</label>
<div class="quick-filters">
<button class="quick-filter-btn" data-preset="all">All Data</button>
<button class="quick-filter-btn" data-preset="commercial">Commercial Only</button>
<button class="quick-filter-btn" data-preset="trial">Trial Only</button>
<button class="quick-filter-btn" data-preset="standard">Standard License</button>
<button class="quick-filter-btn" data-preset="pro">Pro License</button>
<button class="quick-filter-btn" data-preset="surveillance">Surveillance</button>
</div>
</div>
<div class="filter-group">
<label>Actions</label>
<div style="display: flex; gap: 8px;">
<button id="resetFiltersBtn" class="secondary">Reset All Filters</button>
</div>
</div>
</div>
</div>
<div class="stats-bar" id="statsBar"></div>
<div class="charts-container">
<div class="chart-wrapper main">
<div class="chart-title">Sales Trend Analysis</div>
<div id="mainChart"></div>
</div>
<div class="chart-wrapper pie">
<div class="chart-title">Distribution Analysis</div>
<div id="pieChart"></div>
</div>
</div>
<!-- Data Table Section -->
<div class="data-table-section">
<div class="table-header">
<div class="table-title">Raw Data Table (Filtered)</div>
<div class="table-controls">
<input type="text" id="tableSearch" placeholder="Search...">
<button id="selectAllBtn" class="primary">Select All</button>
<button id="deselectAllBtn">Deselect All</button>
<button id="invertSelectionBtn">Invert Selection</button>
</div>
</div>
<div class="table-info" id="tableInfo"></div>
<div class="table-container">
<table class="data-table" id="dataTable">
<thead>
<tr>
<th><input type="checkbox" id="headerCheckbox" checked></th>
<th>#</th>
<th>Month</th>
<th>Region</th>
<th>Channel</th>
<th>License Type</th>
<th>Year</th>
<th>Count</th>
<th>Year Count</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<div class="footer">
<p>TP-Link Omada Business Unit | Data Range: 2024.10 - 2026.01 | Product Owner: Lucien Chen</p>
</div>
</div>
<script>
// CSV file to load - update this file to refresh data
const CSV_FILE = 'license-data.csv';
// Global variables that will be set after loading
let parsedData = [];
let selectedRows = new Set();
let allMonths = [];
let allRegions = [];
let allChannels = [];
let allLicenseTypes = [];
let allYears = [];
let allLicenseYearCategories = [];
let selectedYears = new Set();
let selectedRegions = new Set();
let selectedChannels = new Set();
let selectedLicenseTypes = new Set();
let mainChart, pieChart;
let searchFilter = '';
let currentSort = { field: 'month', order: 'desc' };
// Load CSV file
async function loadCSVData() {
try {
const response = await fetch(CSV_FILE);
if (!response.ok) {
throw new Error(`Failed to load ${CSV_FILE}: ${response.status}`);
}
return await response.text();
} catch (error) {
console.error('Error loading CSV:', error);
document.body.innerHTML = `<div style="padding: 50px; text-align: center; color: red;">
<h2>数据加载失败</h2>
<p>无法加载 ${CSV_FILE}</p>
<p>${error.message}</p>
</div>`;
throw error;
}
}
// Parse CSV
function parseCSV(csvString) {
const lines = csvString.trim().split('\n');
const headers = lines[0].split(',');
const data = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',');
const row = {};
headers.forEach((header, index) => {
row[header.trim()] = values[index] ? values[index].trim() : '';
});
// Map business_region to region for compatibility
row.region = row.business_region || row.region;
row.license_year = parseFloat(row.license_year) || 0;
row.license_count = parseFloat(row.license_count) || 0;
row.license_year_count = parseFloat(row.license_year_count) || 0;
row.month = row.month.substring(0, 7);
// Categorize license year for grouping
row.license_year_category = categorizeLicenseYear(row.license_year);
data.push(row);
}
return data;
}
// Categorize license year for display
function categorizeLicenseYear(year) {
if (year < 0.1) return 'Trial (<30d)';
if (year < 0.3) return 'Trial (~90d)';
if (year < 0.6) return 'Trial (~180d)';
if (year >= 0.9 && year < 1.5) return '1 Year';
if (year >= 2 && year < 4) return '2-3 Years';
if (year >= 4 && year < 6) return '5 Years';
if (year >= 8 && year < 10) return '9 Years';
return year.toFixed(1) + ' Year';
}
// Initialize data (will be called after loading CSV)
function initializeData(csvRawData) {
parsedData = parseCSV(csvRawData);
// Track selected rows
selectedRows = new Set(parsedData.map((_, index) => index));
// Get unique values
allMonths = [...new Set(parsedData.map(d => d.month))].sort();
allRegions = [...new Set(parsedData.map(d => d.region))].sort();
allChannels = [...new Set(parsedData.map(d => d.purchase_type))].sort();
allLicenseTypes = [...new Set(parsedData.map(d => d.license_type))].sort();
allYears = [...new Set(allMonths.map(m => m.substring(0, 4)))].sort();
allLicenseYearCategories = [...new Set(parsedData.map(d => d.license_year_category))];
// Filters
selectedYears = new Set(allYears);
selectedRegions = new Set(allRegions);
selectedChannels = new Set(allChannels);
selectedLicenseTypes = new Set(allLicenseTypes);
}
// Color schemes
const regionColors = {
'APAC': '#3498db',
'EMEA': '#2ecc71',
'Americas': '#e74c3c'
};
const channelColors = {
'Online': '#27ae60',
'Offline': '#3498db',
'Trial': '#f39c12'
};
const licenseTypeColors = {
'Standard': '#3498db',
'Pro': '#9b59b6',
'Surveillance': '#e67e22',
'Trial': '#f39c12'
};
const licenseYearColors = {
'Trial (<30d)': '#ff6b6b',
'Trial (~90d)': '#ffa502',
'Trial (~180d)': '#ffd93d',
'1 Year': '#6bcb77',
'2-3 Years': '#4d96ff',
'5 Years': '#9b59b6',
'9 Years': '#2c3e50'
};
// Generate stable color from string (for fallback)
function stringToColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e'];
return colors[Math.abs(hash) % colors.length];
}
const regionNames = {
'APAC': 'APAC',
'EMEA': 'EMEA',
'Americas': 'Americas'
};
const channelNames = {
'Online': 'Online',
'Offline': 'Offline',
'Trial': 'Trial'
};
// Initialize charts (will be called in init function)
function initializeCharts() {
mainChart = echarts.init(document.getElementById('mainChart'));
pieChart = echarts.init(document.getElementById('pieChart'));
}
// Initialize filter checkboxes
function initFilterCheckboxes() {
// Year checkboxes
const yearContainer = document.getElementById('yearCheckboxes');
allYears.forEach(year => {
const item = createCheckboxItem(year, year, true, handleYearChange);
yearContainer.appendChild(item);
});
// Region checkboxes
const regionContainer = document.getElementById('regionCheckboxes');
allRegions.forEach(region => {
const item = createCheckboxItem(region, regionNames[region] || region, true, handleRegionChange);
regionContainer.appendChild(item);
});
// Channel checkboxes
const channelContainer = document.getElementById('channelCheckboxes');
allChannels.forEach(channel => {
const item = createCheckboxItem(channel, channelNames[channel] || channel, true, handleChannelChange);
channelContainer.appendChild(item);
});
// License type checkboxes
const licenseTypeContainer = document.getElementById('licenseTypeCheckboxes');
allLicenseTypes.forEach(type => {
const item = createCheckboxItem(type, type, true, handleLicenseTypeChange);
licenseTypeContainer.appendChild(item);
});
}
function createCheckboxItem(value, label, checked, handler) {
const item = document.createElement('div');
item.className = 'filter-checkbox-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `filter-${value}`;
checkbox.value = value;
checkbox.checked = checked;
checkbox.addEventListener('change', handler);
const span = document.createElement('span');
span.textContent = label;
span.addEventListener('click', () => checkbox.click());
item.appendChild(checkbox);
item.appendChild(span);
return item;
}
function handleYearChange(e) {
updateFilterSet(e, selectedYears);
updateChartsAndTable();
}
function handleRegionChange(e) {
updateFilterSet(e, selectedRegions);
updateChartsAndTable();
}
function handleChannelChange(e) {
updateFilterSet(e, selectedChannels);
updateChartsAndTable();
}
function handleLicenseTypeChange(e) {
updateFilterSet(e, selectedLicenseTypes);
updateChartsAndTable();
}
function updateFilterSet(e, filterSet) {
const value = e.target.value;
if (e.target.checked) {
filterSet.add(value);
} else {
filterSet.delete(value);
}
}
// Filter data based on selections
function getFilteredData() {
return parsedData.filter((row, index) => {
if (!selectedRows.has(index)) return false;
const year = row.month.substring(0, 4);
return selectedYears.has(year) &&
selectedRegions.has(row.region) &&
selectedChannels.has(row.purchase_type) &&
selectedLicenseTypes.has(row.license_type);
});
}
// Get time categories
function getTimeCategories(dimension, filteredData) {
const months = [...new Set(filteredData.map(d => d.month))].sort();
if (dimension === 'monthly') {
return months;
} else if (dimension === 'quarterly') {
const quarters = new Set();
months.forEach(m => {
const year = m.substring(0, 4);
const month = parseInt(m.substring(5, 7));
const quarter = Math.ceil(month / 3);
quarters.add(`${year}Q${quarter}`);
});
return [...quarters].sort();
} else {
return [...new Set(months.map(m => m.substring(0, 4)))].sort();
}
}
// Aggregate data
function aggregateData(dimension, groupBy, metric) {
const filteredData = getFilteredData();
const categories = getTimeCategories(dimension, filteredData);
const result = {};
let groups = [];
if (groupBy === 'region') {
groups = [...selectedRegions];
} else if (groupBy === 'channel') {
groups = [...selectedChannels];
} else if (groupBy === 'licenseType') {
groups = [...selectedLicenseTypes];
} else if (groupBy === 'licenseYear') {
groups = [...new Set(filteredData.map(d => d.license_year_category))];
} else {
groups = ['Total'];
}
groups.forEach(g => {
result[g] = {};
categories.forEach(c => {
result[g][c] = 0;
});
});
filteredData.forEach(row => {
let timeKey;
if (dimension === 'monthly') {
timeKey = row.month;
} else if (dimension === 'quarterly') {
const year = row.month.substring(0, 4);
const month = parseInt(row.month.substring(5, 7));
const quarter = Math.ceil(month / 3);
timeKey = `${year}Q${quarter}`;
} else {
timeKey = row.month.substring(0, 4);
}
const value = metric === 'license_count' ? row.license_count : row.license_year_count;
let groupKey;
if (groupBy === 'region') {
groupKey = row.region;
} else if (groupBy === 'channel') {
groupKey = row.purchase_type;
} else if (groupBy === 'licenseType') {
groupKey = row.license_type;
} else if (groupBy === 'licenseYear') {
groupKey = row.license_year_category;
} else {
groupKey = 'Total';
}
if (result[groupKey] && result[groupKey][timeKey] !== undefined) {
result[groupKey][timeKey] += value;
}
});
return { categories, data: result, groups };
}
// Update stats bar
function updateStats(aggregated, groupBy) {
const statsBar = document.getElementById('statsBar');
let grandTotal = 0;
const groupTotals = {};
aggregated.groups.forEach(g => {
groupTotals[g] = 0;
aggregated.categories.forEach(c => {
groupTotals[g] += aggregated.data[g][c];
});
grandTotal += groupTotals[g];
});
let html = `
<div class="stat-item">
<div class="stat-value">${formatNumber(grandTotal)}</div>
<div class="stat-label">Total</div>
</div>
`;
const getColor = (key) => {
if (groupBy === 'region') return regionColors[key];
if (groupBy === 'channel') return channelColors[key];
if (groupBy === 'licenseType') return licenseTypeColors[key];
return '#667eea';
};
const getName = (key) => {
if (groupBy === 'region') return regionNames[key] || key;
if (groupBy === 'channel') return channelNames[key] || key;
return key;
};
const topItems = Object.entries(groupTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
topItems.forEach(([key, total]) => {
const percentage = grandTotal > 0 ? ((total / grandTotal) * 100).toFixed(1) : 0;
html += `
<div class="stat-item">
<div class="stat-value" style="color: ${getColor(key) || '#667eea'}">${formatNumber(total)}</div>
<div class="stat-label">${getName(key)} (${percentage}%)</div>
</div>
`;
});
statsBar.innerHTML = html;
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return Math.round(num).toLocaleString();
}
// Update pie chart - inherit colors from main chart
function updatePieChart(aggregated, groupBy, timeIndex) {
const timeLabel = aggregated.categories[timeIndex];
const pieData = [];
// Get color map from main chart's series
const mainOption = mainChart.getOption();
const colorMap = {};
if (mainOption && mainOption.series) {
mainOption.series.forEach(s => {
if (s.name && s.itemStyle && s.itemStyle.color) {
colorMap[s.name] = s.itemStyle.color;
}
});
}
const getName = (key) => {
if (groupBy === 'region') return regionNames[key] || key;
if (groupBy === 'channel') return channelNames[key] || key;
return key;
};
aggregated.groups.forEach(g => {
const value = aggregated.data[g][timeLabel];
if (value > 0) {
const name = getName(g);
pieData.push({
name: name,
value: Math.round(value),
itemStyle: { color: colorMap[name] || stringToColor(g) }
});
}
});
const total = pieData.reduce((sum, item) => sum + item.value, 0);
const pieOption = {
title: {
text: timeLabel,
subtext: `Total: ${formatNumber(total)}`,
left: 'center',
top: 10,
textStyle: { fontSize: 16, fontWeight: 'bold', color: '#2c3e50' },
subtextStyle: { fontSize: 13, color: '#7f8c8d' }
},
tooltip: {
trigger: 'item',
formatter: (params) => {
const percentage = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0;
return `<strong>${params.name}</strong><br/>
Count: <strong>${params.value.toLocaleString()}</strong><br/>
Share: <strong>${percentage}%</strong>`;
}
},
legend: { orient: 'horizontal', bottom: 10, textStyle: { fontSize: 10 } },
series: [{
type: 'pie',
radius: ['35%', '60%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: {
show: true,
formatter: (params) => {
const percentage = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0;
return `${percentage}%`;
},
fontSize: 11
},
emphasis: {
label: { show: true, fontSize: 13, fontWeight: 'bold' },
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
},
data: pieData
}]
};
pieChart.setOption(pieOption, true);
}
// Render main chart
function renderMainChart() {
const timeDimension = document.getElementById('timeSelect').value;
const chartType = document.getElementById('chartTypeSelect').value;
const groupBy = document.getElementById('groupBySelect').value;
const metric = document.getElementById('metricSelect').value;
const aggregated = aggregateData(timeDimension, groupBy, metric);
if (aggregated.categories.length === 0) {
mainChart.clear();
mainChart.setOption({
title: {
text: 'No data with current filters',
left: 'center',
top: 'middle',
textStyle: { fontSize: 16, color: '#95a5a6' }
}
});
return;
}
const series = [];
const legendData = [];
const getColor = (key) => {
if (groupBy === 'region') return regionColors[key];
if (groupBy === 'channel') return channelColors[key];
if (groupBy === 'licenseType') return licenseTypeColors[key];
if (groupBy === 'licenseYear') return licenseYearColors[key];
return null;
};
const getName = (key) => {
if (groupBy === 'region') return regionNames[key] || key;
if (groupBy === 'channel') return channelNames[key] || key;
return key;
};
aggregated.groups.forEach((g, idx) => {
const seriesData = aggregated.categories.map(c => Math.round(aggregated.data[g][c]));
const name = getName(g);
const color = getColor(g) || stringToColor(g);
const config = {
name: name,
type: chartType.includes('line') || chartType.includes('area') ? 'line' : 'bar',
data: seriesData,
itemStyle: { color: color },
label: {
show: timeDimension === 'yearly',
position: 'top',
formatter: (params) => formatNumber(params.value)
}
};
if (chartType === 'stack-bar' || chartType === 'area') config.stack = 'total';
if (chartType === 'area') { config.areaStyle = { opacity: 0.7 }; config.smooth = true; }
if (chartType.includes('line')) { config.smooth = true; config.symbol = 'circle'; config.symbolSize = 5; }
series.push(config);
legendData.push(name);
});
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params) => {
let result = `<strong>${params[0].axisValue}</strong><br/>`;
let total = 0;
params.forEach(item => {
result += `${item.marker} ${item.seriesName}: <strong>${item.value.toLocaleString()}</strong><br/>`;
total += item.value;
});
if (groupBy !== 'total' && params.length > 1) {
result += `<hr style="margin: 5px 0"/>Total: <strong>${total.toLocaleString()}</strong>`;
}
return result;
}
},
legend: { data: legendData, top: 10, textStyle: { fontSize: 11 } },
grid: {
left: '3%',
right: '4%',
bottom: timeDimension === 'monthly' ? '18%' : '10%',
top: '15%',
containLabel: true
},
toolbox: {
feature: {
dataZoom: { yAxisIndex: 'none' },
restore: {},
saveAsImage: { name: `Omada_License_${new Date().getTime()}` }
},
right: 20,
top: 10
},
dataZoom: [{
type: 'slider',
show: timeDimension === 'monthly' && aggregated.categories.length > 12,
xAxisIndex: [0],
start: 0,
end: 100,
bottom: 30
}],
xAxis: {
type: 'category',
data: aggregated.categories,
axisLabel: { rotate: timeDimension === 'monthly' ? 45 : 0, fontSize: 11 },
axisLine: { lineStyle: { color: '#95a5a6' } }
},
yAxis: {
type: 'value',
name: metric === 'license_count' ? 'License Count' : 'License Year Count',
nameTextStyle: { fontSize: 11, fontWeight: 'bold' },
axisLabel: { formatter: (value) => formatNumber(value) },
splitLine: { lineStyle: { type: 'dashed', opacity: 0.3 } }
},
series: series
};
mainChart.setOption(option, true);
updateStats(aggregated, groupBy);
if (groupBy !== 'total' && aggregated.categories.length > 0) {
updatePieChart(aggregated, groupBy, aggregated.categories.length - 1);
} else {
pieChart.clear();
pieChart.setOption({
title: {
text: 'Select a grouping for distribution',
left: 'center',
top: 'middle',
textStyle: { fontSize: 14, color: '#95a5a6' }
}
});
}
}
// Mouse hover for pie sync (will be called after charts are initialized)
function setupChartEvents() {
mainChart.on('mouseover', function(params) {
const groupBy = document.getElementById('groupBySelect').value;
const timeDimension = document.getElementById('timeSelect').value;
const metric = document.getElementById('metricSelect').value;
if (groupBy !== 'total' && params.componentType === 'series') {
const aggregated = aggregateData(timeDimension, groupBy, metric);
if (params.dataIndex < aggregated.categories.length) {
updatePieChart(aggregated, groupBy, params.dataIndex);
}
}
});
}
// Quick filter presets
function applyPreset(preset) {
// Reset all filters first
selectedRegions = new Set(allRegions);
selectedChannels = new Set(allChannels);
selectedLicenseTypes = new Set(allLicenseTypes);
switch(preset) {
case 'commercial':
selectedChannels = new Set(['Online', 'Offline']);
break;
case 'trial':
selectedChannels = new Set(['Trial']);
break;
case 'standard':
selectedLicenseTypes = new Set(['Standard']);
break;
case 'pro':
selectedLicenseTypes = new Set(['Pro']);
break;
case 'surveillance':
selectedLicenseTypes = new Set(['Surveillance']);
break;
}
// Update checkboxes
updateCheckboxStates();
updateChartsAndTable();
// Update active button state
document.querySelectorAll('.quick-filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.preset === preset);
});
}
function updateCheckboxStates() {
// Update region checkboxes
document.querySelectorAll('#regionCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = selectedRegions.has(cb.value);
});
// Update channel checkboxes
document.querySelectorAll('#channelCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = selectedChannels.has(cb.value);
});
// Update license type checkboxes
document.querySelectorAll('#licenseTypeCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = selectedLicenseTypes.has(cb.value);
});
}
function resetAllFilters() {
selectedYears = new Set(allYears);
selectedRegions = new Set(allRegions);
selectedChannels = new Set(allChannels);
selectedLicenseTypes = new Set(allLicenseTypes);
selectedRows = new Set(parsedData.map((_, i) => i));
// Update all checkboxes
document.querySelectorAll('.filter-checkboxes input[type="checkbox"]').forEach(cb => {
cb.checked = true;
});
document.querySelectorAll('.quick-filter-btn').forEach(btn => {
btn.classList.remove('active');
});
updateChartsAndTable();
}
function updateChartsAndTable() {
renderMainChart();
renderDataTable();
}
// Data Table
function getTableFilteredIndices() {
const filteredByFilters = parsedData.map((row, i) => {
const year = row.month.substring(0, 4);
const passesFilters = selectedYears.has(year) &&
selectedRegions.has(row.region) &&
selectedChannels.has(row.purchase_type) &&
selectedLicenseTypes.has(row.license_type);
return passesFilters ? i : -1;
}).filter(i => i !== -1);
if (!searchFilter) return filteredByFilters;
const lowerSearch = searchFilter.toLowerCase();
return filteredByFilters.filter(i => {
const row = parsedData[i];
const searchStr = `${row.month} ${row.region} ${row.purchase_type} ${row.license_type} ${row.license_year}`.toLowerCase();
return searchStr.includes(lowerSearch);
});
}
function renderDataTable() {
const tbody = document.getElementById('tableBody');
const filteredIndices = getTableFilteredIndices();
let html = '';
filteredIndices.forEach((dataIndex, displayIndex) => {
const row = parsedData[dataIndex];
const isChecked = selectedRows.has(dataIndex);
const rowClass = isChecked ? '' : 'unchecked';
html += `
<tr class="${rowClass}" data-index="${dataIndex}">
<td><input type="checkbox" class="row-checkbox" data-index="${dataIndex}" ${isChecked ? 'checked' : ''}></td>
<td>${displayIndex + 1}</td>
<td>${row.month}</td>
<td><span class="region-badge ${row.region}">${regionNames[row.region] || row.region}</span></td>
<td><span class="channel-badge ${row.purchase_type}">${channelNames[row.purchase_type] || row.purchase_type}</span></td>
<td><span class="type-badge">${row.license_type}</span></td>
<td class="number-cell">${row.license_year.toFixed(2)}</td>
<td class="number-cell">${Math.round(row.license_count).toLocaleString()}</td>
<td class="number-cell">${Math.round(row.license_year_count).toLocaleString()}</td>
</tr>
`;
});
tbody.innerHTML = html;
updateTableInfo();
updateHeaderCheckbox();
}
function updateTableInfo() {
const filteredIndices = getTableFilteredIndices();
const selectedInFiltered = filteredIndices.filter(i => selectedRows.has(i)).length;
let totalLicenseCount = 0;
let totalLicenseYearCount = 0;
filteredIndices.forEach(index => {
if (selectedRows.has(index)) {
totalLicenseCount += parsedData[index].license_count;
totalLicenseYearCount += parsedData[index].license_year_count;
}
});
const infoHtml = `
Showing: <strong>${filteredIndices.length}</strong> rows |
Selected: <strong>${selectedInFiltered}</strong> |
License Count: <strong>${formatNumber(totalLicenseCount)}</strong> |
Year Count: <strong>${formatNumber(totalLicenseYearCount)}</strong>
`;
document.getElementById('tableInfo').innerHTML = infoHtml;
}
function updateHeaderCheckbox() {
const filteredIndices = getTableFilteredIndices();
const allSelected = filteredIndices.every(i => selectedRows.has(i));
const someSelected = filteredIndices.some(i => selectedRows.has(i));
const headerCheckbox = document.getElementById('headerCheckbox');
headerCheckbox.checked = allSelected;
headerCheckbox.indeterminate = someSelected && !allSelected;
}
function handleRowCheckboxChange(e) {
const index = parseInt(e.target.dataset.index);
const row = e.target.closest('tr');
if (e.target.checked) {
selectedRows.add(index);
row.classList.remove('unchecked');
} else {
selectedRows.delete(index);
row.classList.add('unchecked');
}
updateTableInfo();
updateHeaderCheckbox();
renderMainChart();
}
function handleHeaderCheckboxChange(e) {
const filteredIndices = getTableFilteredIndices();
if (e.target.checked) {
filteredIndices.forEach(i => selectedRows.add(i));
} else {
filteredIndices.forEach(i => selectedRows.delete(i));
}
renderDataTable();
renderMainChart();
}
function selectAll() {
const filteredIndices = getTableFilteredIndices();
filteredIndices.forEach(i => selectedRows.add(i));
renderDataTable();
renderMainChart();
}
function deselectAll() {
const filteredIndices = getTableFilteredIndices();
filteredIndices.forEach(i => selectedRows.delete(i));
renderDataTable();
renderMainChart();
}
function invertSelection() {
const filteredIndices = getTableFilteredIndices();
filteredIndices.forEach(i => {
if (selectedRows.has(i)) {
selectedRows.delete(i);
} else {
selectedRows.add(i);
}
});
renderDataTable();
renderMainChart();
}
// Event listeners
document.getElementById('timeSelect').addEventListener('change', renderMainChart);
document.getElementById('chartTypeSelect').addEventListener('change', renderMainChart);
document.getElementById('groupBySelect').addEventListener('change', renderMainChart);
document.getElementById('metricSelect').addEventListener('change', renderMainChart);
document.getElementById('exportBtn').addEventListener('click', function() {
const url = mainChart.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
const link = document.createElement('a');
link.download = `Omada_License_${new Date().getTime()}.png`;
link.href = url;
link.click();
});
document.getElementById('resetFiltersBtn').addEventListener('click', resetAllFilters);
document.querySelectorAll('.quick-filter-btn').forEach(btn => {
btn.addEventListener('click', () => applyPreset(btn.dataset.preset));
});
document.getElementById('tableBody').addEventListener('change', function(e) {
if (e.target.classList.contains('row-checkbox')) {
handleRowCheckboxChange(e);
}
});
document.getElementById('headerCheckbox').addEventListener('change', handleHeaderCheckboxChange);
document.getElementById('selectAllBtn').addEventListener('click', selectAll);
document.getElementById('deselectAllBtn').addEventListener('click', deselectAll);
document.getElementById('invertSelectionBtn').addEventListener('click', invertSelection);
document.getElementById('tableSearch').addEventListener('input', (e) => {
searchFilter = e.target.value;
renderDataTable();
});
window.addEventListener('resize', function() {
mainChart.resize();
pieChart.resize();
});
// Main initialization
async function init() {
try {
// Show loading state
document.querySelector('.container').style.opacity = '0.5';
// Load CSV data
const csvRawData = await loadCSVData();
// Initialize data from CSV
initializeData(csvRawData);
// Initialize charts
initializeCharts();
// Setup chart events
setupChartEvents();
// Initialize UI
initFilterCheckboxes();
renderDataTable();
renderMainChart();
// Restore opacity
document.querySelector('.container').style.opacity = '1';
} catch (error) {
console.error('Initialization failed:', error);
}
}
// Start the application
init();
</script>
</body>
</html>