Skip to content

ChenyqThu/chenyqthu.github.io

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

66 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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>

About

No description, website, or topics provided.

License

Unknown, Unknown licenses found

Licenses found

Unknown
license.html
Unknown
license-data.csv

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages