Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 69 additions & 20 deletions src/alternative_interface/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,29 +376,71 @@ def series_label_of(key):
}


def normalize_dataset(data):
def normalize_dataset(data, day_labels=None, initial_view_start=None, initial_view_end=None):
"""
Normalize a dataset to 0-100% range based on its min/max.
Scale a dataset so that its highest value during the displayed 2 years is set to 100.
Multiplies each value by a constant (100 / max_value_in_range).
Preserves None values for missing data.
"""
# Filter out None values for min/max calculation
if not data:
return data

# If we have the initial view range, scale based on max value in that range
if day_labels and initial_view_start and initial_view_end and len(day_labels) == len(data):
# Find indices that fall within the initial view range (2 years)
view_indices = []
for i, day_label in enumerate(day_labels):
if initial_view_start <= day_label <= initial_view_end:
view_indices.append(i)

# Find max value only in the view range
view_values = [
data[i]
for i in view_indices
if i < len(data) and data[i] is not None and not (
isinstance(data[i], float) and (data[i] != data[i] or data[i] in (float("inf"), float("-inf")))
)
]

if view_values:
max_val_in_range = max(view_values)
if max_val_in_range > 0:
# Scale factor: multiply by (100 / max_value_in_range)
scale_factor = 100.0 / max_val_in_range

# Scale all values in the dataset
normalized = []
for value in data:
if value is None:
normalized.append(None)
elif isinstance(value, float) and (
value != value or value in (float("inf"), float("-inf"))
):
normalized.append(None)
else:
normalized.append(value * scale_factor)
return normalized

# Fallback: if no view range provided, scale based on max value in entire dataset
numeric_values = [
v
for v in data
if v is not None
and not (
if v is not None and not (
isinstance(v, float) and (v != v or v in (float("inf"), float("-inf")))
)
]

if not numeric_values:
return data # Return as-is if no valid numeric values

min_val = min(numeric_values)
max_val = max(numeric_values)
range_val = (max_val - min_val) or 1 # Avoid division by zero
if max_val <= 0:
return data # Return as-is if max is 0 or negative

# Scale so max value = 100
scale_factor = 100.0 / max_val

# Normalize each value
# Scale each value
normalized = []
for value in data:
if value is None:
Expand All @@ -408,7 +450,7 @@ def normalize_dataset(data):
):
normalized.append(None)
else:
normalized.append(((value - min_val) / range_val) * 100)
normalized.append(value * scale_factor)

return normalized

Expand All @@ -420,19 +462,20 @@ def get_chart_data(indicators, geography):
geo_level__name=geo_type, geo_id=geo_value
).display_name

# Calculate date range: last 12 months from today, but fetch data from 2020
# Calculate date range: last 2 years from today for initial view
today = datetime.now().date()
two_years_ago = today - timedelta(days=730)
# Format dates as strings
end_date = today.strftime("%Y-%m-%d")
start_date = two_years_ago.strftime("%Y-%m-%d")

# Store the initial view range (last 12 months)
# Store the initial view range (last 2 years)
chart_data["initialViewStart"] = start_date
chart_data["initialViewEnd"] = end_date

# Fetch data from a wider range (2020 to today) for scrolling
data_start_date = "1990-01-01"
# Fetch data from a wider range (10 years) for scrolling
ten_years_ago = today - timedelta(days=3650) # ~10 years
data_start_date = ten_years_ago.strftime("%Y-%m-%d")
data_end_date = today.strftime("%Y-%m-%d")

for indicator in indicators:
Expand Down Expand Up @@ -465,18 +508,24 @@ def get_chart_data(indicators, geography):
series_by="signal", # label per indicator (adjust to ("signal","geo_value") if needed)
time_type=indicator_time_type,
)
# Initialize labels once; assume same date range for all
if not chart_data["labels"]:
chart_data["labels"] = series["labels"]
chart_data["dayLabels"] = series["dayLabels"]
chart_data["timePositions"] = series["timePositions"]

# Apply readable label, color, and normalize data for each dataset
for ds in series["datasets"]:
ds["label"] = title
ds["borderColor"] = color
ds["backgroundColor"] = f"{color}33"
# Normalize data to 0-100% range
# Scale data so max value in displayed 2 years = 100
if ds.get("data"):
ds["data"] = normalize_dataset(ds["data"])
# Initialize labels once; assume same date range for all
if not chart_data["labels"]:
chart_data["labels"] = series["labels"]
chart_data["dayLabels"] = series["dayLabels"]
chart_data["timePositions"] = series["timePositions"]
ds["data"] = normalize_dataset(
ds["data"],
day_labels=chart_data["dayLabels"],
initial_view_start=chart_data["initialViewStart"],
initial_view_end=chart_data["initialViewEnd"]
)
chart_data["datasets"].extend(series["datasets"])
return chart_data
119 changes: 78 additions & 41 deletions src/assets/js/alter_dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const CHART_REDRAW_DELAY = 100;
const CHART_RESIZE_DELAY = 10;
const TYPING_ANIMATION_DELAY = 500;
const BLUR_DELAY = 100;
const HINT_AUTO_HIDE_DELAY = 10000;
const CHART_UPDATE_THROTTLE = 16; // ~60fps for smooth interactions

// Utility functions
const ChartUtils = {
Expand Down Expand Up @@ -82,6 +82,7 @@ const ChartUtils = {
createDataset(ds, i, dayLabelsLength) {
const timeType = ds.timeType || 'week';
const isWeekly = timeType === 'week';
const isLargeDataset = dayLabelsLength > 1000;
// Always use palette color based on index for consistency between chart and legend
const color = CHART_PALETTE[i % CHART_PALETTE.length];

Expand All @@ -91,16 +92,19 @@ const ChartUtils = {
// Use palette color to ensure consistency with legend
borderColor: color,
backgroundColor: color + '33',
borderWidth: 2,
borderWidth: isLargeDataset ? 1.5 : 2, // Thinner lines for large datasets
fill: true,
tension: 0,
pointRadius: isWeekly ? 3 : 0,
pointHoverRadius: isWeekly ? 6 : 4,
pointBackgroundColor: isWeekly ? color : undefined,
pointBorderColor: isWeekly ? '#fff' : undefined,
pointBorderWidth: isWeekly ? 1.5 : 0,
pointRadius: isLargeDataset ? 0 : (isWeekly ? 3 : 0), // No points for large datasets
pointHoverRadius: isLargeDataset ? 3 : (isWeekly ? 6 : 4),
pointBackgroundColor: isLargeDataset ? undefined : (isWeekly ? color : undefined),
pointBorderColor: isLargeDataset ? undefined : (isWeekly ? '#fff' : undefined),
pointBorderWidth: isLargeDataset ? 0 : (isWeekly ? 1.5 : 0),
spanGaps: isWeekly,
timeType: timeType
timeType: timeType,
// Performance optimizations for large datasets
pointHitRadius: isLargeDataset ? 5 : undefined, // Smaller hit radius for large datasets
pointHoverBorderWidth: isLargeDataset ? 0 : undefined
};
}
};
Expand Down Expand Up @@ -287,6 +291,7 @@ class AlterDashboard {
constructor() {
this.originalDatasets = [];
this.normalized = true;
this.updateThrottleTimer = null;
this.init();
}

Expand All @@ -298,14 +303,12 @@ class AlterDashboard {
}

initChartHint() {
if (localStorage.getItem('chartHintDismissed') === 'true') {
const hint = document.getElementById('chartHint');
if (hint) {
hint.style.display = 'none';
}
} else {
autoHideChartHint();
}
const hint = document.getElementById('chartHint');
if (!hint) return;

// Ensure hint is visible on every page load
hint.style.display = 'flex';
hint.classList.remove('hidden');
}

initChart() {
Expand All @@ -330,6 +333,11 @@ class AlterDashboard {
ChartUtils.createDataset(ds, i, dayLabels.length)
);

// Ensure all datasets are visible by default
datasets.forEach((ds) => {
ds.hidden = false;
});

this.originalDatasets = datasets.map(d => ({
...d,
originalData: Array.isArray(d.data) ? [...d.data] : []
Expand Down Expand Up @@ -594,12 +602,26 @@ class AlterDashboard {
}

getChartOptions(dayLabels) {
const isLargeDataset = dayLabels.length > 1000;

return {
responsive: true,
maintainAspectRatio: false,
animation: false, // Disable animations for better performance
transitions: {
active: {
animation: {
duration: 0 // Disable transitions during updates
}
}
},
layout: {
padding: { top: 30 }
},
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: { display: false },
htmlLegend: { containerID: 'chartHtmlLegend' },
Expand All @@ -614,6 +636,7 @@ class AlterDashboard {
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
cornerRadius: 8,
position: isLargeDataset ? 'nearest' : 'average',
enabled: function(context) {
const chart = context.chart;
return !chart._isPanning && !chart._isZooming;
Expand Down Expand Up @@ -690,10 +713,11 @@ class AlterDashboard {
ticks: {
font: { size: 10 },
color: '#64748b',
maxTicksLimit: 20,
maxTicksLimit: isLargeDataset ? 15 : 20, // Reduce ticks for large datasets
autoSkip: true,
autoSkipPadding: 5,
display: true,
sampleSize: isLargeDataset ? 100 : undefined, // Sample ticks for performance
callback: function(value) {
const chart = this.chart;
if (!chart?.data?.labels) return '';
Expand Down Expand Up @@ -737,24 +761,24 @@ class AlterDashboard {
mode: 'nearest',
axis: 'x',
intersect: false,
includeInvisible: true
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
includeInvisible: false // Exclude invisible datasets for performance
},
elements: {
point: {
radius: 0,
hoverRadius: 4
radius: isLargeDataset ? 0 : 0, // No points for large datasets
hoverRadius: isLargeDataset ? 3 : 4,
hitRadius: isLargeDataset ? 5 : 10 // Smaller hit radius for performance
},
line: {
borderWidth: 2,
tension: 0
borderWidth: isLargeDataset ? 1.5 : 2, // Thinner lines for large datasets
tension: 0,
cubicInterpolationMode: 'default' // Use default interpolation for performance
}
},
hover: {
animationDuration: 0
animationDuration: 0,
mode: 'index',
intersect: false
},
transitions: {
active: {
Expand Down Expand Up @@ -864,6 +888,11 @@ class AlterDashboard {
originalData: Array.isArray(d.data) ? [...d.data] : []
}));

// Ensure all datasets are visible
datasets.forEach((ds, index) => {
ds.hidden = false;
});

this.chart.data.labels = dayLabels;
this.chart.data.datasets = datasets;

Expand All @@ -878,9 +907,23 @@ class AlterDashboard {

this.updateZoomLimits(dayLabels);
this.updateScaleConfiguration(dayLabels);
this.chart.update('none');
this.forceRedraw();
this.setInitialZoom(dayLabels);

// Optimize: use requestAnimationFrame for smoother updates with large datasets
const isLargeDataset = dayLabels.length > 1000;
if (isLargeDataset) {
requestAnimationFrame(() => {
this.chart.update('none');
setTimeout(() => {
this.setInitialZoom(dayLabels);
}, INITIAL_ZOOM_DELAY);
});
} else {
// Single update with animation disabled, then set zoom
this.chart.update('none');
setTimeout(() => {
this.setInitialZoom(dayLabels);
}, INITIAL_ZOOM_DELAY);
}
}

updateZoomLimits(dayLabels) {
Expand Down Expand Up @@ -935,24 +978,18 @@ class AlterDashboard {
function dismissChartHint() {
const hint = document.getElementById('chartHint');
if (hint) {
// Add animation
hint.style.animation = 'fadeOut 0.3s ease-out';
// Hide after animation completes
setTimeout(() => {
hint.style.display = 'none';
hint.classList.add('hidden');
}, 300);
localStorage.setItem('chartHintDismissed', 'true');
}
}

function autoHideChartHint() {
const hint = document.getElementById('chartHint');
if (hint && !localStorage.getItem('chartHintDismissed')) {
setTimeout(() => {
if (hint && hint.style.display !== 'none') {
dismissChartHint();
}
}, HINT_AUTO_HIDE_DELAY);
}
}
// Make function globally accessible
window.dismissChartHint = dismissChartHint;

// Initialize typing animations
function initPathogenTypingAnimation() {
Expand Down
2 changes: 1 addition & 1 deletion src/epiportal/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from sentry_sdk.integrations.redis import RedisIntegration

APP_VERSION = "1.0.16"
ALTERNATIVE_INTERFACE_VERSION = "1.0.5"
ALTERNATIVE_INTERFACE_VERSION = "1.0.6"


EPIVIS_URL = os.environ.get("EPIVIS_URL", "https://delphi.cmu.edu/epivis/")
Expand Down
Loading
Loading