├── data ├── bwapi │ ├── __init__.py │ ├── README.txt │ └── server.py ├── food │ └── __init__.py ├── common │ ├── __init__.py │ ├── delete_methods.py │ ├── import_.py │ ├── distances │ │ └── json_to_csv.py │ └── impacts.py ├── textile │ ├── __init__.py │ └── activities.py ├── .gitignore ├── setup.py ├── docker │ ├── entrypoint.sh │ ├── requirements.txt │ └── Dockerfile ├── import_ecoinvent.py ├── import_method.py └── README.md ├── Procfile ├── review ├── .gitignore ├── elm.json └── src │ └── ReviewConfig.elm ├── public ├── robots.txt ├── img │ ├── chevron-down.png │ ├── img_outil_api.png │ ├── picto_bulle.png │ ├── picto_textile.png │ ├── illustration-score.png │ ├── img_outil_methode.png │ ├── ingredient-editor.png │ ├── picto_alimentaire.png │ ├── img_outil_calculateur.png │ └── logo.svg ├── fonts │ ├── Marianne-Bold.woff │ ├── Marianne-Bold.woff2 │ ├── Marianne-Regular.woff │ └── Marianne-Regular.woff2 ├── icomoon │ ├── fonts │ │ ├── icomoon.eot │ │ ├── icomoon.ttf │ │ └── icomoon.woff │ └── style.css └── pages │ ├── accessibilité.md │ └── mentions-légales.md ├── tests ├── .gitignore ├── Data │ ├── Food │ │ ├── ProcessTest.elm │ │ └── PreparationTest.elm │ ├── GitbookTest.elm │ ├── Textile │ │ ├── ProductTest.elm │ │ └── LifeCycleTest.elm │ ├── TransportTest.elm │ └── DefinitionTest.elm ├── TestUtils.elm ├── Views │ ├── MarkdownTest.elm │ └── FormatTest.elm ├── Request │ └── VersionTest.elm └── Server │ └── ServerTest.elm ├── .prettierrc ├── .proxyrc.json ├── .env.sample ├── .prettierignore ├── bin ├── update-version.sh └── build-db ├── .husky ├── pre-commit └── _ │ └── husky.sh ├── nodemon.json ├── .editorconfig ├── src ├── Views │ ├── Component │ │ ├── StepsBorder.elm │ │ ├── DownArrow.elm │ │ ├── MassInput.elm │ │ └── SplitInput.elm │ ├── Debug.elm │ ├── Container.elm │ ├── Spinner.elm │ ├── Link.elm │ ├── Score.elm │ ├── ComplementsDetails.elm │ ├── CountrySelect.elm │ ├── Button.elm │ ├── CardTabs.elm │ ├── Icon.elm │ ├── Transport.elm │ ├── Sidebar.elm │ ├── AutocompleteSelector.elm │ ├── Modal.elm │ ├── Alert.elm │ ├── Impact.elm │ └── Table.elm ├── Page │ ├── Explore │ │ ├── Common.elm │ │ ├── TextileProcesses.elm │ │ ├── FoodProcesses.elm │ │ └── Table.elm │ └── Editorial.elm ├── Server │ ├── Request.elm │ └── Route.elm ├── Data │ ├── Key.elm │ ├── Zone.elm │ ├── Color.elm │ ├── Food │ │ ├── Origin.elm │ │ ├── Db.elm │ │ └── Ingredient │ │ │ └── Category.elm │ ├── Github.elm │ ├── Textile │ │ ├── DyeingMedium.elm │ │ ├── HeatSource.elm │ │ ├── Db.elm │ │ ├── MakingComplexity.elm │ │ ├── Printing.elm │ │ ├── Material │ │ │ └── Origin.elm │ │ └── Material.elm │ ├── Env.elm │ ├── Scope.elm │ ├── Scoring.elm │ ├── AutocompleteSelector.elm │ ├── Matomo.elm │ ├── Bookmark.elm │ └── Split.elm ├── Request │ ├── Common.elm │ ├── Github.elm │ ├── Matomo.elm │ └── Version.elm ├── Ports.elm └── Static │ └── Db.elm-template ├── .gitignore ├── index.html ├── LICENSE ├── lib ├── charts │ ├── stats.js │ ├── base.js │ ├── pefpie.js │ ├── index.js │ └── food-comparator.js └── index.js ├── .github └── workflows │ ├── node.js.yml │ └── codeql-analysis.yml ├── elm.json ├── package.json ├── index.js └── server.js /data/bwapi/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /data/food/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /data/common/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /data/textile/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run server:start 2 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | /*.csv 2 | /*.zip 3 | 4 | -------------------------------------------------------------------------------- /review/.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff 2 | suppressed 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /branches/ 3 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | e2e-food-output.json 2 | e2e-textile-output.json 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.proxyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://127.0.0.1:3000/" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/img/chevron-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/chevron-down.png -------------------------------------------------------------------------------- /public/img/img_outil_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/img_outil_api.png -------------------------------------------------------------------------------- /public/img/picto_bulle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/picto_bulle.png -------------------------------------------------------------------------------- /public/img/picto_textile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/picto_textile.png -------------------------------------------------------------------------------- /public/fonts/Marianne-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/fonts/Marianne-Bold.woff -------------------------------------------------------------------------------- /public/fonts/Marianne-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/fonts/Marianne-Bold.woff2 -------------------------------------------------------------------------------- /public/icomoon/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/icomoon/fonts/icomoon.eot -------------------------------------------------------------------------------- /public/icomoon/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/icomoon/fonts/icomoon.ttf -------------------------------------------------------------------------------- /public/icomoon/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/icomoon/fonts/icomoon.woff -------------------------------------------------------------------------------- /public/img/illustration-score.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/illustration-score.png -------------------------------------------------------------------------------- /public/img/img_outil_methode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/img_outil_methode.png -------------------------------------------------------------------------------- /public/img/ingredient-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/ingredient-editor.png -------------------------------------------------------------------------------- /public/img/picto_alimentaire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/picto_alimentaire.png -------------------------------------------------------------------------------- /public/fonts/Marianne-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/fonts/Marianne-Regular.woff -------------------------------------------------------------------------------- /public/fonts/Marianne-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/fonts/Marianne-Regular.woff2 -------------------------------------------------------------------------------- /public/img/img_outil_calculateur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1k0/ecobalyse/master/public/img/img_outil_calculateur.png -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | MATOMO_HOST=stats.beta.gouv.fr 2 | MATOMO_SITE_ID=57 3 | MATOMO_TOKEN=xxx 4 | SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.yml 2 | *.yaml 3 | *.html 4 | package-lock.json 5 | public/icomoon/* 6 | public/vendor/* 7 | styles.scss 8 | tests/e2e-*-output.json 9 | -------------------------------------------------------------------------------- /data/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="ecobalyse_data", 5 | version="1.0", 6 | packages=["food", "common", "textile"], 7 | ) 8 | -------------------------------------------------------------------------------- /bin/update-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # On scalingo we don't have a git repository, but we have a SOURCE_VERSION env var 4 | HASH=${SOURCE_VERSION:-`git rev-parse HEAD`} 5 | 6 | echo "{\"hash\": \"$HASH\"}" > public/version.json 7 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # Check if code is formatted 5 | if ! npm run format:check ; then 6 | echo "Code is not formatted. Run 'npm run format:json' to format before committing." 7 | exit 1 8 | fi 9 | -------------------------------------------------------------------------------- /data/common/delete_methods.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import bw2data 3 | import bw2io 4 | 5 | bw2data.projects.set_current("textile") 6 | for i in list(bw2data.methods): 7 | del bw2data.methods[i] 8 | 9 | # del bw2data.databases["biosphere3"] 10 | # bw2io.bw2setup() 11 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "NODE_ENV": "development" 4 | }, 5 | "ext": "js,mjs,json,elm,yaml", 6 | "ignore": ["tests/", ".git", "node_modules/**/node_modules"], 7 | "watch": ["server.js", "server-app.js", "lib", "openapi.yaml", "public/data", "public/pages"] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.elm] 12 | indent_size = 4 13 | 14 | [elm.json] 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /src/Views/Component/StepsBorder.elm: -------------------------------------------------------------------------------- 1 | module Views.Component.StepsBorder exposing (style) 2 | 3 | import Html 4 | import Html.Attributes as Attrs 5 | 6 | 7 | style : String -> Html.Attribute msg 8 | style color = 9 | Attrs.style "border-left" ("5px " ++ color ++ " solid") 10 | -------------------------------------------------------------------------------- /src/Views/Debug.elm: -------------------------------------------------------------------------------- 1 | module Views.Debug exposing (view) 2 | 3 | import DebugToJson 4 | import Html exposing (..) 5 | 6 | 7 | view : List (Attribute msg) -> a -> Html msg 8 | view attrs = 9 | Debug.toString 10 | >> DebugToJson.pp 11 | >> text 12 | >> List.singleton 13 | >> pre attrs 14 | -------------------------------------------------------------------------------- /data/docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ECOBALYSE_ID=$(ls -lnd /home/jovyan/ecobalyse|awk '{print $3}') 4 | JOVYAN_ID=$(id -u jovyan) 5 | 6 | if [ $ECOBALYSE_ID -ne $JOVYAN_ID ]; then 7 | usermod -u $ECOBALYSE_ID jovyan 8 | fi 9 | 10 | pushd /home/jovyan/${ECOBALYSE:=ecobalyse}/data 11 | pip install -e . 12 | popd 13 | 14 | ldconfig 15 | 16 | gosu jovyan "$@" 17 | -------------------------------------------------------------------------------- /src/Views/Container.elm: -------------------------------------------------------------------------------- 1 | module Views.Container exposing 2 | ( centered 3 | , fluid 4 | ) 5 | 6 | import Html exposing (..) 7 | import Html.Attributes exposing (..) 8 | 9 | 10 | centered : List (Attribute msg) -> List (Html msg) -> Html msg 11 | centered attrs = 12 | div (class "container" :: attrs) 13 | 14 | 15 | fluid : List (Attribute msg) -> List (Html msg) -> Html msg 16 | fluid attrs = 17 | div (class "container-fluid" :: attrs) 18 | -------------------------------------------------------------------------------- /src/Views/Spinner.elm: -------------------------------------------------------------------------------- 1 | module Views.Spinner exposing (view) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | 6 | 7 | view : Html msg 8 | view = 9 | div 10 | [ class "d-flex flex-column gap-3 justify-content-center align-items-center" 11 | , style "min-height" "25vh" 12 | ] 13 | [ div [ class "spinner-border text-primary", attribute "role" "status" ] [] 14 | , p [ class "text-muted" ] 15 | [ text "Chargement…" ] 16 | ] 17 | -------------------------------------------------------------------------------- /src/Page/Explore/Common.elm: -------------------------------------------------------------------------------- 1 | module Page.Explore.Common exposing (scopesView) 2 | 3 | import Data.Scope as Scope exposing (Scope) 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | 7 | 8 | scopesView : { a | scopes : List Scope } -> Html msg 9 | scopesView = 10 | .scopes 11 | >> List.map 12 | (\scope -> 13 | span [ class "badge badge-success" ] 14 | [ text <| Scope.toLabel scope ] 15 | ) 16 | >> div [ class "d-flex gap-1" ] 17 | -------------------------------------------------------------------------------- /src/Server/Request.elm: -------------------------------------------------------------------------------- 1 | module Server.Request exposing (Request) 2 | 3 | import Json.Encode as Encode 4 | 5 | 6 | type alias Request = 7 | -- Notes: 8 | -- - `method` is ExpressJS `method` string (HTTP verb: GET, POST, etc.) 9 | -- - `url` is ExpressJS' request `url` string 10 | -- - `body` is the JSON body; if no JSON body exist in the request, fallbacks to `{}` 11 | -- - `jsResponseHandler` is an ExpressJS response callback function 12 | { method : String 13 | , url : String 14 | , body : Encode.Value 15 | , jsResponseHandler : Encode.Value 16 | } 17 | -------------------------------------------------------------------------------- /src/Data/Key.elm: -------------------------------------------------------------------------------- 1 | module Data.Key exposing (escape) 2 | 3 | import Json.Decode as Decode exposing (Decoder) 4 | 5 | 6 | escape : msg -> Decoder msg 7 | escape msg = 8 | succeedForKeyCode 27 msg 9 | 10 | 11 | forKeyCode : Int -> msg -> Int -> Decoder msg 12 | forKeyCode key msg keyCode = 13 | if keyCode == key then 14 | Decode.succeed msg 15 | 16 | else 17 | Decode.fail (String.fromInt keyCode) 18 | 19 | 20 | succeedForKeyCode : Int -> msg -> Decoder msg 21 | succeedForKeyCode key msg = 22 | Decode.field "keyCode" Decode.int 23 | |> Decode.andThen (forKeyCode key msg) 24 | -------------------------------------------------------------------------------- /src/Request/Common.elm: -------------------------------------------------------------------------------- 1 | module Request.Common exposing (errorToString) 2 | 3 | import Http 4 | 5 | 6 | errorToString : Http.Error -> String 7 | errorToString error = 8 | case error of 9 | Http.BadUrl url -> 10 | "URL invalide: " ++ url 11 | 12 | Http.Timeout -> 13 | "Délai dépassé." 14 | 15 | Http.NetworkError -> 16 | "Erreur de communication réseau. Êtes-vous connecté ?" 17 | 18 | Http.BadStatus status_code -> 19 | "Erreur HTTP " ++ String.fromInt status_code 20 | 21 | Http.BadBody body -> 22 | "Échec de l'interprétation de la réponse HTTP: " ++ body 23 | -------------------------------------------------------------------------------- /src/Views/Component/DownArrow.elm: -------------------------------------------------------------------------------- 1 | module Views.Component.DownArrow exposing (view) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | 6 | 7 | view : List (Html msg) -> List (Html msg) -> Html msg 8 | view leftChildren rightChildren = 9 | div [ class "d-flex justify-content-between text-muted" ] 10 | [ span 11 | [ class "w-50 fs-7 py-4" 12 | , style "padding" ".5rem 1rem" 13 | ] 14 | leftChildren 15 | , div [ class "DownArrow" ] [] 16 | , div 17 | [ class "w-50 fs-7 py-4" 18 | , style "padding" ".5rem 1rem" 19 | ] 20 | rightChildren 21 | ] 22 | -------------------------------------------------------------------------------- /src/Views/Link.elm: -------------------------------------------------------------------------------- 1 | module Views.Link exposing (external, internal, smallPillExternal) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | 6 | 7 | external : List (Attribute msg) -> List (Html msg) -> Html msg 8 | external attrs = 9 | a (attrs ++ [ target "_blank", class "link-external", rel "noopener noreferrer" ]) 10 | 11 | 12 | internal : List (Attribute msg) -> List (Html msg) -> Html msg 13 | internal attrs = 14 | a attrs 15 | 16 | 17 | smallPillExternal : List (Attribute msg) -> List (Html msg) -> Html msg 18 | smallPillExternal attrs = 19 | a 20 | (target "_blank" 21 | :: rel "noopener noreferrer" 22 | :: class "btn btn-sm text-secondary text-decoration-none btn-link p-0 ms-1" 23 | :: attrs 24 | ) 25 | -------------------------------------------------------------------------------- /tests/Data/Food/ProcessTest.elm: -------------------------------------------------------------------------------- 1 | module Data.Food.ProcessTest exposing (..) 2 | 3 | import Data.Food.Process as Process 4 | import Expect 5 | import Test exposing (..) 6 | import TestUtils exposing (asTest, suiteWithDb) 7 | 8 | 9 | suite : Test 10 | suite = 11 | suiteWithDb "Data.Food.Process" 12 | (\{ foodDb } -> 13 | [ describe "findByCode" 14 | [ foodDb.processes 15 | |> Process.findByIdentifier (Process.codeFromString "AGRIBALU000000003102592") 16 | |> Result.map (.name >> Process.nameToString) 17 | |> Expect.equal (Ok "Carrot, conventional, national average, at farm gate {FR} U") 18 | |> asTest "should find a process by its identifier" 19 | ] 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /src/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Ports exposing 2 | ( addBodyClass 3 | , appStarted 4 | , copyToClipboard 5 | , loadRapidoc 6 | , removeBodyClass 7 | , saveStore 8 | , scrollIntoView 9 | , scrollTo 10 | , storeChanged 11 | ) 12 | 13 | -- Outgoing 14 | 15 | 16 | port addBodyClass : String -> Cmd msg 17 | 18 | 19 | port appStarted : () -> Cmd msg 20 | 21 | 22 | port copyToClipboard : String -> Cmd msg 23 | 24 | 25 | port loadRapidoc : String -> Cmd msg 26 | 27 | 28 | port removeBodyClass : String -> Cmd msg 29 | 30 | 31 | port saveStore : String -> Cmd msg 32 | 33 | 34 | port scrollIntoView : String -> Cmd msg 35 | 36 | 37 | port scrollTo : { x : Float, y : Float } -> Cmd msg 38 | 39 | 40 | 41 | -- Incoming 42 | 43 | 44 | port storeChanged : (String -> msg) -> Sub msg 45 | -------------------------------------------------------------------------------- /src/Request/Github.elm: -------------------------------------------------------------------------------- 1 | module Request.Github exposing (getChangelog) 2 | 3 | import Data.Env as Env 4 | import Data.Github as Github 5 | import Data.Session exposing (Session) 6 | import Json.Decode as Decode 7 | import RemoteData exposing (WebData) 8 | import RemoteData.Http exposing (defaultConfig) 9 | 10 | 11 | config : RemoteData.Http.Config 12 | config = 13 | -- drop ALL headers because Parcel's proxy messes with them 14 | -- see https://stackoverflow.com/a/47840149/330911 15 | { defaultConfig | headers = [] } 16 | 17 | 18 | getChangelog : Session -> (WebData (List Github.Commit) -> msg) -> Cmd msg 19 | getChangelog _ event = 20 | RemoteData.Http.getWithConfig config 21 | ("https://api.github.com/repos/" ++ Env.githubRepository ++ "/commits") 22 | event 23 | (Decode.list Github.decodeCommit) 24 | -------------------------------------------------------------------------------- /src/Views/Score.elm: -------------------------------------------------------------------------------- 1 | module Views.Score exposing (view) 2 | 3 | import Data.Impact exposing (Impacts) 4 | import Data.Impact.Definition exposing (Definition) 5 | import Html exposing (..) 6 | import Html.Attributes exposing (..) 7 | import Mass exposing (Mass) 8 | import Views.Format as Format 9 | 10 | 11 | type alias Config = 12 | { impactDefinition : Definition 13 | , mass : Mass 14 | , score : Impacts 15 | } 16 | 17 | 18 | view : Config -> Html msg 19 | view { impactDefinition, mass, score } = 20 | div [ class "card bg-secondary shadow-sm" ] 21 | [ div [ class "card-body text-center text-nowrap text-white display-3 lh-1" ] 22 | [ Format.formatImpact impactDefinition score ] 23 | , div [ class "card-footer text-white text-center" ] 24 | [ text "Pour ", Format.kg mass ] 25 | ] 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /dist 3 | elm-stuff 4 | elm-stuff 5 | /node_modules 6 | /public/app.js 7 | /public/icomoon/*.txt 8 | /public/icomoon/*.zip 9 | /public/icomoon/demo-files 10 | /public/icomoon/demo-files 11 | /public/icomoon/demo.html 12 | /public/main.css 13 | /public/main.css.map 14 | /server-app.js 15 | /src/Static/Db.elm 16 | /xls 17 | /public/version.json 18 | /.parcel-cache/ 19 | 20 | .vscode 21 | *.pyc 22 | .DS_Store 23 | processes-no-impacts.json 24 | /data.egg-info/* 25 | /data/food/import_agb/agribalyse3_no_param.CSV 26 | /data/food/import_agb/agribalyse3_no_param.CSV.zip 27 | /data/food/import_agb3.1/AGB31.bw2package 28 | /data/Dockerfile 29 | /data/ecobalyse_data.egg-info/ 30 | /data/ECOINVENT3.9.1/* 31 | /data/AGB3.1.1.20230306.CSV 32 | /data/Environmental Footprint 3.1 (adapted) patch wtu.CSV 33 | /data/Environmental Footprint 3.1 (adapted).CSV 34 | /data/venv/ 35 | -------------------------------------------------------------------------------- /.husky/_/husky.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | if [ -z "$husky_skip_init" ]; then 3 | debug () { 4 | if [ "$HUSKY_DEBUG" = "1" ]; then 5 | echo "husky (debug) - $1" 6 | fi 7 | } 8 | 9 | readonly hook_name="$(basename -- "$0")" 10 | debug "starting $hook_name..." 11 | 12 | if [ "$HUSKY" = "0" ]; then 13 | debug "HUSKY env variable is set to 0, skipping hook" 14 | exit 0 15 | fi 16 | 17 | if [ -f ~/.huskyrc ]; then 18 | debug "sourcing ~/.huskyrc" 19 | . ~/.huskyrc 20 | fi 21 | 22 | readonly husky_skip_init=1 23 | export husky_skip_init 24 | sh -e "$0" "$@" 25 | exitCode="$?" 26 | 27 | if [ $exitCode != 0 ]; then 28 | echo "husky - $hook_name hook exited with code $exitCode (error)" 29 | fi 30 | 31 | if [ $exitCode = 127 ]; then 32 | echo "husky - command not found in PATH=$PATH" 33 | fi 34 | 35 | exit $exitCode 36 | fi 37 | -------------------------------------------------------------------------------- /src/Static/Db.elm-template: -------------------------------------------------------------------------------- 1 | module Static.Db exposing (Db, db) 2 | 3 | 4 | import Data.Food.Db as FoodDb 5 | import Data.Textile.Db as TextileDb 6 | 7 | 8 | type alias Db = 9 | { foodDb : FoodDb.Db, textileDb : TextileDb.Db } 10 | 11 | 12 | textileDb : Result String TextileDb.Db 13 | textileDb = 14 | """%textileJson%""" |> TextileDb.buildFromJson 15 | 16 | 17 | foodProcessesJson : String 18 | foodProcessesJson = 19 | """%foodProcessesJson%""" 20 | 21 | 22 | foodIngredientsJson : String 23 | foodIngredientsJson = 24 | """%foodIngredientsJson%""" 25 | 26 | 27 | foodDb : Result String FoodDb.Db 28 | foodDb = 29 | textileDb 30 | |> Result.andThen 31 | (\textileDbParsed -> 32 | FoodDb.buildFromJson textileDbParsed foodProcessesJson foodIngredientsJson 33 | ) 34 | 35 | 36 | db : Result String Db 37 | db = 38 | Result.map2 Db foodDb textileDb 39 | -------------------------------------------------------------------------------- /data/docker/requirements.txt: -------------------------------------------------------------------------------- 1 | # don't forget to keep sync the api link in Dockerfile 2 | #jupyter_contrib_nbextensions @ git+https://github.com/ipython-contrib/jupyter_contrib_nbextensions 3 | #jupyter_nbextensions_configurator @ git+https://github.com/Jupyter-contrib/jupyter_nbextensions_configurator 4 | IPython>=8.16, <9 5 | fastapi 6 | fjson>=0.1, <0.2 7 | flatdict>=4.0, <4.1 8 | git+https://github.com/brightway-lca/brightway2-analyzer@0.11.7 9 | git+https://github.com/brightway-lca/brightway2-calc@2.0.DEV16 10 | git+https://github.com/brightway-lca/brightway2-data@4.0.DEV33 11 | git+https://github.com/brightway-lca/brightway2-parameters@1.1.0 12 | git+https://github.com/brightway-lca/bw_projects@v2.1.0 13 | git+https://github.com/ccomb/brightway2-io@ccomb-2 14 | ipywidgets>=8.1, <8.2 15 | jupyter-collaboration 16 | matplotlib>=3.7, <3.8 17 | numpy>=1.25, <1.26 18 | olca-ipc==0.0.12 19 | pandas>=2.0, <2.1 20 | scipy>=1.11, <1.12 21 | uvicorn 22 | xlrd>=2.0, <2.1 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ecobalyse 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Views/ComplementsDetails.elm: -------------------------------------------------------------------------------- 1 | module Views.ComplementsDetails exposing (view) 2 | 3 | import Data.Impact as Impact 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Views.Format as Format 7 | 8 | 9 | type alias Config = 10 | { complementsImpacts : Impact.ComplementsImpacts 11 | } 12 | 13 | 14 | view : Config -> List (Html msg) -> Html msg 15 | view { complementsImpacts } detailedImpacts = 16 | details [ class "ComplementsDetails fs-7" ] 17 | (summary [] 18 | [ div [ class "ComplementsTable d-flex justify-content-between w-100" ] 19 | [ span [ title "Cliquez pour plier/déplier" ] [ text "Compléments" ] 20 | , span [ class "text-muted text-end", title "Total des compléments" ] 21 | [ complementsImpacts 22 | |> Impact.getTotalComplementsImpacts 23 | |> Format.complement 24 | ] 25 | ] 26 | ] 27 | :: detailedImpacts 28 | ) 29 | -------------------------------------------------------------------------------- /src/Data/Zone.elm: -------------------------------------------------------------------------------- 1 | module Data.Zone exposing 2 | ( Zone(..) 3 | , decode 4 | ) 5 | 6 | import Json.Decode as Decode exposing (Decoder) 7 | import Json.Decode.Extra as DE 8 | 9 | 10 | type Zone 11 | = Africa 12 | | Asia 13 | | Europe 14 | | MiddleEast 15 | | NorthAmerica 16 | | Oceania 17 | | SouthAmerica 18 | 19 | 20 | decode : Decoder Zone 21 | decode = 22 | Decode.string 23 | |> Decode.andThen (fromString >> DE.fromResult) 24 | 25 | 26 | fromString : String -> Result String Zone 27 | fromString string = 28 | case string of 29 | "Africa" -> 30 | Ok Africa 31 | 32 | "Asia" -> 33 | Ok Asia 34 | 35 | "Europe" -> 36 | Ok Europe 37 | 38 | "Middle_East" -> 39 | Ok MiddleEast 40 | 41 | "North_America" -> 42 | Ok NorthAmerica 43 | 44 | "Oceania" -> 45 | Ok Oceania 46 | 47 | "South_America" -> 48 | Ok SouthAmerica 49 | 50 | _ -> 51 | Err <| "Zone géographique inconnue : " ++ string 52 | -------------------------------------------------------------------------------- /src/Request/Matomo.elm: -------------------------------------------------------------------------------- 1 | module Request.Matomo exposing (getApiStats, getWebStats) 2 | 3 | import Data.Matomo as Matomo 4 | import Data.Session exposing (Session) 5 | import Http 6 | import RemoteData exposing (WebData) 7 | 8 | 9 | getStats : String -> String -> String -> (WebData (List Matomo.Stat) -> msg) -> Cmd msg 10 | getStats host jsonKey qs event = 11 | Http.get 12 | { url = "https://" ++ host ++ "/" ++ qs 13 | , expect = 14 | Matomo.decodeStats jsonKey 15 | |> Http.expectJson (RemoteData.fromResult >> event) 16 | } 17 | 18 | 19 | getApiStats : Session -> (WebData (List Matomo.Stat) -> msg) -> Cmd msg 20 | getApiStats { matomo } = 21 | "?module=API&method=Goals.get&format=json&idGoal=1&period=day&date=last30&idSite=" 22 | ++ matomo.siteId 23 | |> getStats matomo.host "nb_conversions" 24 | 25 | 26 | getWebStats : Session -> (WebData (List Matomo.Stat) -> msg) -> Cmd msg 27 | getWebStats { matomo } = 28 | "?module=API&method=VisitsSummary.get&format=json&period=day&date=last30&idSite=" 29 | ++ matomo.siteId 30 | |> getStats matomo.host "nb_visits" 31 | -------------------------------------------------------------------------------- /src/Data/Color.elm: -------------------------------------------------------------------------------- 1 | module Data.Color exposing (..) 2 | 3 | -- Extracted from elm-charts for convenience & reuse 4 | -- Typical chart sequence is purple, pink, blue, green, red, yellow, turquoise, orange, moss, brown 5 | 6 | 7 | pink : String 8 | pink = 9 | "#ea60df" 10 | 11 | 12 | purple : String 13 | purple = 14 | "#7b4dff" 15 | 16 | 17 | blue : String 18 | blue = 19 | "#12a5ed" 20 | 21 | 22 | moss : String 23 | moss = 24 | "#92b42c" 25 | 26 | 27 | green : String 28 | green = 29 | "#71c614" 30 | 31 | 32 | orange : String 33 | orange = 34 | "#ff8400" 35 | 36 | 37 | turquoise : String 38 | turquoise = 39 | "#22d2ba" 40 | 41 | 42 | red : String 43 | red = 44 | "#f5325b" 45 | 46 | 47 | darkYellow : String 48 | darkYellow = 49 | "#eabd39" 50 | 51 | 52 | darkBlue : String 53 | darkBlue = 54 | "#7345f6" 55 | 56 | 57 | coral : String 58 | coral = 59 | "#ea7369" 60 | 61 | 62 | magenta : String 63 | magenta = 64 | "#db4cb2" 65 | 66 | 67 | brown : String 68 | brown = 69 | "#871c1c" 70 | 71 | 72 | mint : String 73 | mint = 74 | "#6df0d2" 75 | 76 | 77 | yellow : String 78 | yellow = 79 | "#ffca00" 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ministères de la Transition écologique & de la Cohésion des territoires 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 | -------------------------------------------------------------------------------- /data/common/import_.py: -------------------------------------------------------------------------------- 1 | import bw2data 2 | 3 | 4 | def add_missing_substances(project, biosphere): 5 | """Two additional substances provided by ecoinvent and that seem to be in 3.9.2 but not in 3.9.1""" 6 | substances = { 7 | "a35f0a31-fe92-56db-b0ca-cc878d270fde": { 8 | "name": "Hydrogen cyanide", 9 | "synonyms": ["Hydrocyanic acid", "Formic anammonide", "Formonitrile"], 10 | "categories": ("air",), 11 | "unit": "kilogram", 12 | "CAS Number": "000074-90-8", 13 | "formula": "HCN", 14 | }, 15 | "ea53a165-9f19-54f2-90a3-3e5c7a05051f": { 16 | "name": "N,N-Dimethylformamide", 17 | "synonyms": ["Formamide, N,N-dimethyl-", "Dimethyl formamide"], 18 | "categories": ("air",), 19 | "unit": "kilogram", 20 | "CAS Number": "000068-12-2", 21 | "formula": "C3H7NO", 22 | }, 23 | } 24 | bw2data.projects.set_current(project) 25 | bio = bw2data.Database(biosphere) 26 | for code, activity in substances.items(): 27 | if not [flow for flow in bio if flow["code"] == code]: 28 | bio.new_activity(code, **activity) 29 | -------------------------------------------------------------------------------- /src/Views/CountrySelect.elm: -------------------------------------------------------------------------------- 1 | module Views.CountrySelect exposing (view) 2 | 3 | import Data.Country as Country exposing (Country) 4 | import Data.Scope as Scope exposing (Scope) 5 | import Html exposing (..) 6 | import Html.Attributes exposing (..) 7 | import Html.Events exposing (..) 8 | 9 | 10 | type alias Config msg = 11 | { attributes : List (Attribute msg) 12 | , countries : List Country 13 | , onSelect : Country.Code -> msg 14 | , scope : Scope 15 | , selectedCountry : Country.Code 16 | } 17 | 18 | 19 | view : Config msg -> Html msg 20 | view { attributes, countries, onSelect, scope, selectedCountry } = 21 | countries 22 | |> Scope.only scope 23 | |> List.sortBy .name 24 | |> List.map 25 | (\{ code, name } -> 26 | option 27 | [ selected (selectedCountry == code) 28 | , value <| Country.codeToString code 29 | ] 30 | [ text name ] 31 | ) 32 | |> select 33 | (class 34 | "form-select" 35 | :: onInput (Country.codeFromString >> onSelect) 36 | :: attributes 37 | ) 38 | -------------------------------------------------------------------------------- /src/Views/Button.elm: -------------------------------------------------------------------------------- 1 | module Views.Button exposing 2 | ( docsPill 3 | , docsPillLink 4 | , pillClasses 5 | , smallPill 6 | , smallPillLink 7 | ) 8 | 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | 12 | 13 | pillClasses : String 14 | pillClasses = 15 | "d-inline-flex align-items-center btn btn-sm gap-1 rounded-pill" 16 | 17 | 18 | smallPillClasses : String 19 | smallPillClasses = 20 | pillClasses ++ " text-secondary text-decoration-none btn-link p-0 ms-1" 21 | 22 | 23 | smallPill : List (Attribute msg) -> List (Html msg) -> Html msg 24 | smallPill attrs = 25 | button (class smallPillClasses :: attrs) 26 | 27 | 28 | smallPillLink : List (Attribute msg) -> List (Html msg) -> Html msg 29 | smallPillLink attrs = 30 | a (class smallPillClasses :: attrs) 31 | 32 | 33 | docsPillClasses : String 34 | docsPillClasses = 35 | pillClasses ++ " btn-primary fs-7 py-0" 36 | 37 | 38 | docsPill : List (Attribute msg) -> List (Html msg) -> Html msg 39 | docsPill attrs = 40 | button (class docsPillClasses :: attrs) 41 | 42 | 43 | docsPillLink : List (Attribute msg) -> List (Html msg) -> Html msg 44 | docsPillLink attrs = 45 | a (class docsPillClasses :: attrs) 46 | -------------------------------------------------------------------------------- /tests/Data/Food/PreparationTest.elm: -------------------------------------------------------------------------------- 1 | module Data.Food.PreparationTest exposing (..) 2 | 3 | import Data.Food.Preparation as Preparation 4 | import Data.Impact as Impact 5 | import Data.Impact.Definition as Definition 6 | import Data.Split as Split 7 | import Data.Unit as Unit 8 | import Energy 9 | import Expect 10 | import Mass 11 | import Test exposing (..) 12 | import TestUtils exposing (asTest, suiteWithDb) 13 | 14 | 15 | suite : Test 16 | suite = 17 | suiteWithDb "Data.Food.Preparation" 18 | (\{ foodDb } -> 19 | [ describe "apply" 20 | [ { id = Preparation.Id "sample" 21 | , name = "Sample" 22 | , elec = ( Energy.kilowattHours 1, Split.half ) 23 | , heat = ( Energy.megajoules 1, Split.half ) 24 | , applyRawToCookedRatio = False 25 | } 26 | |> Preparation.apply foodDb (Mass.kilograms 1) 27 | |> Impact.getImpact Definition.Cch 28 | |> Unit.impactToFloat 29 | |> Expect.within (Expect.Absolute 0.001) 0.08 30 | |> asTest "compute impacts from applying a consumption preparation technique" 31 | ] 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /tests/TestUtils.elm: -------------------------------------------------------------------------------- 1 | module TestUtils exposing 2 | ( asTest 3 | , expectImpactsEqual 4 | , suiteWithDb 5 | ) 6 | 7 | import Data.Impact as Impact exposing (Impacts) 8 | import Data.Impact.Definition as Definition exposing (Base) 9 | import Data.Unit as Unit 10 | import Expect exposing (Expectation) 11 | import Static.Db as StaticDb 12 | import Test exposing (..) 13 | 14 | 15 | asTest : String -> Expectation -> Test 16 | asTest label = 17 | always >> test label 18 | 19 | 20 | suiteWithDb : String -> (StaticDb.Db -> List Test) -> Test 21 | suiteWithDb name suite = 22 | case StaticDb.db of 23 | Ok db -> 24 | describe name (suite db) 25 | 26 | Err error -> 27 | describe name 28 | [ test "should load static database" <| 29 | \_ -> Expect.fail <| "Couldn't parse static database: " ++ error 30 | ] 31 | 32 | 33 | expectImpactsEqual : Base (Float -> Expectation) -> Impacts -> Expectation 34 | expectImpactsEqual impacts subject = 35 | Definition.trigrams 36 | |> List.map 37 | (\trigram -> 38 | Impact.getImpact trigram >> Unit.impactToFloat >> Definition.get trigram impacts 39 | ) 40 | |> (\expectations -> 41 | Expect.all expectations subject 42 | ) 43 | -------------------------------------------------------------------------------- /lib/charts/stats.js: -------------------------------------------------------------------------------- 1 | import BaseChart from "./base"; 2 | 3 | function ucfirst(s) { 4 | return s.charAt(0).toUpperCase() + s.slice(1); 5 | } 6 | 7 | export default class extends BaseChart { 8 | constructor() { 9 | super(); 10 | } 11 | 12 | static get observedAttributes() { 13 | return ["data", "heading", "height", "unit"]; 14 | } 15 | 16 | get config() { 17 | return { 18 | chart: { 19 | type: "spline", 20 | }, 21 | accessibility: { 22 | description: `Évolution de la fréquentation`, 23 | }, 24 | subtitle: { 25 | text: "30 derniers jours", 26 | }, 27 | xAxis: { 28 | type: "datetime", 29 | }, 30 | series: [{ lineWidth: 4 }], 31 | }; 32 | } 33 | 34 | attributeChanged(name, oldValue, newValue) { 35 | if (name === "data") { 36 | const data = JSON.parse(newValue); 37 | this.chart.series[0].setData(data); 38 | } else if (name === "heading") { 39 | this.chart.setTitle({ text: newValue }); 40 | } else if (name === "height") { 41 | this.chart.setSize(null, parseInt(newValue, 10)); 42 | } else if (name === "unit") { 43 | const unitTitle = ucfirst(newValue + "s"); 44 | this.chart.yAxis[0].setTitle({ text: unitTitle }); 45 | this.chart.series[0].setName(unitTitle); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Views/CardTabs.elm: -------------------------------------------------------------------------------- 1 | module Views.CardTabs exposing (view) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | import Html.Events exposing (..) 6 | 7 | 8 | type alias Tab msg = 9 | { label : String 10 | , active : Bool 11 | , onTabClick : msg 12 | } 13 | 14 | 15 | type alias Config msg = 16 | { tabs : List (Tab msg) 17 | , content : List (Html msg) 18 | } 19 | 20 | 21 | view : Config msg -> Html msg 22 | view { tabs, content } = 23 | div [ class "card shadow-sm" ] 24 | (div [ class "card-header px-0 pb-0 border-bottom-0" ] 25 | [ tabs 26 | |> List.map 27 | (\{ label, onTabClick, active } -> 28 | li [ class "TabsTab nav-item", classList [ ( "active", active ) ] ] 29 | [ button 30 | [ class "nav-link no-outline border-top-0" 31 | , classList [ ( "active", active ) ] 32 | , onClick onTabClick 33 | ] 34 | [ text label ] 35 | ] 36 | ) 37 | |> ul [ class "Tabs nav nav-tabs nav-fill justify-content-end gap-2 px-2" ] 38 | ] 39 | :: content 40 | ) 41 | -------------------------------------------------------------------------------- /src/Data/Food/Origin.elm: -------------------------------------------------------------------------------- 1 | module Data.Food.Origin exposing 2 | ( Origin(..) 3 | , decode 4 | , toLabel 5 | ) 6 | 7 | import Json.Decode as Decode exposing (Decoder) 8 | import Json.Decode.Extra as DE 9 | 10 | 11 | type Origin 12 | = France 13 | | EuropeAndMaghreb 14 | | OutOfEuropeAndMaghreb 15 | | OutOfEuropeAndMaghrebByPlane 16 | 17 | 18 | decode : Decoder Origin 19 | decode = 20 | Decode.string 21 | |> Decode.andThen (fromString >> DE.fromResult) 22 | 23 | 24 | fromString : String -> Result String Origin 25 | fromString string = 26 | case string of 27 | "France" -> 28 | Ok France 29 | 30 | "EuropeAndMaghreb" -> 31 | Ok EuropeAndMaghreb 32 | 33 | "OutOfEuropeAndMaghreb" -> 34 | Ok OutOfEuropeAndMaghreb 35 | 36 | "OutOfEuropeAndMaghrebByPlane" -> 37 | Ok OutOfEuropeAndMaghrebByPlane 38 | 39 | _ -> 40 | Err <| "Origine géographique inconnue : " ++ string 41 | 42 | 43 | toLabel : Origin -> String 44 | toLabel origin = 45 | case origin of 46 | France -> 47 | "France" 48 | 49 | EuropeAndMaghreb -> 50 | "Europe et Maghreb" 51 | 52 | OutOfEuropeAndMaghreb -> 53 | "Hors Europe et Maghreb" 54 | 55 | OutOfEuropeAndMaghrebByPlane -> 56 | "Hors Europe et Maghreb par avion" 57 | -------------------------------------------------------------------------------- /src/Data/Github.elm: -------------------------------------------------------------------------------- 1 | module Data.Github exposing (Commit, decodeCommit) 2 | 3 | import Iso8601 4 | import Json.Decode as Decode exposing (Decoder) 5 | import Json.Decode.Pipeline as Pipe 6 | import Time exposing (Posix) 7 | 8 | 9 | type alias Commit = 10 | { sha : String 11 | , message : String 12 | , date : Posix 13 | , authorName : String 14 | , authorLogin : String 15 | , authorAvatar : Maybe String 16 | } 17 | 18 | 19 | decodeCommit : Decoder Commit 20 | decodeCommit = 21 | Decode.succeed Commit 22 | |> Pipe.requiredAt [ "sha" ] Decode.string 23 | |> Pipe.requiredAt [ "commit", "message" ] Decode.string 24 | |> Pipe.requiredAt [ "commit", "author", "date" ] Iso8601.decoder 25 | |> Pipe.requiredAt [ "commit", "author", "name" ] Decode.string 26 | |> Pipe.optionalAt [ "author", "login" ] Decode.string "Ecobalyse" 27 | |> Pipe.optionalAt [ "author", "avatar_url" ] (Decode.maybe Decode.string) Nothing 28 | |> Decode.andThen 29 | (\({ authorAvatar, authorName } as commit) -> 30 | Decode.succeed 31 | (if authorAvatar == Nothing && authorName == "Ingredient editor" then 32 | { commit | authorAvatar = Just "img/ingredient-editor.png" } 33 | 34 | else 35 | commit 36 | ) 37 | ) 38 | -------------------------------------------------------------------------------- /public/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Views/Component/MassInput.elm: -------------------------------------------------------------------------------- 1 | module Views.Component.MassInput exposing (view) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes as Attr exposing (..) 5 | import Html.Events exposing (..) 6 | import Mass exposing (Mass) 7 | 8 | 9 | type alias Config msg = 10 | { disabled : Bool 11 | , mass : Mass 12 | , onChange : Maybe Mass -> msg 13 | } 14 | 15 | 16 | view : Config msg -> Html msg 17 | view { disabled, mass, onChange } = 18 | div [ class "input-group input-group" ] 19 | [ input 20 | [ class "form-control text-end incdec-arrows-left" 21 | , type_ "number" 22 | , step "1" 23 | , mass 24 | |> Mass.inGrams 25 | |> round 26 | |> String.fromInt 27 | |> value 28 | , title "Quantité en grammes" 29 | , onInput <| 30 | \str -> 31 | onChange 32 | (if str == "" then 33 | Nothing 34 | 35 | else 36 | str 37 | |> String.toFloat 38 | |> Maybe.map Mass.grams 39 | ) 40 | , Attr.min "0" 41 | , Attr.disabled disabled 42 | ] 43 | [] 44 | , span [ class "input-group-text", title "grammes" ] 45 | [ text "g" 46 | ] 47 | ] 48 | -------------------------------------------------------------------------------- /src/Views/Component/SplitInput.elm: -------------------------------------------------------------------------------- 1 | module Views.Component.SplitInput exposing (view) 2 | 3 | import Data.Split as Split exposing (Split) 4 | import Html exposing (..) 5 | import Html.Attributes as Attr exposing (..) 6 | import Html.Events exposing (..) 7 | 8 | 9 | type alias Config msg = 10 | { disabled : Bool 11 | , share : Split 12 | , onChange : Maybe Split -> msg 13 | } 14 | 15 | 16 | view : Config msg -> Html msg 17 | view { disabled, share, onChange } = 18 | div [ class "input-group input-group" ] 19 | [ input 20 | [ class "form-control text-end incdec-arrows-left" 21 | , type_ "number" 22 | , step "1" 23 | , share 24 | |> Split.toPercent 25 | |> String.fromInt 26 | |> value 27 | , title "Quantité en pourcents" 28 | , onInput <| 29 | \str -> 30 | onChange 31 | (if str == "" then 32 | Nothing 33 | 34 | else 35 | str 36 | |> String.toInt 37 | |> Maybe.andThen (Split.fromPercent >> Result.toMaybe) 38 | ) 39 | , Attr.min "0" 40 | , Attr.disabled disabled 41 | ] 42 | [] 43 | , span [ class "input-group-text", title "pourcents" ] 44 | [ text "%" 45 | ] 46 | ] 47 | -------------------------------------------------------------------------------- /src/Data/Textile/DyeingMedium.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.DyeingMedium exposing 2 | ( DyeingMedium(..) 3 | , decode 4 | , encode 5 | , fromString 6 | , toLabel 7 | , toString 8 | ) 9 | 10 | import Json.Decode as Decode exposing (Decoder) 11 | import Json.Decode.Extra as DE 12 | import Json.Encode as Encode 13 | 14 | 15 | type DyeingMedium 16 | = Article 17 | | Fabric 18 | | Yarn 19 | 20 | 21 | decode : Decoder DyeingMedium 22 | decode = 23 | Decode.string 24 | |> Decode.andThen (fromString >> DE.fromResult) 25 | 26 | 27 | encode : DyeingMedium -> Encode.Value 28 | encode = 29 | toString >> Encode.string 30 | 31 | 32 | fromString : String -> Result String DyeingMedium 33 | fromString string = 34 | case string of 35 | "article" -> 36 | Ok Article 37 | 38 | "fabric" -> 39 | Ok Fabric 40 | 41 | "yarn" -> 42 | Ok Yarn 43 | 44 | _ -> 45 | Err <| "Type de support de teinture inconnu: " ++ string 46 | 47 | 48 | toLabel : DyeingMedium -> String 49 | toLabel medium = 50 | case medium of 51 | Article -> 52 | "Article" 53 | 54 | Fabric -> 55 | "Tissu" 56 | 57 | Yarn -> 58 | "Fil" 59 | 60 | 61 | toString : DyeingMedium -> String 62 | toString medium = 63 | case medium of 64 | Article -> 65 | "article" 66 | 67 | Fabric -> 68 | "fabric" 69 | 70 | Yarn -> 71 | "yarn" 72 | -------------------------------------------------------------------------------- /review/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": ["src"], 4 | "elm-version": "0.19.1", 5 | "dependencies": { 6 | "direct": { 7 | "elm/core": "1.0.5", 8 | "jfmengels/elm-review": "2.13.0", 9 | "jfmengels/elm-review-cognitive-complexity": "1.0.2", 10 | "jfmengels/elm-review-common": "1.3.2", 11 | "jfmengels/elm-review-debug": "1.0.8", 12 | "jfmengels/elm-review-simplify": "2.0.23", 13 | "jfmengels/elm-review-unused": "1.1.29", 14 | "stil4m/elm-syntax": "7.2.9", 15 | "truqu/elm-review-noredundantconcat": "1.0.1", 16 | "truqu/elm-review-noredundantcons": "1.0.1" 17 | }, 18 | "indirect": { 19 | "elm/bytes": "1.0.8", 20 | "elm/html": "1.0.0", 21 | "elm/json": "1.1.3", 22 | "elm/parser": "1.1.0", 23 | "elm/project-metadata-utils": "1.0.2", 24 | "elm/random": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.3", 27 | "elm-community/list-extra": "8.7.0", 28 | "elm-explorations/test": "2.1.0", 29 | "miniBill/elm-unicode": "1.0.2", 30 | "pzp1997/assoc-list": "1.0.0", 31 | "rtfeldman/elm-hex": "1.0.0", 32 | "stil4m/structured-writer": "1.0.3" 33 | } 34 | }, 35 | "test-dependencies": { 36 | "direct": { 37 | "elm-explorations/test": "2.1.0" 38 | }, 39 | "indirect": {} 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Data/Env.elm: -------------------------------------------------------------------------------- 1 | module Data.Env exposing 2 | ( betagouvUrl 3 | , communityUrl 4 | , contactEmail 5 | , defaultDeadStock 6 | , gitbookUrl 7 | , githubRepository 8 | , githubUrl 9 | , maxMakingDeadStockRatio 10 | , maxMakingWasteRatio 11 | , maxMaterials 12 | , minMakingDeadStockRatio 13 | , minMakingWasteRatio 14 | ) 15 | 16 | import Data.Split as Split exposing (Split) 17 | 18 | 19 | betagouvUrl : String 20 | betagouvUrl = 21 | "https://beta.gouv.fr/startups/ecobalyse.html" 22 | 23 | 24 | communityUrl : String 25 | communityUrl = 26 | "https://fabrique-numerique.gitbook.io/ecobalyse/communaute" 27 | 28 | 29 | contactEmail : String 30 | contactEmail = 31 | "ecobalyse@beta.gouv.fr" 32 | 33 | 34 | gitbookUrl : String 35 | gitbookUrl = 36 | "https://fabrique-numerique.gitbook.io/ecobalyse" 37 | 38 | 39 | githubRepository : String 40 | githubRepository = 41 | "MTES-MCT/ecobalyse" 42 | 43 | 44 | githubUrl : String 45 | githubUrl = 46 | "https://github.com/" ++ githubRepository 47 | 48 | 49 | minMakingWasteRatio : Split 50 | minMakingWasteRatio = 51 | Split.zero 52 | 53 | 54 | maxMakingWasteRatio : Split 55 | maxMakingWasteRatio = 56 | Split.fourty 57 | 58 | 59 | minMakingDeadStockRatio : Split 60 | minMakingDeadStockRatio = 61 | Split.zero 62 | 63 | 64 | maxMakingDeadStockRatio : Split 65 | maxMakingDeadStockRatio = 66 | Split.thirty 67 | 68 | 69 | defaultDeadStock : Split 70 | defaultDeadStock = 71 | Split.fifteen 72 | 73 | 74 | maxMaterials : Int 75 | maxMaterials = 76 | 5 77 | -------------------------------------------------------------------------------- /lib/charts/base.js: -------------------------------------------------------------------------------- 1 | import Highcharts from "highcharts"; 2 | import exportingOption from "highcharts/modules/exporting"; 3 | import dataExportOption from "highcharts/modules/export-data"; 4 | import offlineOption from "highcharts/modules/offline-exporting"; 5 | 6 | exportingOption(Highcharts); 7 | dataExportOption(Highcharts); 8 | offlineOption(Highcharts); 9 | 10 | export default class extends HTMLElement { 11 | constructor() { 12 | super(); 13 | } 14 | 15 | static get observedAttributes() { 16 | throw new Error("Must be implemented"); 17 | } 18 | 19 | get config() { 20 | throw new Error("Must be implemented"); 21 | } 22 | 23 | attributeChanged(name, oldValue, newValue) { 24 | throw new Error("Must be implemented"); 25 | } 26 | 27 | get container() { 28 | return this.querySelector(".chart-container"); 29 | } 30 | 31 | attributeChangedCallback(name, oldValue, newValue) { 32 | requestAnimationFrame(() => { 33 | this.attributeChanged(name, oldValue, newValue); 34 | }); 35 | } 36 | 37 | connectedCallback() { 38 | this.appendChild( 39 | document.createRange().createContextualFragment(` 40 |
41 | `), 42 | ); 43 | 44 | this.chart = Highcharts.chart(this.container, this.config); 45 | 46 | // Force reflow 47 | requestAnimationFrame(() => { 48 | this.chart.reflow(); 49 | }); 50 | } 51 | 52 | disconnectedCallback() { 53 | if (this.chart) { 54 | this.removeChild(this.container); 55 | this.chart.destroy(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Request/Version.elm: -------------------------------------------------------------------------------- 1 | module Request.Version exposing 2 | ( Version(..) 3 | , loadVersion 4 | , pollVersion 5 | , toString 6 | , updateVersion 7 | ) 8 | 9 | import Json.Decode as Decode 10 | import RemoteData exposing (WebData) 11 | import RemoteData.Http as Http 12 | import Time 13 | 14 | 15 | type Version 16 | = Unknown 17 | | Version String 18 | | NewerVersion 19 | 20 | 21 | toString : Version -> Maybe String 22 | toString version = 23 | case version of 24 | Version string -> 25 | Just string 26 | 27 | _ -> 28 | Nothing 29 | 30 | 31 | updateVersion : Version -> WebData String -> Version 32 | updateVersion currentVersion webData = 33 | case webData of 34 | RemoteData.Success v -> 35 | case currentVersion of 36 | Version currentV -> 37 | if currentV /= v then 38 | NewerVersion 39 | 40 | else 41 | currentVersion 42 | 43 | NewerVersion -> 44 | currentVersion 45 | 46 | _ -> 47 | Version v 48 | 49 | _ -> 50 | currentVersion 51 | 52 | 53 | hashDecoder : Decode.Decoder String 54 | hashDecoder = 55 | Decode.field "hash" Decode.string 56 | 57 | 58 | loadVersion : (WebData String -> msg) -> Cmd msg 59 | loadVersion event = 60 | Http.get "/version.json" event hashDecoder 61 | 62 | 63 | pollVersion : msg -> Sub msg 64 | pollVersion event = 65 | Time.every (60 * 1000) (always event) 66 | -------------------------------------------------------------------------------- /tests/Data/GitbookTest.elm: -------------------------------------------------------------------------------- 1 | module Data.GitbookTest exposing (..) 2 | 3 | import Data.Env as Env 4 | import Data.Gitbook as Gitbook 5 | import Expect 6 | import Test exposing (..) 7 | import TestUtils exposing (asTest) 8 | 9 | 10 | suite : Test 11 | suite = 12 | describe "Data.Gitbook" 13 | [ describe "handleMarkdownLink" 14 | [ Gitbook.handleMarkdownGitbookLink Nothing "http://google.com" 15 | |> Expect.equal "http://google.com" 16 | |> asTest "should resolve an external link" 17 | , Gitbook.handleMarkdownGitbookLink (Just Gitbook.TextileSpinning) "http://google.com" 18 | |> Expect.equal "http://google.com" 19 | |> asTest "should resolve an external link even with a path provided" 20 | , Gitbook.handleMarkdownGitbookLink (Just Gitbook.TextileSpinning) "filature.md" 21 | |> Expect.equal (Env.gitbookUrl ++ "/textile/filature") 22 | |> asTest "should resolve an internal link from current page path" 23 | , Gitbook.handleMarkdownGitbookLink (Just Gitbook.TextileSpinning) "../faq.md" 24 | |> Expect.equal (Env.gitbookUrl ++ "/textile/../faq") 25 | |> asTest "should resolve an internal link from current page path down a folder level" 26 | , Gitbook.handleMarkdownGitbookLink (Just Gitbook.TextileSpinning) "foo/bar.md" 27 | |> Expect.equal (Env.gitbookUrl ++ "/textile/foo/bar") 28 | |> asTest "should resolve an internal link from current page path up a folder level" 29 | ] 30 | ] 31 | -------------------------------------------------------------------------------- /lib/charts/pefpie.js: -------------------------------------------------------------------------------- 1 | import BaseChart from "./base"; 2 | 3 | export default class extends BaseChart { 4 | constructor() { 5 | super(); 6 | } 7 | 8 | static get observedAttributes() { 9 | return ["data"]; 10 | } 11 | 12 | get config() { 13 | return { 14 | chart: { 15 | type: "pie", 16 | }, 17 | title: null, 18 | caption: { 19 | text: `Le score PEF est calculé selon la méthodologie proposée par le PEFCR Apparel 20 | & Footwear. Dans un premier temps et faute de données disponibles dans la Base 21 | Impacts, l'épuisement des ressources en eau, l'ecotoxicité eau douce, la toxicité 22 | humaine (cancer) et la toxicité humaine (non cancer) ne sont pas pris en compte à ce 23 | stade.`, 24 | }, 25 | plotOptions: { 26 | series: { 27 | animation: false, 28 | }, 29 | pie: { 30 | allowPointSelect: true, 31 | cursor: "pointer", 32 | dataLabels: { 33 | enabled: true, 34 | format: "{point.name}: {point.percentage:.1f}%", 35 | }, 36 | }, 37 | }, 38 | tooltip: { 39 | pointFormat: "{point.y:.2f} mPt ({point.percentage:.1f} %)", 40 | }, 41 | series: [ 42 | { 43 | name: "Valeur (mPt)", 44 | data: [], 45 | }, 46 | ], 47 | }; 48 | } 49 | 50 | attributeChanged(name, oldValue, newValue) { 51 | if (name === "data") { 52 | const data = JSON.parse(newValue); 53 | this.chart.series[0].setData(data); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Data/Scope.elm: -------------------------------------------------------------------------------- 1 | module Data.Scope exposing 2 | ( Scope(..) 3 | , decode 4 | , encode 5 | , only 6 | , parseSlug 7 | , toLabel 8 | , toString 9 | ) 10 | 11 | import Json.Decode as Decode exposing (Decoder) 12 | import Json.Decode.Extra as DE 13 | import Json.Encode as Encode 14 | import Url.Parser as Parser exposing (Parser) 15 | 16 | 17 | type Scope 18 | = Food 19 | | Textile 20 | 21 | 22 | decode : Decoder Scope 23 | decode = 24 | Decode.string 25 | |> Decode.andThen (fromString >> DE.fromResult) 26 | 27 | 28 | encode : Scope -> Encode.Value 29 | encode = 30 | toString >> Encode.string 31 | 32 | 33 | fromString : String -> Result String Scope 34 | fromString string = 35 | case string of 36 | "textile" -> 37 | Ok Textile 38 | 39 | "food" -> 40 | Ok Food 41 | 42 | _ -> 43 | Err <| "Couldn't decode unknown scope " ++ string 44 | 45 | 46 | only : Scope -> List { a | scopes : List Scope } -> List { a | scopes : List Scope } 47 | only scope = 48 | List.filter (.scopes >> List.member scope) 49 | 50 | 51 | parseSlug : Parser (Scope -> a) a 52 | parseSlug = 53 | Parser.custom "SCOPE" <| 54 | (fromString >> Result.toMaybe) 55 | 56 | 57 | toLabel : Scope -> String 58 | toLabel scope = 59 | case scope of 60 | Food -> 61 | "Alimentaire" 62 | 63 | Textile -> 64 | "Textile" 65 | 66 | 67 | toString : Scope -> String 68 | toString scope = 69 | case scope of 70 | Food -> 71 | "food" 72 | 73 | Textile -> 74 | "textile" 75 | -------------------------------------------------------------------------------- /tests/Views/MarkdownTest.elm: -------------------------------------------------------------------------------- 1 | module Views.MarkdownTest exposing (..) 2 | 3 | import Data.Gitbook as Gitbook 4 | import Expect 5 | import Html exposing (..) 6 | import Test exposing (..) 7 | import TestUtils exposing (asTest) 8 | import Views.Markdown as Markdown 9 | 10 | 11 | gitbookPage : String -> Gitbook.Page 12 | gitbookPage md = 13 | { title = "" 14 | , description = Nothing 15 | , path = Gitbook.TextileUse 16 | , markdown = md 17 | } 18 | 19 | 20 | suite : Test 21 | suite = 22 | describe "Views.Markdown" 23 | [ describe "Markdown.parse" 24 | -- NOTE: unfortunately, failing test results will show in diffs, 25 | -- making it super hard to debug. I couldn't identify any solution to this, 26 | -- yet it's important to have this test ensuring the very basics work. 27 | [ "plop" 28 | |> Markdown.Simple 29 | |> Markdown.parse 30 | |> Expect.equal (Ok [ p [] [ text "plop" ] ]) 31 | |> asTest "should parse the simplest Markdown string" 32 | , gitbookPage "plop" 33 | |> Markdown.Gitbook 34 | |> Markdown.parse 35 | |> Expect.equal (Ok [ p [] [ text "plop" ] ]) 36 | |> asTest "should parse the simplest Gitbook page Markdown string" 37 | , gitbookPage "Foo & Bar" 38 | |> Markdown.Gitbook 39 | |> Markdown.parse 40 | |> Expect.equal (Ok [ p [] [ text "Foo & Bar" ] ]) 41 | |> asTest "should handle Gitbook page HTML entities in Markdown string" 42 | ] 43 | ] 44 | -------------------------------------------------------------------------------- /src/Data/Scoring.elm: -------------------------------------------------------------------------------- 1 | module Data.Scoring exposing 2 | ( Scoring 3 | , compute 4 | , empty 5 | ) 6 | 7 | import Data.Impact as Impact exposing (Impacts) 8 | import Data.Impact.Definition as Definition exposing (Definitions) 9 | import Data.Unit as Unit 10 | import Quantity 11 | 12 | 13 | type alias Scoring = 14 | { all : Unit.Impact 15 | , allWithoutComplements : Unit.Impact 16 | , complements : Unit.Impact 17 | , climate : Unit.Impact 18 | , biodiversity : Unit.Impact 19 | , health : Unit.Impact 20 | , resources : Unit.Impact 21 | } 22 | 23 | 24 | compute : Definitions -> Unit.Impact -> Impacts -> Scoring 25 | compute definitions totalComplementsImpactPerKg perKgWithoutComplements = 26 | let 27 | ecsPerKgWithoutComplements = 28 | perKgWithoutComplements 29 | |> Impact.getImpact Definition.Ecs 30 | 31 | subScores = 32 | perKgWithoutComplements 33 | |> Impact.toProtectionAreas definitions 34 | in 35 | { all = Quantity.difference ecsPerKgWithoutComplements totalComplementsImpactPerKg 36 | , allWithoutComplements = ecsPerKgWithoutComplements 37 | , complements = totalComplementsImpactPerKg 38 | , climate = subScores.climate 39 | , biodiversity = subScores.biodiversity 40 | , health = subScores.health 41 | , resources = subScores.resources 42 | } 43 | 44 | 45 | empty : Scoring 46 | empty = 47 | { all = Unit.impact 0 48 | , allWithoutComplements = Unit.impact 0 49 | , complements = Unit.impact 0 50 | , climate = Unit.impact 0 51 | , biodiversity = Unit.impact 0 52 | , health = Unit.impact 0 53 | , resources = Unit.impact 0 54 | } 55 | -------------------------------------------------------------------------------- /public/pages/accessibilité.md: -------------------------------------------------------------------------------- 1 | # Déclaration d’accessibilité 2 | 3 | Établie le 19 avril 2022. 4 | 5 | La Fabrique Numérique du Ministère de la Transition écologique et solidaire et du Ministère de la Cohésion 6 | des territoires et des Relations avec les collectivités territoriales s’engage à rendre son service 7 | accessible, conformément à l’article 47 de la loi n° 2005-102 du 11 février 2005. 8 | 9 | Cette déclaration d’accessibilité s’applique à **Ecobalyse** (ecobalyse.beta.gouv.fr). 10 | 11 | ## État de conformité 12 | 13 | **Ecobalyse** est **non conforme** avec le Référentiel Général d’Amélioration de l’Accessibilité (RGAA). 14 | Le site n’a encore pas été audité. 15 | 16 | ## Amélioration et contact 17 | 18 | Si vous n’arrivez pas à accéder à un contenu ou à un service, vous pouvez contacter le responsable du service 19 | pour être orienté vers une alternative accessible ou obtenir le contenu sous une autre forme. 20 | 21 | - E-mail : [ecobalyse@beta.gouv.fr](mailto:ecobalyse@beta.gouv.fr?Subject=Accessibilité) 22 | 23 | ## Voie de recours 24 | 25 | Cette procédure est à utiliser dans le cas suivant : vous avez signalé au responsable du site internet 26 | un défaut d’accessibilité qui vous empêche d’accéder à un contenu ou à un des services du portail et vous 27 | n’avez pas obtenu de réponse satisfaisante. 28 | 29 | Vous pouvez : 30 | 31 | - Écrire un message au [Défenseur des droits](https://formulaire.defenseurdesdroits.fr/) 32 | - Contacter [le délégué du Défenseur des droits dans votre région](https://www.defenseurdesdroits.fr/saisir/delegues) 33 | - Envoyer un courrier par la poste (gratuit, ne pas mettre de timbre) : 34 | > Défenseur des droits 35 | > Libre réponse 71120 36 | > 75342 Paris CEDEX 07 37 | -------------------------------------------------------------------------------- /src/Data/AutocompleteSelector.elm: -------------------------------------------------------------------------------- 1 | module Data.AutocompleteSelector exposing (init) 2 | 3 | import Autocomplete exposing (Autocomplete) 4 | import String.Normalize as Normalize 5 | import Task 6 | 7 | 8 | init : (element -> String) -> List element -> Autocomplete element 9 | init toString availableElements = 10 | Autocomplete.init 11 | { query = "" 12 | , choices = availableElements 13 | , ignoreList = [] 14 | } 15 | (\lastChoices -> 16 | Task.succeed 17 | { lastChoices 18 | | choices = 19 | availableElements 20 | |> getChoices toString lastChoices.query 21 | } 22 | ) 23 | 24 | 25 | getChoices : (element -> String) -> String -> List element -> List element 26 | getChoices toString query = 27 | let 28 | toWords = 29 | String.toLower 30 | >> Normalize.removeDiacritics 31 | >> String.foldl 32 | (\c acc -> 33 | if not (List.member c [ '(', ')' ]) then 34 | String.cons c acc 35 | 36 | else 37 | acc 38 | ) 39 | "" 40 | >> String.split " " 41 | 42 | searchWords = 43 | toWords (String.trim query) 44 | in 45 | List.map (\element -> ( toWords (toString element), element )) 46 | >> List.filter 47 | (\( words, _ ) -> 48 | query == "" || List.all (\w -> List.any (String.contains w) words) searchWords 49 | ) 50 | >> List.sortBy (Tuple.second >> toString) 51 | >> List.map Tuple.second 52 | -------------------------------------------------------------------------------- /src/Data/Matomo.elm: -------------------------------------------------------------------------------- 1 | module Data.Matomo exposing (Stat, decodeStats, encodeStats) 2 | 3 | import Dict 4 | import Iso8601 5 | import Json.Decode as Decode exposing (Decoder) 6 | import Json.Encode as Encode 7 | import Result.Extra as RE 8 | import Time exposing (Posix) 9 | 10 | 11 | type alias Stat = 12 | { label : String 13 | , hits : Int 14 | , time : Posix 15 | } 16 | 17 | 18 | decodeStats : String -> Decoder (List Stat) 19 | decodeStats key = 20 | Decode.dict 21 | (Decode.oneOf 22 | [ Decode.at [ key ] Decode.int 23 | 24 | -- When no data is available at a given date, assume no traffic 25 | , Decode.succeed 0 26 | ] 27 | ) 28 | |> Decode.andThen 29 | (Dict.toList 30 | >> List.map 31 | (\( label, hits ) -> 32 | Iso8601.toTime label 33 | |> Result.map (Stat label hits) 34 | |> Result.mapError (always ("Format de date invalide: " ++ label)) 35 | ) 36 | >> RE.combine 37 | >> (\res -> 38 | case res of 39 | Ok list -> 40 | Decode.succeed list 41 | 42 | Err err -> 43 | Decode.fail err 44 | ) 45 | ) 46 | 47 | 48 | encodeStats : List Stat -> String 49 | encodeStats stats = 50 | stats 51 | |> Encode.list 52 | (\{ time, hits } -> 53 | -- The format for Highcharts' line chart is [[timestamp, value], …] 54 | Encode.list Encode.int [ Time.posixToMillis time, hits ] 55 | ) 56 | |> Encode.encode 0 57 | -------------------------------------------------------------------------------- /data/import_ecoinvent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from bw2data.project import projects 4 | from common.import_ import add_missing_substances 5 | from zipfile import ZipFile 6 | import bw2data 7 | import bw2io 8 | import os 9 | import shutil 10 | 11 | PROJECT = "textile" 12 | # Ecoinvent 13 | DATAPATH = "./ECOINVENT3.9.1.zip" 14 | DBNAME = "Ecoinvent 3.9.1" 15 | BIOSPHERE = "biosphere3" 16 | 17 | 18 | def import_ecoinvent(datapath=DATAPATH, project=PROJECT, dbname=DBNAME): 19 | """ 20 | Import file at path `datapath` into database named `dbname` in the project 21 | """ 22 | projects.set_current(project) 23 | # projects.create_project(project, activate=True, exist_ok=True) 24 | 25 | # unzip 26 | with ZipFile(datapath) as zf: 27 | print("### Extracting the zip file...") 28 | zf.extractall() 29 | unzipped = datapath[0:-4] 30 | 31 | print(f"### Importing {dbname} database from {unzipped}...") 32 | ecoinvent = bw2io.importers.SingleOutputEcospold2Importer( 33 | os.path.join(unzipped, "datasets"), dbname 34 | ) 35 | shutil.rmtree(unzipped) 36 | ecoinvent.apply_strategies() 37 | ecoinvent.add_unlinked_flows_to_biosphere_database() 38 | ecoinvent.write_database() 39 | print(f"### Finished importing {dbname}") 40 | 41 | 42 | def main(): 43 | projects.set_current(PROJECT) 44 | # projects.create_project(PROJECT, activate=True, exist_ok=True) 45 | bw2data.preferences["biosphere_database"] = BIOSPHERE 46 | bw2io.bw2setup() 47 | add_missing_substances(PROJECT, BIOSPHERE) 48 | 49 | # Import Ecoinvent 50 | if DBNAME not in bw2data.databases: 51 | import_ecoinvent() 52 | else: 53 | print(f"### {DBNAME} is already imported") 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /src/Data/Food/Db.elm: -------------------------------------------------------------------------------- 1 | module Data.Food.Db exposing 2 | ( Db 3 | , buildFromJson 4 | ) 5 | 6 | import Data.Country exposing (Country) 7 | import Data.Food.Ingredient as Ingredient exposing (Ingredient) 8 | import Data.Food.Process as Process exposing (Process) 9 | import Data.Impact.Definition exposing (Definitions) 10 | import Data.Textile.Db as TextileDb 11 | import Data.Transport as Transport 12 | import Json.Decode as Decode 13 | import Json.Decode.Extra as DE 14 | 15 | 16 | type alias Db = 17 | { -- Common datasources 18 | impactDefinitions : Definitions 19 | , countries : List Country 20 | , transports : Transport.Distances 21 | 22 | -- Food specific datasources 23 | , processes : List Process 24 | , ingredients : List Ingredient 25 | , wellKnown : Process.WellKnown 26 | } 27 | 28 | 29 | buildFromJson : TextileDb.Db -> String -> String -> Result String Db 30 | buildFromJson { impactDefinitions, countries, transports } foodProcessesJson ingredientsJson = 31 | foodProcessesJson 32 | |> Decode.decodeString (Process.decodeList impactDefinitions) 33 | |> Result.andThen 34 | (\processes -> 35 | ingredientsJson 36 | |> Decode.decodeString 37 | (Ingredient.decodeIngredients processes 38 | |> Decode.andThen 39 | (\ingredients -> 40 | Process.loadWellKnown processes 41 | |> Result.map (Db impactDefinitions countries transports processes ingredients) 42 | |> DE.fromResult 43 | ) 44 | ) 45 | ) 46 | |> Result.mapError Decode.errorToString 47 | -------------------------------------------------------------------------------- /data/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jupyter/minimal-notebook:notebook-7.0.6 2 | 3 | ENV BRIGHTWAY2_DIR /home/$NB_USER/data 4 | ENV BRIGHTWAY2_DOCKER 1 5 | ENV BRIGHTWAY2_OUTPUT_DIR /home/$NB_USER/output 6 | ENV XDG_CACHE_HOME="/home/${NB_USER}/.cache/" 7 | 8 | USER $NB_USER 9 | 10 | RUN mkdir -p /home/$NB_USER/data \ 11 | && mkdir -p /home/$NB_USER/notebooks \ 12 | && mkdir -p /home/$NB_USER/output \ 13 | && fix-permissions "/home/${NB_USER}/data" \ 14 | && fix-permissions "/home/${NB_USER}/notebooks" \ 15 | && fix-permissions "/home/${NB_USER}/output" 16 | 17 | # keep in sync with requirements.txt 18 | # allow to update the image if the source repo is updated 19 | ADD https://api.github.com/repos/ccomb/brightway2-io/git/refs/tags/ccomb-2 bw2-io.json 20 | ADD https://api.github.com/repos/brightway-lca/brightway2-parameters/git/refs/tags/1.1.0 bw2-parameters.json 21 | ADD https://api.github.com/repos/brightway-lca/brightway2-data/git/refs/tags/4.0.DEV33 bw2-data.json 22 | ADD https://api.github.com/repos/brightway-lca/brightway2-calc/git/refs/tags/2.0.DEV16 bw2-calc.json 23 | ADD https://api.github.com/repos/brightway-lca/brightway2-analyzer/git/refs/tags/0.11.7 bw2-analyzer.json 24 | ADD https://api.github.com/repos/brightway-lca/bw_projects/git/refs/tags/v2.1.0 bw_projects.json 25 | 26 | COPY requirements.txt . 27 | 28 | USER root 29 | RUN apt update \ 30 | && apt install -y gosu vim \ 31 | && pip install -r /home/$NB_USER/requirements.txt \ 32 | && rm /home/$NB_USER/requirements.txt \ 33 | && if [ "$(dpkg --print-architecture)" = "amd64" ]; then pip install pypardiso==0.4; ldconfig; fi; 34 | 35 | COPY simapro-biosphere.json /opt/conda/lib/python3.11/site-packages/bw2io/data/ 36 | COPY entrypoint.sh / 37 | 38 | VOLUME /home/$NB_USER 39 | WORKDIR /home/$NB_USER/notebooks 40 | ENTRYPOINT ["/entrypoint.sh"] 41 | -------------------------------------------------------------------------------- /lib/charts/index.js: -------------------------------------------------------------------------------- 1 | import Highcharts from "highcharts"; 2 | import enableA11y from "highcharts/modules/accessibility"; 3 | import FoodComparator from "./food-comparator"; 4 | import PefPie from "./pefpie"; 5 | import Stats from "./stats"; 6 | 7 | // Enable a11y https://www.highcharts.com/docs/accessibility/accessibility-module 8 | enableA11y(Highcharts); 9 | 10 | Highcharts.setOptions({ 11 | lang: { 12 | loading: "Chargement…", 13 | months: [ 14 | "janvier", 15 | "février", 16 | "mars", 17 | "avril", 18 | "mai", 19 | "juin", 20 | "juillet", 21 | "août", 22 | "septembre", 23 | "octobre", 24 | "novembre", 25 | "décembre", 26 | ], 27 | weekdays: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"], 28 | shortMonths: [ 29 | "jan", 30 | "fév", 31 | "mar", 32 | "avr", 33 | "mai", 34 | "juin", 35 | "juil", 36 | "aoû", 37 | "sep", 38 | "oct", 39 | "nov", 40 | "déc", 41 | ], 42 | exportButtonTitle: "Exporter", 43 | printButtonTitle: "Imprimer", 44 | rangeSelectorFrom: "Du", 45 | rangeSelectorTo: "au", 46 | rangeSelectorZoom: "Période", 47 | downloadPNG: "Télécharger en PNG", 48 | downloadJPEG: "Télécharger en JPEG", 49 | downloadPDF: "Télécharger en PDF", 50 | downloadSVG: "Télécharger en SVG", 51 | resetZoom: "Réinitialiser le zoom", 52 | resetZoomTitle: "Réinitialiser le zoom", 53 | thousandsSep: " ", 54 | decimalPoint: ",", 55 | }, 56 | }); 57 | 58 | export default { 59 | registerElements: function () { 60 | customElements.define("chart-pefpie", PefPie); 61 | customElements.define("chart-stats", Stats); 62 | customElements.define("chart-food-comparator", FoodComparator); 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /tests/Request/VersionTest.elm: -------------------------------------------------------------------------------- 1 | module Request.VersionTest exposing (..) 2 | 3 | import Expect 4 | import Http exposing (Error(..)) 5 | import RemoteData exposing (RemoteData(..)) 6 | import Request.Version exposing (Version(..), updateVersion) 7 | import Test exposing (..) 8 | import TestUtils exposing (asTest) 9 | 10 | 11 | suite : Test 12 | suite = 13 | describe "updateVersion" 14 | -- Failed poll 15 | [ updateVersion Unknown (Failure (BadBody "bad body")) 16 | |> Expect.equal Unknown 17 | |> asTest "should leave the current version unchanged (Unknown) if the poll failed" 18 | , updateVersion (Version "hash") (Failure (BadBody "bad body")) 19 | |> Expect.equal (Version "hash") 20 | |> asTest "should leave the current version unchanged (Version ...) if the poll failed" 21 | , updateVersion NewerVersion (Failure (BadBody "bad body")) 22 | |> Expect.equal NewerVersion 23 | |> asTest "should leave the current version unchanged (NewerVersion) if the poll failed" 24 | 25 | -- Successful poll 26 | , updateVersion Unknown (Success "hash") 27 | |> Expect.equal (Version "hash") 28 | |> asTest "should go from Unknown to Version ..." 29 | , updateVersion (Version "hash") (Success "hash") 30 | |> Expect.equal (Version "hash") 31 | |> asTest "should leave the version unchanged if it didn't change" 32 | , updateVersion (Version "hash1") (Success "hash2") 33 | |> Expect.equal NewerVersion 34 | |> asTest "should change to NewerVersion if the version changed" 35 | , updateVersion NewerVersion (Success "hash") 36 | |> Expect.equal NewerVersion 37 | |> asTest "should leave the current version unchanged (NewerVersion)" 38 | ] 39 | -------------------------------------------------------------------------------- /public/pages/mentions-légales.md: -------------------------------------------------------------------------------- 1 | # Mentions légales 2 | 3 | ## Éditeur de la Plateforme 4 | 5 | Ecobalyse est édité par la Fabrique Numérique du Ministère de la Transition écologique et solidaire et 6 | du Ministère de la Cohésion des territoires et des Relations avec les collectivités territoriales (Grande 7 | Arche de la Défense, 92055 La Défense CEDEX), avec l'appui de 8 | [l’incubateur de services numériques beta.gouv.fr](https://beta.gouv.fr/) de la direction 9 | interministérielle du numérique (DINUM). 10 | 11 | ## Directeur de la publication 12 | 13 | Mr Pascal Dagras. 14 | 15 | ## Hébergement de la Plateforme 16 | 17 | Le site Ecobalyse (ecobalyse.beta.gouv.fr) est hébergé par la société [Scalingo SAS](https://scalingo.com/fr), 18 | inscrite au RCS (Strasbourg B 808 665 483) et dont les serveurs se situent en France. 19 | 20 | - SIREN : 808665483 21 | - Siège social : 15 avenue du Rhin, 67100 Strasbourg, France. 22 | 23 | ## Accessibilité 24 | 25 | Voir la page [Déclaration d’accessibilité](/#/pages/accessibilit%C3%A9). 26 | 27 | ## Signaler un dysfonctionnement 28 | 29 | Si vous rencontrez un défaut d’accessibilité vous empêchant d’accéder à un contenu ou une fonctionnalité 30 | du site, merci de nous en faire part. Si vous n’obtenez pas de réponse rapide de notre part, vous êtes en 31 | droit de faire parvenir vos doléances ou une demande de saisine au Défenseur des droits. 32 | 33 | ## Politique de confidentialité 34 | 35 | Le site Ecobalyse ne collecte ni n'héberge aucune donnée à caractère personnel. 36 | 37 | # Sécurité 38 | 39 | Le site est protégé par un certificat électronique, matérialisé pour la grande majorité des navigateurs par un cadenas. Cette protection participe à la confidentialité des échanges. En aucun cas les services associés à la plateforme ne seront à l’origine d’envoi de courriels pour demander la saisie d’informations personnelles. 40 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | 29 | - name: Cache node_modules 30 | id: cache-node_modules 31 | uses: actions/cache@v2 32 | with: 33 | path: node_modules 34 | key: node_modules-${{ hashFiles('package.json', 'package-lock.json') }} 35 | 36 | - name: Cache ~/.elm 37 | # see https://docs.microsoft.com/en-us/answers/questions/510640/deploy-elm-app-to-azure-static-website-from-github.html 38 | uses: actions/cache@v2 39 | with: 40 | path: ~/.elm 41 | key: elm-cache-${{ hashFiles('elm.json') }} 42 | restore-keys: elm-cache- 43 | 44 | - name: Install dependencies 45 | run: npm ci --prefer-offline --no-audit 46 | 47 | - name: Build app 48 | run: npm run build --if-present 49 | 50 | - name: Build Elm static Db 51 | run: npm run db:build 52 | 53 | - name: Run prettier formatting check 54 | run: npm run format:check 55 | 56 | - name: Run elm-review 57 | run: npm run test:review 58 | 59 | - name: Run client tests 60 | run: npm run test:client 61 | 62 | - name: Run server tests 63 | run: npm run test:server 64 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const matomo = require("piwik"); 2 | 3 | function setupTracker(spec) { 4 | const tracker = matomo.setup(`https://${process.env.MATOMO_HOST}`, process.env.MATOMO_TOKEN); 5 | 6 | function toMatomoCvar(data) { 7 | return data.reduce((acc, entry, index) => { 8 | if (entry.value !== undefined) { 9 | acc[index + 1] = [entry.name, entry.value]; 10 | } 11 | return acc; 12 | }, {}); 13 | } 14 | 15 | function getQueryParams(query) { 16 | return Object.keys(spec.components.parameters).map((p) => { 17 | const name = spec.components.parameters[p].name.replace("[]", ""); 18 | return { name: `query.${name}`, value: query[name] }; 19 | }); 20 | } 21 | 22 | return { 23 | track(status, req) { 24 | if (process.env.NODE_ENV !== "production") { 25 | return; 26 | } 27 | try { 28 | // https://developer.matomo.org/api-reference/tracking-api 29 | const payload = { 30 | url: "https://ecobalyse.beta.gouv.fr/api" + req.url, 31 | idsite: process.env.MATOMO_SITE_ID, 32 | action_name: "ApiRequest", 33 | e_a: "ApiRequest", 34 | idgoal: 1, // "API" goal on Matomo 35 | _rcn: "API", 36 | _cvar: toMatomoCvar( 37 | [ 38 | { name: "http.method", value: req.method }, 39 | { name: "http.path", value: req.path }, 40 | { name: "http.status", value: status }, 41 | ].concat(getQueryParams(req.query)), 42 | ), 43 | ua: req.header("User-Agent"), 44 | lang: req.header("Accept-Language"), 45 | rand: Math.floor(Math.random() * 10000000), 46 | rec: 1, 47 | }; 48 | tracker.track(payload, (err) => { 49 | if (err) { 50 | console.error(err); 51 | } 52 | }); 53 | } catch (err) { 54 | console.error(err); 55 | } 56 | }, 57 | }; 58 | } 59 | 60 | module.exports = { 61 | setupTracker, 62 | }; 63 | -------------------------------------------------------------------------------- /src/Views/Icon.elm: -------------------------------------------------------------------------------- 1 | module Views.Icon exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | 6 | 7 | icon : String -> Html msg 8 | icon name = 9 | i [ attribute "aria-hidden" "true", class ("icon icon-" ++ name) ] [] 10 | 11 | 12 | boat : Html msg 13 | boat = 14 | icon "ship" 15 | 16 | 17 | boatCooled : Html msg 18 | boatCooled = 19 | i [ attribute "aria-hidden" "true", class "icon icon-ship" ] 20 | [ snow ] 21 | 22 | 23 | build : Html msg 24 | build = 25 | icon "build" 26 | 27 | 28 | bus : Html msg 29 | bus = 30 | icon "truck" 31 | 32 | 33 | busCooled : Html msg 34 | busCooled = 35 | i [ attribute "aria-hidden" "true", class "icon icon-truck" ] 36 | [ snow ] 37 | 38 | 39 | check : Html msg 40 | check = 41 | icon "check" 42 | 43 | 44 | checkCircle : Html msg 45 | checkCircle = 46 | icon "check-circle" 47 | 48 | 49 | clipboard : Html msg 50 | clipboard = 51 | icon "clipboard" 52 | 53 | 54 | exclamation : Html msg 55 | exclamation = 56 | icon "exclamation" 57 | 58 | 59 | ham : Html msg 60 | ham = 61 | icon "ham" 62 | 63 | 64 | info : Html msg 65 | info = 66 | icon "info" 67 | 68 | 69 | lock : Html msg 70 | lock = 71 | icon "lock" 72 | 73 | 74 | plane : Html msg 75 | plane = 76 | icon "plane" 77 | 78 | 79 | plus : Html msg 80 | plus = 81 | icon "plus" 82 | 83 | 84 | question : Html msg 85 | question = 86 | icon "question" 87 | 88 | 89 | search : Html msg 90 | search = 91 | icon "search" 92 | 93 | 94 | snow : Html msg 95 | snow = 96 | icon "snow" 97 | 98 | 99 | stats : Html msg 100 | stats = 101 | icon "stats" 102 | 103 | 104 | trash : Html msg 105 | trash = 106 | icon "trash" 107 | 108 | 109 | warning : Html msg 110 | warning = 111 | icon "warning" 112 | 113 | 114 | zoomin : Html msg 115 | zoomin = 116 | icon "zoomin" 117 | 118 | 119 | zoomout : Html msg 120 | zoomout = 121 | icon "zoomout" 122 | -------------------------------------------------------------------------------- /src/Page/Explore/TextileProcesses.elm: -------------------------------------------------------------------------------- 1 | module Page.Explore.TextileProcesses exposing (table) 2 | 3 | import Data.Dataset as Dataset 4 | import Data.Scope exposing (Scope) 5 | import Data.Textile.Process as Process exposing (Process) 6 | import Html exposing (..) 7 | import Html.Attributes exposing (..) 8 | import Page.Explore.Table exposing (Table) 9 | import Route 10 | 11 | 12 | table : { detailed : Bool, scope : Scope } -> Table Process String msg 13 | table { detailed, scope } = 14 | { toId = .uuid >> Process.uuidToString 15 | , toRoute = .uuid >> Just >> Dataset.TextileProcesses >> Route.Explore scope 16 | , rows = 17 | [ { label = "Étape" 18 | , toValue = .stepUsage 19 | , toCell = .stepUsage >> text 20 | } 21 | , { label = "Identifiant" 22 | , toValue = .uuid >> Process.uuidToString 23 | , toCell = 24 | .uuid 25 | >> Process.uuidToString 26 | >> (\uuid -> 27 | if detailed then 28 | code [] [ text uuid ] 29 | 30 | else 31 | a [ Route.href (Route.Explore scope (Dataset.TextileProcesses (Just (Process.Uuid uuid)))) ] 32 | [ code [] [ text uuid ] ] 33 | ) 34 | } 35 | , { label = "Nom" 36 | , toValue = .name 37 | , toCell = .name >> text 38 | } 39 | , { label = "Source" 40 | , toValue = .source 41 | , toCell = 42 | \process -> 43 | span [ title process.source ] [ text process.source ] 44 | } 45 | , { label = "Correctif" 46 | , toValue = .correctif 47 | , toCell = 48 | \process -> 49 | span [ title process.correctif ] [ text process.correctif ] 50 | } 51 | , { label = "Unité" 52 | , toValue = .unit 53 | , toCell = .unit >> text 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /data/bwapi/README.txt: -------------------------------------------------------------------------------- 1 | This a reimplementation of the Brightway explorer through an API, aimed at 2 | being less dependent from Jupyter and Python. Note that this correspond to the 3 | direct export of the Brightway data. 4 | 5 | # Sample list of API endpoints: 6 | 7 | 8 | ## Projects and databases 9 | 10 | * The list of Brightway projects 11 | 12 | https://bwapi.ecobalyse.fr/projects 13 | 14 | * The list of Databases in the project `food` 15 | 16 | https://bwapi.ecobalyse.fr/food/databases 17 | 18 | 19 | ## Search 20 | 21 | * A search of the term `coffee` in the database `Agribalyse 3.1.1` 22 | 23 | https://bwapi.ecobalyse.fr/food/Agribalyse%203.1.1/search/?q=coffee 24 | 25 | ## Activity data and graph 26 | 27 | * The full data of the activity with code `109d03783d26742b87f1a94889d972f3` 28 | 29 | https://bwapi.ecobalyse.fr/food/Agribalyse%203.1.1/109d03783d26742b87f1a94889d972f3/data 30 | 31 | * The first level of the upstream activities of the same activity 32 | 33 | https://bwapi.ecobalyse.fr/food/Agribalyse%203.1.1/109d03783d26742b87f1a94889d972f3/technosphere 34 | 35 | * The elementary flows of the same activity 36 | 37 | https://bwapi.ecobalyse.fr/food/Agribalyse%203.1.1/109d03783d26742b87f1a94889d972f3/biosphere 38 | 39 | * The substitution exchanges of the same activity 40 | 41 | https://bwapi.ecobalyse.fr/food/Agribalyse%203.1.1/109d03783d26742b87f1a94889d972f3/substitution 42 | 43 | * The impacts of the same activity 44 | 45 | https://bwapi.ecobalyse.fr/food/Agribalyse%203.1.1/109d03783d26742b87f1a94889d972f3/impacts/EF%20v3.1 46 | 47 | 48 | ## Methods 49 | 50 | * The list of LCIA methods 51 | 52 | https://bwapi.ecobalyse.fr/food/methods 53 | 54 | * The list of impact categories for a method 55 | 56 | https://bwapi.ecobalyse.fr/food/methods/EF%20v3.1 57 | 58 | * The details of a method 59 | 60 | https://bwapi.ecobalyse.fr/food/methods/EF%20v3.1/acidification/accumulated%20exceedance%20(AE) 61 | 62 | * The characterization factors of a method 63 | 64 | https://bwapi.ecobalyse.fr/food/characterization_factors/EF%20v3.1/acidification/accumulated%20exceedance%20(AE) 65 | 66 | 67 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": ["src"], 4 | "elm-version": "0.19.1", 5 | "dependencies": { 6 | "direct": { 7 | "NoRedInk/elm-json-decode-pipeline": "1.0.1", 8 | "NoRedInk/elm-sortable-table": "1.0.0", 9 | "chain-partners/elm-bignum": "1.0.1", 10 | "cuducos/elm-format-number": "8.1.4", 11 | "dillonkearns/elm-markdown": "7.0.1", 12 | "elm/browser": "1.0.2", 13 | "elm/core": "1.0.5", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/regex": "1.0.0", 18 | "elm/time": "1.0.0", 19 | "elm/url": "1.0.0", 20 | "elm-community/json-extra": "4.3.0", 21 | "elm-community/list-extra": "8.7.0", 22 | "elm-community/result-extra": "2.4.0", 23 | "elm-community/string-extra": "4.0.1", 24 | "f0i/debug-to-json": "1.0.6", 25 | "futureworkz/elm-autocomplete": "1.0.2", 26 | "gingko/time-distance": "2.4.0", 27 | "ianmackenzie/elm-units": "2.9.0", 28 | "krisajenkins/remotedata": "6.0.1", 29 | "kuon/elm-string-normalize": "1.0.5", 30 | "ohanhi/remotedata-http": "4.0.0", 31 | "rtfeldman/elm-iso8601-date-strings": "1.1.4", 32 | "truqu/elm-base64": "2.0.4", 33 | "turboMaCk/any-dict": "2.6.0" 34 | }, 35 | "indirect": { 36 | "elm/bytes": "1.0.8", 37 | "elm/file": "1.0.5", 38 | "elm/parser": "1.1.0", 39 | "elm/virtual-dom": "1.0.3", 40 | "jinjor/elm-debounce": "3.0.0", 41 | "myrho/elm-round": "1.0.5", 42 | "robinheghan/murmur3": "1.0.0", 43 | "rtfeldman/elm-css": "17.1.1", 44 | "rtfeldman/elm-hex": "1.0.0" 45 | } 46 | }, 47 | "test-dependencies": { 48 | "direct": { 49 | "elm-explorations/test": "2.1.1" 50 | }, 51 | "indirect": { 52 | "elm/random": "1.0.0" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Data/Textile/HeatSource.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.HeatSource exposing 2 | ( HeatSource(..) 3 | , decode 4 | , encode 5 | , fromString 6 | , toLabelWithZone 7 | , toString 8 | ) 9 | 10 | import Data.Zone as Zone exposing (Zone) 11 | import Json.Decode as Decode exposing (Decoder) 12 | import Json.Decode.Extra as DE 13 | import Json.Encode as Encode 14 | 15 | 16 | type HeatSource 17 | = Coal 18 | | HeavyFuel 19 | | LightFuel 20 | | NaturalGas 21 | 22 | 23 | decode : Decoder HeatSource 24 | decode = 25 | Decode.string 26 | |> Decode.andThen (fromString >> DE.fromResult) 27 | 28 | 29 | encode : HeatSource -> Encode.Value 30 | encode = 31 | toString >> Encode.string 32 | 33 | 34 | fromString : String -> Result String HeatSource 35 | fromString string = 36 | case string of 37 | "coal" -> 38 | Ok Coal 39 | 40 | "heavyfuel" -> 41 | Ok HeavyFuel 42 | 43 | "lightfuel" -> 44 | Ok LightFuel 45 | 46 | "naturalgas" -> 47 | Ok NaturalGas 48 | 49 | _ -> 50 | Err <| "Source de production de vapeur inconnue: " ++ string 51 | 52 | 53 | toLabel : HeatSource -> String 54 | toLabel source = 55 | case source of 56 | Coal -> 57 | "Charbon" 58 | 59 | HeavyFuel -> 60 | "Fioul lourd" 61 | 62 | LightFuel -> 63 | "Fioul léger" 64 | 65 | NaturalGas -> 66 | "Gaz naturel" 67 | 68 | 69 | toLabelWithZone : Zone -> HeatSource -> String 70 | toLabelWithZone zone heatSource = 71 | let 72 | zoneLabel = 73 | case zone of 74 | Zone.Europe -> 75 | " (Europe)" 76 | 77 | _ -> 78 | " (hors Europe)" 79 | in 80 | toLabel heatSource ++ zoneLabel 81 | 82 | 83 | toString : HeatSource -> String 84 | toString source = 85 | case source of 86 | Coal -> 87 | "coal" 88 | 89 | HeavyFuel -> 90 | "heavyfuel" 91 | 92 | LightFuel -> 93 | "lightfuel" 94 | 95 | NaturalGas -> 96 | "naturalgas" 97 | -------------------------------------------------------------------------------- /src/Views/Transport.elm: -------------------------------------------------------------------------------- 1 | module Views.Transport exposing (entry, viewDetails) 2 | 3 | import Data.Transport exposing (Transport) 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Length exposing (Length) 7 | import Views.Format as Format 8 | import Views.Icon as Icon 9 | 10 | 11 | type alias Config = 12 | { fullWidth : Bool 13 | , onlyIcons : Bool 14 | , hideNoLength : Bool 15 | , airTransportLabel : Maybe String 16 | , seaTransportLabel : Maybe String 17 | , roadTransportLabel : Maybe String 18 | } 19 | 20 | 21 | viewDetails : Config -> Transport -> List (Html msg) 22 | viewDetails { onlyIcons, hideNoLength, airTransportLabel, seaTransportLabel, roadTransportLabel } { air, sea, seaCooled, road, roadCooled } = 23 | [ { distance = air, icon = Icon.plane, label = Maybe.withDefault "Transport aérien" airTransportLabel } 24 | , { distance = sea, icon = Icon.boat, label = Maybe.withDefault "Transport maritime" seaTransportLabel } 25 | , { distance = seaCooled, icon = Icon.boatCooled, label = "Transport maritime réfrigéré" } 26 | , { distance = road, icon = Icon.bus, label = Maybe.withDefault "Transport routier" roadTransportLabel } 27 | , { distance = roadCooled, icon = Icon.busCooled, label = "Transport routier réfrigéré" } 28 | ] 29 | |> List.filterMap 30 | (\{ distance, icon, label } -> 31 | if Length.inKilometers distance == 0 && hideNoLength then 32 | Nothing 33 | 34 | else 35 | Just <| entry { onlyIcons = onlyIcons, distance = distance, icon = icon, label = label } 36 | ) 37 | 38 | 39 | type alias EntryConfig msg = 40 | { onlyIcons : Bool 41 | , distance : Length 42 | , icon : Html msg 43 | , label : String 44 | } 45 | 46 | 47 | entry : EntryConfig msg -> Html msg 48 | entry { onlyIcons, distance, icon, label } = 49 | span 50 | [ class "d-flex align-items-center gap-1", title label ] 51 | [ span [ style "cursor" "help" ] [ icon ] 52 | , if onlyIcons then 53 | text "" 54 | 55 | else 56 | Format.km distance 57 | ] 58 | -------------------------------------------------------------------------------- /tests/Data/Textile/ProductTest.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.ProductTest exposing (..) 2 | 3 | import Data.Textile.Inputs as Inputs 4 | import Data.Textile.Product as Product 5 | import Data.Unit as Unit 6 | import Duration 7 | import Expect 8 | import Test exposing (..) 9 | import TestUtils exposing (asTest) 10 | 11 | 12 | sampleQuery : Inputs.Query 13 | sampleQuery = 14 | Inputs.tShirtCotonAsie 15 | 16 | 17 | suite : Test 18 | suite = 19 | describe "Data.Product" 20 | [ describe "customDaysOfWear" 21 | [ { daysOfWear = Duration.days 100, wearsPerCycle = 20 } 22 | |> Product.customDaysOfWear (Just (Unit.quality 1)) Nothing 23 | |> Expect.equal 24 | { daysOfWear = Duration.days 100 25 | , useNbCycles = 5 26 | } 27 | |> asTest "should compute custom number of days of wear" 28 | , { daysOfWear = Duration.days 100, wearsPerCycle = 20 } 29 | |> Product.customDaysOfWear (Just (Unit.quality 0.8)) Nothing 30 | |> Expect.equal 31 | { daysOfWear = Duration.days 80 32 | , useNbCycles = 4 33 | } 34 | |> asTest "should compute custom number of days of wear with custom quality" 35 | , { daysOfWear = Duration.days 100, wearsPerCycle = 20 } 36 | |> Product.customDaysOfWear Nothing (Just (Unit.reparability 1.2)) 37 | |> Expect.equal 38 | { daysOfWear = Duration.days 120 39 | , useNbCycles = 6 40 | } 41 | |> asTest "should compute custom number of days of wear with custom reparability" 42 | , { daysOfWear = Duration.days 100, wearsPerCycle = 20 } 43 | |> Product.customDaysOfWear (Just (Unit.quality 1.2)) (Just (Unit.reparability 1.2)) 44 | |> Expect.equal 45 | { daysOfWear = Duration.days 144 46 | , useNbCycles = 7 47 | } 48 | |> asTest "should compute custom number of days of wear with custom quality & reparability" 49 | ] 50 | ] 51 | -------------------------------------------------------------------------------- /lib/charts/food-comparator.js: -------------------------------------------------------------------------------- 1 | import BaseChart from "./base"; 2 | 3 | export default class extends BaseChart { 4 | constructor() { 5 | super(); 6 | } 7 | 8 | static get observedAttributes() { 9 | return ["data"]; 10 | } 11 | 12 | get config() { 13 | return { 14 | chart: { 15 | type: "bar", 16 | height: "100%", 17 | animation: false, 18 | }, 19 | title: false, 20 | xAxis: { 21 | categories: [], 22 | tickPosition: "inside", 23 | labels: { 24 | allowOverlap: false, 25 | style: { fontSize: "13px", color: "#333" }, 26 | }, 27 | }, 28 | yAxis: { 29 | title: { 30 | text: "µPts d'impact", 31 | }, 32 | }, 33 | legend: { 34 | reversed: true, 35 | }, 36 | plotOptions: { 37 | animation: false, 38 | series: { 39 | animation: false, 40 | stacking: "normal", 41 | }, 42 | }, 43 | tooltip: { 44 | valueDecimals: 2, 45 | valueSuffix: " µPt d'impact", 46 | }, 47 | series: [], 48 | exporting: { 49 | fallbackToExportServer: false, 50 | chartOptions: { 51 | title: false, 52 | }, 53 | filename: "ecobalyse", 54 | }, 55 | }; 56 | } 57 | 58 | attributeChanged(name, oldValue, newValue) { 59 | if (name === "data") { 60 | const rawData = JSON.parse(newValue); 61 | // Code below will map the JSON data received from Elm to data structures 62 | // expected by Highcharts. 63 | const series = rawData[0].data.map(({ name, color }, idx) => { 64 | return { name, color, data: rawData.map(({ data }) => data[idx].y) }; 65 | }); 66 | this.chart.update({ 67 | xAxis: { 68 | categories: rawData.map(({ label }) => label), 69 | }, 70 | }); 71 | // Remove all existing series... 72 | while (this.chart.series.length) { 73 | this.chart.series[0].remove(); 74 | } 75 | // ... and replace them with fresh ones 76 | for (const serie of series) { 77 | this.chart.addSeries(serie); 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Data/Textile/Db.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.Db exposing 2 | ( Db 3 | , buildFromJson 4 | ) 5 | 6 | import Data.Country as Country exposing (Country) 7 | import Data.Impact.Definition as Definition exposing (Definitions) 8 | import Data.Textile.Material as Material exposing (Material) 9 | import Data.Textile.Process as TextileProcess 10 | import Data.Textile.Product as Product exposing (Product) 11 | import Data.Transport as Transport exposing (Distances) 12 | import Json.Decode as Decode exposing (Decoder) 13 | import Json.Decode.Extra as DE 14 | 15 | 16 | type alias Db = 17 | { impactDefinitions : Definitions 18 | , processes : List TextileProcess.Process 19 | , countries : List Country 20 | , materials : List Material 21 | , products : List Product 22 | , transports : Distances 23 | , wellKnown : TextileProcess.WellKnown 24 | } 25 | 26 | 27 | buildFromJson : String -> Result String Db 28 | buildFromJson json = 29 | Decode.decodeString decode json 30 | |> Result.mapError Decode.errorToString 31 | 32 | 33 | decode : Decoder Db 34 | decode = 35 | Decode.field "impacts" Definition.decode 36 | |> Decode.andThen 37 | (\definitions -> 38 | Decode.field "processes" (TextileProcess.decodeList definitions) 39 | |> Decode.andThen 40 | (\processes -> 41 | Decode.map4 (Db definitions processes) 42 | (Decode.field "countries" (Country.decodeList processes)) 43 | (Decode.field "materials" (Material.decodeList processes)) 44 | (Decode.field "products" (Product.decodeList processes)) 45 | (Decode.field "transports" Transport.decodeDistances) 46 | |> Decode.andThen 47 | (\partiallyLoaded -> 48 | TextileProcess.loadWellKnown processes 49 | |> Result.map partiallyLoaded 50 | |> DE.fromResult 51 | ) 52 | ) 53 | ) 54 | -------------------------------------------------------------------------------- /src/Page/Editorial.elm: -------------------------------------------------------------------------------- 1 | module Page.Editorial exposing 2 | ( Model 3 | , Msg(..) 4 | , init 5 | , update 6 | , view 7 | ) 8 | 9 | import Data.Session exposing (Session) 10 | import Html exposing (..) 11 | import Html.Attributes exposing (..) 12 | import Http 13 | import List.Extra as LE 14 | import Ports 15 | import RemoteData exposing (WebData) 16 | import Views.Alert as Alert 17 | import Views.Container as Container 18 | import Views.Markdown as Markdown 19 | import Views.Spinner as Spinner 20 | 21 | 22 | type alias Model = 23 | { slug : String 24 | , content : WebData String 25 | } 26 | 27 | 28 | type Msg 29 | = ContentReceived (WebData String) 30 | 31 | 32 | init : String -> Session -> ( Model, Session, Cmd Msg ) 33 | init slug session = 34 | ( { slug = slug, content = RemoteData.Loading } 35 | , session 36 | , Cmd.batch 37 | [ Ports.scrollTo { x = 0, y = 0 } 38 | , Http.get 39 | { url = "pages/" ++ slug ++ ".md" 40 | , expect = 41 | Http.expectString 42 | (RemoteData.fromResult >> ContentReceived) 43 | } 44 | ] 45 | ) 46 | 47 | 48 | update : Session -> Msg -> Model -> ( Model, Session, Cmd Msg ) 49 | update session msg model = 50 | case msg of 51 | ContentReceived content -> 52 | ( { model | content = content }, session, Cmd.none ) 53 | 54 | 55 | view : Session -> Model -> ( String, List (Html Msg) ) 56 | view _ model = 57 | case model.content of 58 | RemoteData.Success content -> 59 | ( content 60 | |> String.split "\n" 61 | |> List.head 62 | |> Maybe.andThen (String.split "# " >> LE.last) 63 | |> Maybe.withDefault "Sans titre" 64 | , [ Container.centered [] 65 | [ Markdown.simple [ class "pb-5" ] content 66 | ] 67 | ] 68 | ) 69 | 70 | RemoteData.Loading -> 71 | ( "Chargement…", [ Spinner.view ] ) 72 | 73 | RemoteData.Failure httpError -> 74 | ( "Erreur de chargement", [ Alert.httpError httpError ] ) 75 | 76 | RemoteData.NotAsked -> 77 | ( "", [] ) 78 | -------------------------------------------------------------------------------- /review/src/ReviewConfig.elm: -------------------------------------------------------------------------------- 1 | module ReviewConfig exposing (config) 2 | 3 | 4 | import NoDebug.TodoOrToString 5 | import NoExposingEverything 6 | import NoImportingEverything 7 | import NoMissingTypeAnnotation 8 | import NoRedundantConcat 9 | import NoRedundantCons 10 | import NoUnused.CustomTypeConstructorArgs 11 | import NoUnused.CustomTypeConstructors 12 | import NoUnused.Dependencies 13 | import NoUnused.Exports 14 | import NoUnused.Modules 15 | import NoUnused.Parameters 16 | import NoUnused.Patterns 17 | import NoUnused.Variables 18 | import Review.Rule as Rule exposing (Rule) 19 | import Simplify 20 | import CognitiveComplexity 21 | 22 | 23 | config : List Rule 24 | config = 25 | [ -- CognitiveComplexity 26 | CognitiveComplexity.rule 15 27 | -- NoDebug 28 | , NoDebug.TodoOrToString.rule 29 | |> Rule.ignoreErrorsForFiles [ "src/Views/Debug.elm" ] 30 | -- Common 31 | , NoExposingEverything.rule 32 | |> Rule.ignoreErrorsForFiles [ "src/Data/Color.elm" ] 33 | |> Rule.ignoreErrorsForFiles [ "src/Views/Icon.elm" ] 34 | |> Rule.ignoreErrorsForDirectories [ "tests/" ] 35 | , NoImportingEverything.rule 36 | [ "Html" 37 | , "Html.Attributes" 38 | , "Html.Events" 39 | , "Svg" 40 | , "Svg.Attributes" 41 | ] 42 | |> Rule.ignoreErrorsForDirectories [ "tests/" ] 43 | , NoMissingTypeAnnotation.rule 44 | |> Rule.ignoreErrorsForDirectories [ "tests/" ] 45 | , NoRedundantConcat.rule 46 | , NoRedundantCons.rule 47 | -- NoUnused 48 | , NoUnused.CustomTypeConstructors.rule [] 49 | |> Rule.ignoreErrorsForFiles [ "src/Views/Modal.elm" ] 50 | , NoUnused.CustomTypeConstructorArgs.rule 51 | |> Rule.ignoreErrorsForFiles [ "src/Server/Route.elm" ] 52 | |> Rule.ignoreErrorsForFiles [ "src/Views/Page.elm" ] 53 | , NoUnused.Dependencies.rule 54 | , NoUnused.Exports.rule 55 | |> Rule.ignoreErrorsForFiles [ "src/Views/Button.elm" ] 56 | |> Rule.ignoreErrorsForFiles [ "src/Views/Debug.elm" ] 57 | , NoUnused.Modules.rule 58 | |> Rule.ignoreErrorsForFiles [ "src/Views/Debug.elm" ] 59 | , NoUnused.Parameters.rule 60 | , NoUnused.Patterns.rule 61 | , NoUnused.Variables.rule 62 | -- Simlify 63 | , Simplify.rule Simplify.defaults 64 | ] 65 | -------------------------------------------------------------------------------- /bin/build-db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This script generates JSON Db fixtures so tests can perform assertions 5 | * against actual JSON data without having to rely on Http requests, which 6 | * is impossible in an Elm test environment. 7 | */ 8 | const fs = require("fs"); 9 | 10 | function getJson(path) { 11 | return JSON.parse(fs.readFileSync(path).toString()); 12 | } 13 | 14 | /** 15 | * Adapts a standard JSON string to what is expected to be the format 16 | * used in Elm's template strings (`"""{}"""`). 17 | */ 18 | function serializeForElmTemplateString(toStringify) { 19 | return JSON.stringify(toStringify).replaceAll("\\", "\\\\"); 20 | } 21 | 22 | function buildTextileJsonDb(basePath = "public/data") { 23 | return serializeForElmTemplateString({ 24 | // common data 25 | countries: getJson(`${basePath}/countries.json`), 26 | impacts: getJson(`${basePath}/impacts.json`), 27 | transports: getJson(`${basePath}/transports.json`), 28 | // textile data 29 | materials: getJson(`${basePath}/textile/materials.json`), 30 | processes: getJson(`${basePath}/textile/processes.json`), 31 | products: getJson(`${basePath}/textile/products.json`), 32 | }); 33 | } 34 | 35 | function buildFoodProcessesJsonDb(basePath = "public/data/food") { 36 | return serializeForElmTemplateString(getJson(`${basePath}/processes.json`)); 37 | } 38 | 39 | function buildFoodIngredientsJsonDb(basePath = "public/data/food") { 40 | return serializeForElmTemplateString(getJson(`${basePath}/ingredients.json`)); 41 | } 42 | 43 | const targetDbFile = "src/Static/Db.elm"; 44 | const elmTemplate = fs.readFileSync(`${targetDbFile}-template`).toString(); 45 | const elmWithFixtures = elmTemplate 46 | .replace("%textileJson%", buildTextileJsonDb()) 47 | .replace("%foodProcessesJson%", buildFoodProcessesJsonDb()) 48 | .replace("%foodIngredientsJson%", buildFoodIngredientsJsonDb()); 49 | 50 | const header = 51 | "---- THIS FILE WAS GENERATED FROM THE FILE `Db.elm-template` BY THE `/bin/build-db` SCRIPT"; 52 | 53 | try { 54 | fs.writeFileSync(targetDbFile, `${header}\n\n${elmWithFixtures}`); 55 | const fileSizeInKB = Math.ceil(fs.statSync(targetDbFile).size / 1024); 56 | console.log(`Successfully generated Elm static database at ${targetDbFile} (${fileSizeInKB} KB)`); 57 | } catch (err) { 58 | throw new Error(`Unable to generate Elm static database: ${err}`); 59 | } 60 | -------------------------------------------------------------------------------- /tests/Data/Textile/LifeCycleTest.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.LifeCycleTest exposing (..) 2 | 3 | import Data.Country as Country 4 | import Data.Textile.Db as TextileDb 5 | import Data.Textile.Inputs as Inputs exposing (tShirtCotonFrance) 6 | import Data.Textile.LifeCycle as LifeCycle exposing (LifeCycle) 7 | import Expect 8 | import Length 9 | import Test exposing (..) 10 | import TestUtils exposing (asTest, suiteWithDb) 11 | 12 | 13 | km : Float -> Length.Length 14 | km = 15 | Length.kilometers 16 | 17 | 18 | lifeCycleToTransports : TextileDb.Db -> Inputs.Query -> LifeCycle -> Result String LifeCycle 19 | lifeCycleToTransports textileDb query lifeCycle = 20 | query 21 | |> Inputs.fromQuery textileDb 22 | |> Result.map 23 | (\materials -> 24 | LifeCycle.computeStepsTransport textileDb materials lifeCycle 25 | ) 26 | 27 | 28 | suite : Test 29 | suite = 30 | suiteWithDb "Data.LifeCycle" 31 | (\{ textileDb } -> 32 | [ describe "computeTransportSummary" 33 | [ tShirtCotonFrance 34 | |> LifeCycle.fromQuery textileDb 35 | |> Result.andThen (lifeCycleToTransports textileDb tShirtCotonFrance) 36 | |> Result.map LifeCycle.computeTotalTransportImpacts 37 | |> Result.map (\{ road, sea } -> ( Length.inKilometers road, Length.inKilometers sea )) 38 | |> Expect.equal (Ok ( 3000, 21549 )) 39 | |> asTest "should compute default distances" 40 | , let 41 | tShirtCotonEnnoblementIndia = 42 | { tShirtCotonFrance 43 | | countryFabric = Country.Code "FR" 44 | , countryDyeing = Country.Code "IN" -- Ennoblement in India 45 | , countryMaking = Country.Code "FR" 46 | } 47 | in 48 | tShirtCotonEnnoblementIndia 49 | |> LifeCycle.fromQuery textileDb 50 | |> Result.andThen (lifeCycleToTransports textileDb tShirtCotonEnnoblementIndia) 51 | |> Result.map LifeCycle.computeTotalTransportImpacts 52 | |> Result.map (\{ road, sea } -> ( Length.inKilometers road, Length.inKilometers sea )) 53 | |> Expect.equal (Ok ( 2000, 45471 )) 54 | |> asTest "should compute custom distances" 55 | ] 56 | ] 57 | ) 58 | -------------------------------------------------------------------------------- /tests/Data/TransportTest.elm: -------------------------------------------------------------------------------- 1 | module Data.TransportTest exposing (..) 2 | 3 | import Data.Country as Country 4 | import Data.Impact as Impact exposing (Impacts) 5 | import Data.Scope as Scope 6 | import Data.Transport as Transport exposing (Transport) 7 | import Dict.Any as AnyDict 8 | import Expect 9 | import Length 10 | import Test exposing (..) 11 | import TestUtils exposing (asTest, suiteWithDb) 12 | 13 | 14 | km : Float -> Length.Length 15 | km = 16 | Length.kilometers 17 | 18 | 19 | franceChina : Impacts -> Transport 20 | franceChina impacts = 21 | { road = km 8169 22 | , roadCooled = km 0 23 | , sea = km 21549 24 | , seaCooled = km 0 25 | , air = km 8189 26 | , impacts = impacts 27 | } 28 | 29 | 30 | suite : Test 31 | suite = 32 | suiteWithDb "Data.Transport" 33 | (\{ textileDb } -> 34 | let 35 | defaultImpacts = 36 | Impact.empty 37 | in 38 | [ textileDb.countries 39 | |> List.map 40 | (\{ code } -> 41 | AnyDict.keys textileDb.transports 42 | |> List.member code 43 | |> Expect.equal True 44 | |> asTest (Country.codeToString code ++ "should have transports data available") 45 | ) 46 | |> describe "transports data availability checks" 47 | , describe "getTransportBetween" 48 | [ textileDb.transports 49 | |> Transport.getTransportBetween Scope.Textile defaultImpacts (Country.Code "FR") (Country.Code "CN") 50 | |> Expect.equal (franceChina defaultImpacts) 51 | |> asTest "should retrieve distance between two countries" 52 | , textileDb.transports 53 | |> Transport.getTransportBetween Scope.Textile defaultImpacts (Country.Code "CN") (Country.Code "FR") 54 | |> Expect.equal (franceChina defaultImpacts) 55 | |> asTest "should retrieve distance between two swapped countries" 56 | , textileDb.transports 57 | |> Transport.getTransportBetween Scope.Textile defaultImpacts (Country.Code "FR") (Country.Code "FR") 58 | |> Expect.equal (Transport.defaultInland Scope.Textile defaultImpacts) 59 | |> asTest "should apply default inland transport when country is the same" 60 | ] 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '22 6 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /src/Page/Explore/FoodProcesses.elm: -------------------------------------------------------------------------------- 1 | module Page.Explore.FoodProcesses exposing (table) 2 | 3 | import Data.Dataset as Dataset 4 | import Data.Food.Db as FoodDb 5 | import Data.Food.Process as FoodProcess 6 | import Data.Scope exposing (Scope) 7 | import Html exposing (..) 8 | import Page.Explore.Table exposing (Table) 9 | import Route 10 | 11 | 12 | table : FoodDb.Db -> { detailed : Bool, scope : Scope } -> Table FoodProcess.Process String msg 13 | table _ { detailed, scope } = 14 | { toId = .code >> FoodProcess.codeToString 15 | , toRoute = .code >> Just >> Dataset.FoodProcesses >> Route.Explore scope 16 | , rows = 17 | [ { label = "Identifiant" 18 | , toValue = .code >> FoodProcess.codeToString 19 | , toCell = 20 | \process -> 21 | if detailed then 22 | code [] [ text (FoodProcess.codeToString process.code) ] 23 | 24 | else 25 | a [ Route.href (Route.Explore scope (Dataset.FoodProcesses (Just process.code))) ] 26 | [ code [] [ text (FoodProcess.codeToString process.code) ] ] 27 | } 28 | , { label = "Nom" 29 | , toValue = getDisplayName 30 | , toCell = getDisplayName >> text 31 | } 32 | , { label = "Catégorie" 33 | , toValue = .category >> FoodProcess.categoryToString 34 | , toCell = .category >> FoodProcess.categoryToString >> text 35 | } 36 | , { label = "Nom technique" 37 | , toValue = .name >> FoodProcess.nameToString 38 | , toCell = .name >> FoodProcess.nameToString >> text 39 | } 40 | , { label = "Identifiant source" 41 | , toValue = .code >> FoodProcess.codeToString 42 | , toCell = \process -> code [] [ text (FoodProcess.codeToString process.code) ] 43 | } 44 | , { label = "Unité" 45 | , toValue = .unit 46 | , toCell = .unit >> text 47 | } 48 | , { label = "Description du système" 49 | , toValue = .systemDescription 50 | , toCell = .systemDescription >> text 51 | } 52 | , { label = "Commentaire" 53 | , toValue = .comment >> Maybe.withDefault "N/A" 54 | , toCell = .comment >> Maybe.withDefault "N/A" >> text 55 | } 56 | ] 57 | } 58 | 59 | 60 | getDisplayName : FoodProcess.Process -> String 61 | getDisplayName process = 62 | case process.displayName of 63 | Just displayName -> 64 | displayName 65 | 66 | Nothing -> 67 | FoodProcess.nameToString process.name 68 | -------------------------------------------------------------------------------- /src/Views/Sidebar.elm: -------------------------------------------------------------------------------- 1 | module Views.Sidebar exposing (Config, view) 2 | 3 | import Data.Bookmark exposing (Bookmark) 4 | import Data.Impact exposing (Impacts) 5 | import Data.Impact.Definition exposing (Definition, Trigram) 6 | import Data.Scope exposing (Scope) 7 | import Data.Session exposing (Session) 8 | import Html exposing (..) 9 | import Html.Attributes exposing (..) 10 | import Mass exposing (Mass) 11 | import Views.Bookmark as BookmarkView 12 | import Views.Impact as ImpactView 13 | import Views.ImpactTabs as ImpactTabs 14 | import Views.Score as ScoreView 15 | 16 | 17 | type alias Config msg = 18 | { session : Session 19 | , scope : Scope 20 | 21 | -- Impact selector 22 | , selectedImpact : Definition 23 | , switchImpact : Result String Trigram -> msg 24 | 25 | -- Score 26 | , productMass : Mass 27 | , totalImpacts : Impacts 28 | 29 | -- Impacts tabs 30 | , impactTabsConfig : ImpactTabs.Config msg 31 | 32 | -- Bookmarks 33 | , activeBookmarkTab : BookmarkView.ActiveTab 34 | , bookmarkName : String 35 | , copyToClipBoard : String -> msg 36 | , compareBookmarks : msg 37 | , deleteBookmark : Bookmark -> msg 38 | , saveBookmark : msg 39 | , updateBookmarkName : String -> msg 40 | , switchBookmarkTab : BookmarkView.ActiveTab -> msg 41 | } 42 | 43 | 44 | view : Config msg -> Html msg 45 | view config = 46 | div 47 | [ class "d-flex flex-column gap-3 mb-3 sticky-md-top" 48 | , style "top" "7px" 49 | ] 50 | [ ImpactView.selector 51 | config.session.textileDb.impactDefinitions 52 | { selectedImpact = config.selectedImpact.trigram 53 | , switchImpact = config.switchImpact 54 | } 55 | , ScoreView.view 56 | { impactDefinition = config.selectedImpact 57 | , score = config.totalImpacts 58 | , mass = config.productMass 59 | } 60 | , config.impactTabsConfig 61 | |> ImpactTabs.view config.session.textileDb.impactDefinitions 62 | , BookmarkView.view 63 | { session = config.session 64 | , activeTab = config.activeBookmarkTab 65 | , bookmarkName = config.bookmarkName 66 | , impact = config.selectedImpact 67 | , scope = config.scope 68 | , copyToClipBoard = config.copyToClipBoard 69 | , compare = config.compareBookmarks 70 | , delete = config.deleteBookmark 71 | , save = config.saveBookmark 72 | , update = config.updateBookmarkName 73 | , switchTab = config.switchBookmarkTab 74 | } 75 | ] 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecobalyse", 3 | "version": "0.0.1", 4 | "description": "Accélérer l'affichage environnemental de la filière textile française", 5 | "author": "Ecobalyse ", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "build": "npm run server:build && rimraf dist && npm run build:init && parcel build index.html --public-url ./", 10 | "build:init": "./bin/update-version.sh && mkdir -p dist && cp -r public/* dist/ && npm run db:build", 11 | "db:build": "./bin/build-db", 12 | "format:json": "npx prettier@3.0.3 --write .", 13 | "format:check": "npx prettier@3.0.3 --check .", 14 | "postinstall": "npx husky install", 15 | "server:build": "npm run db:build && elm make src/Server.elm --optimize --output=server-app.js", 16 | "server:dev": "npm run server:build && nodemon server.js --config nodemon.json", 17 | "server:debug": "elm make src/Server.elm --output=server-app.js && nodemon server.js --config nodemon.json", 18 | "server:start": "node server.js", 19 | "start:parcel": "parcel serve index.html --no-cache", 20 | "start:dev": "npm run build:init && concurrently -n \"watch,parcel,server\" -c \"green,cyan\" \"npm run start:parcel\" \"npm run server:dev\"", 21 | "start": "PARCEL_ELM_NO_DEBUG=1 npm run start:dev", 22 | "test:client": "npx elm-test", 23 | "test:review": "npx elm-review", 24 | "test:server": "npm run server:build && jest", 25 | "test": "npm run db:build && npm run test:review && npm run test:client && npm run test:server" 26 | }, 27 | "dependencies": { 28 | "@parcel/transformer-elm": "^2.10.3", 29 | "@parcel/transformer-image": "^2.10.3", 30 | "@parcel/transformer-sass": "^2.10.3", 31 | "@sentry/browser": "^7.87.0", 32 | "@sentry/node": "^7.87.0", 33 | "@sentry/tracing": "^7.87.0", 34 | "bootstrap": "^5.3.2", 35 | "cors": "^2.8.5", 36 | "dotenv": "^16.3.1", 37 | "elm": "^0.19.1-6", 38 | "express": "^4.18.2", 39 | "helmet": "^7.1.0", 40 | "highcharts": "^11.2.0", 41 | "js-yaml": "^4.1.0", 42 | "parcel": "^2.10.3", 43 | "piwik": "^1.0.9" 44 | }, 45 | "devDependencies": { 46 | "concurrently": "^8.2.2", 47 | "elm-format": "^0.8.7", 48 | "elm-json": "^0.2.13", 49 | "elm-review": "^2.10.3", 50 | "elm-test": "0.19.1-revision12", 51 | "husky": "^8.0.3", 52 | "jest": "^29.7.0", 53 | "nodemon": "^3.0.2", 54 | "npm-check-updates": "^16.14.12", 55 | "prettier": "^3.1.1", 56 | "process": "^0.11.10", 57 | "rimraf": "^5.0.5", 58 | "superagent": "^8.1.2", 59 | "supertest": "^6.3.3" 60 | }, 61 | "cacheDirectories": [ 62 | "node_modules", 63 | "~/.elm" 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /public/icomoon/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src: url('fonts/icomoon.eot?nj6lan'); 4 | src: url('fonts/icomoon.eot?nj6lan#iefix') format('embedded-opentype'), 5 | url('fonts/icomoon.ttf?nj6lan') format('truetype'), 6 | url('fonts/icomoon.woff?nj6lan') format('woff'), 7 | url('fonts/icomoon.svg?nj6lan#icomoon') format('svg'); 8 | font-weight: normal; 9 | font-style: normal; 10 | font-display: block; 11 | } 12 | 13 | [class^="icon-"], [class*=" icon-"] { 14 | /* use !important to prevent issues with browser extensions that change fonts */ 15 | font-family: 'icomoon' !important; 16 | speak: never; 17 | font-style: normal; 18 | font-weight: normal; 19 | font-variant: normal; 20 | text-transform: none; 21 | line-height: 1; 22 | 23 | /* Better Font Rendering =========== */ 24 | -webkit-font-smoothing: antialiased; 25 | -moz-osx-font-smoothing: grayscale; 26 | } 27 | 28 | .icon-leaf:before { 29 | content: "\e901"; 30 | } 31 | .icon-search:before { 32 | content: "\e902"; 33 | } 34 | .icon-ham:before { 35 | content: "\e918"; 36 | } 37 | .icon-external:before { 38 | content: "\e908"; 39 | } 40 | .icon-zoomin:before { 41 | content: "\e906"; 42 | } 43 | .icon-zoomout:before { 44 | content: "\e907"; 45 | } 46 | .icon-atom:before { 47 | content: "\e903"; 48 | } 49 | .icon-rail:before { 50 | content: "\e916"; 51 | } 52 | .icon-build:before { 53 | content: "\e909"; 54 | } 55 | .icon-home:before { 56 | content: "\e90b"; 57 | } 58 | .icon-snow:before { 59 | content: "\e917"; 60 | } 61 | .icon-plus:before { 62 | content: "\f067"; 63 | } 64 | .icon-check:before { 65 | content: "\f00c"; 66 | } 67 | .icon-lock:before { 68 | content: "\f023"; 69 | } 70 | .icon-check-circle:before { 71 | content: "\f058"; 72 | } 73 | .icon-question:before { 74 | content: "\f059"; 75 | } 76 | .icon-info:before { 77 | content: "\f05a"; 78 | } 79 | .icon-exclamation:before { 80 | content: "\f06a"; 81 | } 82 | .icon-warning:before { 83 | content: "\f071"; 84 | } 85 | .icon-plane:before { 86 | content: "\f072"; 87 | } 88 | .icon-stats:before { 89 | content: "\f080"; 90 | } 91 | .icon-truck:before { 92 | content: "\f0d1"; 93 | } 94 | .icon-clipboard:before { 95 | content: "\f0ea"; 96 | } 97 | .icon-paste:before { 98 | content: "\f0ea"; 99 | } 100 | .icon-document:before { 101 | content: "\f15c"; 102 | } 103 | .icon-help:before { 104 | content: "\f1cd"; 105 | } 106 | .icon-calculator:before { 107 | content: "\f1ec"; 108 | } 109 | .icon-trash:before { 110 | content: "\f1f8"; 111 | } 112 | .icon-ship:before { 113 | content: "\f21a"; 114 | } 115 | .icon-examples:before { 116 | content: "\f277"; 117 | } -------------------------------------------------------------------------------- /tests/Server/ServerTest.elm: -------------------------------------------------------------------------------- 1 | module Server.ServerTest exposing (..) 2 | 3 | import Data.Food.Query as FoodQuery 4 | import Expect 5 | import Json.Encode as Encode 6 | import Server 7 | import Server.Request exposing (Request) 8 | import Test exposing (..) 9 | import TestUtils exposing (asTest, suiteWithDb) 10 | 11 | 12 | suite : Test 13 | suite = 14 | suiteWithDb "Server" 15 | (\dbs -> 16 | [ describe "ports" 17 | -- Note: these prevent false elm-review reports 18 | [ Server.input (always Sub.none) 19 | |> Expect.notEqual Sub.none 20 | |> asTest "should apply input subscription" 21 | , Server.output Encode.null 22 | |> Expect.notEqual Cmd.none 23 | |> asTest "should apply output command" 24 | ] 25 | , describe "handleRequest" 26 | [ "/invalid" 27 | |> request "GET" Encode.null 28 | |> Server.handleRequest dbs 29 | |> Tuple.first 30 | |> Expect.equal 404 31 | |> asTest "should catch invalid endpoints" 32 | 33 | -- GET queries 34 | , "/food/recipe?ingredients[]=invalid" 35 | |> request "GET" Encode.null 36 | |> Server.handleRequest dbs 37 | |> Tuple.first 38 | |> Expect.equal 400 39 | |> asTest "should reject an invalid GET query" 40 | , "/food/recipe?ingredients[]=egg-indoor-code3;120" 41 | |> request "GET" Encode.null 42 | |> Server.handleRequest dbs 43 | |> Tuple.first 44 | |> Expect.equal 200 45 | |> asTest "should accept a valid GET query" 46 | 47 | -- POST queries 48 | , "/food/recipe" 49 | |> request "POST" Encode.null 50 | |> Server.handleRequest dbs 51 | |> Tuple.first 52 | |> Expect.equal 400 53 | |> asTest "should reject an invalid POST query" 54 | , "/food/recipe" 55 | |> request "POST" (FoodQuery.encode FoodQuery.carrotCake) 56 | |> Server.handleRequest dbs 57 | |> Tuple.first 58 | |> Expect.equal 200 59 | |> asTest "should accept a valid POST query" 60 | ] 61 | ] 62 | ) 63 | 64 | 65 | request : String -> Encode.Value -> String -> Request 66 | request method body url = 67 | { method = method 68 | , url = url 69 | , body = body 70 | , jsResponseHandler = Encode.null 71 | } 72 | -------------------------------------------------------------------------------- /data/common/distances/json_to_csv.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pandas as pd 3 | import gettext 4 | 5 | """Convert distances.json to distances.csv, countries.csv to use it in excel 6 | """ 7 | 8 | with open("distances.json", "r") as f: 9 | distances = json.load(f) 10 | 11 | data = [] 12 | 13 | countries_set = set() 14 | # iterate on countries_from 15 | for country_from, country_from_dict in distances.items(): 16 | countries_set.add(country_from) 17 | # iterate on countries_to 18 | for country_to, dist in country_from_dict.items(): 19 | countries_set.add(country_to) 20 | route = country_from + "-" + country_to 21 | reverse_route = country_to + "-" + country_from 22 | # check alphabetical order 23 | if country_from < country_to: 24 | country_0 = country_from 25 | country_1 = country_to 26 | else: 27 | country_0 = country_to 28 | country_1 = country_from 29 | route_ordered = country_0 + "-" + country_1 30 | # add row 31 | row = [route, route_ordered, dist["road"], dist["sea"], dist["air"]] 32 | data.append(row) 33 | # add reverse route row 34 | row_reverse = [ 35 | reverse_route, 36 | route_ordered, 37 | dist["road"], 38 | dist["sea"], 39 | dist["air"], 40 | ] 41 | data.append(row_reverse) 42 | 43 | 44 | # add 500 km of road distance for all intra country distances 45 | countries_list = list(countries_set) 46 | countries_list = sorted(countries_list) 47 | 48 | for country in countries_list: 49 | route = country + "-" + country 50 | row = [route, route, 500, 0, 0] 51 | data.append(row) 52 | 53 | # csv export 54 | distances_df = pd.DataFrame( 55 | data, columns=["route", "ordered_route", "road", "sea", "air"] 56 | ) 57 | distances_df.to_csv("distances.csv", index=False) 58 | countries_importance = pd.read_csv("countries_importance.csv") 59 | 60 | ## build list of countries with alpha 2 correspondance 61 | 62 | # build dic of alpha2 -> country 63 | alpha2_to_country = {} 64 | 65 | for i, x in countries_importance.iterrows(): 66 | alpha2_to_country[x["Alpha-2 code"]] = x["Country"] 67 | 68 | 69 | country_alpha2 = [] 70 | 71 | french = gettext.translation("iso3166", pycountry.LOCALES_DIR, languages=["fr"]) 72 | french.install() 73 | # print list of countries 74 | for alpha2 in countries_list: 75 | py_country = pycountry.countries.get(alpha_2=alpha2) 76 | country_french = _(py_country.name) 77 | row = [alpha2, country_french, py_country.name] 78 | country_alpha2.append(row) 79 | 80 | # csv export 81 | countries_df = pd.DataFrame(country_alpha2, columns=["alpha-2", "name_fr", "name_en"]) 82 | countries_df.to_csv("countries_output.csv", index=False, encoding="utf-8") 83 | -------------------------------------------------------------------------------- /src/Data/Textile/MakingComplexity.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.MakingComplexity exposing 2 | ( MakingComplexity(..) 3 | , decode 4 | , fromString 5 | , toDuration 6 | , toLabel 7 | , toString 8 | ) 9 | 10 | import Duration exposing (Duration) 11 | import Json.Decode as Decode exposing (Decoder) 12 | import Json.Decode.Extra as DE 13 | 14 | 15 | type MakingComplexity 16 | = VeryHigh 17 | | High 18 | | Medium 19 | | Low 20 | | VeryLow 21 | | NotApplicable 22 | 23 | 24 | toDuration : MakingComplexity -> Duration 25 | toDuration makingComplexity = 26 | case makingComplexity of 27 | VeryHigh -> 28 | Duration.minutes 120 29 | 30 | High -> 31 | Duration.minutes 60 32 | 33 | Medium -> 34 | Duration.minutes 30 35 | 36 | Low -> 37 | Duration.minutes 15 38 | 39 | VeryLow -> 40 | Duration.minutes 5 41 | 42 | NotApplicable -> 43 | Duration.minutes 0 44 | 45 | 46 | toLabel : MakingComplexity -> String 47 | toLabel makingComplexity = 48 | case makingComplexity of 49 | VeryHigh -> 50 | "Très élevée" 51 | 52 | High -> 53 | "Elevée" 54 | 55 | Medium -> 56 | "Moyenne" 57 | 58 | Low -> 59 | "Faible" 60 | 61 | VeryLow -> 62 | "Très faible" 63 | 64 | NotApplicable -> 65 | "Non applicable" 66 | 67 | 68 | toString : MakingComplexity -> String 69 | toString makingComplexity = 70 | case makingComplexity of 71 | VeryHigh -> 72 | "very-high" 73 | 74 | High -> 75 | "high" 76 | 77 | Medium -> 78 | "medium" 79 | 80 | Low -> 81 | "low" 82 | 83 | VeryLow -> 84 | "very-low" 85 | 86 | NotApplicable -> 87 | "non-applicable" 88 | 89 | 90 | fromString : String -> Result String MakingComplexity 91 | fromString str = 92 | case str of 93 | "very-high" -> 94 | Ok VeryHigh 95 | 96 | "high" -> 97 | Ok High 98 | 99 | "medium" -> 100 | Ok Medium 101 | 102 | "low" -> 103 | Ok Low 104 | 105 | "very-low" -> 106 | Ok VeryLow 107 | 108 | "not-applicable" -> 109 | Ok NotApplicable 110 | 111 | _ -> 112 | Err ("Type de complexité de fabrication inconnu\u{00A0}: " ++ str) 113 | 114 | 115 | decode : Decoder MakingComplexity 116 | decode = 117 | Decode.string 118 | |> Decode.andThen 119 | (\complexityStr -> 120 | DE.fromResult (fromString complexityStr) 121 | ) 122 | -------------------------------------------------------------------------------- /data/common/impacts.py: -------------------------------------------------------------------------------- 1 | impacts = { 2 | "acd": ("Environmental Footprint 3.1 (adapted) patch wtu", "Acidification"), 3 | "ozd": ("Environmental Footprint 3.1 (adapted) patch wtu", "Ozone depletion"), 4 | "cch": ("Environmental Footprint 3.1 (adapted) patch wtu", "Climate change"), 5 | "fwe": ( 6 | "Environmental Footprint 3.1 (adapted) patch wtu", 7 | "Eutrophication, freshwater", 8 | ), 9 | "swe": ( 10 | "Environmental Footprint 3.1 (adapted) patch wtu", 11 | "Eutrophication, marine", 12 | ), 13 | "tre": ( 14 | "Environmental Footprint 3.1 (adapted) patch wtu", 15 | "Eutrophication, terrestrial", 16 | ), 17 | "pco": ( 18 | "Environmental Footprint 3.1 (adapted) patch wtu", 19 | "Photochemical ozone formation", 20 | ), 21 | "pma": ("Environmental Footprint 3.1 (adapted) patch wtu", "Particulate matter"), 22 | "ior": ( 23 | "Environmental Footprint 3.1 (adapted) patch wtu", 24 | "Ionising radiation", 25 | ), 26 | "fru": ( 27 | "Environmental Footprint 3.1 (adapted) patch wtu", 28 | "Resource use, fossils", 29 | ), 30 | "mru": ( 31 | "Environmental Footprint 3.1 (adapted) patch wtu", 32 | "Resource use, minerals and metals", 33 | ), 34 | "ldu": ( 35 | "Environmental Footprint 3.1 (adapted) patch wtu", 36 | "Land use", 37 | ), 38 | "wtu": ( 39 | "Environmental Footprint 3.1 (adapted) patch wtu", 40 | "Water use", 41 | ), 42 | "etf-o1": ( 43 | "Environmental Footprint 3.1 (adapted) patch wtu", 44 | "Ecotoxicity, freshwater - organics - p.1", 45 | ), 46 | "etf-o2": ( 47 | "Environmental Footprint 3.1 (adapted) patch wtu", 48 | "Ecotoxicity, freshwater - organics - p.2", 49 | ), 50 | "etf-i": ( 51 | "Environmental Footprint 3.1 (adapted) patch wtu", 52 | "Ecotoxicity, freshwater - inorganics", 53 | ), 54 | "etf1": ( 55 | "Environmental Footprint 3.1 (adapted) patch wtu", 56 | "Ecotoxicity, freshwater - part 1", 57 | ), 58 | "etf2": ( 59 | "Environmental Footprint 3.1 (adapted) patch wtu", 60 | "Ecotoxicity, freshwater - part 2", 61 | ), 62 | "htc": ( 63 | "Environmental Footprint 3.1 (adapted) patch wtu", 64 | "Human toxicity, cancer", 65 | ), 66 | "htc-o": ( 67 | "Environmental Footprint 3.1 (adapted) patch wtu", 68 | "Human toxicity, cancer - organics", 69 | ), 70 | "htc-i": ( 71 | "Environmental Footprint 3.1 (adapted) patch wtu", 72 | "Human toxicity, cancer - inorganics", 73 | ), 74 | "htn": ( 75 | "Environmental Footprint 3.1 (adapted) patch wtu", 76 | "Human toxicity, non-cancer", 77 | ), 78 | "htn-o": ( 79 | "Environmental Footprint 3.1 (adapted) patch wtu", 80 | "Human toxicity, non-cancer - organics", 81 | ), 82 | "htn-i": ( 83 | "Environmental Footprint 3.1 (adapted) patch wtu", 84 | "Human toxicity, non-cancer - inorganics", 85 | ), 86 | } 87 | -------------------------------------------------------------------------------- /tests/Data/DefinitionTest.elm: -------------------------------------------------------------------------------- 1 | module Data.DefinitionTest exposing (..) 2 | 3 | import Data.Impact.Definition as Definition 4 | import Expect 5 | import Set 6 | import Test exposing (..) 7 | import TestUtils exposing (asTest, suiteWithDb) 8 | 9 | 10 | sumDefinitions : Definition.Base Int -> Int 11 | sumDefinitions = 12 | Definition.foldl (\_ a b -> a + b) 0 13 | 14 | 15 | suite : Test 16 | suite = 17 | suiteWithDb "Data.Impact.Definition" 18 | (\{ textileDb } -> 19 | [ Definition.trigrams 20 | |> List.length 21 | |> Expect.equal 21 22 | |> asTest "There are 21 impact trigrams" 23 | , Definition.trigrams 24 | |> List.map ((\trigram -> Definition.get trigram textileDb.impactDefinitions) >> .trigram >> Definition.toString) 25 | |> Set.fromList 26 | |> Set.toList 27 | |> List.length 28 | |> Expect.equal (List.length Definition.trigrams) 29 | |> asTest "There are 21 unique impact definitions and trigrams" 30 | , Definition.trigrams 31 | |> List.map Definition.toString 32 | |> List.filterMap (Definition.toTrigram >> Result.toMaybe) 33 | |> List.length 34 | |> Expect.equal (List.length Definition.trigrams) 35 | |> asTest "There's a string for each trigram and a trigram for each string" 36 | , Definition.init 0 37 | |> sumDefinitions 38 | |> Expect.equal 0 39 | |> asTest "init will set all the fields to the same value" 40 | , Definition.init 0 41 | |> Definition.map (\_ a -> a + 1) 42 | |> Expect.equal (Definition.init 1) 43 | |> asTest "map will apply a function to all the fields" 44 | , Definition.init 0 45 | |> Definition.update Definition.Acd ((+) 1) 46 | |> sumDefinitions 47 | |> Expect.equal 1 48 | |> asTest "update will change only one field" 49 | , Definition.init 0 50 | |> Definition.update Definition.Acd ((+) 1) 51 | |> (\definitions -> (\trigram -> Definition.get trigram definitions) Definition.Acd) 52 | |> Expect.equal 1 53 | |> asTest "get will retrive the value of a field" 54 | , Definition.init 1 55 | |> Definition.filter ((==) Definition.Acd) (always 0) 56 | |> sumDefinitions 57 | |> Expect.equal 1 58 | |> asTest "filter will zero all the values for fields filtered out" 59 | , Definition.toList textileDb.impactDefinitions 60 | |> List.length 61 | |> Expect.equal 21 62 | |> asTest "there are 21 impacts in total" 63 | , Definition.init 1 64 | |> Definition.filter Definition.isAggregate (always 0) 65 | |> sumDefinitions 66 | |> Expect.equal 2 67 | |> asTest "There are exactly two aggregated scores" 68 | ] 69 | ) 70 | -------------------------------------------------------------------------------- /src/Page/Explore/Table.elm: -------------------------------------------------------------------------------- 1 | module Page.Explore.Table exposing 2 | ( Config 3 | , Table 4 | , viewDetails 5 | , viewList 6 | ) 7 | 8 | import Data.Scope exposing (Scope) 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (..) 12 | import Route exposing (Route) 13 | import Table as SortableTable 14 | import Views.Table as TableView 15 | 16 | 17 | type alias Table data comparable msg = 18 | { toId : data -> String 19 | , toRoute : data -> Route 20 | , rows : 21 | List 22 | { label : String 23 | , toValue : data -> comparable 24 | , toCell : data -> Html msg 25 | } 26 | } 27 | 28 | 29 | type alias Config data msg = 30 | { toId : data -> String 31 | , toMsg : SortableTable.State -> msg 32 | , columns : List (SortableTable.Column data msg) 33 | , customizations : SortableTable.Customizations data msg 34 | } 35 | 36 | 37 | viewDetails : 38 | Scope 39 | -> ({ detailed : Bool, scope : Scope } -> Table data comparable msg) 40 | -> data 41 | -> Html msg 42 | viewDetails scope createTable item = 43 | TableView.responsiveDefault [ class "view-details" ] 44 | [ createTable { detailed = True, scope = scope } 45 | |> .rows 46 | |> List.map 47 | (\{ label, toCell } -> 48 | tr [] 49 | [ th [] [ text label ] 50 | , td [] [ toCell item ] 51 | ] 52 | ) 53 | |> tbody [] 54 | ] 55 | 56 | 57 | viewList : 58 | (Route -> msg) 59 | -> Config data msg 60 | -> SortableTable.State 61 | -> Scope 62 | -> ({ detailed : Bool, scope : Scope } -> Table data comparable msg) 63 | -> List data 64 | -> Html msg 65 | viewList routeToMsg defaultConfig tableState scope createTable items = 66 | let 67 | { toId, toRoute, rows } = 68 | createTable { detailed = False, scope = scope } 69 | 70 | customizations = 71 | defaultConfig.customizations 72 | 73 | config = 74 | { defaultConfig 75 | | toId = toId 76 | , columns = 77 | rows 78 | |> List.map 79 | (\{ label, toCell, toValue } -> 80 | SortableTable.veryCustomColumn 81 | { name = label 82 | , viewData = \item -> { attributes = [], children = [ toCell item ] } 83 | , sorter = SortableTable.increasingOrDecreasingBy toValue 84 | } 85 | ) 86 | , customizations = 87 | { customizations 88 | | rowAttrs = toRoute >> routeToMsg >> onClick >> List.singleton 89 | } 90 | } 91 | |> SortableTable.customConfig 92 | in 93 | div [ class "DatasetTable table-responsive" ] 94 | [ SortableTable.view config tableState items 95 | ] 96 | -------------------------------------------------------------------------------- /src/Data/Food/Ingredient/Category.elm: -------------------------------------------------------------------------------- 1 | module Data.Food.Ingredient.Category exposing 2 | ( Category(..) 3 | , decode 4 | , fromAnimalOrigin 5 | , toLabel 6 | ) 7 | 8 | import Json.Decode as Decode exposing (Decoder) 9 | import Json.Decode.Extra as DE 10 | 11 | 12 | type Category 13 | = AnimalProduct 14 | | Conventional 15 | | DairyProduct 16 | | GrainRaw 17 | | GrainProcessed 18 | | Misc 19 | | NutOilseedRaw 20 | | NutOilseedProcessed 21 | | SpiceCondimentOrAdditive 22 | | VegetableFresh 23 | | VegetableProcessed 24 | | Organic 25 | | BleuBlancCoeur 26 | 27 | 28 | fromAnimalOrigin : List Category -> Bool 29 | fromAnimalOrigin categories = 30 | [ AnimalProduct, DairyProduct ] 31 | |> List.any (\c -> List.member c categories) 32 | 33 | 34 | fromString : String -> Result String Category 35 | fromString str = 36 | case str of 37 | "animal_product" -> 38 | Ok AnimalProduct 39 | 40 | "conventional" -> 41 | Ok Conventional 42 | 43 | "dairy_product" -> 44 | Ok DairyProduct 45 | 46 | "grain_raw" -> 47 | Ok GrainRaw 48 | 49 | "grain_processed" -> 50 | Ok GrainProcessed 51 | 52 | "misc" -> 53 | Ok Misc 54 | 55 | "nut_oilseed_raw" -> 56 | Ok NutOilseedRaw 57 | 58 | "nut_oilseed_processed" -> 59 | Ok NutOilseedProcessed 60 | 61 | "spice_condiment_additive" -> 62 | Ok SpiceCondimentOrAdditive 63 | 64 | "vegetable_fresh" -> 65 | Ok VegetableFresh 66 | 67 | "vegetable_processed" -> 68 | Ok VegetableProcessed 69 | 70 | "organic" -> 71 | Ok Organic 72 | 73 | "bleublanccoeur" -> 74 | Ok BleuBlancCoeur 75 | 76 | _ -> 77 | Err <| "Categorie d'ingrédient invalide : " ++ str 78 | 79 | 80 | toLabel : Category -> String 81 | toLabel category = 82 | case category of 83 | AnimalProduct -> 84 | "Viandes, œufs, poissons, et dérivés" 85 | 86 | Conventional -> 87 | "Conventionnel" 88 | 89 | DairyProduct -> 90 | "Lait et ingrédients laitiers" 91 | 92 | GrainRaw -> 93 | "Céréales brutes" 94 | 95 | GrainProcessed -> 96 | "Céréales transformées" 97 | 98 | Misc -> 99 | "Divers" 100 | 101 | NutOilseedRaw -> 102 | "Fruits à coque et oléoprotéagineux bruts" 103 | 104 | NutOilseedProcessed -> 105 | "Graisses végétales et oléoprotéagineux transformés" 106 | 107 | SpiceCondimentOrAdditive -> 108 | "Condiments, épices, additifs" 109 | 110 | VegetableFresh -> 111 | "Fruits et légumes frais" 112 | 113 | VegetableProcessed -> 114 | "Fruits et légumes transformés" 115 | 116 | Organic -> 117 | "Bio" 118 | 119 | BleuBlancCoeur -> 120 | "Bleu-Blanc-Cœur" 121 | 122 | 123 | decode : Decoder Category 124 | decode = 125 | Decode.string 126 | |> Decode.andThen (fromString >> DE.fromResult) 127 | -------------------------------------------------------------------------------- /data/textile/activities.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # from pprint import pprint 3 | from bw2data.project import projects 4 | import bw2data 5 | import json 6 | 7 | projects.create_project("textile", activate=True, exist_ok=True) 8 | 9 | with open("../../public/data/textile/processes.json") as f: 10 | processes = json.loads(f.read()) 11 | with open("../../public/data/textile/materials.json") as f: 12 | materials = {m["name"]: m for m in json.loads(f.read())} 13 | with open("codes.json") as f: 14 | codes = {c["code"]: c["name"] for c in json.loads(f.read())} 15 | 16 | 17 | # check no missing process 18 | for material in materials.keys(): 19 | if material not in [p["name"] for p in processes]: 20 | print(f"missing process: {material}") 21 | 22 | for process in processes: 23 | name = process["name"] 24 | if name in materials: 25 | process.update(materials[name]) 26 | if False: # process["step_usage"] == "Energie": 27 | process["source"] = "Ecoinvent 3.9.1" 28 | del process["impacts"] 29 | # ELEC 30 | if name.startswith("Mix électrique réseau"): 31 | (pname, ccode) = name.split(", ", maxsplit=1) 32 | pname = pname.replace( 33 | "Mix électrique réseau", "Market for electricity, medium voltage" 34 | ) 35 | name = f"{pname} {codes[ccode]}" 36 | # STEAM 37 | elif name.startswith("Mix Vapeur"): 38 | if name.endswith("FR") or name.endswith("RER"): 39 | name = "heat from steam in chemical industry RER" 40 | elif name.endswith("RSA"): 41 | name = "heat from steam in chemical industry ROW" 42 | else: 43 | assert False 44 | elif name.startswith("Vapeur à partir de gaz naturel"): 45 | if name.endswith("RER"): 46 | name = "market for heat natural gas europe" 47 | elif name.endswith("RSA"): 48 | name = "market for heat natural gas ROW" 49 | else: 50 | assert False 51 | elif name.startswith("Vapeur à partir de fioul") or name.startswith( 52 | "Vapeur à partir de charbon" 53 | ): 54 | if name.endswith("RER"): 55 | name = "market for heat other Europe" 56 | elif name.endswith("RSA"): 57 | name = "market for heat other ROW" 58 | else: 59 | assert False 60 | 61 | process["name"] = name 62 | new_process = bw2data.Database("Ecoinvent 3.9.1").search(name) 63 | match len(new_process): 64 | case 0: 65 | print(f"Could not find process {name}") 66 | case _: 67 | process["uuid"] = new_process[0].as_dict()["activity"] 68 | if process["source"] == "Base Impacts": 69 | process["source"] = "Base Impacts 2.01" 70 | if process["source"].startswith("Ecoinvent"): 71 | process["correctif"] = "" 72 | 73 | processes = [ 74 | {p[0]: p[1].encode("utf-8") if type(p) is str else p[1] for p in process.items()} 75 | for process in processes 76 | ] 77 | 78 | open("activities.json", "w").write(json.dumps(processes, indent=2, ensure_ascii=False)) 79 | 80 | # Mix électrique réseau, AN (antilles néerlandaises) 81 | # Mix électrique réseau, non spécifié 82 | -------------------------------------------------------------------------------- /src/Data/Textile/Printing.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.Printing exposing 2 | ( Kind(..) 3 | , Printing 4 | , decode 5 | , defaultRatio 6 | , encode 7 | , fromString 8 | , fromStringParam 9 | , kindLabel 10 | , toFullLabel 11 | , toString 12 | ) 13 | 14 | import Data.Split as Split exposing (Split) 15 | import Json.Decode as Decode exposing (Decoder) 16 | import Json.Decode.Extra as DE 17 | import Json.Encode as Encode 18 | 19 | 20 | type Kind 21 | = Pigment 22 | | Substantive 23 | 24 | 25 | type alias Printing = 26 | { kind : Kind 27 | , ratio : Split 28 | } 29 | 30 | 31 | decode : Decoder Printing 32 | decode = 33 | Decode.map2 Printing 34 | (Decode.field "kind" decodeKind) 35 | (Decode.field "ratio" Split.decodeFloat) 36 | 37 | 38 | decodeKind : Decoder Kind 39 | decodeKind = 40 | Decode.string 41 | |> Decode.andThen (fromString >> DE.fromResult) 42 | 43 | 44 | defaultRatio : Split 45 | defaultRatio = 46 | Split.twenty 47 | 48 | 49 | encode : Printing -> Encode.Value 50 | encode v = 51 | Encode.object 52 | [ ( "kind", encodeKind v.kind ) 53 | , ( "ratio", Split.encodeFloat v.ratio ) 54 | ] 55 | 56 | 57 | encodeKind : Kind -> Encode.Value 58 | encodeKind = 59 | toString >> Encode.string 60 | 61 | 62 | fromStringParam : String -> Result String Printing 63 | fromStringParam string = 64 | let 65 | toRatio s = 66 | case String.toFloat s of 67 | Just float -> 68 | if float > 0 && float <= 1 then 69 | Split.fromFloat float 70 | 71 | else 72 | Err "Le ratio de surface d'impression doit être supérieur à zéro et inférieur à 1." 73 | 74 | Nothing -> 75 | Err <| "Ratio de surface teinte invalide: " ++ s 76 | in 77 | case String.split ";" string of 78 | [ "pigment" ] -> 79 | Ok { kind = Pigment, ratio = defaultRatio } 80 | 81 | [ "substantive" ] -> 82 | Ok { kind = Substantive, ratio = defaultRatio } 83 | 84 | [ "pigment", str ] -> 85 | str |> toRatio |> Result.map (Printing Pigment) 86 | 87 | [ "substantive", str ] -> 88 | str |> toRatio |> Result.map (Printing Substantive) 89 | 90 | _ -> 91 | Err <| "Format de type et surface d'impression invalide: " ++ string 92 | 93 | 94 | fromString : String -> Result String Kind 95 | fromString string = 96 | case string of 97 | "pigment" -> 98 | Ok Pigment 99 | 100 | "substantive" -> 101 | Ok Substantive 102 | 103 | _ -> 104 | Err <| "Type d'impression inconnu: " ++ string 105 | 106 | 107 | kindLabel : Kind -> String 108 | kindLabel kind = 109 | case kind of 110 | Pigment -> 111 | "Pigmentaire" 112 | 113 | Substantive -> 114 | "Fixé-lavé" 115 | 116 | 117 | toFullLabel : Printing -> String 118 | toFullLabel { kind, ratio } = 119 | kindLabel kind ++ " (" ++ Split.toPercentString ratio ++ "%)" 120 | 121 | 122 | toString : Kind -> String 123 | toString printing = 124 | case printing of 125 | Pigment -> 126 | "pigment" 127 | 128 | Substantive -> 129 | "substantive" 130 | -------------------------------------------------------------------------------- /tests/Views/FormatTest.elm: -------------------------------------------------------------------------------- 1 | module Views.FormatTest exposing (..) 2 | 3 | import Data.Split as Split 4 | import Expect 5 | import Html exposing (text) 6 | import Test exposing (..) 7 | import TestUtils exposing (asTest) 8 | import Views.Format as Format 9 | 10 | 11 | suite : Test 12 | suite = 13 | describe "Views.Format" 14 | [ describe "Format.formatFloat" 15 | [ 0 16 | |> Format.formatFloat 99 17 | |> Expect.equal "0" 18 | |> asTest "should format zero" 19 | , 5 20 | |> Format.formatFloat 2 21 | |> Expect.equal "5,00" 22 | |> asTest "should not format an int rendering a specific number of 0 decimals" 23 | , 5.02 24 | |> Format.formatFloat 2 25 | |> Expect.equal "5,02" 26 | |> asTest "should not format a float rounding it at a specific number of decimals" 27 | , 0.502 28 | |> Format.formatFloat 2 29 | |> Expect.equal "0,50" 30 | |> asTest "should not format a float < 1 rounding it at a specific number of decimals" 31 | , 0.0502 32 | |> Format.formatFloat 2 33 | |> Expect.equal "0,05" 34 | |> asTest "should not format a float < 0.1 rounding it at a specific number of decimals" 35 | , 0.00502 36 | |> Format.formatFloat 2 37 | |> Expect.equal "5,02e-3" 38 | |> asTest "should format a float < 0.01 rounding it at a specific number of decimals" 39 | , 0.000502 40 | |> Format.formatFloat 2 41 | |> Expect.equal "5,02e-4" 42 | |> asTest "should format a float < 0.001 in scientific notation (E-3)" 43 | , 0.000000502 44 | |> Format.formatFloat 2 45 | |> Expect.equal "5,02e-7" 46 | |> asTest "should format a float < 0.000001 in scientific notation (E-6)" 47 | , 0.000000000502 48 | |> Format.formatFloat 2 49 | |> Expect.equal "5,02e-10" 50 | |> asTest "should format a float < 0.000000001 in scientific notation (E-9)" 51 | , -5.02 52 | |> Format.formatFloat 2 53 | |> Expect.equal "-5,02" 54 | |> asTest "should not format a negative float in scientific notation" 55 | , -0.000000000502 56 | |> Format.formatFloat 2 57 | |> Expect.equal "-5,02e-10" 58 | |> asTest "should format a negative float in scientific notation" 59 | , 105 60 | |> Format.formatFloat 2 61 | |> Expect.equal "105" 62 | |> asTest "should not format a number > 100 to provided decimal precision" 63 | ] 64 | , describe "Format.percentage" 65 | [ 0.12 66 | |> Split.fromFloat 67 | |> Result.map Format.splitAsPercentage 68 | |> Expect.equal (Ok (text "12\u{202F}%")) 69 | |> asTest "should properly format a Split as percentage" 70 | , 0.12 71 | |> Split.fromFloat 72 | |> Result.map (Format.splitAsFloat 1) 73 | |> Expect.equal (Ok (text "0,1")) 74 | |> asTest "should properly format a Split as float" 75 | ] 76 | ] 77 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { Elm } from "./src/Main.elm"; 2 | import * as Sentry from "@sentry/browser"; 3 | import { BrowserTracing } from "@sentry/browser"; 4 | import Charts from "./lib/charts"; 5 | 6 | // Sentry 7 | if (process.env.SENTRY_DSN) { 8 | Sentry.init({ 9 | dsn: process.env.SENTRY_DSN, 10 | integrations: [new BrowserTracing()], 11 | tracesSampleRate: 0, 12 | allowUrls: [ 13 | /^https:\/\/ecobalyse\.beta\.gouv\.fr/, 14 | /^https:\/\/ecobalyse\.osc-fr1\.scalingo\.io/, 15 | /^https:\/\/ecobalyse-pr(\d+)\.osc-fr1\.scalingo\.io/, 16 | ], 17 | ignoreErrors: [ 18 | // Most often due to DOM-aggressive browser extensions 19 | /_VirtualDom_applyPatch/, 20 | ], 21 | }); 22 | } 23 | 24 | function loadScript(scriptUrl) { 25 | var d = document, 26 | g = d.createElement("script"), 27 | s = d.getElementsByTagName("script")[0]; 28 | g.async = true; 29 | g.src = scriptUrl; 30 | s.parentNode.insertBefore(g, s); 31 | } 32 | 33 | // The localStorage key to use to store serialized session data 34 | const storeKey = "store"; 35 | 36 | const app = Elm.Main.init({ 37 | flags: { 38 | clientUrl: location.origin + location.pathname, 39 | rawStore: localStorage[storeKey] || "", 40 | matomo: { 41 | host: process.env.MATOMO_HOST || "", 42 | siteId: process.env.MATOMO_SITE_ID || "", 43 | }, 44 | }, 45 | }); 46 | 47 | app.ports.copyToClipboard.subscribe((text) => { 48 | navigator.clipboard.writeText(text).then( 49 | function () {}, 50 | function (err) { 51 | alert( 52 | `Votre navigateur ne supporte pas la copie automatique; 53 | vous pouvez copier l'adresse manuellement`, 54 | ); 55 | }, 56 | ); 57 | }); 58 | 59 | app.ports.appStarted.subscribe(() => { 60 | var _paq = (window._paq = window._paq || []); 61 | _paq.push(["trackPageView"]); 62 | _paq.push(["enableLinkTracking"]); 63 | var u = `https://${process.env.MATOMO_HOST}/`; 64 | _paq.push(["setTrackerUrl", u + "matomo.php"]); 65 | _paq.push(["disableCookies"]); 66 | _paq.push(["setSiteId", process.env.MATOMO_SITE_ID]); 67 | loadScript(u + "matomo.js"); 68 | }); 69 | 70 | app.ports.loadRapidoc.subscribe((rapidocScriptUrl) => { 71 | // load the rapi-doc script if the component hasn't be registered yet 72 | if (!customElements.get("rapi-doc")) { 73 | loadScript(rapidocScriptUrl); 74 | } 75 | }); 76 | 77 | app.ports.saveStore.subscribe((rawStore) => { 78 | localStorage[storeKey] = rawStore; 79 | }); 80 | 81 | app.ports.addBodyClass.subscribe((cls) => { 82 | document.body.classList.add(cls); 83 | }); 84 | 85 | app.ports.removeBodyClass.subscribe((cls) => { 86 | document.body.classList.remove(cls); 87 | }); 88 | 89 | app.ports.scrollTo.subscribe((pos) => { 90 | window.scrollTo(pos.x, pos.y); 91 | }); 92 | 93 | app.ports.scrollIntoView.subscribe((id) => { 94 | let node = document.getElementById(id); 95 | node?.scrollIntoView({ behavior: "smooth" }); 96 | }); 97 | 98 | // Ensure session is refreshed when it changes in another tab/window 99 | window.addEventListener( 100 | "storage", 101 | (event) => { 102 | if (event.storageArea === localStorage && event.key === storeKey) { 103 | app.ports.storeChanged.send(event.newValue); 104 | } 105 | }, 106 | false, 107 | ); 108 | 109 | // Register custom chart elements 110 | Charts.registerElements(); 111 | -------------------------------------------------------------------------------- /src/Data/Textile/Material/Origin.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.Material.Origin exposing 2 | ( Origin(..) 3 | , decode 4 | , isSynthetic 5 | , threadProcess 6 | , toLabel 7 | , toMicrofibersComplement 8 | , toString 9 | ) 10 | 11 | import Data.Unit as Unit 12 | import Json.Decode as Decode exposing (Decoder) 13 | import Json.Decode.Extra as DE 14 | 15 | 16 | type Origin 17 | = ArtificialFromInorganic 18 | | ArtificialFromOrganic 19 | | NaturalFromAnimal 20 | | NaturalFromVegetal 21 | | Synthetic 22 | 23 | 24 | decode : Decoder Origin 25 | decode = 26 | Decode.string 27 | |> Decode.andThen (fromString >> DE.fromResult) 28 | 29 | 30 | fromString : String -> Result String Origin 31 | fromString origin = 32 | case origin of 33 | "ArtificialFromInorganic" -> 34 | Ok ArtificialFromInorganic 35 | 36 | "ArtificialFromOrganic" -> 37 | Ok ArtificialFromOrganic 38 | 39 | "NaturalFromAnimal" -> 40 | Ok NaturalFromAnimal 41 | 42 | "NaturalFromVegetal" -> 43 | Ok NaturalFromVegetal 44 | 45 | "Synthetic" -> 46 | Ok Synthetic 47 | 48 | _ -> 49 | Err <| "Origine inconnue: " ++ origin 50 | 51 | 52 | isSynthetic : Origin -> Bool 53 | isSynthetic origin = 54 | origin == Synthetic 55 | 56 | 57 | toMicrofibersComplement : Origin -> Unit.Impact 58 | toMicrofibersComplement origin = 59 | -- see https://fabrique-numerique.gitbook.io/ecobalyse/textile/limites-methodologiques/old/microfibres#calcul-du-complement-microfibres 60 | -- Notes: 61 | -- - this is a malus expressed as a negative µPts/kg impact 62 | -- - the float value corresponds to Ref(f) * 1000 to ease applying the formula 63 | case origin of 64 | ArtificialFromInorganic -> 65 | Unit.impact -850 66 | 67 | ArtificialFromOrganic -> 68 | Unit.impact -360 69 | 70 | NaturalFromAnimal -> 71 | Unit.impact -570 72 | 73 | NaturalFromVegetal -> 74 | Unit.impact -420 75 | 76 | Synthetic -> 77 | Unit.impact -790 78 | 79 | 80 | toLabel : Origin -> String 81 | toLabel origin = 82 | case origin of 83 | ArtificialFromInorganic -> 84 | "Matière artificielle d'origine inorganique" 85 | 86 | ArtificialFromOrganic -> 87 | "Matière artificielle d'origine organique" 88 | 89 | NaturalFromAnimal -> 90 | "Matière naturelle d'origine animale" 91 | 92 | NaturalFromVegetal -> 93 | "Matière naturelle d'origine végétale" 94 | 95 | Synthetic -> 96 | "Matière synthétique" 97 | 98 | 99 | toString : Origin -> String 100 | toString origin = 101 | case origin of 102 | ArtificialFromInorganic -> 103 | "ArtificialFromInorganic" 104 | 105 | ArtificialFromOrganic -> 106 | "ArtificialFromOrganic" 107 | 108 | NaturalFromAnimal -> 109 | "NaturalFromAnimal" 110 | 111 | NaturalFromVegetal -> 112 | "NaturalFromVegetal" 113 | 114 | Synthetic -> 115 | "Synthetic" 116 | 117 | 118 | threadProcess : Origin -> String 119 | threadProcess origin = 120 | case origin of 121 | Synthetic -> 122 | "Filage" 123 | 124 | _ -> 125 | "Filature" 126 | -------------------------------------------------------------------------------- /src/Views/AutocompleteSelector.elm: -------------------------------------------------------------------------------- 1 | module Views.AutocompleteSelector exposing (Config, view) 2 | 3 | import Autocomplete exposing (Autocomplete) 4 | import Autocomplete.View as AutocompleteView 5 | import Html exposing (..) 6 | import Html.Attributes exposing (..) 7 | import Views.Modal as ModalView 8 | 9 | 10 | type alias Config element msg = 11 | { autocompleteState : Autocomplete element 12 | , closeModal : msg 13 | , noOp : msg 14 | , onAutocomplete : Autocomplete.Msg element -> msg 15 | , onAutocompleteSelect : msg 16 | , placeholderText : String 17 | , title : String 18 | , toLabel : element -> String 19 | , toCategory : element -> String 20 | } 21 | 22 | 23 | view : Config element msg -> Html msg 24 | view ({ autocompleteState, closeModal, noOp, onAutocomplete, onAutocompleteSelect, placeholderText, title } as config) = 25 | ModalView.view 26 | { size = ModalView.Large 27 | , close = closeModal 28 | , noOp = noOp 29 | , title = title 30 | , subTitle = Nothing 31 | , formAction = Nothing 32 | , content = 33 | let 34 | { query, choices, selectedIndex } = 35 | Autocomplete.viewState autocompleteState 36 | 37 | { inputEvents, choiceEvents } = 38 | AutocompleteView.events 39 | { onSelect = onAutocompleteSelect 40 | , mapHtml = onAutocomplete 41 | } 42 | in 43 | [ input 44 | (inputEvents 45 | ++ [ type_ "search" 46 | , id "element-search" 47 | , class "form-control" 48 | , autocomplete False 49 | , attribute "role" "combobox" 50 | , attribute "aria-autocomplete" "list" 51 | , attribute "aria-owns" "element-autocomplete-choices" 52 | , placeholder placeholderText 53 | , value query 54 | ] 55 | ) 56 | [] 57 | , choices 58 | |> List.indexedMap (renderChoice config choiceEvents selectedIndex) 59 | |> div [ class "ElementAutocomplete", id "element-autocomplete-choices" ] 60 | ] 61 | , footer = [] 62 | } 63 | 64 | 65 | renderChoice : Config element msg -> (Int -> List (Attribute msg)) -> Maybe Int -> Int -> element -> Html msg 66 | renderChoice { toLabel, toCategory } events selectedIndex_ index element = 67 | let 68 | selected = 69 | Autocomplete.isSelected selectedIndex_ index 70 | in 71 | button 72 | (events index 73 | ++ [ class "AutocompleteChoice" 74 | , class "d-flex justify-content-between align-items-center gap-1 w-100" 75 | , class "btn btn-outline-primary border-0 border-bottom text-start no-outline" 76 | , classList [ ( "btn-primary selected", selected ) ] 77 | , attribute "role" "option" 78 | , attribute "aria-selected" 79 | (if selected then 80 | "true" 81 | 82 | else 83 | "false" 84 | ) 85 | ] 86 | ) 87 | [ span [ class "text-nowrap" ] [ text <| toLabel element ] 88 | , span [ class "text-muted fs-8 text-truncate" ] 89 | [ text <| toCategory element ] 90 | ] 91 | -------------------------------------------------------------------------------- /src/Views/Modal.elm: -------------------------------------------------------------------------------- 1 | module Views.Modal exposing (Size(..), view) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | import Html.Events exposing (..) 6 | import Json.Decode as Decode 7 | 8 | 9 | type alias Config msg = 10 | { size : Size 11 | , close : msg 12 | , noOp : msg 13 | , title : String 14 | , subTitle : Maybe String 15 | , content : List (Html msg) 16 | , footer : List (Html msg) 17 | , formAction : Maybe msg 18 | } 19 | 20 | 21 | type Size 22 | = ExtraLarge 23 | | Large 24 | | Small 25 | | Standard 26 | 27 | 28 | view : Config msg -> Html msg 29 | view config = 30 | let 31 | modalContentAttrs = 32 | [ class "modal-content" 33 | , custom "mouseup" 34 | (Decode.succeed 35 | { message = config.noOp 36 | , stopPropagation = True 37 | , preventDefault = True 38 | } 39 | ) 40 | ] 41 | 42 | modalContentTag = 43 | case config.formAction of 44 | Just msg -> 45 | Html.form (modalContentAttrs ++ [ onSubmit msg ]) 46 | 47 | Nothing -> 48 | div modalContentAttrs 49 | in 50 | div [ class "Modal" ] 51 | [ div 52 | [ class "modal d-block fade show" 53 | , attribute "tabindex" "-1" 54 | , attribute "aria-modal" "true" 55 | , attribute "role" "dialog" 56 | , custom "mouseup" 57 | (Decode.succeed 58 | { message = config.close 59 | , stopPropagation = True 60 | , preventDefault = True 61 | } 62 | ) 63 | ] 64 | [ div 65 | [ class "modal-dialog modal-dialog-centered modal-dialog-scrollable" 66 | , classList 67 | [ ( "modal-xl", config.size == ExtraLarge ) 68 | , ( "modal-lg", config.size == Large ) 69 | , ( "modal-sm", config.size == Small ) 70 | ] 71 | , attribute "aria-modal" "true" 72 | ] 73 | [ modalContentTag 74 | [ div [ class "modal-header" ] 75 | [ h5 [ class "modal-title lh-sm" ] 76 | [ span [ class "me-2", attribute "aria-hidden" "true" ] [ text "→" ] 77 | , text config.title 78 | , case config.subTitle of 79 | Just subTitle -> 80 | small [ class "text-muted fs-7 fw-normal ps-2" ] [ text subTitle ] 81 | 82 | Nothing -> 83 | text "" 84 | ] 85 | , button 86 | [ type_ "button" 87 | , class "btn-close" 88 | , onClick config.close 89 | , attribute "aria-label" "Fermer" 90 | ] 91 | [] 92 | ] 93 | , config.content 94 | |> div [ class "modal-body no-scroll-chaining p-0" ] 95 | , if config.footer /= [] then 96 | div [ class "modal-footer bg-light" ] config.footer 97 | 98 | else 99 | text "" 100 | ] 101 | ] 102 | ] 103 | , div [ class "modal-backdrop fade show" ] [] 104 | ] 105 | -------------------------------------------------------------------------------- /src/Views/Alert.elm: -------------------------------------------------------------------------------- 1 | module Views.Alert exposing 2 | ( Level(..) 3 | , httpError 4 | , preformatted 5 | , simple 6 | ) 7 | 8 | import Data.Env as Env 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (..) 12 | import Http 13 | import Request.Common as HttpCommon 14 | import Views.Icon as Icon 15 | 16 | 17 | type alias Config msg = 18 | { level : Level 19 | , close : Maybe msg 20 | , title : Maybe String 21 | , content : List (Html msg) 22 | } 23 | 24 | 25 | type Level 26 | = Danger 27 | | Info 28 | 29 | 30 | icon : Level -> Html msg 31 | icon level = 32 | case level of 33 | Danger -> 34 | span [ class "me-1" ] [ Icon.warning ] 35 | 36 | Info -> 37 | span [ class "me-1" ] [ Icon.info ] 38 | 39 | 40 | httpError : Http.Error -> Html msg 41 | httpError error = 42 | simple 43 | { title = Just "Erreur de chargement des données" 44 | , close = Nothing 45 | , level = Info 46 | , content = 47 | case error |> HttpCommon.errorToString |> String.lines of 48 | [] -> 49 | [] 50 | 51 | [ line ] -> 52 | [ text line ] 53 | 54 | firstLine :: rest -> 55 | [ div [] 56 | [ p [ class "mb-2" ] [ text "Une erreur serveur a été rencontrée\u{00A0}:" ] 57 | , pre [ class "mb-1" ] [ text firstLine ] 58 | , details [ class "mb-2" ] 59 | [ summary [] [ text "Afficher les détails de l'erreur" ] 60 | , pre [ class "mt-1" ] 61 | [ rest |> String.join "\n" |> String.trim |> text ] 62 | ] 63 | , a 64 | [ class "btn btn-primary" 65 | , href 66 | ("mailto:" 67 | ++ Env.contactEmail 68 | ++ "?Subject=[Ecobalyse]+Erreur+rencontrée&Body=" 69 | ++ HttpCommon.errorToString error 70 | ) 71 | ] 72 | [ text "Envoyer un rapport d'incident" ] 73 | ] 74 | ] 75 | } 76 | 77 | 78 | preformatted : Config msg -> Html msg 79 | preformatted config = 80 | simple { config | content = [ pre [ class "fs-7 mb-0" ] config.content ] } 81 | 82 | 83 | simple : Config msg -> Html msg 84 | simple { level, content, title, close } = 85 | div 86 | [ class <| "alert alert-" ++ levelToClass level 87 | , classList [ ( "alert-dismissible", close /= Nothing ) ] 88 | ] 89 | [ case title of 90 | Just title_ -> 91 | h5 [ class "alert-heading d-flex align-items-center" ] 92 | [ icon level, text title_ ] 93 | 94 | Nothing -> 95 | text "" 96 | , div [] content 97 | , case close of 98 | Just closeMsg -> 99 | button 100 | [ type_ "button" 101 | , class "btn-close" 102 | , attribute "aria-label" "Fermer" 103 | , attribute "data-bs-dismiss" "alert" 104 | , onClick closeMsg 105 | ] 106 | [] 107 | 108 | Nothing -> 109 | text "" 110 | ] 111 | 112 | 113 | levelToClass : Level -> String 114 | levelToClass level = 115 | case level of 116 | Danger -> 117 | "danger" 118 | 119 | Info -> 120 | "info" 121 | -------------------------------------------------------------------------------- /src/Data/Bookmark.elm: -------------------------------------------------------------------------------- 1 | module Data.Bookmark exposing 2 | ( Bookmark 3 | , Query(..) 4 | , decode 5 | , encode 6 | , findByFoodQuery 7 | , findByTextileQuery 8 | , isFood 9 | , isTextile 10 | , sort 11 | , toId 12 | , toQueryDescription 13 | ) 14 | 15 | import Data.Food.Db as FoodDb 16 | import Data.Food.Query as FoodQuery 17 | import Data.Food.Recipe as Recipe 18 | import Data.Scope as Scope exposing (Scope) 19 | import Data.Textile.Db as TextileDb 20 | import Data.Textile.Inputs as TextileQuery 21 | import Json.Decode as Decode exposing (Decoder) 22 | import Json.Encode as Encode 23 | import Time exposing (Posix) 24 | 25 | 26 | type alias Bookmark = 27 | { name : String 28 | , created : Posix 29 | , query : Query 30 | } 31 | 32 | 33 | type Query 34 | = Food FoodQuery.Query 35 | | Textile TextileQuery.Query 36 | 37 | 38 | decode : Decoder Bookmark 39 | decode = 40 | Decode.map3 Bookmark 41 | (Decode.field "name" Decode.string) 42 | (Decode.field "created" (Decode.map Time.millisToPosix Decode.int)) 43 | (Decode.field "query" decodeQuery) 44 | 45 | 46 | decodeQuery : Decoder Query 47 | decodeQuery = 48 | Decode.oneOf 49 | [ Decode.map Food FoodQuery.decode 50 | , Decode.map Textile TextileQuery.decodeQuery 51 | ] 52 | 53 | 54 | encode : Bookmark -> Encode.Value 55 | encode v = 56 | Encode.object 57 | [ ( "name", Encode.string v.name ) 58 | , ( "created", Encode.int <| Time.posixToMillis v.created ) 59 | , ( "query", encodeQuery v.query ) 60 | ] 61 | 62 | 63 | encodeQuery : Query -> Encode.Value 64 | encodeQuery v = 65 | case v of 66 | Food query -> 67 | FoodQuery.encode query 68 | 69 | Textile query -> 70 | TextileQuery.encodeQuery query 71 | 72 | 73 | isFood : Bookmark -> Bool 74 | isFood { query } = 75 | case query of 76 | Food _ -> 77 | True 78 | 79 | _ -> 80 | False 81 | 82 | 83 | isTextile : Bookmark -> Bool 84 | isTextile { query } = 85 | case query of 86 | Textile _ -> 87 | True 88 | 89 | _ -> 90 | False 91 | 92 | 93 | findByQuery : Query -> List Bookmark -> Maybe Bookmark 94 | findByQuery query = 95 | List.filter (.query >> (==) query) 96 | >> List.head 97 | 98 | 99 | findByFoodQuery : FoodQuery.Query -> List Bookmark -> Maybe Bookmark 100 | findByFoodQuery foodQuery = 101 | findByQuery (Food foodQuery) 102 | 103 | 104 | findByTextileQuery : TextileQuery.Query -> List Bookmark -> Maybe Bookmark 105 | findByTextileQuery textileQuery = 106 | findByQuery (Textile textileQuery) 107 | 108 | 109 | scope : Bookmark -> Scope 110 | scope bookmark = 111 | case bookmark.query of 112 | Food _ -> 113 | Scope.Food 114 | 115 | Textile _ -> 116 | Scope.Textile 117 | 118 | 119 | sort : List Bookmark -> List Bookmark 120 | sort = 121 | List.sortBy (.created >> Time.posixToMillis) >> List.reverse 122 | 123 | 124 | toId : Bookmark -> String 125 | toId bookmark = 126 | Scope.toString (scope bookmark) ++ ":" ++ bookmark.name 127 | 128 | 129 | toQueryDescription : { foodDb : FoodDb.Db, textileDb : TextileDb.Db } -> Bookmark -> String 130 | toQueryDescription { foodDb, textileDb } bookmark = 131 | case bookmark.query of 132 | Food foodQuery -> 133 | foodQuery 134 | |> Recipe.fromQuery foodDb 135 | |> Result.map Recipe.toString 136 | |> Result.withDefault bookmark.name 137 | 138 | Textile textileQuery -> 139 | textileQuery 140 | |> TextileQuery.fromQuery textileDb 141 | |> Result.map TextileQuery.toString 142 | |> Result.withDefault bookmark.name 143 | -------------------------------------------------------------------------------- /src/Views/Impact.elm: -------------------------------------------------------------------------------- 1 | module Views.Impact exposing 2 | ( impactQuality 3 | , selector 4 | ) 5 | 6 | import Data.Gitbook as Gitbook 7 | import Data.Impact.Definition as Definition exposing (Definitions) 8 | import Html exposing (..) 9 | import Html.Attributes as Attr exposing (..) 10 | import Html.Events exposing (..) 11 | import Views.Button as Button 12 | import Views.Icon as Icon 13 | 14 | 15 | qualityDocumentationUrl : String 16 | qualityDocumentationUrl = 17 | Gitbook.publicUrlFromPath Gitbook.ImpactQuality 18 | 19 | 20 | impactQuality : Definition.Quality -> List (Html msg) 21 | impactQuality quality = 22 | let 23 | maybeInfo = 24 | case quality of 25 | Definition.NotFinished -> 26 | Just 27 | { cls = "btn-danger" 28 | , icon = Icon.build 29 | , label = "N/A" 30 | , help = "Impact en cours de construction" 31 | } 32 | 33 | Definition.GoodQuality -> 34 | Just 35 | { cls = "btn-success" 36 | , icon = Icon.checkCircle 37 | , label = "I" 38 | , help = "Qualité satisfaisante" 39 | } 40 | 41 | Definition.AverageQuality -> 42 | Just 43 | { cls = "bg-info text-white" 44 | , icon = Icon.info 45 | , label = "II" 46 | , help = "Qualité satisfaisante mais nécessitant des améliorations" 47 | } 48 | 49 | Definition.BadQuality -> 50 | Just 51 | { cls = "btn-warning" 52 | , icon = Icon.warning 53 | , label = "III" 54 | , help = "Donnée incomplète à utiliser avec prudence" 55 | } 56 | 57 | Definition.UnknownQuality -> 58 | Nothing 59 | in 60 | case maybeInfo of 61 | Just { cls, icon, label, help } -> 62 | [ a 63 | [ class <| Button.pillClasses ++ " fs-7 py-0 " ++ cls 64 | , target "_blank" 65 | , href qualityDocumentationUrl 66 | , title help 67 | ] 68 | [ icon 69 | , text "Qualité\u{00A0}: " 70 | , strong [] [ text label ] 71 | ] 72 | ] 73 | 74 | Nothing -> 75 | [] 76 | 77 | 78 | type alias SelectorConfig msg = 79 | { selectedImpact : Definition.Trigram 80 | , switchImpact : Result String Definition.Trigram -> msg 81 | } 82 | 83 | 84 | selector : Definitions -> SelectorConfig msg -> Html msg 85 | selector definitions { selectedImpact, switchImpact } = 86 | let 87 | toOption ({ trigram, label } as impact) = 88 | option 89 | [ Attr.selected (selectedImpact == impact.trigram) 90 | , value <| Definition.toString trigram 91 | ] 92 | [ text label ] 93 | in 94 | div [ class "ImpactSelector input-group" ] 95 | [ select 96 | [ class "form-select" 97 | , onInput (Definition.toTrigram >> switchImpact) 98 | ] 99 | [ Definition.toList definitions 100 | |> List.filter (.trigram >> Definition.isAggregate) 101 | |> List.map toOption 102 | |> optgroup [ attribute "label" "Impacts agrégés" ] 103 | , Definition.toList definitions 104 | |> List.filter (.trigram >> Definition.isAggregate >> not) 105 | |> List.sortBy .label 106 | |> List.map toOption 107 | |> optgroup [ attribute "label" "Impacts détaillés" ] 108 | ] 109 | ] 110 | -------------------------------------------------------------------------------- /data/import_method.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from bw2data.project import projects 3 | from zipfile import ZipFile 4 | import bw2data 5 | import bw2io 6 | import functools 7 | import os 8 | import sys 9 | from bw2io.strategies import ( 10 | drop_unspecified_subcategories, 11 | # fix_localized_water_flows, 12 | link_iterable_by_fields, 13 | match_subcategories, 14 | migrate_exchanges, 15 | normalize_biosphere_categories, 16 | normalize_biosphere_names, 17 | normalize_simapro_biosphere_categories, 18 | normalize_simapro_biosphere_names, 19 | normalize_units, 20 | set_biosphere_type, 21 | ) 22 | 23 | 24 | PROJECT = sys.argv[1] 25 | # Agribalyse 26 | BIOSPHERE = "biosphere3" 27 | METHODNAME = "Environmental Footprint 3.1 (adapted) patch wtu" # defined inside the csv 28 | METHODPATH = METHODNAME + ".CSV.zip" 29 | 30 | # excluded strategies and migrations 31 | EXCLUDED_FOOD = [ 32 | "normalize_simapro_biosphere_names", 33 | "normalize_biosphere_names", 34 | "fix_localized_water_flows", 35 | "simapro-water", 36 | ] 37 | 38 | 39 | def import_method(datapath=METHODPATH, project=PROJECT, biosphere=BIOSPHERE): 40 | """ 41 | Import file at path `datapath` linked to biosphere named `dbname` 42 | """ 43 | print(f"### Importing {datapath}...") 44 | projects.set_current(PROJECT) 45 | 46 | # unzip 47 | with ZipFile(datapath) as zf: 48 | print("### Extracting the zip file...") 49 | zf.extractall() 50 | unzipped = datapath[0:-4] 51 | 52 | # projects.create_project(project, activate=True, exist_ok=True) 53 | ef = bw2io.importers.SimaProLCIACSVImporter( 54 | unzipped, 55 | biosphere=biosphere, 56 | normalize_biosphere=False if project == "textile" else True 57 | # normalize_biosphere to align the categories between LCI and LCIA 58 | ) 59 | os.unlink(unzipped) 60 | ef.statistics() 61 | 62 | # exclude strategies/migrations in EXCLUDED 63 | if project == "food": 64 | ef.strategies = [ 65 | s for s in ef.strategies if not any([e in repr(s) for e in EXCLUDED_FOOD]) 66 | ] 67 | if project == "textile": 68 | ef.strategies = [ 69 | normalize_units, 70 | set_biosphere_type, 71 | # fix_localized_water_flows, # adding it leads to 60m3 72 | drop_unspecified_subcategories, 73 | functools.partial(normalize_biosphere_categories, lcia=True), 74 | functools.partial(normalize_biosphere_names, lcia=True), 75 | functools.partial(migrate_exchanges, migration="simapro-water"), 76 | normalize_simapro_biosphere_categories, 77 | normalize_simapro_biosphere_names, # removing avoid multiple CFs 78 | functools.partial( 79 | link_iterable_by_fields, 80 | other=( 81 | obj 82 | for obj in bw2data.Database(ef.biosphere_name) 83 | if obj.get("type") == "emission" 84 | ), 85 | # fields=("name", "unit", "categories"), 86 | kind="biosphere", 87 | ), 88 | functools.partial(match_subcategories, biosphere_db_name=ef.biosphere_name), 89 | ] 90 | ef.apply_strategies() 91 | 92 | # ef.write_excel(METHODNAME) 93 | # drop CFs which are not linked to a biosphere substance 94 | ef.drop_unlinked() 95 | ef.write_methods() 96 | print(f"### Finished importing {METHODNAME}") 97 | 98 | 99 | def main(): 100 | # Import custom method 101 | projects.set_current(PROJECT) 102 | # projects.create_project(PROJECT, activate=True, exist_ok=True) 103 | bw2data.preferences["biosphere_database"] = BIOSPHERE 104 | bw2io.bw2setup() 105 | 106 | if len([method for method in bw2data.methods if method[0] == METHODNAME]) == 0: 107 | import_method() 108 | else: 109 | print(f"{METHODNAME} already imported") 110 | 111 | 112 | if __name__ == "__main__": 113 | main() 114 | -------------------------------------------------------------------------------- /src/Data/Split.elm: -------------------------------------------------------------------------------- 1 | module Data.Split exposing 2 | ( Split 3 | , apply 4 | , applyToQuantity 5 | , complement 6 | , decodeFloat 7 | , decodePercent 8 | , divideBy 9 | , encodeFloat 10 | , encodePercent 11 | , fifteen 12 | , fourty 13 | , fromFloat 14 | , fromPercent 15 | , full 16 | , half 17 | , quarter 18 | , tenth 19 | , thirty 20 | , toFloat 21 | , toFloatString 22 | , toPercent 23 | , toPercentString 24 | , twenty 25 | , zero 26 | ) 27 | 28 | {-| 29 | 30 | This module manages splits, or "shares", eg: 0.33, or 33%, or a third. Also, the precision will be up to two decimals, so the equivalent of a percent. 31 | 0.121 or 1.119 will both be rounded to 0.12 or 12%. 32 | 33 | -} 34 | 35 | import Json.Decode as Decode exposing (Decoder) 36 | import Json.Encode as Encode 37 | import Quantity exposing (Quantity) 38 | 39 | 40 | type Split 41 | = Split Int 42 | 43 | 44 | zero : Split 45 | zero = 46 | Split 0 47 | 48 | 49 | full : Split 50 | full = 51 | Split 100 52 | 53 | 54 | tenth : Split 55 | tenth = 56 | Split 10 57 | 58 | 59 | fifteen : Split 60 | fifteen = 61 | Split 15 62 | 63 | 64 | twenty : Split 65 | twenty = 66 | Split 20 67 | 68 | 69 | thirty : Split 70 | thirty = 71 | Split 30 72 | 73 | 74 | fourty : Split 75 | fourty = 76 | Split 40 77 | 78 | 79 | half : Split 80 | half = 81 | Split 50 82 | 83 | 84 | quarter : Split 85 | quarter = 86 | Split 25 87 | 88 | 89 | fromFloat : Float -> Result String Split 90 | fromFloat float = 91 | if float < 0 || float > 1 then 92 | Err ("Une part (en nombre flottant) doit être comprise entre 0 et 1 inclus (ici: " ++ String.fromFloat float ++ ")") 93 | 94 | else 95 | float 96 | |> (*) 100 97 | |> round 98 | |> Split 99 | |> Ok 100 | 101 | 102 | fromPercent : Int -> Result String Split 103 | fromPercent int = 104 | if int < 0 || int > 100 then 105 | Err ("Une part (en pourcentage) doit être comprise entre 0 et 100 inclus (ici: " ++ String.fromInt int ++ ")") 106 | 107 | else 108 | Ok (Split int) 109 | 110 | 111 | toFloat : Split -> Float 112 | toFloat (Split int) = 113 | Basics.toFloat int / 100 114 | 115 | 116 | toPercent : Split -> Int 117 | toPercent (Split int) = 118 | int 119 | 120 | 121 | toFloatString : Split -> String 122 | toFloatString = 123 | toFloat >> String.fromFloat 124 | 125 | 126 | toPercentString : Split -> String 127 | toPercentString (Split int) = 128 | String.fromInt int 129 | 130 | 131 | complement : Split -> Split 132 | complement (Split int) = 133 | Split (100 - int) 134 | 135 | 136 | apply : Float -> Split -> Float 137 | apply input split = 138 | toFloat split * input 139 | 140 | 141 | applyToQuantity : Quantity Float units -> Split -> Quantity Float units 142 | applyToQuantity quantity split = 143 | Quantity.multiplyBy (toFloat split) quantity 144 | 145 | 146 | divideBy : Float -> Split -> Float 147 | divideBy input split = 148 | input / toFloat split 149 | 150 | 151 | decodeFloat : Decoder Split 152 | decodeFloat = 153 | Decode.float 154 | |> Decode.map fromFloat 155 | |> Decode.andThen 156 | (\result -> 157 | case result of 158 | Ok split -> 159 | Decode.succeed split 160 | 161 | Err error -> 162 | Decode.fail error 163 | ) 164 | 165 | 166 | decodePercent : Decoder Split 167 | decodePercent = 168 | Decode.int 169 | |> Decode.map fromPercent 170 | |> Decode.andThen 171 | (\result -> 172 | case result of 173 | Ok split -> 174 | Decode.succeed split 175 | 176 | Err error -> 177 | Decode.fail error 178 | ) 179 | 180 | 181 | encodeFloat : Split -> Encode.Value 182 | encodeFloat = 183 | toFloat >> Encode.float 184 | 185 | 186 | encodePercent : Split -> Encode.Value 187 | encodePercent = 188 | toPercent >> Encode.int 189 | -------------------------------------------------------------------------------- /src/Views/Table.elm: -------------------------------------------------------------------------------- 1 | module Views.Table exposing 2 | ( percentageTable 3 | , responsiveDefault 4 | ) 5 | 6 | import Data.Impact.Definition exposing (Definition) 7 | import Html exposing (..) 8 | import Html.Attributes exposing (..) 9 | import Views.Format as Format 10 | 11 | 12 | type alias DataPoint msg = 13 | { name : String 14 | , value : Float 15 | , entryAttributes : List (Attribute msg) 16 | } 17 | 18 | 19 | responsiveDefault : List (Attribute msg) -> List (Html msg) -> Html msg 20 | responsiveDefault attrs content = 21 | div [ class "DatasetTable table-responsive" ] 22 | [ table 23 | (class "table table-striped table-hover table-responsive mb-0" 24 | :: attrs 25 | ) 26 | content 27 | ] 28 | 29 | 30 | percentageTable : Definition -> List (DataPoint msg) -> Html msg 31 | percentageTable impactDefinition data = 32 | let 33 | values = 34 | List.map .value data 35 | 36 | ( total, minimum, maximum ) = 37 | ( List.sum values 38 | , values |> List.maximum |> Maybe.withDefault 0 39 | , values |> List.maximum |> Maybe.withDefault 0 40 | ) 41 | in 42 | if total == 0 || maximum == 0 then 43 | text "" 44 | 45 | else 46 | div [ class "table-responsive", style "max-height" "400px" ] 47 | [ table [ class "table table-hover w-100 m-0" ] 48 | [ data 49 | |> List.map 50 | (\{ name, value, entryAttributes } -> 51 | { name = name 52 | , impact = value 53 | , percent = value / total * 100 54 | , width = 55 | if value < 0 then 56 | abs (value / minimum * 100) 57 | 58 | else 59 | value / maximum * 100 60 | , entryAttributes = entryAttributes 61 | } 62 | ) 63 | |> List.map 64 | (\{ name, impact, percent, width, entryAttributes } -> 65 | let 66 | entryTitle = 67 | name 68 | ++ ": " 69 | ++ Format.formatFloat 2 percent 70 | ++ "\u{202F}% (" 71 | ++ Format.formatFloat 2 impact 72 | ++ "\u{202F}" 73 | ++ impactDefinition.unit 74 | ++ ")" 75 | in 76 | tr 77 | (title entryTitle 78 | :: entryAttributes 79 | ) 80 | [ th [ class "text-truncate fw-normal fs-8", style "max-width" "200px" ] [ text name ] 81 | , td [ class "HorizontalBarChart", style "width" "200px", style "vertical-align" "middle" ] 82 | [ div 83 | [ class "ext" 84 | , classList [ ( "pos", percent >= 0 ), ( "neg", percent < 0 ) ] 85 | ] 86 | [ div 87 | [ class "bar" 88 | , classList [ ( "bg-secondary", percent >= 0 ), ( "bg-success", percent < 0 ) ] 89 | , style "width" (String.fromFloat width ++ "%") 90 | ] 91 | [] 92 | ] 93 | ] 94 | , td [ class "text-end text-nowrap fs-8" ] 95 | [ Format.percent percent 96 | ] 97 | ] 98 | ) 99 | |> tbody [] 100 | ] 101 | ] 102 | -------------------------------------------------------------------------------- /src/Server/Route.elm: -------------------------------------------------------------------------------- 1 | module Server.Route exposing 2 | ( Route(..) 3 | , endpoint 4 | ) 5 | 6 | import Data.Food.Query as BuilderQuery 7 | import Data.Impact as Impact 8 | import Data.Impact.Definition as Definition 9 | import Data.Textile.Inputs as TextileInputs 10 | import Server.Query as Query 11 | import Server.Request exposing (Request) 12 | import Static.Db as StaticDb 13 | import Url 14 | import Url.Parser as Parser exposing ((), (), Parser, s) 15 | 16 | 17 | {-| A server request route. 18 | 19 | Note: The API root, serving the OpenAPI documentation, is handled by the 20 | ExpressJS server directly (see server.js). 21 | 22 | -} 23 | type Route 24 | = -- Food Routes 25 | -- GET 26 | -- Food country list 27 | GetFoodCountryList 28 | -- Food ingredient list 29 | | GetFoodIngredientList 30 | -- Food packaging list 31 | | GetFoodPackagingList 32 | -- Food transforms list 33 | | GetFoodTransformList 34 | -- Food recipe builder (GET, query string) 35 | | GetFoodRecipe (Result Query.Errors BuilderQuery.Query) 36 | -- POST 37 | -- Food recipe builder (POST, JSON body) 38 | | PostFoodRecipe 39 | -- 40 | -- Textile Routes 41 | -- GET 42 | -- Textile country list 43 | | GetTextileCountryList 44 | -- Textile Material list 45 | | GetTextileMaterialList 46 | -- Textile Product list 47 | | GetTextileProductList 48 | -- Textile Simple version of all impacts (GET, query string) 49 | | GetTextileSimulator (Result Query.Errors TextileInputs.Query) 50 | -- Textile Detailed version for all impacts (GET, query string) 51 | | GetTextileSimulatorDetailed (Result Query.Errors TextileInputs.Query) 52 | -- Textile Simple version for one specific impact (GET, query string) 53 | | GetTextileSimulatorSingle Definition.Trigram (Result Query.Errors TextileInputs.Query) 54 | -- POST 55 | -- Textile Simple version of all impacts (POST, JSON body) 56 | | PostTextileSimulator 57 | -- Textile Detailed version for all impacts (POST, JSON body) 58 | | PostTextileSimulatorDetailed 59 | -- Textile Simple version for one specific impact (POST, JSON bosy) 60 | | PostTextileSimulatorSingle Definition.Trigram 61 | 62 | 63 | parser : StaticDb.Db -> Parser (Route -> a) a 64 | parser { foodDb, textileDb } = 65 | Parser.oneOf 66 | [ -- Food 67 | Parser.map GetFoodCountryList (s "GET" s "food" s "countries") 68 | , Parser.map GetFoodIngredientList (s "GET" s "food" s "ingredients") 69 | , Parser.map GetFoodTransformList (s "GET" s "food" s "transforms") 70 | , Parser.map GetFoodPackagingList (s "GET" s "food" s "packagings") 71 | , Parser.map GetFoodRecipe (s "GET" s "food" s "recipe" Query.parseFoodQuery foodDb) 72 | , Parser.map PostFoodRecipe (s "POST" s "food" s "recipe") 73 | 74 | -- Textile 75 | , Parser.map GetTextileCountryList (s "GET" s "textile" s "countries") 76 | , Parser.map GetTextileMaterialList (s "GET" s "textile" s "materials") 77 | , Parser.map GetTextileProductList (s "GET" s "textile" s "products") 78 | , Parser.map GetTextileSimulator (s "GET" s "textile" s "simulator" Query.parseTextileQuery textileDb) 79 | , Parser.map GetTextileSimulatorDetailed (s "GET" s "textile" s "simulator" s "detailed" Query.parseTextileQuery textileDb) 80 | , Parser.map GetTextileSimulatorSingle (s "GET" s "textile" s "simulator" Impact.parseTrigram Query.parseTextileQuery textileDb) 81 | , Parser.map PostTextileSimulator (s "POST" s "textile" s "simulator") 82 | , Parser.map PostTextileSimulatorDetailed (s "POST" s "textile" s "simulator" s "detailed") 83 | , Parser.map PostTextileSimulatorSingle (s "POST" s "textile" s "simulator" Impact.parseTrigram) 84 | ] 85 | 86 | 87 | endpoint : StaticDb.Db -> Request -> Maybe Route 88 | endpoint dbs { method, url } = 89 | -- Notes: 90 | -- - Url.fromString can't build a Url without a fully qualified URL, so as we only have the 91 | -- request path from Express, we build a fake URL with a fake protocol and hostname. 92 | -- - We update the path appending the HTTP method to it, for simpler, cheaper route parsing. 93 | Url.fromString ("http://x/" ++ method ++ url) 94 | |> Maybe.andThen (Parser.parse (parser dbs)) 95 | -------------------------------------------------------------------------------- /src/Data/Textile/Material.elm: -------------------------------------------------------------------------------- 1 | module Data.Textile.Material exposing 2 | ( CFFData 3 | , Id(..) 4 | , Material 5 | , decodeList 6 | , encode 7 | , encodeId 8 | , findById 9 | , getRecyclingData 10 | , idToString 11 | ) 12 | 13 | import Data.Country as Country 14 | import Data.Split as Split exposing (Split) 15 | import Data.Textile.Material.Origin as Origin exposing (Origin) 16 | import Data.Textile.Process as Process exposing (Process) 17 | import Json.Decode as Decode exposing (Decoder) 18 | import Json.Decode.Pipeline as JDP 19 | import Json.Encode as Encode 20 | 21 | 22 | type alias Material = 23 | { id : Id 24 | , name : String 25 | , shortName : String 26 | , origin : Origin 27 | , materialProcess : Process 28 | , recycledProcess : Maybe Process 29 | , recycledFrom : Maybe Id 30 | , geographicOrigin : String -- A textual information about the geographic origin of the material 31 | , defaultCountry : Country.Code -- Default country for Material and Spinning steps 32 | , priority : Int -- Used to sort materials 33 | , cffData : Maybe CFFData 34 | } 35 | 36 | 37 | type Id 38 | = Id String 39 | 40 | 41 | 42 | ---- Recycling 43 | 44 | 45 | type alias CFFData = 46 | -- Circular Footprint Formula data 47 | { manufacturerAllocation : Split 48 | , recycledQualityRatio : Split 49 | } 50 | 51 | 52 | getRecyclingData : Material -> List Material -> Maybe ( Material, CFFData ) 53 | getRecyclingData material materials = 54 | -- If material is non-recycled, retrieve relevant recycled equivalent material & CFF data 55 | Maybe.map2 Tuple.pair 56 | (material.recycledFrom 57 | |> Maybe.andThen 58 | (\id -> 59 | findById id materials 60 | |> Result.toMaybe 61 | ) 62 | ) 63 | material.cffData 64 | 65 | 66 | 67 | ---- Helpers 68 | 69 | 70 | findById : Id -> List Material -> Result String Material 71 | findById id = 72 | List.filter (.id >> (==) id) 73 | >> List.head 74 | >> Result.fromMaybe ("Matière non trouvée id=" ++ idToString id ++ ".") 75 | 76 | 77 | decode : List Process -> Decoder Material 78 | decode processes = 79 | Decode.succeed Material 80 | |> JDP.required "id" (Decode.map Id Decode.string) 81 | |> JDP.required "name" Decode.string 82 | |> JDP.required "shortName" Decode.string 83 | |> JDP.required "origin" Origin.decode 84 | |> JDP.required "materialProcessUuid" (Process.decodeFromUuid processes) 85 | |> JDP.required "recycledProcessUuid" (Decode.maybe (Process.decodeFromUuid processes)) 86 | |> JDP.required "recycledFrom" (Decode.maybe (Decode.map Id Decode.string)) 87 | |> JDP.required "geographicOrigin" Decode.string 88 | |> JDP.required "defaultCountry" (Decode.string |> Decode.map Country.codeFromString) 89 | |> JDP.required "priority" Decode.int 90 | |> JDP.required "cff" (Decode.maybe decodeCFFData) 91 | 92 | 93 | decodeCFFData : Decoder CFFData 94 | decodeCFFData = 95 | Decode.succeed CFFData 96 | |> JDP.required "manufacturerAllocation" Split.decodeFloat 97 | |> JDP.required "recycledQualityRatio" Split.decodeFloat 98 | 99 | 100 | decodeList : List Process -> Decoder (List Material) 101 | decodeList processes = 102 | Decode.list (decode processes) 103 | 104 | 105 | encode : Material -> Encode.Value 106 | encode v = 107 | Encode.object 108 | [ ( "id", encodeId v.id ) 109 | , ( "name", v.name |> Encode.string ) 110 | , ( "shortName", Encode.string v.shortName ) 111 | , ( "origin", v.origin |> Origin.toString |> Encode.string ) 112 | , ( "materialProcessUuid", Process.encodeUuid v.materialProcess.uuid ) 113 | , ( "recycledProcessUuid" 114 | , v.recycledProcess |> Maybe.map (.uuid >> Process.encodeUuid) |> Maybe.withDefault Encode.null 115 | ) 116 | , ( "recycledFrom", v.recycledFrom |> Maybe.map encodeId |> Maybe.withDefault Encode.null ) 117 | , ( "geographicOrigin", Encode.string v.geographicOrigin ) 118 | , ( "defaultCountry", v.defaultCountry |> Country.codeToString |> Encode.string ) 119 | , ( "priority", Encode.int v.priority ) 120 | ] 121 | 122 | 123 | encodeId : Id -> Encode.Value 124 | encodeId = 125 | idToString >> Encode.string 126 | 127 | 128 | idToString : Id -> String 129 | idToString (Id string) = 130 | string 131 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | Comment générer les données json utilisées par le frontal elm : 2 | 3 | # Avec docker 4 | 5 | - Installez `docker` et `make` 6 | - Si vous êtes sur Mac avec architecture ARM, affectez 6Go de RAM à Docker dans Docker Desktop : 7 | Settings → Ressources → Advanced → Memory = 6G 8 | - Préparez les bases de données à importer, elle ne font pas partie du dépôt : 9 | - Agribalyse : compressé dans un fichier `AGB3.1.1.20230306.CSV.zip` dans ce dossier data/ 10 | - Ecoinvent : décompressé dans un dossier `ECOINVENT3.9.1` dans ce même dossier 11 | - Lancez **`make`** ce qui va successivement : 12 | - construire l'image docker 13 | - importer agribalyse et EF 3.1 adapted dans un projet `food` de Brightway 14 | - importer ecoinvent et EF 3.1 adapted dans un projet `textile` de Brightway 15 | - exporter les données json utilisées côté front-end 16 | 17 | Le processus entier prend environ 1h. En cas de problème vous pouvez redémarrer de zéro en faisant 18 | d'abord un `make clean_data` (qui supprime le volume docker). 19 | 20 | ## Autres commandes : 21 | 22 | - `make image` : pour construire l'image docker choisie 23 | - `make import_agribalyse` : pour importer Agribalyse 3.1.1 dans Brightway (projet food). 24 | Assurez-vous d'avoir le fichier `AGB3.1.1.20230306.CSV.zip` dans le dossier `data/` 25 | - `make import_food_method` : pour importer EF 3.1 adapted dans Brightway (projet food). 26 | Assurez-vous d'avoir le fichier `Environmental Footprint 3.1 (adapted).CSV` dans le dossier 27 | `data/` 28 | - `make import_textile_method` : pour importer EF 3.1 adapted dans Brightway (projet textile). 29 | Assurez-vous d'avoir le fichier `Environmental Footprint 3.1 (adapted).CSV` dans le dossier 30 | `data/` 31 | - `make import_ecoinvent` : pour importer Ecoinvent 3.9.1. Brightway (projet textile). Assurez-vous 32 | d'avoir le dossier `ECOINVENT3.9.1/` dans le dossier `data/` 33 | - `make export_food` : pour exporter les json pour le builder alimentaire 34 | - `make export_textile` : pour exporter les json pour le builder textile 35 | - `make delete_textile_method` : pour supprimer la méthode utilisée dans le projet textile 36 | - `make json` : lance toutes les commandes précédentes dans l'ordre 37 | - `make shell` : lance un shell bash à l'intérieur du conteneur 38 | - `make python` : lance un interpréteur Python à l'intérieur du conteneur 39 | - `make jupyter_password` : définit le mot de passe jupyter. Doit être lancé avant son démarrage. 40 | - `make root_shell` : lance un shell root à l'intérieur du conteneur 41 | - `make jupyter_password` : pour définir le mot de passe de Jupyter avant de le lancer 42 | - `make start_notebook` : lance le serveur Jupyter dans le conteneur 43 | - `make stop_notebook` : arrête le serveur Jupyter donc aussi le conteneur 44 | - `make clean_data` : supprime toutes les données (celles de brightway et jupyter mais pas les json 45 | générés) 46 | - `make clean_image` : supprime l'image docker 47 | - `make clean` : lance `clean_data` et `clean_image` 48 | 49 | ## Travailler dans le conteneur : 50 | 51 | Vous pouvez entrer dans le conteneur avec `make shell`. 52 | 53 | Toutes les données du conteneur, notamment celles de Brightway et de Jupyter, sont dans 54 | `/home/jovyan` qui est situé dans un volume docker (`/var/lib/docker/volume/jovyan` sur le _host_). 55 | Le dépôt git ecobalyse se retrouve (via un bind mount) aussi à l'intérieur du conteneur dans 56 | `/home/jovyan/ecobalyse`. Les fichiers json générés arrivent directement sur place au bon endroit 57 | pour être comparées puis commités. 58 | 59 | ## Lancer le serveur Jupyter de dev 60 | 61 | Avant de lancer Jupyter vous pouvez définir son mot de passe avec `make jupyter_password`. Ensuite 62 | vous le démarrez avec `make start_notebook`. 63 | 64 | ## Lancer le serveur Jupyter pour l'éditeur d'ingrédients 65 | 66 | Avant de lancer Jupyter vous pouvez définir son mot de passe avec `make jupyter_password`. Ensuite 67 | vous le démarrez avec `JUPYTER_PORT=8889 make start_notebook`. 68 | 69 | ## Lancer l'explorateur Brightway 70 | 71 | Créez un notebook dans Jupyter puis tapez `import notebooks.explore`, puis shift-Enter 72 | 73 | ## Lancer l'éditeur de procédés/ingrédients 74 | 75 | Créez un notebook dans Jupyter puis tapez `import notebooks.ingredients`, puis shift-Enter 76 | 77 | ## Remarques 78 | 79 | Si l'`export` prend plus de 2 secondes par procédé, c'est un problème d'installation de `pypardiso` 80 | ou de la bibliothèque `mkl` (Math Kernel Library d'Intel) ou une incompatibilité avec l'architecture 81 | CPU utilisée. Dans ce cas c'est le solveur de Scipy qui est utilisé. Il est possible que cela 82 | explique les très légères différences d'arrondi rencontrées dans les résultats. 83 | -------------------------------------------------------------------------------- /data/bwapi/server.py: -------------------------------------------------------------------------------- 1 | from bw2data.project import projects 2 | from bw2data.utils import get_activity 3 | from fastapi import FastAPI, Request 4 | from fastapi.responses import JSONResponse 5 | from typing import Union 6 | import bw2calc 7 | import bw2data 8 | 9 | api = FastAPI() 10 | 11 | # projects and databases 12 | 13 | 14 | @api.get("/projects", response_class=JSONResponse) 15 | async def projectlist(_: Request): 16 | return list(bw2data.projects) 17 | 18 | 19 | @api.get("/{project}/databases", response_class=JSONResponse) 20 | async def databases(_: Request, project: str): 21 | projects.set_current(project) 22 | return list(bw2data.databases) 23 | 24 | 25 | # search 26 | 27 | 28 | @api.get("/{project}/{dbname}/search/", response_class=JSONResponse) 29 | async def search( 30 | _: Request, 31 | project: str, 32 | dbname: str, 33 | q: Union[str, None], 34 | limit: Union[int, None] = 20, 35 | ): 36 | projects.set_current(project) 37 | return [a.as_dict() for a in bw2data.Database(dbname).search(q, limit=limit)] 38 | 39 | 40 | # activity data and graph 41 | 42 | 43 | @api.get("/{project}/{dbname}/{code}/data", response_class=JSONResponse) 44 | async def data(_: Request, project: str, dbname: str, code: str): 45 | projects.set_current(project) 46 | return get_activity((dbname, code)).as_dict() 47 | 48 | 49 | @api.get("/{project}/{dbname}/{code}/technosphere", response_class=JSONResponse) 50 | async def technosphere(_: Request, project: str, dbname: str, code: str): 51 | projects.set_current(project) 52 | return [ 53 | exchange.as_dict() for exchange in get_activity((dbname, code)).technosphere() 54 | ] 55 | 56 | 57 | @api.get("/{project}/{dbname}/{code}/biosphere", response_class=JSONResponse) 58 | async def biosphere(_: Request, project: str, dbname: str, code: str): 59 | projects.set_current(project) 60 | return [exchange.as_dict() for exchange in get_activity((dbname, code)).biosphere()] 61 | 62 | 63 | @api.get("/{project}/{dbname}/{code}/substitution", response_class=JSONResponse) 64 | async def substitution(_: Request, project: str, dbname: str, code: str): 65 | projects.set_current(project) 66 | return [ 67 | exchange.as_dict() for exchange in get_activity((dbname, code)).substitution() 68 | ] 69 | 70 | 71 | # methods 72 | 73 | 74 | @api.get("/{project}/methods", response_class=JSONResponse) 75 | async def methods(_: Request, project: str): 76 | projects.set_current(project) 77 | return sorted({m[0] for m in bw2data.methods}) 78 | 79 | 80 | @api.get("/{project}/methods/{method}", response_class=JSONResponse) 81 | async def method(_: Request, project: str, method: str): 82 | projects.set_current(project) 83 | return sorted({m for m in bw2data.methods if m[0] == method}) 84 | 85 | 86 | @api.get( 87 | "/{project}/methods/{method}/{impact_category:path}", 88 | response_class=JSONResponse, 89 | ) 90 | async def impact_category(_: Request, project: str, method: str, impact_category: str): 91 | projects.set_current(project) 92 | try: 93 | return bw2data.methods[(method,) + tuple(impact_category.split("/"))] 94 | except KeyError: 95 | return JSONResponse( 96 | status_code=404, content={"message": "Impact category not found"} 97 | ) 98 | 99 | 100 | @api.get( 101 | "/{project}/characterization_factors/{method}/{impact_category:path}", 102 | response_class=JSONResponse, 103 | ) 104 | async def characterization_factors( 105 | _: Request, project: str, method: str, impact_category: str 106 | ): 107 | projects.set_current(project) 108 | try: 109 | return bw2data.Method((method,) + tuple(impact_category.split("/"))).load() 110 | except: 111 | return JSONResponse( 112 | status_code=404, content={"message": "Impact category not found"} 113 | ) 114 | 115 | 116 | # impacts 117 | 118 | 119 | @api.get("/{project}/{dbname}/{code}/impacts/{method}", response_class=JSONResponse) 120 | async def impacts(_: Request, project: str, dbname: str, code: str, method: str): 121 | projects.set_current(project) 122 | lca = bw2calc.LCA({get_activity((dbname, code)): 1}) 123 | lca.lci() 124 | impacts = [] 125 | for m in [m for m in list(bw2data.methods) if m[0] == method]: 126 | lca.switch_method(m) 127 | lca.lcia() 128 | impacts.append( 129 | { 130 | "method": m, 131 | "score": lca.score, 132 | "unit": bw2data.methods[m].get("unit", "(no unit)"), 133 | } 134 | ) 135 | return impacts 136 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const fs = require("fs"); 3 | const express = require("express"); 4 | const bodyParser = require("body-parser"); 5 | const cors = require("cors"); 6 | const yaml = require("js-yaml"); 7 | const helmet = require("helmet"); 8 | const Sentry = require("@sentry/node"); 9 | const { Elm } = require("./server-app"); 10 | const lib = require("./lib"); 11 | 12 | const app = express(); // web app 13 | const api = express(); // api app 14 | const host = "0.0.0.0"; 15 | const port = process.env.PORT || 3000; 16 | 17 | // Env vars 18 | const { SENTRY_DSN, MATOMO_HOST, MATOMO_SITE_ID, MATOMO_TOKEN } = process.env; 19 | 20 | // Matomo 21 | if (process.env.NODE_ENV !== "test" && (!MATOMO_HOST || !MATOMO_SITE_ID || !MATOMO_TOKEN)) { 22 | console.error("Matomo environment variables are missing. Please check the README."); 23 | process.exit(1); 24 | } 25 | 26 | // Sentry 27 | if (SENTRY_DSN) { 28 | Sentry.init({ dsn: SENTRY_DSN, tracesSampleRate: 0.1 }); 29 | // Note: Sentry middleware *must* be the very first applied to be effective 30 | app.use(Sentry.Handlers.requestHandler()); 31 | } 32 | 33 | // Web 34 | 35 | // Note: helmet middlewares have to be called *after* the Sentry middleware 36 | // but *before* other middlewares to be applied effectively 37 | app.use( 38 | helmet({ 39 | crossOriginEmbedderPolicy: false, 40 | hsts: false, 41 | xssFilter: false, 42 | contentSecurityPolicy: { 43 | useDefaults: true, 44 | directives: { 45 | "default-src": [ 46 | "'self'", 47 | "https://api.github.com", 48 | "https://raw.githubusercontent.com", 49 | "https://sentry.incubateur.net", 50 | "*.gouv.fr", 51 | ], 52 | "frame-src": ["'self'", `https://${process.env.MATOMO_HOST}`], 53 | "img-src": [ 54 | "'self'", 55 | "data:", 56 | "blob:", 57 | "https://avatars.githubusercontent.com/", 58 | "https://raw.githubusercontent.com", 59 | ], 60 | // FIXME: We should be able to remove 'unsafe-inline' as soon as the Matomo 61 | // server sends the appropriate `Access-Control-Allow-Origin` header 62 | // @see https://matomo.org/faq/how-to/faq_18694/ 63 | "script-src": ["'self'", "'unsafe-inline'", `https://${process.env.MATOMO_HOST}`], 64 | "object-src": ["blob:"], 65 | }, 66 | }, 67 | }), 68 | ); 69 | 70 | app.use( 71 | express.static("dist", { 72 | setHeaders: (res) => { 73 | // Note: helmet sets this header to `0` by default and doesn't allow overriding 74 | // this value 75 | res.set("X-XSS-Protection", "1; mode=block"); 76 | }, 77 | }), 78 | ); 79 | 80 | // Redirects: Web 81 | app.get("/accessibilite", (_, res) => res.redirect("/#/pages/accessibilité")); 82 | app.get("/mentions-legales", (_, res) => res.redirect("/#/pages/mentions-légales")); 83 | app.get("/stats", (_, res) => res.redirect("/#/stats")); 84 | 85 | // API 86 | 87 | const openApiContents = yaml.load(fs.readFileSync("openapi.yaml")); 88 | 89 | // Matomo 90 | const apiTracker = lib.setupTracker(openApiContents); 91 | 92 | const elmApp = Elm.Server.init(); 93 | 94 | elmApp.ports.output.subscribe(({ status, body, jsResponseHandler }) => { 95 | return jsResponseHandler({ status, body }); 96 | }); 97 | 98 | api.get("/", (req, res) => { 99 | apiTracker.track(200, req); 100 | res.status(200).send(openApiContents); 101 | }); 102 | 103 | // Redirects: API 104 | api.get(/^\/countries$/, (_, res) => res.redirect("textile/countries")); 105 | api.get(/^\/materials$/, (_, res) => res.redirect("textile/materials")); 106 | api.get(/^\/products$/, (_, res) => res.redirect("textile/products")); 107 | const cleanRedirect = (url) => (url.startsWith("/") ? url : ""); 108 | api.get(/^\/simulator(.*)$/, ({ url }, res) => res.redirect(`/api/textile${cleanRedirect(url)}`)); 109 | 110 | // Note: Text/JSON request body parser (JSON is decoded in Elm) 111 | api.all(/(.*)/, bodyParser.json(), (req, res) => { 112 | elmApp.ports.input.send({ 113 | method: req.method, 114 | url: req.url, 115 | body: req.body, 116 | jsResponseHandler: ({ status, body }) => { 117 | apiTracker.track(status, req); 118 | res.status(status).send(body); 119 | }, 120 | }); 121 | }); 122 | 123 | api.use(cors()); // Enable CORS for all API requests 124 | app.use("/api", api); 125 | 126 | // Sentry error handler 127 | // Note: *must* be called *before* any other error handler 128 | if (SENTRY_DSN) { 129 | app.use(Sentry.Handlers.errorHandler()); 130 | } 131 | 132 | const server = app.listen(port, host, () => { 133 | console.log(`Server listening at http://${host}:${port}`); 134 | }); 135 | 136 | module.exports = server; 137 | --------------------------------------------------------------------------------