├── public ├── CNAME ├── logo.png ├── github-64.png ├── logo128.png ├── logo192.png ├── logo512.png ├── robots.txt ├── logo_square-white.png ├── manifest.json ├── vehicles.csv └── logo_new_light.svg ├── postcss.config.js ├── src ├── simulator │ ├── db │ │ ├── classType.ts │ │ ├── vehicleTypes.ts │ │ ├── VehicleProvider.tsx │ │ ├── vehicle.ts │ │ └── country.ts │ ├── footprintEstimator │ │ ├── vehicleFootprintModel.ts │ │ ├── model │ │ │ ├── carbone42020ICE.ts │ │ │ ├── carbone42020Electric.ts │ │ │ └── carbone42020ICCT2021Electric.ts │ │ ├── FootprintProvider.tsx │ │ └── footprintEstimator.ts │ ├── landingPage │ │ ├── comparison │ │ │ ├── bubble1.svg │ │ │ ├── bubble3.svg │ │ │ ├── bubble2.svg │ │ │ ├── LineChart.tsx │ │ │ ├── BarChart.tsx │ │ │ └── Comparison.tsx │ │ ├── SimulatorSection.tsx │ │ ├── headerIcon3.svg │ │ ├── headerIcon1.svg │ │ ├── combinations │ │ │ └── Combinations.tsx │ │ ├── LandingPage.tsx │ │ └── headerIcon2.svg │ └── SectionTitle.tsx ├── utils │ ├── notEmpty.ts │ ├── assertUnreachable.ts │ ├── VehicleTitle.tsx │ ├── arrow.svg │ ├── EnergyIcon.tsx │ ├── Sticky.tsx │ ├── mediumElectricity.svg │ ├── mediumDiesel.svg │ ├── mediumGasoline.svg │ └── StickyCollapse.tsx ├── components │ ├── navigation │ │ ├── routes.ts │ │ ├── NavigationMenu.tsx │ │ ├── NavFooter.tsx │ │ └── NavHeader.tsx │ └── design │ │ ├── Parameter.tsx │ │ └── colors.ts ├── lib │ ├── basePath.ts │ └── vehicleCombinations.ts ├── methodology │ └── Methodology.tsx └── about │ └── About.tsx ├── app ├── simulator │ └── page.tsx ├── about │ └── page.tsx ├── methodology │ └── page.tsx ├── globals.css ├── page.tsx ├── sitemap.ts ├── compare │ └── [vehicle1] │ │ └── [vehicle2] │ │ └── page.tsx └── layout.tsx ├── next-env.d.ts ├── next.config.js ├── .gitignore ├── tailwind.config.js ├── scripts └── og-images │ ├── LegendItem.tsx │ ├── helpers.ts │ ├── data.ts │ ├── index.ts │ ├── generate.ts │ ├── VerticalStackedBar.tsx │ └── OgImage.tsx ├── tsconfig.json ├── LICENSE ├── package.json ├── README.md └── .github └── workflows └── deploy.yml /public/CNAME: -------------------------------------------------------------------------------- 1 | evfootprint.org 2 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Traace-co/ev-footprint/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/github-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Traace-co/ev-footprint/HEAD/public/github-64.png -------------------------------------------------------------------------------- /public/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Traace-co/ev-footprint/HEAD/public/logo128.png -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Traace-co/ev-footprint/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Traace-co/ev-footprint/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/logo_square-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Traace-co/ev-footprint/HEAD/public/logo_square-white.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/simulator/db/classType.ts: -------------------------------------------------------------------------------- 1 | export enum ClassType { 2 | Light = 'light', 3 | Regular = 'regular', 4 | Heavy = 'heavy' 5 | } -------------------------------------------------------------------------------- /app/simulator/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | export default function SimulatorPage() { 4 | redirect('/') 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/notEmpty.ts: -------------------------------------------------------------------------------- 1 | export function notEmpty(value: TValue | null | undefined): value is TValue { 2 | return value !== null && value !== undefined; 3 | } -------------------------------------------------------------------------------- /src/components/navigation/routes.ts: -------------------------------------------------------------------------------- 1 | export const routes = { 2 | pathnames: { 3 | simulator: "", 4 | methodology: "methodology", 5 | about: "about", 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/basePath.ts: -------------------------------------------------------------------------------- 1 | // Base path for assets (empty for custom domain evfootprint.org) 2 | export const basePath = ""; 3 | 4 | export function withBasePath(path: string): string { 5 | return path; 6 | } 7 | -------------------------------------------------------------------------------- /src/simulator/footprintEstimator/vehicleFootprintModel.ts: -------------------------------------------------------------------------------- 1 | export interface VehicleFootprintModel { 2 | productionkgCO2PerKg: number 3 | endOfLifekgCO2PerKg: number 4 | kgCO2BatteryPerKWh?: number 5 | } -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/simulator/footprintEstimator/model/carbone42020ICE.ts: -------------------------------------------------------------------------------- 1 | import { VehicleFootprintModel } from "../vehicleFootprintModel"; 2 | 3 | export class Carbone42020InternalCombustionEngine implements VehicleFootprintModel { 4 | productionkgCO2PerKg = 5.2 5 | endOfLifekgCO2PerKg = 0.4 6 | } -------------------------------------------------------------------------------- /src/utils/assertUnreachable.ts: -------------------------------------------------------------------------------- 1 | // Hack to check for exhaustive matching 2 | // https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript 3 | export function assertUnreachable(x: never): never { 4 | throw new Error("Didn't expect to get here"); 5 | } -------------------------------------------------------------------------------- /src/simulator/footprintEstimator/model/carbone42020Electric.ts: -------------------------------------------------------------------------------- 1 | import { VehicleFootprintModel } from "../vehicleFootprintModel"; 2 | 3 | export class Carbone42020Electric implements VehicleFootprintModel { 4 | productionkgCO2PerKg = 4.8 5 | endOfLifekgCO2PerKg = 0.4 6 | kgCO2BatteryPerKWh = 101 7 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "export", 4 | 5 | // Required for static export with images 6 | images: { 7 | unoptimized: true, 8 | }, 9 | 10 | // Trailing slashes help with routing 11 | trailingSlash: true, 12 | }; 13 | 14 | module.exports = nextConfig; 15 | -------------------------------------------------------------------------------- /src/components/design/Parameter.tsx: -------------------------------------------------------------------------------- 1 | export function Parameter(props: { title: string, children: React.ReactNode }) { 2 | return
3 |
4 | {props.title} 5 |
6 |
7 | {props.children} 8 |
9 |
10 | } 11 | -------------------------------------------------------------------------------- /src/simulator/footprintEstimator/model/carbone42020ICCT2021Electric.ts: -------------------------------------------------------------------------------- 1 | import { VehicleFootprintModel } from "../vehicleFootprintModel"; 2 | 3 | // Combination of the ICarbone 4 stufy 4 | export class Carbone42020ICCT2021Electric implements VehicleFootprintModel { 5 | productionkgCO2PerKg = 4.8 6 | endOfLifekgCO2PerKg = 0.4 7 | kgCO2BatteryPerKWh = 60 8 | } -------------------------------------------------------------------------------- /src/simulator/landingPage/comparison/bubble1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/simulator/landingPage/comparison/bubble3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/simulator/landingPage/comparison/bubble2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | import { About } from '@/about/About' 3 | 4 | export const metadata: Metadata = { 5 | title: 'About - Footprint simulator for electric vehicles', 6 | description: 'Learn about the EV Footprint simulator, its purpose, and the team behind it.', 7 | } 8 | 9 | export default function AboutPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/simulator/landingPage/SimulatorSection.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { SectionTitle } from "../SectionTitle"; 3 | 4 | export function SimulatorSection(props: { title: ReactNode, children: ReactNode, level: 1 | 2 | 3, icon?: string }) { 5 | const { children } = props 6 | 7 | return
8 | 9 | {children} 10 |
11 | } -------------------------------------------------------------------------------- /src/utils/VehicleTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Vehicle } from "../simulator/db/vehicleTypes"; 2 | import { EnergyIcon } from "./EnergyIcon"; 3 | 4 | export function VehicleTitle(props: { vehicle: Vehicle }) { 5 | const { vehicle } = props 6 | return <> 7 | 8 | 9 | 10 | {`${vehicle.name}`} 11 | 12 | } -------------------------------------------------------------------------------- /app/methodology/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | import { Methodology } from '@/methodology/Methodology' 3 | 4 | export const metadata: Metadata = { 5 | title: 'Methodology - Footprint simulator for electric vehicles', 6 | description: 'Understand the methodology and research behind the EV Footprint simulator.', 7 | } 8 | 9 | export default function MethodologyPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /out 14 | /.next 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /src/utils/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: 8 | Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", 9 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: 16 | source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/design/colors.ts: -------------------------------------------------------------------------------- 1 | import { Energy } from "@/simulator/db/vehicleTypes"; 2 | 3 | export function colorFromEnergy(energy: Energy): string { 4 | switch (energy) { 5 | case Energy.Electricity: 6 | return "#FADB13"; 7 | case Energy.Diesel: 8 | return "#200F76"; 9 | case Energy.Gasoline: 10 | return "#041C15"; 11 | } 12 | } 13 | 14 | export const navigationBackgroundColor = "#2B225A"; 15 | export const secondaryBackgroundColor = "#C1AEFF33"; 16 | export const textColor = "#041C15"; 17 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./app/**/*.{js,jsx,ts,tsx}", 5 | "./src/**/*.{js,jsx,ts,tsx}", 6 | ], 7 | theme: { 8 | fontFamily: { 9 | sans: ['Roboto', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'] 10 | }, 11 | extend: {}, 12 | }, 13 | plugins: [], 14 | } 15 | -------------------------------------------------------------------------------- /scripts/og-images/LegendItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function LegendItem({ color, label }: { color: string; label: string }) { 4 | return ( 5 |
6 |
16 |
{label}
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "EV Footprint", 3 | "name": "EV Footprint: Should you switch to an electric vehicle?", 4 | "icons": [ 5 | { 6 | "src": "logo128.png", 7 | "type": "image/png", 8 | "sizes": "128x128" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } -------------------------------------------------------------------------------- /public/vehicles.csv: -------------------------------------------------------------------------------- 1 | id,name,weightUnladenKg,batteryCapacitykWh,averageRangeKm,averageConsumptionPer100km,energy 2 | light-gasoline-e95,Light Gasoline E95 Car (B Segment),1145,,,"6,3",Gasoline 3 | gasoline-e95,Medium Gasoline E95 Car (D Segment),1520,,,"8,3",Gasoline 4 | diesel,Medium Diesel Car (D Segment),1560,,,"6,9",Diesel 5 | large-suv-gasoline-e95,Large Gasoline E95 SUV,2175,,,"12",Gasoline 6 | large-suv-diesel,Large Diesel SUV,2245,,,"9,8",Diesel 7 | electric-car,Medium Electric Car (D Segment),1800,60,"285,7",,Electricity 8 | light-electric-car,Light Electric Car (B Segment),1333,50,"312,5",,Electricity 9 | large-suv-electric-car,Large Electric SUV,2352,100,400,,Electricity -------------------------------------------------------------------------------- /src/simulator/db/vehicleTypes.ts: -------------------------------------------------------------------------------- 1 | export enum Energy { 2 | Gasoline = "Gasoline", 3 | Diesel = "Diesel", 4 | Electricity = "Electricity", 5 | } 6 | 7 | export interface Vehicle { 8 | id: string; 9 | name: string; 10 | weightUnladenKg: number; 11 | batteryCapacitykWh?: number; 12 | averageRangeKm?: number; 13 | averageConsumptionPer100km?: number; 14 | energy: Energy; 15 | } 16 | 17 | export interface Country { 18 | id: string; 19 | name: string; 20 | kgCO2PerKWh: number; 21 | emoji?: string; 22 | } 23 | 24 | export function parseSanitizedFloat(value: string): number | undefined { 25 | const trimmed = value.trim(); 26 | return trimmed.length > 0 ? parseFloat(trimmed.replace(",", ".")) : undefined; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/simulator/landingPage/headerIcon3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/utils/EnergyIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Energy } from "../simulator/db/vehicleTypes" 2 | import { assertUnreachable } from './assertUnreachable' 3 | import dieselIcon from './mediumDiesel.svg' 4 | import electricityIcon from './mediumElectricity.svg' 5 | import gasolineIcon from './mediumGasoline.svg' 6 | 7 | export function iconSrcForEnergy(energy: Energy) { 8 | switch (energy) { 9 | case Energy.Gasoline: 10 | return gasolineIcon.src 11 | case Energy.Diesel: 12 | return dieselIcon.src 13 | case Energy.Electricity: 14 | return electricityIcon.src 15 | } 16 | assertUnreachable(energy) 17 | } 18 | 19 | export function EnergyIcon(props: { energy: Energy }) { 20 | return {props.energy} 21 | } 22 | -------------------------------------------------------------------------------- /src/simulator/SectionTitle.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export function SectionTitle(props: { title: ReactNode, level: 1 | 2 | 3, icon?: string }) { 4 | const { level, icon, title } = props 5 | 6 | let heading: ReactNode 7 | 8 | switch (level) { 9 | case 1: 10 | heading =

{title}

11 | break 12 | case 2: 13 | heading =

{title}

14 | break 15 | case 3: 16 | heading =

{title}

17 | break 18 | default: 19 | break 20 | } 21 | 22 | return
23 | {icon && 24 |
25 | 26 |
27 | } 28 | {heading} 29 |
30 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { LandingPage } from '@/simulator/landingPage/LandingPage' 2 | import type { Metadata } from 'next' 3 | 4 | const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://evfootprint.org' 5 | 6 | const title = 'EV Footprint - Footprint simulator for electric vehicles' 7 | const description = 'Simulate the true impact on climate and CO2 emissions of choosing an electric car rather than a traditional gasoline car.' 8 | 9 | export const metadata: Metadata = { 10 | metadataBase: new URL(SITE_URL), 11 | title, 12 | description, 13 | openGraph: { 14 | title, 15 | description, 16 | url: SITE_URL, 17 | siteName: 'EV Footprint', 18 | type: 'website', 19 | }, 20 | twitter: { 21 | card: 'summary_large_image', 22 | title, 23 | description, 24 | }, 25 | } 26 | 27 | export default function Home() { 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules", 40 | "scripts" 41 | ] 42 | } -------------------------------------------------------------------------------- /src/simulator/db/VehicleProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from "antd" 2 | import React, { useEffect, useState } from "react" 3 | import { parseAllVehicles } from "../db/vehicle" 4 | import { Vehicle } from "../db/vehicleTypes" 5 | 6 | export interface VehicleProviderInterface { 7 | allVehicles: Vehicle[] 8 | } 9 | export const VehicleContext = React.createContext({ allVehicles: [] }) 10 | 11 | export function VehicleProvider(props: { children: React.ReactNode }) { 12 | const [allVehicles, setAllVehicles] = useState() 13 | 14 | useEffect(() => { 15 | if (allVehicles) { return } 16 | 17 | parseAllVehicles().then(vehicles => setAllVehicles(vehicles)) 18 | }) 19 | 20 | if (!allVehicles) { 21 | return 22 | } 23 | return ( 24 | 25 | {props.children} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { generateAllCombinations } from "@/lib/vehicleCombinations"; 2 | import { MetadataRoute } from "next"; 3 | 4 | export default function sitemap(): MetadataRoute.Sitemap { 5 | const baseUrl = "https://evfootprint.org"; 6 | 7 | const mainPages: MetadataRoute.Sitemap = [ 8 | { 9 | url: baseUrl, 10 | lastModified: new Date(), 11 | priority: 1, 12 | }, 13 | { 14 | url: `${baseUrl}/methodology`, 15 | lastModified: new Date(), 16 | priority: 0.8, 17 | }, 18 | { 19 | url: `${baseUrl}/about`, 20 | lastModified: new Date(), 21 | priority: 0.8, 22 | }, 23 | ]; 24 | 25 | const combinations = generateAllCombinations(); 26 | const comparisonPages: MetadataRoute.Sitemap = combinations.map((combo) => ({ 27 | url: `${baseUrl}/compare/${combo.vehicle1}/${combo.vehicle2}/`, 28 | lastModified: new Date(), 29 | priority: 0.7, 30 | })); 31 | 32 | return [...mainPages, ...comparisonPages]; 33 | } 34 | -------------------------------------------------------------------------------- /scripts/og-images/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Vehicle } from "../../src/simulator/db/vehicleTypes"; 2 | 3 | export function getVehicleById( 4 | vehicles: Vehicle[], 5 | id: string, 6 | ): Vehicle | undefined { 7 | return vehicles.find((v) => v.id === id); 8 | } 9 | 10 | export function getShortName(name: string): string { 11 | // Shorten vehicle names for the chart 12 | return name 13 | .replace(" (B Segment)", "") 14 | .replace(" (D Segment)", "") 15 | .replace("Gasoline E95 ", "Gasoline "); 16 | } 17 | 18 | export interface VehicleCombination { 19 | vehicle1: string; 20 | vehicle2: string; 21 | } 22 | 23 | export function generateAllCombinations( 24 | vehicles: Vehicle[], 25 | ): VehicleCombination[] { 26 | const combinations: VehicleCombination[] = []; 27 | for (const v1 of vehicles) { 28 | for (const v2 of vehicles) { 29 | combinations.push({ vehicle1: v1.id, vehicle2: v2.id }); 30 | } 31 | } 32 | return combinations; 33 | } 34 | -------------------------------------------------------------------------------- /src/simulator/db/vehicle.ts: -------------------------------------------------------------------------------- 1 | import { withBasePath } from "@/lib/basePath"; 2 | import { parse } from "csv-parse/browser/esm/sync"; 3 | import { Energy, parseSanitizedFloat, Vehicle } from "./vehicleTypes"; 4 | 5 | export async function parseAllVehicles(): Promise { 6 | const databaseString = await ( 7 | await fetch(withBasePath("/vehicles.csv")) 8 | ).text(); 9 | const records = parse(databaseString, { 10 | columns: true, 11 | skip_empty_lines: true, 12 | }); 13 | return records.map((record: any) => { 14 | return { 15 | id: record.id, 16 | name: record.name, 17 | batteryCapacitykWh: parseSanitizedFloat(record.batteryCapacitykWh), 18 | averageConsumptionPer100km: parseSanitizedFloat( 19 | record.averageConsumptionPer100km, 20 | ), 21 | averageRangeKm: parseSanitizedFloat(record.averageRangeKm), 22 | weightUnladenKg: parseSanitizedFloat(record.weightUnladenKg), 23 | energy: record.energy as Energy, 24 | }; 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/simulator/landingPage/headerIcon1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/utils/Sticky.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useRef, useState } from "react"; 2 | 3 | export function Sticky(props: { children: (isSticky: boolean) => ReactNode }) { 4 | const ref = useRef(null) 5 | const [isSticky, setIsSticky] = useState(false) 6 | 7 | useEffect(() => { 8 | const cachedRef = ref.current 9 | if (!cachedRef) { return } 10 | const observer = new IntersectionObserver( 11 | ([e]) => { 12 | setIsSticky(e.intersectionRatio < 1 && e.boundingClientRect.top < 0) 13 | }, 14 | { 15 | threshold: [1], 16 | } 17 | ) 18 | 19 | observer.observe(cachedRef) 20 | 21 | // unmount 22 | return function () { 23 | observer.unobserve(cachedRef) 24 | } 25 | }, [ref]) 26 | 27 | return
35 | {props.children(isSticky)} 36 |
37 | } -------------------------------------------------------------------------------- /src/components/navigation/NavigationMenu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Menu } from 'antd' 4 | import Link from 'next/link' 5 | import { usePathname } from 'next/navigation' 6 | import { navigationBackgroundColor } from '../design/colors' 7 | import { routes } from './routes' 8 | 9 | export function NavigationMenu(props: { className?: string }) { 10 | const pathname = usePathname() 11 | const selectedKey = pathname.replace('/', '').replace(/\/$/, '') || routes.pathnames.simulator 12 | 13 | return ( 14 | SIMULATOR 23 | }, { 24 | key: routes.pathnames.methodology, 25 | label: METHODOLOGY 26 | }, { 27 | key: routes.pathnames.about, 28 | label: ABOUT 29 | }]} 30 | /> 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tennaxia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/simulator/landingPage/combinations/Combinations.tsx: -------------------------------------------------------------------------------- 1 | import { VehicleTitle } from "@/utils/VehicleTitle"; 2 | import Link from "next/link"; 3 | import { useContext } from "react"; 4 | import { VehicleContext } from "../../db/VehicleProvider"; 5 | import { Energy } from "../../db/vehicleTypes"; 6 | 7 | export function Combinations() { 8 | const { allVehicles } = useContext(VehicleContext) 9 | return
10 | {allVehicles.map(vehicle1 => ( 11 | allVehicles.filter(v => v.energy === Energy.Electricity 12 | && (vehicle1.energy !== Energy.Electricity 13 | || (vehicle1.energy === Energy.Electricity && v.id < vehicle1.id)) // Prevent duplicate comparisons 14 | ).map(vehicle2 => ( 15 |
16 | 17 |

18 | 19 | 20 | vs 21 | 22 | 23 |

24 | 25 |
26 | )) 27 | ) 28 | ) 29 | } 30 |
31 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ev-footprint", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ant-design/icons": "^5.5.0", 7 | "@ant-design/nextjs-registry": "^1.0.0", 8 | "@testing-library/jest-dom": "^5.16.4", 9 | "@testing-library/react": "^13.3.0", 10 | "@testing-library/user-event": "^14.3.0", 11 | "antd": "^5.22.0", 12 | "chart.js": "^3.8.2", 13 | "chartjs-plugin-datalabels": "^2.1.0", 14 | "csv-parse": "^5.3.0", 15 | "next": "^14.2.0", 16 | "polished": "^4.2.2", 17 | "react": "^18.2.0", 18 | "react-chartjs-2": "^4.3.1", 19 | "react-dom": "^18.2.0", 20 | "react-responsive": "^9.0.0-beta.10", 21 | "styled-components": "^5.3.5", 22 | "web-vitals": "^2.1.4" 23 | }, 24 | "scripts": { 25 | "dev": "next dev", 26 | "build": "next build && tsx scripts/og-images/index.ts", 27 | "start": "next start -p 3006", 28 | "lint": "next lint" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^20.0.0", 32 | "@types/react": "^18.2.0", 33 | "@types/react-dom": "^18.2.0", 34 | "autoprefixer": "^10.4.7", 35 | "postcss": "^8.4.31", 36 | "satori": "^0.18.3", 37 | "sharp": "^0.34.5", 38 | "tailwindcss": "^3.1.6", 39 | "tsx": "^4.21.0", 40 | "typescript": "^5.0.0" 41 | } 42 | } -------------------------------------------------------------------------------- /src/components/navigation/NavFooter.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { withBasePath } from '@/lib/basePath' 4 | import { Divider } from 'antd' 5 | import Link from 'next/link' 6 | import { routes } from './routes' 7 | 8 | function NavLink(props: { pathname: string, title: string }) { 9 | const { pathname, title } = props 10 | return ( 11 | 12 | {title} 13 | 14 | ) 15 | } 16 | 17 | export function NavFooter() { 18 | return ( 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 |
27 |
Built with 💚 by
28 |
29 | 30 | Tennaxia logo 31 | 32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/simulator/footprintEstimator/FootprintProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import { Country } from "../db/country" 3 | import { VehicleContext } from "../db/VehicleProvider" 4 | import { Footprint, FootprintEstimator } from "./footprintEstimator" 5 | 6 | export interface NamedFootprint { 7 | name: string 8 | footprint: Footprint 9 | } 10 | 11 | export interface FootprintProviderInterface { 12 | footprints: NamedFootprint[] 13 | maxFootprint: NamedFootprint, minFootprint: NamedFootprint, 14 | maxUsageFootprint: NamedFootprint, minUsageFootprint: NamedFootprint, 15 | ratio: number, usageRatio: number 16 | } 17 | export const FootprintContext = React.createContext(undefined) 18 | 19 | export function FootprintProvider(props: { 20 | children: React.ReactNode, totalDistanceKm: number, country: Country, 21 | vehicle1Id: string, vehicle2Id: string 22 | }) { 23 | const { totalDistanceKm, country, vehicle1Id, vehicle2Id } = props 24 | 25 | const { allVehicles } = useContext(VehicleContext) 26 | 27 | const estimate = new FootprintEstimator().computeEmissions({ 28 | vehicles: allVehicles.filter(v => v.id === vehicle1Id || v.id === vehicle2Id), totalDistanceKm, country 29 | }) 30 | 31 | return ( 32 | 33 | {props.children} 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/simulator/db/country.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface Country { 3 | id: string 4 | name: string 5 | kgCO2PerKWh: number, 6 | emoji: string 7 | } 8 | 9 | export const allCountries: Country[] = [ 10 | // Source for kgCO2PerKWh (unless otherwise specified): 11 | // ADEME (https://bilans-ges.ademe.fr/documentation/UPLOAD_DOC_FR/index.htm?moyenne_par_pays.htm) 12 | { 13 | id: 'eu', 14 | name: 'Europe', 15 | emoji: '🇪🇺', 16 | kgCO2PerKWh: 0.306 // Source: Carbone 4 17 | }, 18 | { 19 | id: 'us', 20 | name: 'USA', 21 | emoji: '🇺🇸', 22 | kgCO2PerKWh: 0.522 23 | }, 24 | { 25 | id: 'fr', 26 | name: 'France', 27 | emoji: '🇫🇷', 28 | kgCO2PerKWh: 0.0505 // Source: Carbone 4 29 | }, 30 | { 31 | id: 'de', 32 | name: 'Germany', 33 | emoji: '🇩🇪', 34 | kgCO2PerKWh: 0.461 35 | }, 36 | { 37 | id: 'cn', 38 | name: 'China', 39 | emoji: '🇨🇳', 40 | kgCO2PerKWh: 0.766 41 | }, 42 | { 43 | id: 'in', 44 | name: 'India', 45 | emoji: '🇮🇳', 46 | kgCO2PerKWh: 0.912 47 | }, 48 | { 49 | id: 'pl', 50 | name: 'Poland', 51 | emoji: '🇵🇱', 52 | kgCO2PerKWh: 0.781 53 | }, 54 | { 55 | id: 'se', 56 | name: 'Sweden', 57 | emoji: '🇸🇪', 58 | kgCO2PerKWh: 0.03 59 | }, 60 | { 61 | id: 'renewable', 62 | name: '100% Renewable', 63 | emoji: '🌿', 64 | kgCO2PerKWh: 0.018 // Source: Carbone 4 65 | } 66 | ] -------------------------------------------------------------------------------- /scripts/og-images/data.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "csv-parse/sync"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import { allCountries } from "../../src/simulator/db/country"; 5 | import { 6 | Energy, 7 | parseSanitizedFloat, 8 | Vehicle, 9 | } from "../../src/simulator/db/vehicleTypes"; 10 | import { FootprintEstimator } from "../../src/simulator/footprintEstimator/footprintEstimator"; 11 | 12 | export function loadVehiclesFromCsv(): Vehicle[] { 13 | const csvPath = path.join(process.cwd(), "public", "vehicles.csv"); 14 | const csvContent = fs.readFileSync(csvPath, "utf-8"); 15 | 16 | const records = parse(csvContent, { 17 | columns: true, 18 | skip_empty_lines: true, 19 | }); 20 | 21 | return records.map((record: any) => ({ 22 | id: record.id, 23 | name: record.name, 24 | weightUnladenKg: parseSanitizedFloat(record.weightUnladenKg) ?? 0, 25 | batteryCapacitykWh: parseSanitizedFloat(record.batteryCapacitykWh), 26 | averageRangeKm: parseSanitizedFloat(record.averageRangeKm), 27 | averageConsumptionPer100km: parseSanitizedFloat( 28 | record.averageConsumptionPer100km, 29 | ), 30 | energy: record.energy as Energy, 31 | })); 32 | } 33 | 34 | // Use EU average for OG images 35 | export const defaultCountry = allCountries.find((c) => c.id === "fr")!; 36 | export const DEFAULT_TOTAL_DISTANCE_KM = 200000; 37 | 38 | // Reuse FootprintEstimator from the main codebase 39 | export const footprintEstimator = new FootprintEstimator(); 40 | -------------------------------------------------------------------------------- /src/utils/mediumElectricity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/methodology/Methodology.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Typography } from "antd"; 4 | 5 | export function Methodology() { 6 | const { Paragraph, Title } = Typography 7 | return
8 | 9 | Hypotheses 10 | 11 | 12 | Our model was built thanks to the research of the French consultancy firm Carbone 4. For a full understanding of the hypotheses you can read the publication on their website: [EN] Road transportation: what alternative motorisations are suitable for the climate?. 13 | 14 | 15 | For the purpose of this simulator we used a simplified model, focusing on Gasoline and Electricity. 16 | 17 | 18 | For the estimation of the emissions caused by battery manufacturing, we used more up-to-date data from ICCT. 19 | 20 | 21 | Open-source 22 | 23 | 24 | This simulator was released under an open-source license. Feel free to contribute or give feedback by creating issues on the GitHub project! 25 | 26 |
27 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EV Footprint 2 | 3 | ## What is it? 4 | 5 | This simulator is based on the latest studies and we built it to: 6 | * simply compare carbon emissions through their lifecycle of most common types of cars. 7 | * break down misconceptions about the electric car. 8 | * help you estimate if you should switch to an electric vehicle. 9 | 10 | ## Who created this tool? 11 | 12 | This simulator was originally created as a side project by team members of [Tennaxia](https://tennaxia.com). 13 | At Tennaxia, we help companies to effectively manage their sustainability, ESG, Carbon and EHS operations, measure their impact and achieve concrete progress. 14 | 15 | ## Is it really open source? 16 | 17 | Yes. We believe that trust and transparency go together, and we want the model and the hypotheses to be as transparent as possible. 18 | 19 | ## How can I contribute? 20 | 21 | Just open an issue in the project and describe the issue that you are facing or the feature request that your suggest. The project administrators will discuss it with you. 22 | 23 | ## Technical Overview 24 | 25 | Built with **Next.js 14** (App Router), **Ant Design 5**, **Tailwind CSS**, and **Chart.js**. Pages are in `app/`, components and simulator logic in `src/`. 26 | 27 | ### Running Locally 28 | 29 | ```bash 30 | npm install 31 | npm run dev # Development at http://localhost:3000 32 | npm run build # Production build (static export to ./out) 33 | ``` 34 | 35 | ### Deployment 36 | 37 | Configured for GitHub Pages with static export. Push to `main` triggers automatic deployment via GitHub Actions. 38 | -------------------------------------------------------------------------------- /src/simulator/landingPage/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { VehicleProvider } from "../db/VehicleProvider"; 4 | import { Combinations } from "./combinations/Combinations"; 5 | import { Comparison } from "./comparison/Comparison"; 6 | import headerIcon1 from './headerIcon1.svg'; 7 | import { SimulatorSection } from "./SimulatorSection"; 8 | 9 | interface LandingPageProps { 10 | vehicle1Id?: string; 11 | vehicle2Id?: string; 12 | } 13 | 14 | export function LandingPage({ vehicle1Id, vehicle2Id }: LandingPageProps = {}) { 15 | return
16 |
17 |
18 | 22 |
23 | This simulator is based on the latest studies and we built it to: 24 |
    25 |
  • simply compare carbon emissions through their lifecycle of most common types of cars.
  • 26 |
  • break down misconceptions about the electric car.
  • 27 |
  • help you estimate if you should switch to an electric vehicle.
  • 28 |
29 |
30 |
31 |
32 |
33 | 34 | <> 35 | 36 | 39 | 40 | 41 | 42 | 43 |
44 | 45 | } -------------------------------------------------------------------------------- /src/simulator/landingPage/headerIcon2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /scripts/og-images/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { loadVehiclesFromCsv } from "./data"; 4 | import { generateOgImage, loadFont } from "./generate"; 5 | import { generateAllCombinations } from "./helpers"; 6 | 7 | async function main() { 8 | console.log("Loading vehicles from CSV..."); 9 | const vehicles = loadVehiclesFromCsv(); 10 | console.log(`Found ${vehicles.length} vehicles.`); 11 | 12 | console.log("Loading font..."); 13 | const fontData = await loadFont(); 14 | 15 | console.log("Loading logo..."); 16 | const logoPath = path.join(process.cwd(), "public", "logo_square-white.png"); 17 | const logoBuffer = fs.readFileSync(logoPath); 18 | const logoBase64 = `data:image/png;base64,${logoBuffer.toString("base64")}`; 19 | 20 | const combinations = generateAllCombinations(vehicles); 21 | console.log(`Generating ${combinations.length} OG images...`); 22 | 23 | let count = 0; 24 | for (const combo of combinations) { 25 | const outputDir = path.join( 26 | process.cwd(), 27 | "out", 28 | "compare", 29 | combo.vehicle1, 30 | combo.vehicle2, 31 | ); 32 | 33 | fs.mkdirSync(outputDir, { recursive: true }); 34 | 35 | const outputPath = path.join(outputDir, "opengraph-image.png"); 36 | const pngBuffer = await generateOgImage( 37 | vehicles, 38 | combo.vehicle1, 39 | combo.vehicle2, 40 | fontData, 41 | logoBase64, 42 | ); 43 | fs.writeFileSync(outputPath, pngBuffer); 44 | 45 | count++; 46 | if (count % 10 === 0) { 47 | console.log(` Generated ${count}/${combinations.length} images...`); 48 | } 49 | } 50 | 51 | console.log(`Done! Generated ${combinations.length} OG images.`); 52 | } 53 | 54 | main().catch((err) => { 55 | console.error("Error generating OG images:", err); 56 | process.exit(1); 57 | }); 58 | -------------------------------------------------------------------------------- /scripts/og-images/generate.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import satori from "satori"; 3 | import sharp from "sharp"; 4 | import { Vehicle } from "../../src/simulator/db/vehicleTypes"; 5 | import { 6 | DEFAULT_TOTAL_DISTANCE_KM, 7 | defaultCountry, 8 | footprintEstimator, 9 | } from "./data"; 10 | import { getVehicleById } from "./helpers"; 11 | import { OgImage } from "./OgImage"; 12 | 13 | export async function loadFont(): Promise { 14 | const response = await fetch( 15 | "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff", 16 | ); 17 | return response.arrayBuffer(); 18 | } 19 | 20 | export async function generateOgImage( 21 | vehicles: Vehicle[], 22 | vehicle1Id: string, 23 | vehicle2Id: string, 24 | fontData: ArrayBuffer, 25 | logoBase64: string, 26 | ): Promise { 27 | const vehicle1 = getVehicleById(vehicles, vehicle1Id)!; 28 | const vehicle2 = getVehicleById(vehicles, vehicle2Id)!; 29 | 30 | const footprint1 = footprintEstimator.estimate({ 31 | vehicle: vehicle1, 32 | totalDistanceKm: DEFAULT_TOTAL_DISTANCE_KM, 33 | country: defaultCountry, 34 | }); 35 | const footprint2 = footprintEstimator.estimate({ 36 | vehicle: vehicle2, 37 | totalDistanceKm: DEFAULT_TOTAL_DISTANCE_KM, 38 | country: defaultCountry, 39 | }); 40 | 41 | const svg = await satori( 42 | React.createElement(OgImage, { 43 | vehicle1Name: vehicle1.name, 44 | vehicle2Name: vehicle2.name, 45 | footprint1, 46 | footprint2, 47 | logoBase64, 48 | country: defaultCountry, 49 | }), 50 | { 51 | width: 1200, 52 | height: 627, 53 | fonts: [ 54 | { 55 | name: "Inter", 56 | data: fontData, 57 | weight: 400, 58 | style: "normal", 59 | }, 60 | ], 61 | }, 62 | ); 63 | 64 | const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); 65 | return pngBuffer; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/navigation/NavHeader.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { withBasePath } from '@/lib/basePath' 4 | import Link from 'next/link' 5 | import { usePathname } from 'next/navigation' 6 | import { NavigationMenu } from './NavigationMenu' 7 | import { routes } from './routes' 8 | 9 | export const baseTitle = 'Footprint simulator for electric vehicles' 10 | 11 | export function buildTitle(prefix: string) { 12 | return `${prefix} - ${baseTitle}` 13 | } 14 | 15 | export function NavHeader() { 16 | const pathname = usePathname() 17 | 18 | const selectedKey = pathname.replace('/', '').replace(/\/$/, '') 19 | 20 | let titlePrefix: string | undefined 21 | switch (selectedKey) { 22 | case routes.pathnames.simulator: 23 | case '': 24 | titlePrefix = 'EV Footprint' 25 | break 26 | case routes.pathnames.about: 27 | titlePrefix = 'About' 28 | break 29 | case routes.pathnames.methodology: 30 | titlePrefix = 'Methodology' 31 | break 32 | } 33 | 34 | return ( 35 |
36 |
37 | 38 | EV Footprint 40 | 41 |
42 | 43 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/mediumDiesel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/utils/mediumGasoline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/compare/[vehicle1]/[vehicle2]/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateAllCombinations, getVehicleDisplayName } from "@/lib/vehicleCombinations"; 2 | import { LandingPage } from "@/simulator/landingPage/LandingPage"; 3 | import type { Metadata } from "next"; 4 | 5 | const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://evfootprint.org"; 6 | 7 | interface Props { 8 | params: Promise<{ 9 | vehicle1: string; 10 | vehicle2: string; 11 | }>; 12 | } 13 | 14 | export async function generateStaticParams() { 15 | const combinations = generateAllCombinations(); 16 | return combinations.map((combo) => ({ 17 | vehicle1: combo.vehicle1, 18 | vehicle2: combo.vehicle2, 19 | })); 20 | } 21 | 22 | export async function generateMetadata({ params }: Props): Promise { 23 | const { vehicle1, vehicle2 } = await params; 24 | const vehicle1Name = getVehicleDisplayName(vehicle1); 25 | const vehicle2Name = getVehicleDisplayName(vehicle2); 26 | 27 | const title = `${vehicle1Name} vs ${vehicle2Name} - Carbon Footprint Comparison`; 28 | const description = `Compare the CO2 emissions and environmental impact of ${vehicle1Name} versus ${vehicle2Name} over their lifecycle. Find out which vehicle is better for the climate.`; 29 | const url = `${SITE_URL}/compare/${vehicle1}/${vehicle2}/`; 30 | 31 | const imageUrl = `${SITE_URL}/compare/${vehicle1}/${vehicle2}/opengraph-image.png`; 32 | 33 | return { 34 | metadataBase: new URL(SITE_URL), 35 | title, 36 | description, 37 | openGraph: { 38 | title, 39 | description, 40 | url, 41 | siteName: "EV Footprint", 42 | type: "website", 43 | images: [{ url: imageUrl, width: 1200, height: 627, alt: "Vehicle Carbon Footprint Comparison" }], 44 | }, 45 | twitter: { 46 | card: "summary_large_image", 47 | title, 48 | description, 49 | images: [imageUrl], 50 | }, 51 | }; 52 | } 53 | 54 | export default async function ComparePage({ params }: Props) { 55 | const { vehicle1, vehicle2 } = await params; 56 | return ; 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/StickyCollapse.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Collapse } from "antd"; 4 | import { ReactNode, useState } from "react"; 5 | import headerIcon2 from '../simulator/landingPage/headerIcon2.svg'; 6 | import { SectionTitle } from "../simulator/SectionTitle"; 7 | import arrow from './arrow.svg'; 8 | import { Sticky } from "./Sticky"; 9 | 10 | // Automatically collapses the content when the component becomes sticky 11 | export function StickyCollapse(props: { children: ReactNode }) { 12 | 13 | const [isActive, setIsActive] = useState(undefined) 14 | const [wasSticky, setWasSticky] = useState(false) 15 | 16 | return 17 | {isSticky => { 18 | if (isSticky) { 19 | setWasSticky(true) 20 | } 21 | const wasOrIsSticky = isSticky || wasSticky 22 | const shouldOpenPanel = isActive === undefined // undefined means that default behavior was not overridden 23 | ? !wasOrIsSticky // Remember if the component was sticky at least once, to prevent oscillation between states 24 | : isActive 25 | 26 | return
27 | ( 30 |
31 | 35 |
36 | )} 37 | expandIconPosition='right' 38 | onChange={onChange => { 39 | setIsActive(onChange.length > 0) 40 | }} 41 | activeKey={shouldOpenPanel ? ['parameters'] : []}> 42 | 46 | } > 47 | {props.children} 48 | 49 |
50 |
51 | } 52 | } 53 | 54 |
55 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { navigationBackgroundColor, textColor } from '@/components/design/colors' 4 | import { NavFooter } from '@/components/navigation/NavFooter' 5 | import { NavHeader } from '@/components/navigation/NavHeader' 6 | import { NavigationMenu } from '@/components/navigation/NavigationMenu' 7 | import { withBasePath } from '@/lib/basePath' 8 | import { AntdRegistry } from '@ant-design/nextjs-registry' 9 | import { ConfigProvider, Layout } from 'antd' 10 | import './globals.css' 11 | 12 | const { Header, Content, Footer } = Layout 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode 18 | }) { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 36 | 37 |
38 | 39 |
40 | 41 | 42 |
43 | {children} 44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/vehicleCombinations.ts: -------------------------------------------------------------------------------- 1 | import { Energy, Vehicle } from "@/simulator/db/vehicleTypes"; 2 | import { parse } from "csv-parse/browser/esm/sync"; 3 | 4 | // Parse vehicles synchronously from raw CSV string 5 | function parseVehiclesFromCsv(csvString: string): Vehicle[] { 6 | const records = parse(csvString, { 7 | columns: true, 8 | skip_empty_lines: true, 9 | }); 10 | return records.map((record: any) => ({ 11 | id: record.id, 12 | name: record.name, 13 | energy: record.energy as Energy, 14 | weightUnladenKg: parseFloat( 15 | record.weightUnladenKg?.replace(",", ".") || "0", 16 | ), 17 | })); 18 | } 19 | 20 | // Static vehicle data for build-time generation 21 | const vehiclesCsv = `id,name,weightUnladenKg,batteryCapacitykWh,averageRangeKm,averageConsumptionPer100km,energy 22 | light-gasoline-e95,Light Gasoline E95 Car (B Segment),1145,,,"6,3",Gasoline 23 | gasoline-e95,Medium Gasoline E95 Car (D Segment),1520,,,"8,3",Gasoline 24 | diesel,Medium Diesel Car (D Segment),1560,,,"6,9",Diesel 25 | large-suv-gasoline-e95,Large Gasoline E95 SUV,2175,,,"12",Gasoline 26 | large-suv-diesel,Large Diesel SUV,2245,,,"9,8",Diesel 27 | electric-car,Medium Electric Car (D Segment),1800,60,"285,7",,Electricity 28 | light-electric-car,Light Electric Car (B Segment),1333,50,"312,5",,Electricity 29 | large-suv-electric-car,Large Electric SUV,2352,100,400,,Electricity`; 30 | 31 | export const staticVehicles = parseVehiclesFromCsv(vehiclesCsv); 32 | 33 | export interface VehicleCombination { 34 | vehicle1: string; 35 | vehicle2: string; 36 | } 37 | 38 | export function generateAllCombinations(): VehicleCombination[] { 39 | const combinations: VehicleCombination[] = []; 40 | 41 | for (const vehicle1 of staticVehicles) { 42 | for (const vehicle2 of staticVehicles) { 43 | combinations.push({ 44 | vehicle1: vehicle1.id, 45 | vehicle2: vehicle2.id, 46 | }); 47 | } 48 | } 49 | 50 | return combinations; 51 | } 52 | 53 | export function getVehicleById(id: string): Vehicle | undefined { 54 | return staticVehicles.find((v) => v.id === id); 55 | } 56 | 57 | export function getVehicleDisplayName(id: string): string { 58 | const vehicle = getVehicleById(id); 59 | return vehicle?.name ?? id; 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Next.js site to GitHub Pages 2 | # 3 | # To get started with Next.js see: https://nextjs.org/docs/getting-started 4 | # 5 | name: Deploy Next.js site to Pages 6 | 7 | on: 8 | # Runs on pushes targeting the default branch 9 | push: 10 | branches: ["main"] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 16 | permissions: 17 | contents: read 18 | pages: write 19 | id-token: write 20 | 21 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 22 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 23 | concurrency: 24 | group: "pages" 25 | cancel-in-progress: false 26 | 27 | jobs: 28 | # Build job 29 | build: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Detect package manager 35 | id: detect-package-manager 36 | run: | 37 | if [ -f "${{ github.workspace }}/yarn.lock" ]; then 38 | echo "manager=yarn" >> $GITHUB_OUTPUT 39 | echo "command=install" >> $GITHUB_OUTPUT 40 | echo "runner=yarn" >> $GITHUB_OUTPUT 41 | exit 0 42 | elif [ -f "${{ github.workspace }}/package.json" ]; then 43 | echo "manager=npm" >> $GITHUB_OUTPUT 44 | echo "command=ci" >> $GITHUB_OUTPUT 45 | echo "runner=npx --no-install" >> $GITHUB_OUTPUT 46 | exit 0 47 | else 48 | echo "Unable to determine package manager" 49 | exit 1 50 | fi 51 | - name: Setup Node 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: "20" 55 | cache: ${{ steps.detect-package-manager.outputs.manager }} 56 | - name: Setup Pages 57 | id: setup-pages 58 | uses: actions/configure-pages@v5 59 | - name: Restore cache 60 | uses: actions/cache@v4 61 | with: 62 | path: | 63 | .next/cache 64 | # Generate a new cache whenever packages or source files change. 65 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 66 | # If source files changed but packages didn't, rebuild from a prior cache. 67 | restore-keys: | 68 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- 69 | - name: Install dependencies 70 | run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} 71 | - name: Build with Next.js 72 | run: ${{ steps.detect-package-manager.outputs.manager }} run build 73 | - name: Upload artifact 74 | uses: actions/upload-pages-artifact@v3 75 | with: 76 | path: ./out 77 | 78 | # Deployment job 79 | deploy: 80 | environment: 81 | name: github-pages 82 | url: ${{ steps.deployment.outputs.page_url }} 83 | runs-on: ubuntu-latest 84 | needs: build 85 | steps: 86 | - name: Deploy to GitHub Pages 87 | id: deployment 88 | uses: actions/deploy-pages@v4 89 | -------------------------------------------------------------------------------- /scripts/og-images/VerticalStackedBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Footprint } from "../../src/simulator/footprintEstimator/footprintEstimator"; 3 | 4 | // Note: We cannot use the actual BarChart.tsx component here because it uses Chart.js, 5 | // which requires a DOM/canvas environment. Satori (used for OG image generation) only 6 | // supports a subset of CSS and renders JSX to SVG, so we recreate the chart visually 7 | // using styled divs that match the BarChart.tsx appearance (colors, stacking order, etc.). 8 | 9 | export function VerticalStackedBar({ 10 | footprint, 11 | maxTotalTCO2e, 12 | label, 13 | }: { 14 | footprint: Footprint; 15 | maxTotalTCO2e: number; 16 | label: string; 17 | }) { 18 | const barWidth = 140; 19 | const maxHeight = 380; 20 | 21 | // Convert to tCO2e (same as BarChart.tsx lines 113-116) 22 | const productionTCO2e = footprint.productionWithoutBatteryKgCO2e / 1000; 23 | const batteryTCO2e = footprint.batteryProductionKgCO2e / 1000; 24 | const usageTCO2e = footprint.usageKgCO2e / 1000; 25 | const endOfLifeTCO2e = footprint.endOfLifeKgCO2e / 1000; 26 | const totalTCO2e = (footprint.totalKgCO2e / 1000).toPrecision(3); 27 | 28 | // Scale heights based on max value 29 | const scale = maxHeight / maxTotalTCO2e; 30 | const productionHeight = productionTCO2e * scale; 31 | const batteryHeight = batteryTCO2e * scale; 32 | const usageHeight = usageTCO2e * scale; 33 | const endOfLifeHeight = endOfLifeTCO2e * scale; 34 | 35 | return ( 36 |
37 | {/* Total label on top */} 38 |
47 | {`${totalTCO2e} tCO2e`} 48 |
49 | {/* Stacked bar - rendered top to bottom: end of life, usage, battery, production */} 50 |
59 | {/* End of life - Purple - top */} 60 |
68 | {/* Usage - Teal */} 69 |
77 | {/* Battery production - Pink */} 78 |
86 | {/* Production (without battery) - Red - bottom */} 87 |
95 |
96 | {/* Vehicle name label below */} 97 |
110 | {label} 111 |
112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /src/about/About.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Typography } from "antd"; 4 | 5 | export function About() { 6 | const { Paragraph, Title } = Typography 7 | return
8 | 9 | The problem 10 | 11 | 12 | The future of low-carbon mobility is a complex matter. And one of the hottest topics is the electric car. 13 | 14 | 15 | The use of cars is one of the major sources of emissions on a national and individual scale. The objectives set internationally with the Paris agreements require us to question our uses and the possible alternatives to traditional mobility. 16 | 17 | 18 | Yet, we believe that the transition to other forms of transportation than the traditional car will take time. In the meantime, electric vehicles can help dramatically reduce the emissions of the transportation sector. But they do have some drawbacks that can have a varying impact, depending on various parameters. 19 | 20 | 21 | These drawbacks trigger many questions: 22 |
    23 |
  • 24 | Has an electric car a net positive impact on the climate? 25 |
  • 26 |
  • 27 | What about the emissions caused by the production of the battery? 28 |
  • 29 |
  • 30 | Shouldn't I keep my existing gasoline car that was already produced? It's better, right? 31 |
  • 32 |
  • 33 | How does a large electric SUV compare to a small gasoline car? 34 |
  • 35 |
  • 36 | Is the electric car worth it even in countries with a high-carbon electricity mix? 37 |
  • 38 |
39 |
40 | 41 | We realized that most people do not have the answer to these questions. We believe that helping them find the answers on their own will accelerate their decision making. 42 | 43 | 44 | That is why we built this simulator. To help people realize what would be the gain to switch to an electric vehicle in their own use case. 45 | 46 | 47 | What does the science say? 48 | 49 | 50 | Many studies (e.g. from Carbone 4, Transport & Environment, Paul Scherrer Institute…) show that for a primary vehicle, the electric vehicle is less emissive than its thermal equivalent, even in countries with a more carbon intensive energy mix than France. 51 | 52 | 53 | Except in the case of secondary vehicles (which drive an average of 3,000 kilometers per year), it is therefore useful for the purpose of reducing emissions to promote the use of electric cars rather than gasoline-powered cars. However, this transition raises other societal and geopolitical questions, which Carbone 4 attempts to answer in this comprehensive FAQ: [FR] Preconceived ideas about electric cars. 54 | 55 | 56 | Who created this tool? 57 | 58 | 59 | This simulator was originally created as a side project by team members of Tennaxia. 60 | 61 | 62 | Tennaxia is developing software products to help companies of all sizes achieve their sustainability goals. And they are hiring! 63 | 64 |
65 | } -------------------------------------------------------------------------------- /public/logo_new_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /scripts/og-images/OgImage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Country } from "../../src/simulator/db/country"; 3 | import { Footprint } from "../../src/simulator/footprintEstimator/footprintEstimator"; 4 | import { getShortName } from "./helpers"; 5 | import { LegendItem } from "./LegendItem"; 6 | import { VerticalStackedBar } from "./VerticalStackedBar"; 7 | 8 | export function OgImage({ 9 | vehicle1Name, 10 | vehicle2Name, 11 | footprint1, 12 | footprint2, 13 | logoBase64, 14 | country, 15 | }: { 16 | vehicle1Name: string; 17 | vehicle2Name: string; 18 | footprint1: Footprint; 19 | footprint2: Footprint; 20 | logoBase64: string; 21 | country: Country; 22 | }) { 23 | // Sort so the winner (lower footprint) is on the left 24 | const isVehicle1Winner = footprint1.totalKgCO2e <= footprint2.totalKgCO2e; 25 | const leftName = isVehicle1Winner ? vehicle1Name : vehicle2Name; 26 | const rightName = isVehicle1Winner ? vehicle2Name : vehicle1Name; 27 | const leftFootprint = isVehicle1Winner ? footprint1 : footprint2; 28 | const rightFootprint = isVehicle1Winner ? footprint2 : footprint1; 29 | 30 | // Calculate max for scaling (in tCO2e, matching BarChart.tsx line 56) 31 | const maxTotalTCO2e = 32 | Math.max( 33 | Math.ceil(footprint1.totalKgCO2e / 10000) * 10, 34 | Math.ceil(footprint2.totalKgCO2e / 10000) * 10, 35 | ) + 10; 36 | 37 | const minTotal = Math.min(footprint1.totalKgCO2e, footprint2.totalKgCO2e); 38 | const maxTotal = Math.max(footprint1.totalKgCO2e, footprint2.totalKgCO2e); 39 | const ratio = (maxTotal / minTotal).toFixed(1); 40 | const winner = getShortName(leftName); 41 | 42 | return ( 43 |
55 | {/* Header */} 56 |
64 |
70 | 71 |
72 |
EV Footprint
73 |
by Tennaxia
74 |
75 |
76 | {/* Legend - moved to header row */} 77 |
78 |
79 | 80 | 81 | 82 | 83 |
84 | 85 |
86 | Country: {country.name} 87 |
88 |
89 |
90 | 91 | {/* Chart Area - Vertical bars side by side (winner on left) */} 92 |
102 | 107 | 112 |
113 | 114 | {/* Result summary */} 115 |
124 | {`${winner} emits ${ratio}x less CO2 than ${getShortName(rightName)}`} 125 |
126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/simulator/landingPage/comparison/LineChart.tsx: -------------------------------------------------------------------------------- 1 | import { colorFromEnergy } from '@/components/design/colors'; 2 | import { Typography } from 'antd'; 3 | import { 4 | BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Title, 5 | Tooltip 6 | } from 'chart.js'; 7 | import { CSSProperties } from 'react'; 8 | import { Line } from 'react-chartjs-2'; 9 | import { useMediaQuery } from 'react-responsive'; 10 | import { Country } from '../../db/country'; 11 | import { Vehicle } from '../../db/vehicleTypes'; 12 | import { FootprintEstimator } from '../../footprintEstimator/footprintEstimator'; 13 | 14 | ChartJS.register( 15 | CategoryScale, 16 | LinearScale, 17 | BarElement, 18 | PointElement, 19 | LineElement, 20 | Title, 21 | Tooltip, 22 | Legend 23 | ); 24 | export function LineChart(props: { 25 | className?: string, style?: CSSProperties 26 | country: Country, vehicles: Vehicle[] 27 | }) { 28 | const { country, vehicles } = props 29 | const footprintEstimator = new FootprintEstimator() 30 | 31 | const isBigScreen = useMediaQuery({ query: '(min-width: 640px)' }) // sm 32 | 33 | const { Paragraph } = Typography 34 | 35 | const options = { 36 | maintainAspectRatio: false, 37 | responsive: true, 38 | elements: { 39 | point: { 40 | radius: 1, 41 | pointStyle: 'line' 42 | } 43 | }, 44 | scales: { 45 | y: { 46 | title: { 47 | display: true, 48 | text: 'tCO2e' 49 | }, 50 | min: 0 51 | }, 52 | x: { 53 | title: { 54 | display: true, 55 | text: 'Total distance in the life of the vehicle (km)' 56 | } 57 | } 58 | }, 59 | plugins: { 60 | legend: { 61 | display: true, 62 | position: isBigScreen ? 'right' : 'bottom', 63 | maxWidth: 400 64 | } 65 | } 66 | } as any 67 | 68 | const distances: number[] = [] 69 | for (let i = 0; i <= 400000; i += 40000) { 70 | distances.push(i) 71 | } 72 | 73 | const data = { 74 | labels: distances.map(d => `${d} km`), 75 | datasets: vehicles.map(vehicle => ( 76 | { 77 | label: vehicle.name, 78 | data: distances.map(totalDistanceKm => { 79 | const footprint = footprintEstimator.estimate({ vehicle, totalDistanceKm, country }) 80 | return footprint.totalKgCO2e / 1000 81 | }), 82 | backgroundColor: colorFromEnergy(vehicle.energy), 83 | borderColor: colorFromEnergy(vehicle.energy) 84 | } 85 | )), 86 | } 87 | 88 | const vehicle1 = vehicles[0] 89 | const vehicle1FixedValue = footprintEstimator.estimate({ vehicle: vehicle1, country, totalDistanceKm: 0 }).totalKgCO2e 90 | const vehicle1Coefficient = footprintEstimator.estimate({ vehicle: vehicle1, country, totalDistanceKm: 1 }).totalKgCO2e - vehicle1FixedValue 91 | 92 | const vehicle2 = vehicles[1] 93 | const vehicle2FixedValue = footprintEstimator.estimate({ vehicle: vehicle2, country, totalDistanceKm: 0 }).totalKgCO2e 94 | const vehicle2Coefficient = footprintEstimator.estimate({ vehicle: vehicle2, country, totalDistanceKm: 1 }).totalKgCO2e - vehicle2FixedValue 95 | 96 | const threshold = (vehicle2FixedValue - vehicle1FixedValue) / (vehicle1Coefficient - vehicle2Coefficient) 97 | 98 | let maxVehicle: Vehicle 99 | let minVehicle: Vehicle 100 | if (threshold > 0) { 101 | maxVehicle = vehicle1FixedValue > vehicle2FixedValue ? vehicle2 : vehicle1 102 | minVehicle = vehicle1FixedValue < vehicle2FixedValue ? vehicle2 : vehicle1 103 | } else { 104 | maxVehicle = vehicle1FixedValue > vehicle2FixedValue ? vehicle1 : vehicle2 105 | minVehicle = vehicle1FixedValue < vehicle2FixedValue ? vehicle1 : vehicle2 106 | } 107 | 108 | return
110 | 111 | {threshold < 0 ? 112 | 113 | {`A ${minVehicle.name} is `} 114 | always better{` for the environment than a ${maxVehicle.name}.`} 115 | 116 | : 117 | 118 | {`A ${minVehicle.name} starts to be better for the climate than a ${maxVehicle.name} after `} 119 | {`${Math.round(threshold).toLocaleString()} km`}. 120 | 121 | } 122 | 123 | 124 |
125 | 132 |
133 |
134 | } 135 | 136 | -------------------------------------------------------------------------------- /src/simulator/footprintEstimator/footprintEstimator.ts: -------------------------------------------------------------------------------- 1 | import { Country } from "../db/country"; 2 | import { Energy, Vehicle } from "../db/vehicleTypes"; 3 | import { Carbone42020ICCT2021Electric } from "./model/carbone42020ICCT2021Electric"; 4 | import { Carbone42020InternalCombustionEngine } from "./model/carbone42020ICE"; 5 | import { VehicleFootprintModel } from "./vehicleFootprintModel"; 6 | 7 | export interface Footprint { 8 | productionWithoutBatteryKgCO2e: number; 9 | batteryProductionKgCO2e: number; 10 | endOfLifeKgCO2e: number; 11 | usageKgCO2e: number; 12 | totalKgCO2e: number; 13 | } 14 | 15 | export class FootprintEstimator { 16 | computeEmissions(params: { 17 | vehicles: Vehicle[]; 18 | totalDistanceKm: number; 19 | country: Country; 20 | }) { 21 | const { vehicles, totalDistanceKm, country } = params; 22 | const footprints = vehicles.map((vehicle) => ({ 23 | name: vehicle.name, 24 | footprint: this.estimate({ vehicle, totalDistanceKm, country }), 25 | })); 26 | 27 | const maxFootprint = footprints.reduce((a, b) => 28 | a.footprint.totalKgCO2e < b.footprint.totalKgCO2e ? b : a, 29 | ); 30 | const minFootprint = footprints.reduce((a, b) => 31 | a.footprint.totalKgCO2e < b.footprint.totalKgCO2e ? a : b, 32 | ); 33 | const ratio = 34 | maxFootprint.footprint.totalKgCO2e / minFootprint.footprint.totalKgCO2e; 35 | 36 | const maxUsageFootprint = footprints.reduce((a, b) => 37 | a.footprint.usageKgCO2e < b.footprint.usageKgCO2e ? b : a, 38 | ); 39 | const minUsageFootprint = footprints.reduce((a, b) => 40 | a.footprint.usageKgCO2e < b.footprint.usageKgCO2e ? a : b, 41 | ); 42 | const usageRatio = 43 | maxUsageFootprint.footprint.usageKgCO2e / 44 | minUsageFootprint.footprint.usageKgCO2e; 45 | 46 | return { 47 | footprints, 48 | maxFootprint, 49 | minFootprint, 50 | maxUsageFootprint, 51 | minUsageFootprint, 52 | ratio, 53 | usageRatio, 54 | }; 55 | } 56 | 57 | estimate(params: { 58 | vehicle: Vehicle; 59 | totalDistanceKm: number; 60 | country: Country; 61 | }): Footprint { 62 | const { vehicle } = params; 63 | 64 | const model: VehicleFootprintModel = 65 | vehicle.energy === Energy.Electricity 66 | ? new Carbone42020ICCT2021Electric() 67 | : new Carbone42020InternalCombustionEngine(); 68 | 69 | const kgCO2VehiclePerKg = 70 | model.productionkgCO2PerKg + model.endOfLifekgCO2PerKg; 71 | 72 | let batteryProductionKgCO2e: number; 73 | if (model.kgCO2BatteryPerKWh && vehicle.batteryCapacitykWh) { 74 | batteryProductionKgCO2e = 75 | model.kgCO2BatteryPerKWh * vehicle.batteryCapacitykWh; 76 | } else { 77 | batteryProductionKgCO2e = 0; 78 | } 79 | 80 | const result = { 81 | productionWithoutBatteryKgCO2e: 82 | kgCO2VehiclePerKg * vehicle.weightUnladenKg, 83 | batteryProductionKgCO2e: batteryProductionKgCO2e, 84 | endOfLifeKgCO2e: model.endOfLifekgCO2PerKg * vehicle.weightUnladenKg, 85 | usageKgCO2e: this.computeUsageKgCO2e(params), 86 | }; 87 | return { 88 | ...result, 89 | totalKgCO2e: 90 | result.productionWithoutBatteryKgCO2e + 91 | result.batteryProductionKgCO2e + 92 | result.endOfLifeKgCO2e + 93 | result.usageKgCO2e, 94 | }; 95 | } 96 | 97 | private computeUsageKgCO2e(params: { 98 | vehicle: Vehicle; 99 | totalDistanceKm: number; 100 | country: Country; 101 | }): number { 102 | const { vehicle, totalDistanceKm, country } = params; 103 | switch (vehicle.energy) { 104 | case Energy.Electricity: 105 | if (!vehicle.batteryCapacitykWh || !vehicle.averageRangeKm) { 106 | throw new Error( 107 | "An electric vehicle must have its battery capacity and range defined!", 108 | ); 109 | } 110 | const efficiency = vehicle.batteryCapacitykWh / vehicle.averageRangeKm; 111 | return country.kgCO2PerKWh * efficiency * totalDistanceKm; 112 | case Energy.Diesel: 113 | case Energy.Gasoline: 114 | // Source: Ademe (https://bilans-ges.ademe.fr/fr/basecarbone/donnees-consulter/liste-element/categorie/405) 115 | const emissionFactorkgCO2PerLiter = 116 | vehicle.energy === Energy.Gasoline 117 | ? 2.7 // E10 118 | : 3.1; // Gazole B7 119 | if (!vehicle.averageConsumptionPer100km) { 120 | throw new Error( 121 | "A gasoline or diesel vehicle must have its consumption defined! Error for vehicle " + 122 | JSON.stringify(vehicle) + 123 | "consumption: " + 124 | vehicle.averageConsumptionPer100km, 125 | ); 126 | } 127 | return ( 128 | (vehicle.averageConsumptionPer100km * 129 | emissionFactorkgCO2PerLiter * 130 | totalDistanceKm) / 131 | 100 132 | ); 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/simulator/landingPage/comparison/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from 'antd'; 2 | import { 3 | BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, Scale, Tick, Title, 4 | Tooltip 5 | } from 'chart.js'; 6 | import ChartDataLabels from 'chartjs-plugin-datalabels'; 7 | import { CSSProperties } from 'react'; 8 | import { Bar } from 'react-chartjs-2'; 9 | import { useMediaQuery } from 'react-responsive'; 10 | import { Country } from '../../db/country'; 11 | import { Vehicle } from '../../db/vehicleTypes'; 12 | import { FootprintEstimator } from '../../footprintEstimator/footprintEstimator'; 13 | 14 | ChartJS.register( 15 | CategoryScale, 16 | LinearScale, 17 | BarElement, 18 | Title, 19 | Tooltip, 20 | Legend, 21 | ) 22 | 23 | export function BarChart(props: { 24 | className?: string, style?: CSSProperties, 25 | totalDistanceKm: number, 26 | country: Country, vehicles: Vehicle[] 27 | }) { 28 | const { country, vehicles, totalDistanceKm } = props 29 | 30 | const isBigScreen = useMediaQuery({ query: '(min-width: 640px)' }) // sm 31 | 32 | const { Paragraph } = Typography 33 | 34 | const footprintEstimator = new FootprintEstimator() 35 | const { footprints, minUsageFootprint, maxUsageFootprint, usageRatio } = footprintEstimator.computeEmissions({ 36 | vehicles, totalDistanceKm, country 37 | }) 38 | 39 | const options = { 40 | maintainAspectRatio: false, 41 | responsive: true, 42 | scales: { 43 | x: { 44 | stacked: true, 45 | ticks: { 46 | callback: (scale: Scale, tickValue: number | string, index: number, ticks: Tick[]) => { 47 | return isBigScreen 48 | ? footprints[tickValue as number].name.split(/(?=\()/) // Put "(XX Segment)" on another line 49 | : '' 50 | } 51 | } as any 52 | }, 53 | y: { 54 | stacked: true, 55 | min: 0, 56 | max: Math.max(50, ...footprints.map(f => Math.ceil(f.footprint.totalKgCO2e / 10000) * 10)) + 10, 57 | title: { 58 | display: true, 59 | text: 'Emissions of the vehicle (tCO2e)' 60 | }, 61 | }, 62 | }, 63 | plugins: { 64 | tooltip: { 65 | callbacks: { 66 | beforeBody: (context: any) => { 67 | const footprint = footprints[context[0].dataIndex] 68 | const totalEmissionstCO2e = footprint.footprint.totalKgCO2e / 1000 69 | return `Total emissions of the vehicle: ${(Math.round(totalEmissionstCO2e * 10) / 10).toLocaleString()} tCO2e` 70 | }, 71 | label: function (context: any) { 72 | return `${context.dataset.label}: ${Math.round(context.parsed.y).toLocaleString()} tCO2e` 73 | } 74 | } 75 | }, 76 | legend: { 77 | display: true, 78 | position: isBigScreen ? 'right' : 'bottom', 79 | maxWidth: 400 80 | }, 81 | datalabels: { 82 | formatter: (value: any, ctx: { 83 | datasetIndex: number, 84 | dataIndex: number, 85 | chart: { 86 | data: { 87 | datasets: { 88 | data: number[] 89 | }[] 90 | } 91 | } 92 | }) => { 93 | let datasets = ctx.chart.data.datasets 94 | if (ctx.datasetIndex === datasets.length - 1) { 95 | const totalValue = datasets.map(dataset => dataset.data[ctx.dataIndex]) 96 | .reduce((a, b) => a + b, 0) 97 | return `${totalValue.toPrecision(3).toLocaleString()} tCO2e` 98 | } 99 | else { 100 | return ''; 101 | } 102 | 103 | }, 104 | anchor: 'end', 105 | align: 'end' 106 | } 107 | } as any 108 | }; 109 | 110 | const labels = footprints.map(fp => fp.name) 111 | const ids = footprints.map(fp => fp.name) 112 | 113 | const productionData = footprints.map(fp => fp.footprint.productionWithoutBatteryKgCO2e / 1000) 114 | const batteryProductionData = footprints.map(fp => fp.footprint.batteryProductionKgCO2e / 1000) 115 | const usageData = footprints.map(fp => fp.footprint.usageKgCO2e / 1000) 116 | const endOfLifeData = footprints.map(fp => fp.footprint.endOfLifeKgCO2e / 1000) 117 | 118 | const data = { 119 | labels, 120 | ids, 121 | datasets: [ 122 | { 123 | label: 'Production (without battery)', 124 | data: productionData, 125 | backgroundColor: 'rgb(255, 99, 132)', 126 | }, 127 | { 128 | label: 'Battery production', 129 | data: batteryProductionData, 130 | backgroundColor: 'rgb(255, 160, 190)', 131 | }, 132 | { 133 | label: 'Usage', 134 | data: usageData, 135 | backgroundColor: 'rgb(75, 192, 192)', 136 | }, 137 | { 138 | label: 'End of life', 139 | data: endOfLifeData, 140 | backgroundColor: 'rgb(150, 11, 190)', 141 | } 142 | ] 143 | } 144 | 145 | return
147 | 148 | {!isNaN(usageRatio) && 149 | 150 | 151 | {`A ${minUsageFootprint.name} emits `} 152 | {`${usageRatio.toPrecision(2)}x less CO2`} 153 | {` during its usage phase than a ${maxUsageFootprint.name}.`} 154 | 155 | 156 | } 157 |
158 | {/* https://www.chartjs.org/docs/latest/configuration/responsive.html#important-note */} 159 | 167 |
168 |
169 | } 170 | 171 | -------------------------------------------------------------------------------- /src/simulator/landingPage/comparison/Comparison.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { secondaryBackgroundColor } from "@/components/design/colors" 4 | import { Parameter } from "@/components/design/Parameter" 5 | import { StickyCollapse } from "@/utils/StickyCollapse" 6 | import { VehicleTitle } from "@/utils/VehicleTitle" 7 | import { InfoCircleTwoTone } from "@ant-design/icons" 8 | import { Select, Slider, Tooltip } from "antd" 9 | import { useRouter } from "next/navigation" 10 | import { useContext, useState } from "react" 11 | import { ClassType } from "../../db/classType" 12 | import { allCountries, Country } from "../../db/country" 13 | import { VehicleContext } from "../../db/VehicleProvider" 14 | import { Vehicle } from "../../db/vehicleTypes" 15 | import { FootprintContext, FootprintProvider } from "../../footprintEstimator/FootprintProvider" 16 | import headerIcon3Img from '../headerIcon3.svg' 17 | import { SimulatorSection } from "../SimulatorSection" 18 | import { BarChart } from "./BarChart" 19 | import bubble1Img from './bubble1.svg' 20 | import bubble2Img from './bubble2.svg' 21 | import bubble3Img from './bubble3.svg' 22 | import { LineChart } from "./LineChart" 23 | 24 | const headerIcon3 = headerIcon3Img.src 25 | const bubble1 = bubble1Img.src 26 | const bubble2 = bubble2Img.src 27 | const bubble3 = bubble3Img.src 28 | 29 | function CountryOption(props: { country: Country }) { 30 | const { country: { name, emoji } } = props 31 | return
{`${emoji} ${name}`}
32 | } 33 | 34 | function isLanguageCompatible(params: { language: string, countryCode: string }) { 35 | const { language, countryCode } = params 36 | return language.toLocaleLowerCase().includes(countryCode) 37 | } 38 | 39 | function autoDetectCountryCode() { 40 | const languages = navigator.languages 41 | for (const language of languages) { 42 | const country = allCountries.find(c => isLanguageCompatible({ language, countryCode: c.id })) 43 | if (country) { 44 | return country.id 45 | } 46 | } 47 | return 'eu' 48 | } 49 | 50 | const DEFAULT_VEHICLE1 = 'gasoline-e95' 51 | const DEFAULT_VEHICLE2 = 'electric-car' 52 | 53 | interface ComparisonProps { 54 | initialVehicle1Id?: string; 55 | initialVehicle2Id?: string; 56 | } 57 | 58 | export function Comparison({ 59 | initialVehicle1Id = DEFAULT_VEHICLE1, 60 | initialVehicle2Id = DEFAULT_VEHICLE2 61 | }: ComparisonProps = {}) { 62 | const router = useRouter() 63 | const { allVehicles } = useContext(VehicleContext) 64 | 65 | const [countryId, setCountryId] = useState(autoDetectCountryCode) 66 | const [vehicle1Id, setVehicle1Id] = useState(initialVehicle1Id) 67 | const [vehicle2Id, setVehicle2Id] = useState(initialVehicle2Id) 68 | const [totalDistanceKm, setTotalDistanceKm] = useState(200000) 69 | 70 | const country = allCountries.find(c => countryId === c.id) 71 | 72 | function navigateToComparison(v1: string, v2: string) { 73 | router.push(`/compare/${v1}/${v2}/`) 74 | } 75 | 76 | function classTypeName(classType: ClassType) { 77 | switch (classType) { 78 | case ClassType.Light: return 'Light' 79 | case ClassType.Regular: return 'Regular' 80 | case ClassType.Heavy: return 'Heavy' 81 | default: return '' 82 | } 83 | } 84 | 85 | function vehiclesForClassType(classType: ClassType, allVehicles: Vehicle[]) { 86 | return allVehicles.filter(vehicle => { 87 | switch (classType) { 88 | case ClassType.Light: 89 | return vehicle.weightUnladenKg < 1500 90 | case ClassType.Regular: 91 | return vehicle.weightUnladenKg >= 1500 && vehicle.weightUnladenKg < 2000 92 | case ClassType.Heavy: 93 | return vehicle.weightUnladenKg >= 2000 94 | default: return false 95 | } 96 | }) 97 | } 98 | 99 | if (!country) { 100 | console.error('Unable to find country with ID=' + countryId) 101 | return null 102 | } 103 | return 106 |
107 |
108 | 109 |
110 |
111 | {[ 112 | { title: 'Car #1', value: vehicle1Id, onChange: (v: string) => { setVehicle1Id(v); navigateToComparison(v, vehicle2Id); } }, 113 | { title: 'Car #2', value: vehicle2Id, onChange: (v: string) => { setVehicle2Id(v); navigateToComparison(vehicle1Id, v); } } 114 | ].map((parameter, idx) => ( 115 | 116 | 135 | 136 | ))} 137 |
138 | 139 |
140 | 151 | 152 | Some countries have a cleaner electricity mix than others. This will impact the emissions of an electric car. 153 |
}> 154 | 155 | 156 |
157 | 158 | 159 |
160 | setTotalDistanceKm(event)} 167 | /> 168 |
169 |
{`${totalDistanceKm.toLocaleString()}`}
170 | km 171 |
172 |
173 |
174 |
175 | 176 | 179 |
180 |
181 | 182 | } 183 | 184 | function ComparisonDisplay(props: { totalDistanceKm: number, vehicle1Id: string, vehicle2Id: string, country: Country }) { 185 | const { totalDistanceKm, vehicle1Id, vehicle2Id, country } = props 186 | const footprintContext = useContext(FootprintContext) 187 | 188 | const { allVehicles } = useContext(VehicleContext) 189 | 190 | if (!footprintContext) { return null } // Should not happen 191 | 192 | const { minFootprint, maxFootprint, ratio } = footprintContext 193 | 194 | const vehicle1 = allVehicles.find(vehicle => vehicle.id === vehicle1Id)! 195 | const vehicle2 = allVehicles.find(vehicle => vehicle.id === vehicle2Id)! 196 | 197 | 198 | return <> 199 | 203 |
204 |
205 |
206 | 207 |
208 |
209 |
210 | {`At the end of their life (${totalDistanceKm.toLocaleString()} km), a ${minFootprint.name}`} 211 |
212 |
213 | will have emitted {`${ratio.toPrecision(2)}x less CO2`}{` than a ${maxFootprint.name}.`} 214 |
215 |
216 |
217 | }> 220 | 224 | 225 | }> 228 | 231 | 232 |
233 |
234 | 235 | } 236 | 237 | function BackgroundTitle(props: { title: string, icon?: string }) { 238 | const { title, icon } = props 239 | return
240 | {icon && 241 |
242 | 243 |
244 | } 245 |
246 | {title} 247 |
248 |
249 | } --------------------------------------------------------------------------------