// --- GLOBAL PALETTE LOGIC ---
let globalPalette = {};
let globalLabels = [];
function collectAllLabels() {
// Donut ve line chartlarda geçen tüm benzersiz isimleri topla
const donutLabels = Array.from(document.querySelectorAll('.donutWrapper'))
.flatMap(wrapper => JSON.parse(wrapper.getAttribute('data-labels') || '[]'));
const lineLabels = Array.from(document.querySelectorAll('.lineWrapper'))
.flatMap(wrapper => {
const series = JSON.parse(wrapper.getAttribute('data-series') || '[]');
return series.map(s => s.name);
});
// Sıralı ve benzersiz
globalLabels = Array.from(new Set([...donutLabels, ...lineLabels]));
}
// Rastgele renk üreten fonksiyon (label sayısına göre)
function generatePalette(labels) {
const colors = [];
const goldenRatio = 0.618033988749895;
let h = 0.13; // sabit başlat, her yüklemede aynı renkler için
for (let i = 0; i < labels.length; i++) {
h += goldenRatio;
h %= 1;
const color = `hsl(${Math.floor(h * 360)}, 65%, 55%)`;
colors.push(color);
}
const palette = {};
labels.forEach((label, i) => { palette[label] = colors[i]; });
return palette;
}
// Chart instance map for resizing
const chartInstances = new WeakMap();
// Helper: Remove all children of an element
function clearElement(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
// Donut Chart Renderer
function renderDonutChart(wrapper) {
clearElement(wrapper);
const title = wrapper.getAttribute('data-title');
const labels = JSON.parse(wrapper.getAttribute('data-labels'));
const values = JSON.parse(wrapper.getAttribute('data-values'));
const total = values.reduce((a,b) => a+b, 0);
const percentages = values.map(v => total ? (v/total*100) : 0);
// Global palette kullan
const palette = globalPalette;
// Title
const titleEl = document.createElement('h3');
titleEl.textContent = title;
titleEl.style.textAlign = 'center';
titleEl.style.margin = '0 0 10px 0';
titleEl.style.color = '#152935';
wrapper.appendChild(titleEl);
// Responsive size (centered vertically)
const size = Math.min(wrapper.offsetWidth || 320, 320);
// Center canvas vertically using a flex container
const flexCenter = document.createElement('div');
flexCenter.style.display = 'flex';
flexCenter.style.flexDirection = 'column';
flexCenter.style.justifyContent = 'center';
flexCenter.style.alignItems = 'center';
flexCenter.style.height = '100%';
flexCenter.style.flex = '1 1 auto';
// Canvas
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
flexCenter.appendChild(canvas);
wrapper.appendChild(flexCenter);
// Chart.js Donut
const chart = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: labels.map(label => palette[label]),
borderWidth: 2,
borderColor: "#fff"
}]
},
options: {
cutout: '65%',
plugins: {
legend: { display: false }
},
responsive: false,
maintainAspectRatio: false
}
});
chartInstances.set(wrapper, chart);
}
// Line Chart Renderer
function renderLineChart(wrapper) {
clearElement(wrapper);
const title = wrapper.getAttribute('data-title');
const labels = JSON.parse(wrapper.getAttribute('data-labels'));
const series = JSON.parse(wrapper.getAttribute('data-series'));
// Global palette kullan
const palette = globalPalette;
// Set specific colors for titles based on chart type
let titleColor = '#152935';
let lineColor = '#152935';
let lineStyle = [];
if (title === 'Media Signal Volume') {
lineColor = '#1B57D1';
// Dotted line for Media Signal Volume
lineStyle = [5, 5];
} else if (title === 'Commercial Signal Count') {
lineColor = '#FF832B';
// Solid line for Commercial Signal Count (default)
lineStyle = [];
}
// Title
const titleEl = document.createElement('h3');
titleEl.textContent = title;
titleEl.style.textAlign = 'center';
titleEl.style.margin = '0 0 10px 0';
titleEl.style.color = titleColor;
wrapper.appendChild(titleEl);
// Scrollable container for chart and legend
const scrollContainer = document.createElement('div');
// scrollContainer.style.overflowX = 'auto';
scrollContainer.style.maxWidth = '100%';
wrapper.appendChild(scrollContainer);
// Chart and legend container (flex)
const container = document.createElement('div');
container.style.display = 'flex';
container.style.alignItems = 'flex-start';
container.style.gap = '24px';
scrollContainer.appendChild(container);
// Store original data for filtering
const originalLabels = [...labels];
const originalSeries = series.map(s => ({...s, data: [...s.data]}));
let filteredLabels = [...labels];
let filteredSeries = series.map(s => ({...s, data: [...s.data]}));
// Line chart dimensions
const parentDiv = wrapper.parentElement;
const parentWidth = parentDiv ? parentDiv.offsetWidth : 1000;
const chartWidth = '100%';
const chartHeight = 320;
// Chart wrapper with relative positioning for zoom sliders
const chartWrapper = document.createElement('div');
chartWrapper.style.position = 'relative';
chartWrapper.style.flex = '1';
chartWrapper.style.minWidth = '0';
// Zoom containers will be positioned after chart is created
// We'll use Chart.js layout callback to get accurate positions
let xZoomContainer, yZoomContainer;
// Function to create zoom controls
function createZoomControls() {
// Add track styling if not already added
if (!document.querySelector('style[data-chart-sliders]')) {
const style = document.createElement('style');
style.setAttribute('data-chart-sliders', 'true');
style.textContent = `
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #1B57D1;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
margin-top: -6px;
position: relative;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #1B57D1;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
position: relative;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
border-radius: 2px;
}
input[type="range"]::-moz-range-track {
height: 4px;
background: transparent;
border-radius: 2px;
}
`;
document.head.appendChild(style);
}
// Get chart layout to position zoom controls accurately
const chartArea = chart.chartArea;
if (!chartArea) return;
// Get scales to calculate padding for axis labels
const xScale = chart.scales.x;
const yScale = chart.scales.y;
// X axis labels are rotated 90 degrees, so they need space below
// Estimate space needed for rotated x-axis labels (typically 60-80px)
const xAxisLabelHeight = 70; // Space for rotated x-axis labels
// Y axis labels are on the left, estimate width needed (typically 50-60px)
const yAxisLabelWidth = 55; // Space for y-axis labels on left
// Calculate center position for X zoom to align with x-axis
// X-axis is centered at chartArea, so we need to center the zoom slider accordingly
const xAxisCenterY = chartArea.bottom + (xAxisLabelHeight / 2); // Center of x-axis labels area
// X-axis zoom slider container - positioned BELOW x-axis labels (which are rotated 90deg)
// X axis labels are rotated 90 degrees and positioned below chartArea.bottom
// We need to place zoom axis below the labels, centered vertically
xZoomContainer = document.createElement('div');
xZoomContainer.style.position = 'absolute';
xZoomContainer.style.top = `${chartArea.bottom + xAxisLabelHeight}px`; // Below x-axis labels (rotated 90deg)
xZoomContainer.style.left = `${chartArea.left}px`; // Align with chart area start
xZoomContainer.style.width = `${chartArea.right - chartArea.left}px`;
xZoomContainer.style.height = '20px';
xZoomContainer.style.display = 'flex';
xZoomContainer.style.alignItems = 'center';
xZoomContainer.style.justifyContent = 'flex-start';
xZoomContainer.style.padding = '0 8px';
xZoomContainer.style.zIndex = '10';
xZoomContainer.style.pointerEvents = 'none'; // Don't block chart interactions
const xZoomLabel = document.createElement('span');
xZoomLabel.textContent = 'X Zoom:';
xZoomLabel.style.fontSize = '11px';
xZoomLabel.style.color = '#152935';
xZoomLabel.style.marginRight = '6px';
xZoomLabel.style.whiteSpace = 'nowrap';
xZoomLabel.style.pointerEvents = 'none';
const xZoomSliderWrapper = document.createElement('div');
xZoomSliderWrapper.style.position = 'relative';
xZoomSliderWrapper.style.flex = '1';
xZoomSliderWrapper.style.height = '20px';
xZoomSliderWrapper.style.display = 'flex';
xZoomSliderWrapper.style.alignItems = 'center';
xZoomSliderWrapper.style.pointerEvents = 'auto'; // Allow slider interaction
const xZoomSliderMin = document.createElement('input');
xZoomSliderMin.type = 'range';
xZoomSliderMin.min = '0';
xZoomSliderMin.max = '100';
xZoomSliderMin.value = '0';
xZoomSliderMin.style.position = 'absolute';
xZoomSliderMin.style.width = '100%';
xZoomSliderMin.style.height = '20px'; // Increased height for better hit area
xZoomSliderMin.style.cursor = 'pointer';
xZoomSliderMin.style.zIndex = '2';
xZoomSliderMin.style.pointerEvents = 'auto';
xZoomSliderMin.style.margin = '0';
xZoomSliderMin.style.top = '50%';
xZoomSliderMin.style.transform = 'translateY(-50%)';
xZoomSliderMin.style.background = 'transparent'; // Make track area transparent
const xZoomSliderMax = document.createElement('input');
xZoomSliderMax.type = 'range';
xZoomSliderMax.min = '0';
xZoomSliderMax.max = '100';
xZoomSliderMax.value = '100';
xZoomSliderMax.style.position = 'absolute';
xZoomSliderMax.style.width = '100%';
xZoomSliderMax.style.height = '20px'; // Increased height for better hit area
xZoomSliderMax.style.cursor = 'pointer';
xZoomSliderMax.style.zIndex = '3';
xZoomSliderMax.style.pointerEvents = 'auto';
xZoomSliderMax.style.margin = '0';
xZoomSliderMax.style.top = '50%';
xZoomSliderMax.style.transform = 'translateY(-50%)';
xZoomSliderMax.style.background = 'transparent'; // Make track area transparent
[xZoomSliderMin, xZoomSliderMax].forEach(slider => {
slider.style.webkitAppearance = 'none';
slider.style.appearance = 'none';
slider.style.background = 'transparent';
slider.style.outline = 'none';
slider.style.cursor = 'pointer';
});
// Make both sliders always interactive - use mouse position to determine active slider
let xSliderActive = null;
xZoomSliderWrapper.addEventListener('mousemove', function(e) {
const rect = this.getBoundingClientRect();
const x = e.clientX - rect.left;
const minPercent = parseFloat(xZoomSliderMin.value);
const maxPercent = parseFloat(xZoomSliderMax.value);
const sliderWidth = rect.width;
const minPos = (minPercent / 100) * sliderWidth;
const maxPos = (maxPercent / 100) * sliderWidth;
// Determine which slider is closer to mouse position
const distToMin = Math.abs(x - minPos);
const distToMax = Math.abs(x - maxPos);
if (distToMin < distToMax && distToMin < 30) {
xSliderActive = 'min';
xZoomSliderMin.style.zIndex = '5';
xZoomSliderMax.style.zIndex = '3';
} else if (distToMax < 30) {
xSliderActive = 'max';
xZoomSliderMax.style.zIndex = '5';
xZoomSliderMin.style.zIndex = '2';
}
});
xZoomSliderMin.addEventListener('mousedown', function(e) {
e.stopPropagation();
xSliderActive = 'min';
xZoomSliderMin.style.zIndex = '5';
xZoomSliderMax.style.zIndex = '3';
});
xZoomSliderMax.addEventListener('mousedown', function(e) {
e.stopPropagation();
xSliderActive = 'max';
xZoomSliderMax.style.zIndex = '5';
xZoomSliderMin.style.zIndex = '2';
});
// Reset z-index when mouse is released
const resetXZIndex = function() {
xZoomSliderMin.style.zIndex = '2';
xZoomSliderMax.style.zIndex = '3';
xSliderActive = null;
};
document.addEventListener('mouseup', resetXZIndex);
xZoomSliderWrapper.addEventListener('mouseleave', resetXZIndex);
xZoomSliderWrapper.appendChild(xZoomSliderMin);
xZoomSliderWrapper.appendChild(xZoomSliderMax);
xZoomContainer.appendChild(xZoomLabel);
xZoomContainer.appendChild(xZoomSliderWrapper);
chartWrapper.appendChild(xZoomContainer);
// Calculate center position for Y zoom to align with y-axis
// Y-axis is centered at chartArea.left, so we need to center the zoom slider accordingly
const yAxisCenterX = chartArea.left - (yAxisLabelWidth / 2); // Center of y-axis labels area
// Y-axis zoom slider container - positioned to the LEFT of y-axis labels, centered
yZoomContainer = document.createElement('div');
yZoomContainer.style.position = 'absolute';
yZoomContainer.style.left = `${chartArea.left - yAxisLabelWidth - 20}px`; // Left of y-axis labels
yZoomContainer.style.top = `${chartArea.top}px`; // Align with top of chart area
yZoomContainer.style.width = '20px';
yZoomContainer.style.height = `${chartArea.bottom - chartArea.top}px`;
yZoomContainer.style.display = 'flex';
yZoomContainer.style.flexDirection = 'column';
yZoomContainer.style.alignItems = 'center';
yZoomContainer.style.justifyContent = 'center';
yZoomContainer.style.padding = '0';
yZoomContainer.style.zIndex = '10';
yZoomContainer.style.pointerEvents = 'none'; // Don't block chart interactions
const yZoomLabel = document.createElement('span');
yZoomLabel.textContent = 'Y Zoom:';
yZoomLabel.style.fontSize = '11px';
yZoomLabel.style.color = '#152935';
yZoomLabel.style.marginBottom = '6px';
yZoomLabel.style.writingMode = 'vertical-rl';
yZoomLabel.style.textOrientation = 'mixed';
yZoomLabel.style.whiteSpace = 'nowrap';
yZoomLabel.style.pointerEvents = 'none';
const yZoomSliderWrapper = document.createElement('div');
yZoomSliderWrapper.style.position = 'relative';
yZoomSliderWrapper.style.width = `${chartArea.bottom - chartArea.top}px`;
yZoomSliderWrapper.style.height = '20px';
yZoomSliderWrapper.style.transform = 'rotate(-90deg)';
yZoomSliderWrapper.style.transformOrigin = 'center';
yZoomSliderWrapper.style.display = 'flex';
yZoomSliderWrapper.style.alignItems = 'center';
yZoomSliderWrapper.style.pointerEvents = 'auto'; // Allow slider interaction
const yZoomSliderMin = document.createElement('input');
yZoomSliderMin.type = 'range';
yZoomSliderMin.min = '0';
yZoomSliderMin.max = '100';
yZoomSliderMin.value = '0';
yZoomSliderMin.style.position = 'absolute';
yZoomSliderMin.style.width = `${chartArea.bottom - chartArea.top}px`;
yZoomSliderMin.style.height = '20px'; // Increased height for better hit area
yZoomSliderMin.style.cursor = 'pointer';
yZoomSliderMin.style.zIndex = '2';
yZoomSliderMin.style.pointerEvents = 'auto';
yZoomSliderMin.style.margin = '0';
yZoomSliderMin.style.top = '0';
yZoomSliderMin.style.left = '0';
yZoomSliderMin.style.background = 'transparent'; // Make track area transparent
const yZoomSliderMax = document.createElement('input');
yZoomSliderMax.type = 'range';
yZoomSliderMax.min = '0';
yZoomSliderMax.max = '100';
yZoomSliderMax.value = '100';
yZoomSliderMax.style.position = 'absolute';
yZoomSliderMax.style.width = `${chartArea.bottom - chartArea.top}px`;
yZoomSliderMax.style.height = '20px'; // Increased height for better hit area
yZoomSliderMax.style.cursor = 'pointer';
yZoomSliderMax.style.zIndex = '3';
yZoomSliderMax.style.pointerEvents = 'auto';
yZoomSliderMax.style.margin = '0';
yZoomSliderMax.style.top = '0';
yZoomSliderMax.style.left = '0';
yZoomSliderMax.style.background = 'transparent'; // Make track area transparent
[yZoomSliderMin, yZoomSliderMax].forEach(slider => {
slider.style.webkitAppearance = 'none';
slider.style.appearance = 'none';
slider.style.background = 'transparent';
slider.style.outline = 'none';
slider.style.cursor = 'pointer';
});
// Make both sliders always interactive - use mouse position to determine active slider
// Note: Y slider is rotated -90deg, so we need to account for that
let ySliderActive = null;
yZoomSliderWrapper.addEventListener('mousemove', function(e) {
const rect = this.getBoundingClientRect();
// Since the slider is rotated -90deg, we need to calculate relative to the rotated coordinate system
// The slider runs vertically, so we use clientY instead of clientX
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const mouseX = e.clientX - centerX;
const mouseY = e.clientY - centerY;
// Rotate back to get the position along the slider (which is now vertical)
const sliderPos = mouseY; // After rotation, Y becomes the slider position
const sliderHeight = rect.height;
const minPercent = parseFloat(yZoomSliderMin.value);
const maxPercent = parseFloat(yZoomSliderMax.value);
const minPos = (minPercent / 100) * sliderHeight - sliderHeight / 2;
const maxPos = (maxPercent / 100) * sliderHeight - sliderHeight / 2;
// Determine which slider is closer
const distToMin = Math.abs(sliderPos - minPos);
const distToMax = Math.abs(sliderPos - maxPos);
if (distToMin < distToMax && distToMin < 30) {
ySliderActive = 'min';
yZoomSliderMin.style.zIndex = '5';
yZoomSliderMax.style.zIndex = '3';
} else if (distToMax < 30) {
ySliderActive = 'max';
yZoomSliderMax.style.zIndex = '5';
yZoomSliderMin.style.zIndex = '2';
}
});
yZoomSliderMin.addEventListener('mousedown', function(e) {
e.stopPropagation();
ySliderActive = 'min';
yZoomSliderMin.style.zIndex = '5';
yZoomSliderMax.style.zIndex = '3';
});
yZoomSliderMax.addEventListener('mousedown', function(e) {
e.stopPropagation();
ySliderActive = 'max';
yZoomSliderMax.style.zIndex = '5';
yZoomSliderMin.style.zIndex = '2';
});
// Reset z-index when mouse is released
const resetYZIndex = function() {
yZoomSliderMin.style.zIndex = '2';
yZoomSliderMax.style.zIndex = '3';
ySliderActive = null;
};
document.addEventListener('mouseup', resetYZIndex);
yZoomSliderWrapper.addEventListener('mouseleave', resetYZIndex);
yZoomSliderWrapper.appendChild(yZoomSliderMin);
yZoomSliderWrapper.appendChild(yZoomSliderMax);
yZoomContainer.appendChild(yZoomLabel);
yZoomContainer.appendChild(yZoomSliderWrapper);
chartWrapper.appendChild(yZoomContainer);
// Store slider references - use the variables we just created
// These are the actual slider elements we created above
const xSliderMinEl = xZoomContainer.querySelector('input[type="range"]:first-of-type');
const xSliderMaxEl = xZoomContainer.querySelector('input[type="range"]:last-of-type');
const ySliderMinEl = yZoomContainer.querySelector('input[type="range"]:first-of-type');
const ySliderMaxEl = yZoomContainer.querySelector('input[type="range"]:last-of-type');
// Attach event listeners - these will work because updateXZoom and updateYZoom are defined later
// We'll use closure to capture the slider references
if (xSliderMinEl) {
xSliderMinEl.addEventListener('input', function() {
if (!chart || !filteredLabels || filteredLabels.length === 0) return;
let minPercent = parseFloat(this.value);
let maxPercent = parseFloat(xSliderMaxEl.value);
// Prevent crossing - ensure min <= max
if (minPercent > maxPercent) {
xSliderMaxEl.value = minPercent;
maxPercent = minPercent;
}
// If both are at extremes (0 and 100), show full chart
if (minPercent === 0 && maxPercent === 100) {
chart.data.labels = [...filteredLabels];
chart.data.datasets.forEach((ds, i) => {
ds.data = [...filteredSeries[i].data];
});
} else {
const startPercent = minPercent / 100;
const endPercent = maxPercent / 100;
let startIndex = Math.floor(filteredLabels.length * startPercent);
let endIndex = Math.ceil(filteredLabels.length * endPercent);
// If min and max are the same (end to end), show at least one data point
if (startIndex >= endIndex) {
endIndex = Math.min(filteredLabels.length, startIndex + 1);
}
// Ensure indices are within bounds
startIndex = Math.max(0, Math.min(startIndex, filteredLabels.length - 1));
endIndex = Math.max(startIndex + 1, Math.min(endIndex, filteredLabels.length));
chart.data.labels = filteredLabels.slice(startIndex, endIndex);
chart.data.datasets.forEach((ds, i) => {
ds.data = filteredSeries[i].data.slice(startIndex, endIndex);
});
}
chart.update('none');
});
}
if (xSliderMaxEl) {
xSliderMaxEl.addEventListener('input', function() {
if (!chart || !filteredLabels || filteredLabels.length === 0) return;
let minPercent = parseFloat(xSliderMinEl.value);
let maxPercent = parseFloat(this.value);
// Prevent crossing - ensure min <= max
if (minPercent > maxPercent) {
xSliderMinEl.value = maxPercent;
minPercent = maxPercent;
}
// If both are at extremes (0 and 100), show full chart
if (minPercent === 0 && maxPercent === 100) {
chart.data.labels = [...filteredLabels];
chart.data.datasets.forEach((ds, i) => {
ds.data = [...filteredSeries[i].data];
});
} else {
const startPercent = minPercent / 100;
const endPercent = maxPercent / 100;
let startIndex = Math.floor(filteredLabels.length * startPercent);
let endIndex = Math.ceil(filteredLabels.length * endPercent);
// If min and max are the same (end to end), show at least one data point
if (startIndex >= endIndex) {
endIndex = Math.min(filteredLabels.length, startIndex + 1);
}
// Ensure indices are within bounds
startIndex = Math.max(0, Math.min(startIndex, filteredLabels.length - 1));
endIndex = Math.max(startIndex + 1, Math.min(endIndex, filteredLabels.length));
chart.data.labels = filteredLabels.slice(startIndex, endIndex);
chart.data.datasets.forEach((ds, i) => {
ds.data = filteredSeries[i].data.slice(startIndex, endIndex);
});
}
chart.update('none');
});
}
if (ySliderMinEl) {
ySliderMinEl.addEventListener('input', function() {
if (!chart || !filteredSeries || filteredSeries.length === 0) return;
// Get values from currently displayed data (after X zoom if applied)
const allValues = chart.data.datasets.flatMap(ds => ds.data.filter(v => v != null && v !== undefined));
if (allValues.length === 0) return;
const min = Math.min(...allValues);
const max = Math.max(...allValues);
const range = max - min;
if (range === 0) return; // Prevent division by zero
let minPercent = parseFloat(this.value);
let maxPercent = parseFloat(ySliderMaxEl.value);
// Prevent crossing - ensure min <= max
if (minPercent > maxPercent) {
ySliderMaxEl.value = minPercent;
maxPercent = minPercent;
}
// If both are at extremes (0 and 100), show full range
if (minPercent === 0 && maxPercent === 100) {
chart.options.scales.y.min = undefined;
chart.options.scales.y.max = undefined;
} else {
const startPercent = minPercent / 100;
const endPercent = maxPercent / 100;
// Calculate new min and max based on percentage (same logic as X zoom)
// minPercent: 0 = show from bottom (min), 100 = show from top (max)
// maxPercent: 0 = show to bottom (min), 100 = show to top (max)
let newMin = min + range * startPercent;
let newMax = min + range * endPercent;
// If min and max are the same (end to end), show at least a small range
if (newMax <= newMin) {
const center = (newMin + newMax) / 2;
const smallRange = Math.max(range * 0.01, 1); // At least 1% of range or 1 unit
newMin = center - smallRange / 2;
newMax = center + smallRange / 2;
}
// Ensure valid range and bounds
newMin = Math.max(min, Math.min(newMin, max));
newMax = Math.max(newMin + 0.01, Math.min(newMax, max));
chart.options.scales.y.min = newMin;
chart.options.scales.y.max = newMax;
}
chart.update('none');
});
}
if (ySliderMaxEl) {
ySliderMaxEl.addEventListener('input', function() {
if (!chart || !filteredSeries || filteredSeries.length === 0) return;
// Get values from currently displayed data (after X zoom if applied)
const allValues = chart.data.datasets.flatMap(ds => ds.data.filter(v => v != null && v !== undefined));
if (allValues.length === 0) return;
const min = Math.min(...allValues);
const max = Math.max(...allValues);
const range = max - min;
if (range === 0) return; // Prevent division by zero
let minPercent = parseFloat(ySliderMinEl.value);
let maxPercent = parseFloat(this.value);
// Prevent crossing - ensure min <= max
if (minPercent > maxPercent) {
ySliderMinEl.value = maxPercent;
minPercent = maxPercent;
}
// If both are at extremes (0 and 100), show full range
if (minPercent === 0 && maxPercent === 100) {
chart.options.scales.y.min = undefined;
chart.options.scales.y.max = undefined;
} else {
const startPercent = minPercent / 100;
const endPercent = maxPercent / 100;
// Calculate new min and max based on percentage (same logic as X zoom)
// minPercent: 0 = show from bottom (min), 100 = show from top (max)
// maxPercent: 0 = show to bottom (min), 100 = show to top (max)
let newMin = min + range * startPercent;
let newMax = min + range * endPercent;
// If min and max are the same (end to end), show at least a small range
if (newMax <= newMin) {
const center = (newMin + newMax) / 2;
const smallRange = Math.max(range * 0.01, 1); // At least 1% of range or 1 unit
newMin = center - smallRange / 2;
newMax = center + smallRange / 2;
}
// Ensure valid range and bounds
newMin = Math.max(min, Math.min(newMin, max));
newMax = Math.max(newMin + 0.01, Math.min(newMax, max));
chart.options.scales.y.min = newMin;
chart.options.scales.y.max = newMax;
}
chart.update('none');
});
}
}
// Canvas
const canvas = document.createElement('canvas');
//canvas.width = chartWidth;
canvas.height = chartHeight;
canvas.style.width = `${chartWidth}`;
canvas.style.height = `${chartHeight}px`;
chartWrapper.appendChild(canvas);
container.appendChild(chartWrapper);
// Legend buttons container
const legendButtonsContainer = document.createElement('div');
legendButtonsContainer.style.display = 'flex';
legendButtonsContainer.style.gap = '8px';
legendButtonsContainer.style.marginBottom = '8px';
legendButtonsContainer.style.marginTop = '12px';
legendButtonsContainer.style.minWidth = '160px';
// Clear All button
const clearAllBtn = document.createElement('button');
clearAllBtn.textContent = 'Clear All';
clearAllBtn.style.padding = '6px 12px';
clearAllBtn.style.border = '1px solid #ccc';
clearAllBtn.style.borderRadius = '4px';
clearAllBtn.style.fontSize = '14px';
clearAllBtn.style.color = '#152935';
clearAllBtn.style.backgroundColor = '#fff';
clearAllBtn.style.cursor = 'pointer';
clearAllBtn.style.flex = '1';
clearAllBtn.addEventListener('mouseenter', () => {
clearAllBtn.style.backgroundColor = '#f5f5f5';
});
clearAllBtn.addEventListener('mouseleave', () => {
clearAllBtn.style.backgroundColor = '#fff';
});
// Select All button
const selectAllBtn = document.createElement('button');
selectAllBtn.textContent = 'Select All';
selectAllBtn.style.padding = '6px 12px';
selectAllBtn.style.border = '1px solid #ccc';
selectAllBtn.style.borderRadius = '4px';
selectAllBtn.style.fontSize = '14px';
selectAllBtn.style.color = '#152935';
selectAllBtn.style.backgroundColor = '#fff';
selectAllBtn.style.cursor = 'pointer';
selectAllBtn.style.flex = '1';
selectAllBtn.addEventListener('mouseenter', () => {
selectAllBtn.style.backgroundColor = '#f5f5f5';
});
selectAllBtn.addEventListener('mouseleave', () => {
selectAllBtn.style.backgroundColor = '#fff';
});
legendButtonsContainer.appendChild(clearAllBtn);
legendButtonsContainer.appendChild(selectAllBtn);
// Legend (checkboxes)
const legend = document.createElement('div');
legend.style.display = 'flex';
legend.style.flexDirection = 'column';
legend.style.gap = '12px';
legend.style.marginTop = '0';
legend.style.minWidth = '160px';
container.appendChild(legendButtonsContainer);
container.appendChild(legend);
// Extract unique years and quarters from labels
const yearQuarterMap = {};
const years = [];
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
labels.forEach(label => {
const match = label.match(/(\d{4})\s*(Q[1-4])?/);
if (match) {
const year = match[1];
if (!yearQuarterMap[year]) {
yearQuarterMap[year] = new Set();
years.push(year);
}
if (match[2]) {
yearQuarterMap[year].add(match[2]);
}
}
});
years.sort();
// Custom dropdown function for Year and Quarter filters
function createCustomDropdown(labelText, items, selectedValues) {
const dropdownWrapper = document.createElement('div');
dropdownWrapper.style.position = 'relative';
dropdownWrapper.style.display = 'flex';
dropdownWrapper.style.alignItems = 'center';
dropdownWrapper.style.marginRight = '16px';
// Label
const label = document.createElement('label');
label.textContent = labelText + ':';
label.style.color = '#152935';
label.style.fontSize = '14px';
label.style.marginRight = '6px';
label.style.flexShrink = '0';
// Toggle button
const toggleBtn = document.createElement('button');
toggleBtn.style.padding = '6px 12px';
toggleBtn.style.border = '1px solid #ccc';
toggleBtn.style.borderRadius = '4px';
toggleBtn.style.fontSize = '14px';
toggleBtn.style.color = '#152935';
toggleBtn.style.backgroundColor = '#fff';
toggleBtn.style.cursor = 'pointer';
toggleBtn.style.minWidth = '140px';
toggleBtn.style.textAlign = 'left';
toggleBtn.style.flexShrink = '0';
const updateButtonText = () => {
const selected = selectedValues.filter(v => v !== 'selectall');
const arrow = isOpen ? '▲' : '▼';
if (selected.length === 0) {
toggleBtn.textContent = `None selected ${arrow}`;
} else if (selected.length === items.length) {
toggleBtn.textContent = `All selected ${arrow}`;
} else {
toggleBtn.textContent = `${selected.length} selected ${arrow}`;
}
};
let isOpen = false;
updateButtonText();
// Dropdown menu (hidden by default)
const dropdownMenu = document.createElement('div');
dropdownMenu.style.display = 'none';
dropdownMenu.style.position = 'absolute';
dropdownMenu.style.top = '100%';
dropdownMenu.style.left = '0';
dropdownMenu.style.marginTop = '4px';
dropdownMenu.style.padding = '8px';
dropdownMenu.style.border = '1px solid #ccc';
dropdownMenu.style.borderRadius = '4px';
dropdownMenu.style.backgroundColor = '#fff';
dropdownMenu.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
dropdownMenu.style.zIndex = '1000';
dropdownMenu.style.maxHeight = '200px';
dropdownMenu.style.overflowY = 'auto';
dropdownMenu.style.overflowX = 'hidden';
dropdownMenu.style.minWidth = '140px';
dropdownMenu.style.overscrollBehavior = 'contain';
// Enable wheel scrolling in dropdown menu
dropdownMenu.addEventListener('wheel', (e) => {
e.stopPropagation(); // Prevent event from bubbling up
// Allow default scroll behavior
}, { passive: true });
// Also prevent click events from closing dropdown when scrolling
dropdownMenu.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
// Select All checkbox
const selectAllRow = document.createElement('label');
selectAllRow.style.display = 'flex';
selectAllRow.style.alignItems = 'center';
selectAllRow.style.padding = '4px 0';
selectAllRow.style.cursor = 'pointer';
selectAllRow.style.userSelect = 'none';
const selectAllCheckbox = document.createElement('input');
selectAllCheckbox.type = 'checkbox';
selectAllCheckbox.checked = selectedValues.length === items.length;
selectAllCheckbox.style.marginRight = '8px';
selectAllCheckbox.style.cursor = 'pointer';
const selectAllLabel = document.createElement('span');
selectAllLabel.textContent = 'Select All';
selectAllLabel.style.fontSize = '14px';
selectAllLabel.style.color = '#152935';
selectAllRow.appendChild(selectAllCheckbox);
selectAllRow.appendChild(selectAllLabel);
selectAllRow.addEventListener('click', (e) => {
e.stopPropagation();
if (e.target !== selectAllCheckbox) {
selectAllCheckbox.checked = !selectAllCheckbox.checked;
}
const allSelected = selectAllCheckbox.checked;
if (allSelected) {
selectedValues.length = 0;
selectedValues.push(...items);
items.forEach(item => {
const option = optionMap.get(item);
if (option) option.querySelector('input').checked = true;
});
} else {
selectedValues.length = 0;
items.forEach(item => {
const option = optionMap.get(item);
if (option) option.querySelector('input').checked = false;
});
}
updateButtonText();
applyFilters();
});
dropdownMenu.appendChild(selectAllRow);
// Individual checkboxes
const optionMap = new Map();
items.forEach(item => {
const row = document.createElement('label');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.padding = '4px 0';
row.style.cursor = 'pointer';
row.style.userSelect = 'none';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = item;
checkbox.checked = selectedValues.includes(item);
checkbox.style.marginRight = '8px';
checkbox.style.cursor = 'pointer';
checkbox.addEventListener('change', () => {
if (checkbox.checked && !selectedValues.includes(item)) {
selectedValues.push(item);
} else if (!checkbox.checked) {
const index = selectedValues.indexOf(item);
if (index > -1) selectedValues.splice(index, 1);
}
selectAllCheckbox.checked = selectedValues.length === items.length;
updateButtonText();
applyFilters();
});
const labelSpan = document.createElement('span');
labelSpan.textContent = item;
labelSpan.style.fontSize = '14px';
labelSpan.style.color = '#152935';
row.appendChild(checkbox);
row.appendChild(labelSpan);
row.addEventListener('click', (e) => {
if (e.target !== checkbox) {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
}
});
dropdownMenu.appendChild(row);
optionMap.set(item, row);
});
// Toggle dropdown
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
isOpen = dropdownMenu.style.display !== 'none';
isOpen = !isOpen;
dropdownMenu.style.display = isOpen ? 'block' : 'none';
updateButtonText();
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!dropdownWrapper.contains(e.target)) {
isOpen = false;
dropdownMenu.style.display = 'none';
updateButtonText();
}
});
dropdownWrapper.appendChild(label);
const btnWrapper = document.createElement('div');
btnWrapper.style.position = 'relative';
btnWrapper.appendChild(toggleBtn);
btnWrapper.appendChild(dropdownMenu);
dropdownWrapper.appendChild(btnWrapper);
return dropdownWrapper;
}
// Chart.js instance
const chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: filteredLabels,
datasets: filteredSeries.map(s => ({
label: s.name,
data: s.data,
borderColor: s.name === "Total" ? lineColor : palette[s.name],
backgroundColor: s.name === "Total" ? lineColor : palette[s.name],
borderWidth: s.name === "Total" ? 4 : 2,
pointRadius: s.name === "Total" ? 5 : 3,
pointBackgroundColor: "#fff",
pointBorderWidth: 2,
pointBorderColor: s.name === "Total" ? lineColor : palette[s.name],
pointHoverRadius: s.name === "Total" ? 8 : 6,
fill: false,
tension: 0.6, // Increased from 0.3 for smoother curves
borderDash: s.name === "Total" && title === "Media Signal Volume" ? lineStyle : [],
zIndex: s.name === "Total" ? 3 : 2,
hidden: false,
cubicInterpolationMode: 'monotone' // Add monotone interpolation for better curves
}))
},
options: {
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: "#152935", maxRotation: 90, minRotation: 90 },
grid: { display: false }
},
y: {
title: { display: true, text: 'Signal Activity', color: "#152935" },
ticks: { color: "#152935" },
grid: { display: false }
}
},
elements: {
line: {
tension: 0.6 // Global tension setting
}
},
responsive: false,
maintainAspectRatio: false
}
});
chartInstances.set(wrapper, chart);
// Create zoom controls after chart is initialized
// Wait for chart to render first, then position everything
setTimeout(() => {
createZoomControls();
// Position filter container below x zoom axis
const chartArea = chart.chartArea;
if (chartArea && filterContainer && xZoomContainer) {
// X zoom axis is positioned at chartArea.bottom + xAxisLabelHeight (70px)
// So filter container should be below that
const xZoomBottom = chartArea.bottom + 70 + 20; // chartArea.bottom + xAxisLabelHeight + xZoom height
filterContainer.style.top = `${xZoomBottom + 8}px`; // Add some spacing
filterContainer.style.left = `${chartArea.left}px`;
filterContainer.style.width = `${chartArea.right - chartArea.left}px`;
}
}, 100);
// Store zoom state
let xZoomMin = 0;
let xZoomMax = 100;
let yZoomMin = 0;
let yZoomMax = 100;
let xZoomSliderMin, xZoomSliderMax, yZoomSliderMin, yZoomSliderMax;
// X-axis zoom handler - Range slider
function updateXZoom() {
if (!xZoomSliderMin || !xZoomSliderMax || !chart) return;
const totalLabels = filteredLabels.length;
if (totalLabels === 0) return;
const minPercent = parseFloat(xZoomSliderMin.value);
const maxPercent = parseFloat(xZoomSliderMax.value);
// Ensure min <= max
if (minPercent > maxPercent) {
if (xZoomSliderMin === document.activeElement) {
xZoomSliderMax.value = minPercent;
} else {
xZoomSliderMin.value = maxPercent;
}
return updateXZoom();
}
xZoomMin = minPercent;
xZoomMax = maxPercent;
const startPercent = minPercent / 100;
const endPercent = maxPercent / 100;
const startIndex = Math.floor(totalLabels * startPercent);
const endIndex = Math.ceil(totalLabels * endPercent);
chart.data.labels = filteredLabels.slice(startIndex, endIndex);
chart.data.datasets.forEach((ds, i) => {
ds.data = filteredSeries[i].data.slice(startIndex, endIndex);
});
chart.update('none'); // Use 'none' mode to prevent animations that might cause issues
}
// Y-axis zoom handler - Range slider
function updateYZoom() {
if (!yZoomSliderMin || !yZoomSliderMax || !chart) return;
const allValues = filteredSeries.flatMap(s => s.data.filter(v => v != null));
if (allValues.length === 0) return;
const min = Math.min(...allValues);
const max = Math.max(...allValues);
const range = max - min;
const minPercent = parseFloat(yZoomSliderMin.value);
const maxPercent = parseFloat(yZoomSliderMax.value);
// Ensure min <= max
if (minPercent > maxPercent) {
if (yZoomSliderMin === document.activeElement) {
yZoomSliderMax.value = minPercent;
} else {
yZoomSliderMin.value = maxPercent;
}
return updateYZoom();
}
yZoomMin = minPercent;
yZoomMax = maxPercent;
// minPercent: 0-100 where 0 shows full range, 100 shows minimal range from bottom
// maxPercent: 0-100 where 0 shows full range, 100 shows minimal range from top
// When both are 0, show full range (min to max)
// When both are 100, show minimal range in the middle
const zoomMinFactor = minPercent / 100; // How much to crop from bottom (0-1)
const zoomMaxFactor = maxPercent / 100; // How much to crop from top (0-1)
const newMin = min + range * zoomMinFactor;
const newMax = max - range * zoomMaxFactor;
chart.options.scales.y.min = newMin;
chart.options.scales.y.max = newMax;
chart.update('none'); // Use 'none' mode to prevent animations
}
// Year and Quarter dropdowns - positioned below X axis
const filterContainer = document.createElement('div');
filterContainer.style.display = 'flex';
filterContainer.style.justifyContent = 'center';
filterContainer.style.gap = '8px';
filterContainer.style.alignItems = 'center';
filterContainer.style.position = 'absolute';
filterContainer.style.paddingTop = '8px';
const selectedYears = [...years];
const selectedQuarters = [...quarters];
const yearDropdown = createCustomDropdown('Year', years, selectedYears);
const quarterDropdown = createCustomDropdown('Quarter', quarters, selectedQuarters);
filterContainer.appendChild(yearDropdown);
filterContainer.appendChild(quarterDropdown);
chartWrapper.appendChild(filterContainer);
// Filter function - Multiselect support
function applyFilters() {
filteredLabels = [];
const filteredIndices = [];
originalLabels.forEach((label, index) => {
let match = true;
if (selectedYears.length > 0) {
const yearMatch = label.match(/(\d{4})/);
if (!yearMatch || !selectedYears.includes(yearMatch[1])) {
match = false;
}
}
if (selectedQuarters.length > 0 && match) {
const quarterMatch = label.match(/Q([1-4])/);
if (!quarterMatch || !selectedQuarters.includes(quarterMatch[0])) {
match = false;
}
}
if (match) {
filteredLabels.push(label);
filteredIndices.push(index);
}
});
filteredSeries = originalSeries.map(s => ({
...s,
data: filteredIndices.map(i => s.data[i])
}));
chart.data.labels = filteredLabels;
chart.data.datasets.forEach((ds, i) => {
ds.data = filteredSeries[i].data;
});
// Reset zoom sliders if they exist
if (xZoomSliderMin && xZoomSliderMax) {
xZoomSliderMin.value = '0';
xZoomSliderMax.value = '100';
}
if (yZoomSliderMin && yZoomSliderMax) {
yZoomSliderMin.value = '0';
yZoomSliderMax.value = '100';
}
xZoomMin = 0;
xZoomMax = 100;
yZoomMin = 0;
yZoomMax = 100;
chart.options.scales.y.min = undefined;
chart.options.scales.y.max = undefined;
chart.update('none'); // Update chart first
// Then update zoom if sliders exist
if (xZoomSliderMin && xZoomSliderMax) {
updateXZoom();
}
if (yZoomSliderMin && yZoomSliderMax) {
updateYZoom();
}
}
// Create checkboxes for each dataset
chart.data.datasets.forEach((ds, i) => {
const row = document.createElement('label');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.cursor = 'pointer';
row.style.gap = '8px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !ds.hidden;
checkbox.style.width = '16px';
checkbox.style.height = '16px';
checkbox.style.display = 'none'; // Hide the actual checkbox input
checkbox.dataset.index = i; // Add dataset index for reference
// Custom checkbox box
const box = document.createElement('span');
box.style.display = 'inline-flex'; // Use flex for centering
box.style.alignItems = 'center';
box.style.justifyContent = 'center';
box.style.width = '16px';
box.style.height = '16px';
box.style.border = `2px solid ${ds.borderColor}`;
box.style.borderRadius = '4px';
box.style.backgroundColor = 'white';
box.style.position = 'relative';
// Checkmark inside the box
const check = document.createElement('span');
check.textContent = '✔';
check.style.fontSize = '13px';
check.style.color = ds.borderColor;
check.style.display = checkbox.checked ? 'block' : 'none';
check.style.lineHeight = '1';
check.style.position = 'static'; // Let flexbox center it
box.appendChild(check);
// Store check element reference on checkbox for easy access
checkbox.checkElement = check;
// Line indicator
const line = document.createElement('span');
line.style.display = 'inline-block';
line.style.width = '16px';
line.style.height = '2px';
line.style.borderTop = ds.borderDash && ds.borderDash.length ?
`2px dashed ${ds.borderColor}` :
`2px solid ${ds.borderColor}`;
// Checkbox event: show/hide dataset
checkbox.addEventListener('change', (e) => {
chart.setDatasetVisibility(i, checkbox.checked);
chart.update();
check.style.display = checkbox.checked ? 'block' : 'none';
});
// Direct click handler for the entire label element
row.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (e.target !== checkbox) {
checkbox.checked = !checkbox.checked;
chart.setDatasetVisibility(i, checkbox.checked);
chart.update();
check.style.display = checkbox.checked ? 'block' : 'none';
}
return false;
});
const txt = document.createElement('span');
txt.textContent = ds.label;
txt.style.color = ds.borderColor;
txt.style.fontWeight = ds.label === "Total" ? "bold" : "normal";
row.appendChild(checkbox); // Hidden checkbox
row.appendChild(box); // Custom checkbox styling
row.appendChild(line); // Line style indicator
row.appendChild(txt); // Label text
legend.appendChild(row);
});
// Add event listeners to Clear All and Select All buttons
clearAllBtn.addEventListener('click', () => {
chart.data.datasets.forEach((ds, i) => {
chart.setDatasetVisibility(i, false);
const checkbox = legend.querySelector(`input[data-index="${i}"]`);
if (checkbox) {
checkbox.checked = false;
if (checkbox.checkElement) {
checkbox.checkElement.style.display = 'none';
}
}
});
chart.update();
});
selectAllBtn.addEventListener('click', () => {
chart.data.datasets.forEach((ds, i) => {
chart.setDatasetVisibility(i, true);
const checkbox = legend.querySelector(`input[data-index="${i}"]`);
if (checkbox) {
checkbox.checked = true;
if (checkbox.checkElement) {
checkbox.checkElement.style.display = 'block';
}
}
});
chart.update();
});
}
// Render donut legend row
function renderDonutLegendRow(chartRow) {
const donut = chartRow.querySelector('.donutWrapper');
if (!donut) return;
const legendRow = chartRow.querySelector('.donutLegendRow');
if (!legendRow) return;
clearElement(legendRow);
const labels = JSON.parse(donut.getAttribute('data-labels'));
const values = JSON.parse(donut.getAttribute('data-values'));
const total = values.reduce((a,b) => a+b, 0);
const percentages = values.map(v => total ? (v/total*100) : 0);
// Global palette kullan
const palette = globalPalette;
legendRow.style.display = 'flex';
legendRow.style.flexDirection = 'row'; // Changed back to row since we're not adding explanation here
legendRow.style.flexWrap = 'wrap';
legendRow.style.gap = '18px';
legendRow.style.margin = '18px 0 0 0';
legendRow.style.fontSize = '15px';
legendRow.style.maxWidth = 'calc(100% - 40px)';
legendRow.style.color = '#152935';
labels.forEach((label, i) => {
const colorBox = document.createElement('span');
colorBox.style.background = palette[label];
colorBox.style.display = 'inline-block';
colorBox.style.width = '16px';
colorBox.style.height = '16px';
colorBox.style.borderRadius = '3px';
const txt = document.createElement('span');
txt.textContent = `${label} ${percentages[i].toFixed(1)}% (${values[i]})`;
const item = document.createElement('div');
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.gap = '7px';
item.appendChild(colorBox);
item.appendChild(txt);
legendRow.appendChild(item);
});
// Removed explanation text from here, as it's now in the separate container at the bottom
}
// Render all charts
function renderAllCharts() {
collectAllLabels();
globalPalette = generatePalette(globalLabels);
document.querySelectorAll('.donutWrapper').forEach(renderDonutChart);
document.querySelectorAll('.lineWrapper').forEach(renderLineChart);
document.querySelectorAll('.chartRow').forEach(renderDonutLegendRow);
}
window.addEventListener('DOMContentLoaded', () => {
renderAllCharts();
// Resize observer for each chart wrapper
const resizeObserver = new ResizeObserver(() => {
renderAllCharts();
});
document.querySelectorAll('.donutWrapper, .lineWrapper').forEach(wrapper => {
resizeObserver.observe(wrapper);
});
});