From e95a5b1a9ff0971f53995dd9d0f505a57877570e Mon Sep 17 00:00:00 2001 From: Bartal Laearsson Date: Sat, 22 Aug 2026 23:41:18 +0100 Subject: [PATCH] moving closer to something useful --- src/api.rs | 400 ++++++++++++++++++++++++++++++++++++++++------ src/types.rs | 7 + static/app.js | 188 ++++++++++++++++++++-- static/index.html | 21 +-- static/style.css | 90 ++++++++++- 5 files changed, 628 insertions(+), 78 deletions(-) diff --git a/src/api.rs b/src/api.rs index 30a8fda..ca5cab9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,7 +1,7 @@ use crate::db; use crate::types::{ - Config, LandingDto, LookupDto, MonthlyAggregate, MonthlySpeciesBreakdown, PriceTrend, - SpeciesDto, SummaryDto, TopSpecies, + AvailableFiltersDto, Config, LandingDto, LookupDto, MonthlyAggregate, + MonthlySpeciesBreakdown, PriceTrend, SpeciesDto, SummaryDto, TopSpecies, }; use axum::{ Json, Router, @@ -48,12 +48,189 @@ fn default_limit() -> u32 { const MAX_LIMIT: u32 = 10000; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Default)] pub struct SummaryQuery { pub species: Option, + pub species_multi: Option, pub month: Option, pub month_from: Option, pub month_to: Option, + pub zone: Option, + pub zone_multi: Option, + pub gear: Option, + pub gear_multi: Option, +} + +impl SummaryQuery { + fn parse_multi(value: &Option) -> Vec { + match value { + Some(v) if !v.is_empty() => { + v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + } + _ => Vec::new(), + } + } + + fn build_conditions(&self) -> (Vec, Vec>) { + let mut conditions: Vec = Vec::new(); + let mut args: Vec> = Vec::new(); + + if let Some(ref month) = self.month { + conditions.push("month = ?".to_string()); + args.push(Box::new(month.clone())); + } + if let Some(ref month_from) = self.month_from { + conditions.push("month >= ?".to_string()); + args.push(Box::new(month_from.clone())); + } + if let Some(ref month_to) = self.month_to { + conditions.push("month <= ?".to_string()); + args.push(Box::new(month_to.clone())); + } + + let species_list = Self::parse_multi(&self.species_multi); + if !species_list.is_empty() { + let placeholders: Vec = (0..species_list.len()) + .map(|_| "?".to_string()) + .collect(); + conditions.push(format!("species_code IN ({})", placeholders.join(", "))); + for s in &species_list { + args.push(Box::new(s.clone())); + } + } else if let Some(ref species) = self.species { + conditions.push("species_code = ?".to_string()); + args.push(Box::new(species.clone())); + } + + let zone_list = Self::parse_multi(&self.zone_multi); + if !zone_list.is_empty() { + let placeholders: Vec = (0..zone_list.len()) + .map(|_| "?".to_string()) + .collect(); + conditions.push(format!("zone_code IN ({})", placeholders.join(", "))); + for z in &zone_list { + args.push(Box::new(z.clone())); + } + } else if let Some(ref zone) = self.zone { + conditions.push("zone_code = ?".to_string()); + args.push(Box::new(zone.clone())); + } + + let gear_list = Self::parse_multi(&self.gear_multi); + if !gear_list.is_empty() { + let placeholders: Vec = (0..gear_list.len()) + .map(|_| "?".to_string()) + .collect(); + conditions.push(format!("gear_code IN ({})", placeholders.join(", "))); + for g in &gear_list { + args.push(Box::new(g.clone())); + } + } else if let Some(ref gear) = self.gear { + conditions.push("gear_code = ?".to_string()); + args.push(Box::new(gear.clone())); + } + + (conditions, args) + } + + fn build_where_and(&self) -> (String, Vec>) { + let (conditions, args) = self.build_conditions(); + let clause = if conditions.is_empty() { + String::new() + } else { + format!(" AND {}", conditions.join(" AND ")) + }; + (clause, args) + } + + fn build_where_prefix(&self) -> (String, Vec>) { + let (conditions, args) = self.build_conditions(); + let clause = if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + }; + (clause, args) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AvailableFiltersQuery { + pub month_from: Option, + pub month_to: Option, + pub species_multi: Option, + pub zone_multi: Option, + pub gear_multi: Option, +} + +impl AvailableFiltersQuery { + fn parse_multi(value: &Option) -> Vec { + match value { + Some(v) if !v.is_empty() => { + v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + } + _ => Vec::new(), + } + } + + fn build_conditions_excluding(&self, exclude: &str) -> (Vec, Vec>) { + let mut conditions: Vec = Vec::new(); + let mut args: Vec> = Vec::new(); + + if let Some(ref month_from) = self.month_from { + conditions.push("month >= ?".to_string()); + args.push(Box::new(month_from.clone())); + } + if let Some(ref month_to) = self.month_to { + conditions.push("month <= ?".to_string()); + args.push(Box::new(month_to.clone())); + } + + if exclude != "species" { + let species_list = Self::parse_multi(&self.species_multi); + if !species_list.is_empty() { + let placeholders: Vec = species_list.iter().map(|_| "?".to_string()).collect(); + conditions.push(format!("species_code IN ({})", placeholders.join(", "))); + for s in &species_list { + args.push(Box::new(s.clone())); + } + } + } + + if exclude != "zone" { + let zone_list = Self::parse_multi(&self.zone_multi); + if !zone_list.is_empty() { + let placeholders: Vec = zone_list.iter().map(|_| "?".to_string()).collect(); + conditions.push(format!("zone_code IN ({})", placeholders.join(", "))); + for z in &zone_list { + args.push(Box::new(z.clone())); + } + } + } + + if exclude != "gear" { + let gear_list = Self::parse_multi(&self.gear_multi); + if !gear_list.is_empty() { + let placeholders: Vec = gear_list.iter().map(|_| "?".to_string()).collect(); + conditions.push(format!("gear_code IN ({})", placeholders.join(", "))); + for g in &gear_list { + args.push(Box::new(g.clone())); + } + } + } + + (conditions, args) + } + + fn build_where_prefix_excluding(&self, exclude: &str) -> (String, Vec>) { + let (conditions, args) = self.build_conditions_excluding(exclude); + let clause = if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + }; + (clause, args) + } } #[derive(Debug)] @@ -127,6 +304,7 @@ pub fn build_router(state: AppState) -> Router { .route("/api/species", get(get_species)) .route("/api/zones", get(get_zones)) .route("/api/gear", get(get_gear)) + .route("/api/available-filters", get(get_available_filters)) .route("/api/landings", get(get_landings)) .route("/api/summary", get(get_summary)) .route("/api/summary/monthly-breakdown", get(get_monthly_breakdown)) @@ -262,31 +440,7 @@ async fn get_summary( let result = tokio::task::spawn_blocking(move || -> db::Result { let conn = conn.blocking_lock(); - let mut conditions: Vec = Vec::new(); - let mut args: Vec> = Vec::new(); - - if let Some(ref month) = params.month { - conditions.push("month = ?".to_string()); - args.push(Box::new(month.clone())); - } - if let Some(ref month_from) = params.month_from { - conditions.push("month >= ?".to_string()); - args.push(Box::new(month_from.clone())); - } - if let Some(ref month_to) = params.month_to { - conditions.push("month <= ?".to_string()); - args.push(Box::new(month_to.clone())); - } - if let Some(ref species) = params.species { - conditions.push("species_code = ?".to_string()); - args.push(Box::new(species.clone())); - } - - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!(" AND {}", conditions.join(" AND ")) - }; + let (where_clause, args) = params.build_where_and(); let arg_refs: Vec<&dyn duckdb::ToSql> = args.iter().map(|b| b.as_ref()).collect(); @@ -394,31 +548,7 @@ async fn get_monthly_breakdown( tokio::task::spawn_blocking(move || -> db::Result> { let conn = conn.blocking_lock(); - let mut conditions: Vec = Vec::new(); - let mut args: Vec> = Vec::new(); - - if let Some(ref month) = params.month { - conditions.push("month = ?".to_string()); - args.push(Box::new(month.clone())); - } - if let Some(ref month_from) = params.month_from { - conditions.push("month >= ?".to_string()); - args.push(Box::new(month_from.clone())); - } - if let Some(ref month_to) = params.month_to { - conditions.push("month <= ?".to_string()); - args.push(Box::new(month_to.clone())); - } - if let Some(ref species) = params.species { - conditions.push("species_code = ?".to_string()); - args.push(Box::new(species.clone())); - } - - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!(" WHERE {}", conditions.join(" AND ")) - }; + let (where_clause, args) = params.build_where_prefix(); let sql = format!( "SELECT month, species_code, MAX(species_label) as species_label, \ @@ -455,6 +585,77 @@ async fn get_monthly_breakdown( Ok(Json(result)) } +async fn get_available_filters( + State(state): State, + Query(params): Query, +) -> ApiResult> { + let conn = state.conn.clone(); + + let result = tokio::task::spawn_blocking(move || -> db::Result { + let conn = conn.blocking_lock(); + + let (species_clause, species_args) = params.build_where_prefix_excluding("species"); + let species_arg_refs: Vec<&dyn duckdb::ToSql> = species_args.iter().map(|b| b.as_ref()).collect(); + + let species_sql = if species_clause.is_empty() { + "SELECT DISTINCT species_code FROM landings WHERE species_code != 'TOTAL' ORDER BY species_code" + } else { + "SELECT DISTINCT species_code FROM landings WHERE species_code != 'TOTAL'" + }; + let species_sql = if species_clause.is_empty() { + species_sql.to_string() + } else { + format!("SELECT DISTINCT species_code FROM landings{species_clause} AND species_code != 'TOTAL' ORDER BY species_code") + }; + + let mut stmt = conn.prepare(&species_sql)?; + let species: Vec = stmt + .query_map(species_arg_refs.as_slice(), |row| row.get(0))? + .collect::, duckdb::Error>>()?; + + let (zone_clause, zone_args) = params.build_where_prefix_excluding("zone"); + let zone_arg_refs: Vec<&dyn duckdb::ToSql> = zone_args.iter().map(|b| b.as_ref()).collect(); + + let zone_sql = if zone_clause.is_empty() { + "SELECT DISTINCT zone_code FROM landings ORDER BY zone_code".to_string() + } else { + format!("SELECT DISTINCT zone_code FROM landings{zone_clause} ORDER BY zone_code") + }; + + let mut stmt = conn.prepare(&zone_sql)?; + let zones: Vec = stmt + .query_map(zone_arg_refs.as_slice(), |row| row.get(0))? + .collect::, duckdb::Error>>()?; + + let (gear_clause, gear_args) = params.build_where_prefix_excluding("gear"); + let gear_arg_refs: Vec<&dyn duckdb::ToSql> = gear_args.iter().map(|b| b.as_ref()).collect(); + + let gear_sql = if gear_clause.is_empty() { + "SELECT DISTINCT gear_code FROM landings ORDER BY gear_code".to_string() + } else { + format!("SELECT DISTINCT gear_code FROM landings{gear_clause} ORDER BY gear_code") + }; + + let mut stmt = conn.prepare(&gear_sql)?; + let gear: Vec = stmt + .query_map(gear_arg_refs.as_slice(), |row| row.get(0))? + .collect::, duckdb::Error>>()?; + + Ok(AvailableFiltersDto { + species, + zones, + gear, + }) + }) + .await + .map_err(|e| ApiError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Task join error: {e}"), + })??; + + Ok(Json(result)) +} + async fn export_parquet(State(state): State) -> ApiResult { let conn = state.conn.clone(); @@ -929,6 +1130,52 @@ mod tests { } } + #[tokio::test] + async fn test_get_summary_with_multi_species_filter() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/summary?species_multi=COD,HER")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: SummaryDto = resp.json().await.unwrap(); + assert_eq!(body.monthly.len(), 2); + + let jan = body.monthly.iter().find(|m| m.month == "2024M01").unwrap(); + assert!((jan.total_mass.unwrap() - 3000.0).abs() < f64::EPSILON); + assert!((jan.total_value.unwrap() - 8000.0).abs() < f64::EPSILON); + } + + #[tokio::test] + async fn test_get_summary_with_zone_filter() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/summary?zone=TOTAL")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: SummaryDto = resp.json().await.unwrap(); + assert!(!body.monthly.is_empty()); + } + + #[tokio::test] + async fn test_get_summary_with_gear_filter() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/summary?gear=TOTAL")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: SummaryDto = resp.json().await.unwrap(); + assert!(!body.monthly.is_empty()); + } + #[tokio::test] async fn test_get_summary_with_month_range_filter() { let state = test_state(); @@ -1188,4 +1435,51 @@ mod tests { assert_eq!(row.species_label, "Toskur"); } } + + #[tokio::test] + async fn test_get_available_filters_no_constraints() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/available-filters")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: AvailableFiltersDto = resp.json().await.unwrap(); + assert!(body.species.contains(&"COD".to_string())); + assert!(body.zones.contains(&"TOTAL".to_string())); + assert!(body.gear.contains(&"TOTAL".to_string())); + } + + #[tokio::test] + async fn test_get_available_filters_with_species_constraint() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/available-filters?species_multi=COD")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: AvailableFiltersDto = resp.json().await.unwrap(); + assert!(body.species.contains(&"COD".to_string())); + assert!(body.zones.contains(&"TOTAL".to_string())); + assert!(body.gear.contains(&"TOTAL".to_string())); + } + + #[tokio::test] + async fn test_get_available_filters_excludes_unrelated_species() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/available-filters?zone_multi=TOTAL")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: AvailableFiltersDto = resp.json().await.unwrap(); + assert!(body.species.contains(&"COD".to_string())); + assert!(body.species.contains(&"HER".to_string())); + } } diff --git a/src/types.rs b/src/types.rs index f2eb27b..d0f25cd 100644 --- a/src/types.rs +++ b/src/types.rs @@ -47,6 +47,13 @@ pub struct MetadataResponse { pub variables: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailableFiltersDto { + pub species: Vec, + pub zones: Vec, + pub gear: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct VariableMeta { pub code: String, diff --git a/static/app.js b/static/app.js index c6d1a9d..e4a2d05 100644 --- a/static/app.js +++ b/static/app.js @@ -351,15 +351,92 @@ async function fetchData(endpoint) { } } -function populateSelect(id, items, codeKey, labelKey) { - const sel = document.getElementById(id); - sel.innerHTML = ''; +function populateDropdown(containerId, items, codeKey, labelKey) { + const container = document.getElementById(containerId); + if (!container) return; + + const checkboxList = container.querySelector('.checkbox-dropdown-list'); + checkboxList.innerHTML = ''; + + const selectAllLabel = document.createElement('label'); + selectAllLabel.className = 'checkbox-item select-all-wrapper'; + selectAllLabel.innerHTML = ` + + All + `; + checkboxList.appendChild(selectAllLabel); + + const separator = document.createElement('div'); + separator.className = 'dropdown-separator'; + checkboxList.appendChild(separator); + items.forEach((item) => { - const opt = document.createElement('option'); - opt.value = item[codeKey]; - opt.textContent = item[labelKey]; - sel.appendChild(opt); + const label = document.createElement('label'); + label.className = 'checkbox-item'; + label.innerHTML = ` + + ${item[labelKey]} + `; + checkboxList.appendChild(label); }); + + const selectAllCb = selectAllLabel.querySelector('.select-all'); + selectAllCb.addEventListener('change', (e) => { + const cbs = checkboxList.querySelectorAll(`input[name="${containerId}"]`); + cbs.forEach(cb => { + if (!cb.disabled) { + cb.checked = e.target.checked; + } + }); + updateDropdownLabel(containerId); + }); + + checkboxList.querySelectorAll(`input[name="${containerId}"]`).forEach(cb => { + cb.addEventListener('change', () => updateDropdownLabel(containerId)); + }); + + updateDropdownLabel(containerId); +} + +function updateDropdownLabel(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + const cbs = container.querySelectorAll(`input[name="${containerId}"]`); + const checked = Array.from(cbs).filter(cb => cb.checked && !cb.disabled); + const labelEl = container.querySelector('.dropdown-label'); + + if (checked.length === 0) { + labelEl.textContent = 'None selected'; + } else if (checked.length === cbs.length) { + labelEl.textContent = 'All selected'; + } else { + labelEl.textContent = `${checked.length} selected`; + } +} + +function toggleDropdown(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + const list = container.querySelector('.checkbox-dropdown-list'); + const isOpen = list.classList.contains('open'); + + document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => { + el.classList.remove('open'); + }); + + if (!isOpen) { + list.classList.add('open'); + } +} + +function getSelectedValues(groupName) { + const checkboxes = document.querySelectorAll(`input[name="${groupName}"]:checked`); + return Array.from(checkboxes).filter(cb => !cb.disabled).map(cb => cb.value); } function populateMonthSelect(selectId, months) { @@ -383,15 +460,15 @@ async function populateDropdowns() { ]); if (speciesList) { - populateSelect('species', speciesList, 'code', 'label'); + populateDropdown('species', speciesList, 'code', 'label'); } if (zonesList) { - populateSelect('zone', zonesList, 'code', 'label'); + populateDropdown('zone', zonesList, 'code', 'label'); } if (gearList) { - populateSelect('gear', gearList, 'code', 'label'); + populateDropdown('gear', gearList, 'code', 'label'); } if (summaryData && summaryData.monthly) { @@ -405,16 +482,62 @@ async function populateDropdowns() { document.getElementById('month_to').value = allMonths[allMonths.length - 1]; } + await updateAvailableFilters(); + showLoading(false); } +async function updateAvailableFilters() { + const species = getSelectedValues('species'); + const zone = getSelectedValues('zone'); + const gear = getSelectedValues('gear'); + const monthFrom = document.getElementById('month_from').value; + const monthTo = document.getElementById('month_to').value; + + const params = new URLSearchParams(); + if (species.length > 0) params.append('species_multi', species.join(',')); + if (zone.length > 0) params.append('zone_multi', zone.join(',')); + if (gear.length > 0) params.append('gear_multi', gear.join(',')); + if (monthFrom) params.append('month_from', monthFrom); + if (monthTo) params.append('month_to', monthTo); + + const available = await fetchData(`/api/available-filters?${params.toString()}`); + + if (available) { + disableUnavailableOptions('species', available.species); + disableUnavailableOptions('zone', available.zones); + disableUnavailableOptions('gear', available.gear); + } +} + +function disableUnavailableOptions(containerId, availableCodes) { + const container = document.getElementById(containerId); + if (!container) return; + + const checkboxes = container.querySelectorAll(`input[type="checkbox"]`); + checkboxes.forEach(cb => { + if (cb.classList.contains('select-all')) return; + + const isAvailable = availableCodes.includes(cb.value); + cb.disabled = !isAvailable; + cb.parentElement.style.opacity = isAvailable ? '1' : '0.4'; + cb.parentElement.style.cursor = isAvailable ? 'pointer' : 'not-allowed'; + + if (!isAvailable && cb.checked) { + cb.checked = false; + } + }); + + updateDropdownLabel(containerId); +} + async function updateCharts() { showLoading(true); hideEmptyState(); - const species = document.getElementById('species').value; - const zone = document.getElementById('zone').value; - const gear = document.getElementById('gear').value; + const species = getSelectedValues('species'); + const zone = getSelectedValues('zone'); + const gear = getSelectedValues('gear'); const monthFrom = document.getElementById('month_from').value; const monthTo = document.getElementById('month_to').value; @@ -425,7 +548,9 @@ async function updateCharts() { } const summaryParams = new URLSearchParams(); - if (species) summaryParams.append('species', species); + if (species.length > 0) summaryParams.append('species_multi', species.join(',')); + if (zone.length > 0) summaryParams.append('zone_multi', zone.join(',')); + if (gear.length > 0) summaryParams.append('gear_multi', gear.join(',')); if (monthFrom) summaryParams.append('month_from', monthFrom); if (monthTo) summaryParams.append('month_to', monthTo); @@ -526,6 +651,15 @@ function hideEmptyState() { document.getElementById('apply-filters').addEventListener('click', updateCharts); +// Close dropdowns when clicking outside +document.addEventListener('click', (e) => { + if (!e.target.closest('.filter-group.multi-select')) { + document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => { + el.classList.remove('open'); + }); + } +}); + (() => { function currentSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches @@ -558,5 +692,31 @@ document.getElementById('apply-filters').addEventListener('click', updateCharts) document.addEventListener('DOMContentLoaded', () => { initCharts(); + + // Setup click handlers for dropdown toggles + ['species', 'zone', 'gear'].forEach(id => { + const container = document.getElementById(id); + if (container) { + const label = container.querySelector('.dropdown-label'); + if (label) { + label.addEventListener('click', (e) => { + e.stopPropagation(); + toggleDropdown(id); + }); + } + + // Listen for changes inside the dropdown list + const list = container.querySelector('.checkbox-dropdown-list'); + if (list) { + list.addEventListener('change', () => { + updateAvailableFilters(); + }); + } + } + }); + + document.getElementById('month_from').addEventListener('change', updateAvailableFilters); + document.getElementById('month_to').addEventListener('change', updateAvailableFilters); + populateDropdowns().then(updateCharts); }); diff --git a/static/index.html b/static/index.html index 09d3f7c..d9f35f1 100644 --- a/static/index.html +++ b/static/index.html @@ -36,17 +36,20 @@
-
- - +
+ + +
-
- - +
+ + +
-
- - +
+ + +
diff --git a/static/style.css b/static/style.css index 8c24fcf..e18fcf6 100644 --- a/static/style.css +++ b/static/style.css @@ -207,6 +207,7 @@ nav { display: flex; flex-direction: column; gap: 0.4rem; + position: relative; } .filter-group label { @@ -231,6 +232,91 @@ nav { } .filter-group select:focus { border-color: var(--accent); } +/* ---- Multi-Select Dropdown ---- */ +.filter-group.multi-select { + position: relative; +} + +.dropdown-trigger { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 2px; + color: var(--ink); + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: border-color 0.2s; +} + +.dropdown-trigger:hover { + border-color: var(--accent); +} + +.dropdown-trigger::after { + content: '▼'; + font-size: 0.6rem; + margin-left: 0.5rem; + opacity: 0.6; +} + +.checkbox-dropdown-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 200px; + overflow-y: auto; + background: var(--bg-panel); + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 2px 2px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + display: none; + padding: 0.5rem 0; +} + +.checkbox-dropdown-list.open { + display: block; +} + +.dropdown-separator { + border-top: 1px solid var(--border); + margin: 0.25rem 0; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.1s; +} + +.checkbox-item:hover { + background: var(--surface-hover); +} + +.checkbox-item input[type="checkbox"] { + cursor: pointer; + accent-color: var(--accent); + margin-right: 0.5rem; +} + +.select-all-wrapper { + font-weight: 600; + border-bottom: 1px solid var(--border); + margin-bottom: 0.25rem; + padding-bottom: 0.25rem; +} + .apply-btn { align-self: flex-end; padding: 0.5rem 1.2rem; @@ -269,8 +355,8 @@ nav { height: 450px; } -.chart-container:nth-child(2), /* Donut */ -.chart-container:nth-child(3) { /* Bar */ +.chart-container:nth-child(2), +.chart-container:nth-child(3) { height: 350px; }