diff --git a/_data/costs-and-benefits.yaml b/_data/costs-and-benefits.yaml new file mode 100644 index 000000000..a21edcf06 --- /dev/null +++ b/_data/costs-and-benefits.yaml @@ -0,0 +1,2348 @@ +buildings_measures: +- id: 1 + building_category: Rodinný dům uhlí – E + measure_name: Uhelný kotel + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 15.0 + efficiency: 0.85 + lifetime: 15 + demand_heat_measure_mwh: 29 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 85000 + capex_installation_czk: 13000 + capex_preparation_czk: 0 + opex_maintenance_czk: 3500 + emissions_embedded_kg: 350 +- id: 2 + building_category: Rodinný dům uhlí – E + measure_name: Renovace bez zateplení + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 40 + demand_heat_measure_mwh: 29 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 220000 + capex_installation_czk: 170000 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 5200 +- id: 3 + building_category: Rodinný dům uhlí – E + measure_name: Nedělám nic + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 40 + demand_heat_measure_mwh: 29 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 0 + capex_installation_czk: 0 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 0 +- id: 4 + building_category: Rodinný dům uhlí – C + measure_name: Uhelný kotel + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 10.0 + efficiency: 0.85 + lifetime: 15 + demand_heat_measure_mwh: 18 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 65000 + capex_installation_czk: 13000 + capex_preparation_czk: 0 + opex_maintenance_czk: 3500 + emissions_embedded_kg: 350 +- id: 5 + building_category: Rodinný dům uhlí – C + measure_name: Renovace bez zateplení + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 40 + demand_heat_measure_mwh: 18 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 220000 + capex_installation_czk: 170000 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 5200 +- id: 6 + building_category: Rodinný dům uhlí – C + measure_name: Nedělám nic + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 40 + demand_heat_measure_mwh: 18 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 0 + capex_installation_czk: 0 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 0 +- id: 7 + building_category: Rodinný dům plyn – E + measure_name: Plynový kotel + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 15.0 + efficiency: 1.05 + lifetime: 15 + demand_heat_measure_mwh: 24 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 68000 + capex_installation_czk: 27000 + capex_preparation_czk: 0 + opex_maintenance_czk: 2500 + emissions_embedded_kg: 250 +- id: 8 + building_category: Rodinný dům plyn – E + measure_name: Renovace bez zateplení + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 24 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 220000 + capex_installation_czk: 170000 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 5200 +- id: 9 + building_category: Rodinný dům plyn – E + measure_name: Nedělám nic + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 24 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 0 + capex_installation_czk: 0 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 0 +- id: 10 + building_category: Rodinný dům plyn – C + measure_name: Plynový kotel + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 10.0 + efficiency: 1.05 + lifetime: 15 + demand_heat_measure_mwh: 14 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 68000 + capex_installation_czk: 27000 + capex_preparation_czk: 0 + opex_maintenance_czk: 2500 + emissions_embedded_kg: 250 +- id: 11 + building_category: Rodinný dům plyn – C + measure_name: Renovace bez zateplení + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 14 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 220000 + capex_installation_czk: 170000 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 5200 +- id: 12 + building_category: Rodinný dům plyn – C + measure_name: Nedělám nic + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 14 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 0 + capex_installation_czk: 0 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 0 +- id: 13 + building_category: Byt ve starší zástavbě s vlastním plynovým kotlem + measure_name: Plynový kotel + dwellings: 8 + demand_heat_building_mwh: 10 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 15 + demand_heat_measure_mwh: 10 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 58000 + capex_installation_czk: 17000 + capex_preparation_czk: 0 + opex_maintenance_czk: 2500 + emissions_embedded_kg: 250 +- id: 14 + building_category: Byt ve starší zástavbě s vlastním plynovým kotlem + measure_name: Renovace bez zateplení + dwellings: 8 + demand_heat_building_mwh: 10 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 10 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 62500 + capex_installation_czk: 50000 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 1500 +- id: 15 + building_category: Byt ve starší zástavbě s vlastním plynovým kotlem + measure_name: Nedělám nic + dwellings: 8 + demand_heat_building_mwh: 10 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 10 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 0 + capex_installation_czk: 0 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 0 +- id: 16 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Plynový kotel + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 15 + demand_heat_measure_mwh: 6 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 58000 + capex_installation_czk: 17000 + capex_preparation_czk: 0 + opex_maintenance_czk: 2500 + emissions_embedded_kg: 250 +- id: 17 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Plynová kotelna + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 0.95 + lifetime: 15 + demand_heat_measure_mwh: 6 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 90000 + capex_installation_czk: 35000 + capex_preparation_czk: 21667 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 300 +- id: 18 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Renovace bez zateplení + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 0.95 + lifetime: 40 + demand_heat_measure_mwh: 6 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 50000 + capex_installation_czk: 40000 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 1200 +- id: 19 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Nedělám nic + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 0.95 + lifetime: 40 + demand_heat_measure_mwh: 6 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 0 + capex_installation_czk: 0 + capex_preparation_czk: 0 + opex_maintenance_czk: 0 + emissions_embedded_kg: 0 +- id: 20 + building_category: Rodinný dům uhlí – E + measure_name: Tepelné čerpadlo + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 10.0 + efficiency: 2.8 + lifetime: 15 + demand_heat_measure_mwh: 9 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 200000 + capex_installation_czk: 50000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 1800 + measure_baseline: Uhelný kotel + measure_baseline_id: 1 +- id: 21 + building_category: Rodinný dům uhlí – E + measure_name: Kotel na biomasu + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Biomass + capacity_kw: 15.0 + efficiency: 0.9 + lifetime: 15 + demand_heat_measure_mwh: 28 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 95000 + capex_installation_czk: 22000 + capex_preparation_czk: 23000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 500 + measure_baseline: Uhelný kotel + measure_baseline_id: 1 +- id: 22 + building_category: Rodinný dům uhlí – E + measure_name: Soláry na střeše+baterie + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 5.0 + efficiency: 0.85 + lifetime: 25 + demand_heat_measure_mwh: 29 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.5 + capex_technology_czk: 220000 + capex_installation_czk: 80000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 3000 + emissions_embedded_kg: 2700 + measure_baseline: Nedělám nic + measure_baseline_id: 3 +- id: 23 + building_category: Rodinný dům uhlí – E + measure_name: Elektrický kotel + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 15.0 + efficiency: 0.99 + lifetime: 15 + demand_heat_measure_mwh: 25 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 42000 + capex_installation_czk: 18000 + capex_preparation_czk: 0 + opex_maintenance_czk: 1000 + emissions_embedded_kg: 150 + measure_baseline: Uhelný kotel + measure_baseline_id: 1 +- id: 24 + building_category: Rodinný dům uhlí – E + measure_name: Zateplení + fasáda + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 40 + demand_heat_measure_mwh: 15 + demand_electricity_measure_mwh: 4 + energy_savings: 0.5 + electricity_savings: 0.0 + capex_technology_czk: 430000 + capex_installation_czk: 350000 + capex_preparation_czk: 0 + opex_maintenance_czk: 1000 + emissions_embedded_kg: 7500 + measure_baseline: Renovace bez zateplení + measure_baseline_id: 2 +- id: 25 + building_category: Rodinný dům uhlí – E + measure_name: Výměna oken a dveří + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 25 + demand_heat_measure_mwh: 24 + demand_electricity_measure_mwh: 4 + energy_savings: 0.2 + electricity_savings: 0.0 + capex_technology_czk: 200000 + capex_installation_czk: 120000 + capex_preparation_czk: 0 + opex_maintenance_czk: 800 + emissions_embedded_kg: 900 + measure_baseline: Nedělám nic + measure_baseline_id: 3 +- id: 26 + building_category: Rodinný dům uhlí – C + measure_name: Tepelné čerpadlo + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 8.0 + efficiency: 3.5 + lifetime: 15 + demand_heat_measure_mwh: 4 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 160000 + capex_installation_czk: 50000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 1800 + measure_baseline: Uhelný kotel + measure_baseline_id: 4 +- id: 27 + building_category: Rodinný dům uhlí – C + measure_name: Kotel na biomasu + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Biomass + capacity_kw: 10.0 + efficiency: 0.9 + lifetime: 15 + demand_heat_measure_mwh: 17 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 70000 + capex_installation_czk: 22000 + capex_preparation_czk: 23000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 500 + measure_baseline: Uhelný kotel + measure_baseline_id: 4 +- id: 28 + building_category: Rodinný dům uhlí – C + measure_name: Soláry na střeše+baterie + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 5.0 + efficiency: 0.85 + lifetime: 25 + demand_heat_measure_mwh: 18 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.5 + capex_technology_czk: 220000 + capex_installation_czk: 80000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 3000 + emissions_embedded_kg: 2700 + measure_baseline: Nedělám nic + measure_baseline_id: 6 +- id: 29 + building_category: Rodinný dům uhlí – C + measure_name: Elektrický kotel + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 10.0 + efficiency: 0.99 + lifetime: 15 + demand_heat_measure_mwh: 15 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 38000 + capex_installation_czk: 18000 + capex_preparation_czk: 0 + opex_maintenance_czk: 1000 + emissions_embedded_kg: 150 + measure_baseline: Uhelný kotel + measure_baseline_id: 4 +- id: 30 + building_category: Rodinný dům uhlí – C + measure_name: Zateplení + fasáda + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 40 + demand_heat_measure_mwh: 11 + demand_electricity_measure_mwh: 4 + energy_savings: 0.4 + electricity_savings: 0.0 + capex_technology_czk: 330000 + capex_installation_czk: 350000 + capex_preparation_czk: 0 + opex_maintenance_czk: 1000 + emissions_embedded_kg: 7500 + measure_baseline: Renovace bez zateplení + measure_baseline_id: 5 +- id: 31 + building_category: Rodinný dům uhlí – C + measure_name: Výměna oken a dveří + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Lignite + capacity_kw: 0.0 + efficiency: 0.85 + lifetime: 25 + demand_heat_measure_mwh: 14 + demand_electricity_measure_mwh: 4 + energy_savings: 0.2 + electricity_savings: 0.0 + capex_technology_czk: 200000 + capex_installation_czk: 120000 + capex_preparation_czk: 0 + opex_maintenance_czk: 800 + emissions_embedded_kg: 900 + measure_baseline: Nedělám nic + measure_baseline_id: 6 +- id: 32 + building_category: Rodinný dům plyn – E + measure_name: Tepelné čerpadlo + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 10.0 + efficiency: 2.8 + lifetime: 15 + demand_heat_measure_mwh: 9 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 200000 + capex_installation_czk: 50000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 1800 + measure_baseline: Plynový kotel + measure_baseline_id: 7 +- id: 33 + building_category: Rodinný dům plyn – E + measure_name: Kotel na biomasu + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Biomass + capacity_kw: 15.0 + efficiency: 0.9 + lifetime: 15 + demand_heat_measure_mwh: 28 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 95000 + capex_installation_czk: 22000 + capex_preparation_czk: 23000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 500 + measure_baseline: Plynový kotel + measure_baseline_id: 7 +- id: 34 + building_category: Rodinný dům plyn – E + measure_name: Soláry na střeše+baterie + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 5.0 + efficiency: 1.05 + lifetime: 25 + demand_heat_measure_mwh: 24 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.5 + capex_technology_czk: 220000 + capex_installation_czk: 80000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 3000 + emissions_embedded_kg: 2700 + measure_baseline: Nedělám nic + measure_baseline_id: 9 +- id: 35 + building_category: Rodinný dům plyn – E + measure_name: Elektrický kotel + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 15.0 + efficiency: 0.99 + lifetime: 15 + demand_heat_measure_mwh: 25 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 42000 + capex_installation_czk: 18000 + capex_preparation_czk: 0 + opex_maintenance_czk: 2000 + emissions_embedded_kg: 150 + measure_baseline: Plynový kotel + measure_baseline_id: 7 +- id: 36 + building_category: Rodinný dům plyn – E + measure_name: Zateplení + fasáda + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 14 + demand_electricity_measure_mwh: 4 + energy_savings: 0.4 + electricity_savings: 0.0 + capex_technology_czk: 430000 + capex_installation_czk: 350000 + capex_preparation_czk: 0 + opex_maintenance_czk: 1000 + emissions_embedded_kg: 7500 + measure_baseline: Renovace bez zateplení + measure_baseline_id: 8 +- id: 37 + building_category: Rodinný dům plyn – E + measure_name: Výměna oken a dveří + dwellings: 1 + demand_heat_building_mwh: 25 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 25 + demand_heat_measure_mwh: 19 + demand_electricity_measure_mwh: 4 + energy_savings: 0.2 + electricity_savings: 0.0 + capex_technology_czk: 200000 + capex_installation_czk: 120000 + capex_preparation_czk: 0 + opex_maintenance_czk: 800 + emissions_embedded_kg: 900 + measure_baseline: Nedělám nic + measure_baseline_id: 9 +- id: 38 + building_category: Rodinný dům plyn – C + measure_name: Tepelné čerpadlo + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 8.0 + efficiency: 3.5 + lifetime: 15 + demand_heat_measure_mwh: 4 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 160000 + capex_installation_czk: 50000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 1800 + measure_baseline: Plynový kotel + measure_baseline_id: 10 +- id: 39 + building_category: Rodinný dům plyn – C + measure_name: Kotel na biomasu + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Biomass + capacity_kw: 10.0 + efficiency: 0.9 + lifetime: 15 + demand_heat_measure_mwh: 17 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 70000 + capex_installation_czk: 22000 + capex_preparation_czk: 23000 + opex_maintenance_czk: 5000 + emissions_embedded_kg: 500 + measure_baseline: Plynový kotel + measure_baseline_id: 10 +- id: 40 + building_category: Rodinný dům plyn – C + measure_name: Soláry na střeše+baterie + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 5.0 + efficiency: 1.05 + lifetime: 25 + demand_heat_measure_mwh: 14 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.5 + capex_technology_czk: 220000 + capex_installation_czk: 80000 + capex_preparation_czk: 50000 + opex_maintenance_czk: 3000 + emissions_embedded_kg: 2700 + measure_baseline: Nedělám nic + measure_baseline_id: 12 +- id: 41 + building_category: Rodinný dům plyn – C + measure_name: Elektrický kotel + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Electricity + capacity_kw: 10.0 + efficiency: 0.99 + lifetime: 15 + demand_heat_measure_mwh: 15 + demand_electricity_measure_mwh: 4 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 38000 + capex_installation_czk: 18000 + capex_preparation_czk: 0 + opex_maintenance_czk: 2000 + emissions_embedded_kg: 150 + measure_baseline: Plynový kotel + measure_baseline_id: 10 +- id: 42 + building_category: Rodinný dům plyn – C + measure_name: Zateplení + fasáda + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 9 + demand_electricity_measure_mwh: 4 + energy_savings: 0.4 + electricity_savings: 0.0 + capex_technology_czk: 330000 + capex_installation_czk: 350000 + capex_preparation_czk: 0 + opex_maintenance_czk: 1000 + emissions_embedded_kg: 7500 + measure_baseline: Renovace bez zateplení + measure_baseline_id: 11 +- id: 43 + building_category: Rodinný dům plyn – C + measure_name: Výměna oken a dveří + dwellings: 1 + demand_heat_building_mwh: 15 + demand_electricity_mwh: 4 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 25 + demand_heat_measure_mwh: 11 + demand_electricity_measure_mwh: 4 + energy_savings: 0.2 + electricity_savings: 0.0 + capex_technology_czk: 200000 + capex_installation_czk: 120000 + capex_preparation_czk: 0 + opex_maintenance_czk: 800 + emissions_embedded_kg: 900 + measure_baseline: Nedělám nic + measure_baseline_id: 12 +- id: 44 + building_category: Byt ve starší zástavbě s vlastním plynovým kotlem + measure_name: Elektrický kotel + dwellings: 8 + demand_heat_building_mwh: 10 + demand_electricity_mwh: 2 + fuel: Electricity + capacity_kw: 0.0 + efficiency: 0.99 + lifetime: 15 + demand_heat_measure_mwh: 10 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 33000 + capex_installation_czk: 17000 + capex_preparation_czk: 0 + opex_maintenance_czk: 2000 + emissions_embedded_kg: 150 + measure_baseline: Plynový kotel + measure_baseline_id: 13 +- id: 45 + building_category: Byt ve starší zástavbě s vlastním plynovým kotlem + measure_name: Výměna oken a dveří + dwellings: 8 + demand_heat_building_mwh: 10 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 25 + demand_heat_measure_mwh: 8 + demand_electricity_measure_mwh: 2 + energy_savings: 0.15 + electricity_savings: 0.0 + capex_technology_czk: 65000 + capex_installation_czk: 20000 + capex_preparation_czk: 0 + opex_maintenance_czk: 500 + emissions_embedded_kg: 175 + measure_baseline: Nedělám nic + note: rozpočítané na jeden byt (v rámci menšího domu s 8 byty) + measure_baseline_id: 15 +- id: 46 + building_category: Byt ve starší zástavbě s vlastním plynovým kotlem + measure_name: Zateplení + fasáda + dwellings: 8 + demand_heat_building_mwh: 10 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 1.05 + lifetime: 40 + demand_heat_measure_mwh: 5 + demand_electricity_measure_mwh: 2 + energy_savings: 0.5 + electricity_savings: 0.0 + capex_technology_czk: 126000 + capex_installation_czk: 99000 + capex_preparation_czk: 0 + opex_maintenance_czk: 1000 + emissions_embedded_kg: 2175 + measure_baseline: Renovace bez zateplení + note: rozpočítané na jeden byt (v rámci menšího domu s 8 byty) + measure_baseline_id: 14 +- id: 47 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Elektrický kotel + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Electricity + capacity_kw: 0.0 + efficiency: 0.99 + lifetime: 15 + demand_heat_measure_mwh: 6 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 15000 + capex_installation_czk: 5000 + capex_preparation_czk: 0 + opex_maintenance_czk: 150 + emissions_embedded_kg: 150 + measure_baseline: Plynová kotelna + note: společný kotel rozpočítaný na jeden byt (v rámci menšího domu s 15 byty) + measure_baseline_id: 17 +- id: 48 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Tepelné čerpadlo + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Electricity + capacity_kw: 0.0 + efficiency: 3.2 + lifetime: 15 + demand_heat_measure_mwh: 2 + demand_electricity_measure_mwh: 2 + energy_savings: 0.0 + electricity_savings: 0.0 + capex_technology_czk: 50000 + capex_installation_czk: 20000 + capex_preparation_czk: 10000 + opex_maintenance_czk: 2300 + emissions_embedded_kg: 800 + measure_baseline: Plynová kotelna + note: společné čerpadlo rozpočítané na jeden byt (v rámci menšího domu s 15 byty) + measure_baseline_id: 17 +- id: 49 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Výměna oken a dveří + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 0.95 + lifetime: 25 + demand_heat_measure_mwh: 5 + demand_electricity_measure_mwh: 2 + energy_savings: 0.15 + electricity_savings: 0.0 + capex_technology_czk: 60000 + capex_installation_czk: 20000 + capex_preparation_czk: 0 + opex_maintenance_czk: 200 + emissions_embedded_kg: 200 + measure_baseline: Nedělám nic + note: rozpočítané na jeden byt (v rámci menšího domu s 15 byty) + measure_baseline_id: 19 +- id: 50 + building_category: Byt v panelovém domě s plynovou kotelnou + measure_name: Zateplení + fasáda + dwellings: 15 + demand_heat_building_mwh: 6 + demand_electricity_mwh: 2 + fuel: Gas + capacity_kw: 0.0 + efficiency: 0.95 + lifetime: 40 + demand_heat_measure_mwh: 3 + demand_electricity_measure_mwh: 2 + energy_savings: 0.5 + electricity_savings: 0.0 + capex_technology_czk: 80000 + capex_installation_czk: 70000 + capex_preparation_czk: 0 + opex_maintenance_czk: 600 + emissions_embedded_kg: 1740 + measure_baseline: Renovace bez zateplení + note: rozpočítané na jeden byt (v rámci menšího domu s 15 byty) + measure_baseline_id: 18 +transport_measures: +- id: 51 + transport_category: Nové malé + measure_name: Nové malé auto na benzín + demand_energy_per_100km: 6.9 + mileage: 15000 + fuel: Petrol + lifetime: 15 + capex_czk: 490000 + opex_maintenance_czk: 12000 + opex_insurance_czk: 16000 + emissions_embedded_kg: 6000 + note: Fabia – 6,5 l, 15 000 km +- id: 52 + transport_category: Nové velké + measure_name: Nové velké auto na naftu + demand_energy_per_100km: 7.2 + mileage: 20000 + fuel: Diesel + lifetime: 15 + capex_czk: 1150000 + opex_maintenance_czk: 20000 + opex_insurance_czk: 24000 + emissions_embedded_kg: 8500 + note: Kodiaq – 8 l, 20 000 km +- id: 53 + transport_category: Ojeté malé + measure_name: Ojeté malé auto na benzín + demand_energy_per_100km: 7.5 + mileage: 15000 + fuel: Petrol + lifetime: 7 + capex_czk: 200000 + opex_maintenance_czk: 18000 + opex_insurance_czk: 7000 + emissions_embedded_kg: 2800 + note: Fabia – 7,5 l, 15 000 km +- id: 54 + transport_category: Ojeté velké + measure_name: Ojeté velké auto na naftu + demand_energy_per_100km: 8.5 + mileage: 20000 + fuel: Diesel + lifetime: 7 + capex_czk: 530000 + opex_maintenance_czk: 25000 + opex_insurance_czk: 10000 + emissions_embedded_kg: 2800 + note: Kodiaq – 8,5 l, 20 000 km +- id: 55 + transport_category: Nové malé + measure_name: Nový malý elektromobil + demand_energy_per_100km: 0.02 + mileage: 15000 + fuel: Electricity + lifetime: 15 + capex_czk: 650000 + opex_maintenance_czk: 5000 + opex_insurance_czk: 18000 + emissions_embedded_kg: 10000 + measure_baseline: Nové malé auto na benzín + note: Elektromobil + measure_baseline_id: 51 +- id: 56 + transport_category: Nové malé + measure_name: Nový malý hybrid + demand_energy_per_100km: 4.5 + mileage: 15000 + fuel: Petrol + lifetime: 15 + capex_czk: 600000 + opex_maintenance_czk: 9000 + opex_insurance_czk: 16000 + emissions_embedded_kg: 7000 + measure_baseline: Nové malé auto na benzín + note: Hybrid – 4,5 l + measure_baseline_id: 51 +- id: 57 + transport_category: Nové velké + measure_name: Nový velký elektromobil + demand_energy_per_100km: 0.0215 + mileage: 20000 + fuel: Electricity + lifetime: 15 + capex_czk: 1130000 + opex_maintenance_czk: 10000 + opex_insurance_czk: 28000 + emissions_embedded_kg: 15000 + measure_baseline: Nové velké auto na naftu + note: Elektromobil + measure_baseline_id: 52 +- id: 58 + transport_category: Nové velké + measure_name: Nový velký hybrid + demand_energy_per_100km: 6.5 + mileage: 20000 + fuel: Petrol + lifetime: 15 + capex_czk: 1200000 + opex_maintenance_czk: 18000 + opex_insurance_czk: 26000 + emissions_embedded_kg: 9500 + measure_baseline: Nové velké auto na naftu + note: Hybrid – 6,5 l + measure_baseline_id: 52 +- id: 59 + transport_category: Ojeté malé + measure_name: Ojetý malý elektromobil + demand_energy_per_100km: 0.017 + mileage: 15000 + fuel: Electricity + lifetime: 7 + capex_czk: 280000 + opex_maintenance_czk: 9000 + opex_insurance_czk: 8000 + emissions_embedded_kg: 4667 + measure_baseline: Ojeté malé auto na benzín + note: Elektromobil + measure_baseline_id: 53 +- id: 60 + transport_category: Ojeté malé + measure_name: Ojetý malý hybrid + demand_energy_per_100km: 5.5 + mileage: 15000 + fuel: Petrol + lifetime: 7 + capex_czk: 350000 + opex_maintenance_czk: 15000 + opex_insurance_czk: 7000 + emissions_embedded_kg: 3267 + measure_baseline: Ojeté malé auto na benzín + note: Hybrid – 5,5 l + measure_baseline_id: 53 +- id: 61 + transport_category: Ojeté velké + measure_name: Ojetý velký elektromobil + demand_energy_per_100km: 0.028 + mileage: 20000 + fuel: Electricity + lifetime: 7 + capex_czk: 480000 + opex_maintenance_czk: 11000 + opex_insurance_czk: 11000 + emissions_embedded_kg: 7000 + measure_baseline: Ojeté velké auto na naftu + note: Elektromobil + measure_baseline_id: 54 +- id: 62 + transport_category: Ojeté velké + measure_name: Ojetý velký hybrid + demand_energy_per_100km: 7.0 + mileage: 20000 + fuel: Petrol + lifetime: 7 + capex_czk: 550000 + opex_maintenance_czk: 17500 + opex_insurance_czk: 10000 + emissions_embedded_kg: 4433 + measure_baseline: Ojeté velké auto na naftu + note: Hybrid – 7 l + measure_baseline_id: 54 +fuel_emission_factors: +- fuel: Gas + unit: MWh + emission_factor: 200.0 +- fuel: Lignite + unit: MWh + emission_factor: 365.0 +- fuel: Biomass + unit: MWh + emission_factor: 0.0 +- fuel: Petrol + unit: l + emission_factor: 2.4 +- fuel: Diesel + unit: l + emission_factor: 2.6 +carbon_cost_scenarios: +- carbon_price_scenario: 0 € – bez ceny uhlíku + carbon_price_eur: 0 +- carbon_price_scenario: 60 € – ETS2 nižší + carbon_price_eur: 60 +- carbon_price_scenario: 100 € – ETS2 vyšší + carbon_price_eur: 100 +- carbon_price_scenario: 200 € – skutečná cena uhlíku + carbon_price_eur: 200 +discount_rate_scenarios: +- discount_rate_scenario: 0 % — bezúročná půjčka + discount_rate: 0.0 +- discount_rate_scenario: 3 % — výhodný úvěr / hypotéka + discount_rate: 0.03 +- discount_rate_scenario: 7 % — výnos akcií / podnikatelský úvěr + discount_rate: 0.07 +electricity_price_scenarios: +- electricity_price_scenario: Nabíjím doma ze sítě + electricity_price_factor: 1.0 +- electricity_price_scenario: Nabíjím převážně doma ze sítě + electricity_price_factor: 1.45 +- electricity_price_scenario: Nabíjím doma ze solárů + electricity_price_factor: 0.5 +- electricity_price_scenario: Nabíjím převážně venku na rychlonabíječce + electricity_price_factor: 2.0 +fuel_scenarios: +- scenario: CP + prices: + - year_calendar: 2026 + year_investment: 1 + gas: 2719 + lignite: 1420 + biomass: 1651 + electricity: 4464 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 401 + - year_calendar: 2027 + year_investment: 2 + gas: 2709 + lignite: 1420 + biomass: 1660 + electricity: 4446 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 381 + - year_calendar: 2028 + year_investment: 3 + gas: 2699 + lignite: 1420 + biomass: 1670 + electricity: 4427 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 362 + - year_calendar: 2029 + year_investment: 4 + gas: 2688 + lignite: 1420 + biomass: 1679 + electricity: 4409 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 342 + - year_calendar: 2030 + year_investment: 5 + gas: 2678 + lignite: 1420 + biomass: 1688 + electricity: 4391 + petrol: 36 + diesel: 33 + electricity_emission_factor_kg_mwh: 322 + - year_calendar: 2031 + year_investment: 6 + gas: 2668 + lignite: 1420 + biomass: 1698 + electricity: 4373 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 302 + - year_calendar: 2032 + year_investment: 7 + gas: 2657 + lignite: 1420 + biomass: 1707 + electricity: 4355 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 283 + - year_calendar: 2033 + year_investment: 8 + gas: 2647 + lignite: 1420 + biomass: 1716 + electricity: 4336 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 263 + - year_calendar: 2034 + year_investment: 9 + gas: 2637 + lignite: 1420 + biomass: 1725 + electricity: 4318 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 243 + - year_calendar: 2035 + year_investment: 10 + gas: 2626 + lignite: 1420 + biomass: 1735 + electricity: 4300 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 224 + - year_calendar: 2036 + year_investment: 11 + gas: 2636 + lignite: 1420 + biomass: 1744 + electricity: 4283 + petrol: 37 + diesel: 34 + electricity_emission_factor_kg_mwh: 204 + - year_calendar: 2037 + year_investment: 12 + gas: 2645 + lignite: 1420 + biomass: 1754 + electricity: 4267 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 184 + - year_calendar: 2038 + year_investment: 13 + gas: 2655 + lignite: 1420 + biomass: 1763 + electricity: 4250 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 164 + - year_calendar: 2039 + year_investment: 14 + gas: 2664 + lignite: 1420 + biomass: 1773 + electricity: 4233 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 145 + - year_calendar: 2040 + year_investment: 15 + gas: 2674 + lignite: 1420 + biomass: 1782 + electricity: 4217 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 125 + - year_calendar: 2041 + year_investment: 16 + gas: 2683 + lignite: 1420 + biomass: 1792 + electricity: 4200 + petrol: 38 + diesel: 35 + electricity_emission_factor_kg_mwh: 120 + - year_calendar: 2042 + year_investment: 17 + gas: 2693 + lignite: 1420 + biomass: 1801 + electricity: 4183 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 115 + - year_calendar: 2043 + year_investment: 18 + gas: 2702 + lignite: 1420 + biomass: 1811 + electricity: 4167 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 110 + - year_calendar: 2044 + year_investment: 19 + gas: 2712 + lignite: 1420 + biomass: 1820 + electricity: 4150 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 105 + - year_calendar: 2045 + year_investment: 20 + gas: 2721 + lignite: 1420 + biomass: 1830 + electricity: 4133 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 100 + - year_calendar: 2046 + year_investment: 21 + gas: 2731 + lignite: 1420 + biomass: 1839 + electricity: 4117 + petrol: 39 + diesel: 36 + electricity_emission_factor_kg_mwh: 95 + - year_calendar: 2047 + year_investment: 22 + gas: 2740 + lignite: 1420 + biomass: 1849 + electricity: 4100 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 90 + - year_calendar: 2048 + year_investment: 23 + gas: 2750 + lignite: 1420 + biomass: 1859 + electricity: 4083 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 85 + - year_calendar: 2049 + year_investment: 24 + gas: 2759 + lignite: 1420 + biomass: 1868 + electricity: 4067 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 80 + - year_calendar: 2050 + year_investment: 25 + gas: 2769 + lignite: 1420 + biomass: 1878 + electricity: 4050 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 75 + - year_calendar: 2051 + year_investment: 26 + gas: 2778 + lignite: 1420 + biomass: 1887 + electricity: 4033 + petrol: 40 + diesel: 37 + electricity_emission_factor_kg_mwh: 70 + - year_calendar: 2052 + year_investment: 27 + gas: 2788 + lignite: 1420 + biomass: 1897 + electricity: 4017 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 65 + - year_calendar: 2053 + year_investment: 28 + gas: 2797 + lignite: 1420 + biomass: 1906 + electricity: 4000 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 60 + - year_calendar: 2054 + year_investment: 29 + gas: 2807 + lignite: 1420 + biomass: 1916 + electricity: 3983 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 55 + - year_calendar: 2055 + year_investment: 30 + gas: 2816 + lignite: 1420 + biomass: 1925 + electricity: 3967 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 50 + - year_calendar: 2056 + year_investment: 31 + gas: 2826 + lignite: 1420 + biomass: 1935 + electricity: 3950 + petrol: 41 + diesel: 38 + electricity_emission_factor_kg_mwh: 45 + - year_calendar: 2057 + year_investment: 32 + gas: 2835 + lignite: 1420 + biomass: 1944 + electricity: 3933 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 40 + - year_calendar: 2058 + year_investment: 33 + gas: 2845 + lignite: 1420 + biomass: 1954 + electricity: 3917 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 35 + - year_calendar: 2059 + year_investment: 34 + gas: 2854 + lignite: 1420 + biomass: 1963 + electricity: 3900 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 30 + - year_calendar: 2060 + year_investment: 35 + gas: 2864 + lignite: 1420 + biomass: 1973 + electricity: 3883 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 25 + - year_calendar: 2061 + year_investment: 36 + gas: 2873 + lignite: 1420 + biomass: 1982 + electricity: 3867 + petrol: 42 + diesel: 39 + electricity_emission_factor_kg_mwh: 20 + - year_calendar: 2062 + year_investment: 37 + gas: 2883 + lignite: 1420 + biomass: 1992 + electricity: 3850 + petrol: 42 + diesel: 39 + electricity_emission_factor_kg_mwh: 15 + - year_calendar: 2063 + year_investment: 38 + gas: 2892 + lignite: 1420 + biomass: 2001 + electricity: 3833 + petrol: 42 + diesel: 40 + electricity_emission_factor_kg_mwh: 10 + - year_calendar: 2064 + year_investment: 39 + gas: 2902 + lignite: 1420 + biomass: 2011 + electricity: 3817 + petrol: 42 + diesel: 40 + electricity_emission_factor_kg_mwh: 5 + - year_calendar: 2065 + year_investment: 40 + gas: 2911 + lignite: 1420 + biomass: 2020 + electricity: 3800 + petrol: 42 + diesel: 40 + electricity_emission_factor_kg_mwh: 5 +- scenario: NZ + prices: + - year_calendar: 2026 + year_investment: 1 + gas: 2635 + lignite: 1420 + biomass: 1651 + electricity: 4427 + petrol: 32 + diesel: 29 + electricity_emission_factor_kg_mwh: 401 + carbon_price_eur_nz: 60 + - year_calendar: 2027 + year_investment: 2 + gas: 2582 + lignite: 1420 + biomass: 1660 + electricity: 4391 + petrol: 32 + diesel: 30 + electricity_emission_factor_kg_mwh: 376 + carbon_price_eur_nz: 84 + - year_calendar: 2028 + year_investment: 3 + gas: 2530 + lignite: 1420 + biomass: 1670 + electricity: 4355 + petrol: 32 + diesel: 30 + electricity_emission_factor_kg_mwh: 351 + carbon_price_eur_nz: 107 + - year_calendar: 2029 + year_investment: 4 + gas: 2477 + lignite: 1420 + biomass: 1679 + electricity: 4318 + petrol: 32 + diesel: 30 + electricity_emission_factor_kg_mwh: 326 + carbon_price_eur_nz: 131 + - year_calendar: 2030 + year_investment: 5 + gas: 2424 + lignite: 1420 + biomass: 1688 + electricity: 4282 + petrol: 32 + diesel: 30 + electricity_emission_factor_kg_mwh: 301 + carbon_price_eur_nz: 154 + - year_calendar: 2031 + year_investment: 6 + gas: 2372 + lignite: 1420 + biomass: 1698 + electricity: 4246 + petrol: 32 + diesel: 30 + electricity_emission_factor_kg_mwh: 276 + carbon_price_eur_nz: 156 + - year_calendar: 2032 + year_investment: 7 + gas: 2319 + lignite: 1420 + biomass: 1707 + electricity: 4209 + petrol: 32 + diesel: 30 + electricity_emission_factor_kg_mwh: 251 + carbon_price_eur_nz: 158 + - year_calendar: 2033 + year_investment: 8 + gas: 2266 + lignite: 1420 + biomass: 1716 + electricity: 4173 + petrol: 32 + diesel: 30 + electricity_emission_factor_kg_mwh: 226 + carbon_price_eur_nz: 160 + - year_calendar: 2034 + year_investment: 9 + gas: 2214 + lignite: 1420 + biomass: 1725 + electricity: 4136 + petrol: 33 + diesel: 31 + electricity_emission_factor_kg_mwh: 200 + carbon_price_eur_nz: 162 + - year_calendar: 2035 + year_investment: 10 + gas: 2161 + lignite: 1420 + biomass: 1735 + electricity: 4100 + petrol: 33 + diesel: 31 + electricity_emission_factor_kg_mwh: 175 + carbon_price_eur_nz: 165 + - year_calendar: 2036 + year_investment: 11 + gas: 2160 + lignite: 1420 + biomass: 1744 + electricity: 4057 + petrol: 33 + diesel: 31 + electricity_emission_factor_kg_mwh: 150 + carbon_price_eur_nz: 167 + - year_calendar: 2037 + year_investment: 12 + gas: 2158 + lignite: 1420 + biomass: 1754 + electricity: 4013 + petrol: 33 + diesel: 31 + electricity_emission_factor_kg_mwh: 125 + carbon_price_eur_nz: 169 + - year_calendar: 2038 + year_investment: 13 + gas: 2157 + lignite: 1420 + biomass: 1763 + electricity: 3970 + petrol: 33 + diesel: 31 + electricity_emission_factor_kg_mwh: 100 + carbon_price_eur_nz: 171 + - year_calendar: 2039 + year_investment: 14 + gas: 2156 + lignite: 1420 + biomass: 1773 + electricity: 3927 + petrol: 33 + diesel: 31 + electricity_emission_factor_kg_mwh: 75 + carbon_price_eur_nz: 173 + - year_calendar: 2040 + year_investment: 15 + gas: 2155 + lignite: 1420 + biomass: 1782 + electricity: 3883 + petrol: 34 + diesel: 32 + electricity_emission_factor_kg_mwh: 50 + carbon_price_eur_nz: 175 + - year_calendar: 2041 + year_investment: 16 + gas: 2153 + lignite: 1420 + biomass: 1792 + electricity: 3840 + petrol: 34 + diesel: 32 + electricity_emission_factor_kg_mwh: 31 + carbon_price_eur_nz: 179 + - year_calendar: 2042 + year_investment: 17 + gas: 2152 + lignite: 1420 + biomass: 1801 + electricity: 3797 + petrol: 34 + diesel: 32 + electricity_emission_factor_kg_mwh: 29 + carbon_price_eur_nz: 183 + - year_calendar: 2043 + year_investment: 18 + gas: 2151 + lignite: 1420 + biomass: 1811 + electricity: 3753 + petrol: 34 + diesel: 32 + electricity_emission_factor_kg_mwh: 26 + carbon_price_eur_nz: 186 + - year_calendar: 2044 + year_investment: 19 + gas: 2149 + lignite: 1420 + biomass: 1820 + electricity: 3710 + petrol: 34 + diesel: 32 + electricity_emission_factor_kg_mwh: 24 + carbon_price_eur_nz: 190 + - year_calendar: 2045 + year_investment: 20 + gas: 2148 + lignite: 1420 + biomass: 1830 + electricity: 3667 + petrol: 35 + diesel: 32 + electricity_emission_factor_kg_mwh: 22 + carbon_price_eur_nz: 194 + - year_calendar: 2046 + year_investment: 21 + gas: 2147 + lignite: 1420 + biomass: 1839 + electricity: 3623 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 19 + carbon_price_eur_nz: 198 + - year_calendar: 2047 + year_investment: 22 + gas: 2146 + lignite: 1420 + biomass: 1849 + electricity: 3580 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 17 + carbon_price_eur_nz: 202 + - year_calendar: 2048 + year_investment: 23 + gas: 2144 + lignite: 1420 + biomass: 1859 + electricity: 3537 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 15 + carbon_price_eur_nz: 205 + - year_calendar: 2049 + year_investment: 24 + gas: 2143 + lignite: 1420 + biomass: 1868 + electricity: 3493 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 12 + carbon_price_eur_nz: 209 + - year_calendar: 2050 + year_investment: 25 + gas: 2142 + lignite: 1420 + biomass: 1878 + electricity: 3450 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 10 + carbon_price_eur_nz: 213 + - year_calendar: 2051 + year_investment: 26 + gas: 2141 + lignite: 1420 + biomass: 1887 + electricity: 3407 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 8 + carbon_price_eur_nz: 217 + - year_calendar: 2052 + year_investment: 27 + gas: 2139 + lignite: 1420 + biomass: 1897 + electricity: 3363 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 221 + - year_calendar: 2053 + year_investment: 28 + gas: 2138 + lignite: 1420 + biomass: 1906 + electricity: 3320 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 225 + - year_calendar: 2054 + year_investment: 29 + gas: 2137 + lignite: 1420 + biomass: 1916 + electricity: 3277 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 228 + - year_calendar: 2055 + year_investment: 30 + gas: 2136 + lignite: 1420 + biomass: 1925 + electricity: 3233 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 232 + - year_calendar: 2056 + year_investment: 31 + gas: 2134 + lignite: 1420 + biomass: 1935 + electricity: 3190 + petrol: 37 + diesel: 34 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 236 + - year_calendar: 2057 + year_investment: 32 + gas: 2133 + lignite: 1420 + biomass: 1944 + electricity: 3147 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 240 + - year_calendar: 2058 + year_investment: 33 + gas: 2132 + lignite: 1420 + biomass: 1954 + electricity: 3103 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 244 + - year_calendar: 2059 + year_investment: 34 + gas: 2130 + lignite: 1420 + biomass: 1963 + electricity: 3060 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 248 + - year_calendar: 2060 + year_investment: 35 + gas: 2129 + lignite: 1420 + biomass: 1973 + electricity: 3017 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 252 + - year_calendar: 2061 + year_investment: 36 + gas: 2128 + lignite: 1420 + biomass: 1982 + electricity: 2973 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 256 + - year_calendar: 2062 + year_investment: 37 + gas: 2127 + lignite: 1420 + biomass: 1992 + electricity: 2930 + petrol: 38 + diesel: 35 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 259 + - year_calendar: 2063 + year_investment: 38 + gas: 2125 + lignite: 1420 + biomass: 2001 + electricity: 2887 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 263 + - year_calendar: 2064 + year_investment: 39 + gas: 2124 + lignite: 1420 + biomass: 2011 + electricity: 2843 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 267 + - year_calendar: 2065 + year_investment: 40 + gas: 2123 + lignite: 1420 + biomass: 2020 + electricity: 2800 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 5 + carbon_price_eur_nz: 271 +- scenario: CP_EC + prices: + - year_calendar: 2026 + year_investment: 1 + gas: 2719 + lignite: 1420 + biomass: 1651 + electricity: 4464 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 401 + - year_calendar: 2027 + year_investment: 2 + gas: 2709 + lignite: 1420 + biomass: 1660 + electricity: 4446 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 381 + - year_calendar: 2028 + year_investment: 3 + gas: 2699 + lignite: 1420 + biomass: 1670 + electricity: 4427 + petrol: 35 + diesel: 33 + electricity_emission_factor_kg_mwh: 362 + - year_calendar: 2029 + year_investment: 4 + gas: 4705 + lignite: 1500 + biomass: 2351 + electricity: 5200 + petrol: 50 + diesel: 46 + electricity_emission_factor_kg_mwh: 308 + - year_calendar: 2030 + year_investment: 5 + gas: 4705 + lignite: 1500 + biomass: 2351 + electricity: 6586 + petrol: 50 + diesel: 46 + electricity_emission_factor_kg_mwh: 308 + - year_calendar: 2031 + year_investment: 6 + gas: 6695 + lignite: 1500 + biomass: 3039 + electricity: 8500 + petrol: 64 + diesel: 60 + electricity_emission_factor_kg_mwh: 304 + - year_calendar: 2032 + year_investment: 7 + gas: 6695 + lignite: 1500 + biomass: 3039 + electricity: 8500 + petrol: 64 + diesel: 60 + electricity_emission_factor_kg_mwh: 304 + - year_calendar: 2033 + year_investment: 8 + gas: 4668 + lignite: 1500 + biomass: 2377 + electricity: 6586 + petrol: 50 + diesel: 47 + electricity_emission_factor_kg_mwh: 259 + - year_calendar: 2034 + year_investment: 9 + gas: 3668 + lignite: 1500 + biomass: 2000 + electricity: 5200 + petrol: 46 + diesel: 44 + electricity_emission_factor_kg_mwh: 239 + - year_calendar: 2035 + year_investment: 10 + gas: 2626 + lignite: 1420 + biomass: 1735 + electricity: 4300 + petrol: 36 + diesel: 34 + electricity_emission_factor_kg_mwh: 224 + - year_calendar: 2036 + year_investment: 11 + gas: 2636 + lignite: 1420 + biomass: 1744 + electricity: 4283 + petrol: 37 + diesel: 34 + electricity_emission_factor_kg_mwh: 204 + - year_calendar: 2037 + year_investment: 12 + gas: 2645 + lignite: 1420 + biomass: 1754 + electricity: 4267 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 184 + - year_calendar: 2038 + year_investment: 13 + gas: 2655 + lignite: 1420 + biomass: 1763 + electricity: 4250 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 164 + - year_calendar: 2039 + year_investment: 14 + gas: 2664 + lignite: 1420 + biomass: 1773 + electricity: 4233 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 145 + - year_calendar: 2040 + year_investment: 15 + gas: 2674 + lignite: 1420 + biomass: 1782 + electricity: 4217 + petrol: 37 + diesel: 35 + electricity_emission_factor_kg_mwh: 125 + - year_calendar: 2041 + year_investment: 16 + gas: 2683 + lignite: 1420 + biomass: 1792 + electricity: 4200 + petrol: 38 + diesel: 35 + electricity_emission_factor_kg_mwh: 120 + - year_calendar: 2042 + year_investment: 17 + gas: 2693 + lignite: 1420 + biomass: 1801 + electricity: 4183 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 115 + - year_calendar: 2043 + year_investment: 18 + gas: 2702 + lignite: 1420 + biomass: 1811 + electricity: 4167 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 110 + - year_calendar: 2044 + year_investment: 19 + gas: 2712 + lignite: 1420 + biomass: 1820 + electricity: 4150 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 105 + - year_calendar: 2045 + year_investment: 20 + gas: 2721 + lignite: 1420 + biomass: 1830 + electricity: 4133 + petrol: 38 + diesel: 36 + electricity_emission_factor_kg_mwh: 100 + - year_calendar: 2046 + year_investment: 21 + gas: 2731 + lignite: 1420 + biomass: 1839 + electricity: 4117 + petrol: 39 + diesel: 36 + electricity_emission_factor_kg_mwh: 95 + - year_calendar: 2047 + year_investment: 22 + gas: 2740 + lignite: 1420 + biomass: 1849 + electricity: 4100 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 90 + - year_calendar: 2048 + year_investment: 23 + gas: 2750 + lignite: 1420 + biomass: 1859 + electricity: 4083 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 85 + - year_calendar: 2049 + year_investment: 24 + gas: 2759 + lignite: 1420 + biomass: 1868 + electricity: 4067 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 80 + - year_calendar: 2050 + year_investment: 25 + gas: 2769 + lignite: 1420 + biomass: 1878 + electricity: 4050 + petrol: 39 + diesel: 37 + electricity_emission_factor_kg_mwh: 75 + - year_calendar: 2051 + year_investment: 26 + gas: 2778 + lignite: 1420 + biomass: 1887 + electricity: 4033 + petrol: 40 + diesel: 37 + electricity_emission_factor_kg_mwh: 70 + - year_calendar: 2052 + year_investment: 27 + gas: 2788 + lignite: 1420 + biomass: 1897 + electricity: 4017 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 65 + - year_calendar: 2053 + year_investment: 28 + gas: 2797 + lignite: 1420 + biomass: 1906 + electricity: 4000 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 60 + - year_calendar: 2054 + year_investment: 29 + gas: 2807 + lignite: 1420 + biomass: 1916 + electricity: 3983 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 55 + - year_calendar: 2055 + year_investment: 30 + gas: 2816 + lignite: 1420 + biomass: 1925 + electricity: 3967 + petrol: 40 + diesel: 38 + electricity_emission_factor_kg_mwh: 50 + - year_calendar: 2056 + year_investment: 31 + gas: 2826 + lignite: 1420 + biomass: 1935 + electricity: 3950 + petrol: 41 + diesel: 38 + electricity_emission_factor_kg_mwh: 45 + - year_calendar: 2057 + year_investment: 32 + gas: 2835 + lignite: 1420 + biomass: 1944 + electricity: 3933 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 40 + - year_calendar: 2058 + year_investment: 33 + gas: 2845 + lignite: 1420 + biomass: 1954 + electricity: 3917 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 35 + - year_calendar: 2059 + year_investment: 34 + gas: 2854 + lignite: 1420 + biomass: 1963 + electricity: 3900 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 30 + - year_calendar: 2060 + year_investment: 35 + gas: 2864 + lignite: 1420 + biomass: 1973 + electricity: 3883 + petrol: 41 + diesel: 39 + electricity_emission_factor_kg_mwh: 25 + - year_calendar: 2061 + year_investment: 36 + gas: 2873 + lignite: 1420 + biomass: 1982 + electricity: 3867 + petrol: 42 + diesel: 39 + electricity_emission_factor_kg_mwh: 20 + - year_calendar: 2062 + year_investment: 37 + gas: 2883 + lignite: 1420 + biomass: 1992 + electricity: 3850 + petrol: 42 + diesel: 39 + electricity_emission_factor_kg_mwh: 15 + - year_calendar: 2063 + year_investment: 38 + gas: 2892 + lignite: 1420 + biomass: 2001 + electricity: 3833 + petrol: 42 + diesel: 40 + electricity_emission_factor_kg_mwh: 10 + - year_calendar: 2064 + year_investment: 39 + gas: 2902 + lignite: 1420 + biomass: 2011 + electricity: 3817 + petrol: 42 + diesel: 40 + electricity_emission_factor_kg_mwh: 5 + - year_calendar: 2065 + year_investment: 40 + gas: 2911 + lignite: 1420 + biomass: 2020 + electricity: 3800 + petrol: 42 + diesel: 40 + electricity_emission_factor_kg_mwh: 5 diff --git a/assets-local/charts/_charts.scss b/assets-local/charts/_charts.scss new file mode 100644 index 000000000..f45d085fd --- /dev/null +++ b/assets-local/charts/_charts.scss @@ -0,0 +1,151 @@ +// _charts.scss — FoK chart CSS +// +// Handles layout and positioning that D3 can't easily do in JS. +// All color/font values are set in fok-theme.js and applied via inline styles by D3. +// This file only deals with structural CSS: display, position, overflow, cursor. + +// ── Chart wrapper ────────────────────────────────────────────────────────── + +.fok-chart { + position: relative; + width: 100%; + + &__title { + margin-bottom: 0.5rem; + } +} + +.fok-chart-svg { + display: block; + width: 100%; + height: auto; + overflow: visible; // allow axis labels outside viewBox +} + +// ── Axes ─────────────────────────────────────────────────────────────────── + +.fok-axis { + .domain { + shape-rendering: crispEdges; + } + + .tick line { + shape-rendering: crispEdges; + } + + .tick text { + user-select: none; + } +} + +// ── Bars ─────────────────────────────────────────────────────────────────── + +.fok-bar { + cursor: default; + transition: opacity 0.15s ease; +} + +// ── Lines ────────────────────────────────────────────────────────────────── + +.fok-line { + pointer-events: none; +} + +.fok-area { + pointer-events: none; +} + +.fok-voronoi-cell { + cursor: crosshair; +} + +// ── Map ──────────────────────────────────────────────────────────────────── + +.cz-region { + transition: fill 0.2s ease; +} + +.cz-point { + cursor: pointer; + transition: r 0.1s ease; +} + +// ── Tooltip ──────────────────────────────────────────────────────────────── + +.fok-tooltip { + // Structural only — colors/fonts are set via fok-theme.js in JS + position: fixed; + pointer-events: none; + z-index: 9999; + max-width: 220px; + word-wrap: break-word; + + strong { + display: block; + margin-bottom: 2px; + } +} + +// ── Legend ───────────────────────────────────────────────────────────────── + +.fok-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 0.5rem; + + &--vertical { + flex-direction: column; + gap: 8px; + } + + &__item { + display: flex; + align-items: center; + gap: 6px; + } + + &__swatch { + display: inline-block; + border-radius: 2px; + flex-shrink: 0; + } + + &__label { + white-space: nowrap; + } +} + +// ── Small multiples grid ─────────────────────────────────────────────────── + +.fok-small-multiples { + display: grid; + gap: 24px 32px; + grid-template-columns: repeat(2, 1fr); + + @media (max-width: 600px) { + grid-template-columns: 1fr; + } +} + +.fok-small-multiples--3col { + grid-template-columns: repeat(3, 1fr); +} + +.fok-small-multiples--4col { + grid-template-columns: repeat(4, 1fr); +} + +// ── Annotation ───────────────────────────────────────────────────────────── + +.fok-annotation { + pointer-events: none; + user-select: none; +} + +// ── Axis label ───────────────────────────────────────────────────────────── + +.fok-axis-label { + pointer-events: none; + user-select: none; +} diff --git a/assets-local/charts/ai-prototyping-guidelines.md b/assets-local/charts/ai-prototyping-guidelines.md new file mode 100644 index 000000000..75514bf93 --- /dev/null +++ b/assets-local/charts/ai-prototyping-guidelines.md @@ -0,0 +1,148 @@ +# Datavis AI Prototyping Guidelines + +Rules for AI-assisted chart prototyping in the FoK design system (D3 + fok-theme.js). + +--- + +## Chart types and when to use them + +| Chart type | Use when | +|---|---| +| Stacked bar (horizontal, proportional) | Share of total — single row breakdown | +| Stacked bar (vertical, small multiples) | Compare base + delta across categories | +| Stacked area (proportional) | Geographic/categorical mix evolving over time | +| Line chart | Time series, trends over years | +| Choropleth map (world) | Geographic comparison of a single variable | + +--- + +## Connecting historical data to targets + +**Rule: use a dashed line to bridge the gap between the last measured value and the target.** + +This pattern separates fact (solid line = measured data) from plan (dashed line = projected/required trajectory). + +``` +Historical data ────────────── ╌╌╌╌╌╌╌╌╌○ Target + ^last ^target year + data point +``` + +### Implementation + +```js +// Dashed projection segment +g.append('path') + .datum([ + { date: new Date(lastYear, 0, 1), value: lastValue }, + { date: new Date(targetYear, 0, 1), value: targetValue }, + ]) + .attr('fill', 'none') + .attr('stroke', lineColor) + .attr('stroke-width', 1.2) // slightly thinner than the solid line + .attr('stroke-dasharray', '5 4') + .attr('d', d3.line().x(d => xSc(d.date)).y(d => ySc(d.value))); +``` + +### Target marker: open circle + +Target values are always drawn as an **open circle** (white fill, colored stroke) — not a filled dot. Filled dots are used for actual data points. + +```js +g.append('circle') + .attr('cx', xSc(new Date(targetYear, 0, 1))) + .attr('cy', ySc(targetValue)) + .attr('r', 5) + .attr('fill', '#fff') // open = projected, not measured + .attr('stroke', lineColor) + .attr('stroke-width', 2); +``` + +### Annotation placement + +- Last data point label: anchored to the **left/above** the point (`text-anchor: end`) so it doesn't run into the dashed segment +- Target label: anchored to the **right** of the circle (`text-anchor: start`, `x + 8`) +- Use `FoKTheme.colors.grey` for the target annotation, the series color for the data annotation + +--- + +## General rules + +### SVG width must match rendered column width + +The chart `width` option sets the SVG `viewBox`. Because the SVG is responsive (`width: 100%`), it scales to fit its container. Text in SVG scales proportionally with the viewBox — **if the viewBox is wider than the container, all text shrinks; if narrower, it grows.** + +**The only way to get consistent font sizes across all charts is to set `width` to the actual rendered pixel width of the chart's container** — not a generic default. This is what makes a 12 px axis label look 12 px everywhere. + +| Layout | Approx. rendered width | Correct `width` option | +|---|---|---| +| Full-width content column | ~800 px | `800` (default) | +| `.col-md-6` (half column) | ~420 px | `420` | +| `.col-6` inside `.col-md-6` (quarter) | ~200 px | `200` | + +```js +// Wrong — default 800px viewBox in a 420px column → text appears ~63% of intended size +fokBarChartStacked('#chart', data, { height: 340 }); + +// Wrong — 280px viewBox in a 200px column → text appears ~71% of intended size +fokLineChart('#chart', data, { width: 280, height: 220 }); + +// Correct — viewBox matches the actual rendered column width +fokBarChartStacked('#chart-half', data, { width: 420, height: 340 }); +fokLineChart('#chart-quarter', data, { width: 200, height: 220 }); +``` + +> **To verify**: measure the rendered container with DevTools. The rendered pixel width of the container and the `width` option must match for text to appear at the intended size. + +### Fonts + +- Titles: **Inter Bold** (`FoKTheme.fontTitle`, weight 700) +- Everything else: **Roboto** (`FoKTheme.font`, weight 400/700) +- Minimum readable font size in SVG coordinates: **12px** — but only meaningful if the viewBox width is set correctly (see above) + +### Colors + +Line charts use `theme.colors.categorical[0]` for the first (or only) series — **not** `theme.colors.primary`. To override the line color, override `categorical`: + +```js +// Wrong — primary is not used by line charts +theme: { ...FoKTheme, colors: { ...FoKTheme.colors, primary: '#3b3b93' } } + +// Correct +theme: { ...FoKTheme, colors: { ...FoKTheme.colors, categorical: ['#3b3b93', ...FoKTheme.colors.categorical.slice(1)] } } +``` + +Named sector colors from `FoKTheme.colors.sectors`: + +| Sector | Color | +|---|---| +| energetika | `#f4465b` | +| průmysl | `#3b3b93` | +| doprava | `#8546af` | +| budovy | `#0d80d8` | +| zemědělství | `#00aa95` | +| odpady | `#fab519` | +| ostatní | `#b5b8bd` | + +### Small multiples + +- Set `width: 420` (or match the actual column width) on each chart — the SVG viewBox scaling will otherwise make text illegible +- Shared legend below the grid, not repeated per chart (`legend: false` on each chart, render once manually) + +### Y-axis ticks + +- Prefer explicit `yTickValues` over automatic ticks for cleaner grids +- Round to natural intervals: 500 or 1000 for large absolute values, 10 for small (e.g. Kč/litr) + +### Axis styling + +- No domain lines (axis baseline) — set via `_styleAxis()` in fok-utils.js, applies globally +- Y-axis label: horizontal text above the chart, left-aligned, not rotated +- Tick padding: 12px (set in `FoKTheme.axis.tickPadding`) + +### Lines + +**Never use smoothed curves.** All lines must use `d3.curveLinear` (the D3 default). Do not pass any curve option to `d3.line()` or `d3.area()`. This applies to fokLineChart, fokBarChartStacked, and any custom D3 code in this project. `curveMonotoneX`, `curveBasis`, `curveCardinal`, and all other interpolators are forbidden. + +- Solid line for measured/historical data +- Dashed line (`stroke-dasharray: '5 4'`, `stroke-width` slightly reduced) for projections and trajectories diff --git a/assets-local/charts/demo.html b/assets-local/charts/demo.html new file mode 100644 index 000000000..04ece2c69 --- /dev/null +++ b/assets-local/charts/demo.html @@ -0,0 +1,402 @@ + + + + + + + FoK Chart System — Demo + + + + + + + + + + + + + + + + + + + + + + +

FoK Chart System — Demo

+

Standalone scratchpad. No build step needed — just open in a browser.

+ + +
+

Bar chart — emise skleníkových plynů ČR podle sektorů (2022)

+

+ Základní svislý sloupcový graf. Barvy z FoKTheme.colors.categorical. + Najeďte myší na sloupec pro tooltip. +

+
+
+ + +
+

Horizontal bar chart — největší emitenti EU (2022, Mt CO₂eq)

+

+ Vodorovný sloupcový graf s řazením. Stejná funkce fokBarChart, + parametr horizontal: true, sorted: true. +

+
+
+ + +
+

Line chart — vývoj emisí ČR 1990–2022 (Mt CO₂eq)

+

+ Liniový graf s oblastí (area: true). + Horizontální mřížka z fokAxisY. +

+
+
+ + +
+

Multi-series line chart — emise vybraných sektorů ČR 2000–2022

+

+ Více řad, kategorická paleta, legenda a voronoi tooltip. +

+
+
+ + +
+

Bar chart — záporné hodnoty (LULUCF sink + ostatní sektory, 2022)

+

+ Graf s hodnotami pod nulou. Nulová čára je vykreslena automaticky. +

+
+
+ + + + +
+

Stacked bar (proportional) — podíl ETS na emisích EU

+

+ Jednořádkový 100% sloupcový graf. fokBarChartStacked s proportional: true, horizontal: true, showInnerLabels: true. +

+
+
+ + +
+

Small multiples — dopad ceny povolenky ETS 2 na cenu paliv

+

+ Čtyři grafy v mřížce 2×2. Každý zobrazuje aktuální cenu (světlá) + nárůst o cenu emisní povolenky (tmavá). +

+
+
+
+
+
+
+
+
+ + +
+

World map — zpoplatnění emisí z dopravy a budov ve světě (2024)

+

+ fokMapWorld — choropleth mapa načítající TopoJSON z CDN. +

+
+
+ + + + + diff --git a/assets-local/charts/fok-chart-area-stacked.js b/assets-local/charts/fok-chart-area-stacked.js new file mode 100644 index 000000000..05c8a42f1 --- /dev/null +++ b/assets-local/charts/fok-chart-area-stacked.js @@ -0,0 +1,230 @@ +/** + * fok-chart-area-stacked.js — stacked area chart factory + * + * Renders a stacked area chart (optionally normalized to 100 %) into a container. + * All cosmetics come from the theme; no hardcoded colors or sizes. + * + * Dependencies (load order): + * D3 v7+ → fok-theme.js → fok-utils.js → this file + * + * Usage: + * fokAreaChartStacked('#chart', data, { + * x: d => d.year, // string or number year + * keys: ['a', 'b', 'c'], // stacking order (bottom → top) + * colors: { a: '#f00', b: '#00f', c: '#0f0' }, + * labels: { a: 'A', b: 'B', c: 'C' }, + * proportional: true, // normalize rows to 100 % + * yLabel: '% objemu', + * title: 'Původ', + * width: 420, + * height: 300, + * theme: myTheme, + * tooltipHtml: row => `${row.year}...`, + * }); + * + * Options: + * x {function} accessor returning the x value (numeric year or string) + * keys {string[]} series keys in stacking order (bottom first) + * colors {object} { key: colorString } + * labels {object} { key: labelString } + * proportional {boolean} normalize each row to 100 % (default false) + * legend {boolean} render a color legend below the chart (default false) + * yLabel {string} y-axis label + * title {string} chart title + * yFormat {function} override y-axis tick formatter + * tooltipHtml {function} (row) => HTML string shown on hover + * width {number} viewBox width (default 800) + * height {number} viewBox height (default 420) + * margins {object} override default margins + * theme {object} override full theme + */ +function fokAreaChartStacked(containerSelector, data, options = {}) { + const theme = { ...FoKTheme, ...(options.theme ?? {}) }; + const margin = fokMargin(options.margins ?? {}, theme); + const W = options.width ?? 800; + const H = options.height ?? 420; + const inner = { w: W - margin.left - margin.right, h: H - margin.top - margin.bottom }; + + const keys = options.keys ?? []; + const colors = options.colors ?? {}; + const labels = options.labels ?? {}; + const xAcc = options.x ?? (d => d.year); + + // Normalize to proportional if requested + const rows = data.map(d => { + const row = { ...d }; + if (options.proportional) { + const total = keys.reduce((s, k) => s + (+(row[k] ?? 0)), 0); + if (total > 0) keys.forEach(k => { row[k] = (+(row[k] ?? 0) / total) * 100; }); + } + return row; + }); + + const xVals = rows.map(d => +(xAcc(d))); + + // Stack + const series = d3.stack().keys(keys)(rows); + + // ── Clear container ───────────────────────────────────────────────────── + const container = d3.select(containerSelector); + container.selectAll('*').remove(); + + // ── Title ─────────────────────────────────────────────────────────────── + if (options.title) { + container.append('div') + .attr('class', 'fok-chart__title') + .style('font-family', theme.fontTitle) + .style('font-size', theme.fontSize.title + 'px') + .style('font-weight', theme.fontWeight.titleBold) + .style('color', theme.colors.text) + .style('margin-bottom', '8px') + .text(options.title); + } + + // ── SVG scaffold ──────────────────────────────────────────────────────── + const svg = fokResponsiveSVG(container, `0 0 ${W} ${H}`); + const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + + // ── Scales ────────────────────────────────────────────────────────────── + const xScale = d3.scaleLinear().domain(d3.extent(xVals)).range([0, inner.w]); + const yMax = options.proportional ? 100 : d3.max(series, s => d3.max(s, d => d[1])); + const yScale = d3.scaleLinear().domain([0, yMax]).range([inner.h, 0]); + + // ── Grid + Axes ────────────────────────────────────────────────────────── + const yFmt = options.yFormat + ?? (options.proportional ? v => `${Math.round(v)} %` : v => fokFormatNumber(v)); + + g.append('g') + .attr('class', 'fok-axis fok-axis--y') + .call(fokAxisY(yScale, { + tickValues: options.yTickValues ?? (options.proportional ? [0, 25, 50, 75, 100] : undefined), + tickFormat: yFmt, + gridLines: true, + gridWidth: inner.w, + }, theme)); + + g.append('g') + .attr('class', 'fok-axis fok-axis--x') + .attr('transform', `translate(0,${inner.h})`) + .call(fokAxisX(xScale, { + tickValues: options.xTickValues ?? xVals, + tickFormat: v => String(Math.round(v)), + }, theme)); + + // ── Y-axis label ───────────────────────────────────────────────────────── + if (options.yLabel) { + g.append('text') + .attr('class', 'fok-axis-label') + .attr('x', -margin.left + 4) + .attr('y', -10) + .attr('text-anchor', 'start') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .text(options.yLabel); + } + + // ── Area + separator line generators ───────────────────────────────────── + const areaGen = d3.area() + .x((d, i) => xScale(xVals[i])) + .y0(d => yScale(d[0])) + .y1(d => yScale(d[1])); + + const topLineGen = d3.line() + .x((d, i) => xScale(xVals[i])) + .y(d => yScale(d[1])); + + // ── Draw stacked areas ─────────────────────────────────────────────────── + const areasG = g.append('g'); + + series.forEach(s => { + areasG.append('path') + .datum(s) + .attr('fill', colors[s.key] ?? theme.colors.categorical[0]) + .attr('stroke', '#fff') + .attr('stroke-width', 0.5) + .attr('opacity', 0.88) + .attr('d', areaGen); + + areasG.append('path') + .datum(s) + .attr('fill', 'none') + .attr('stroke', '#fff') + .attr('stroke-width', 0.8) + .attr('opacity', 0.5) + .attr('d', topLineGen); + }); + + // ── Tooltip + vertical crosshair ───────────────────────────────────────── + const tip = fokTooltip(theme); + + const vline = g.append('line') + .attr('y1', 0).attr('y2', inner.h) + .attr('stroke', theme.colors.grey) + .attr('stroke-width', 1) + .attr('stroke-dasharray', '4 3') + .attr('opacity', 0) + .attr('pointer-events', 'none'); + + function defaultTooltip(row) { + const xVal = +xAcc(row); + let html = `${Math.round(xVal)}
`; + keys.slice().reverse().forEach(k => { + const v = +(row[k] ?? 0); + html += ` ` + + `${labels[k] ?? k}: ${fokFormatNumber(v, 1)}${options.proportional ? ' %' : ''}
`; + }); + return html; + } + + const tooltipFn = options.tooltipHtml ?? defaultTooltip; + + g.append('rect') + .attr('width', inner.w) + .attr('height', inner.h) + .attr('fill', 'transparent') + .style('cursor', 'crosshair') + .on('mousemove', function(event) { + const [mx] = d3.pointer(event); + const xYear = xScale.invert(mx); + const closest = xVals.reduce( + (best, yr) => Math.abs(yr - xYear) < Math.abs(best - xYear) ? yr : best, + xVals[0], + ); + const row = rows.find(d => +xAcc(d) === closest); + if (!row) return; + vline.attr('x1', xScale(closest)).attr('x2', xScale(closest)).attr('opacity', 1); + tip.show(tooltipFn(row)); + tip.move(event); + }) + .on('mouseleave', () => { vline.attr('opacity', 0); tip.hide(); }); + + // ── Custom annotations ──────────────────────────────────────────────────── + // Each annotation: { x, y, text, anchor?, color?, fontSize? } + // x and y are data-space coordinates (same units as the chart axes). + // The top of the text is anchored at y; anchor sets text-anchor (default 'end'). + if (options.annotations) { + const annotG = g.append('g').attr('class', 'fok-annotations'); + options.annotations.forEach(ann => { + annotG.append('text') + .attr('x', xScale(ann.x)) + .attr('y', yScale(ann.y)) + .attr('text-anchor', ann.anchor ?? 'end') + .attr('dominant-baseline', 'hanging') + .attr('fill', ann.color ?? theme.colors.grey) + .attr('font-family', ann.fontFamily ?? theme.font) + .attr('font-size', ann.fontSize ?? theme.fontSize.annotation) + .attr('font-weight', ann.fontWeight ?? 400) + .attr('pointer-events', 'none') + .text(ann.text); + }); + } + + // ── Legend ──────────────────────────────────────────────────────────────── + if (options.legend) { + const legendItems = keys.map(k => ({ label: labels[k] ?? k, color: colors[k] ?? '#888' })); + fokLegend(container, legendItems, {}, theme); + } + + return { svg, tip }; +} diff --git a/assets-local/charts/fok-chart-bar-stacked.js b/assets-local/charts/fok-chart-bar-stacked.js new file mode 100644 index 000000000..272589712 --- /dev/null +++ b/assets-local/charts/fok-chart-bar-stacked.js @@ -0,0 +1,276 @@ +/** + * fok-chart-bar-stacked.js — stacked bar chart factory + * + * Covers two common patterns: + * 1. Proportional horizontal bar (options.proportional: true, options.horizontal: true) + * → used for "share of X" single-row breakdowns + * 2. Stacked vertical/horizontal bars + * → used for small-multiples "base + delta" charts (fuel prices, etc.) + * + * Dependencies: D3 v7+ → fok-theme.js → fok-utils.js → this file + * + * Data format: + * Array of row objects. Each row must contain one field per `options.keys` entry. + * The `x` accessor picks the category label from each row. + * + * Usage — proportional single-row: + * fokBarChartStacked('#el', [{ ets1: 35, ets2: 39, rest: 26 }], { + * x: () => '', + * keys: ['ets1', 'ets2', 'rest'], + * colors: { ets1: '#3b3b93', ets2: '#0d80d8', rest: '#b5b8bd' }, + * labels: { ets1: 'ETS 1', ets2: 'ETS 2', rest: 'Nezpoplatněné' }, + * proportional: true, + * horizontal: true, + * showInnerLabels: true, + * }); + * + * Usage — stacked vertical (small multiples): + * fokBarChartStacked('#el', data, { + * x: d => d.permitPrice + '€', + * keys: ['base', 'ets'], + * colors: { base: '#bfcad9', ets: '#3b3b93' }, + * labels: { base: 'Současná cena', ets: 'Nárůst (ETS 2)' }, + * yLabel: 'Kč / litr', + * }); + * + * Options: + * keys {string[]} stack keys, bottom→top order + * colors {object} key → fill color + * labels {object} key → display label (legend, tooltip) + * x {function} category accessor (default: d => d.label) + * horizontal {boolean} horizontal bars (default false) + * proportional {boolean} normalize to 100% (default false) + * showInnerLabels {boolean} text inside bars (default false) + * legend {boolean} render legend (default true when >1 key) + * legendDirection {'horizontal'|'vertical'} (default 'horizontal') + * tooltipHtml {function} (rawRow, key) => HTML + * yLabel {string} + * title {string} + * width/height {number} + * margins {object} + * theme {object} + */ +function fokBarChartStacked(containerSelector, data, options = {}) { + // ── Config ──────────────────────────────────────────────────────────────── + const theme = { ...FoKTheme, ...(options.theme ?? {}) }; + const margin = fokMargin(options.margins ?? {}, theme); + const W = options.width ?? 800; + const H = options.height ?? 420; + const inner = { w: W - margin.left - margin.right, h: H - margin.top - margin.bottom }; + + const keys = options.keys ?? []; + const colorMap = options.colors ?? {}; + const labelMap = options.labels ?? {}; + const xAcc = options.x ?? (d => d.label); + const horiz = options.horizontal ?? false; + const proportional = options.proportional ?? false; + const showLabels = options.showInnerLabels ?? false; + + // ── Normalize rows ──────────────────────────────────────────────────────── + const rows = data.map(d => { + const total = keys.reduce((s, k) => s + (+d[k] || 0), 0); + const row = { _x: xAcc(d), _raw: d, _total: total }; + keys.forEach(k => { + row[k] = proportional ? (+d[k] || 0) / total * 100 : (+d[k] || 0); + }); + return row; + }); + + // ── Clear + title ───────────────────────────────────────────────────────── + const container = d3.select(containerSelector); + container.selectAll('*').remove(); + + if (options.title) { + container.append('div') + .attr('class', 'fok-chart__title') + .style('font-family', theme.fontTitle) + .style('font-size', theme.fontSize.title + 'px') + .style('font-weight', theme.fontWeight.titleBold) + .style('color', theme.colors.text) + .style('margin-bottom', '8px') + .text(options.title); + } + + // ── SVG scaffold ────────────────────────────────────────────────────────── + const svg = fokResponsiveSVG(container, `0 0 ${W} ${H}`); + const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + + // ── D3 stack ────────────────────────────────────────────────────────────── + const stack = d3.stack().keys(keys); + const series = stack(rows); + const yMax = proportional ? 100 : d3.max(series, s => d3.max(s, d => d[1])); + const cats = rows.map(r => r._x); + + // ── Scales ──────────────────────────────────────────────────────────────── + let xScale, yScale; + if (!horiz) { + xScale = d3.scaleBand().domain(cats).range([0, inner.w]) + .paddingInner(theme.bar.padding).paddingOuter(theme.bar.padding / 2); + yScale = d3.scaleLinear().domain([0, yMax]).range([inner.h, 0]).nice(); + } else { + xScale = d3.scaleLinear().domain([0, yMax]).range([0, inner.w]); + yScale = d3.scaleBand().domain(cats).range([0, inner.h]) + .paddingInner(theme.bar.padding).paddingOuter(theme.bar.padding / 2); + } + + // ── Axes ────────────────────────────────────────────────────────────────── + const pctFmt = v => Math.round(v) + ' %'; + + if (!horiz) { + g.append('g').attr('class', 'fok-axis fok-axis--y') + .call(fokAxisY(yScale, { + ticks: options.yTicks ?? 6, + tickValues: options.yTickValues, + tickFormat: proportional ? pctFmt : (options.yFormat ?? (v => fokFormatNumber(v))), + gridLines: true, + gridWidth: inner.w, + }, theme)); + + g.append('g').attr('class', 'fok-axis fok-axis--x') + .attr('transform', `translate(0,${inner.h})`) + .call(fokAxisX(xScale, { tickValues: options.xTickValues, tickFormat: options.xFormat }, theme)); + } else { + // Horizontal: y-axis = categories (band), x-axis = values + g.append('g').attr('class', 'fok-axis fok-axis--y') + .call(fokAxisY(yScale, { tickFormat: options.xFormat, gridLines: false }, theme)); + + g.append('g').attr('class', 'fok-axis fok-axis--x') + .attr('transform', `translate(0,${inner.h})`) + .call(fokAxisX(xScale, { + ticks: proportional ? 5 : 6, + tickFormat: proportional ? pctFmt : (options.yFormat ?? (v => fokFormatNumber(v))), + gridLines: true, + gridHeight: inner.h, + }, theme)); + } + + // ── y-axis label (horizontal, above chart) ──────────────────────────────── + if (options.yLabel && !horiz) { + g.append('text') + .attr('class', 'fok-axis-label') + .attr('x', -margin.left + 4) + .attr('y', -10) + .attr('text-anchor', 'start') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .text(options.yLabel); + } + if (options.yLabel && horiz) { + g.append('text') + .attr('class', 'fok-axis-label') + .attr('x', inner.w / 2) + .attr('y', inner.h + margin.bottom - 4) + .attr('text-anchor', 'middle') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .text(options.yLabel); + } + + // ── Tooltip ─────────────────────────────────────────────────────────────── + const tip = fokTooltip(theme); + + function defaultTooltip(d, key) { + const raw = proportional ? d._raw[key] : d[key]; + const fmt = proportional + ? fokFormatNumber(raw, 1) + ' %' + : fokFormatNumber(d[key], 1) + (options.yLabel ? ' ' + options.yLabel : ''); + return `${d._x || ''}
` + + ` ` + + `${labelMap[key] ?? key}: ${fmt}`; + } + + // ── Draw stacked rects ──────────────────────────────────────────────────── + series.forEach(s => { + const key = s.key; + const grp = g.append('g').attr('class', 'fok-stack-group'); + + const rects = grp.selectAll('rect') + .data(s) + .join('rect') + .attr('class', 'fok-bar') + .attr('fill', colorMap[key] ?? theme.colors.primary) + .attr('stroke', '#fff') + .attr('stroke-width', 0.5) + .on('mouseover', function(event, d) { + d3.select(this).attr('opacity', 0.8); + tip.show(options.tooltipHtml ? options.tooltipHtml(d.data._raw, key) : defaultTooltip(d.data, key)); + tip.move(event); + }) + .on('mousemove', event => tip.move(event)) + .on('mouseleave', function() { + d3.select(this).attr('opacity', 1); + tip.hide(); + }); + + if (!horiz) { + rects + .attr('x', d => xScale(d.data._x)) + .attr('width', xScale.bandwidth()) + .attr('y', d => yScale(d[1])) + .attr('height', d => Math.max(0, yScale(d[0]) - yScale(d[1]))); + + if (showLabels) { + const minH = theme.fontSize.axisLabel * 2.5; + const offset = theme.fontSize.axisLabel * 1.5; + grp.selectAll('.fok-bar-label') + .data(s.filter(d => yScale(d[0]) - yScale(d[1]) >= minH)) + .join('text') + .attr('class', 'fok-bar-label') + .attr('x', d => xScale(d.data._x) + xScale.bandwidth() / 2) + .attr('y', d => yScale(d[1]) + offset) + .attr('text-anchor', 'middle') + .attr('fill', '#fff') + .attr('font-family', theme.font) + .attr('font-size', 10) + .attr('font-weight', 400) + .attr('pointer-events', 'none') + .text(d => { + const val = proportional ? (d[1] - d[0]) : d.data._raw[key]; + return proportional + ? Math.round(d[1] - d[0]) + ' %' + : fokFormatNumber(val, 0); + }); + } + } else { + rects + .attr('y', d => yScale(d.data._x)) + .attr('height', yScale.bandwidth()) + .attr('x', d => xScale(d[0])) + .attr('width', d => Math.max(0, xScale(d[1]) - xScale(d[0]))); + } + + // ── Inner labels (proportional + horizontal mode) ────────────────────── + if (showLabels && horiz) { + grp.selectAll('.fok-bar-label') + .data(s.filter(d => xScale(d[1]) - xScale(d[0]) > 28)) + .join('text') + .attr('class', 'fok-bar-label') + .attr('x', d => xScale(d[0]) + (xScale(d[1]) - xScale(d[0])) / 2) + .attr('y', d => yScale(d.data._x) + yScale.bandwidth() / 2) + .attr('dy', '0.35em') + .attr('text-anchor', 'middle') + .attr('fill', '#fff') + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .attr('font-weight', theme.fontWeight.bold) + .attr('pointer-events', 'none') + .text(d => { + const val = proportional ? (d[1] - d[0]) : d.data._raw[key]; + return proportional + ? Math.round(d[1] - d[0]) + ' %' + : fokFormatNumber(val, 0); + }); + } + }); + + // ── Legend ──────────────────────────────────────────────────────────────── + const showLegend = options.legend !== false && keys.length > 1; + if (showLegend) { + const items = keys.map(k => ({ label: labelMap[k] ?? k, color: colorMap[k] ?? theme.colors.primary })); + fokLegend(container, items, { direction: options.legendDirection ?? 'horizontal' }, theme); + } + + return { svg, tip }; +} diff --git a/assets-local/charts/fok-chart-bar.js b/assets-local/charts/fok-chart-bar.js new file mode 100644 index 000000000..8ba5b7c24 --- /dev/null +++ b/assets-local/charts/fok-chart-bar.js @@ -0,0 +1,287 @@ +/** + * fok-chart-bar.js — bar chart factory + * + * Renders a vertical bar chart into a container element. + * All cosmetics come from the theme; no hardcoded colors or sizes. + * + * Dependencies (load order): + * D3 v7+ → fok-theme.js → fok-utils.js → this file + * + * Basic usage: + * fokBarChart('#my-container', data, { + * x: d => d.label, + * y: d => d.value, + * yLabel: 'Mt CO₂', + * }); + * + * Data format: + * Array of objects. The `x` and `y` accessors tell the chart which fields to use. + * + * Options: + * x {function} accessor for x category (default: d => d.label) + * y {function} accessor for y value (default: d => d.value) + * color {function|string} accessor or fixed color for bar fill + * (default: theme.colors.primary) + * yLabel {string} label shown on y-axis + * xLabel {string} label shown on x-axis + * title {string} chart title rendered above + * yDomain {[min,max]} override y scale domain + * yFormat {function} tick formatter for y axis + * xFormat {function} tick formatter for x axis (band labels) + * tooltipHtml{function} (d) => HTML string for tooltip + * width {number} viewBox width (default 800) + * height {number} viewBox height (default 420) + * margins {object} override default margins + * theme {object} override full theme + * horizontal {boolean} render as horizontal bar chart (default false) + * sorted {boolean} sort bars descending by value (default false) + */ +function fokBarChart(containerSelector, data, options = {}) { + // ── Resolve config ────────────────────────────────────────────────────── + const theme = { ...FoKTheme, ...(options.theme ?? {}) }; + const margin = fokMargin(options.margins ?? {}, theme); + const W = options.width ?? 800; + const H = options.height ?? 420; + const inner = { w: W - margin.left - margin.right, h: H - margin.top - margin.bottom }; + + const xAcc = options.x ?? (d => d.label); + const yAcc = options.y ?? (d => d.value); + const colorAcc = typeof options.color === 'function' + ? options.color + : () => (typeof options.color === 'string' ? options.color : theme.colors.primary); + + const horiz = options.horizontal ?? false; + const sorted = options.sorted ?? false; + const showLabels = options.showInnerLabels ?? false; + + // ── Prepare data ──────────────────────────────────────────────────────── + let rows = data.map(d => ({ _x: xAcc(d), _y: +yAcc(d), _raw: d })); + if (sorted) rows = rows.slice().sort((a, b) => d3.descending(a._y, b._y)); + + const labels = rows.map(r => r._x); + const yMax = options.yDomain ? options.yDomain[1] : d3.max(rows, r => r._y); + const yMin = options.yDomain ? options.yDomain[0] : Math.min(0, d3.min(rows, r => r._y)); + + // ── Clear container ───────────────────────────────────────────────────── + const container = d3.select(containerSelector); + container.selectAll('*').remove(); + + // ── Title ─────────────────────────────────────────────────────────────── + if (options.title) { + container.append('div') + .attr('class', 'fok-chart__title') + .style('font-family', theme.fontTitle) + .style('font-size', theme.fontSize.title + 'px') + .style('font-weight', theme.fontWeight.titleBold) + .style('color', theme.colors.text) + .style('margin-bottom', '8px') + .text(options.title); + } + + // ── SVG scaffold ──────────────────────────────────────────────────────── + const svg = fokResponsiveSVG(container, `0 0 ${W} ${H}`) + .attr('class', 'fok-chart-svg'); + + const g = svg.append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // ── Scales ────────────────────────────────────────────────────────────── + let xScale, yScale; + + if (!horiz) { + xScale = d3.scaleBand() + .domain(labels) + .range([0, inner.w]) + .paddingInner(theme.bar.padding) + .paddingOuter(theme.bar.padding / 2); + + yScale = d3.scaleLinear() + .domain([yMin, yMax]) + .range([inner.h, 0]) + .nice(); + } else { + // Horizontal: x→value, y→category + xScale = d3.scaleLinear() + .domain([yMin, yMax]) + .range([0, inner.w]) + .nice(); + + yScale = d3.scaleBand() + .domain(labels) + .range([0, inner.h]) + .paddingInner(theme.bar.padding) + .paddingOuter(theme.bar.padding / 2); + } + + // ── Grid + Axes ────────────────────────────────────────────────────────── + if (!horiz) { + // Horizontal grid lines + g.append('g') + .attr('class', 'fok-axis fok-axis--y') + .call(fokAxisY(yScale, { + ticks: options.yTicks ?? 6, + tickValues: options.yTickValues, + tickFormat: options.yFormat ?? (v => fokFormatNumber(v)), + gridLines: true, + gridWidth: inner.w, + }, theme)); + + // X axis + g.append('g') + .attr('class', 'fok-axis fok-axis--x') + .attr('transform', `translate(0,${inner.h})`) + .call(fokAxisX(xScale, { + tickValues: options.xTickValues, + tickFormat: options.xFormat, + }, theme)); + } else { + // Horizontal bar chart: y-axis = categories, x-axis = values + g.append('g') + .attr('class', 'fok-axis fok-axis--y') + .call(fokAxisY(yScale, { + tickFormat: options.xFormat, + gridLines: false, + }, theme)); + + g.append('g') + .attr('class', 'fok-axis fok-axis--x') + .attr('transform', `translate(0,${inner.h})`) + .call(fokAxisX(xScale, { + ticks: 6, + tickFormat: options.yFormat ?? (v => fokFormatNumber(v)), + gridLines: true, + gridHeight: inner.h, + }, theme)); + + + } + + // ── Zero line ──────────────────────────────────────────────────────────── + if (yMin < 0) { + const zeroY = !horiz ? yScale(0) : xScale(0); + g.append('line') + .attr('class', 'fok-zero-line') + .attr('x1', horiz ? zeroY : 0) + .attr('x2', horiz ? zeroY : inner.w) + .attr('y1', horiz ? 0 : zeroY) + .attr('y2', horiz ? inner.h : zeroY) + .attr('stroke', theme.colors.grey) + .attr('stroke-width', 1); + } + + // ── Axis labels ────────────────────────────────────────────────────────── + if (options.yLabel) { + if (!horiz) { + // Horizontal label above the top of the y-axis + g.append('text') + .attr('class', 'fok-axis-label') + .attr('x', -margin.left + 4) + .attr('y', -10) + .attr('text-anchor', 'start') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .text(options.yLabel); + } else { + // Horizontal bar: value axis is x — label below x-axis + g.append('text') + .attr('class', 'fok-axis-label') + .attr('x', inner.w / 2) + .attr('y', inner.h + margin.bottom - 4) + .attr('text-anchor', 'middle') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .text(options.yLabel); + } + } + + if (options.xLabel) { + g.append('text') + .attr('class', 'fok-axis-label') + .attr('x', horiz ? -(inner.h / 2) : inner.w / 2) + .attr('y', horiz ? -(margin.left - 14) : inner.h + margin.bottom - 4) + .attr('transform', horiz ? 'rotate(-90)' : null) + .attr('text-anchor', 'middle') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .text(options.xLabel); + } + + // ── Tooltip ────────────────────────────────────────────────────────────── + const tip = fokTooltip(theme); + + const defaultTooltip = d => + `${d._x}
` + + `${fokFormatNumber(d._y, 1)}` + + (options.yLabel ? ` ${options.yLabel}` : ''); + + const tooltipHtml = options.tooltipHtml + ? d => options.tooltipHtml(d._raw) + : defaultTooltip; + + // ── Bars ───────────────────────────────────────────────────────────────── + const bars = g.selectAll('.fok-bar') + .data(rows) + .join('rect') + .attr('class', 'fok-bar'); + + if (!horiz) { + bars + .attr('x', d => xScale(d._x)) + .attr('width', xScale.bandwidth()) + .attr('y', d => d._y >= 0 ? yScale(d._y) : yScale(0)) + .attr('height', d => Math.abs(yScale(d._y) - yScale(0))) + .attr('fill', d => colorAcc(d._raw)) + .attr('stroke', '#fff') + .attr('stroke-width', 0.5) + .attr('rx', theme.bar.radius) + .attr('ry', theme.bar.radius); + } else { + bars + .attr('y', d => yScale(d._x)) + .attr('height', yScale.bandwidth()) + .attr('x', d => d._y >= 0 ? xScale(0) : xScale(d._y)) + .attr('width', d => Math.abs(xScale(d._y) - xScale(0))) + .attr('fill', d => colorAcc(d._raw)) + .attr('stroke', '#fff') + .attr('stroke-width', 0.5) + .attr('rx', theme.bar.radius) + .attr('ry', theme.bar.radius); + } + + // ── Inner labels ───────────────────────────────────────────────────────── + if (showLabels && !horiz) { + const minH = theme.fontSize.axisLabel * 2.5; + const offset = theme.fontSize.axisLabel * 1.5; + g.selectAll('.fok-bar-label') + .data(rows.filter(d => Math.abs(yScale(d._y) - yScale(0)) >= minH)) + .join('text') + .attr('class', 'fok-bar-label') + .attr('x', d => xScale(d._x) + xScale.bandwidth() / 2) + .attr('y', d => (d._y >= 0 ? yScale(d._y) : yScale(0)) + offset) + .attr('text-anchor', 'middle') + .attr('fill', '#fff') + .attr('font-family', theme.font) + .attr('font-size', 10) + .attr('font-weight', 400) + .attr('pointer-events', 'none') + .text(d => (options.yFormat ?? (v => fokFormatNumber(v, 0)))(d._y)); + } + + bars + .on('mouseover', function(event, d) { + d3.select(this).attr('opacity', 0.8); + tip.show(tooltipHtml(d)); + tip.move(event); + }) + .on('mousemove', (event) => tip.move(event)) + .on('mouseleave', function() { + d3.select(this).attr('opacity', 1); + tip.hide(); + }); + + // Cleanup tooltip when chart is replaced + return { svg, tip }; +} diff --git a/assets-local/charts/fok-chart-line.js b/assets-local/charts/fok-chart-line.js new file mode 100644 index 000000000..43d6e73dc --- /dev/null +++ b/assets-local/charts/fok-chart-line.js @@ -0,0 +1,250 @@ +/** + * fok-chart-line.js — line / area chart factory + * + * Renders one or more time-series lines (with optional filled areas) into a container. + * All cosmetics come from the theme; no hardcoded colors or sizes. + * + * Dependencies (load order): + * D3 v7+ → fok-theme.js → fok-utils.js → this file + * + * Basic usage — single series: + * fokLineChart('#chart', data, { + * x: d => new Date(d.year, 0, 1), + * y: d => d.value, + * yLabel: 'Mt CO₂', + * }); + * + * Multi-series usage: + * fokLineChart('#chart', seriesArray, { + * multi: true, + * x: d => new Date(d.year, 0, 1), + * y: d => d.value, + * series: d => d.sector, // group key accessor + * legend: true, + * }); + * + * Options: + * x {function} accessor for x value (Date or number) + * y {function} accessor for y value + * series {function} accessor for series key (multi-series mode) + * multi {boolean} treat data as multi-series (default false) + * area {boolean} fill area under line(s) (default false) + * legend {boolean} render a legend (multi-series, default false) + * yLabel {string} y-axis label + * xLabel {string} x-axis label + * title {string} chart title + * yDomain {[min,max]} override y scale domain + * xDomain {[min,max]} override x scale domain + * yFormat {function} tick formatter for y axis + * xFormat {function} tick formatter for x axis + * tooltipHtml {function} (d) => HTML string for hovered point tooltip + * width {number} viewBox width (default 800) + * height {number} viewBox height (default 420) + * margins {object} override default margins + * theme {object} override full theme + */ +function fokLineChart(containerSelector, data, options = {}) { + // ── Resolve config ────────────────────────────────────────────────────── + const theme = { ...FoKTheme, ...(options.theme ?? {}) }; + const margin = fokMargin(options.margins ?? {}, theme); + const W = options.width ?? 800; + const H = options.height ?? 420; + const inner = { w: W - margin.left - margin.right, h: H - margin.top - margin.bottom }; + + const xAcc = options.x ?? (d => d.date); + const yAcc = options.y ?? (d => d.value); + const kAcc = options.series ?? (d => d.series ?? 'default'); + + // ── Prepare series ─────────────────────────────────────────────────────── + let seriesMap; + if (options.multi) { + seriesMap = d3.group(data, kAcc); + } else { + seriesMap = new Map([['default', data]]); + } + + const seriesKeys = [...seriesMap.keys()]; + const colorScale = fokColorOrdinal(seriesKeys, theme); + + const allPoints = data.map(d => ({ _x: xAcc(d), _y: +yAcc(d), _k: kAcc(d), _raw: d })); + const xExtent = options.xDomain ?? d3.extent(allPoints, p => p._x); + const yAll = allPoints.map(p => p._y); + const yMin = options.yDomain ? options.yDomain[0] : Math.min(0, d3.min(yAll)); + const yMax = options.yDomain ? options.yDomain[1] : d3.max(yAll); + + // ── Clear container ───────────────────────────────────────────────────── + const container = d3.select(containerSelector); + container.selectAll('*').remove(); + + // ── Title ─────────────────────────────────────────────────────────────── + if (options.title) { + container.append('div') + .attr('class', 'fok-chart__title') + .style('font-family', theme.fontTitle) + .style('font-size', theme.fontSize.title + 'px') + .style('font-weight', theme.fontWeight.titleBold) + .style('color', theme.colors.text) + .style('margin-bottom', '8px') + .text(options.title); + } + + // ── SVG scaffold ──────────────────────────────────────────────────────── + const svg = fokResponsiveSVG(container, `0 0 ${W} ${H}`); + const g = svg.append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // ── Clip path ──────────────────────────────────────────────────────────── + const clipId = 'fok-clip-' + Math.random().toString(36).slice(2, 7); + svg.append('defs').append('clipPath') + .attr('id', clipId) + .append('rect') + .attr('width', inner.w) + .attr('height', inner.h); + + // ── Scales ────────────────────────────────────────────────────────────── + const isTimeScale = xExtent[0] instanceof Date; + const xScale = isTimeScale + ? d3.scaleTime().domain(xExtent).range([0, inner.w]) + : d3.scaleLinear().domain(xExtent).range([0, inner.w]); + + const yScale = d3.scaleLinear() + .domain([yMin, yMax]) + .range([inner.h, 0]) + .nice(); + + // ── Grid + Axes ────────────────────────────────────────────────────────── + g.append('g') + .attr('class', 'fok-axis fok-axis--y') + .call(fokAxisY(yScale, { + ticks: options.yTicks ?? 6, + tickValues: options.yTickValues, + tickFormat: options.yFormat ?? (v => fokFormatNumber(v)), + gridLines: true, + gridWidth: inner.w, + }, theme)); + g.append('g') + .attr('class', 'fok-axis fok-axis--x') + .attr('transform', `translate(0,${inner.h})`) + .call(fokAxisX(xScale, { + ticks: options.xTicks ?? 8, + tickValues: options.xTickValues, + tickFormat: options.xFormat, + }, theme)); + + // ── Axis labels ────────────────────────────────────────────────────────── + if (options.yLabel) { + g.append('text') + .attr('class', 'fok-axis-label') + .attr('x', -margin.left + 4) + .attr('y', -10) + .attr('text-anchor', 'start') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel) + .text(options.yLabel); + } + + // ── Line/area generators ───────────────────────────────────────────────── + const lineGen = d3.line() + .x(d => xScale(d._x)) + .y(d => yScale(d._y)) + .defined(d => d._y != null && !isNaN(d._y)); + + const areaGen = d3.area() + .x(d => xScale(d._x)) + .y0(yScale(0)) + .y1(d => yScale(d._y)) + .defined(d => d._y != null && !isNaN(d._y)); + + // ── Draw series ────────────────────────────────────────────────────────── + const seriesG = g.append('g').attr('clip-path', `url(#${clipId})`); + + seriesKeys.forEach((key, i) => { + const pts = seriesMap.get(key).map(d => ({ + _x: xAcc(d), _y: +yAcc(d), _k: key, _raw: d, + })); + const color = colorScale(key); + + if (options.area) { + seriesG.append('path') + .datum(pts) + .attr('class', 'fok-area') + .attr('d', areaGen) + .attr('fill', color) + .attr('stroke', '#fff') + .attr('stroke-width', 0.5) + .attr('opacity', 0.18); + } + + seriesG.append('path') + .datum(pts) + .attr('class', 'fok-line') + .attr('d', lineGen) + .attr('fill', 'none') + .attr('stroke', color) + .attr('stroke-width', theme.line.strokeWidth) + .attr('stroke-linejoin', 'round') + .attr('stroke-linecap', 'round'); + }); + + // ── Tooltip overlay ─────────────────────────────────────────────────────── + const tip = fokTooltip(theme); + + const defaultTooltip = pt => + `${ + pt._x instanceof Date ? pt._x.getFullYear() : pt._x + }
` + + (options.multi ? `${pt._k}
` : '') + + `${fokFormatNumber(pt._y, 1)}` + + (options.yLabel ? ` ${options.yLabel}` : ''); + + const tooltipHtml = options.tooltipHtml + ? pt => options.tooltipHtml(pt._raw) + : defaultTooltip; + + // Voronoi overlay for hover detection + const voronoi = d3.Delaunay.from( + allPoints, + p => xScale(p._x), + p => yScale(p._y), + ).voronoi([0, 0, inner.w, inner.h]); + + const hoverG = seriesG.append('g').attr('class', 'fok-hover-layer'); + + hoverG.selectAll('.fok-voronoi-cell') + .data(allPoints) + .join('path') + .attr('class', 'fok-voronoi-cell') + .attr('d', (d, i) => voronoi.renderCell(i)) + .attr('fill', 'none') + .attr('pointer-events', 'all') + .on('mouseover', function(event, pt) { + tip.show(tooltipHtml(pt)); + tip.move(event); + + // highlight nearest dot + hoverG.selectAll('.fok-hover-dot').remove(); + hoverG.append('circle') + .attr('class', 'fok-hover-dot') + .attr('cx', xScale(pt._x)) + .attr('cy', yScale(pt._y)) + .attr('r', theme.line.dotRadiusHovered) + .attr('fill', colorScale(pt._k)) + .attr('stroke', '#fff') + .attr('stroke-width', 2) + .attr('pointer-events', 'none'); + }) + .on('mousemove', event => tip.move(event)) + .on('mouseleave', function() { + tip.hide(); + hoverG.selectAll('.fok-hover-dot').remove(); + }); + + // ── Legend ──────────────────────────────────────────────────────────────── + if (options.legend && options.multi) { + const legendItems = seriesKeys.map(k => ({ label: k, color: colorScale(k) })); + fokLegend(container, legendItems, {}, theme); + } + + return { svg, tip }; +} diff --git a/assets-local/charts/fok-chart-map-cz.js b/assets-local/charts/fok-chart-map-cz.js new file mode 100644 index 000000000..18afe3a28 --- /dev/null +++ b/assets-local/charts/fok-chart-map-cz.js @@ -0,0 +1,119 @@ +/** + * fok-chart-map-cz.js — Czech Republic map factory + * + * Renders a choropleth or point map of the Czech Republic. + * GeoJSON is loaded from the path passed in options (or the default asset path). + * + * Dependencies (load order): + * D3 v7+ → fok-theme.js → fok-utils.js → this file + * + * Basic usage — dot map: + * fokMapCz('#map-container', points, { + * lon: d => d.longitude, + * lat: d => d.latitude, + * color: d => statusColor(d.status), + * tooltipHtml: d => `${d.name}`, + * }); + * + * Options: + * geoJsonPath {string} path to CZ GeoJSON (default '/assets-local/files/cz-map.json') + * lon {function} longitude accessor + * lat {function} latitude accessor + * color {function|string} point fill color + * r {function|number} point radius (default 6) + * tooltipHtml {function} (d) => HTML string + * width {number} viewBox width (default 900) + * height {number} viewBox height (default 520) + * theme {object} override full theme + * + * Returns a Promise that resolves when the map has rendered. + */ +async function fokMapCz(containerSelector, points, options = {}) { + const theme = { ...FoKTheme, ...(options.theme ?? {}) }; + const W = options.width ?? 900; + const H = options.height ?? 520; + const geoPath = options.geoJsonPath ?? '/assets-local/files/cz-map.json'; + + const lonAcc = options.lon ?? (d => d.longitude); + const latAcc = options.lat ?? (d => d.latitude); + const colorAcc = typeof options.color === 'function' + ? options.color + : () => (typeof options.color === 'string' ? options.color : theme.colors.primary); + const rAcc = typeof options.r === 'function' + ? options.r + : () => (options.r ?? 6); + + // ── Clear container ────────────────────────────────────────────────────── + const container = d3.select(containerSelector); + container.selectAll('*').remove(); + + // ── SVG scaffold ───────────────────────────────────────────────────────── + const svg = fokResponsiveSVG(container, `0 0 ${W} ${H}`); + + const gMap = svg.append('g').attr('class', 'cz-regions'); + const gPts = svg.append('g').attr('class', 'cz-points'); + + // ── Load GeoJSON ───────────────────────────────────────────────────────── + let geojson; + try { + geojson = await d3.json(geoPath); + } catch (err) { + console.error('fokMapCz: failed to load GeoJSON from', geoPath, err); + container.append('p') + .style('color', theme.colors.negative) + .style('font-family', theme.font) + .text('Mapu se nepodařilo načíst.'); + return; + } + + // ── Projection fitted to CZ bounding box ──────────────────────────────── + const projection = d3.geoMercator().fitSize([W, H], geojson); + const path = d3.geoPath().projection(projection); + + // ── Draw regions ───────────────────────────────────────────────────────── + gMap.selectAll('path') + .data(geojson.features) + .join('path') + .attr('class', 'cz-region') + .attr('d', path) + .attr('fill', theme.axis.gridColor) + .attr('stroke', '#fff') + .attr('stroke-width', 0.8); + + // ── Tooltip ─────────────────────────────────────────────────────────────── + const tip = fokTooltip(theme); + + const defaultTooltip = d => + `${lonAcc(d).toFixed(3)}, ${latAcc(d).toFixed(3)}`; + + const tooltipHtml = options.tooltipHtml ?? defaultTooltip; + + // ── Draw points ─────────────────────────────────────────────────────────── + const validPoints = points.filter(d => { + const lon = +lonAcc(d), lat = +latAcc(d); + return isFinite(lon) && isFinite(lat); + }); + + gPts.selectAll('circle') + .data(validPoints) + .join('circle') + .attr('class', 'cz-point') + .attr('cx', d => projection([+lonAcc(d), +latAcc(d)])[0]) + .attr('cy', d => projection([+lonAcc(d), +latAcc(d)])[1]) + .attr('r', d => rAcc(d)) + .attr('fill', d => colorAcc(d)) + .attr('stroke', '#fff') + .attr('stroke-width', 1) + .on('mouseover', function(event, d) { + d3.select(this).attr('r', rAcc(d) * 1.5); + tip.show(tooltipHtml(d)); + tip.move(event); + }) + .on('mousemove', event => tip.move(event)) + .on('mouseleave', function(event, d) { + d3.select(this).attr('r', rAcc(d)); + tip.hide(); + }); + + return { svg, tip, projection, path }; +} diff --git a/assets-local/charts/fok-chart-map-world.js b/assets-local/charts/fok-chart-map-world.js new file mode 100644 index 000000000..21453cef3 --- /dev/null +++ b/assets-local/charts/fok-chart-map-world.js @@ -0,0 +1,148 @@ +/** + * fok-chart-map-world.js — world choropleth map factory + * + * Colors countries by a categorical or quantitative value. + * Uses Natural Earth 110m TopoJSON from jsDelivr (no local file needed). + * + * Requires topojson-client to be loaded before this file: + * + * + * Dependencies: D3 v7+ → topojson-client → fok-theme.js → fok-utils.js → this file + * + * Usage: + * await fokMapWorld('#container', countryColors, { + * unknown: '#e8eef6', + * tooltipHtml: (iso2, color) => `${iso2}`, + * }); + * + * @param {string} containerSelector + * @param {object} countryColors — { ISO2_code: fillColor } e.g. { 'DE': '#3b3b93', 'FR': '#0d80d8' } + * @param {object} options + * @param {string} [options.unknown='#e8eef6'] fill for countries not in countryColors + * @param {string} [options.ocean='#fff'] background (svg fill) + * @param {string} [options.border='#fff'] country border color + * @param {function} [options.tooltipHtml] (iso2, color, d) => HTML string + * @param {number} [options.width=800] + * @param {number} [options.height=440] + * @param {object} [options.theme] + * @returns {Promise<{svg, tip}>} + */ +async function fokMapWorld(containerSelector, countryColors, options = {}) { + const theme = { ...FoKTheme, ...(options.theme ?? {}) }; + const W = options.width ?? 800; + const H = options.height ?? 440; + const unknown = options.unknown ?? theme.axis.gridColor; + const border = options.border ?? '#fff'; + + // ── ISO-2 → ISO numeric lookup ──────────────────────────────────────────── + // (Natural Earth / world-atlas uses numeric ISO 3166-1 codes as feature IDs) + const ISO2_NUM = { + AD:20,AE:784,AF:4,AG:28,AL:8,AM:51,AO:24,AR:32,AT:40,AU:36,AZ:31, + BA:70,BB:52,BD:50,BE:56,BF:854,BG:100,BH:48,BI:108,BJ:204,BN:96, + BO:68,BR:76,BS:44,BT:64,BW:72,BY:112,BZ:84, + CA:124,CD:180,CF:140,CG:178,CH:756,CI:384,CL:152,CM:120,CN:156, + CO:170,CR:188,CU:192,CY:196,CZ:203, + DE:276,DJ:262,DK:208,DO:214,DZ:12, + EC:218,EE:233,EG:818,ER:232,ES:724,ET:231, + FI:246,FJ:242,FR:250, + GA:266,GB:826,GE:268,GH:288,GM:270,GN:324,GQ:226,GR:300,GT:320, + GW:624,GY:328, + HN:340,HR:191,HT:332,HU:348, + ID:360,IE:372,IL:376,IN:356,IQ:368,IR:364,IS:352,IT:380, + JM:388,JO:400,JP:392, + KE:404,KG:417,KH:116,KP:408,KR:410,KW:414,KZ:398, + LA:418,LB:422,LK:144,LR:430,LS:426,LT:440,LU:442,LV:428,LY:434, + MA:504,MD:498,ME:499,MG:450,MK:807,ML:466,MM:104,MN:496,MR:478, + MT:470,MU:480,MW:454,MX:484,MY:458,MZ:508, + NA:516,NE:562,NG:566,NI:558,NL:528,NO:578,NP:524,NZ:554, + OM:512, + PA:591,PE:604,PG:598,PH:608,PK:586,PL:616,PT:620,PY:600, + QA:634, + RO:642,RS:688,RU:643,RW:646, + SA:682,SB:90,SD:729,SE:752,SG:702,SI:705,SK:703,SL:694,SN:686, + SO:706,SR:740,SS:728,SV:222,SY:760,SZ:748, + TD:148,TG:768,TH:764,TJ:762,TM:795,TN:788,TO:776,TR:792,TT:780, + TZ:834, + UA:804,UG:800,US:840,UY:858,UZ:860, + VE:862,VN:704, + YE:887, + ZA:710,ZM:894,ZW:716, + }; + + // Build numeric → color lookup + const numColor = {}; + Object.entries(countryColors).forEach(([iso2, color]) => { + const num = ISO2_NUM[iso2.toUpperCase()]; + if (num !== undefined) numColor[num] = color; + }); + + // ── Clear container ─────────────────────────────────────────────────────── + const container = d3.select(containerSelector); + container.selectAll('*').remove(); + + // ── SVG scaffold ────────────────────────────────────────────────────────── + const svg = fokResponsiveSVG(container, `0 0 ${W} ${H}`) + .style('background', options.ocean ?? '#fff'); + + // ── Load TopoJSON ───────────────────────────────────────────────────────── + const topoUrl = options.topoJsonUrl + ?? 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'; + + let world; + try { + world = await d3.json(topoUrl); + } catch (err) { + console.error('fokMapWorld: failed to load TopoJSON', err); + container.append('p') + .style('color', theme.colors.negative) + .style('font-family', theme.font) + .text('Mapu se nepodařilo načíst.'); + return; + } + + const countries = topojson.feature(world, world.objects.countries); + + // ── Projection ──────────────────────────────────────────────────────────── + const projection = d3.geoNaturalEarth1() + .fitSize([W, H], countries); + const path = d3.geoPath().projection(projection); + + // ── Tooltip ─────────────────────────────────────────────────────────────── + const tip = fokTooltip(theme); + + // Build reverse lookup: numeric ID → ISO-2 + const NUM_ISO2 = {}; + Object.entries(ISO2_NUM).forEach(([iso2, num]) => { NUM_ISO2[num] = iso2; }); + + const defaultTooltip = (iso2, color) => + ` ` + + `${iso2}`; + + const tooltipHtml = options.tooltipHtml ?? defaultTooltip; + + // ── Draw countries ──────────────────────────────────────────────────────── + svg.selectAll('.world-country') + .data(countries.features) + .join('path') + .attr('class', 'world-country') + .attr('d', path) + .attr('fill', d => numColor[+d.id] ?? unknown) + .attr('stroke', border) + .attr('stroke-width', 0.5) + .on('mouseover', function(event, d) { + const color = numColor[+d.id]; + if (!color) return; + d3.select(this).attr('opacity', 0.75); + const iso2 = NUM_ISO2[+d.id] ?? String(d.id); + tip.show(tooltipHtml(iso2, color, d)); + tip.move(event); + }) + .on('mousemove', event => tip.move(event)) + .on('mouseleave', function(event, d) { + if (!numColor[+d.id]) return; + d3.select(this).attr('opacity', 1); + tip.hide(); + }); + + return { svg, tip, projection, path }; +} diff --git a/assets-local/charts/fok-theme.js b/assets-local/charts/fok-theme.js new file mode 100644 index 000000000..dd163fa6e --- /dev/null +++ b/assets-local/charts/fok-theme.js @@ -0,0 +1,108 @@ +/** + * fok-theme.js — FoK chart cosmetics layer + * + * THE single source of truth for all visual values. + * Swap this object to rebrand every chart at once. + * Chart bones (scales, axes, data encoding) must never hardcode any value from here. + */ + +const FoKTheme = { + colors: { + primary: '#0050ae', + accent: '#1a88ff', + grey: '#53616e', + lightGrey: '#9ba5ad', + gridLine: '#e8eef6', + text: '#3a3a45', + + // Categorical palette — sector order: energetika, průmysl, doprava, budovy, zemědělství, odpady, ostatní + categorical: [ + '#f4465b', // energetika + '#3b3b93', // průmysl + '#8546af', // doprava + '#0d80d8', // budovy + '#00aa95', // zemědělství + '#fab519', // odpady + '#b5b8bd', // ostatní + ], + + // Named sector colors for explicit lookup + sectors: { + energetika: '#f4465b', + prumysl: '#3b3b93', + doprava: '#8546af', + budovy: '#0d80d8', + zemedelstvi: '#00aa95', + odpady: '#fab519', + ostatni: '#b5b8bd', + }, + + // Sequential for temperature anomaly (cold → neutral → warm) + sequential: { + cold: '#1a88ff', + neutral: '#f7f7f7', + warm: '#c65163', + }, + + // Semantic + positive: '#5db16f', + negative: '#c65163', + neutral: '#9ba5ad', + }, + + font: '"Roboto", system-ui, sans-serif', + fontTitle: '"Inter", system-ui, sans-serif', + + fontSize: { + title: 16, + subtitle: 13, + axisLabel: 12, + annotation: 11, + tooltip: 12, + }, + + fontWeight: { + normal: 400, + bold: 700, // Inter Bold + titleBold: 700, + }, + + margins: { + top: 24, + right: 20, + bottom: 40, + left: 52, + }, + + axis: { + tickSize: 4, + tickPadding: 12, + tickColor: '#9ba5ad', + gridColor: '#e8eef6', + lineColor: '#9ba5ad', + }, + + bar: { + radius: 0, // px, border-radius on bar tops + padding: 0.2, // band scale inner padding (0–1) + }, + + line: { + strokeWidth: 2, + dotRadius: 3, + dotRadiusHovered: 5, + }, + + tooltip: { + background: '#fff', + border: '1px solid #e8eef6', + borderRadius: 4, + shadow: '0 2px 8px rgba(0,0,0,0.10)', + padding: '8px 12px', + }, + + animation: { + duration: 400, // ms + ease: 'easeCubicOut', + }, +}; diff --git a/assets-local/charts/fok-utils.js b/assets-local/charts/fok-utils.js new file mode 100644 index 000000000..7b1300528 --- /dev/null +++ b/assets-local/charts/fok-utils.js @@ -0,0 +1,308 @@ +/** + * fok-utils.js — shared D3 helpers + * + * All helpers accept an explicit theme argument so they are testable in isolation + * and work correctly if the caller passes a patched theme. + * + * Dependencies (must be loaded before this file): + * - D3 v7+ + * - fok-theme.js (FoKTheme) + */ + +// --------------------------------------------------------------------------- +// Margin +// --------------------------------------------------------------------------- + +/** + * Returns a margin object merged with theme defaults. + * @param {object} overrides — partial {top, right, bottom, left} + * @param {object} [theme] + * @returns {{top: number, right: number, bottom: number, left: number}} + */ +function fokMargin(overrides = {}, theme = FoKTheme) { + return { ...theme.margins, ...overrides }; +} + +// --------------------------------------------------------------------------- +// Responsive SVG +// --------------------------------------------------------------------------- + +/** + * Appends a 100%-wide SVG to container with a fixed viewBox. + * Returns the d3 selection of the element. + * + * @param {d3.Selection|string} container — d3 selection or CSS selector string + * @param {string} viewBox — e.g. "0 0 800 400" + * @param {string} [cssClass] + * @returns {d3.Selection} + */ +function fokResponsiveSVG(container, viewBox, cssClass = 'fok-chart-svg') { + const sel = typeof container === 'string' ? d3.select(container) : container; + return sel.append('svg') + .attr('width', '100%') + .attr('viewBox', viewBox) + .attr('preserveAspectRatio', 'xMidYMid meet') + .attr('class', cssClass); +} + +// --------------------------------------------------------------------------- +// Axes +// --------------------------------------------------------------------------- + +/** + * Creates a styled bottom (X) axis. + * + * @param {d3.Scale} scale + * @param {object} [options] + * @param {number} [options.ticks] + * @param {function}[options.tickFormat] + * @param {boolean} [options.gridLines=false] — draw vertical grid lines + * @param {number} [options.gridHeight] — required when gridLines=true + * @param {object} [theme] + * @returns {function} — d3 axis + post-render styler + */ +function fokAxisX(scale, options = {}, theme = FoKTheme) { + const axis = d3.axisBottom(scale) + .tickSize(options.gridLines ? -options.gridHeight : theme.axis.tickSize) + .tickPadding(theme.axis.tickPadding); + + if (options.ticks !== undefined) axis.ticks(options.ticks); + if (options.tickFormat !== undefined) axis.tickFormat(options.tickFormat); + if (options.tickValues !== undefined) axis.tickValues(options.tickValues); + + return function(selection) { + selection.call(axis); + _styleAxis(selection, options.gridLines, theme); + }; +} + +/** + * Creates a styled left (Y) axis. + * + * @param {d3.Scale} scale + * @param {object} [options] + * @param {number} [options.ticks] + * @param {function}[options.tickFormat] + * @param {boolean} [options.gridLines=true] — draw horizontal grid lines (default on) + * @param {number} [options.gridWidth] — required when gridLines=true + * @param {object} [theme] + * @returns {function} + */ +function fokAxisY(scale, options = {}, theme = FoKTheme) { + const gridLines = options.gridLines !== false; + const axis = d3.axisLeft(scale) + .tickSize(gridLines ? -options.gridWidth : theme.axis.tickSize) + .tickPadding(theme.axis.tickPadding); + + if (options.ticks !== undefined) axis.ticks(options.ticks); + if (options.tickFormat !== undefined) axis.tickFormat(options.tickFormat); + if (options.tickValues !== undefined) axis.tickValues(options.tickValues); + + return function(selection) { + selection.call(axis); + _styleAxis(selection, gridLines, theme); + }; +} + +function _styleAxis(selection, isGrid, theme) { + // Domain line — always hidden (grid lines carry the visual weight) + selection.select('.domain').attr('stroke', 'none'); + + // Ticks — grid lines get gridColor, regular ticks get tickColor + selection.selectAll('.tick line') + .attr('stroke', isGrid ? theme.axis.gridColor : theme.axis.tickColor) + .attr('stroke-width', 1); + + // Labels + selection.selectAll('.tick text') + .attr('fill', theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.axisLabel); +} + +// --------------------------------------------------------------------------- +// Annotation +// --------------------------------------------------------------------------- + +/** + * Appends a styled text annotation to an SVG group. + * + * @param {d3.Selection} svg — the or to append to + * @param {string} text + * @param {number} x + * @param {number} y + * @param {object} [options] + * @param {string} [options.anchor='start'] — text-anchor + * @param {string} [options.color] + * @param {object} [theme] + * @returns {d3.Selection} the text element + */ +function fokAnnotation(svg, text, x, y, options = {}, theme = FoKTheme) { + return svg.append('text') + .attr('class', 'fok-annotation') + .attr('x', x) + .attr('y', y) + .attr('text-anchor', options.anchor ?? 'start') + .attr('fill', options.color ?? theme.colors.grey) + .attr('font-family', theme.font) + .attr('font-size', theme.fontSize.annotation) + .text(text); +} + +// --------------------------------------------------------------------------- +// Legend +// --------------------------------------------------------------------------- + +/** + * Renders a horizontal (default) or vertical color legend into container. + * + * @param {d3.Selection|string} container + * @param {Array<{label: string, color: string}>} items + * @param {object} [options] + * @param {'horizontal'|'vertical'} [options.direction='horizontal'] + * @param {number} [options.swatchSize=12] + * @param {number} [options.gap=8] — gap between swatch and label + * @param {number} [options.itemSpacing=20] — spacing between legend items + * @param {object} [theme] + */ +function fokLegend(container, items, options = {}, theme = FoKTheme) { + const sel = typeof container === 'string' ? d3.select(container) : container; + const direction = options.direction ?? 'horizontal'; + const swatchSize = options.swatchSize ?? 12; + const gap = options.gap ?? 8; + const itemSpacing = options.itemSpacing ?? 20; + + const legendEl = sel.append('div').attr('class', 'fok-legend fok-legend--' + direction); + + items.forEach(item => { + const itemEl = legendEl.append('div').attr('class', 'fok-legend__item'); + + itemEl.append('span') + .attr('class', 'fok-legend__swatch') + .style('background', item.color) + .style('width', swatchSize + 'px') + .style('height', swatchSize + 'px'); + + itemEl.append('span') + .attr('class', 'fok-legend__label') + .style('font-family', theme.font) + .style('font-size', theme.fontSize.axisLabel + 'px') + .style('color', theme.colors.grey) + .style('margin-left', gap + 'px') + .text(item.label); + }); +} + +// --------------------------------------------------------------------------- +// Tooltip +// --------------------------------------------------------------------------- + +/** + * Creates a floating tooltip div and returns show/hide/move helpers. + * Appends the tooltip to document.body (escapes SVG stacking context). + * + * @param {object} [theme] + * @returns {{ show: function, move: function, hide: function, remove: function }} + */ +function fokTooltip(theme = FoKTheme) { + const tip = d3.select('body').append('div') + .attr('class', 'fok-tooltip') + .style('position', 'fixed') + .style('pointer-events', 'none') + .style('opacity', 0) + .style('background', theme.tooltip.background) + .style('border', theme.tooltip.border) + .style('border-radius', theme.tooltip.borderRadius + 'px') + .style('box-shadow', theme.tooltip.shadow) + .style('padding', theme.tooltip.padding) + .style('font-family', theme.font) + .style('font-size', theme.fontSize.tooltip + 'px') + .style('color', theme.colors.text) + .style('z-index', 9999); + + return { + show(html) { + tip.html(html).style('opacity', 1); + }, + move(event) { + const [mx, my] = [event.clientX, event.clientY]; + const tipNode = tip.node(); + const w = tipNode.offsetWidth; + const h = tipNode.offsetHeight; + const vw = window.innerWidth; + const vh = window.innerHeight; + const offset = 12; + + let x = mx + offset; + let y = my - h / 2; + + if (x + w > vw - 8) x = mx - w - offset; + if (y < 8) y = 8; + if (y + h > vh - 8) y = vh - h - 8; + + tip.style('left', x + 'px').style('top', y + 'px'); + }, + hide() { + tip.style('opacity', 0); + }, + remove() { + tip.remove(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Color scale helpers +// --------------------------------------------------------------------------- + +/** + * Returns a d3 ordinal scale mapped to the theme categorical palette. + * @param {string[]} domain + * @param {object} [theme] + * @returns {d3.ScaleOrdinal} + */ +function fokColorOrdinal(domain, theme = FoKTheme) { + return d3.scaleOrdinal() + .domain(domain) + .range(theme.colors.categorical); +} + +/** + * Returns a d3 diverging scale for temperature anomaly. + * @param {number} min + * @param {number} max + * @param {object} [theme] + * @returns {d3.ScaleDiverging} + */ +function fokColorDiverging(min, max, theme = FoKTheme) { + return d3.scaleDiverging() + .domain([min, 0, max]) + .interpolator(d3.interpolateRgbBasis([ + theme.colors.sequential.cold, + theme.colors.sequential.neutral, + theme.colors.sequential.warm, + ])); +} + +// --------------------------------------------------------------------------- +// Number formatting helpers +// --------------------------------------------------------------------------- + +/** + * Formats a number with Czech locale conventions (space as thousands separator). + * e.g. 1234567.8 → "1 234 568" + */ +function fokFormatNumber(value, decimals = 0) { + return value.toLocaleString('cs-CZ', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +/** + * Formats a value with a unit suffix. + * e.g. fokFormatUnit(42.3, 'Mt CO₂', 1) → "42,3 Mt CO₂" + */ +function fokFormatUnit(value, unit, decimals = 1) { + return fokFormatNumber(value, decimals) + ' ' + unit; +} diff --git a/assets-local/js/costs-and-benefits-graphics.js b/assets-local/js/costs-and-benefits-graphics.js new file mode 100644 index 000000000..4a2c7003c --- /dev/null +++ b/assets-local/js/costs-and-benefits-graphics.js @@ -0,0 +1,2737 @@ +(function () { + 'use strict'; + + const data = window.COSTS_AND_BENEFITS; + if (!data) return; + + // ── State ───────────────────────────────────────────────────────────────── + const state = { + carbonPrice: 60, + discountRate: 3, + fuelScenario: 'CP', + electricityPriceFactor: 1.0, + }; + + // ── Formatting ──────────────────────────────────────────────────────────── + const fmtInt = new Intl.NumberFormat('cs-CZ', { maximumFractionDigits: 0 }); + + function fmtCZK(v) { + const sign = v < 0 ? '− ' : '+ '; + const abs = Math.abs(v); + if (abs >= 1e6) return sign + (Math.round(abs / 1e5) / 10).toFixed(1) + ' mil. Kč'; + if (abs >= 1e3) return sign + fmtInt.format(Math.round(abs / 1e3)) + ' tis. Kč'; + return sign + fmtInt.format(abs) + ' Kč'; + } + + // ── Controls ────────────────────────────────────────────────────────────── + function setupControls() { + setupSlider('carbon-price-slider', 'carbon-price-value', v => { + state.carbonPrice = v; + return v + ' €'; + }); + setupSlider('discount-rate-slider', 'discount-rate-value', v => { + state.discountRate = v; + return v + ' %'; + }); + const fsSelect = document.getElementById('fuel-scenario-select'); + const cpSlider = document.getElementById('carbon-price-slider'); + const cpValueEl = document.getElementById('carbon-price-value'); + const cpControlGroup = cpSlider && cpSlider.closest('.control-group'); + + function applyFuelScenario(scenario) { + state.fuelScenario = scenario; + const isNZ = scenario === 'NZ'; + if (cpSlider) { + cpSlider.disabled = isNZ; + if (cpControlGroup) cpControlGroup.classList.toggle('control-group--disabled', isNZ); + } + if (isNZ && cpValueEl) cpValueEl.textContent = 'trajektorie NZ'; + else if (!isNZ && cpValueEl) cpValueEl.textContent = state.carbonPrice + ' €'; + } + + if (fsSelect) { + fsSelect.addEventListener('change', () => { + applyFuelScenario(fsSelect.value); + renderAll(); + }); + } + + } + + function setupSlider(sliderId, valueId, onUpdate) { + const slider = document.getElementById(sliderId); + const valueEl = document.getElementById(valueId); + if (!slider || !valueEl) return; + slider.addEventListener('input', () => { + valueEl.textContent = onUpdate(+slider.value); + renderAll(); + }); + } + + // ── Chart constants ─────────────────────────────────────────────────────── + const CP_CHART_MEASURES = [ + 'Tepelné čerpadlo', + 'Zateplení + fasáda', + 'Výměna oken a dveří', + 'Elektrický kotel', + 'Kotel na biomasu', + 'Soláry na střeše+baterie', + 'Nový malý elektromobil', + 'Nový malý hybrid', + 'Nový velký elektromobil', + 'Nový velký hybrid', + 'Ojetý malý elektromobil', + 'Ojetý malý hybrid', + 'Ojetý velký elektromobil', + 'Ojetý velký hybrid', + ]; + const CP_CHART_COLORS = [ + // Buildings (indices 0–5) + '#1a7a85', '#2860b4', '#6b4fa0', '#c05a1a', '#2e7d32', '#8b6914', + // Transport – EV = red, hybrid = orange, repeated for each size/age group (indices 6–13) + '#c0392b', '#e67e22', // Nový malý elektromobil, Nový malý hybrid + '#c0392b', '#e67e22', // Nový velký elektromobil, Nový velký hybrid + '#c0392b', '#e67e22', // Ojetý malý elektromobil, Ojetý malý hybrid + '#c0392b', '#e67e22', // Ojetý velký elektromobil, Ojetý velký hybrid + ]; + + // ── Sensitivity beeswarm constants ─────────────────────────────────────── + const SB_SCENARIOS = ['CP', 'NZ', 'CP_EC']; + const SB_SCENARIO_LABEL = { CP: 'Současné politiky', NZ: 'Net-zero', CP_EC: 'Energetická krize' }; + const SB_CARBON_PRICES = [0, 60, 100, 200]; + const SB_DISCOUNT_RATES = [0, 3, 7]; + const SB_DEFAULT = { scenario: 'CP', cp: 60, dr: 3 }; + const SB_X_DOMAIN = [-1e6, 1e6]; + + // Color-by scales + const SB_SC_COLORS = { CP: '#e07b39', NZ: '#2a9d8f', CP_EC: '#9b2335' }; + const SB_CP_COLORS = { 0: '#fde0c8', 60: '#fc8d59', 100: '#d7301f', 200: '#7f0000' }; + const SB_DR_COLORS = { 0: '#c6dbef', 3: '#4292c6', 7: '#08306b' }; + const SB_CAT_COLORS = { + 'Rodinný dům uhlí – E': '#903156', + 'Rodinný dům uhlí – C': '#903156', + 'Rodinný dům plyn – E': '#e37373', + 'Rodinný dům plyn – C': '#e37373', + 'Byt ve starší zástavbě s vlastním plynovým kotlem': '#2e7d5b', + 'Byt v panelovém domě s plynovou kotelnou': '#1a7a85', + 'Nové malé': '#6b4fa0', + 'Nové velké': '#8546af', + 'Ojeté malé': '#9b6fc4', + 'Ojeté velké':'#b090d4', + }; + const SB_BUILDING_CATS = [ + 'Rodinný dům uhlí – E', 'Rodinný dům uhlí – C', + 'Rodinný dům plyn – E', 'Rodinný dům plyn – C', + 'Byt ve starší zástavbě s vlastním plynovým kotlem', + 'Byt v panelovém domě s plynovou kotelnou', + ]; + const SB_TRANSPORT_CATS = ['Nové malé', 'Nové velké', 'Ojeté malé', 'Ojeté velké']; + + // ── Tornado chart ───────────────────────────────────────────────────────── + // + // Cena uhlíku: band spanning NPV at 0 € → 200 €; dot at current carbon price. + // Diskontní míra: three coloured dots at 0 %, 3 % and 7 % (no band). + + function renderTornadoChart(container, category, param = 'Cena uhlíku', exclude = [], forceDomain = null) { + const catField = 'building_category'; + + const allMeasures = [ + ...(data.buildings_measures || []), + ...(data.transport_measures || []), + ].filter(m => + (m.measure_baseline_id || m.measure_baseline) && + CP_CHART_MEASURES.includes(m.measure_name) && + (!category || m[catField] === category || m.transport_category === category) + ); + + function calcNpv(entry, cp, dr) { + try { + const r = CostsBenefits.calculate({ + measureId: entry.id, data, + discountRate: dr / 100, + carbonPriceEur: cp, + priceScenario: state.fuelScenario, + electricityPriceFactor: state.electricityPriceFactor, + }); + return isNaN(r.npv) ? null : r.npv; + } catch (_) { return null; } + } + + function findEntry(entries, cp, dr) { + return entries.find(m => { + try { + const r = CostsBenefits.calculate({ + measureId: m.id, data, + discountRate: dr / 100, carbonPriceEur: cp, + priceScenario: state.fuelScenario, + electricityPriceFactor: state.electricityPriceFactor, + }); + return !isNaN(r.npv); + } catch (_) { return false; } + }); + } + + if (param === 'Diskontní míra') { + // ── Three-dot variant ────────────────────────────────────────────────── + const STEP_COLORS = ['#0d4a52', '#1a7a85', '#6ab4bc']; + const STEP_RATES = [0, 3, 7]; + const STEP_LABELS = ['0 %', '3 %', '7 %']; + + const rows = CP_CHART_MEASURES.map((name, ni) => { + if (exclude.includes(name)) return null; + const entries = allMeasures.filter(m => m.measure_name === name); + if (!entries.length) return null; + const entry = findEntry(entries, state.carbonPrice, 3); + if (!entry) return null; + const npvs = STEP_RATES.map(dr => calcNpv(entry, state.carbonPrice, dr)); + if (npvs.every(v => v == null)) return null; + return { name, npvs }; + }).filter(Boolean); + + if (!rows.length) { container.hidden = true; return; } + + const ROW_H = 32; + const LABEL_W = 200; + const T_MARGIN = { top: 20, right: 24, bottom: 36, left: 8 }; + const totalW = container.clientWidth || 700; + const chartW = Math.max(totalW - LABEL_W - T_MARGIN.left - T_MARGIN.right, 120); + const totalH = rows.length * ROW_H + T_MARGIN.top + T_MARGIN.bottom; + + const allVals = rows.flatMap(r => r.npvs.filter(v => v != null)); + const [xMin, xMax] = d3.extent(allVals); + const xPad = (xMax - xMin) * 0.06 || 20000; + const xDomain = forceDomain || d3.scaleLinear().domain([xMin - xPad, xMax + xPad]).nice().domain(); + const xScale = d3.scaleLinear().domain(xDomain).range([0, chartW]); + + d3.select(container).selectAll('*').remove(); + const svg = d3.select(container).append('svg').attr('width', totalW).attr('height', totalH); + const chart = svg.append('g').attr('transform', `translate(${T_MARGIN.left + LABEL_W}, 0)`); + + const zx = xScale(0); + chart.append('line') + .attr('x1', zx).attr('x2', zx) + .attr('y1', T_MARGIN.top).attr('y2', totalH - T_MARGIN.bottom) + .attr('stroke', '#ccc').attr('stroke-width', 1).attr('stroke-dasharray', '3 3'); + + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + const midY = T_MARGIN.top + i * ROW_H + ROW_H / 2; + + svg.append('text') + .attr('x', T_MARGIN.left + LABEL_W - 8).attr('y', midY + 4) + .attr('text-anchor', 'end').attr('font-size', '11px').attr('fill', '#444') + .text(r.name); + + STEP_RATES.forEach((dr, pi) => { + const npv = r.npvs[pi]; + if (npv == null) return; + const dotX = xScale(npv); + chart.append('circle') + .attr('cx', dotX).attr('cy', midY) + .attr('r', 5).attr('fill', STEP_COLORS[pi]) + .attr('stroke', 'white').attr('stroke-width', 1.5); + chart.append('text') + .attr('x', dotX).attr('y', midY - 8) + .attr('text-anchor', 'middle') + .attr('font-size', '9px').attr('fill', STEP_COLORS[pi]) + .text(STEP_LABELS[pi]); + }); + } + + chart.append('g') + .attr('transform', `translate(0, ${totalH - T_MARGIN.bottom})`) + .attr('class', 'chart-axis') + .call(d3.axisBottom(xScale).ticks(5).tickFormat(v => { + const a = Math.abs(v), s = v < 0 ? '−' : v > 0 ? '+' : ''; + if (a >= 1e6) return s + (a / 1e6).toFixed(1) + ' M'; + if (a >= 1e3) return s + Math.round(a / 1e3) + ' tis.'; + return v === 0 ? '0' : s + a; + })); + + } else { + // ── Band + dot variant (Cena uhlíku) ────────────────────────────────── + const rows = CP_CHART_MEASURES.map((name, ni) => { + if (exclude.includes(name)) return null; + const entries = allMeasures.filter(m => m.measure_name === name); + if (!entries.length) return null; + const entry = findEntry(entries, 100, state.discountRate); + if (!entry) return null; + + const dr = state.discountRate; + const npvAtMin = calcNpv(entry, 0, dr); + const npvAtMax = calcNpv(entry, 200, dr); + const npvCurrent = calcNpv(entry, state.carbonPrice, dr); + if (npvCurrent == null) return null; + + return { name, color: CP_CHART_COLORS[ni], npvAtMin, npvAtMax, npvCurrent }; + }).filter(Boolean); + + if (!rows.length) { container.hidden = true; return; } + + const ROW_H = 32; + const LABEL_W = 200; + const T_MARGIN = { top: 34, right: 24, bottom: 36, left: 8 }; + const totalW = container.clientWidth || 700; + const chartW = Math.max(totalW - LABEL_W - T_MARGIN.left - T_MARGIN.right, 120); + const totalH = rows.length * ROW_H + T_MARGIN.top + T_MARGIN.bottom; + + const allVals = rows.flatMap(r => + [r.npvAtMin, r.npvAtMax, r.npvCurrent].filter(v => v != null) + ); + const [xMin, xMax] = d3.extent(allVals); + const xPad = (xMax - xMin) * 0.06 || 20000; + const xDomain = forceDomain || d3.scaleLinear().domain([xMin - xPad, xMax + xPad]).nice().domain(); + const xScale = d3.scaleLinear().domain(xDomain).range([0, chartW]); + + d3.select(container).selectAll('*').remove(); + const svg = d3.select(container).append('svg').attr('width', totalW).attr('height', totalH); + const chart = svg.append('g').attr('transform', `translate(${T_MARGIN.left + LABEL_W}, 0)`); + + const zx = xScale(0); + chart.append('line') + .attr('x1', zx).attr('x2', zx) + .attr('y1', T_MARGIN.top).attr('y2', totalH - T_MARGIN.bottom) + .attr('stroke', '#ccc').attr('stroke-width', 1).attr('stroke-dasharray', '3 3'); + + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + const midY = T_MARGIN.top + i * ROW_H + ROW_H / 2; + const barH = ROW_H * 0.38; + + svg.append('text') + .attr('x', T_MARGIN.left + LABEL_W - 8).attr('y', midY + 4) + .attr('text-anchor', 'end').attr('font-size', '11px').attr('fill', '#444') + .text(r.name); + + if (r.npvAtMin != null && r.npvAtMax != null) { + const x1 = xScale(r.npvAtMin); + const x2 = xScale(r.npvAtMax); + chart.append('rect') + .attr('x', Math.min(x1, x2)).attr('y', midY - barH / 2) + .attr('width', Math.max(Math.abs(x2 - x1), 1)).attr('height', barH) + .attr('fill', r.color).attr('opacity', 0.25).attr('rx', 3); + } + + const dotX = xScale(r.npvCurrent); + chart.append('circle') + .attr('cx', dotX).attr('cy', midY) + .attr('r', 5).attr('fill', r.color) + .attr('stroke', 'white').attr('stroke-width', 1.5); + } + + chart.append('g') + .attr('transform', `translate(0, ${totalH - T_MARGIN.bottom})`) + .attr('class', 'chart-axis') + .call(d3.axisBottom(xScale).ticks(5).tickFormat(v => { + const a = Math.abs(v), s = v < 0 ? '−' : v > 0 ? '+' : ''; + if (a >= 1e6) return s + (a / 1e6).toFixed(1) + ' M'; + if (a >= 1e3) return s + Math.round(a / 1e3) + ' tis.'; + return v === 0 ? '0' : s + a; + })); + + // Legend + const BAND_W = 60, BAND_H = 10, DOT_R = 4; + const legY = 8, legX = chartW - BAND_W - 100; + + chart.append('rect') + .attr('x', legX).attr('y', legY) + .attr('width', BAND_W).attr('height', BAND_H) + .attr('fill', '#999').attr('opacity', 0.25).attr('rx', 2); + chart.append('circle') + .attr('cx', legX + BAND_W / 2).attr('cy', legY + BAND_H / 2) + .attr('r', DOT_R).attr('fill', '#888') + .attr('stroke', 'white').attr('stroke-width', 1.5); + chart.append('text') + .attr('x', legX).attr('y', legY - 2) + .attr('font-size', '9px').attr('fill', '#aaa').text('0 €'); + chart.append('text') + .attr('x', legX + BAND_W).attr('y', legY - 2) + .attr('text-anchor', 'end').attr('font-size', '9px').attr('fill', '#aaa').text('200 €'); + chart.append('text') + .attr('x', legX + BAND_W / 2).attr('y', legY + BAND_H + 9) + .attr('text-anchor', 'middle').attr('font-size', '9px').attr('fill', '#888').text('NPV'); + } + } + // ── Multi-category tornado chart ────────────────────────────────────────── + // Renders several category groups in one SVG with a shared x-axis. + // categories: array of category strings, e.g. ['Nové malé', 'Ojeté malé'] + function renderMultiTornadoChart(container, categories, param = 'Cena uhlíku', exclude = [], forceDomain = null) { + const isDiscountRate = param === 'Diskontní míra'; + const isElTariff = param === 'Tarif elektřiny'; + const STEP_COLORS = ['#0d4a52', '#1a7a85', '#6ab4bc']; + const STEP_RATES = [0, 3, 7]; + const STEP_LABELS = ['0 %', '3 %', '7 %']; + const TARIFF_SCENARIOS = data.electricity_price_scenarios || []; + const TARIFF_FACTORS = TARIFF_SCENARIOS.map(s => s.electricity_price_factor); + const TARIFF_COLORS = ['#2d1b54', '#6b4fa0', '#9b7fd0', '#c8b4e8'].slice(0, TARIFF_FACTORS.length); + const TARIFF_LABELS = TARIFF_SCENARIOS.map(s => s.electricity_price_scenario); + + // epFactor defaults to current state; pass an explicit value for the tariff chart. + function calcNpv(entry, cp, dr, epFactor = state.electricityPriceFactor) { + try { + const r = CostsBenefits.calculate({ + measureId: entry.id, data, + discountRate: dr / 100, carbonPriceEur: cp, + priceScenario: state.fuelScenario, + electricityPriceFactor: epFactor, + }); + return isNaN(r.npv) ? null : r.npv; + } catch (_) { return null; } + } + + function findEntry(entries, cp, dr) { + return entries.find(m => { + try { + const r = CostsBenefits.calculate({ + measureId: m.id, data, + discountRate: dr / 100, carbonPriceEur: cp, + priceScenario: state.fuelScenario, + electricityPriceFactor: state.electricityPriceFactor, + }); + return !isNaN(r.npv); + } catch (_) { return false; } + }); + } + + // Build rows for each category group + const groups = categories.map(category => { + const allMeasures = [ + ...(data.buildings_measures || []), + ...(data.transport_measures || []), + ].filter(m => + (m.measure_baseline_id || m.measure_baseline) && + CP_CHART_MEASURES.includes(m.measure_name) && + (!category || m.building_category === category || m.transport_category === category) + ); + + const rows = CP_CHART_MEASURES.map((name, ni) => { + if (exclude.includes(name)) return null; + const entries = allMeasures.filter(m => m.measure_name === name); + if (!entries.length) return null; + + if (isDiscountRate) { + const entry = findEntry(entries, state.carbonPrice, 3); + if (!entry) return null; + const npvs = STEP_RATES.map(dr => calcNpv(entry, state.carbonPrice, dr)); + if (npvs.every(v => v == null)) return null; + return { name, color: CP_CHART_COLORS[ni], npvs }; + } else if (isElTariff) { + const entry = findEntry(entries, state.carbonPrice, state.discountRate); + if (!entry) return null; + const npvs = TARIFF_FACTORS.map(f => calcNpv(entry, state.carbonPrice, state.discountRate, f)); + if (npvs.every(v => v == null)) return null; + return { name, color: CP_CHART_COLORS[ni], npvs }; + } else { + const entry = findEntry(entries, 100, state.discountRate); + if (!entry) return null; + const dr = state.discountRate; + const npvAtMin = calcNpv(entry, 0, dr); + const npvAtMax = calcNpv(entry, 200, dr); + const npvCurrent = calcNpv(entry, state.carbonPrice, dr); + if (npvCurrent == null) return null; + return { name, color: CP_CHART_COLORS[ni], npvAtMin, npvAtMax, npvCurrent }; + } + }).filter(Boolean); + + return { category, rows }; + }).filter(g => g.rows.length > 0); + + if (!groups.length) { container.hidden = true; return; } + + const ROW_H = 32; + const GROUP_HEADER_H = 22; + const GROUP_GAP = 12; + const LABEL_W = 200; + const T_MARGIN = { top: isElTariff ? 64 : isDiscountRate ? 20 : 34, right: 24, bottom: 36, left: 8 }; + + const totalW = container.clientWidth || 700; + const chartW = Math.max(totalW - LABEL_W - T_MARGIN.left - T_MARGIN.right, 120); + const totalH = groups.reduce((h, g) => h + GROUP_HEADER_H + g.rows.length * ROW_H, 0) + + (groups.length - 1) * GROUP_GAP + + T_MARGIN.top + T_MARGIN.bottom; + + // Shared x-domain + const allVals = groups.flatMap(g => g.rows.flatMap(r => + (isDiscountRate || isElTariff) + ? r.npvs.filter(v => v != null) + : [r.npvAtMin, r.npvAtMax, r.npvCurrent].filter(v => v != null) + )); + const [xMin, xMax] = d3.extent(allVals); + const xPad = (xMax - xMin) * 0.06 || 20000; + const xDomain = forceDomain || d3.scaleLinear().domain([xMin - xPad, xMax + xPad]).nice().domain(); + const xScale = d3.scaleLinear().domain(xDomain).range([0, chartW]); + + d3.select(container).selectAll('*').remove(); + const svg = d3.select(container).append('svg').attr('width', totalW).attr('height', totalH); + const chart = svg.append('g').attr('transform', `translate(${T_MARGIN.left + LABEL_W}, 0)`); + + // Zero line (full chart height) + const zx = xScale(0); + chart.append('line') + .attr('x1', zx).attr('x2', zx) + .attr('y1', T_MARGIN.top).attr('y2', totalH - T_MARGIN.bottom) + .attr('stroke', '#ccc').attr('stroke-width', 1).attr('stroke-dasharray', '3 3'); + + // Legend for carbon price chart (top-right, drawn once) + if (!isDiscountRate && !isElTariff) { + const BAND_W = 60, BAND_H = 10, DOT_R = 4; + const legY = 8, legX = chartW - BAND_W - 100; + chart.append('rect') + .attr('x', legX).attr('y', legY) + .attr('width', BAND_W).attr('height', BAND_H) + .attr('fill', '#999').attr('opacity', 0.25).attr('rx', 2); + chart.append('circle') + .attr('cx', legX + BAND_W / 2).attr('cy', legY + BAND_H / 2) + .attr('r', DOT_R).attr('fill', '#888').attr('stroke', 'white').attr('stroke-width', 1.5); + chart.append('text') + .attr('x', legX).attr('y', legY - 2) + .attr('font-size', '9px').attr('fill', '#aaa').text('0 €'); + chart.append('text') + .attr('x', legX + BAND_W).attr('y', legY - 2) + .attr('text-anchor', 'end').attr('font-size', '9px').attr('fill', '#aaa').text('200 €'); + chart.append('text') + .attr('x', legX + BAND_W / 2).attr('y', legY + BAND_H + 9) + .attr('text-anchor', 'middle').attr('font-size', '9px').attr('fill', '#888').text('NPV'); + } + + // Legend for tariff chart: vertical list of coloured dots + full scenario names. + if (isElTariff) { + const DOT_R = 4; + const ITEM_H = 14; + const legY = 6; + TARIFF_FACTORS.forEach((_, pi) => { + const iy = legY + pi * ITEM_H; + chart.append('circle') + .attr('cx', DOT_R).attr('cy', iy + DOT_R) + .attr('r', DOT_R).attr('fill', TARIFF_COLORS[pi]) + .attr('stroke', 'white').attr('stroke-width', 1); + chart.append('text') + .attr('x', DOT_R * 2 + 5).attr('y', iy + DOT_R + 3) + .attr('font-size', '9px').attr('fill', '#888') + .text(TARIFF_LABELS[pi]); + }); + } + + let currentY = T_MARGIN.top; + + for (let gi = 0; gi < groups.length; gi++) { + const group = groups[gi]; + + // Category header label + svg.append('text') + .attr('x', T_MARGIN.left + 4).attr('y', currentY + 15) + .attr('font-size', '11px').attr('font-weight', '700').attr('fill', '#555') + .text(group.category.toUpperCase()); + currentY += GROUP_HEADER_H; + + for (const r of group.rows) { + const midY = currentY + ROW_H / 2; + const barH = ROW_H * 0.38; + + // Measure label + svg.append('text') + .attr('x', T_MARGIN.left + LABEL_W - 8).attr('y', midY + 4) + .attr('text-anchor', 'end').attr('font-size', '11px').attr('fill', '#444') + .text(r.name); + + if (isDiscountRate || isElTariff) { + const DOT_COLORS = isElTariff ? TARIFF_COLORS : STEP_COLORS; + const DOT_LABELS = isElTariff ? TARIFF_LABELS : STEP_LABELS; + r.npvs.forEach((npv, pi) => { + if (npv == null) return; + const dotX = xScale(npv); + chart.append('circle') + .attr('cx', dotX).attr('cy', midY) + .attr('r', 5).attr('fill', DOT_COLORS[pi]) + .attr('stroke', 'white').attr('stroke-width', 1.5); + // Inline labels only for discount rate; tariff uses a legend instead. + if (!isElTariff) { + chart.append('text') + .attr('x', dotX).attr('y', midY - 8) + .attr('text-anchor', 'middle') + .attr('font-size', '9px').attr('fill', DOT_COLORS[pi]) + .text(DOT_LABELS[pi]); + } + }); + } else { + if (r.npvAtMin != null && r.npvAtMax != null) { + const x1 = xScale(r.npvAtMin), x2 = xScale(r.npvAtMax); + chart.append('rect') + .attr('x', Math.min(x1, x2)).attr('y', midY - barH / 2) + .attr('width', Math.max(Math.abs(x2 - x1), 1)).attr('height', barH) + .attr('fill', r.color).attr('opacity', 0.25).attr('rx', 3); + } + const dotX = xScale(r.npvCurrent); + chart.append('circle') + .attr('cx', dotX).attr('cy', midY) + .attr('r', 5).attr('fill', r.color) + .attr('stroke', 'white').attr('stroke-width', 1.5); + } + + currentY += ROW_H; + } + + if (gi < groups.length - 1) currentY += GROUP_GAP; + } + + // X axis + chart.append('g') + .attr('transform', `translate(0, ${totalH - T_MARGIN.bottom})`) + .attr('class', 'chart-axis') + .call(d3.axisBottom(xScale).ticks(5).tickFormat(v => { + const a = Math.abs(v), s = v < 0 ? '−' : v > 0 ? '+' : ''; + if (a >= 1e6) return s + (a / 1e6).toFixed(1) + ' M'; + if (a >= 1e3) return s + Math.round(a / 1e3) + ' tis.'; + return v === 0 ? '0' : s + a; + })); + } + + // ── Quadrant chart ──────────────────────────────────────────────────────── + // X = Rozdíl NPV oproti základní variantě (Kč) + // Y = Kč / t CO₂ (= −NPV / savedT) + // One point per measure (each row with measure_baseline_id), colored by sector. + + const Q_COLOR_BUILDINGS = '#2860b4'; + const Q_COLOR_TRANSPORT = '#6b4fa0'; + const Q_ANIM_MS = 450; + + // Quadrant colours (dot fill, keyed by TR/TL/BR/BL) + const Q_DOT_COLORS = { tr: '#006b94', tl: '#8dcdeb', br: '#e2a4a4', bl: '#973d4c' }; + function qQuadrantColor(npv, yVal) { + if (npv >= 0) return yVal >= 0 ? Q_DOT_COLORS.tr : Q_DOT_COLORS.br; + return yVal >= 0 ? Q_DOT_COLORS.tl : Q_DOT_COLORS.bl; + } + + // Filter state — sectors and measure names that are currently visible. + // measures = null means "not yet initialised"; after first render it's a Set. + const qFilter = { + sectors: new Set(['buildings', 'transport']), + measures: null, + }; + + // Y-axis mode: 'co2' = total CO₂ saved (t), 'abatement' = abatement cost (Kč/t CO₂) + let qYMode = 'co2'; + + // Build the filter UI (runs once; subsequent calls are no-ops). + function qBuildFilters(wrap, allPoints) { + if (wrap.querySelector('.q-filters')) return; + + const measureNames = []; + for (const p of allPoints) { + if (!measureNames.includes(p.name)) measureNames.push(p.name); + } + if (!qFilter.measures) { + qFilter.measures = new Set(measureNames); + } + + const filtersDiv = document.createElement('div'); + filtersDiv.className = 'q-filters'; + + // ── Sector row (replaces SVG legend) ─────────────────────────────────── + const sectorRow = document.createElement('div'); + sectorRow.className = 'q-filter-row'; + + const sLbl = document.createElement('span'); + sLbl.className = 'q-filter-label'; + sLbl.textContent = 'Sektor:'; + sectorRow.appendChild(sLbl); + + [ + { key: 'buildings', label: 'Budovy', color: Q_COLOR_BUILDINGS }, + { key: 'transport', label: 'Doprava', color: Q_COLOR_TRANSPORT }, + ].forEach(s => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn q-sector-btn' + (qFilter.sectors.has(s.key) ? ' active' : ''); + btn.dataset.sector = s.key; + btn.innerHTML = + `${s.label}`; + btn.addEventListener('click', () => { + if (qFilter.sectors.has(s.key)) { + qFilter.sectors.delete(s.key); + btn.classList.remove('active'); + } else { + qFilter.sectors.add(s.key); + btn.classList.add('active'); + } + renderAll(); + }); + sectorRow.appendChild(btn); + }); + filtersDiv.appendChild(sectorRow); + + // ── Measure row ──────────────────────────────────────────────────────── + const measRow = document.createElement('div'); + measRow.className = 'q-filter-row'; + + const mLbl = document.createElement('span'); + mLbl.className = 'q-filter-label'; + mLbl.textContent = 'Opatření:'; + measRow.appendChild(mLbl); + + measureNames.forEach(name => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn q-measure-btn' + (qFilter.measures.has(name) ? ' active' : ''); + btn.dataset.measureName = name; + btn.textContent = name; + btn.addEventListener('click', () => { + if (qFilter.measures.has(name)) { + qFilter.measures.delete(name); + btn.classList.remove('active'); + } else { + qFilter.measures.add(name); + btn.classList.add('active'); + } + renderAll(); + }); + measRow.appendChild(btn); + }); + filtersDiv.appendChild(measRow); + + // ── Y-axis toggle row ────────────────────────────────────────────────── + const yRow = document.createElement('div'); + yRow.className = 'q-filter-row'; + + const yLbl = document.createElement('span'); + yLbl.className = 'q-filter-label'; + yLbl.textContent = 'Osa Y:'; + yRow.appendChild(yLbl); + + [ + { key: 'co2', label: 'Úspora CO₂ (t)' }, + { key: 'abatement', label: 'Abatement cost (Kč/t CO₂)' }, + ].forEach(item => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn q-ymode-btn' + (qYMode === item.key ? ' active' : ''); + btn.dataset.ymode = item.key; + btn.textContent = item.label; + btn.addEventListener('click', () => { + if (qYMode === item.key) return; + qYMode = item.key; + wrap.querySelectorAll('.q-ymode-btn').forEach(b => + b.classList.toggle('active', b.dataset.ymode === item.key) + ); + renderAll(); + }); + yRow.appendChild(btn); + }); + filtersDiv.appendChild(yRow); + + // Insert before the chart SVG container + wrap.insertBefore(filtersDiv, wrap.querySelector('.quadrant-chart') || wrap.firstChild); + } + + // Floating tooltip (single instance, reused) + const qTip = document.createElement('div'); + Object.assign(qTip.style, { + position: 'fixed', + pointerEvents: 'none', + background: 'rgba(30,30,30,0.88)', + color: '#fff', + borderRadius: '5px', + padding: '5px 9px', + fontSize: '13px', + lineHeight: '1.45', + fontFamily: 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif', + whiteSpace: 'pre-wrap', + zIndex: '9999', + display: 'none', + maxWidth: '280px', + }); + document.body.appendChild(qTip); + + function showQTip(event, text) { + qTip.textContent = text; + qTip.style.display = 'block'; + moveQTip(event); + } + function moveQTip(event) { + const pad = 12; + const tw = qTip.offsetWidth, th = qTip.offsetHeight; + let x = event.clientX + pad, y = event.clientY + pad; + if (x + tw > window.innerWidth - 4) x = event.clientX - tw - pad; + if (y + th > window.innerHeight - 4) y = event.clientY - th - pad; + qTip.style.left = x + 'px'; + qTip.style.top = y + 'px'; + } + function hideQTip() { qTip.style.display = 'none'; } + + // Number formatters + const qFmtInt = new Intl.NumberFormat('cs-CZ', { maximumFractionDigits: 0 }); + + function qFmtAxis(v) { + const a = Math.abs(v), s = v < 0 ? '−' : v > 0 ? '+' : ''; + if (a >= 1e6) return s + (a / 1e6).toFixed(1) + ' M'; + if (a >= 1e3) return s + Math.round(a / 1e3) + ' tis.'; + return v === 0 ? '0' : s + a; + } + + function qFmtCZK(v) { + const sign = v < 0 ? '−' : '+'; + const abs = Math.abs(v); + if (abs >= 1e6) return sign + (Math.round(abs / 1e5) / 10).toFixed(1) + ' mil. Kč'; + if (abs >= 1e3) return sign + qFmtInt.format(Math.round(abs / 1e3)) + ' tis. Kč'; + return sign + qFmtInt.format(abs) + ' Kč'; + } + + function qFmt3sig(x) { return parseFloat(x.toPrecision(3)).toString(); } + + function qFmtCZKperT(czk, savedT) { + if (savedT == null || !savedT || !isFinite(czk / savedT)) return '—'; + const v = czk / savedT; + const sign = v < 0 ? '−' : '+'; + const abs = Math.abs(v); + if (abs >= 1e6) return sign + qFmt3sig(abs / 1e6) + ' mil. Kč/t CO₂'; + if (abs >= 1e3) return sign + qFmt3sig(abs / 1e3) + ' tis. Kč/t CO₂'; + return sign + qFmt3sig(abs) + ' Kč/t CO₂'; + } + + function qFmtTonnes(t) { + if (t == null || !isFinite(t)) return '—'; + const sign = t < 0 ? '− ' : ''; + const abs = Math.abs(t); + if (abs >= 1) return sign + qFmt3sig(abs) + ' t CO₂'; + return sign + Math.round(abs * 1000) + ' kg CO₂'; + } + + // Stable axis domains: computed once across all parameter combinations so + // points animate within fixed scales rather than the axes shifting. + // quadrantDomains.co2 and .abatement each carry an { x, y } pair. + let quadrantDomains = null; + + function qComputePoints(carbonPrice, discountRatePct, scenario) { + const all = [ + ...(data.buildings_measures || []), + ...(data.transport_measures || []), + ].filter(m => m.measure_baseline_id); + + const points = []; + for (const m of all) { + try { + const r = CostsBenefits.calculate({ + measureId: m.id, + data, + discountRate: discountRatePct / 100, + carbonPriceEur: carbonPrice, + priceScenario: scenario, + electricityPriceFactor: state.electricityPriceFactor, + }); + const savedT = r.emissionSavings ? -r.emissionSavings.totalT : null; + if (savedT == null || savedT === 0) continue; + if (!isFinite(r.npv)) continue; + + // NPV uncertainty range from sensitivity analysis (CAPEX ±30 %, fuel prices ±30 %) + const sens = r.sensitivity || []; + const npvLow = sens.length ? Math.min(...sens.map(s => s.minNpv)) : r.npv; + const npvHigh = sens.length ? Math.max(...sens.map(s => s.maxNpv)) : r.npv; + + // savedT uncertainty range across the three fuel-price scenarios + // (carbon price / discount rate don't affect emission factors) + const savedTValues = ['CP', 'NZ', 'CP_EC'].map(sc => { + try { + const rs = CostsBenefits.calculate({ + measureId: m.id, + data, + discountRate: discountRatePct / 100, + carbonPriceEur: carbonPrice, + priceScenario: sc, + electricityPriceFactor: state.electricityPriceFactor, + }); + const v = rs.emissionSavings ? -rs.emissionSavings.totalT : null; + return (v !== null && isFinite(v)) ? v : null; + } catch (_) { return null; } + }).filter(v => v !== null); + const savedTLow = savedTValues.length ? Math.min(...savedTValues) : savedT; + const savedTHigh = savedTValues.length ? Math.max(...savedTValues) : savedT; + + // Abatement cost = −NPV / savedT (positive = you PAY per tonne, negative = you EARN) + const kcPerT = -r.npv / savedT; + const kcPerTLow = -npvHigh / savedT; // most negative = most beneficial per tonne + const kcPerTHigh = -npvLow / savedT; // most positive = most costly per tonne + + points.push({ + id: m.id, + name: m.measure_name, + category: m.building_category || m.transport_category || '', + sector: r.sector, + npv: r.npv, + savedT, + kcPerT, + capexPerT: r.emissionSavings ? r.emissionSavings.perCapexDiff : null, + npvLow, npvHigh, + savedTLow, savedTHigh, + kcPerTLow, kcPerTHigh, + }); + } catch (_) { /* skip */ } + } + return points; + } + + function computeQuadrantDomains() { + const carbonPrices = [0, 60, 100, 200]; + const discountRates = [0, 3, 7]; + const scenarios = ['CP', 'NZ', 'CP_EC']; + + const xs = [], yCo2 = [], yAb = []; + for (const cp of carbonPrices) { + for (const dr of discountRates) { + for (const sc of scenarios) { + for (const p of qComputePoints(cp, dr, sc)) { + xs.push(p.npv); + yCo2.push(p.savedT); + if (isFinite(p.kcPerT)) yAb.push(p.kcPerT); + } + } + } + } + if (!xs.length) return { + co2: { x: [-500000, 500000], y: [-100, 100] }, + abatement: { x: [-500000, 500000], y: [-50000, 50000] }, + }; + + function niceRange(vals, forceZero) { + const ext = d3.extent(vals); + const pad = (ext[1] - ext[0]) * 0.05 || Math.abs(ext[0]) * 0.05 || 1000; + const dLow = forceZero ? Math.min(ext[0] - pad, 0) : ext[0] - pad; + const dHigh = forceZero ? Math.max(ext[1] + pad, 0) : ext[1] + pad; + return d3.scaleLinear().domain([dLow, dHigh]).nice().domain(); + } + + const xDomain = niceRange(xs, true); + return { + co2: { x: xDomain, y: niceRange(yCo2, true) }, + abatement: { x: xDomain, y: niceRange(yAb, true) }, + }; + } + + function renderQuadrantChart(container) { + if (!quadrantDomains) return; + + // container is #quadrant-wrap; the SVG lives inside #quadrant-chart + const chartContainer = container.querySelector('.quadrant-chart') || container; + + const allPoints = qComputePoints(state.carbonPrice, state.discountRate, state.fuelScenario); + + // Build filter UI on first call + qBuildFilters(container, allPoints); + + // Apply active filters + const points = allPoints.filter(p => + qFilter.sectors.has(p.sector) && + (qFilter.measures === null || qFilter.measures.has(p.name)) + ); + + const isAbatement = qYMode === 'abatement'; + const domain = isAbatement ? quadrantDomains.abatement : quadrantDomains.co2; + + const M = { top: 32, right: 24, bottom: 56, left: 100 }; + const totalW = chartContainer.clientWidth || 720; + const totalH = 720; + const chartW = Math.max(totalW - M.left - M.right, 200); + const chartH = totalH - M.top - M.bottom; + + const xScale = d3.scaleLinear().domain(domain.x).range([0, chartW]); + // Both modes: high value at top → range [chartH, 0] + const yScale = d3.scaleLinear().domain(domain.y).range([chartH, 0]); + + let svg = d3.select(chartContainer).select('svg'); + if (svg.empty()) { + svg = d3.select(chartContainer).append('svg').attr('role', 'img') + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + + svg.append('rect').attr('class', 'q-plot-bg') + .attr('fill', '#fafbfc').attr('stroke', '#eee').attr('stroke-width', 1); + + svg.append('rect').attr('class', 'q-quad-bg q-quad-bg-tr').attr('fill', 'rgba(0,133,173,0.15)'); + svg.append('rect').attr('class', 'q-quad-bg q-quad-bg-tl').attr('fill', 'rgba(172,205,220,0.15)'); + svg.append('rect').attr('class', 'q-quad-bg q-quad-bg-br').attr('fill', 'rgba(226,164,164,0.15)'); + svg.append('rect').attr('class', 'q-quad-bg q-quad-bg-bl').attr('fill', 'rgba(151,61,76,0.15)'); + + svg.append('line').attr('class', 'q-zero-x') + .attr('stroke', '#aaa').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + svg.append('line').attr('class', 'q-zero-y') + .attr('stroke', '#aaa').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + + svg.append('g').attr('class', 'chart-axis q-x-axis'); + svg.append('g').attr('class', 'chart-axis q-y-axis'); + + svg.append('text').attr('class', 'q-axis-label q-x-label').attr('text-anchor', 'middle'); + svg.append('text').attr('class', 'q-axis-label q-y-label').attr('text-anchor', 'middle'); + + svg.append('text').attr('class', 'q-quad-label q-quad-tr').attr('text-anchor', 'end') + .attr('font-weight', '700').style('fill', Q_DOT_COLORS.tr); + svg.append('text').attr('class', 'q-quad-label q-quad-tl').attr('text-anchor', 'start') + .attr('font-weight', '700').style('fill', Q_DOT_COLORS.tl); + svg.append('text').attr('class', 'q-quad-label q-quad-br').attr('text-anchor', 'end') + .attr('font-weight', '700').style('fill', Q_DOT_COLORS.br); + svg.append('text').attr('class', 'q-quad-label q-quad-bl').attr('text-anchor', 'start') + .attr('font-weight', '700').style('fill', Q_DOT_COLORS.bl); + + svg.append('ellipse').attr('class', 'q-uncertainty') + .style('pointer-events', 'none') + .attr('opacity', 0); + + svg.append('g').attr('class', 'q-points'); + } + + svg.attr('width', totalW).attr('height', totalH); + + const ox = M.left, oy = M.top; + const zx = ox + xScale(0); + const zy = oy + yScale(0); + + svg.select('.q-plot-bg') + .attr('x', ox).attr('y', oy) + .attr('width', chartW).attr('height', chartH); + + // Quadrant background fills — sized by where the zero axes cross + const qzx = Math.max(ox, Math.min(ox + chartW, zx)); // clamped zero x + const qzy = Math.max(oy, Math.min(oy + chartH, zy)); // clamped zero y + svg.select('.q-quad-bg-tr').attr('x', qzx).attr('y', oy).attr('width', ox + chartW - qzx).attr('height', qzy - oy); + svg.select('.q-quad-bg-tl').attr('x', ox).attr('y', oy).attr('width', qzx - ox).attr('height', qzy - oy); + svg.select('.q-quad-bg-br').attr('x', qzx).attr('y', qzy).attr('width', ox + chartW - qzx).attr('height', oy + chartH - qzy); + svg.select('.q-quad-bg-bl').attr('x', ox).attr('y', qzy).attr('width', qzx - ox).attr('height', oy + chartH - qzy); + + svg.select('.q-zero-x') + .attr('x1', ox).attr('x2', ox + chartW) + .attr('y1', zy).attr('y2', zy); + svg.select('.q-zero-y') + .attr('x1', zx).attr('x2', zx) + .attr('y1', oy).attr('y2', oy + chartH); + + svg.select('.q-x-axis') + .attr('transform', `translate(${ox},${oy + chartH})`) + .call(d3.axisBottom(xScale).ticks(6).tickFormat(qFmtAxis)); + svg.select('.q-y-axis') + .attr('transform', `translate(${ox},${oy})`) + .call(d3.axisLeft(yScale).ticks(5).tickFormat(qFmtAxis)); + + svg.select('.q-x-label') + .attr('x', ox + chartW / 2).attr('y', oy + chartH + 42) + .text('Rozdíl NPV oproti základní variantě (Kč)'); + svg.select('.q-y-label') + .attr('transform', `translate(${ox - 64},${oy + chartH / 2}) rotate(-90)`) + .text(isAbatement ? 'Abatement cost (Kč / t CO₂)' : 'Úspora emisí (t CO₂)'); + + // Quadrant labels — depend on Y mode. + // CO₂ mode: top = more CO₂ saved, bottom = more CO₂ emitted + // Abatement mode: top = cheapest abatement, bottom = most expensive (diagonal) + const QPAD = 6; + if (isAbatement) { + svg.select('.q-quad-tr').attr('x', ox + chartW - QPAD).attr('y', oy + 14) + .text('ZTRÁTA A ZVÝŠENÍ EMISÍ'); + svg.select('.q-quad-tl').attr('x', ox + QPAD).attr('y', oy + 14) + .text('DRAHÁ DEKARBONIZACE'); + svg.select('.q-quad-br').attr('x', ox + chartW - QPAD).attr('y', oy + chartH - QPAD) + .text('ÚSPORA I DEKARBONIZACE'); + svg.select('.q-quad-bl').attr('x', ox + QPAD).attr('y', oy + chartH - QPAD) + .text('ÚSPORA, NO ZVÝŠENÍ EMISÍ'); + } else { + svg.select('.q-quad-tr').attr('x', ox + chartW - QPAD).attr('y', oy + 14) + .text('ÚSPORA I DEKARBONIZACE'); + svg.select('.q-quad-tl').attr('x', ox + QPAD).attr('y', oy + 14) + .text('DRAHÁ DEKARBONIZACE'); + svg.select('.q-quad-br').attr('x', ox + chartW - QPAD).attr('y', oy + chartH - QPAD) + .text('ÚSPORA, NO ZVÝŠENÍ EMISÍ'); + svg.select('.q-quad-bl').attr('x', ox + QPAD).attr('y', oy + chartH - QPAD) + .text('ZTRÁTA A ZVÝŠENÍ EMISÍ'); + } + + // Points + const ptSel = svg.select('.q-points').selectAll('circle.q-pt').data(points, d => d.id); + + const yVal = d => isAbatement ? d.kcPerT : d.savedT; + const yLow = d => isAbatement ? d.kcPerTLow : d.savedTLow; + const yHigh = d => isAbatement ? d.kcPerTHigh : d.savedTHigh; + + const ptEnter = ptSel.enter().append('circle').attr('class', 'q-pt') + .attr('r', 6) + .attr('opacity', 0.85) + .attr('stroke', 'white') + .attr('stroke-width', 1.5) + .attr('cx', d => ox + xScale(d.npv)) + .attr('cy', d => oy + yScale(yVal(d))); + + const ptAll = ptSel.merge(ptEnter); + + ptAll + .attr('fill', d => qQuadrantColor(d.npv, yVal(d))) + .style('cursor', 'pointer') + .on('mouseover', function (e, d) { + d3.select(this).attr('r', 8).attr('opacity', 1); + + // Show uncertainty ellipse + const cx = ox + xScale(d.npv); + const cy = oy + yScale(yVal(d)); + const rx = Math.max((xScale(d.npvHigh) - xScale(d.npvLow)) / 2, 4); + const ry = Math.max(Math.abs(yScale(yHigh(d)) - yScale(yLow(d))) / 2, 4); + d3.select(this.closest('svg')).select('.q-uncertainty') + .attr('cx', cx).attr('cy', cy) + .attr('rx', rx).attr('ry', ry) + .attr('fill', '#aaa').attr('fill-opacity', 0.12) + .attr('stroke', '#bbb').attr('stroke-width', 1) + .attr('stroke-dasharray', '4 3') + .attr('opacity', 1); + + const lines = isAbatement ? [ + d.name + (d.category ? ' — ' + d.category : ''), + 'NPV: ' + qFmtCZK(d.npv) + ' [' + qFmtCZK(d.npvLow) + ' — ' + qFmtCZK(d.npvHigh) + ']', + 'Abatement cost: ' + qFmtCZKperT(d.npv, d.savedT) + ' [' + qFmtCZKperT(d.npvLow, d.savedT) + ' — ' + qFmtCZKperT(d.npvHigh, d.savedT) + ']', + ] : [ + d.name + (d.category ? ' — ' + d.category : ''), + 'NPV: ' + qFmtCZK(d.npv) + ' [' + qFmtCZK(d.npvLow) + ' — ' + qFmtCZK(d.npvHigh) + ']', + 'Úspora emisí: ' + qFmtTonnes(d.savedT) + ' [' + qFmtTonnes(d.savedTLow) + ' — ' + qFmtTonnes(d.savedTHigh) + ']', + ]; + showQTip(e, lines.join('\n')); + }) + .on('mousemove', moveQTip) + .on('mouseout', function () { + d3.select(this).attr('r', 6).attr('opacity', 0.85); + d3.select(this.closest('svg')).select('.q-uncertainty').attr('opacity', 0); + hideQTip(); + }) + .transition().duration(Q_ANIM_MS).ease(d3.easeCubicInOut) + .attr('cx', d => ox + xScale(d.npv)) + .attr('cy', d => oy + yScale(yVal(d))); + + ptSel.exit().remove(); + } + + // ── Static comparison chart (60 € vs 200 €, fixed params) ───────────────── + function renderStaticComparisonChart(container) { + if (!quadrantDomains) return; + + const pointsA = qComputePoints(60, 3, 'CP'); // 60 € — low opacity + const pointsB = qComputePoints(200, 3, 'CP'); // 200 € — full opacity + + // Pair by measure id + const pairs = pointsA.map(a => { + const b = pointsB.find(p => p.id === a.id); + return b ? { a, b } : null; + }).filter(Boolean); + + const M = { top: 32, right: 24, bottom: 56, left: 100 }; + const totalW = container.clientWidth || 720; + const totalH = 720; + const chartW = Math.max(totalW - M.left - M.right, 200); + const chartH = totalH - M.top - M.bottom; + const ox = M.left, oy = M.top; + + const xScale = d3.scaleLinear().domain(quadrantDomains.co2.x).range([0, chartW]); + const yScale = d3.scaleLinear().domain(quadrantDomains.co2.y).range([chartH, 0]); + + d3.select(container).selectAll('*').remove(); + + const svg = d3.select(container).append('svg') + .attr('width', totalW).attr('height', totalH) + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + + // Arrowhead marker + svg.append('defs').append('marker') + .attr('id', 'sc-arrow') + .attr('viewBox', '0 -4 8 8') + .attr('refX', 7).attr('refY', 0) + .attr('markerWidth', 5).attr('markerHeight', 5) + .attr('orient', 'auto') + .append('path').attr('d', 'M0,-4L8,0L0,4').attr('fill', '#aaa'); + + // Background + zero lines + svg.append('rect') + .attr('x', ox).attr('y', oy).attr('width', chartW).attr('height', chartH) + .attr('fill', '#fafbfc').attr('stroke', '#eee').attr('stroke-width', 1); + + const zx = ox + xScale(0), zy = oy + yScale(0); + const qzx = Math.max(ox, Math.min(ox + chartW, zx)); + const qzy = Math.max(oy, Math.min(oy + chartH, zy)); + svg.append('rect').attr('x', qzx).attr('y', oy).attr('width', ox + chartW - qzx).attr('height', qzy - oy).attr('fill', 'rgba(0,133,173,0.15)'); + svg.append('rect').attr('x', ox).attr('y', oy).attr('width', qzx - ox).attr('height', qzy - oy).attr('fill', 'rgba(172,205,220,0.15)'); + svg.append('rect').attr('x', qzx).attr('y', qzy).attr('width', ox + chartW - qzx).attr('height', oy + chartH - qzy).attr('fill', 'rgba(226,164,164,0.15)'); + svg.append('rect').attr('x', ox).attr('y', qzy).attr('width', qzx - ox).attr('height', oy + chartH - qzy).attr('fill', 'rgba(151,61,76,0.15)'); + + svg.append('line') + .attr('x1', ox).attr('x2', ox + chartW).attr('y1', zy).attr('y2', zy) + .attr('stroke', '#aaa').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + svg.append('line') + .attr('x1', zx).attr('x2', zx).attr('y1', oy).attr('y2', oy + chartH) + .attr('stroke', '#aaa').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + + // Axes + svg.append('g').attr('class', 'chart-axis') + .attr('transform', `translate(${ox},${oy + chartH})`) + .call(d3.axisBottom(xScale).ticks(6).tickFormat(qFmtAxis)); + svg.append('g').attr('class', 'chart-axis') + .attr('transform', `translate(${ox},${oy})`) + .call(d3.axisLeft(yScale).ticks(5).tickFormat(qFmtAxis)); + + // Axis labels + svg.append('text').attr('class', 'q-axis-label').attr('text-anchor', 'middle') + .attr('x', ox + chartW / 2).attr('y', oy + chartH + 42) + .text('Rozdíl NPV oproti základní variantě (Kč)'); + svg.append('text').attr('class', 'q-axis-label').attr('text-anchor', 'middle') + .attr('transform', `translate(${ox - 64},${oy + chartH / 2}) rotate(-90)`) + .text('Úspora emisí (t CO₂)'); + + // Quadrant labels + const QPAD = 6; + [ + { cls: 'end', x: ox + chartW - QPAD, y: oy + 14, text: 'ÚSPORA I DEKARBONIZACE', color: Q_DOT_COLORS.tr }, + { cls: 'start', x: ox + QPAD, y: oy + 14, text: 'DRAHÁ DEKARBONIZACE', color: Q_DOT_COLORS.tl }, + { cls: 'end', x: ox + chartW - QPAD, y: oy + chartH - QPAD, text: 'ÚSPORA, NO ZVÝŠENÍ EMISÍ', color: Q_DOT_COLORS.br }, + { cls: 'start', x: ox + QPAD, y: oy + chartH - QPAD, text: 'ZTRÁTA A ZVÝŠENÍ EMISÍ', color: Q_DOT_COLORS.bl }, + ].forEach(q => { + svg.append('text').attr('class', 'q-quad-label') + .attr('text-anchor', q.cls).attr('x', q.x).attr('y', q.y) + .attr('font-weight', '700').style('fill', q.color) + .text(q.text); + }); + + // Legend + const legG = svg.append('g').attr('transform', `translate(${ox + 8}, ${oy - 22})`); + [ + { label: '60 € cena uhlíku', opacity: 0.2, dash: true }, + { label: '200 € cena uhlíku', opacity: 0.85, dash: false }, + ].forEach((it, i) => { + const cx = i * 180; + legG.append('circle').attr('cx', cx).attr('cy', 6).attr('r', 5) + .attr('fill', '#888').attr('opacity', it.opacity) + .attr('stroke', 'white').attr('stroke-width', 1.5); + legG.append('text').attr('class', 'q-legend-text') + .attr('x', cx + 13).attr('y', 10) + .attr('font-size', '11px').attr('fill', '#555') + .text(it.label); + }); + + // Arrows (drawn before dots so dots sit on top) + const DOT_R = 6; + pairs.forEach(({ a, b }) => { + const ax = ox + xScale(a.npv), ay = oy + yScale(a.savedT); + const bx = ox + xScale(b.npv), by = oy + yScale(b.savedT); + const dx = bx - ax, dy = by - ay; + const len = Math.sqrt(dx * dx + dy * dy); + if (len < DOT_R * 2 + 2) return; // too short to draw + const nx = dx / len, ny = dy / len; + svg.append('line') + .attr('x1', ax + nx * (DOT_R + 1)).attr('y1', ay + ny * (DOT_R + 1)) + .attr('x2', bx - nx * (DOT_R + 3)).attr('y2', by - ny * (DOT_R + 3)) + .attr('stroke', '#ccc').attr('stroke-width', 1.5) + .attr('marker-end', 'url(#sc-arrow)'); + }); + + // Set A dots — 20 % opacity + pairs.forEach(({ a }) => { + svg.append('circle') + .attr('cx', ox + xScale(a.npv)).attr('cy', oy + yScale(a.savedT)) + .attr('r', DOT_R) + .attr('fill', qQuadrantColor(a.npv, a.savedT)) + .attr('opacity', 0.2) + .attr('stroke', 'white').attr('stroke-width', 1.5); + }); + + // Set B dots — full opacity + pairs.forEach(({ b }) => { + svg.append('circle') + .attr('cx', ox + xScale(b.npv)).attr('cy', oy + yScale(b.savedT)) + .attr('r', DOT_R) + .attr('fill', qQuadrantColor(b.npv, b.savedT)) + .attr('opacity', 0.85) + .attr('stroke', 'white').attr('stroke-width', 1.5); + }); + } + + // ── Beeswarm chart ──────────────────────────────────────────────────────── + function renderBeeswarmChart(container, sharedAbsMax) { + const points = qComputePoints(state.carbonPrice, state.discountRate, state.fuelScenario) + .filter(p => p.savedT > 0 && isFinite(p.kcPerT)); + + const M = { top: 20, right: 24, bottom: 48, left: 24 }; + const totalW = container.clientWidth || 720; + const totalH = 200; + const chartW = totalW - M.left - M.right; + const chartH = totalH - M.top - M.bottom; + const midY = M.top + chartH / 2; + + const ext = d3.extent(points, p => p.kcPerT); + const localAbsMax = Math.max(Math.abs(ext[0]), Math.abs(ext[1])) * 1.1; + const xScale = d3.scaleLinear().domain([-(sharedAbsMax || localAbsMax), sharedAbsMax || localAbsMax]).range([0, chartW]).nice(); + + d3.select(container).selectAll('*').remove(); + const svg = d3.select(container).append('svg') + .attr('width', totalW).attr('height', totalH) + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + + // Zero line + const zx = M.left + xScale(0); + svg.append('line') + .attr('x1', zx).attr('x2', zx).attr('y1', M.top).attr('y2', M.top + chartH) + .attr('stroke', '#bbb').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + + // Half labels + svg.append('text') + .attr('x', zx - 8).attr('y', M.top + 14) + .attr('text-anchor', 'end').attr('font-size', '11px').attr('fill', '#1a72b8').attr('opacity', 0.8) + .text('Opatření je výhodnější než fosilní alternativa'); + svg.append('text') + .attr('x', zx + 8).attr('y', M.top + 14) + .attr('text-anchor', 'start').attr('font-size', '11px').attr('fill', '#8b35b0').attr('opacity', 0.8) + .text('Opatření je dražší než fosilní alternativa'); + + // X axis + svg.append('g').attr('class', 'chart-axis') + .attr('transform', `translate(${M.left},${M.top + chartH})`) + .call(d3.axisBottom(xScale).ticks(6).tickFormat(v => { + const abs = Math.abs(v); + return abs >= 1e6 ? (v / 1e6).toFixed(1) + ' M' : abs >= 1e3 ? (v / 1e3).toFixed(0) + ' tis.' : v.toString(); + })); + + svg.append('text').attr('text-anchor', 'middle') + .attr('x', M.left + chartW / 2).attr('y', totalH - 2) + .attr('font-size', '11px').attr('fill', '#666') + .text('Kč / t CO₂'); + + // Beeswarm via force simulation + const DOT_R = 5; + const simNodes = points.map(p => ({ ...p, x: M.left + xScale(p.kcPerT), y: midY })); + d3.forceSimulation(simNodes) + .force('x', d3.forceX(d => M.left + xScale(d.kcPerT)).strength(1)) + .force('y', d3.forceY(midY).strength(0.1)) + .force('collide', d3.forceCollide(DOT_R + 1.5)) + .stop() + .tick(120); + + // Clamp dots within chart area + simNodes.forEach(n => { + n.y = Math.max(M.top + DOT_R, Math.min(M.top + chartH - DOT_R, n.y)); + }); + + const dotG = svg.append('g'); + dotG.selectAll('circle').data(simNodes).enter().append('circle') + .attr('r', DOT_R) + .attr('cx', d => d.x) + .attr('cy', d => d.y) + .attr('fill', d => d.kcPerT < 0 ? '#1a72b8' : '#8b35b0') + .attr('opacity', 0.85) + .attr('stroke', 'white').attr('stroke-width', 1) + .style('cursor', 'pointer') + .on('mouseover', function(e, d) { + d3.select(this).attr('r', DOT_R + 2).attr('opacity', 1); + showQTip(e, [ + d.name + (d.category ? ' — ' + d.category : ''), + 'Náklady: ' + qFmtCZKperT(-d.npv, d.savedT), + ].join('\n')); + }) + .on('mousemove', moveQTip) + .on('mouseout', function() { + d3.select(this).attr('r', DOT_R).attr('opacity', 0.85); + hideQTip(); + }); + } + + // ── CAPEX beeswarm chart ────────────────────────────────────────────────── + function renderCapexBeeswarmChart(container, sharedAbsMax) { + const points = qComputePoints(state.carbonPrice, state.discountRate, state.fuelScenario) + .filter(p => p.savedT > 0 && p.capexPerT != null && isFinite(p.capexPerT)); + + const M = { top: 20, right: 24, bottom: 48, left: 24 }; + const totalW = container.clientWidth || 720; + const totalH = 200; + const chartW = totalW - M.left - M.right; + const chartH = totalH - M.top - M.bottom; + const midY = M.top + chartH / 2; + + const ext = d3.extent(points, p => p.capexPerT); + const localAbsMax = Math.max(Math.abs(ext[0]), Math.abs(ext[1])) * 1.1; + const xScale = d3.scaleLinear().domain([-(sharedAbsMax || localAbsMax), sharedAbsMax || localAbsMax]).range([0, chartW]).nice(); + + d3.select(container).selectAll('*').remove(); + const svg = d3.select(container).append('svg') + .attr('width', totalW).attr('height', totalH) + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + + const zx = M.left + xScale(0); + svg.append('line') + .attr('x1', zx).attr('x2', zx).attr('y1', M.top).attr('y2', M.top + chartH) + .attr('stroke', '#bbb').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + + svg.append('text') + .attr('x', zx - 8).attr('y', M.top + 14) + .attr('text-anchor', 'end').attr('font-size', '11px').attr('fill', '#1a72b8').attr('opacity', 0.8) + .text('Opatření vyžaduje nižší investici než fosilní alternativa'); + svg.append('text') + .attr('x', zx + 8).attr('y', M.top + 14) + .attr('text-anchor', 'start').attr('font-size', '11px').attr('fill', '#8b35b0').attr('opacity', 0.8) + .text('Opatření vyžaduje vyšší investici než fosilní alternativa'); + + svg.append('g').attr('class', 'chart-axis') + .attr('transform', `translate(${M.left},${M.top + chartH})`) + .call(d3.axisBottom(xScale).ticks(6).tickFormat(v => { + const abs = Math.abs(v); + return abs >= 1e6 ? (v / 1e6).toFixed(1) + ' M' : abs >= 1e3 ? (v / 1e3).toFixed(0) + ' tis.' : v.toString(); + })); + + svg.append('text').attr('text-anchor', 'middle') + .attr('x', M.left + chartW / 2).attr('y', totalH - 2) + .attr('font-size', '11px').attr('fill', '#666') + .text('Kč / t CO₂ (rozdíl v investičních nákladech)'); + + const DOT_R = 5; + const simNodes = points.map(p => ({ ...p, x: M.left + xScale(p.capexPerT), y: midY })); + d3.forceSimulation(simNodes) + .force('x', d3.forceX(d => M.left + xScale(d.capexPerT)).strength(1)) + .force('y', d3.forceY(midY).strength(0.1)) + .force('collide', d3.forceCollide(DOT_R + 1.5)) + .stop() + .tick(120); + + simNodes.forEach(n => { + n.y = Math.max(M.top + DOT_R, Math.min(M.top + chartH - DOT_R, n.y)); + }); + + svg.append('g').selectAll('circle').data(simNodes).enter().append('circle') + .attr('r', DOT_R) + .attr('cx', d => d.x) + .attr('cy', d => d.y) + .attr('fill', d => d.capexPerT < 0 ? '#1a72b8' : '#8b35b0') + .attr('opacity', 0.85) + .attr('stroke', 'white').attr('stroke-width', 1) + .style('cursor', 'pointer') + .on('mouseover', function(e, d) { + d3.select(this).attr('r', DOT_R + 2).attr('opacity', 1); + const fmt = v => { + const abs = Math.abs(v); + const s = abs >= 1e6 ? (v / 1e6).toFixed(2) + ' M' : abs >= 1e3 ? (v / 1e3).toFixed(1) + ' tis.' : Math.round(v).toString(); + return s + ' Kč/t CO₂'; + }; + showQTip(e, [ + d.name + (d.category ? ' — ' + d.category : ''), + 'Rozdíl v investicích: ' + fmt(d.capexPerT), + ].join('\n')); + }) + .on('mousemove', moveQTip) + .on('mouseout', function() { + d3.select(this).attr('r', DOT_R).attr('opacity', 0.85); + hideQTip(); + }); + } + + // ── NPV scenario comparison (shared helpers) ────────────────────────────── + const SCENARIO_DEFS = [ + { key: 'CP', label: 'Současné politiky', color: '#2860b4' }, + { key: 'NZ', label: 'Net-zero', color: '#1f8c47' }, + { key: 'CP_EC', label: 'Energetická krize', color: '#c43535' }, + ]; + + // categoryFilter: array of category strings, or null for all + function computeScenarioRows(categoryFilter) { + const byId = {}; + for (const sc of SCENARIO_DEFS) { + const pts = qComputePoints(state.carbonPrice, state.discountRate, sc.key) + .filter(p => !categoryFilter || categoryFilter.includes(p.category)); + for (const p of pts) { + if (!byId[p.id]) byId[p.id] = { id: p.id, name: p.name, category: p.category, sector: p.sector }; + if (isFinite(p.npv)) byId[p.id][sc.key] = p.npv; + } + } + return Object.values(byId) + .filter(d => SCENARIO_DEFS.every(sc => d[sc.key] != null)) + .sort((a, b) => a.CP - b.CP); + } + + // ── Dumbbell chart ───────────────────────────────────────────────────────── + // categoryFilter: array of category strings, or null for all + // sharedDomain: [min, max] passed from renderAll for a harmonised x axis + function renderDumbbellChart(container, categoryFilter, sharedDomain) { + const rows = computeScenarioRows(categoryFilter); + if (!rows.length) return; + + const DOT_R = 4; + const ROW_H = 22; + const M = { top: 6, right: 16, bottom: 36, left: 160 }; + const totalW = container.clientWidth || 360; + const chartW = totalW - M.left - M.right; + const totalH = M.top + rows.length * ROW_H + M.bottom; + + const domain = sharedDomain || (() => { + const vals = rows.flatMap(d => SCENARIO_DEFS.map(sc => d[sc.key])); + const [mn, mx] = d3.extent(vals); + const p = (mx - mn) * 0.04 || Math.abs(mn || mx) * 0.1 || 10000; + return [mn - p, mx + p]; + })(); + const xScale = d3.scaleLinear().domain(domain).nice().range([0, chartW]); + + const fmtTick = v => { + const abs = Math.abs(v); + return abs >= 1e6 ? (v / 1e6).toFixed(1) + ' M' : abs >= 1e3 ? (v / 1e3).toFixed(0) + ' tis.' : v.toString(); + }; + + d3.select(container).selectAll('*').remove(); + const svg = d3.select(container).append('svg') + .attr('width', totalW).attr('height', totalH) + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + + // X axis + svg.append('g').attr('class', 'chart-axis') + .attr('transform', `translate(${M.left},${M.top + rows.length * ROW_H})`) + .call(d3.axisBottom(xScale).ticks(5).tickFormat(fmtTick)); + svg.append('text').attr('text-anchor', 'middle') + .attr('x', M.left + chartW / 2).attr('y', totalH - 4) + .attr('font-size', '10px').attr('fill', '#888').text('NPV (Kč)'); + + // Zero line + const zx = M.left + xScale(0); + svg.append('line') + .attr('x1', zx).attr('x2', zx) + .attr('y1', M.top).attr('y2', M.top + rows.length * ROW_H) + .attr('stroke', '#ccc').attr('stroke-width', 1).attr('stroke-dasharray', '3 3'); + + rows.forEach((d, i) => { + const cy = M.top + i * ROW_H + ROW_H / 2; + + svg.append('text').attr('x', M.left - 6).attr('y', cy + 4) + .attr('text-anchor', 'end').attr('font-size', '10px').attr('fill', '#333') + .text(d.name); + + const npvs = SCENARIO_DEFS.map(sc => d[sc.key]); + svg.append('line') + .attr('x1', M.left + xScale(Math.min(...npvs))) + .attr('x2', M.left + xScale(Math.max(...npvs))) + .attr('y1', cy).attr('y2', cy) + .attr('stroke', '#ddd').attr('stroke-width', 1.5); + + // Give reduced opacity to dots that land on exactly the same pixel + const dotPts = SCENARIO_DEFS.map(sc => ({ sc, px: Math.round(M.left + xScale(d[sc.key])) })); + const opacityMap = Object.fromEntries(dotPts.map(p => [p.sc.key, 1])); + dotPts.forEach((a, ai) => dotPts.forEach((b, bi) => { + if (bi > ai && a.px === b.px) { opacityMap[a.sc.key] = 0.4; opacityMap[b.sc.key] = 0.4; } + })); + + // CP_EC and NZ first, CP last so blue stays on top + [...SCENARIO_DEFS].reverse().forEach(sc => { + svg.append('circle') + .attr('cx', M.left + xScale(d[sc.key])).attr('cy', cy) + .attr('r', DOT_R) + .attr('fill', sc.color).attr('stroke', 'white').attr('stroke-width', 1.2) + .attr('opacity', opacityMap[sc.key]) + .style('cursor', 'pointer') + .on('mouseover', function(e) { + d3.select(this).attr('r', DOT_R + 2); + showQTip(e, [ + d.name + (d.category ? ' — ' + d.category : ''), + sc.label + ': ' + fmtCZK(d[sc.key]), + ].join('\n')); + }) + .on('mousemove', moveQTip) + .on('mouseout', function() { d3.select(this).attr('r', DOT_R); hideQTip(); }); + }); + }); + } + + function renderDumbbellLegend(container) { + d3.select(container).select('svg').remove(); + const totalW = container.clientWidth || 400; + const svg = d3.select(container).append('svg').attr('width', totalW).attr('height', 20) + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + SCENARIO_DEFS.forEach((sc, i) => { + const lx = i * 160; + svg.append('circle').attr('cx', lx + 5).attr('cy', 10).attr('r', 4.5).attr('fill', sc.color); + svg.append('text').attr('x', lx + 14).attr('y', 14) + .attr('font-size', '11px').attr('fill', '#555').text(sc.label); + }); + } + + const DUMBBELL_CONFIGS = [ + { id: 'dumbbell-rd-uhli-e', categories: ['Rodinný dům uhlí – E'] }, + { id: 'dumbbell-rd-plyn-e', categories: ['Rodinný dům plyn – E'] }, + { id: 'dumbbell-nove-male', categories: ['Nové malé'] }, + { id: 'dumbbell-nove-velke', categories: ['Nové velké'] }, + { id: 'dumbbell-ojete-male', categories: ['Ojeté malé'] }, + { id: 'dumbbell-ojete-velke', categories: ['Ojeté velké'] }, + ]; + + // ── MAC curve ───────────────────────────────────────────────────────────── + + // All building categories in display order + const MAC_BUILDING_CATS = [ + 'Rodinný dům uhlí – E', + 'Rodinný dům uhlí – C', + 'Rodinný dům plyn – E', + 'Rodinný dům plyn – C', + 'Byt ve starší zástavbě s vlastním plynovým kotlem', + 'Byt v panelovém domě s plynovou kotelnou', + ]; + const MAC_CAT_COLORS = { + 'Rodinný dům uhlí – E': '#7b4f2e', + 'Rodinný dům uhlí – C': '#b07a50', + 'Rodinný dům plyn – E': '#c45e00', + 'Rodinný dům plyn – C': '#e08c3a', + 'Byt ve starší zástavbě s vlastním plynovým kotlem':'#2e7d5b', + 'Byt v panelovém domě s plynovou kotelnou': '#1a7a85', + }; + + // Filter state — selected building category (single) + const macFilter = { category: MAC_BUILDING_CATS[0] }; + + function macBuildFilters(wrap) { + if (wrap.querySelector('.mac-filters')) return; + + const filtersDiv = document.createElement('div'); + filtersDiv.className = 'mac-filters q-filters'; + + const row = document.createElement('div'); + row.className = 'q-filter-row'; + const lbl = document.createElement('label'); + lbl.className = 'q-filter-label'; + lbl.textContent = 'Typ budovy:'; + lbl.setAttribute('for', 'mac-cat-select'); + row.appendChild(lbl); + + const sel = document.createElement('select'); + sel.id = 'mac-cat-select'; + sel.className = 'form-select form-select-sm'; + sel.style.cssText = 'width:auto; min-width:200px;'; + MAC_BUILDING_CATS.forEach(cat => { + const opt = document.createElement('option'); + opt.value = cat; + opt.textContent = cat; + if (cat === macFilter.category) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener('change', () => { + macFilter.category = sel.value; + macRenderSVG(wrap); + }); + row.appendChild(sel); + + filtersDiv.appendChild(row); + wrap.insertBefore(filtersDiv, wrap.firstChild); + } + + function macRenderSVG(container) { + const allPoints = qComputePoints(state.carbonPrice, state.discountRate, state.fuelScenario) + .filter(p => + p.sector === 'buildings' && + p.category === macFilter.category && + isFinite(p.kcPerT) && isFinite(p.savedT) && p.savedT > 0 + ); + + allPoints.sort((a, b) => a.kcPerT - b.kcPerT); + + const M = { top: 32, right: 24, bottom: 80, left: 90 }; + const totalW = container.clientWidth || 720; + const totalH = 420; + const chartW = Math.max(totalW - M.left - M.right, 200); + const chartH = totalH - M.top - M.bottom; + const ox = M.left, oy = M.top; + + let cumX = 0; + const bars = allPoints.map(p => { + const x0 = cumX; + cumX += p.savedT; + return { ...p, x0, x1: cumX }; + }); + const totalSavedT = cumX; + + const xScale = d3.scaleLinear().domain([0, totalSavedT]).range([0, chartW]); + const yMin = Math.min(0, ...bars.map(b => b.kcPerTLow)); + const yMax = Math.max(0, ...bars.map(b => b.kcPerTHigh)); + const yPad = (yMax - yMin) * 0.12 || 1000; + const yScale = d3.scaleLinear() + .domain([yMin - yPad, yMax + yPad]) + .range([chartH, 0]); + + // Remove old SVG only + d3.select(container).select('svg').remove(); + + const svg = d3.select(container).append('svg') + .attr('width', totalW).attr('height', totalH) + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + + svg.append('rect') + .attr('x', ox).attr('y', oy).attr('width', chartW).attr('height', chartH) + .attr('fill', '#fafbfc').attr('stroke', '#eee').attr('stroke-width', 1); + + const zy = oy + yScale(0); + svg.append('line') + .attr('x1', ox).attr('x2', ox + chartW).attr('y1', zy).attr('y2', zy) + .attr('stroke', '#888').attr('stroke-width', 1); + + svg.append('g').attr('class', 'chart-axis') + .attr('transform', `translate(${ox},${oy + chartH})`) + .call(d3.axisBottom(xScale).ticks(6).tickFormat(v => { + if (Math.abs(v) >= 1000) return (v / 1000).toFixed(0) + ' kt'; + return v.toFixed(0) + ' t'; + })); + svg.append('g').attr('class', 'chart-axis') + .attr('transform', `translate(${ox},${oy})`) + .call(d3.axisLeft(yScale).ticks(6).tickFormat(v => { + const abs = Math.abs(v); + if (abs >= 1e6) return (v / 1e6).toFixed(1) + ' M'; + if (abs >= 1e3) return (v / 1e3).toFixed(0) + ' tis.'; + return v.toFixed(0); + })); + + svg.append('text').attr('class', 'q-axis-label').attr('text-anchor', 'middle') + .attr('x', ox + chartW / 2).attr('y', oy + chartH + 52) + .text('Kumulativní úspora emisí (t CO₂)'); + svg.append('text').attr('class', 'q-axis-label').attr('text-anchor', 'middle') + .attr('transform', `translate(${ox - 64},${oy + chartH / 2}) rotate(-90)`) + .text('Abatement cost (Kč / t CO₂)'); + + // Shared tooltip element + let macTip = document.getElementById('mac-tip'); + if (!macTip) { + macTip = document.createElement('div'); + macTip.id = 'mac-tip'; + Object.assign(macTip.style, { + position: 'fixed', pointerEvents: 'none', background: 'rgba(30,30,30,0.88)', + color: '#fff', fontSize: '12px', lineHeight: '1.5', padding: '6px 10px', + borderRadius: '4px', whiteSpace: 'pre', display: 'none', zIndex: 9999, + }); + document.body.appendChild(macTip); + } + + const fmtKcT = v => { + const abs = Math.abs(v), sign = v < 0 ? '− ' : '+ '; + if (abs >= 1e6) return sign + (abs / 1e6).toFixed(2) + ' M Kč/t'; + if (abs >= 1e3) return sign + (abs / 1e3).toFixed(0) + ' tis. Kč/t'; + return sign + Math.round(abs) + ' Kč/t'; + }; + const fmtT = v => Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + ' kt' : Math.round(v) + ' t'; + + const barsG = svg.append('g'); + bars.forEach(b => { + const color = MAC_CAT_COLORS[b.category] || Q_COLOR_BUILDINGS; + const bx = ox + xScale(b.x0); + const bw = Math.max(xScale(b.x1) - xScale(b.x0) - 1, 1); + + // Uncertainty whisker + const wx = bx + bw / 2; + const wTop = oy + yScale(b.kcPerTHigh); + const wBottom = oy + yScale(b.kcPerTLow); + barsG.append('line') + .attr('x1', wx).attr('x2', wx).attr('y1', wTop).attr('y2', wBottom) + .attr('stroke', color).attr('stroke-width', 1.5).attr('opacity', 0.35); + [[wTop], [wBottom]].forEach(([wy]) => { + barsG.append('line') + .attr('x1', wx - 3).attr('x2', wx + 3).attr('y1', wy).attr('y2', wy) + .attr('stroke', color).attr('stroke-width', 1.5).attr('opacity', 0.35); + }); + + // Bar + const barTop = oy + yScale(Math.max(b.kcPerT, 0)); + const barH = Math.max(Math.abs(oy + yScale(Math.min(b.kcPerT, 0)) - barTop), 1); + barsG.append('rect') + .attr('x', bx).attr('y', barTop).attr('width', bw).attr('height', barH) + .attr('fill', color).attr('opacity', 0.75) + .attr('stroke', color).attr('stroke-width', 0.5) + .style('cursor', 'pointer') + .on('mouseover', function (e) { + d3.select(this).attr('opacity', 1); + macTip.textContent = [ + b.name + ' — ' + b.category, + 'Abatement cost: ' + fmtKcT(b.kcPerT) + ' [' + fmtKcT(b.kcPerTLow) + ' — ' + fmtKcT(b.kcPerTHigh) + ']', + 'Úspora CO₂: ' + fmtT(b.savedT), + ].join('\n'); + macTip.style.display = 'block'; + macTip.style.left = (e.clientX + 14) + 'px'; + macTip.style.top = (e.clientY - 28) + 'px'; + }) + .on('mousemove', e => { + macTip.style.left = (e.clientX + 14) + 'px'; + macTip.style.top = (e.clientY - 28) + 'px'; + }) + .on('mouseout', function () { + d3.select(this).attr('opacity', 0.75); + macTip.style.display = 'none'; + }); + + // Rotated label — only if bar wide enough + if (bw > 20) { + const shortName = b.name.length > 22 ? b.name.slice(0, 20) + '…' : b.name; + const labelY = b.kcPerT >= 0 ? barTop - 4 : barTop + barH + 4; + const anchor = b.kcPerT >= 0 ? 'end' : 'start'; + barsG.append('text') + .attr('transform', `translate(${bx + bw / 2},${labelY}) rotate(-60)`) + .attr('text-anchor', anchor) + .attr('font-size', '9px').attr('fill', '#666') + .style('pointer-events', 'none') + .text(shortName); + } + }); + } + + function renderMACChart(container) { + macBuildFilters(container); + macRenderSVG(container); + } + + // ── Render all charts on the page ───────────────────────────────────────── + // Collect all NPV values that will appear in a given chart container. + function collectNpvsForEl(el) { + const param = el.dataset.param || 'Cena uhlíku'; + const exclude = el.dataset.exclude ? el.dataset.exclude.split(',').map(s => s.trim()) : []; + const cats = el.dataset.categories + ? el.dataset.categories.split('|') + : (el.dataset.category ? [el.dataset.category] : []); + const isDR = param === 'Diskontní míra'; + const isElTariff = param === 'Tarif elektřiny'; + const vals = []; + + for (const category of cats) { + const measures = [ + ...(data.buildings_measures || []), + ...(data.transport_measures || []), + ].filter(m => + (m.measure_baseline_id || m.measure_baseline) && + CP_CHART_MEASURES.includes(m.measure_name) && + !exclude.includes(m.measure_name) && + (!category || m.building_category === category || m.transport_category === category) + ); + + for (const name of CP_CHART_MEASURES) { + if (exclude.includes(name)) continue; + const entries = measures.filter(m => m.measure_name === name); + if (!entries.length) continue; + const entry = entries.find(m => { + try { + const r = CostsBenefits.calculate({ + measureId: m.id, data, + discountRate: 0.03, carbonPriceEur: 60, + priceScenario: state.fuelScenario, + electricityPriceFactor: state.electricityPriceFactor, + }); + return !isNaN(r.npv); + } catch (_) { return false; } + }); + if (!entry) continue; + + const cps = (isDR || isElTariff) ? [state.carbonPrice] : [0, state.carbonPrice, 200]; + const drs = isDR ? [0, 3, 7] : [state.discountRate]; + const epFactors = isElTariff + ? (data.electricity_price_scenarios || []).map(s => s.electricity_price_factor) + : [state.electricityPriceFactor]; + for (const cp of cps) { + for (const dr of drs) { + for (const epF of epFactors) { + try { + const r = CostsBenefits.calculate({ + measureId: entry.id, data, + discountRate: dr / 100, carbonPriceEur: cp, + priceScenario: state.fuelScenario, + electricityPriceFactor: epF, + }); + if (!isNaN(r.npv)) vals.push(r.npv); + } catch (_) {} + } + } + } + } + } + return vals; + } + + // ── Chart export (SVG / PNG download) ──────────────────────────────────────── + + const CHART_EXPORT_CSS = [ + 'text { font-family: Roboto, system-ui, sans-serif; }', + '.chart-axis path { stroke: none; }', + '.chart-axis line { stroke: #ddd; }', + '.chart-axis text { font-size: 10px; fill: #888; font-family: Roboto, system-ui, sans-serif; }', + '.q-quad-label { font-size: 10px; fill: #bbb; font-style: italic; }', + '.q-axis-label { font-size: 12px; fill: #666; font-weight: 500; }', + '.chart-col-header { font-size: 10px; fill: #999; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }', + ].join('\n'); + + function prepareExportSVG(svgEl) { + const clone = svgEl.cloneNode(true); + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + if (!clone.getAttribute('viewBox')) { + const w = clone.getAttribute('width') || svgEl.getBoundingClientRect().width; + const h = clone.getAttribute('height') || svgEl.getBoundingClientRect().height; + clone.setAttribute('viewBox', `0 0 ${w} ${h}`); + } + const style = document.createElementNS('http://www.w3.org/2000/svg', 'style'); + style.textContent = CHART_EXPORT_CSS; + clone.insertBefore(style, clone.firstChild); + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('width', '100%'); + bg.setAttribute('height', '100%'); + bg.setAttribute('fill', 'white'); + clone.insertBefore(bg, style.nextSibling); + return clone; + } + + function triggerDownload(url, filename) { + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + function exportSVG(svgEl, filename) { + const clone = prepareExportSVG(svgEl); + const blob = new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml' }); + triggerDownload(URL.createObjectURL(blob), filename + '.svg'); + } + + function exportPNG(svgEl, filename) { + const clone = prepareExportSVG(svgEl); + const svgStr = new XMLSerializer().serializeToString(clone); + const url = URL.createObjectURL(new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' })); + const w = +svgEl.getAttribute('width') || svgEl.getBoundingClientRect().width; + const h = +svgEl.getAttribute('height') || svgEl.getBoundingClientRect().height; + const scale = 2; + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = w * scale; canvas.height = h * scale; + const ctx = canvas.getContext('2d'); + ctx.scale(scale, scale); + ctx.fillStyle = 'white'; ctx.fillRect(0, 0, w, h); + ctx.drawImage(img, 0, 0, w, h); + URL.revokeObjectURL(url); + canvas.toBlob(blob => triggerDownload(URL.createObjectURL(blob), filename + '.png'), 'image/png'); + }; + img.onerror = () => URL.revokeObjectURL(url); + img.src = url; + } + + function fokDownloadBar(container, filename) { + const old = container.querySelector('.chart-dl-bar'); + if (old) old.remove(); + if (!container.querySelector('svg')) return; + const bar = document.createElement('div'); + bar.className = 'chart-dl-bar'; + ['SVG', 'PNG'].forEach(fmt => { + const btn = document.createElement('button'); + btn.className = 'chart-dl-btn'; + btn.textContent = '↓ ' + fmt; + btn.addEventListener('click', e => { + e.stopPropagation(); + const svg = container.querySelector('svg'); + if (svg) (fmt === 'SVG' ? exportSVG : exportPNG)(svg, filename); + }); + bar.appendChild(btn); + }); + container.appendChild(bar); + } + + function addDownloadBars() { + document.querySelectorAll('.tornado-chart').forEach(el => { + const cat = el.dataset.category || (el.dataset.categories || '').split('|')[0]; + const param = el.dataset.param || 'Cena uhlíku'; + fokDownloadBar(el, 'tornado-' + [cat, param].filter(Boolean).join('-')); + }); + [ + ['quadrant-chart', 'quadrant'], + ['static-comparison-chart', 'quadrant-porovnani'], + ['mac-chart', 'mac-curve'], + ['beeswarm-chart', 'beeswarm-npv'], + ['beeswarm-capex-chart', 'beeswarm-capex'], + ...DUMBBELL_CONFIGS.map(c => [c.id, c.id]), + ].forEach(([id, name]) => { + const el = document.getElementById(id); + if (el) fokDownloadBar(el, name); + }); + } + + // ── Sensitivity beeswarm ───────────────────────────────────────────────── + let sbSelectedMeasure = null; + let sbMeasureGroups = null; + let sbGrouped = 'none'; + let sbShowLetters = false; + let sbShowUncertainty = false; + let sbColorBy = null; // null | 'sc' | 'cp' | 'dr' + let sbEnabledScenarios = new Set(['CP', 'NZ', 'CP_EC']); + let sbEnabledDiscountRates = new Set([0, 3, 7]); + let sbEnabledCarbonPrices = new Set([0, 60, 100, 200]); + let sbEnabledBaselines = null; // Set of measure_baseline names; null = all enabled + + function sbFindEntry(measureName, category) { + return [...(data.buildings_measures || []), ...(data.transport_measures || [])].find(m => + m.measure_name === measureName && + (m.building_category === category || m.transport_category === category) + ); + } + + function sbCalcNpv(entry, scenario, cp, dr) { + try { + const r = CostsBenefits.calculate({ + measureId: entry.id, data, + discountRate: dr / 100, + carbonPriceEur: cp, + priceScenario: scenario, + electricityPriceFactor: 1.0, + }); + return isNaN(r.npv) ? null : r.npv; + } catch (_) { return null; } + } + + function sbCalcNpvFull(entry, scenario, cp, dr) { + try { + const r = CostsBenefits.calculate({ + measureId: entry.id, data, + discountRate: dr / 100, + carbonPriceEur: cp, + priceScenario: scenario, + electricityPriceFactor: 1.0, + }); + if (isNaN(r.npv)) return null; + const sens = r.sensitivity || []; + const npvLow = sens.length ? Math.min(...sens.map(s => s.minNpv)) : r.npv; + const npvHigh = sens.length ? Math.max(...sens.map(s => s.maxNpv)) : r.npv; + return { npv: r.npv, npvLow, npvHigh }; + } catch (_) { return null; } + } + + function sbBuildMeasureGroups() { + // Derive cats from the actual data so we don't miss contexts (e.g. apartment buildings) + // that exist in the data but aren't in CP_CHART_MEASURES. + const allB = data.buildings_measures || []; + const allT = data.transport_measures || []; + + // All unique measure names, ordered by CP_CHART_MEASURES then any extras + const bNamesAll = [...new Set(allB.map(m => m.measure_name))]; + const tNamesAll = [...new Set(allT.map(m => m.measure_name))]; + const bNames = [...CP_CHART_MEASURES.filter(n => bNamesAll.includes(n)), ...bNamesAll.filter(n => !CP_CHART_MEASURES.includes(n))]; + const tNames = [...CP_CHART_MEASURES.filter(n => tNamesAll.includes(n)), ...tNamesAll.filter(n => !CP_CHART_MEASURES.includes(n))]; + + function catsFor(names, entries, allowedCats) { + return names + .map(name => ({ + name, + cats: allowedCats.filter(cat => + entries.some(m => m.measure_name === name && + (m.building_category === cat || m.transport_category === cat)) + ), + })) + .filter(g => g.cats.length > 0); + } + + const lcB = allB.filter(m => m.measure_baseline_id); + const lcT = allT.filter(m => m.measure_baseline_id); + const buildingBaselines = [...new Set(lcB.map(m => m.measure_baseline).filter(Boolean))]; + const transportBaselines = [...new Set(lcT.map(m => m.measure_baseline).filter(Boolean))]; + + return { + buildings: catsFor(bNames, allB, SB_BUILDING_CATS), + transport: catsFor(tNames, allT, SB_TRANSPORT_CATS), + buildingBaselines, + transportBaselines, + }; + } + + function sbBuildFilters(wrap) { + if (wrap.querySelector('.sb-filters')) return; + const filtersDiv = document.createElement('div'); + filtersDiv.className = 'sb-filters q-filters'; + + function makeRow(labelText, groups) { + const row = document.createElement('div'); + row.className = 'q-filter-row'; + const lbl = document.createElement('span'); + lbl.className = 'q-filter-label'; + lbl.textContent = labelText; + row.appendChild(lbl); + groups.forEach(g => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-measure-btn' + (sbSelectedMeasure === g.name ? ' active' : ''); + btn.dataset.measure = g.name; + btn.textContent = g.name; + btn.addEventListener('click', () => { + sbSelectedMeasure = g.name; + wrap.querySelectorAll('.sb-measure-btn').forEach(b => + b.classList.toggle('active', b.dataset.measure === g.name) + ); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + row.appendChild(btn); + }); + return row; + } + + const lcBuildings = sbMeasureGroups.buildings.filter(g => CP_CHART_MEASURES.includes(g.name)); + const lcTransport = sbMeasureGroups.transport.filter(g => CP_CHART_MEASURES.includes(g.name)); + filtersDiv.appendChild(makeRow('Nízkoemisní budovy:', lcBuildings)); + + // Fossil baseline pills for buildings + if (sbMeasureGroups.buildingBaselines.length) { + const bblRow = document.createElement('div'); + bblRow.className = 'q-filter-row'; + const bblLbl = document.createElement('span'); + bblLbl.className = 'q-filter-label'; + bblLbl.textContent = 'Fosilní budovy:'; + bblRow.appendChild(bblLbl); + sbMeasureGroups.buildingBaselines.forEach(bl => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-bl-btn' + (sbEnabledBaselines.has(bl) ? ' active' : ''); + btn.dataset.baseline = bl; + btn.textContent = bl; + btn.addEventListener('click', () => { + if (sbEnabledBaselines.has(bl)) { + if (sbEnabledBaselines.size > 1) sbEnabledBaselines.delete(bl); + } else { + sbEnabledBaselines.add(bl); + } + btn.classList.toggle('active', sbEnabledBaselines.has(bl)); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + bblRow.appendChild(btn); + }); + filtersDiv.appendChild(bblRow); + } + + filtersDiv.appendChild(makeRow('Nízkoemisní doprava:', lcTransport)); + + // Fossil baseline pills for transport + if (sbMeasureGroups.transportBaselines.length) { + const tblRow = document.createElement('div'); + tblRow.className = 'q-filter-row'; + const tblLbl = document.createElement('span'); + tblLbl.className = 'q-filter-label'; + tblLbl.textContent = 'Fosilní doprava:'; + tblRow.appendChild(tblLbl); + sbMeasureGroups.transportBaselines.forEach(bl => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-bl-btn' + (sbEnabledBaselines.has(bl) ? ' active' : ''); + btn.dataset.baseline = bl; + btn.textContent = bl; + btn.addEventListener('click', () => { + if (sbEnabledBaselines.has(bl)) { + if (sbEnabledBaselines.size > 1) sbEnabledBaselines.delete(bl); + } else { + sbEnabledBaselines.add(bl); + } + btn.classList.toggle('active', sbEnabledBaselines.has(bl)); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + tblRow.appendChild(btn); + }); + filtersDiv.appendChild(tblRow); + } + + // Scenario toggle pills + const scRow = document.createElement('div'); + scRow.className = 'q-filter-row'; + const scLbl = document.createElement('span'); + scLbl.className = 'q-filter-label'; + scLbl.textContent = 'Scénář:'; + scRow.appendChild(scLbl); + SB_SCENARIOS.forEach(sc => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-scenario-btn' + (sbEnabledScenarios.has(sc) ? ' active' : ''); + btn.dataset.scenario = sc; + btn.textContent = SB_SCENARIO_LABEL[sc]; + btn.addEventListener('click', () => { + if (sbEnabledScenarios.has(sc)) { + if (sbEnabledScenarios.size > 1) sbEnabledScenarios.delete(sc); + } else { + sbEnabledScenarios.add(sc); + } + btn.classList.toggle('active', sbEnabledScenarios.has(sc)); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + scRow.appendChild(btn); + }); + filtersDiv.appendChild(scRow); + + // Discount rate toggle pills + const drRow = document.createElement('div'); + drRow.className = 'q-filter-row'; + const drLbl = document.createElement('span'); + drLbl.className = 'q-filter-label'; + drLbl.textContent = 'Diskontní míra:'; + drRow.appendChild(drLbl); + SB_DISCOUNT_RATES.forEach(dr => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-dr-btn' + (sbEnabledDiscountRates.has(dr) ? ' active' : ''); + btn.dataset.dr = dr; + btn.textContent = dr + ' %'; + btn.addEventListener('click', () => { + if (sbEnabledDiscountRates.has(dr)) { + if (sbEnabledDiscountRates.size > 1) sbEnabledDiscountRates.delete(dr); + } else { + sbEnabledDiscountRates.add(dr); + } + btn.classList.toggle('active', sbEnabledDiscountRates.has(dr)); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + drRow.appendChild(btn); + }); + filtersDiv.appendChild(drRow); + + // Carbon price toggle pills + const cpRow = document.createElement('div'); + cpRow.className = 'q-filter-row'; + const cpLbl = document.createElement('span'); + cpLbl.className = 'q-filter-label'; + cpLbl.textContent = 'Cena uhlíku:'; + cpRow.appendChild(cpLbl); + SB_CARBON_PRICES.forEach(cp => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-cp-btn' + (sbEnabledCarbonPrices.has(cp) ? ' active' : ''); + btn.dataset.cp = cp; + btn.textContent = cp + ' €'; + btn.addEventListener('click', () => { + if (sbEnabledCarbonPrices.has(cp)) { + if (sbEnabledCarbonPrices.size > 1) sbEnabledCarbonPrices.delete(cp); + } else { + sbEnabledCarbonPrices.add(cp); + } + btn.classList.toggle('active', sbEnabledCarbonPrices.has(cp)); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + cpRow.appendChild(btn); + }); + filtersDiv.appendChild(cpRow); + + // Grouping toggle + const gRow = document.createElement('div'); + gRow.className = 'q-filter-row'; + const gLbl = document.createElement('span'); + gLbl.className = 'q-filter-label'; + gLbl.textContent = 'Zobrazení:'; + gRow.appendChild(gLbl); + [ + { key: 'none', label: 'Jeden řádek' }, + { key: 'context', label: 'Kontext' }, + { key: 'rdFuel', label: 'RD souhrnně' }, + { key: 'fuel', label: 'Uhlí / Plyn' }, + { key: 'scenario', label: 'Scénář' }, + { key: 'price', label: 'Cena uhlíku' }, + { key: 'discount', label: 'Diskontní míra' }, + ].forEach(item => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-group-btn' + (sbGrouped === item.key ? ' active' : ''); + btn.dataset.grouped = item.key; + btn.textContent = item.label; + btn.addEventListener('click', () => { + sbGrouped = item.key; + wrap.querySelectorAll('.sb-group-btn').forEach(b => + b.classList.toggle('active', b.dataset.grouped === item.key) + ); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + gRow.appendChild(btn); + }); + filtersDiv.appendChild(gRow); + + // Color-by row + const colorRow = document.createElement('div'); + colorRow.className = 'q-filter-row'; + const colorLbl = document.createElement('span'); + colorLbl.className = 'q-filter-label'; + colorLbl.textContent = 'Barvy dle:'; + colorRow.appendChild(colorLbl); + [ + { key: null, label: 'Šedá' }, + { key: 'sc', label: 'Scénář' }, + { key: 'cp', label: 'Cena CO₂' }, + { key: 'dr', label: 'Diskont. míra' }, + ].forEach(item => { + const btn = document.createElement('button'); + btn.className = 'q-filter-btn sb-color-btn' + (sbColorBy === item.key ? ' active' : ''); + btn.dataset.colorBy = item.key ?? ''; + btn.textContent = item.label; + btn.addEventListener('click', () => { + sbColorBy = item.key; + wrap.querySelectorAll('.sb-color-btn').forEach(b => + b.classList.toggle('active', b.dataset.colorBy === (item.key ?? '')) + ); + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + colorRow.appendChild(btn); + }); + filtersDiv.appendChild(colorRow); + + wrap.insertBefore(filtersDiv, wrap.firstChild); + } + + function sbRenderChart(container) { + if (!container || !sbSelectedMeasure || !sbMeasureGroups) return; + const allGroups = [...sbMeasureGroups.buildings, ...sbMeasureGroups.transport]; + const group = allGroups.find(g => g.name === sbSelectedMeasure); + if (!group) return; + + // Build all dots: one per (category × scenario × carbon_price × discount_rate). + // NZ scenario has a fixed internal carbon trajectory — iterate only once to avoid duplicates. + const dots = []; + for (const cat of group.cats) { + const entry = sbFindEntry(sbSelectedMeasure, cat); + if (!entry || !entry.measure_baseline_id) continue; + if (sbEnabledBaselines && !sbEnabledBaselines.has(entry.measure_baseline)) continue; + for (const sc of SB_SCENARIOS.filter(s => sbEnabledScenarios.has(s))) { + const cps = sc === 'NZ' + ? (sbEnabledCarbonPrices.has(SB_DEFAULT.cp) ? [SB_DEFAULT.cp] : []) + : SB_CARBON_PRICES.filter(p => sbEnabledCarbonPrices.has(p)); + for (const cp of cps) { + for (const dr of SB_DISCOUNT_RATES.filter(r => sbEnabledDiscountRates.has(r))) { + const isDefault = sc === SB_DEFAULT.scenario && cp === SB_DEFAULT.cp && dr === SB_DEFAULT.dr; + if (sbShowUncertainty) { + const res = sbCalcNpvFull(entry, sc, cp, dr); + if (res == null) continue; + dots.push({ cat, sc, cp, dr, npv: res.npv, npvLow: res.npvLow, npvHigh: res.npvHigh, isDefault, x: 0, y: 0 }); + } else { + const npv = sbCalcNpv(entry, sc, cp, dr); + if (npv == null) continue; + dots.push({ cat, sc, cp, dr, npv, isDefault, x: 0, y: 0 }); + } + } + } + } + } + + if (!dots.length) { + d3.select(container).selectAll('*').remove(); + return; + } + + const cats = group.cats; + const totalW = container.clientWidth || 720; + const DOT_R = 5; + + // Resolve lane config for the active grouping mode + const fuelOf = d => /uhlí/i.test(d.cat) ? 'Uhlí' : /plyn/i.test(d.cat) ? 'Plyn' : 'Ostatní'; + const fuelColors = { 'Uhlí': '#903156', 'Plyn': '#e37373', 'Ostatní': '#888' }; + const fuelLanes = ['Uhlí', 'Plyn', 'Ostatní'].filter(v => dots.some(d => fuelOf(d) === v)); + + const rdFuelLaneOf = d => { + if (/Rodinný dům/i.test(d.cat)) return /uhlí/i.test(d.cat) ? 'Rodinný dům – uhlí' : 'Rodinný dům – plyn'; + return d.cat; + }; + const rdFuelColors = { 'Rodinný dům – uhlí': '#903156', 'Rodinný dům – plyn': '#e37373' }; + const rdFuelLaneOrder = ['Rodinný dům – uhlí', 'Rodinný dům – plyn', ...SB_BUILDING_CATS.filter(c => !/Rodinný dům/i.test(c))]; + const rdFuelLanes = rdFuelLaneOrder.filter(v => dots.some(d => rdFuelLaneOf(d) === v)); + + const sbCatLabel = v => v.replace(/ – ([EC])(\b|$)/, ' ($1)'); + const sbLcColor = name => /renovace bez zateplení/i.test(name) ? '#c05a1a' : '#1a7a85'; + + const LANE_CONFIGS = { + context: { lanes: cats, laneOf: d => d.cat, labelFn: sbCatLabel, colorFn: v => SB_CAT_COLORS[v] || '#555', leftMargin: 200, rightMargin: 160, showBaselineLabel: true }, + rdFuel: { lanes: rdFuelLanes, laneOf: rdFuelLaneOf, labelFn: sbCatLabel, colorFn: v => rdFuelColors[v] || SB_CAT_COLORS[v] || '#555', leftMargin: 180, rightMargin: 160, showBaselineLabel: true }, + scenario: { lanes: SB_SCENARIOS, laneOf: d => d.sc, labelFn: v => SB_SCENARIO_LABEL[v], colorFn: () => '#555', leftMargin: 160 }, + price: { lanes: SB_CARBON_PRICES, laneOf: d => d.cp, labelFn: v => v + ' €', colorFn: () => '#555', leftMargin: 60 }, + discount: { lanes: SB_DISCOUNT_RATES,laneOf: d => d.dr, labelFn: v => v + ' %', colorFn: () => '#555', leftMargin: 50 }, + fuel: { lanes: fuelLanes, laneOf: fuelOf, labelFn: v => v, colorFn: v => fuelColors[v] || '#888', leftMargin: 70 }, + }; + const laneCfg = LANE_CONFIGS[sbGrouped] || null; + + let M, LANE_H, totalH, yTarget, yClamp; + if (laneCfg) { + M = { top: 16, right: laneCfg.rightMargin || 24, bottom: 52, left: laneCfg.leftMargin }; + LANE_H = DOT_R * 14; + totalH = laneCfg.lanes.length * LANE_H + M.top + M.bottom; + yTarget = d => { + const idx = laneCfg.lanes.indexOf(laneCfg.laneOf(d)); + return M.top + (idx === -1 ? 0 : idx) * LANE_H + LANE_H / 2; + }; + yClamp = (d, y) => { + const idx = laneCfg.lanes.indexOf(laneCfg.laneOf(d)); + const cy = M.top + (idx === -1 ? 0 : idx) * LANE_H + LANE_H / 2; + return Math.max(cy - LANE_H / 2 + DOT_R, Math.min(cy + LANE_H / 2 - DOT_R, y)); + }; + } else { + M = { top: 20, right: 24, bottom: 52, left: 24 }; + LANE_H = DOT_R * 24; + totalH = LANE_H + M.top + M.bottom; + const midY = M.top + LANE_H / 2; + yTarget = () => midY; + yClamp = (d, y) => Math.max(M.top + DOT_R, Math.min(M.top + LANE_H - DOT_R, y)); + } + + const chartW = Math.max(totalW - M.left - M.right, 200); + const xScale = d3.scaleLinear().domain(SB_X_DOMAIN).range([0, chartW]); + + const xTarget = d => M.left + xScale(Math.max(SB_X_DOMAIN[0], Math.min(SB_X_DOMAIN[1], d.npv))); + dots.forEach(d => { d.x = xTarget(d); d.y = yTarget(d); }); + + // Snap x back to NPV position after each tick so only y is displaced by collide + const sim = d3.forceSimulation(dots) + .force('y', d3.forceY(d => yTarget(d)).strength(laneCfg ? 0.85 : 0.3)) + .force('collide', d3.forceCollide(DOT_R * 1.2)) + .stop(); + const ticks = laneCfg ? 120 : 150; + for (let i = 0; i < ticks; i++) { + sim.tick(); + dots.forEach(d => { d.x = xTarget(d); }); + } + + d3.select(container).selectAll('*').remove(); + const svg = d3.select(container).append('svg') + .attr('width', totalW).attr('height', totalH) + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + + // Zero line + const zx = M.left + xScale(0); + svg.append('line') + .attr('x1', zx).attr('x2', zx) + .attr('y1', M.top).attr('y2', totalH - M.bottom) + .attr('stroke', '#ccc').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + + // Grouped mode: lane lines + left-side labels + if (laneCfg) { + laneCfg.lanes.forEach((v, i) => { + const cy = M.top + i * LANE_H + LANE_H / 2; + svg.append('line') + .attr('x1', M.left).attr('x2', M.left + chartW) + .attr('y1', cy).attr('y2', cy) + .attr('stroke', '#f0f0f0').attr('stroke-width', 1); + + const showBl = laneCfg.showBaselineLabel; + const labelY = showBl ? cy - 5 : cy + 4; + + if (showBl) { + // Left: category name + fossil baseline (fosilní je horší → vlevo) + svg.append('text') + .attr('x', M.left - 8).attr('y', labelY) + .attr('text-anchor', 'end') + .attr('font-size', '11px').attr('fill', '#53616e') + .text(laneCfg.labelFn(v)); + + const firstCat = laneCfg.laneOf === rdFuelLaneOf + ? cats.find(c => rdFuelLaneOf({ cat: c }) === v) + : v; + const blEntry = firstCat ? sbFindEntry(sbSelectedMeasure, firstCat) : null; + const blName = blEntry?.measure_baseline || ''; + if (blName) { + const blColor = /uhlí|uhelný/i.test(blName) ? '#903156' + : /plyn/i.test(blName) ? '#e37373' + : /renovace/i.test(blName) ? '#c05a1a' + : '#888'; + svg.append('text') + .attr('x', M.left - 8).attr('y', cy + 9) + .attr('text-anchor', 'end') + .attr('font-size', '10px').attr('font-weight', '700') + .attr('fill', blColor) + .text(blName); + } + + // Right: LC measure name (nízkoemisní je lepší → vpravo) + svg.append('text') + .attr('x', M.left + chartW + 8).attr('y', cy + 4) + .attr('text-anchor', 'start') + .attr('font-size', '10px').attr('font-weight', '700') + .attr('fill', sbLcColor(sbSelectedMeasure)) + .text(sbSelectedMeasure); + } else { + svg.append('text') + .attr('x', M.left - 8).attr('y', labelY) + .attr('text-anchor', 'end') + .attr('font-size', '11px').attr('fill', laneCfg.colorFn(v)) + .text(laneCfg.labelFn(v)); + } + }); + } + + // X axis + svg.append('g') + .attr('class', 'chart-axis') + .attr('transform', `translate(${M.left},${totalH - M.bottom})`) + .call(sel => { + sel.call(d3.axisBottom(xScale).ticks(7).tickFormat(v => { + const a = Math.abs(v), s = v < 0 ? '−' : v > 0 ? '+' : ''; + if (a >= 1e6) return s + (a / 1e6).toFixed(1) + ' M'; + if (a >= 1e3) return s + Math.round(a / 1e3) + ' tis.'; + return v === 0 ? '0' : s + a; + })); + sel.select('.domain').attr('stroke', 'none'); + sel.selectAll('.tick line').attr('stroke', '#9ba5ad').attr('stroke-width', 1); + sel.selectAll('.tick text') + .attr('fill', '#53616e') + .attr('font-family', '"Roboto", system-ui, sans-serif') + .attr('font-size', 12); + }); + + svg.append('text') + .attr('text-anchor', 'middle') + .attr('x', M.left + chartW / 2).attr('y', totalH - 4) + .attr('font-size', '12px').attr('fill', '#53616e') + .attr('font-family', '"Roboto", system-ui, sans-serif') + .text('Rozdíl NPV oproti základní variantě (Kč)'); + + // Territory labels + svg.append('text') + .attr('x', M.left + 4).attr('y', totalH - M.bottom + 14) + .attr('font-size', '9px').attr('fill', '#bbb').attr('text-anchor', 'start') + .text('← fosilní výhodnější'); + svg.append('text') + .attr('x', M.left + chartW - 4).attr('y', totalH - M.bottom + 14) + .attr('font-size', '9px').attr('fill', '#bbb').attr('text-anchor', 'end') + .text('nízkoemisní výhodnější →'); + + svg.append('text') + .attr('x', zx).attr('y', M.top - 4) + .attr('text-anchor', 'middle') + .attr('font-size', '9px').attr('fill', '#aaa') + .text('Výchozí (CP · 60 € · 3 %)'); + + // Tooltip + let sbTip = document.getElementById('sb-tip'); + if (!sbTip) { + sbTip = document.createElement('div'); + sbTip.id = 'sb-tip'; + Object.assign(sbTip.style, { + position: 'fixed', pointerEvents: 'none', background: 'rgba(30,30,30,0.88)', + color: '#fff', fontSize: '12px', lineHeight: '1.5', padding: '6px 10px', + borderRadius: '4px', whiteSpace: 'pre', display: 'none', zIndex: 9999, + }); + document.body.appendChild(sbTip); + } + + const fmtNpv = v => { + const abs = Math.abs(v), sign = v < 0 ? '− ' : '+ '; + if (abs >= 1e6) return sign + (Math.round(abs / 1e5) / 10).toFixed(1) + ' mil. Kč'; + if (abs >= 1e3) return sign + Math.round(abs / 1e3) + ' tis. Kč'; + return sign + Math.round(abs) + ' Kč'; + }; + + // Per-dot uncertainty bands (drawn behind circles) + if (sbShowUncertainty) { + const bandH = DOT_R * 1.2; + svg.selectAll('.sb-unc-band') + .data(dots.filter(d => d.npvLow != null && d.npvHigh != null)) + .join('rect') + .attr('class', 'sb-unc-band') + .attr('x', d => M.left + xScale(Math.max(SB_X_DOMAIN[0], d.npvLow))) + .attr('width', d => Math.max(1, xScale(Math.min(SB_X_DOMAIN[1], d.npvHigh)) - xScale(Math.max(SB_X_DOMAIN[0], d.npvLow)))) + .attr('y', d => yClamp(d, d.y) - bandH / 2) + .attr('height', bandH) + .attr('fill', '#9ba5ad') + .attr('opacity', d => d.isDefault ? 0.35 : 0.12) + .attr('rx', 2); + } + + // Draw non-default dots first (behind), then default dots on top + [dots.filter(d => !d.isDefault), dots.filter(d => d.isDefault)].forEach(subset => { + svg.selectAll(null) + .data(subset) + .join('circle') + .attr('cx', d => Math.max(M.left + DOT_R, Math.min(M.left + chartW - DOT_R, d.x))) + .attr('cy', d => yClamp(d, d.y)) + .attr('r', d => d.isDefault ? DOT_R + 1 : DOT_R) + .attr('fill', d => { + if (sbColorBy === 'sc') return SB_SC_COLORS[d.sc] || '#888'; + if (sbColorBy === 'cp') return SB_CP_COLORS[d.cp] || '#888'; + if (sbColorBy === 'dr') return SB_DR_COLORS[d.dr] || '#888'; + return d.isDefault ? '#53616e' : '#9ba5ad'; + }) + .attr('opacity', d => d.isDefault ? 0.9 : (sbColorBy ? 0.55 : 0.25)) + .attr('stroke', d => d.isDefault ? 'white' : 'none') + .attr('stroke-width', 1.5) + .style('cursor', 'pointer') + .on('mouseover', function(event, d) { + d3.select(this).attr('opacity', 1); + const cpLabel = d.sc === 'NZ' ? 'trajektorie NZ' : d.cp + ' €'; + sbTip.textContent = [ + d.cat, + 'Scénář: ' + SB_SCENARIO_LABEL[d.sc], + 'Cena uhlíku: ' + cpLabel, + 'Diskontní míra: ' + d.dr + ' %', + 'NPV: ' + fmtNpv(d.npv), + ].join('\n'); + sbTip.style.display = 'block'; + sbTip.style.left = (event.clientX + 14) + 'px'; + sbTip.style.top = (event.clientY - 28) + 'px'; + }) + .on('mousemove', event => { + sbTip.style.left = (event.clientX + 14) + 'px'; + sbTip.style.top = (event.clientY - 28) + 'px'; + }) + .on('mouseout', function(event, d) { + d3.select(this).attr('opacity', d.isDefault ? 0.9 : (sbColorBy ? 0.55 : 0.25)); + sbTip.style.display = 'none'; + }); + }); + + // E / C letter inside building dots (– E or – C suffix categories) + const catLetter = cat => /– E$/.test(cat) ? 'E' : /– C$/.test(cat) ? 'C' : null; + const ecDots = dots.filter(d => catLetter(d.cat)); + if (sbShowLetters && ecDots.length) { + [ecDots.filter(d => !d.isDefault), ecDots.filter(d => d.isDefault)].forEach(subset => { + svg.selectAll(null) + .data(subset) + .join('text') + .attr('x', d => Math.max(M.left + DOT_R, Math.min(M.left + chartW - DOT_R, d.x))) + .attr('y', d => yClamp(d, d.y)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('font-size', d => (d.isDefault ? DOT_R + 1 : DOT_R) * 1.5 + 'px') + .attr('font-weight', '700') + .attr('fill', 'white') + .attr('opacity', d => d.isDefault ? 0.9 : 0.25) + .attr('pointer-events', 'none') + .text(d => catLetter(d.cat)); + }); + } + + fokDownloadBar(container, 'sensitivity-beeswarm'); + + // Context legend: always shown except when grouping BY context (labels are in the chart then) + const legendEl = document.getElementById('sensitivity-beeswarm-legend'); + if (legendEl) { + legendEl.innerHTML = ''; + if (sbGrouped !== 'context' && sbGrouped !== 'rdFuel') { + cats.forEach(cat => { + const item = document.createElement('div'); + item.className = 'sb-legend-item'; + const swatch = document.createElement('span'); + swatch.style.cssText = `display:inline-block;width:10px;height:10px;border-radius:50%;background:${SB_CAT_COLORS[cat] || '#888'};flex-shrink:0;`; + const label = document.createElement('span'); + label.textContent = cat; + item.appendChild(swatch); + item.appendChild(label); + legendEl.appendChild(item); + }); + } + + // E/C toggle button — only when relevant categories exist + if (cats.some(c => catLetter(c))) { + const ecBtn = document.createElement('button'); + ecBtn.className = 'chart-dl-btn'; + ecBtn.style.cssText = 'align-self:center; margin-left:4px;'; + ecBtn.textContent = sbShowLetters ? 'E/C ✓' : 'E/C'; + ecBtn.addEventListener('click', () => { + sbShowLetters = !sbShowLetters; + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + legendEl.appendChild(ecBtn); + } + + // Uncertainty band toggle + const uncBtn = document.createElement('button'); + uncBtn.className = 'chart-dl-btn'; + uncBtn.style.cssText = 'align-self:center; margin-left:4px;'; + uncBtn.textContent = sbShowUncertainty ? 'Rozsah ✓' : 'Rozsah'; + uncBtn.addEventListener('click', () => { + sbShowUncertainty = !sbShowUncertainty; + sbRenderChart(document.getElementById('sensitivity-beeswarm-chart')); + }); + legendEl.appendChild(uncBtn); + } + } + + function sbInit() { + const wrap = document.getElementById('sensitivity-beeswarm-wrap'); + if (!wrap) return; + + sbMeasureGroups = sbBuildMeasureGroups(); + sbEnabledBaselines = new Set([...sbMeasureGroups.buildingBaselines, ...sbMeasureGroups.transportBaselines]); + const allGroups = [...sbMeasureGroups.buildings, ...sbMeasureGroups.transport]; + if (!allGroups.length) return; + + sbSelectedMeasure = allGroups[0].name; + sbBuildFilters(wrap); + + const chartEl = document.getElementById('sensitivity-beeswarm-chart'); + sbRenderChart(chartEl); + + window.addEventListener('resize', () => { + const el = document.getElementById('sensitivity-beeswarm-chart'); + if (el) sbRenderChart(el); + }); + } + + function renderAll() { + // Compute shared x-domain per domain group + const groupVals = {}; + document.querySelectorAll('.tornado-chart[data-domain-group]').forEach(el => { + const g = el.dataset.domainGroup; + if (!groupVals[g]) groupVals[g] = []; + groupVals[g].push(...collectNpvsForEl(el)); + }); + const sharedDomains = {}; + for (const [g, vals] of Object.entries(groupVals)) { + if (!vals.length) continue; + const [vMin, vMax] = d3.extent(vals); + const xPad = (vMax - vMin) * 0.06 || 20000; + sharedDomains[g] = d3.scaleLinear().domain([vMin - xPad, vMax + xPad]).nice().domain(); + } + + document.querySelectorAll('.tornado-chart[data-categories]').forEach(el => { + const cats = el.dataset.categories.split('|'); + const excl = el.dataset.exclude ? el.dataset.exclude.split(',').map(s => s.trim()) : []; + const domain = el.dataset.domainGroup ? sharedDomains[el.dataset.domainGroup] || null : null; + renderMultiTornadoChart(el, cats, el.dataset.param || 'Cena uhlíku', excl, domain); + }); + document.querySelectorAll('.tornado-chart[data-category]').forEach(el => { + const excl = el.dataset.exclude ? el.dataset.exclude.split(',').map(s => s.trim()) : []; + const domain = el.dataset.domainGroup ? sharedDomains[el.dataset.domainGroup] || null : null; + renderTornadoChart(el, el.dataset.category, el.dataset.param || 'Cena uhlíku', excl, domain); + }); + const qEl = document.getElementById('quadrant-wrap'); + if (qEl) renderQuadrantChart(qEl); + const macEl = document.getElementById('mac-chart'); + if (macEl) { + macBuildFilters(macEl); // no-op after first call + macRenderSVG(macEl); + } + // Re-render static chart on resize if visible + const scEl = document.getElementById('static-comparison-chart'); + if (scEl && !scEl.hidden) renderStaticComparisonChart(scEl); + const beeEl = document.getElementById('beeswarm-chart'); + const capexBeeEl = document.getElementById('beeswarm-capex-chart'); + if (beeEl || capexBeeEl) { + const allPts = qComputePoints(state.carbonPrice, state.discountRate, state.fuelScenario) + .filter(p => p.savedT > 0); + const kcVals = allPts.filter(p => isFinite(p.kcPerT)).map(p => p.kcPerT); + const capexVals = allPts.filter(p => p.capexPerT != null && isFinite(p.capexPerT)).map(p => p.capexPerT); + const allVals = [...kcVals, ...capexVals]; + const sharedAbsMax = allVals.length + ? Math.max(Math.abs(d3.min(allVals)), Math.abs(d3.max(allVals))) * 1.1 + : undefined; + if (beeEl) renderBeeswarmChart(beeEl, sharedAbsMax); + if (capexBeeEl) renderCapexBeeswarmChart(capexBeeEl, sharedAbsMax); + } + // Compute shared x-axis domain across all dumbbell charts + const allDbVals = []; + DUMBBELL_CONFIGS.forEach(cfg => { + computeScenarioRows(cfg.categories).forEach(r => + SCENARIO_DEFS.forEach(sc => { if (r[sc.key] != null) allDbVals.push(r[sc.key]); }) + ); + }); + let sharedDbDomain; + if (allDbVals.length) { + const [dbMin, dbMax] = d3.extent(allDbVals); + const dbPad = (dbMax - dbMin) * 0.04; + sharedDbDomain = [dbMin - dbPad, dbMax + dbPad]; + } + const legendEl = document.getElementById('dumbbell-legend'); + if (legendEl) renderDumbbellLegend(legendEl); + DUMBBELL_CONFIGS.forEach(cfg => { + const el = document.getElementById(cfg.id); + if (el) renderDumbbellChart(el, cfg.categories, sharedDbDomain); + }); + addDownloadBars(); + } + + // ── Init ────────────────────────────────────────────────────────────────── + function init() { + if (document.getElementById('quadrant-wrap')) { + quadrantDomains = computeQuadrantDomains(); + } + + // Toggle for static comparison chart + const toggleBtn = document.getElementById('static-chart-toggle'); + const staticEl = document.getElementById('static-comparison-chart'); + if (toggleBtn && staticEl) { + toggleBtn.addEventListener('click', () => { + const expanded = toggleBtn.getAttribute('aria-expanded') === 'true'; + toggleBtn.setAttribute('aria-expanded', String(!expanded)); + staticEl.hidden = expanded; + if (!expanded) { + renderStaticComparisonChart(staticEl); + fokDownloadBar(staticEl, 'quadrant-porovnani'); + } + }); + } + + setupControls(); + renderAll(); + window.addEventListener('resize', renderAll); + sbInit(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/assets-local/js/costs-and-benefits.js b/assets-local/js/costs-and-benefits.js new file mode 100644 index 000000000..9bc7a482d --- /dev/null +++ b/assets-local/js/costs-and-benefits.js @@ -0,0 +1,1259 @@ +(function () { + 'use strict'; + + const data = window.COSTS_AND_BENEFITS; + if (!data) return; + + // ── State ──────────────────────────────────────────────────────────────── + // discountRate stored as integer percentage points (0–7); divided by 100 on use. + const state = { + carbonPrice: 60, + discountRate: 3, + fuelScenario: 'CP', + }; + + // ── Tooltip ────────────────────────────────────────────────────────────── + // A single floating div reused by all charts. Appears immediately on hover + // (no browser-native delay). + const tip = document.createElement('div'); + Object.assign(tip.style, { + position: 'fixed', + pointerEvents: 'none', + background: 'rgba(30,30,30,0.88)', + color: '#fff', + borderRadius: '5px', + padding: '5px 9px', + fontSize: '13px', + lineHeight: '1.45', + fontFamily: 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif', + whiteSpace: 'pre-wrap', // keep \n line breaks AND wrap long lines + zIndex: '9999', + display: 'none', + maxWidth: '260px', + }); + document.body.appendChild(tip); + + function showTip(event, text) { + tip.textContent = text; + tip.style.display = 'block'; + moveTip(event); + } + function moveTip(event) { + const pad = 12; + const tw = tip.offsetWidth, th = tip.offsetHeight; + let x = event.clientX + pad, y = event.clientY + pad; + if (x + tw > window.innerWidth - 4) x = event.clientX - tw - pad; + if (y + th > window.innerHeight - 4) y = event.clientY - th - pad; + tip.style.left = x + 'px'; + tip.style.top = y + 'px'; + } + function hideTip() { tip.style.display = 'none'; } + + // ── Global x-axis domain ───────────────────────────────────────────────── + // Computed once at init across all measures × all parameter combinations. + // Fixed so the scale never shifts when controls change. + let globalXDomain = null; + + // ── Fixed section order ─────────────────────────────────────────────────── + // Computed once at init using default params; never changes (dynamic + // reordering of H2 sections would be disorienting while scrolling). + let fixedBuildingOrder = null; + let fixedTransportOrder = null; + + function computeGlobalDomain() { + const allMeasures = [ + ...(data.buildings_measures || []), + ...(data.transport_measures || []), + ].filter(m => m.measure_baseline_id || m.measure_baseline); + + const vals = []; + const carbonPrices = [0, 60, 200]; + const discountRates = [0, 0.03, 0.07]; + const scenarios = ['CP', 'NZ', 'CP_EC']; + + for (const m of allMeasures) { + for (const cp of carbonPrices) { + for (const dr of discountRates) { + for (const sc of scenarios) { + try { + const result = CostsBenefits.calculate({ + measureId: m.id, + data, + discountRate: dr, + carbonPriceEur: cp, + priceScenario: sc, + }); + vals.push(result.npv); + } catch (_) { /* skip */ } + } + } + } + } + if (!vals.length) return [-500000, 500000]; + + const [vMin, vMax] = d3.extent(vals); + return d3.scaleLinear() + .domain([Math.min(vMin, 0), Math.max(vMax, 0)]) + .nice() + .domain(); + } + + // ── Calculation ────────────────────────────────────────────────────────── + // Returns { npv: { value, low, high }, co2Saved, sensitivity } or null. + function computeRow(measure) { + try { + const result = CostsBenefits.calculate({ + measureId: measure.id, + data, + discountRate: state.discountRate / 100, + carbonPriceEur: state.carbonPrice, + priceScenario: state.fuelScenario, + }); + const sens = result.sensitivity || []; + const low = sens.length ? Math.min(...sens.map(s => s.minNpv)) : result.npv; + const high = sens.length ? Math.max(...sens.map(s => s.maxNpv)) : result.npv; + return { + npv: { value: result.npv, low, high }, + co2Saved: result.emissionSavings ? -result.emissionSavings.totalT : null, + capexDiff: result.capexDiff, + sector: result.sector, + gasSavings: result.gasSavings, + fuelSavings: result.fuelSavings, + sensitivity: sens, + }; + } catch (e) { + console.warn('CostsBenefits.calculate error for measure id', measure.id, ':', e.message); + return null; + } + } + + // ── Formatting ─────────────────────────────────────────────────────────── + const fmtInt = new Intl.NumberFormat('cs-CZ', { maximumFractionDigits: 0 }); + + function fmtCZK(v) { + const sign = v < 0 ? '−\u202f' : '+\u202f'; + const abs = Math.abs(v); + if (abs >= 1e6) return sign + (Math.round(abs / 1e5) / 10).toFixed(1) + '\u202fmil. Kč'; + if (abs >= 1e3) return sign + fmtInt.format(Math.round(abs / 1e3)) + '\u202ftis. Kč'; + return sign + fmtInt.format(abs) + '\u202fKč'; + } + + function fmtCO2(savedT) { + if (savedT === null || savedT === undefined) return '—'; + const sign = savedT < 0 ? '−' : ''; + const abs = Math.abs(savedT); + if (abs >= 100) return sign + fmtInt.format(Math.round(abs)) + '\u202ft CO₂'; + if (abs >= 1) return sign + (Math.round(abs * 10) / 10).toFixed(1) + '\u202ft CO₂'; + return sign + fmtInt.format(Math.round(abs * 1000)) + '\u202fkg CO₂'; + } + + // X t CO₂ (or kg CO₂) saved per 1 000 CZK of `czk`. + // Round x (positive) to 3 significant figures, no trailing zeros. + function fmt3sig(x) { return parseFloat(x.toPrecision(3)).toString(); } + + // Kč needed to save 1 t CO₂ (czk signed: + = cost, −= earning). + function fmtCZKperT(czk, savedT) { + if (savedT == null || !savedT || !isFinite(czk / savedT)) return '—'; + const v = czk / savedT; + const sign = v < 0 ? '−' : ''; + const abs = Math.abs(v); + if (abs >= 1e6) return sign + fmt3sig(abs / 1e6) + ' mil. Kč/t CO₂'; + if (abs >= 1e3) return sign + fmt3sig(abs / 1e3) + ' tis. Kč/t CO₂'; + return sign + fmt3sig(abs) + ' Kč/t CO₂'; + } + + // Kč needed to save 1 MWh of gas (czk signed: + = cost, −= earning). + function fmtCZKperMWh(czk, mwh) { + if (mwh == null || !mwh || !isFinite(czk / mwh)) return '—'; + const v = czk / mwh; + const sign = v < 0 ? '−' : ''; + const abs = Math.abs(v); + if (abs >= 1e6) return sign + fmt3sig(abs / 1e6) + ' mil. Kč/MWh'; + if (abs >= 1e3) return sign + fmt3sig(abs / 1e3) + ' tis. Kč/MWh'; + return sign + fmt3sig(abs) + ' Kč/MWh'; + } + + // Kč needed to save 1 litre of fuel (czk signed: + = cost, −= earning). + function fmtCZKperL(czk, litres) { + if (litres == null || !litres || !isFinite(czk / litres)) return '—'; + const v = czk / litres; + const sign = v < 0 ? '−' : ''; + const abs = Math.abs(v); + if (abs >= 1000) return sign + fmt3sig(abs / 1000) + ' tis. Kč/l'; + return sign + fmt3sig(abs) + ' Kč/l'; + } + + function fmtL(litres) { + if (litres == null || !isFinite(litres)) return '—'; + const sign = litres < 0 ? '−' : ''; + const abs = Math.abs(litres); + if (abs >= 1000) return sign + fmtInt.format(Math.round(abs / 10) * 10) + ' l'; + return sign + fmtInt.format(Math.round(abs)) + ' l'; + } + + function fmtMWh(mwh) { + if (mwh == null || !isFinite(mwh)) return '—'; + const sign = mwh < 0 ? '−' : ''; + const abs = Math.abs(mwh); + if (abs >= 1000) return sign + (Math.round(abs / 100) / 10).toFixed(1) + ' GWh'; + if (abs >= 1) return sign + fmtInt.format(Math.round(abs)) + ' MWh'; + return sign + fmtInt.format(Math.round(abs * 1000)) + ' kWh'; + } + + // ── Controls ───────────────────────────────────────────────────────────── + function setupControls() { + setupSlider('carbon-price-slider', 'carbon-price-value', v => { + state.carbonPrice = v; + return v + '\u202f€'; + }); + setupSlider('discount-rate-slider', 'discount-rate-value', v => { + state.discountRate = v; + return v + '\u202f%'; + }); + const fsSelect = document.getElementById('fuel-scenario-select'); + if (fsSelect) { + fsSelect.addEventListener('change', () => { + state.fuelScenario = fsSelect.value; + renderAll(); + }); + } + } + + function setupSlider(sliderId, valueId, onUpdate) { + const slider = document.getElementById(sliderId); + const valueEl = document.getElementById(valueId); + if (!slider || !valueEl) return; + slider.addEventListener('input', () => { + valueEl.textContent = onUpdate(+slider.value); + renderAll(); + }); + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + const ANIM_MS = 450; + + // Returns a sorted array of { name, baseline, dots } for one section. + // Sorted descending by mean NPV so the most favorable measure is on top. + function getSummaryRows(section) { + const isBuildings = section === 'buildings'; + const allMeasures = isBuildings ? data.buildings_measures : data.transport_measures; + const catField = isBuildings ? 'building_category' : 'transport_category'; + + const measureNames = []; + for (const m of allMeasures) { + if ((m.measure_baseline_id || m.measure_baseline) && !measureNames.includes(m.measure_name)) { + measureNames.push(m.measure_name); + } + } + + return measureNames + .map(name => { + const entries = allMeasures.filter(m => + m.measure_name === name && (m.measure_baseline_id || m.measure_baseline) + ); + const dots = entries.map(m => { + const calc = computeRow(m); + if (!calc) return null; + return { label: m[catField], npv: calc.npv }; + }).filter(Boolean); + const baselines = [...new Set(entries.map(m => m.measure_baseline).filter(Boolean))]; + return { name, baseline: baselines.length ? baselines.join(', ') : null, dots }; + }) + .filter(r => r.dots.length > 0) + .sort((a, b) => d3.mean(b.dots, d => d.npv.value) - d3.mean(a.dots, d => d.npv.value)); + } + + const xAxisFmt = v => { + const a = Math.abs(v); + const s = v < 0 ? '−' : v > 0 ? '+' : ''; + if (a >= 1e6) return s + (a / 1e6).toFixed(1) + ' M'; + if (a >= 1e3) return s + Math.round(a / 1e3) + ' tis.'; + return v === 0 ? '0' : s + a; + }; + + // ── Summary chart ───────────────────────────────────────────────────────── + const SUMMARY_ROW_H = 44; + const SUMMARY_LABEL_W = 260; + const SUMMARY_MARGIN = { top: 48, right: 16, bottom: 36 }; + const SECTION_HDR_H = 22; + + const COLOR_BUILDINGS = '#2860b4'; + const COLOR_TRANSPORT = '#6b4fa0'; + + // NPV > 0: favorable (teal); NPV < 0: costly (red) + const COLOR_FAVORABLE = '#1a7a85'; + const COLOR_COSTLY = '#c0392b'; + + // CO₂ squares — fixed global scale so all measures are comparable + const CO2_UNIT = 25; // 1 box = 25 t CO₂ (= 25 000 kg) + const CO2_MAX_COLS = 4; // wrap to new row after 4 boxes (= 100 t = 100K kg) + + function renderSummaryChart(container) { + if (!globalXDomain) return; + + const sections = [ + { label: 'Budovy', rows: getSummaryRows('buildings'), color: COLOR_BUILDINGS }, + { label: 'Doprava', rows: getSummaryRows('transport'), color: COLOR_TRANSPORT }, + ].filter(s => s.rows.length > 0); + + if (!sections.length) { container.hidden = true; return; } + + const totalW = container.clientWidth || 640; + const chartW = Math.max(totalW - SUMMARY_LABEL_W - SUMMARY_MARGIN.right, 120); + const totalH = sections.reduce((h, s) => + h + SECTION_HDR_H + s.rows.length * SUMMARY_ROW_H, 0 + ) + SUMMARY_MARGIN.top + SUMMARY_MARGIN.bottom; + + const xScale = d3.scaleLinear().domain(globalXDomain).range([0, chartW]); + const z = xScale(0); + + // ── Create SVG skeleton once ────────────────────────────────────────── + let svg = d3.select(container).select('svg'); + if (svg.empty()) { + svg = d3.select(container).append('svg').attr('role', 'img') + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + svg.append('text').attr('class', 'chart-col-header lbl-hdr').attr('x', 4).attr('y', 16) + .text('Opatření'); + svg.append('text').attr('class', 'chart-col-header npv-hdr').attr('text-anchor', 'middle').attr('y', 16); + svg.append('line').attr('class', 'zero-line') + .attr('stroke', '#bbb').attr('stroke-width', 1).attr('stroke-dasharray', '3 3'); + svg.append('text').attr('class', 'half-lbl half-lbl-l') + .attr('font-size', '11px').attr('font-style', 'italic').attr('fill', '#bbb').attr('text-anchor', 'middle'); + svg.append('text').attr('class', 'half-lbl half-lbl-r') + .attr('font-size', '11px').attr('font-style', 'italic').attr('fill', '#bbb').attr('text-anchor', 'middle'); + svg.append('g').attr('class', 'sec-hdrs'); + svg.append('g').attr('class', 'rows-g'); + svg.append('g').attr('class', 'chart-axis x-axis'); + } + + svg.attr('width', totalW).attr('height', totalH); + + // Update static elements that depend on width/height + svg.select('.npv-hdr').attr('x', SUMMARY_LABEL_W + chartW / 2) + .text('Rozdíl NPV oproti základní variantě (Kč)'); + svg.select('.zero-line') + .attr('x1', SUMMARY_LABEL_W + z).attr('x2', SUMMARY_LABEL_W + z) + .attr('y1', SUMMARY_MARGIN.top - 4).attr('y2', totalH - SUMMARY_MARGIN.bottom); + svg.select('.half-lbl-l').attr('x', SUMMARY_LABEL_W + z / 2).attr('y', 34) + .text('Fosilní opatření je výhodnější'); + svg.select('.half-lbl-r').attr('x', SUMMARY_LABEL_W + z + (chartW - z) / 2).attr('y', 34) + .text('Dekarbonizační opatření je výhodnější'); + + // ── Section headers (fixed y — row counts never change) ─────────────── + // Pre-compute y-offsets per section + const secOffsets = []; + let cy = SUMMARY_MARGIN.top; + for (const s of sections) { + secOffsets.push({ headerY: cy, rowsY: cy + SECTION_HDR_H }); + cy += SECTION_HDR_H + s.rows.length * SUMMARY_ROW_H; + } + + const secSel = svg.select('.sec-hdrs').selectAll('text.sec-hdr') + .data(sections, s => s.label); + secSel.enter().append('text').attr('class', 'sec-hdr') + .attr('font-size', '13px').attr('font-weight', '700') + .merge(secSel) + .attr('x', 4) + .attr('fill', s => s.color) + .attr('y', (s, i) => secOffsets[i].headerY + 14) + .text(s => s.label); + secSel.exit().remove(); + + // ── Measure rows (animated on reorder) ─────────────────────────────── + // Build a flat list with absolute y-positions + const allRows = []; + sections.forEach((s, si) => { + s.rows.forEach((row, ri) => { + allRows.push({ ...row, targetY: secOffsets[si].rowsY + ri * SUMMARY_ROW_H }); + }); + }); + + const rowSel = svg.select('.rows-g').selectAll('g.s-row').data(allRows, d => d.name); + + // ENTER — appear at final position, fade in + const rowEnter = rowSel.enter().append('g').attr('class', 's-row') + .attr('transform', d => `translate(0,${d.targetY})`) + .attr('opacity', 0); + + rowEnter.append('text').attr('class', 'r-name').attr('x', 8).attr('font-size', '13px').attr('fill', '#444'); + rowEnter.append('text').attr('class', 'r-base').attr('x', 8).attr('font-size', '11px').attr('fill', '#aaa'); + rowEnter.append('g').attr('class', 'r-dots'); + + // MERGE — transition y-position; update content + const rowAll = rowSel.merge(rowEnter); + + rowAll.transition().duration(ANIM_MS).ease(d3.easeCubicInOut) + .attr('transform', d => `translate(0,${d.targetY})`) + .attr('opacity', 1); + + rowAll.each(function (row) { + const g = d3.select(this); + const mid = SUMMARY_ROW_H / 2; + + g.select('.r-name').attr('y', row.baseline ? mid - 1 : mid + 4).text(row.name); + g.select('.r-base').attr('y', mid + 13).text(row.baseline ? 'vs. ' + row.baseline : ''); + + const dotsG = g.select('.r-dots'); + dotsG.selectAll('*').remove(); + for (const dot of row.dots) { + const tipText = dot.label ? fmtCZK(dot.npv.value) + '\n' + dot.label : fmtCZK(dot.npv.value); + dotsG.append('circle') + .attr('cx', SUMMARY_LABEL_W + xScale(dot.npv.value)).attr('cy', mid) + .attr('r', 6).attr('fill', dot.npv.value >= 0 ? COLOR_FAVORABLE : COLOR_COSTLY) + .attr('stroke', 'white').attr('stroke-width', 1.5).attr('opacity', 0.85) + .on('mouseover', e => showTip(e, tipText)) + .on('mousemove', moveTip) + .on('mouseout', hideTip); + } + }); + + rowSel.exit().transition().duration(ANIM_MS).attr('opacity', 0).remove(); + + // ── X axis ──────────────────────────────────────────────────────────── + svg.select('.x-axis') + .attr('transform', `translate(${SUMMARY_LABEL_W},${totalH - SUMMARY_MARGIN.bottom})`) + .call(d3.axisBottom(xScale).ticks(5).tickFormat(xAxisFmt)); + } + + // ── Detailed measure chart ──────────────────────────────────────────────── + const ROW_H = 54; + const LABEL_W = 200; + const CO2_W = 150; + const FUEL_W = 120; + const MARGIN = { top: 28, right: 16, bottom: 36 }; + + function renderMeasureChart(container, section, measureName) { + const isBuildings = section === 'buildings'; + const allMeasures = isBuildings ? data.buildings_measures : data.transport_measures; + const catField = isBuildings ? 'building_category' : 'transport_category'; + + const entries = allMeasures.filter(m => + m.measure_name === measureName && (m.measure_baseline_id || m.measure_baseline) + ); + if (!entries.length) { container.hidden = true; return; } + container.hidden = false; + + const rows = entries + .map(m => { + const calc = computeRow(m); + if (!calc) return null; + return { + label: m[catField], + baselineName: m.measure_baseline || null, + measureId: m.id, + npv: calc.npv, + co2Saved: calc.co2Saved, + capexDiff: calc.capexDiff, + sector: calc.sector, + gasSavings: calc.gasSavings, + fuelSavings: calc.fuelSavings, + sensitivity: calc.sensitivity, + }; + }) + .filter(Boolean) + .sort((a, b) => b.npv.value - a.npv.value); + + if (!rows.length) { container.hidden = true; return; } + + const totalW = container.clientWidth || 640; + const chartW = Math.max(totalW - LABEL_W - CO2_W - FUEL_W - MARGIN.right, 120); + const totalH = rows.length * ROW_H + MARGIN.top + MARGIN.bottom; + + const xScale = d3.scaleLinear() + .domain(globalXDomain || [-500000, 500000]) + .range([0, chartW]); + const z = xScale(0); + + // ── Create SVG skeleton once ────────────────────────────────────────── + let svg = d3.select(container).select('svg'); + if (svg.empty()) { + svg = d3.select(container).append('svg').attr('role', 'img') + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + svg.append('text').attr('class', 'chart-col-header ctx-hdr').attr('x', 4).attr('y', 16).text('Kontext'); + svg.append('text').attr('class', 'chart-col-header npv-hdr').attr('text-anchor', 'middle').attr('y', 16); + svg.append('text').attr('class', 'chart-col-header co2-hdr').attr('text-anchor', 'start').attr('y', 16); + svg.append('text').attr('class', 'chart-col-header fuel-hdr').attr('text-anchor', 'start').attr('y', 16); + svg.append('line').attr('class', 'zero-line') + .attr('stroke', '#bbb').attr('stroke-width', 1).attr('stroke-dasharray', '3 3'); + svg.append('g').attr('class', 'rows-g'); + svg.append('g').attr('class', 'chart-axis x-axis'); + } + + svg.attr('width', totalW).attr('height', totalH); + + // Update static elements + svg.select('.npv-hdr').attr('x', LABEL_W + z).text('Rozdíl NPV oproti základní variantě'); + svg.select('.co2-hdr').attr('x', LABEL_W + chartW + 8).text('Úspora emisí'); + svg.select('.fuel-hdr').attr('x', LABEL_W + chartW + CO2_W + 8) + .text(isBuildings ? 'Úspora plynu' : 'Úspora PHM'); + svg.select('.zero-line') + .attr('x1', LABEL_W + z).attr('x2', LABEL_W + z) + .attr('y1', MARGIN.top - 4).attr('y2', totalH - MARGIN.bottom); + + // ── Row data join ───────────────────────────────────────────────────── + const rowSel = svg.select('.rows-g').selectAll('g.d-row').data(rows, d => d.label); + + // ENTER — appear at final position, fade in + const rowEnter = rowSel.enter().append('g').attr('class', 'd-row') + .attr('transform', (d, i) => `translate(0,${MARGIN.top + i * ROW_H})`) + .attr('opacity', 0); + + // Transparent full-width rect — catches clicks in the empty label area + rowEnter.append('rect').attr('class', 'row-bg') + .attr('x', 0).attr('y', 0).attr('height', ROW_H).attr('fill', 'transparent'); + + // foreignObject label (created once; text updated below) + const fo = rowEnter.append('foreignObject') + .attr('x', 4).attr('y', 2).attr('width', LABEL_W - 8).attr('height', ROW_H - 4); + const div = fo.append('xhtml:div') + .style('display', 'flex').style('flex-direction', 'column') + .style('justify-content', 'center').style('height', '100%'); + div.append('xhtml:span').attr('class', 'lbl-main') + .style('font-size', '14px').style('line-height', '1.3').style('color', '#444'); + div.append('xhtml:span').attr('class', 'lbl-base') + .style('font-size', '11px').style('color', '#bbb').style('margin-top', '1px'); + + // Uncertainty band + const bandG = rowEnter.append('g').attr('class', 'u-band'); + bandG.append('line').attr('class', 'band-line') + .attr('stroke', '#999').attr('stroke-width', 5) + .attr('stroke-linecap', 'round').attr('opacity', 0.35); + bandG.append('rect').attr('class', 'band-hit') + .attr('height', 16).attr('fill', 'transparent'); + + rowEnter.append('circle').attr('class', 'npv-dot').attr('r', 6) + .attr('stroke', 'white').attr('stroke-width', 2); + rowEnter.append('text').attr('class', 'npv-lbl') + .attr('text-anchor', 'middle').attr('font-size', '14px'); + rowEnter.append('g').attr('class', 'co2-g'); + rowEnter.append('g').attr('class', 'fuel-g'); + + // MERGE — animate y; update all content + const rowAll = rowSel.merge(rowEnter); + + rowAll.transition().duration(ANIM_MS).ease(d3.easeCubicInOut) + .attr('transform', (d, i) => `translate(0,${MARGIN.top + i * ROW_H})`) + .attr('opacity', 1); + + rowAll.each(function (row) { + const g = d3.select(this); + const mid = ROW_H / 2; + const color = row.npv.value >= 0 ? COLOR_FAVORABLE : COLOR_COSTLY; + const dotX = LABEL_W + xScale(row.npv.value); + + g.select('.row-bg').attr('width', totalW); + g.style('cursor', 'pointer') + .on('click', () => toggleRowDetail(container, row)); + + g.select('.lbl-main').text(row.label); + g.select('.lbl-base').text(row.baselineName ? 'vs. ' + row.baselineName : ''); + + const dominant = row.sensitivity && row.sensitivity.length + ? row.sensitivity.reduce((b, s) => (s.maxNpv - s.minNpv) > (b.maxNpv - b.minNpv) ? s : b) + : null; + const bandTip = [ + `Rozsah nejistoty: ${fmtCZK(row.npv.low)} až ${fmtCZK(row.npv.high)}`, + dominant ? `Největší vliv: ${dominant.param}` : null, + ].filter(Boolean).join('\n'); + const dotTip = [ + fmtCZK(row.npv.value), + dominant ? `Největší vliv: ${dominant.param}` : null, + ].filter(Boolean).join('\n'); + + g.select('.band-line') + .attr('x1', LABEL_W + xScale(row.npv.low)).attr('x2', LABEL_W + xScale(row.npv.high)) + .attr('y1', mid).attr('y2', mid); + g.select('.band-hit') + .attr('x', LABEL_W + xScale(row.npv.low)) + .attr('y', mid - 8) + .attr('width', Math.max(xScale(row.npv.high) - xScale(row.npv.low), 1)) + .on('mouseover', e => showTip(e, bandTip)) + .on('mousemove', moveTip) + .on('mouseout', hideTip); + + g.select('.npv-dot').attr('cx', dotX).attr('cy', mid).attr('fill', color) + .on('mouseover', e => showTip(e, dotTip)) + .on('mousemove', moveTip) + .on('mouseout', hideTip); + g.select('.npv-lbl').attr('x', dotX).attr('y', mid - 11).attr('fill', color) + .text(fmtCZK(row.npv.value)); + + // CO₂ squares — fixed global scale: 1 box = CO2_UNIT t, wrap at CO2_MAX_COLS + const co2G = g.select('.co2-g'); + co2G.selectAll('*').remove(); + const co2Color = (row.co2Saved !== null && row.co2Saved < 0) ? COLOR_COSTLY : COLOR_FAVORABLE; + const SQ = 7, SQ_GAP = 2, SQ_STEP = SQ + SQ_GAP; + const absVal = row.co2Saved !== null ? Math.abs(row.co2Saved) : 0; + // Round to nearest half-unit (12.5 t), then split into full + half boxes + const halfUnits = row.co2Saved !== null ? Math.round(absVal / (CO2_UNIT / 2)) : 0; + const nSq = Math.floor(halfUnits / 2); // full boxes (25 t each) + const hasHalf = (halfUnits % 2) === 1; // leftover half-box (12.5 t) + const sqX = LABEL_W + chartW + 8; + + // Grid layout: treat half-box as occupying one slot in row 0 + const nSlots = nSq + (hasHalf ? 1 : 0); + const nCols = Math.min(nSlots, CO2_MAX_COLS); + const nRows = nSlots > 0 ? Math.ceil(nSlots / CO2_MAX_COLS) : 0; + const gridH = nRows > 0 ? nRows * SQ + (nRows - 1) * SQ_GAP : 0; + const gridTop = mid - gridH / 2; + + for (let s = 0; s < nSq; s++) { + const col = s % CO2_MAX_COLS; + const rowIdx = Math.floor(s / CO2_MAX_COLS); + co2G.append('rect') + .attr('x', sqX + col * SQ_STEP) + .attr('y', gridTop + rowIdx * SQ_STEP) + .attr('width', SQ).attr('height', SQ) + .attr('fill', co2Color).attr('opacity', 0.7); + } + if (hasHalf) { + // Half-box in the next slot after all full boxes + const hCol = nSq % CO2_MAX_COLS; + const hRowIdx = Math.floor(nSq / CO2_MAX_COLS); + co2G.append('rect') + .attr('x', sqX + hCol * SQ_STEP) + .attr('y', gridTop + hRowIdx * SQ_STEP) + .attr('width', SQ / 2).attr('height', SQ) + .attr('fill', co2Color).attr('opacity', 0.7); + } + const textX = sqX + CO2_MAX_COLS * SQ_STEP + 4; + const co2Negative = row.co2Saved !== null && row.co2Saved < 0; + const co2RelStr = (!co2Negative && row.co2Saved) ? fmtCZKperT(-row.npv.value, row.co2Saved) : null; + co2G.append('text') + .attr('x', textX).attr('y', (co2RelStr || co2Negative) ? mid - 1 : mid + 5) + .attr('font-size', '13px').attr('fill', co2Color) + .text(fmtCO2(row.co2Saved)); + if (co2Negative) { + co2G.append('text') + .attr('x', textX).attr('y', mid + 13) + .attr('font-size', '10px').attr('fill', '#bbb') + .text('zvyšuje emise'); + } else if (co2RelStr) { + co2G.append('text') + .attr('x', textX).attr('y', mid + 13) + .attr('font-size', '10px').attr('fill', '#bbb') + .text(co2RelStr); + } + + // Fuel / gas column + const fuelG = g.select('.fuel-g'); + const fuelColX = LABEL_W + chartW + CO2_W + 8; + fuelG.selectAll('*').remove(); + let fuelAbsStr = '—', fuelRelStr = null; + if (row.sector === 'transport' && row.fuelSavings) { + fuelAbsStr = fmtL(row.fuelSavings.totalL); + if (row.npv) fuelRelStr = fmtCZKperL(-row.npv.value, row.fuelSavings.totalL); + } else if (row.sector !== 'transport' && row.gasSavings) { + fuelAbsStr = fmtMWh(row.gasSavings.totalMwh); + if (row.npv) fuelRelStr = fmtCZKperMWh(-row.npv.value, row.gasSavings.totalMwh); + } + fuelG.append('text') + .attr('x', fuelColX).attr('y', fuelRelStr ? mid - 1 : mid + 5) + .attr('font-size', '13px').attr('fill', '#555') + .text(fuelAbsStr); + if (fuelRelStr) { + fuelG.append('text') + .attr('x', fuelColX).attr('y', mid + 13) + .attr('font-size', '10px').attr('fill', '#bbb') + .text(fuelRelStr); + } + }); + + rowSel.exit().transition().duration(ANIM_MS).attr('opacity', 0).remove(); + + // ── X axis ──────────────────────────────────────────────────────────── + svg.select('.x-axis') + .attr('transform', `translate(${LABEL_W},${MARGIN.top + rows.length * ROW_H})`) + .call(d3.axisBottom(xScale).ticks(5).tickFormat(xAxisFmt)); + } + + // ── Group chart (transport: all measures in one category group) ────────── + // Renders every measure whose transport_category starts with `group` + // (e.g. "Nové" matches "Nové malé" and "Nové velké"). + // Row label = measure_name; secondary line = baseline name. + function renderGroupChart(container, section, group) { + const isBuildings = section === 'buildings'; + const allMeasures = isBuildings ? data.buildings_measures : data.transport_measures; + const catField = isBuildings ? 'building_category' : 'transport_category'; + + const entries = allMeasures.filter(m => + (m.measure_baseline_id || m.measure_baseline) && + m[catField] && m[catField].startsWith(group) + ); + if (!entries.length) { container.hidden = true; return; } + container.hidden = false; + + const rows = entries + .map(m => { + const calc = computeRow(m); + if (!calc) return null; + return { + label: m.measure_name, + baselineName: m.measure_baseline || null, + measureId: m.id, + npv: calc.npv, + co2Saved: calc.co2Saved, + capexDiff: calc.capexDiff, + sector: calc.sector, + gasSavings: calc.gasSavings, + fuelSavings: calc.fuelSavings, + sensitivity: calc.sensitivity, + }; + }) + .filter(Boolean) + .sort((a, b) => b.npv.value - a.npv.value); + + if (!rows.length) { container.hidden = true; return; } + + // Reuse the same SVG skeleton and rendering logic as renderMeasureChart. + const totalW = container.clientWidth || 640; + const chartW = Math.max(totalW - LABEL_W - CO2_W - FUEL_W - MARGIN.right, 120); + const totalH = rows.length * ROW_H + MARGIN.top + MARGIN.bottom; + + const xScale = d3.scaleLinear() + .domain(globalXDomain || [-500000, 500000]) + .range([0, chartW]); + const z = xScale(0); + + let svg = d3.select(container).select('svg'); + if (svg.empty()) { + svg = d3.select(container).append('svg').attr('role', 'img') + .style('font-family', 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'); + svg.append('text').attr('class', 'chart-col-header ctx-hdr').attr('x', 4).attr('y', 16).text('Opatření'); + svg.append('text').attr('class', 'chart-col-header npv-hdr').attr('text-anchor', 'middle').attr('y', 16); + svg.append('text').attr('class', 'chart-col-header co2-hdr').attr('text-anchor', 'start').attr('y', 16); + svg.append('text').attr('class', 'chart-col-header fuel-hdr').attr('text-anchor', 'start').attr('y', 16); + svg.append('line').attr('class', 'zero-line') + .attr('stroke', '#bbb').attr('stroke-width', 1).attr('stroke-dasharray', '3 3'); + svg.append('g').attr('class', 'rows-g'); + svg.append('g').attr('class', 'chart-axis x-axis'); + } + + svg.attr('width', totalW).attr('height', totalH); + svg.select('.npv-hdr').attr('x', LABEL_W + z).text('Rozdíl NPV oproti základní variantě'); + svg.select('.co2-hdr').attr('x', LABEL_W + chartW + 8).text('Úspora emisí'); + svg.select('.fuel-hdr').attr('x', LABEL_W + chartW + CO2_W + 8).text('Úspora PHM'); + svg.select('.zero-line') + .attr('x1', LABEL_W + z).attr('x2', LABEL_W + z) + .attr('y1', MARGIN.top - 4).attr('y2', totalH - MARGIN.bottom); + + const rowSel = svg.select('.rows-g').selectAll('g.d-row').data(rows, d => d.label); + + const rowEnter = rowSel.enter().append('g').attr('class', 'd-row') + .attr('transform', (d, i) => `translate(0,${MARGIN.top + i * ROW_H})`) + .attr('opacity', 0); + + rowEnter.append('rect').attr('class', 'row-bg') + .attr('x', 0).attr('y', 0).attr('height', ROW_H).attr('fill', 'transparent'); + + const fo = rowEnter.append('foreignObject') + .attr('x', 4).attr('y', 2).attr('width', LABEL_W - 8).attr('height', ROW_H - 4); + const div = fo.append('xhtml:div') + .style('display', 'flex').style('flex-direction', 'column') + .style('justify-content', 'center').style('height', '100%'); + div.append('xhtml:span').attr('class', 'lbl-main') + .style('font-size', '14px').style('line-height', '1.3').style('color', '#444'); + div.append('xhtml:span').attr('class', 'lbl-base') + .style('font-size', '11px').style('color', '#bbb').style('margin-top', '1px'); + + const bandG = rowEnter.append('g').attr('class', 'u-band'); + bandG.append('line').attr('class', 'band-line') + .attr('stroke', '#999').attr('stroke-width', 5) + .attr('stroke-linecap', 'round').attr('opacity', 0.35); + bandG.append('rect').attr('class', 'band-hit') + .attr('height', 16).attr('fill', 'transparent'); + + rowEnter.append('circle').attr('class', 'npv-dot').attr('r', 6) + .attr('stroke', 'white').attr('stroke-width', 2); + rowEnter.append('text').attr('class', 'npv-lbl') + .attr('text-anchor', 'middle').attr('font-size', '14px'); + rowEnter.append('g').attr('class', 'co2-g'); + rowEnter.append('g').attr('class', 'fuel-g'); + + const rowAll = rowSel.merge(rowEnter); + rowAll.transition().duration(ANIM_MS).ease(d3.easeCubicInOut) + .attr('transform', (d, i) => `translate(0,${MARGIN.top + i * ROW_H})`) + .attr('opacity', 1); + + rowAll.each(function (row) { + const g = d3.select(this); + const mid = ROW_H / 2; + const color = row.npv.value >= 0 ? COLOR_FAVORABLE : COLOR_COSTLY; + const dotX = LABEL_W + xScale(row.npv.value); + + g.select('.row-bg').attr('width', totalW); + g.style('cursor', 'pointer').on('click', () => toggleRowDetail(container, row)); + g.select('.lbl-main').text(row.label); + g.select('.lbl-base').text(row.baselineName ? 'vs. ' + row.baselineName : ''); + + const dominant = row.sensitivity && row.sensitivity.length + ? row.sensitivity.reduce((b, s) => (s.maxNpv - s.minNpv) > (b.maxNpv - b.minNpv) ? s : b) + : null; + const bandTip = [ + `Rozsah nejistoty: ${fmtCZK(row.npv.low)} až ${fmtCZK(row.npv.high)}`, + dominant ? `Největší vliv: ${dominant.param}` : null, + ].filter(Boolean).join('\n'); + const dotTip = [ + fmtCZK(row.npv.value), + dominant ? `Největší vliv: ${dominant.param}` : null, + ].filter(Boolean).join('\n'); + + g.select('.band-line') + .attr('x1', LABEL_W + xScale(row.npv.low)).attr('x2', LABEL_W + xScale(row.npv.high)) + .attr('y1', mid).attr('y2', mid); + g.select('.band-hit') + .attr('x', LABEL_W + xScale(row.npv.low)).attr('y', mid - 8) + .attr('width', Math.max(xScale(row.npv.high) - xScale(row.npv.low), 1)) + .on('mouseover', e => showTip(e, bandTip)).on('mousemove', moveTip).on('mouseout', hideTip); + g.select('.npv-dot').attr('cx', dotX).attr('cy', mid).attr('fill', color) + .on('mouseover', e => showTip(e, dotTip)).on('mousemove', moveTip).on('mouseout', hideTip); + g.select('.npv-lbl').attr('x', dotX).attr('y', mid - 11).attr('fill', color) + .text(fmtCZK(row.npv.value)); + + // CO₂ squares + const co2G = g.select('.co2-g'); + co2G.selectAll('*').remove(); + const co2Color = (row.co2Saved !== null && row.co2Saved < 0) ? COLOR_COSTLY : COLOR_FAVORABLE; + const SQ = 7, SQ_GAP = 2, SQ_STEP = SQ + SQ_GAP; + const absVal = row.co2Saved !== null ? Math.abs(row.co2Saved) : 0; + const halfUnits = row.co2Saved !== null ? Math.round(absVal / (CO2_UNIT / 2)) : 0; + const nSq = Math.floor(halfUnits / 2); + const hasHalf = (halfUnits % 2) === 1; + const sqX = LABEL_W + chartW + 8; + const nSlots = nSq + (hasHalf ? 1 : 0); + const nRows = nSlots > 0 ? Math.ceil(nSlots / CO2_MAX_COLS) : 0; + const gridH = nRows > 0 ? nRows * SQ + (nRows - 1) * SQ_GAP : 0; + const gridTop = mid - gridH / 2; + for (let s = 0; s < nSq; s++) { + co2G.append('rect') + .attr('x', sqX + (s % CO2_MAX_COLS) * SQ_STEP) + .attr('y', gridTop + Math.floor(s / CO2_MAX_COLS) * SQ_STEP) + .attr('width', SQ).attr('height', SQ).attr('fill', co2Color).attr('opacity', 0.7); + } + if (hasHalf) { + co2G.append('rect') + .attr('x', sqX + (nSq % CO2_MAX_COLS) * SQ_STEP) + .attr('y', gridTop + Math.floor(nSq / CO2_MAX_COLS) * SQ_STEP) + .attr('width', SQ / 2).attr('height', SQ).attr('fill', co2Color).attr('opacity', 0.7); + } + const textX = sqX + CO2_MAX_COLS * SQ_STEP + 4; + const co2Negative = row.co2Saved !== null && row.co2Saved < 0; + const co2RelStr = (!co2Negative && row.co2Saved) ? fmtCZKperT(-row.npv.value, row.co2Saved) : null; + co2G.append('text').attr('x', textX).attr('y', (co2RelStr || co2Negative) ? mid - 1 : mid + 5) + .attr('font-size', '13px').attr('fill', co2Color).text(fmtCO2(row.co2Saved)); + if (co2Negative) { + co2G.append('text').attr('x', textX).attr('y', mid + 13) + .attr('font-size', '10px').attr('fill', '#bbb').text('zvyšuje emise'); + } else if (co2RelStr) { + co2G.append('text').attr('x', textX).attr('y', mid + 13) + .attr('font-size', '10px').attr('fill', '#bbb').text(co2RelStr); + } + + // Fuel column + const fuelG = g.select('.fuel-g'); + const fuelColX = LABEL_W + chartW + CO2_W + 8; + fuelG.selectAll('*').remove(); + let fuelAbsStr = '—', fuelRelStr = null; + if (row.fuelSavings) { + fuelAbsStr = fmtL(row.fuelSavings.totalL); + if (row.npv) fuelRelStr = fmtCZKperL(-row.npv.value, row.fuelSavings.totalL); + } + fuelG.append('text').attr('x', fuelColX).attr('y', fuelRelStr ? mid - 1 : mid + 5) + .attr('font-size', '13px').attr('fill', '#555').text(fuelAbsStr); + if (fuelRelStr) { + fuelG.append('text').attr('x', fuelColX).attr('y', mid + 13) + .attr('font-size', '10px').attr('fill', '#bbb').text(fuelRelStr); + } + }); + + rowSel.exit().transition().duration(ANIM_MS).attr('opacity', 0).remove(); + + svg.select('.x-axis') + .attr('transform', `translate(${LABEL_W},${MARGIN.top + rows.length * ROW_H})`) + .call(d3.axisBottom(xScale).ticks(5).tickFormat(xAxisFmt)); + } + + // ── DOM section reordering ─────────────────────────────────────────────── + // Reorders the H2 + chart-div pairs inside a section to match a given + // measure-name order. Called once at init; never called again. + function reorderSection(sectionId, order) { + if (!order || !order.length) return; + + // Collect all (h2, chartDiv) pairs for this section + const charts = Array.from( + document.querySelectorAll(`.measure-chart[data-section="${sectionId}"]`) + ); + const pairs = charts.map(chart => { + const h2 = chart.previousElementSibling; + return (h2 && h2.tagName === 'H2') ? { name: chart.dataset.measure, h2, chart } : null; + }).filter(Boolean); + + if (!pairs.length) return; + + // Sort pairs into the desired order + const rank = new Map(order.map((name, i) => [name, i])); + pairs.sort((a, b) => + (rank.has(a.name) ? rank.get(a.name) : Infinity) - + (rank.has(b.name) ? rank.get(b.name) : Infinity) + ); + + // Find the H1 anchor for this section ('Budovy' / 'Doprava') + const sectionLabel = sectionId === 'buildings' ? 'Budovy' : 'Doprava'; + const parent = pairs[0].h2.parentNode; + const h1 = Array.from(parent.children) + .find(el => el.tagName === 'H1' && el.textContent.trim() === sectionLabel); + if (!h1) return; + + // Re-insert pairs in sorted order immediately after the H1 + let anchor = h1; + for (const { h2, chart } of pairs) { + anchor.after(h2); + h2.after(chart); + anchor = chart; + } + } + + // ── Row detail panel ───────────────────────────────────────────────────── + function toggleRowDetail(container, row) { + const existing = container.querySelector('.row-detail'); + const wasOpen = existing && existing.dataset.rowLabel === row.label; + if (existing) existing.remove(); + if (wasOpen) return; + + let result; + try { + result = CostsBenefits.calculate({ + measureId: row.measureId, + data, + discountRate: state.discountRate / 100, + carbonPriceEur: state.carbonPrice, + priceScenario: state.fuelScenario, + }); + } catch (e) { return; } + + renderDetailPanel(container, row, result); + } + + function renderDetailPanel(container, row, result) { + const panel = document.createElement('div'); + panel.className = 'row-detail'; + panel.dataset.rowLabel = row.label; + + // Header + const hdr = document.createElement('div'); + hdr.className = 'row-detail-header'; + const title = document.createElement('span'); + title.className = 'row-detail-title'; + title.innerHTML = `<strong>${row.label}</strong>${row.baselineName ? ` <span class="row-detail-vs">vs. ${row.baselineName}</span>` : ''}`; + const closeBtn = document.createElement('button'); + closeBtn.className = 'row-detail-close'; + closeBtn.textContent = '✕'; + closeBtn.onclick = () => panel.remove(); + hdr.appendChild(title); + hdr.appendChild(closeBtn); + panel.appendChild(hdr); + + // Stats grid (3 rows × 3 cols + row labels) + const savedT = result.emissionSavings ? -result.emissionSavings.totalT : null; + const extraCapex = -result.capexDiff; // positive = measure costs more than baseline + + function makeStat(label, value, extraClass, tip, sub) { + const d = document.createElement('div'); + d.className = 'row-detail-stat' + (extraClass ? ' ' + extraClass : ''); + const subHtml = sub ? `<span class="stat-sub">${sub}</span>` : ''; + d.innerHTML = `<span class="stat-lbl">${label}</span><span class="stat-val">${value}</span>${subHtml}`; + if (tip) { + d.addEventListener('mouseover', e => showTip(e, tip)); + d.addEventListener('mousemove', moveTip); + d.addEventListener('mouseout', hideTip); + } + return d; + } + function makeRowLbl(text) { + const d = document.createElement('div'); + d.className = 'stats-row-lbl'; + d.textContent = text; + return d; + } + function makeColHdr(text, extraClass) { + const d = document.createElement('div'); + d.className = 'stats-col-hdr-cell' + (extraClass ? ' ' + extraClass : ''); + d.textContent = text; + return d; + } + + const grid = document.createElement('div'); + grid.className = 'stats-grid'; + + // Column header row + grid.appendChild(document.createElement('div')); // empty corner + grid.appendChild(document.createElement('div')); // col 1: no header + grid.appendChild(makeColHdr('Kč / NPV', 'stats-cell-npv')); + grid.appendChild(makeColHdr('Kč / diff CAPEX', 'stats-cell-capex')); + + // Row 1: Money + const payVal = result.paybackYear != null ? result.paybackYear + ' let' : '—'; + grid.appendChild(makeRowLbl('Peníze')); + grid.appendChild(makeStat('Návratnost', payVal)); + grid.appendChild(makeStat('NPV', fmtCZK(result.npv), 'stats-cell-npv')); + grid.appendChild(makeStat('Rozdíl CAPEX', fmtCZK(result.capexDiff), 'stats-cell-capex')); + + // Row 2: Emissions + if (savedT != null) { + grid.appendChild(makeRowLbl('Emise')); + grid.appendChild(makeStat('Úspora emisí', fmtCO2(savedT))); + grid.appendChild(makeStat('Kč/t CO₂', + fmtCZKperT(-result.npv, savedT), 'stats-cell-npv')); + grid.appendChild(makeStat('Kč/t CO₂', + fmtCZKperT(extraCapex, savedT), 'stats-cell-capex')); + } + + // Row 3: natural gas (buildings) or liquid fuel/PHM (transport) + { + if (result.sector === 'transport') { + const fs = result.fuelSavings; + const fsTotalL = fs ? fs.totalL : null; + const fsAnnL = fs ? fs.annualL : null; + const annSubFs = fsAnnL != null ? '(' + fmtL(fsAnnL) + '/rok)' : null; + grid.appendChild(makeRowLbl('PHM')); + grid.appendChild(makeStat('Úspora PHM celkem', fsTotalL != null ? fmtL(fsTotalL) : '—', + null, null, annSubFs)); + grid.appendChild(makeStat('Kč/l', + fsTotalL == null ? '—' : fmtCZKperL(-result.npv, fsTotalL), + 'stats-cell-npv')); + grid.appendChild(makeStat('Kč/l', + fsTotalL == null ? '—' : fmtCZKperL(extraCapex, fsTotalL), + 'stats-cell-capex')); + } else { + const gs = result.gasSavings; + const gsTotalMwh = gs ? gs.totalMwh : null; + const gsAnnMwh = gs ? gs.annualMwh : null; + const annSubGs = gsAnnMwh != null ? '(' + fmtMWh(gsAnnMwh) + '/rok)' : null; + grid.appendChild(makeRowLbl('Plyn')); + grid.appendChild(makeStat('Úspora plynu celkem', gsTotalMwh != null ? fmtMWh(gsTotalMwh) : '—', + null, null, annSubGs)); + grid.appendChild(makeStat('Kč/MWh', + gsTotalMwh == null ? '—' : fmtCZKperMWh(-result.npv, gsTotalMwh), + 'stats-cell-npv')); + grid.appendChild(makeStat('Kč/MWh', + gsTotalMwh == null ? '—' : fmtCZKperMWh(extraCapex, gsTotalMwh), + 'stats-cell-capex')); + } + } + + panel.appendChild(grid); + + // NPV timeline chart + const timelineEl = document.createElement('div'); + if ((result.yearByYear || []).length) { + const tlHdr = document.createElement('div'); + tlHdr.className = 'row-detail-section-label'; + tlHdr.textContent = 'Kumulativní NPV v čase'; + panel.appendChild(tlHdr); + timelineEl.className = 'row-detail-timeline'; + panel.appendChild(timelineEl); + } + + // Tornado chart + const tornEl = document.createElement('div'); + const sens = result.sensitivity || []; + if (sens.length) { + const tornHdr = document.createElement('div'); + tornHdr.className = 'row-detail-section-label'; + tornHdr.textContent = 'Citlivostní analýza'; + panel.appendChild(tornHdr); + tornEl.className = 'row-detail-tornado'; + panel.appendChild(tornEl); + } + + // Append panel before rendering charts so clientWidth is valid + container.appendChild(panel); + if (timelineEl.className) renderNpvTimeline(timelineEl, result); + if (tornEl.className) renderTornado(tornEl, result); + } + + function renderNpvTimeline(container, result) { + const FONT = 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'; + const rows = result.yearByYear || []; + if (!rows.length) return; + + const margin = { top: 16, right: 12, bottom: 32, left: 64 }; + const chartH = 110; + + const years = rows.map(r => r.year); + + // Fixed bar step so every year occupies the same width regardless of lifetime. + const BAR_STEP = 20; // px per year slot (bar + gap) + const chartW = years.length * BAR_STEP; + const totalW = margin.left + chartW + margin.right; + const totalH = chartH + margin.top + margin.bottom; + + const [vMin, vMax] = d3.extent(rows, r => r.cumDisc); + + const xScale = d3.scaleBand().domain(years).range([0, chartW]).padding(0.12); + const yScale = d3.scaleLinear() + .domain([Math.min(vMin, 0), Math.max(vMax, 0)]).nice() + .range([chartH, 0]); + + const svg = d3.select(container).append('svg') + .attr('width', totalW).attr('height', totalH) + .style('font-family', FONT) + .style('display', 'block'); + + const chart = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + + // Zero baseline + const z = yScale(0); + chart.append('line') + .attr('x1', 0).attr('x2', chartW).attr('y1', z).attr('y2', z) + .attr('stroke', '#ccc').attr('stroke-width', 1); + + // Payback year marker + if (result.paybackYear != null && xScale(result.paybackYear) != null) { + const px = xScale(result.paybackYear) + xScale.bandwidth() / 2; + chart.append('line') + .attr('x1', px).attr('x2', px).attr('y1', 0).attr('y2', chartH) + .attr('stroke', '#aaa').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + chart.append('text').attr('x', px + 3).attr('y', 10) + .attr('font-size', '10px').attr('fill', '#aaa').text('Návratnost'); + } + + // Bars + rows.forEach(row => { + const color = row.cumDisc >= 0 ? COLOR_FAVORABLE : COLOR_COSTLY; + chart.append('rect') + .attr('x', xScale(row.year)) + .attr('y', Math.min(yScale(row.cumDisc), z)) + .attr('width', xScale.bandwidth()) + .attr('height', Math.max(Math.abs(yScale(row.cumDisc) - z), 1)) + .attr('fill', color).attr('opacity', 0.75); + }); + + // X axis — thin out labels on longer lifetimes + const lifetime = years[years.length - 1] || 0; + const step = lifetime <= 15 ? 1 : lifetime <= 30 ? 5 : 10; + const tickValues = years.filter(y => y % step === 0); + chart.append('g') + .attr('transform', `translate(0,${chartH})`).attr('class', 'chart-axis') + .call(d3.axisBottom(xScale).tickValues(tickValues).tickFormat(d => d)); + chart.append('text') + .attr('x', 0).attr('y', chartH + 28) + .attr('text-anchor', 'start').attr('font-size', '11px').attr('fill', '#999') + .text('Rok od investice →'); + + // Y axis + chart.append('g').attr('class', 'chart-axis') + .call(d3.axisLeft(yScale).ticks(4).tickFormat(xAxisFmt)); + } + + function renderTornado(container, result) { + const FONT = 'Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif'; + const sens = [...(result.sensitivity || [])] + .sort((a, b) => (b.maxNpv - b.minNpv) - (a.maxNpv - a.minNpv)); + if (!sens.length) return; + + const width = container.clientWidth || 560; + const TROW_H = 26; + const TLBL_W = 220; + const TM = { top: 22, right: 12, bottom: 28 }; + const totalH = sens.length * TROW_H + TM.top + TM.bottom; + const chartW = Math.max(width - TLBL_W - TM.right, 80); + + const baseNpv = result.npv; + const allVals = [0, baseNpv, ...sens.flatMap(s => [s.minNpv, s.maxNpv])]; + const [xMin, xMax] = d3.extent(allVals); + const pad = (xMax - xMin) * 0.06 || 10000; + const xScale = d3.scaleLinear() + .domain([Math.min(xMin - pad, 0), Math.max(xMax + pad, 0)]) + .nice().range([0, chartW]); + + const svg = d3.select(container).append('svg') + .attr('width', width).attr('height', totalH) + .style('font-family', FONT); + + const chart = svg.append('g').attr('transform', `translate(${TLBL_W},0)`); + + // Zero line + const z = xScale(0); + chart.append('line') + .attr('x1', z).attr('x2', z) + .attr('y1', TM.top - 2).attr('y2', totalH - TM.bottom) + .attr('stroke', '#aaa').attr('stroke-width', 1).attr('stroke-dasharray', '4 3'); + chart.append('text').attr('x', z).attr('y', TM.top - 4) + .attr('text-anchor', 'middle').attr('font-size', '10px').attr('fill', '#aaa') + .text('NPV = 0'); + + sens.forEach((s, i) => { + const midY = TM.top + i * TROW_H + TROW_H / 2; + const barH = TROW_H * 0.55; + + // Parameter label + svg.append('text').attr('x', TLBL_W - 6).attr('y', midY + 4) + .attr('text-anchor', 'end').attr('font-size', '12px').attr('fill', '#555') + .text(s.param); + + // Downside bar: baseNpv → minNpv (worse direction) + if (s.minNpv !== baseNpv) { + chart.append('rect') + .attr('x', xScale(Math.min(baseNpv, s.minNpv))) + .attr('y', midY - barH / 2) + .attr('width', Math.abs(xScale(s.minNpv) - xScale(baseNpv))) + .attr('height', barH) + .attr('fill', COLOR_COSTLY).attr('opacity', 0.75); + } + // Upside bar: baseNpv → maxNpv (better direction) + if (s.maxNpv !== baseNpv) { + chart.append('rect') + .attr('x', xScale(Math.min(baseNpv, s.maxNpv))) + .attr('y', midY - barH / 2) + .attr('width', Math.abs(xScale(s.maxNpv) - xScale(baseNpv))) + .attr('height', barH) + .attr('fill', COLOR_FAVORABLE).attr('opacity', 0.75); + } + }); + + // X axis + chart.append('g') + .attr('transform', `translate(0,${totalH - TM.bottom})`) + .attr('class', 'chart-axis') + .call(d3.axisBottom(xScale).ticks(4).tickFormat(xAxisFmt)); + } + + // ── Render all ──────────────────────────────────────────────────────────── + function renderAll() { + // Close any open detail panels — params changed, values would be stale + document.querySelectorAll('.row-detail').forEach(el => el.remove()); + + const summaryEl = document.getElementById('summary-chart'); + if (summaryEl) renderSummaryChart(summaryEl); + + document.querySelectorAll('.measure-chart[data-section]').forEach(el => { + if (el.dataset.group) { + renderGroupChart(el, el.dataset.section, el.dataset.group); + } else { + renderMeasureChart(el, el.dataset.section, el.dataset.measure); + } + }); + } + + // ── Init ───────────────────────────────────────────────────────────────── + function init() { + globalXDomain = computeGlobalDomain(); + + // Compute fixed ordering using default params (state is at defaults here). + fixedBuildingOrder = getSummaryRows('buildings').map(r => r.name); + fixedTransportOrder = getSummaryRows('transport').map(r => r.name); + reorderSection('buildings', fixedBuildingOrder); + // Transport uses fixed group headings (Nové / Ojeté) — no per-measure reordering needed. + + setupControls(); + renderAll(); + window.addEventListener('resize', renderAll); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); \ No newline at end of file diff --git a/assets-local/js/costs-benefits-calculator.js b/assets-local/js/costs-benefits-calculator.js new file mode 100644 index 000000000..f70fb57be --- /dev/null +++ b/assets-local/js/costs-benefits-calculator.js @@ -0,0 +1,564 @@ +// ============================================================================= +// Costs & Benefits Calculator +// +// Calculates NPV, emission savings, energy savings (buildings), and sensitivity +// analysis for pairs of decarbonisation measures vs. their fossil baselines. +// +// Expected input: data object from _data/costs-and-benefits.yaml (via Jekyll). +// +// Usage: +// const result = CostsBenefits.calculate({ +// measureId: 20, +// data: window.CB_DATA, // {{ site.data['costs-and-benefits'] | jsonify }} +// discountRate: 0.03, // e.g. 0.00, 0.03, 0.07 +// carbonPriceEur: 60, // e.g. 0, 60, 100, 200 +// priceScenario: 'CP', // 'CP', 'NZ', or 'CP_EC' +// exchangeRate: 23, // CZK per EUR +// electricityPriceFactor: 1.0, // transport EV charging scenario multiplier +// // (from electricity_price_scenarios in the YAML) +// // 0.5 = home solar, 1.0 = home grid, 2.0 = fast charger +// }); +// +// ============================================================================= + +const CostsBenefits = (() => { + 'use strict'; + + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + const DEFAULT_DISCOUNT_RATE = 0.03; + const DEFAULT_CARBON_PRICE_EUR = 60; + const DEFAULT_PRICE_SCENARIO = 'CP'; + const DEFAULT_EXCHANGE_RATE = 23; + + // Sensitivity analysis parameter labels (mirrors R make_variants) + const SENSITIVITY_PARAM_ORDER = [ + // 'Cena uhlíku', + // 'Diskontní míra', + 'Investiční náklady opatření', + 'Investiční náklady základní varianty', + 'Cena elektřiny', + 'Cena zemního plynu', + 'Cena hnědého uhlí', + 'Cena biomasy', + 'Cena benzínu', + 'Cena nafty', + ]; + + const FUEL_PRICE_LABEL = { + Electricity: 'Cena elektřiny', + Gas: 'Cena zemního plynu', + Lignite: 'Cena hnědého uhlí', + Biomass: 'Cena biomasy', + Petrol: 'Cena benzínu', + Diesel: 'Cena nafty', + }; + + // --------------------------------------------------------------------------- + // Data helpers + // --------------------------------------------------------------------------- + + function getPricesForScenario(data, scenario) { + const entries = data.fuel_scenarios.filter(s => s.scenario === scenario); + if (!entries.length) throw new Error(`Unknown price scenario: ${scenario}`); + if (entries.length === 1) return entries[0].prices; + + // Multiple entries per scenario (one per fuel type): merge all into a single + // prices array keyed by year_investment, with later entries' fields overwriting earlier ones. + const yearMap = {}; + for (const entry of entries) { + for (const p of entry.prices) { + yearMap[p.year_investment] = Object.assign(yearMap[p.year_investment] || {}, p); + } + } + return Object.values(yearMap).sort((a, b) => a.year_investment - b.year_investment); + } + + function getPricesForYear(scenarioPrices, yearInvestment) { + return scenarioPrices.find(p => p.year_investment === yearInvestment) || null; + } + + function getEmissionFactor(fuel, yearPrices, emissionFactors) { + if (fuel === 'Electricity') return yearPrices.electricity_emission_factor_kg_mwh; + const entry = emissionFactors.find(f => f.fuel === fuel); + return entry ? entry.emission_factor : 0; + } + + function getFuelPrice(yearPrices, fuel) { + const key = fuel.toLowerCase(); + return yearPrices[key] !== undefined ? yearPrices[key] : 0; + } + + // Precise fuel heat demand (MWh) computed from source parameters rather than the + // rounded integer stored in demand_heat_measure_mwh, which can drift ~5 % from + // the exact fraction used in the Excel reference model. + function computeHeatDemand(measure) { + if (measure.demand_heat_building_mwh != null && measure.efficiency) { + const savings = measure.energy_savings || 0; + return measure.demand_heat_building_mwh * (1 - savings) / measure.efficiency; + } + return measure.demand_heat_measure_mwh; + } + + // Total CAPEX for a measure (buildings split into three parts; transport has capex_czk) + function getCapex(measure) { + return (measure.capex_technology_czk || 0) + + (measure.capex_installation_czk || 0) + + (measure.capex_preparation_czk || 0) + + (measure.capex_czk || 0); + } + + function getSector(measure) { + return measure.building_category ? 'buildings' : 'transport'; + } + + // --------------------------------------------------------------------------- + // Annual OPEX calculation + // --------------------------------------------------------------------------- + + function calcBuildingOpex(measure, yearPrices, sccCzk, emissionFactors) { + const heatDemand = computeHeatDemand(measure); + const priceHeat = getFuelPrice(yearPrices, measure.fuel); + const priceEl = yearPrices.electricity; + const efFuel = getEmissionFactor(measure.fuel, yearPrices, emissionFactors); + const efEl = yearPrices.electricity_emission_factor_kg_mwh; + + const energyCost = heatDemand * priceHeat + + measure.demand_electricity_measure_mwh * priceEl; + // Carbon price applies only to direct fossil fuel combustion, not to electricity + // (electricity price already embeds ETS costs at the generation level). + // This covers both auxiliary electricity (demand_electricity_mwh) and measures + // that use electricity as their primary heating fuel (e.g. heat pump, electric boiler). + const efFuelForCarbon = measure.fuel === 'Electricity' ? 0 : efFuel; + const carbonCost = sccCzk * heatDemand * efFuelForCarbon / 1000; + return energyCost + measure.opex_maintenance_czk + carbonCost; + } + + function calcTransportOpex(measure, yearPrices, sccCzk, emissionFactors, electricityPriceFactor) { + const consumption = measure.demand_energy_per_100km * measure.mileage / 100; + let priceF = getFuelPrice(yearPrices, measure.fuel); + // Scale electricity price by the charging-scenario factor from electricity_price_scenarios + // (home grid / home solar / fast charger). Has no effect on ICE/hybrid vehicles. + if (measure.fuel === 'Electricity') priceF *= electricityPriceFactor; + // Carbon price applies only to fossil fuels, not electricity. + const ef = measure.fuel === 'Electricity' + ? 0 + : getEmissionFactor(measure.fuel, yearPrices, emissionFactors); + const carbonCost = sccCzk * consumption * ef / 1000; + return consumption * priceF + + measure.opex_maintenance_czk + + (measure.opex_insurance_czk || 0) + + carbonCost; + } + + function calcOpex(measure, sector, yearPrices, sccCzk, emissionFactors, electricityPriceFactor) { + return sector === 'buildings' + ? calcBuildingOpex(measure, yearPrices, sccCzk, emissionFactors) + : calcTransportOpex(measure, yearPrices, sccCzk, emissionFactors, electricityPriceFactor); + } + + // --------------------------------------------------------------------------- + // Core NPV computation + // --------------------------------------------------------------------------- + // Options: + // discountRate – real discount rate (0.03 = 3 %) + // carbonPriceEur – social cost of carbon in EUR/t CO2 + // exchangeRate – CZK per EUR + // electricityPriceFactor– EV charging scenario multiplier (transport only, default 1.0) + // capexBlMult – multiplier for baseline CAPEX (default 1.0, sensitivity only) + // capexMeasMult – multiplier for measure CAPEX (default 1.0, sensitivity only) + // fuelPriceMult – multiplier for a specific fuel price (sensitivity only) + // fuelPriceName – which fuel to scale ('Gas', 'Electricity', etc.) + + function computeNpv(baseline, measure, sector, scenarioPrices, opts, emissionFactors) { + const discountRate = opts.discountRate !== undefined ? opts.discountRate : DEFAULT_DISCOUNT_RATE; + const carbonPriceEur = opts.carbonPriceEur !== undefined ? opts.carbonPriceEur : DEFAULT_CARBON_PRICE_EUR; + const exchangeRate = opts.exchangeRate !== undefined ? opts.exchangeRate : DEFAULT_EXCHANGE_RATE; + const electricityPriceFactor= opts.electricityPriceFactor!== undefined ? opts.electricityPriceFactor: 1.0; + const capexBlMult = opts.capexBlMult !== undefined ? opts.capexBlMult : 1.0; + const capexMeasMult = opts.capexMeasMult !== undefined ? opts.capexMeasMult : 1.0; + const fuelPriceMult = opts.fuelPriceMult !== undefined ? opts.fuelPriceMult : 1.0; + const fuelPriceName = opts.fuelPriceName || null; + + const sccCzkDefault = carbonPriceEur * exchangeRate; + const lifetime = Math.min(baseline.lifetime, measure.lifetime); + const capexDiff = getCapex(baseline) * capexBlMult - getCapex(measure) * capexMeasMult; + + let opexSum = 0; + + for (let t = 1; t <= lifetime; t++) { + const rawPrices = getPricesForYear(scenarioPrices, t); + if (!rawPrices) continue; + + // Apply optional fuel price multiplier for sensitivity analysis + const yearPrices = applyFuelPriceMult(rawPrices, fuelPriceName, fuelPriceMult); + + // Use per-year NZ carbon price trajectory when available + const sccCzk = yearPrices.carbon_price_eur_nz != null + ? yearPrices.carbon_price_eur_nz * exchangeRate + : sccCzkDefault; + + const discFactor = discountRate === 0 ? 1 : 1 / Math.pow(1 + discountRate, t); + + const opexBl = calcOpex(baseline, sector, yearPrices, sccCzk, emissionFactors, electricityPriceFactor); + const opexMeas = calcOpex(measure, sector, yearPrices, sccCzk, emissionFactors, electricityPriceFactor); + + opexSum += (opexBl - opexMeas) * discFactor; + } + + return Math.round(capexDiff + opexSum); + } + + // Returns prices for a single year with one fuel price scaled by `mult` + function applyFuelPriceMult(yearPrices, fuelName, mult) { + if (!fuelName || mult === 1.0) return yearPrices; + const copy = Object.assign({}, yearPrices); + const key = fuelName.toLowerCase(); + if (copy[key] !== undefined) copy[key] = copy[key] * mult; + return copy; + } + + // --------------------------------------------------------------------------- + // Year-by-year table (used for payback and charts) + // --------------------------------------------------------------------------- + + function buildYearByYear(baseline, measure, sector, scenarioPrices, opts, emissionFactors) { + const discountRate = opts.discountRate !== undefined ? opts.discountRate : DEFAULT_DISCOUNT_RATE; + const carbonPriceEur = opts.carbonPriceEur !== undefined ? opts.carbonPriceEur : DEFAULT_CARBON_PRICE_EUR; + const exchangeRate = opts.exchangeRate !== undefined ? opts.exchangeRate : DEFAULT_EXCHANGE_RATE; + const electricityPriceFactor= opts.electricityPriceFactor!== undefined ? opts.electricityPriceFactor: 1.0; + const sccCzkDefault = carbonPriceEur * exchangeRate; + const lifetime = Math.min(baseline.lifetime, measure.lifetime); + const capexDiff = getCapex(baseline) - getCapex(measure); + + const rows = []; + let cumDisc = capexDiff; // year 0: CAPEX difference (not discounted) + + // Year 0 – embedded emissions only + rows.push({ + year: 0, + opexBaseline: 0, + opexMeasure: 0, + opexDiff: 0, + discFactor: 1, + opexDiffDisc: capexDiff, + cumDisc: capexDiff, + emissionsBaseline: baseline.emissions_embedded_kg, + emissionsMeasure: measure.emissions_embedded_kg, + emissionsDiff: measure.emissions_embedded_kg - baseline.emissions_embedded_kg, + energyBaseline: null, + energyMeasure: null, + energyDiff: null, + }); + + for (let t = 1; t <= lifetime; t++) { + const yearPrices = getPricesForYear(scenarioPrices, t); + if (!yearPrices) continue; + + const sccCzk = yearPrices.carbon_price_eur_nz != null + ? yearPrices.carbon_price_eur_nz * exchangeRate + : sccCzkDefault; + + const discFactor = discountRate === 0 ? 1 : 1 / Math.pow(1 + discountRate, t); + + const opexBl = calcOpex(baseline, sector, yearPrices, sccCzk, emissionFactors, electricityPriceFactor); + const opexMeas = calcOpex(measure, sector, yearPrices, sccCzk, emissionFactors, electricityPriceFactor); + + const opexDiff = opexBl - opexMeas; + const opexDiffDisc = opexDiff * discFactor; + cumDisc += opexDiffDisc; + + // Operational emissions (kg CO2) + const efFuelBl = getEmissionFactor(baseline.fuel, yearPrices, emissionFactors); + const efFuelMeas = getEmissionFactor(measure.fuel, yearPrices, emissionFactors); + const efEl = yearPrices.electricity_emission_factor_kg_mwh; + + let emBl, emMeas, enBl, enMeas; + + if (sector === 'buildings') { + const heatBl = computeHeatDemand(baseline); + const heatMeas = computeHeatDemand(measure); + emBl = heatBl * efFuelBl + baseline.demand_electricity_measure_mwh * efEl; + emMeas = heatMeas * efFuelMeas + measure.demand_electricity_measure_mwh * efEl; + enBl = heatBl + baseline.demand_electricity_measure_mwh; + enMeas = heatMeas + measure.demand_electricity_measure_mwh; + } else { + const cBl = baseline.demand_energy_per_100km * baseline.mileage / 100; + const cMeas = measure.demand_energy_per_100km * measure.mileage / 100; + emBl = cBl * efFuelBl; + emMeas = cMeas * efFuelMeas; + enBl = null; + enMeas = null; + } + + rows.push({ + year: t, + opexBaseline: Math.round(opexBl), + opexMeasure: Math.round(opexMeas), + opexDiff: Math.round(opexDiff), + discFactor, + opexDiffDisc: Math.round(opexDiffDisc), + cumDisc: Math.round(cumDisc), + emissionsBaseline: Math.round(emBl), + emissionsMeasure: Math.round(emMeas), + emissionsDiff: Math.round(emMeas - emBl), // negative = measure emits less + energyBaseline: enBl, + energyMeasure: enMeas, + energyDiff: enBl !== null ? enMeas - enBl : null, // negative = measure uses less + }); + } + + return rows; + } + + // --------------------------------------------------------------------------- + // Aggregate indicators + // --------------------------------------------------------------------------- + + function calcPaybackYear(yearByYear) { + for (const row of yearByYear) { + if (row.year > 0 && row.cumDisc >= 0) return row.year; + } + return null; // never pays back within lifetime + } + + function calcEmissionSavings(yearByYear, npv, capexDiff) { + // Sum operational diffs (row.emissionsDiff, sign: measure - baseline, negative = good) + // Plus embedded at year 0 + const totalKg = yearByYear.reduce((acc, row) => acc + (row.emissionsDiff || 0), 0); + const totalT = totalKg / 1000; + + // Total baseline emissions (operational + embedded) + const totalBlKg = yearByYear.reduce((acc, row) => { + if (row.year === 0) return acc + (row.emissionsBaseline || 0); + return acc + (row.emissionsBaseline || 0); + }, 0); + + const relative = totalBlKg !== 0 ? totalKg / totalBlKg : null; + // perNpv: negative = favorable (NPV positive + emission savings negative cancel to negative) + // positive = extra cost per tonne CO2 saved (NPV negative but still saving emissions) + const perNpv = totalT !== 0 ? npv / totalT : null; + const perCapexDiff = totalT !== 0 ? capexDiff / totalT : null; + + return { totalT, totalKg, relative, perNpv, perCapexDiff }; + } + + // Annual liquid-fuel consumption (litres) – Petrol or Diesel transport measures only. + const LIQUID_FUELS = new Set(['Petrol', 'Diesel']); + function annualFuelLitres(measure, sector) { + if (!LIQUID_FUELS.has(measure.fuel)) return 0; + if (sector === 'transport') return measure.demand_energy_per_100km * measure.mileage / 100; + return 0; + } + + // Liquid-fuel (PHM) savings over the lifetime (litres) – transport only. + // Returns null when neither baseline nor measure uses liquid fuel. + function calcFuelSavings(baseline, measure, sector, lifetime) { + const annualBl = annualFuelLitres(baseline, sector); + const annualMeas = annualFuelLitres(measure, sector); + if (annualBl === 0 && annualMeas === 0) return null; + const annualSaved = annualBl - annualMeas; // positive = saving fuel + return { annualL: annualSaved, totalL: annualSaved * lifetime }; + } + + // Annual natural-gas consumption (MWh) – buildings measures only. + function annualGasMwh(measure, sector) { + if (measure.fuel !== 'Gas') return 0; + if (sector === 'buildings') return computeHeatDemand(measure); + return 0; + } + + // Natural-gas savings over the lifetime (MWh) – buildings only. + // Returns null when neither baseline nor measure uses gas. + function calcGasSavings(baseline, measure, sector, lifetime) { + const annualBl = annualGasMwh(baseline, sector); + const annualMeas = annualGasMwh(measure, sector); + if (annualBl === 0 && annualMeas === 0) return null; + const annualSaved = annualBl - annualMeas; // positive = saving gas + return { annualMwh: annualSaved, totalMwh: annualSaved * lifetime }; + } + + function calcEnergySavings(yearByYear, npv, capexDiff) { + // Buildings only – returns null if energy data not present + const hasEnergy = yearByYear.some(r => r.year > 0 && r.energyDiff !== null); + if (!hasEnergy) return null; + + const operationalRows = yearByYear.filter(r => r.year > 0); + // Annual energy is constant across years (no price dependency), so use year 1 + const year1 = operationalRows[0]; + const annualMwh = year1 ? year1.energyDiff : 0; + + const totalMwh = operationalRows.reduce((acc, r) => acc + (r.energyDiff || 0), 0); + const totalBlMwh = operationalRows.reduce((acc, r) => acc + (r.energyBaseline || 0), 0); + + const relative = totalBlMwh !== 0 ? totalMwh / totalBlMwh : null; + const perNpv = totalMwh !== 0 ? npv / totalMwh : null; + const perCapexDiff = totalMwh !== 0 ? capexDiff / totalMwh : null; + + return { totalMwh, annualMwh, relative, perNpv, perCapexDiff }; + } + + // --------------------------------------------------------------------------- + // Sensitivity analysis (mirrors R make_variants + prep_tornado logic) + // + // Returns one object per sensitivity parameter, each with: + // param – parameter name + // baselineNpv – NPV at default settings + // minNpv – lowest NPV across all variants of this parameter + // maxNpv – highest NPV + // minLabel – label of the variant that produced minNpv + // maxLabel – label of the variant that produced maxNpv + // minDev – minNpv − baselineNpv + // maxDev – maxNpv − baselineNpv + // --------------------------------------------------------------------------- + + function computeSensitivity(baseline, measure, sector, scenarioPrices, baseOpts, emissionFactors) { + const baseNpv = computeNpv(baseline, measure, sector, scenarioPrices, baseOpts, emissionFactors); + if (baseNpv === null) return null; + + // Collect relevant fuels (deduped, in order: baseline first) + const fuelSet = [baseline.fuel]; + if (measure.fuel && !fuelSet.includes(measure.fuel)) fuelSet.push(measure.fuel); + + // Build variant list following R's make_variants structure + const variants = [ + // // Discount rate + // { param: 'Diskontní míra', label: '0 %', opts: { ...baseOpts, discountRate: 0.00 } }, + // { param: 'Diskontní míra', label: '3 %', opts: { ...baseOpts, discountRate: 0.03 } }, + // { param: 'Diskontní míra', label: '7 %', opts: { ...baseOpts, discountRate: 0.07 } }, + // // Carbon price + // { param: 'Cena uhlíku', label: '0 €', opts: { ...baseOpts, carbonPriceEur: 0 } }, + // { param: 'Cena uhlíku', label: '60 €', opts: { ...baseOpts, carbonPriceEur: 60 } }, + // { param: 'Cena uhlíku', label: '200 €',opts: { ...baseOpts, carbonPriceEur: 200 } }, + // CAPEX of the alternative measure + { param: 'Investiční náklady opatření', label: '-30 %', opts: { ...baseOpts, capexMeasMult: 0.7 } }, + { param: 'Investiční náklady opatření', label: 'Základ', opts: { ...baseOpts, capexMeasMult: 1.0 } }, + { param: 'Investiční náklady opatření', label: '+30 %', opts: { ...baseOpts, capexMeasMult: 1.3 } }, + // CAPEX of the baseline + { param: 'Investiční náklady základní varianty', label: '-30 %', opts: { ...baseOpts, capexBlMult: 0.7 } }, + { param: 'Investiční náklady základní varianty', label: 'Základ', opts: { ...baseOpts, capexBlMult: 1.0 } }, + { param: 'Investiční náklady základní varianty', label: '+30 %', opts: { ...baseOpts, capexBlMult: 1.3 } }, + ]; + + // Fuel price variants for all fuels used by baseline or measure + fuelSet.forEach(fuel => { + const paramLabel = FUEL_PRICE_LABEL[fuel] || `Cena: ${fuel}`; + [ + { mult: 0.7, label: '-30 %' }, + { mult: 1.0, label: 'Základ' }, + { mult: 1.3, label: '+30 %' }, + ].forEach(({ mult, label }) => { + // "Základ" variant with no scaling is equivalent to the baseline opts + const fuelName = mult === 1.0 ? null : fuel; + variants.push({ + param: paramLabel, + label, + opts: { ...baseOpts, fuelPriceName: fuelName, fuelPriceMult: mult }, + }); + }); + }); + + // Compute NPV for every variant + const computed = variants.map(v => ({ + param: v.param, + label: v.label, + npv: computeNpv(baseline, measure, sector, scenarioPrices, v.opts, emissionFactors), + })).filter(v => v.npv !== null); + + // Group by param and derive range + const byParam = {}; + computed.forEach(v => { + if (!byParam[v.param]) byParam[v.param] = []; + byParam[v.param].push(v); + }); + + const sensitivity = Object.entries(byParam) + .map(([param, rows]) => { + const npvs = rows.map(r => r.npv); + const minIdx = npvs.indexOf(Math.min(...npvs)); + const maxIdx = npvs.indexOf(Math.max(...npvs)); + return { + param, + baselineNpv: baseNpv, + minNpv: rows[minIdx].npv, + maxNpv: rows[maxIdx].npv, + minLabel: rows[minIdx].label, + maxLabel: rows[maxIdx].label, + minDev: rows[minIdx].npv - baseNpv, + maxDev: rows[maxIdx].npv - baseNpv, + }; + }) + .filter(r => r.maxNpv - r.minNpv > 0); // skip params with no effect + + // Sort by the predefined order (unrecognised params appended at the end) + sensitivity.sort((a, b) => { + const ai = SENSITIVITY_PARAM_ORDER.indexOf(a.param); + const bi = SENSITIVITY_PARAM_ORDER.indexOf(b.param); + if (ai === -1 && bi === -1) return 0; + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); + + return sensitivity; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + function calculate(options) { + const { + measureId, + data, + discountRate = DEFAULT_DISCOUNT_RATE, + carbonPriceEur = DEFAULT_CARBON_PRICE_EUR, + priceScenario = DEFAULT_PRICE_SCENARIO, + exchangeRate = DEFAULT_EXCHANGE_RATE, + electricityPriceFactor = 1.0, + } = options; + + // Locate the measure + const allMeasures = [ + ...(data.buildings_measures || []), + ...(data.transport_measures || []), + ]; + const measure = allMeasures.find(m => m.id === measureId); + if (!measure) throw new Error(`Measure id ${measureId} not found`); + if (!measure.measure_baseline_id) throw new Error(`Measure id ${measureId} has no measure_baseline_id`); + + const baseline = allMeasures.find(m => m.id === measure.measure_baseline_id); + if (!baseline) throw new Error(`Baseline id ${measure.measure_baseline_id} not found`); + + const sector = getSector(measure); + const scenarioPrices = getPricesForScenario(data, priceScenario); + const emissionFactors = data.fuel_emission_factors; + const lifetime = Math.min(baseline.lifetime, measure.lifetime); + + const baseOpts = { discountRate, carbonPriceEur, exchangeRate, electricityPriceFactor }; + + const npv = computeNpv(baseline, measure, sector, scenarioPrices, baseOpts, emissionFactors); + + const capexDiff = getCapex(baseline) - getCapex(measure); + const yearByYear = buildYearByYear(baseline, measure, sector, scenarioPrices, baseOpts, emissionFactors); + + return { + sector, + measure, + baseline, + lifetime, + capexDiff, + npv, + paybackYear: calcPaybackYear(yearByYear), + emissionSavings: calcEmissionSavings(yearByYear, npv, capexDiff), + energySavings: calcEnergySavings(yearByYear, npv, capexDiff), // null for transport + fuelSavings: calcFuelSavings(baseline, measure, sector, lifetime), + gasSavings: calcGasSavings(baseline, measure, sector, lifetime), + yearByYear, + sensitivity: computeSensitivity(baseline, measure, sector, scenarioPrices, baseOpts, emissionFactors), + }; + } + + return { calculate }; +})(); diff --git a/collections/_explainers/opatreni-dekarbonizace-domacnosti.md b/collections/_explainers/opatreni-dekarbonizace-domacnosti.md new file mode 100644 index 000000000..30c872ffd --- /dev/null +++ b/collections/_explainers/opatreni-dekarbonizace-domacnosti.md @@ -0,0 +1,561 @@ +--- +layout: empty +title: "Opatření pro dekarbonizaci domácností – grafy" +slug: "opatreni-dekarbonizace-domacnosti" +published: 2026-04-28 +authors: + - ids: ["katerina-kolouchova", "jan-krcal"] + - ids: ["marcel-otruba"] + minor-role: "vizualizace" +weight: 74.5 +tags-scopes: [ eu ] +tags-topics: [ emise, opatreni, ekonomika ] +extra-scripts: +- https://d3js.org/d3.v7.min.js +- /assets-local/js/costs-benefits-calculator.js +- /assets-local/js/costs-and-benefits-graphics.js +--- + +<script> + window.COSTS_AND_BENEFITS = {{ site.data["costs-and-benefits"] | jsonify }}; +</script> + +<style> +/* ── Controls bar ──────────────────────────────────────────────────────────── */ +.controls-inner { + display: flex; + flex-wrap: wrap; + gap: 8px 32px; + align-items: flex-start; + padding-bottom: 0.25rem; +} + +.control-group { + display: flex; + flex-direction: column; + flex: 1 1 180px; + max-width: 360px; + min-width: 160px; +} +.control-group.control-group--select { + flex: 0 1 200px; + min-width: 160px; + max-width: 220px; + justify-content: flex-end; +} +.control-group--disabled { + opacity: 0.4; + pointer-events: none; +} + +.control-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 2px; +} + +.control-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #666; +} + +.control-value { + font-size: 12px; + font-weight: 700; + color: #1a7a85; +} + +/* ── Slider with ticks ─────────────────────────────────────────────────────── */ +.slider-with-ticks { + position: relative; + padding-bottom: 34px; +} +.slider-with-ticks input[type=range] { + width: 100%; + margin: 0; + cursor: pointer; + accent-color: #1a7a85; +} +.tick-labels { + position: absolute; + left: 0; + right: 0; + top: 20px; + pointer-events: none; +} +.tick-label { + position: absolute; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; +} +.tick-mark { + display: block; + width: 1px; + height: 6px; + background: #bbb; + margin-bottom: 1px; +} +.tick-text { + font-size: 9px; + text-align: center; + color: #888; + line-height: 1.2; + white-space: nowrap; +} +.tick-text small { + display: block; + color: #bbb; + font-size: 8px; +} + +/* ── Chart ─────────────────────────────────────────────────────────────────── */ +.tornado-chart { + margin: 4px 0 24px; + overflow-x: auto; +} +.tornado-chart svg { display: block; } + +.quadrant-wrap { + margin: 4px 0 24px; +} +.quadrant-chart { + overflow-x: auto; +} +.quadrant-chart svg { display: block; } + +.q-quad-label { + font-size: 10px; + font-style: italic; +} + +.q-axis-label { + font-size: 12px; + fill: #666; + font-weight: 500; +} + +/* ── Quadrant filters ──────────────────────────────────────────────────────── */ +.q-filters { + margin-bottom: 10px; +} +.q-filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; + margin-bottom: 6px; +} +.q-filter-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #999; + margin-right: 4px; + white-space: nowrap; + flex-shrink: 0; +} +.q-filter-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px 3px; + border: 1.5px solid #e0e0e0; + border-radius: 12px; + background: #f8f8f8; + color: #bbb; + font-size: 11px; + font-family: inherit; + cursor: pointer; + line-height: 1.6; + transition: border-color 0.12s, color 0.12s, background 0.12s, opacity 0.12s; +} +.q-filter-btn.active { + background: #fff; + color: #333; + border-color: #bbb; +} +.q-filter-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + opacity: 0.35; + transition: opacity 0.12s; +} +.q-filter-btn.active .q-filter-dot { opacity: 1; } +.q-sector-btn.active[data-sector="buildings"] { color: #2860b4; border-color: #2860b4; } +.q-sector-btn.active[data-sector="transport"] { color: #6b4fa0; border-color: #6b4fa0; } + +/* ── Static comparison chart toggle ────────────────────────────────────────── */ +.static-chart-toggle-wrap { + margin-top: 12px; +} +.static-chart-toggle-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 14px 6px; + border: 1.5px solid #ddd; + border-radius: 6px; + background: #f8f8f8; + color: #555; + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.static-chart-toggle-btn:hover { background: #f0f0f0; border-color: #bbb; } +.static-chart-toggle-btn[aria-expanded="true"] { background: #fff; border-color: #aaa; color: #333; } +.static-chart-toggle-icon { + font-size: 9px; + transition: transform 0.2s; + display: inline-block; +} +.static-chart-toggle-btn[aria-expanded="true"] .static-chart-toggle-icon { + transform: rotate(90deg); +} +.static-comparison-chart { + margin-top: 12px; +} + +.chart-col-header { + font-size: 10px; + fill: #999; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.chart-axis path, +.chart-axis line { stroke: #ddd; } +.chart-axis text { font-size: 10px; fill: #888; } + +/* ── Chart download buttons ─────────────────────────────────────────────────── */ +.chart-dl-bar { + display: flex; + gap: 5px; + justify-content: flex-end; + margin-top: 4px; +} +.chart-dl-btn { + font-size: 10px; + color: #bbb; + background: none; + border: 1px solid #e4e7ed; + border-radius: 3px; + padding: 2px 8px 3px; + cursor: pointer; + font-family: inherit; + line-height: 1.4; + letter-spacing: 0.02em; + transition: color 0.12s, border-color 0.12s; +} +.chart-dl-btn:hover { + color: #53616e; + border-color: #aaa; +} + +/* ── Sensitivity beeswarm ────────────────────────────────────────────────────── */ +.sb-filters { + padding-bottom: 10px; +} + +.sb-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + margin-top: 8px; +} +.sb-legend-item { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: #555; +} +</style> + +<div class="section pb-3"> + <div class="container between-navbars"> + <h1>{{ page.title }}</h1> + {% include tags.html tags=page.tags slug=page.slug link="true" %} + </div> +</div> + +<div id="secondary-navbar" class="section"> + <div class="container page-title">{{ page.title }}</div> + <div class="container controls-inner"> + + <div class="control-group control-group--select"> + <label class="control-label" for="fuel-scenario-select">Scénář cen energií</label> + <select id="fuel-scenario-select" class="form-select form-select-sm mt-1"> + <option value="CP">Současné politiky</option> + <option value="NZ">Net-zero</option> + <option value="CP_EC">Energetická krize</option> + </select> + </div> + + <div class="control-group"> + <div class="control-head"> + <span class="control-label">Cena uhlíku</span> + <span class="control-value" id="carbon-price-value">60 €</span> + </div> + <div class="slider-with-ticks"> + <input type="range" id="carbon-price-slider" min="0" max="200" step="10" value="60"> + <div class="tick-labels"> + <span class="tick-label" style="left:8px"> + <span class="tick-mark"></span> + <span class="tick-text">0 €<small>bez ceny uhlíku</small></span> + </span> + <span class="tick-label" style="left:calc(30% + 3.2px)"> + <span class="tick-mark"></span> + <span class="tick-text">60 €<small>ETS2 nižší</small></span> + </span> + <span class="tick-label" style="left:50%"> + <span class="tick-mark"></span> + <span class="tick-text">100 €<small>ETS2 vyšší</small></span> + </span> + <span class="tick-label" style="left:calc(100% - 8px)"> + <span class="tick-mark"></span> + <span class="tick-text">200 €<small>skutečná cena</small></span> + </span> + </div> + </div> + </div> + + <div class="control-group"> + <div class="control-head"> + <span class="control-label">Diskontní míra</span> + <span class="control-value" id="discount-rate-value">3 %</span> + </div> + <div class="slider-with-ticks"> + <input type="range" id="discount-rate-slider" min="0" max="7" step="1" value="3"> + <div class="tick-labels"> + <span class="tick-label" style="left:8px"> + <span class="tick-mark"></span> + <span class="tick-text">0 %<small>běžný účet</small></span> + </span> + <span class="tick-label" style="left:calc(42.857% + 1.14px)"> + <span class="tick-mark"></span> + <span class="tick-text">3 %<small>spořicí účet</small></span> + </span> + <span class="tick-label" style="left:calc(100% - 8px)"> + <span class="tick-mark"></span> + <span class="tick-text">7 %<small>akcie</small></span> + </span> + </div> + </div> + </div> + + </div> +</div> + +<div class="section pt-3 pb-2"> + <div class="container"> + <p class="chart-col-header mb-2">Nákladová efektivita opatření</p> + <div id="quadrant-wrap" class="quadrant-wrap"> + <div id="quadrant-chart" class="quadrant-chart"></div> + </div> + <div class="static-chart-toggle-wrap"> + <button id="static-chart-toggle" class="static-chart-toggle-btn" aria-expanded="false"> + <span class="static-chart-toggle-icon">▶</span> + Porovnání scénářů ceny uhlíku: 60 € vs. 200 € + </button> + <div id="static-comparison-chart" class="quadrant-chart static-comparison-chart" hidden></div> + </div> + </div> +</div> + +<div class="section pt-3 pb-2"> + <div class="container"> + <p class="chart-col-header mb-2">Křivka marginálních nákladů dekarbonizace (MAC curve)</p> + <div id="mac-chart" style="overflow-x:auto;"></div> + </div> +</div> + +<div class="section pt-3 pb-2"> + <div class="container"> + + <p class="chart-col-header mb-2" style="color:#2860b4">Budovy</p> + + <p class="chart-col-header mb-1">Cena uhlíku</p> + <div style="display:flex; gap:2rem; flex-wrap:wrap; margin-bottom:1.5rem;"> + <div style="flex:1; min-width:280px;"> + <p class="chart-col-header mb-1">Rodinný dům uhlí – E</p> + <div class="tornado-chart" data-category="Rodinný dům uhlí – E" data-param="Cena uhlíku" data-domain-group="cena-uhliku"></div> + </div> + <div style="flex:1; min-width:280px;"> + <p class="chart-col-header mb-1">Rodinný dům plyn – E</p> + <div class="tornado-chart" data-category="Rodinný dům plyn – E" data-param="Cena uhlíku" data-domain-group="cena-uhliku"></div> + </div> + </div> + + <p class="chart-col-header mb-1">Diskontní míra</p> + <div style="display:flex; gap:2rem; flex-wrap:wrap; margin-bottom:3rem;"> + <div style="flex:1; min-width:280px;"> + <p class="chart-col-header mb-1">Rodinný dům uhlí – E</p> + <div class="tornado-chart" data-category="Rodinný dům uhlí – E" data-param="Diskontní míra" data-exclude="Elektrický kotel" data-domain-group="diskontni-mira"></div> + </div> + <div style="flex:1; min-width:280px;"> + <p class="chart-col-header mb-1">Rodinný dům plyn – E</p> + <div class="tornado-chart" data-category="Rodinný dům plyn – E" data-param="Diskontní míra" data-exclude="Elektrický kotel" data-domain-group="diskontni-mira"></div> + </div> + </div> + + <p class="chart-col-header mb-2" style="color:#6b4fa0">Doprava</p> + + <p class="chart-col-header mb-1">Cena uhlíku</p> + <div style="display:flex; gap:2rem; flex-wrap:wrap; margin-bottom:1.5rem;"> + <div style="flex:1; min-width:200px;"> + <div class="tornado-chart" data-categories="Nové malé|Ojeté malé" data-param="Cena uhlíku" data-domain-group="cena-uhliku"></div> + </div> + <div style="flex:1; min-width:200px;"> + <div class="tornado-chart" data-categories="Nové velké|Ojeté velké" data-param="Cena uhlíku" data-domain-group="cena-uhliku"></div> + </div> + </div> + + <p class="chart-col-header mb-1">Diskontní míra</p> + <div style="display:flex; gap:2rem; flex-wrap:wrap; margin-bottom:1.5rem;"> + <div style="flex:1; min-width:200px;"> + <div class="tornado-chart" data-categories="Nové malé|Ojeté malé" data-param="Diskontní míra" data-domain-group="diskontni-mira"></div> + </div> + <div style="flex:1; min-width:200px;"> + <div class="tornado-chart" data-categories="Nové velké|Ojeté velké" data-param="Diskontní míra" data-domain-group="diskontni-mira"></div> + </div> + </div> + + <p class="chart-col-header mb-1">Tarif elektřiny</p> + <div style="display:flex; gap:2rem; flex-wrap:wrap;"> + <div style="flex:1; min-width:200px;"> + <div class="tornado-chart" data-categories="Nové malé|Ojeté malé" data-param="Tarif elektřiny" data-domain-group="tarif-elektriny"></div> + </div> + <div style="flex:1; min-width:200px;"> + <div class="tornado-chart" data-categories="Nové velké|Ojeté velké" data-param="Tarif elektřiny" data-domain-group="tarif-elektriny"></div> + </div> + </div> + + </div> +</div> + +<div class="section pt-3 pb-2"> + <div class="container"> + <p class="chart-col-header mb-2">Náklady na tunu ušetřeného CO₂ — rozložení opatření</p> + <div id="beeswarm-chart"></div> + </div> +</div> + +<div class="section pt-3 pb-2"> + <div class="container"> + <p class="chart-col-header mb-2">Rozdíl v investičních nákladech na tunu ušetřeného CO₂</p> + <div id="beeswarm-capex-chart"></div> + </div> +</div> + +<div class="section pt-3 pb-4"> + <div class="container"> + <p class="chart-col-header mb-2">NPV opatření v různých cenových scénářích</p> + <div id="dumbbell-legend" style="margin-bottom:1rem;"></div> + <div style="display:flex; gap:2rem; margin-bottom:1.5rem;"> + <div style="flex:1; min-width:0;"> + <p class="chart-col-header mb-1" style="color:#2860b4">Rodinný dům uhlí – E</p> + <div id="dumbbell-rd-uhli-e"></div> + </div> + <div style="flex:1; min-width:0;"> + <p class="chart-col-header mb-1" style="color:#2860b4">Rodinný dům plyn – E</p> + <div id="dumbbell-rd-plyn-e"></div> + </div> + </div> + <div style="display:flex; gap:2rem; margin-bottom:1.5rem;"> + <div style="flex:1; min-width:0;"> + <p class="chart-col-header mb-1" style="color:#6b4fa0">Nové malé</p> + <div id="dumbbell-nove-male"></div> + </div> + <div style="flex:1; min-width:0;"> + <p class="chart-col-header mb-1" style="color:#6b4fa0">Nové velké</p> + <div id="dumbbell-nove-velke"></div> + </div> + </div> + <div style="display:flex; gap:2rem;"> + <div style="flex:1; min-width:0;"> + <p class="chart-col-header mb-1" style="color:#6b4fa0">Ojeté malé</p> + <div id="dumbbell-ojete-male"></div> + </div> + <div style="flex:1; min-width:0;"> + <p class="chart-col-header mb-1" style="color:#6b4fa0">Ojeté velké</p> + <div id="dumbbell-ojete-velke"></div> + </div> + </div> + </div> +</div> + +<div class="section pt-3 pb-4"> + <div class="container"> + <p class="chart-col-header mb-3">Úspora paliva při plošném nasazení opatření</p> + <table class="table table-sm" style="max-width:700px;"> + <thead> + <tr> + <th>Opatření</th> + <th class="text-end">Počet opatření</th> + <th class="text-end">Úspora na jednotku</th> + <th class="text-end">Celková roční úspora</th> + <th class="text-end">Z 60 TWh dovozů plynu</th> + </tr> + </thead> + <tbody> + <tr> + <td>Tepelné čerpadlo <small class="text-muted">(Rodinný dům plyn – E)</small></td> + <td class="text-end">200 000</td> + <td class="text-end">24 MWh plynu / rok</td> + <td class="text-end"><strong>4,8 TWh plynu / rok</strong></td> + <td class="text-end">8 %</td> + </tr> + <tr> + <td>Zateplení + fasáda <small class="text-muted">(Rodinný dům plyn – E)</small></td> + <td class="text-end">200 000</td> + <td class="text-end">10 MWh plynu / rok</td> + <td class="text-end"><strong>2 TWh plynu / rok</strong></td> + <td class="text-end">3,3 %</td> + </tr> + <tr> + <td>Malý elektromobil <small class="text-muted">(Nové malé)</small></td> + <td class="text-end">500 000</td> + <td class="text-end">975 l benzínu / rok</td> + <td class="text-end"><strong>487,5 mil. l benzínu / rok</strong></td> + <td class="text-end">—</td> + </tr> + </tbody> + <tfoot> + <tr> + <td colspan="3" class="text-muted" style="font-size:0.8em;"> + Tepelné čerpadlo: baseline Plynový kotel 24 MWh/rok → opatření přechází na elektřinu (0 MWh plynu). + Zateplení + fasáda: baseline 24 MWh/rok → opatření 14 MWh/rok plynu. + Malý elektromobil: baseline Nové malé auto na benzín, 6,5 l/100 km × 15 000 km/rok = 975 l/rok → elektromobil nespotřebuje benzín. + </td> + </tr> + </tfoot> + </table> + </div> +</div> + +<div class="section pt-3 pb-4"> + <div class="container"> + <p class="chart-col-header mb-2">Sensitivita NPV — všechny kombinace parametrů</p> + <p class="text-muted" style="font-size:12px;margin-bottom:12px;"> + Každá tečka = jedna kombinace (scénář cen × cena uhlíku × diskontní míra) pro daný kontext. + Zvýrazněné tečky = výchozí kombinace (Současné politiky · 60 € · 3 %). + </p> + <div id="sensitivity-beeswarm-wrap"> + <div id="sensitivity-beeswarm-chart"></div> + <div id="sensitivity-beeswarm-legend" class="sb-legend"></div> + </div> + </div> +</div> diff --git a/collections/_studies/2026-opatreni-dekarbonizace-domacnosti.md b/collections/_studies/2026-opatreni-dekarbonizace-domacnosti.md new file mode 100644 index 000000000..839ab555c --- /dev/null +++ b/collections/_studies/2026-opatreni-dekarbonizace-domacnosti.md @@ -0,0 +1,368 @@ +--- +layout: empty +type: "Interaktivní přehled" +title: "Opatření pro dekarbonizaci domácností" +slug: 2026-opatreni-dekarbonizace-domacnosti +redirect_from: +- /opatreni-dekarbonizace-domacnosti +weight: 210 +published: 2026-04-21 +tags-scopes: [ cesko ] +tags-topics: [ opatreni, budovy, doprava ] +caption: "Jak jsou nákladově efektivní různá opatření pro dekarbonizaci domácností?" +intro: | + Tento přehled porovnává nákladovou efektivitu opatření pro dekarbonizaci domácností – jak v oblasti budov, tak v dopravě. Pro každé opatření lze porovnat jeho efektivitu v různých kontextech (typ budovy nebo vozidla). +extra-scripts: +- https://d3js.org/d3.v7.min.js +- /assets-local/js/costs-benefits-calculator.js +- /assets-local/js/costs-and-benefits.js +--- + +<script> + window.COSTS_AND_BENEFITS = {{ site.data["costs-and-benefits"] | jsonify }}; +</script> + +<style> +/* ── Controls bar ──────────────────────────────────────────────────────────── */ +/* Sticky behaviour, z-index, shadow and background are handled by the site's + #secondary-navbar / .secondary-navbar-stuck styles in _core_design.scss. */ + +.controls-inner { + display: flex; + flex-wrap: wrap; + gap: 8px 32px; + align-items: flex-start; + padding-bottom: 0.25rem; +} + +.control-group { + display: flex; + flex-direction: column; + flex: 1 1 180px; + max-width: 360px; + min-width: 160px; +} +.control-group.control-group--select { + flex: 0 1 200px; + min-width: 160px; + max-width: 220px; + justify-content: flex-end; +} + +.control-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 2px; +} + +.control-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #666; +} + +.control-value { + font-size: 0.875rem; + font-weight: 700; + color: #1a7a85; +} + +/* ── Slider with ticks ─────────────────────────────────────────────────────── */ +/* Thumb width on Chrome/Edge/Firefox is ~16 px; tick positions use + left: calc(val/max * (100% - 16px) + 8px) so marks centre on the thumb. */ +.slider-with-ticks { + position: relative; + padding-bottom: 34px; +} +.slider-with-ticks input[type=range] { + width: 100%; + margin: 0; + cursor: pointer; + accent-color: #1a7a85; +} +.tick-labels { + position: absolute; + left: 0; + right: 0; + top: 20px; + pointer-events: none; +} +.tick-label { + position: absolute; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; +} +.tick-mark { + display: block; + width: 1px; + height: 6px; + background: #bbb; + margin-bottom: 1px; +} +.tick-text { + font-size: 0.7rem; + text-align: center; + color: #888; + line-height: 1.2; + white-space: nowrap; +} +.tick-text small { + display: block; + color: #bbb; + font-size: 0.625rem; +} + +/* ── Chart ─────────────────────────────────────────────────────────────────── */ +.measure-chart { + margin: 4px 0 24px; + overflow-x: auto; +} +.measure-chart svg { display: block; } + +.chart-col-header { + font-size: 11px; + fill: #999; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.chart-axis path, +.chart-axis line { stroke: #ddd; } +.chart-axis text { font-size: 12px; fill: #888; } + +/* ── Clickable detail rows ─────────────────────────────────────────────── */ +.d-row { cursor: pointer; } + +/* ── Row detail panel ──────────────────────────────────────────────────── */ +.row-detail { + background: #f7f9fa; + border: 1px solid #e0e6ea; + border-radius: 6px; + margin: 0 0 20px; + padding: 12px 16px 10px; + font-family: Roboto, system-ui, -apple-system, Segoe UI, Arial, sans-serif; + font-size: 13px; +} + +.row-detail-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 10px; + color: #333; +} +.row-detail-title strong { font-size: 14px; } +.row-detail-vs { color: #999; margin-left: 4px; font-size: 12px; } + +.row-detail-close { + background: none; + border: none; + font-size: 15px; + color: #bbb; + cursor: pointer; + padding: 0 0 0 12px; + line-height: 1; + flex-shrink: 0; +} +.row-detail-close:hover { color: #555; } + +/* Stats grid — 4 columns: row-label + 3 data cols */ +.stats-grid { + display: grid; + grid-template-columns: 2.8rem 1fr 1fr 1fr; + row-gap: 6px; + margin-bottom: 12px; + align-items: start; +} + +/* Row category label (Peníze / Emise / Plyn) */ +.stats-row-lbl { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #ccc; + align-self: center; + padding-right: 4px; +} + +/* Column header cells (top of col 2 and col 3) */ +.stats-col-hdr-cell { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #bbb; + padding-bottom: 4px; + border-bottom: 1px solid #e8e8e8; +} + +/* Individual stat cell */ +.row-detail-stat { + display: flex; + flex-direction: column; + gap: 1px; +} +.stat-lbl { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #aaa; +} +.stat-val { + font-size: 0.92rem; + font-weight: 600; + color: #333; +} +.stat-sub { + font-size: 0.7rem; + font-weight: 400; + color: #bbb; + margin-top: 1px; +} + +/* Dashed left dividers on the NPV and CAPEX columns */ +.stats-cell-npv, +.stats-cell-capex { + border-left: 1px dashed #e0e0e0; + padding-left: 10px; +} + +.row-detail-section-label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #999; + margin-bottom: 4px; +} +.row-detail-tornado svg { display: block; } +.row-detail-timeline { overflow-x: auto; } +.row-detail-timeline svg { display: block; } +</style> + +{% assign data = site.data["costs-and-benefits"] %} + +<div class="section pb-3"> + <div class="container between-navbars"> + <h1>{{ page.title }}</h1> + <div class="page-type">{{ page.type }}</div> + {% include tags.html tags=page.tags slug=page.slug link="true" %} + <div class="perex narrow-text">{{ page.intro | markdownify }}</div> + </div> +</div> + +<div id="secondary-navbar" class="section"> + <div class="container page-title">{{ page.title }}</div> + <div class="container controls-inner"> + + <div class="control-group"> + <div class="control-head"> + <span class="control-label">Cena uhlíku</span> + <span class="control-value" id="carbon-price-value">60 €</span> + </div> + <div class="slider-with-ticks"> + <input type="range" id="carbon-price-slider" min="0" max="200" step="10" value="60"> + <div class="tick-labels"> + <!-- left = calc(val/200 * (100% - 16px) + 8px) --> + <span class="tick-label" style="left:8px"> + <span class="tick-mark"></span> + <span class="tick-text">0 €<small>bez ceny uhlíku</small></span> + </span> + <span class="tick-label" style="left:calc(30% + 3.2px)"> + <span class="tick-mark"></span> + <span class="tick-text">60 €<small>ETS2 nižší</small></span> + </span> + <span class="tick-label" style="left:50%"> + <span class="tick-mark"></span> + <span class="tick-text">100 €<small>ETS2 vyšší</small></span> + </span> + <span class="tick-label" style="left:calc(100% - 8px)"> + <span class="tick-mark"></span> + <span class="tick-text">200 €<small>skutečná cena</small></span> + </span> + </div> + </div> + </div> + + <div class="control-group"> + <div class="control-head"> + <span class="control-label">Diskontní míra</span> + <span class="control-value" id="discount-rate-value">3 %</span> + </div> + <div class="slider-with-ticks"> + <input type="range" id="discount-rate-slider" min="0" max="7" step="1" value="3"> + <div class="tick-labels"> + <!-- left = calc(val/7 * (100% - 16px) + 8px) --> + <span class="tick-label" style="left:8px"> + <span class="tick-mark"></span> + <span class="tick-text">0 %<small>běžný účet</small></span> + </span> + <span class="tick-label" style="left:calc(42.857% + 1.14px)"> + <span class="tick-mark"></span> + <span class="tick-text">3 %<small>spořicí účet</small></span> + </span> + <span class="tick-label" style="left:calc(100% - 8px)"> + <span class="tick-mark"></span> + <span class="tick-text">7 %<small>akcie</small></span> + </span> + </div> + </div> + </div> + + <div class="control-group control-group--select"> + <label class="control-label" for="fuel-scenario-select">Scénář cen energií</label> + <select id="fuel-scenario-select" class="form-select form-select-sm mt-1"> + <option value="CP">Současné politiky</option> + <option value="NZ">Net-zero</option> + <option value="CP_EC">Energetická krize</option> + </select> + </div> + + </div> +</div> + +<div class="section pt-3 pb-2"> + <div class="container"> + <div id="summary-chart" class="measure-chart"></div> + </div> +</div> + +<div class="section"> +<div class="container" markdown="1"> + +# Budovy + +{% assign building_measure_names = "" | split: "" %} +{% for measure in data.buildings_measures %} + {% if measure.measure_baseline_id %} + {% unless building_measure_names contains measure.measure_name %} + {% assign building_measure_names = building_measure_names | push: measure.measure_name %} + {% endunless %} + {% endif %} +{% endfor %} + +{% for name in building_measure_names %} +## {{ name }} +<div class="measure-chart" data-section="buildings" data-measure="{{ name | escape }}"></div> +{% endfor %} + +# Doprava + +## Nové + +<div class="measure-chart" data-section="transport" data-group="Nové"></div> + +## Ojeté + +<div class="measure-chart" data-section="transport" data-group="Ojeté"></div> + +</div> +</div> diff --git a/collections/_topics/emise.md b/collections/_topics/emise.md index 7ed37cc27..ba858b12c 100644 --- a/collections/_topics/emise.md +++ b/collections/_topics/emise.md @@ -166,6 +166,7 @@ subtopics: lead: | Podíl jednotlivých sektorů na emisích skleníkových plynů poskytuje užitečné vodítko pro zaměření mitigačních snah. Největších emisních úspor může Česko dosáhnout **proměnou** svého **energetického mixu**. Jednotlivci však také mohou přispět ke snížení emisí, například **snížením energetické náročnosti** svých domácností nebo **omezením automobilové dopravy**, případně také **nižší konzumací masa a mléčných výrobků**. content: # U nového obsahu zvaž přidání také do dohody-legislativa>eu a ekonomika>opatreni. + - 2026-opatreni-dekarbonizace-domacnosti - potencial-zpusobu-snizeni-emisi - emisni-povolenky-ets - emisni-povolenky-ets-2 diff --git a/collections/_topics/energetika.md b/collections/_topics/energetika.md index 4dff5a78c..92d4639c9 100644 --- a/collections/_topics/energetika.md +++ b/collections/_topics/energetika.md @@ -152,14 +152,16 @@ subtopics: - uzemni-stopa-oze - 2024-reserse-vodik -- id: "doprava" - title: "Doprava" - title-short: "Doprava" +- id: "doprava-budovy" + title: "Doprava a budovy" + title-short: "Doprava a budovy" + # TODO: fix the lead (it was anyway outdated) lead: | Emise z dopravy tvoří globálně téměř pětinu všech emisí skleníkových plynů. V Česku je přeprava osob a zboží zodpovědná zhruba za 16 % emisí a objem emisí stále roste. Hlavními nástroji dekarbonizace v sektoru dopravy jsou **změna způsobu dopravy**, **snižování potřeby cestovat** a **elektrifikace** dopravních prostředků. Jak tyto nástroje můžeme aplikovat v praxi? Kolik elektřiny by bylo v Česku potřeba pro nahrazení stávajících vozů se spalovacími motory elektroauty? Jak může v dekarbonizaci pomoci železniční doprava? content: + - 2026-opatreni-dekarbonizace-domacnosti - emise-doprava - elektrifikace-dopravy - 52-dekarbonizace-dopravy @@ -171,7 +173,7 @@ subtopics: - id: "scenare-2030-cr" title: "Scénáře vývoje: Česká energetika v roce 2030" - title-short: "Scénáře Česko 2030" + title-short: "Scénáře ČR 2030" lead: | EU se zavázala **snížit emise skleníkových plynů do roku 2030 o 55 %** (oproti roku 1990). K dosažení tohoto cíle je klíčová právě transformace energetiky. **Jaké jsou scénáře pro transformaci tohoto sektoru v Česku?** qa: @@ -196,7 +198,7 @@ subtopics: - id: "serie-elektrina-2050-cr" series: True title: "Bezemisní energetika v Česku v roce 2050" - title-short: "Bezemisní energetika v Česku 2050" + title-short: "Bezemisní energetika v ČR 2050" series-short-lead: | Tento text je součástí série textů o základních kamenech bezemisní energetiky. lead: |