├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── linting.yml │ └── test-build.yml ├── .gitignore ├── .prettierrc.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── RELEASE-PROCEDURE.md ├── index.html ├── package-lock.json ├── package.json ├── public ├── draco │ ├── README.md │ ├── draco_decoder.js │ ├── draco_decoder.wasm │ ├── draco_encoder.js │ ├── draco_wasm_wrapper.js │ └── gltf │ │ ├── draco_decoder.js │ │ ├── draco_decoder.wasm │ │ ├── draco_encoder.js │ │ └── draco_wasm_wrapper.js ├── images │ ├── WelcomeMessage0.png │ ├── WelcomeMessage1.png │ ├── WelcomeMessage2.png │ ├── WelcomeMessage3.png │ ├── about │ │ ├── about1.png │ │ ├── bmbf.jpg │ │ └── ptf.png │ └── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-144x144.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x150.png │ │ ├── mstile-310x310.png │ │ ├── mstile-70x70.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest ├── locales │ ├── de │ │ └── translation.json │ └── en │ │ └── translation.json ├── robots.txt └── sitemap.xml ├── src ├── Main.jsx ├── components │ ├── ErrorMessages │ │ └── WrongAdress.jsx │ ├── Footer.jsx │ ├── MapPopup.jsx │ ├── PVSimulation │ │ ├── SavingsCalculation.jsx │ │ └── SearchField.jsx │ ├── Template │ │ ├── LoadingBar.jsx │ │ ├── Navigation.jsx │ │ └── WelcomeMessage.jsx │ ├── ThreeViewer │ │ ├── Controls │ │ │ ├── CustomMapControl.jsx │ │ │ └── DrawPVControl.jsx │ │ ├── Meshes │ │ │ ├── HighlitedPVSystem.jsx │ │ │ ├── HiglightedMesh.jsx │ │ │ ├── PVSystems.jsx │ │ │ ├── SimulationMesh.jsx │ │ │ ├── SurroundingMesh.jsx │ │ │ └── VegetationMesh.jsx │ │ ├── Overlay.jsx │ │ ├── PointsAndEdges.jsx │ │ ├── Scene.jsx │ │ ├── Terrain.jsx │ │ └── TextSprite.jsx │ └── ui │ │ ├── accordion.jsx │ │ ├── button.jsx │ │ ├── close-button.jsx │ │ ├── color-mode.jsx │ │ ├── data-list.jsx │ │ ├── dialog.jsx │ │ ├── field.jsx │ │ ├── number-input.jsx │ │ ├── progress.jsx │ │ ├── provider.jsx │ │ ├── slider.jsx │ │ ├── switch.jsx │ │ └── toaster.jsx ├── data │ ├── constants.js │ └── dataLicense.js ├── i18n.js ├── index.jsx ├── pages │ ├── About.jsx │ ├── Datenschutz.jsx │ ├── Impressum.jsx │ ├── Map.jsx │ ├── NotFound.jsx │ └── Simulation.jsx ├── simulation │ ├── download.js │ ├── elevation.js │ ├── location.js │ ├── main.js │ ├── preprocessing.js │ └── processVegetationTiffs.js └── static │ └── css │ └── main.css ├── update.sh └── vite.config.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' 9 | directory: '/' # Location of package manifest 10 | schedule: 11 | interval: 'weekly' 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 👷‍♀️ Build website to deployment branch 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | deployment: 10 | runs-on: ubuntu-latest 11 | environment: production 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | persist-credentials: false 17 | - name: Setup Node 18 | uses: actions/setup-node@v3 19 | - name: Install 20 | run: npm ci 21 | - name: Build and Deploy 22 | env: 23 | NODE_ENV: production 24 | # This is set automatically by github 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | run: | 27 | git config user.name "Automated" 28 | git config user.email "actions@users.noreply.github.com" 29 | git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/open-pv/website.git 30 | npm run build 31 | npm run deploy 32 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | run-linters: 10 | if: github.event.pull_request.draft == false 11 | name: Run linters 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out Git repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Run linters 27 | uses: wearerequired/lint-action@v2 28 | with: 29 | eslint: false 30 | prettier: true 31 | -------------------------------------------------------------------------------- /.github/workflows/test-build.yml: -------------------------------------------------------------------------------- 1 | name: 👷 Deploy test site to github pages 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | deployment: 11 | runs-on: ubuntu-latest 12 | environment: production 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | persist-credentials: false 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | - name: Install 21 | run: | 22 | npm ci 23 | npm install @rollup/rollup-linux-x64-gnu 24 | - name: Build and Deploy 25 | env: 26 | NODE_ENV: production 27 | # This is set automatically by github 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | run: | 30 | git config user.name "Automated" 31 | git config user.email "actions@users.noreply.github.com" 32 | git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/open-pv/website.git 33 | echo "User-agent: *" > public/robots.txt 34 | echo "Disallow: /" >> public/robots.txt 35 | echo "test.openpv.de" > public/CNAME 36 | npm run build 37 | npm run deploy:test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # prefer npm over yarn 2 | yarn.lock 3 | 4 | # eslint 5 | .eslintcache 6 | 7 | # Logs 8 | logs 9 | *.log 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Enviromental 17 | .env 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directory 32 | # Commenting this out is preferred by some people, see 33 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 34 | node_modules 35 | 36 | # Users Environment Variables 37 | .lock-wscript 38 | 39 | # Webpack related 40 | public/dist/ 41 | dist/ 42 | tmp/ 43 | build/ 44 | 45 | # OSX 46 | .DS_Store 47 | 48 | # Nohup 49 | nohup.out 50 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | jsxSingleQuote: true 4 | endOfLine: 'lf' 5 | trailingComma: 'all' 6 | tabWidth: 2 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[css]": { 3 | "editor.suggest.insertMode": "replace" 4 | }, 5 | "editor.formatOnSave": true, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Website](https://img.shields.io/website?url=https%3A%2F%2Fwww.openpv.de%2F)](https://www.openpv.de/) 2 | 3 | # The OpenPV website 4 | 5 | This is the base repository for the website [openpv.de](https://www.openpv.de). The website is built using 6 | 7 | - [React](https://react.dev/) 8 | - [Chakra-UI](https://v2.chakra-ui.com) 9 | - [Three.js](https://threejs.org/) 10 | 11 | The whole site is **static**, reducing the hosting costs as much as possible. The shading simulation happens in the browser, using 12 | our npm package [simshady](https://github.com/open-pv/simshady). 13 | 14 | ## Setup 15 | 16 | If you want to deploy this website locally, you need to follow these steps: 17 | 18 | 1. Clone the repository and enter it. 19 | 2. Make sure that you have [node](https://nodejs.org/en) and the node package manager npm installed. Check this by running 20 | ``` 21 | node --version 22 | npm --version 23 | ``` 24 | 3. Install all required packages from `package.json` by running 25 | ```shell 26 | npm install 27 | ``` 28 | 4. To build the code and host it in a development environment, run 29 | ```shell 30 | npm run dev 31 | ``` 32 | and visit [localhost:5173](http://localhost:5173). 33 | 34 | ## How does this work? 35 | 36 | We have a detailed description in german and english on our [About Page](https://www.openpv.de/about). Also check out our [blog](https://blog.openpv.de). 37 | 38 | ## Funding 39 | 40 | We thank our sponsors. 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /RELEASE-PROCEDURE.md: -------------------------------------------------------------------------------- 1 | # Release Procedure 2 | 3 | ## Version Numbers 4 | 5 | This software follows the [Semantic Versioning (SemVer)](https://semver.org/).
6 | It always has the format `MAJOR.MINOR.PATCH`, e.g. `1.5.0`. 7 | 8 | ## GitHub Release 9 | 10 | ### 1. 📝 Check correctness of test.openpv.de 11 | 12 | - Navigate to test.openpv.de 13 | - Check that this is the website you want to deploy 14 | - Check that it has no bugs 15 | 16 | ### 2. 🐙 Create a `GitHub Release` 17 | 18 | - Named `v0.12.1` 19 | - Possibly add a Title to the Release Notes Headline 20 | - Summarize key changes in the description 21 | - Use the `generate release notes` button provided by GitHub 22 | - Make sure that new contributors are mentioned 23 | - Choose the correct git `tag` 24 | - Choose the `main` branch 25 | - Publish release 26 | 27 | ### 3. Deployment 28 | 29 | - Start the manual deployment process based on the `build` branch 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 16 | 22 | 28 | 29 | 34 | 35 | 36 | 40 | 41 | 42 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "vite", 4 | "build": "vite build", 5 | "preview": "vite preview", 6 | "deploy": "gh-pages -d dist -b build", 7 | "deploy:test": "gh-pages -d dist -b gh-pages", 8 | "format": "prettier --write ." 9 | }, 10 | "dependencies": { 11 | "@chakra-ui/react": "^3.16.1", 12 | "@emotion/react": "^11.14.0", 13 | "@openpv/simshady": "^0.1.1", 14 | "@react-three/drei": "^9.121.5", 15 | "geotiff": "^2.1.3", 16 | "i18next": "^25.0.2", 17 | "i18next-http-backend": "^3.0.2", 18 | "jszip": "^3.10.1", 19 | "maplibre-gl": "^4.7.1", 20 | "next-themes": "^0.4.4", 21 | "proj4": "^2.15.0", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-helmet-async": "^2.0.5", 25 | "react-i18next": "^15.5.1", 26 | "react-icons": "^5.4.0", 27 | "react-map-gl": "^7.1.8", 28 | "react-router-dom": "^7.5.2", 29 | "react-three-fiber": "^6.0.13", 30 | "three": "^0.172.0" 31 | }, 32 | "devDependencies": { 33 | "@vitejs/plugin-react": "^4.3.4", 34 | "gh-pages": "^6.3.0", 35 | "prettier": "3.5.3", 36 | "vite": "^6.3.4" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/draco/README.md: -------------------------------------------------------------------------------- 1 | # Draco 3D Data Compression 2 | 3 | Draco is an open-source library for compressing and decompressing 3D geometric meshes and point clouds. It is intended to improve the storage and transmission of 3D graphics. 4 | 5 | [Website](https://google.github.io/draco/) | [GitHub](https://github.com/google/draco) 6 | 7 | ## Contents 8 | 9 | This folder contains three utilities: 10 | 11 | - `draco_decoder.js` — Emscripten-compiled decoder, compatible with any modern browser. 12 | - `draco_decoder.wasm` — WebAssembly decoder, compatible with newer browsers and devices. 13 | - `draco_wasm_wrapper.js` — JavaScript wrapper for the WASM decoder. 14 | 15 | Each file is provided in two variations: 16 | 17 | - **Default:** Latest stable builds, tracking the project's [master branch](https://github.com/google/draco). 18 | - **glTF:** Builds targeted by the [glTF mesh compression extension](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_draco_mesh_compression), tracking the [corresponding Draco branch](https://github.com/google/draco/tree/gltf_2.0_draco_extension). 19 | 20 | Either variation may be used with `THREE.DRACOLoader`: 21 | 22 | ```js 23 | var dracoLoader = new THREE.DRACOLoader() 24 | dracoLoader.setDecoderPath('path/to/decoders/') 25 | dracoLoader.setDecoderConfig({ type: 'js' }) // (Optional) Override detection of WASM support. 26 | ``` 27 | 28 | Further [documentation on GitHub](https://github.com/google/draco/tree/master/javascript/example#static-loading-javascript-decoder). 29 | 30 | ## License 31 | 32 | [Apache License 2.0](https://github.com/google/draco/blob/master/LICENSE) 33 | -------------------------------------------------------------------------------- /public/draco/draco_decoder.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/draco/draco_decoder.wasm -------------------------------------------------------------------------------- /public/draco/gltf/draco_decoder.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/draco/gltf/draco_decoder.wasm -------------------------------------------------------------------------------- /public/images/WelcomeMessage0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/WelcomeMessage0.png -------------------------------------------------------------------------------- /public/images/WelcomeMessage1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/WelcomeMessage1.png -------------------------------------------------------------------------------- /public/images/WelcomeMessage2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/WelcomeMessage2.png -------------------------------------------------------------------------------- /public/images/WelcomeMessage3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/WelcomeMessage3.png -------------------------------------------------------------------------------- /public/images/about/about1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/about/about1.png -------------------------------------------------------------------------------- /public/images/about/bmbf.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/about/bmbf.jpg -------------------------------------------------------------------------------- /public/images/about/ptf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/about/ptf.png -------------------------------------------------------------------------------- /public/images/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/images/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/images/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2f728f 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/favicon.ico -------------------------------------------------------------------------------- /public/images/favicon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/mstile-144x144.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/mstile-310x150.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/mstile-310x310.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/75fd0f7a986080b13fc20b5dc123ed6526f282db/public/images/favicon/mstile-70x70.png -------------------------------------------------------------------------------- /public/images/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 54 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /public/images/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OpenPV", 3 | "short_name": "OpenPV", 4 | "icons": [ 5 | { 6 | "src": "/images/favicon/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/images/favicon/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#2f728f", 17 | "background_color": "#2f728f", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /public/locales/de/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "OpenPV - Ihr kostenloser Solarrechner", 3 | "mainDescription": "Analysieren Sie das Solarpotenzial Ihres Gebäudes - Open Source, mit frei verfügbaren 3D-Daten und alles direkt im Browser. Datenschutz? Selbstverständlich: keine unnötiges Datensammeln, kein Tracking.", 4 | "sidebar": { 5 | "header": "Ziel des Tools", 6 | "mainText": "Das Tool berechnet dann das Potential für eine Solaranlage auf deiner Dach- und Fassadenfläche.", 7 | "numberSimulations": "Anzahl an gemittelten Sonnenständen", 8 | "numberSimulationsHover": "Für die Simulation mitteln wir über verschiedene Sonnenstände, welche gleichmäßig über das Jahr verteilt sind. Je größer du diese Zahl wählst, desto genauer wird die Simulation. Allerdings dauert die Simulation auch länger." 9 | }, 10 | "yes": "Ja", 11 | "no": "Nein", 12 | "next": "Nächste", 13 | "previous": "Vorherige", 14 | "close": "Schließen", 15 | "delete": "Löschen", 16 | "searchField": { 17 | "placeholder": "Geben Sie Ihre Adresse oder Koordinaten ein - z.B. Lange Point 20, Freising" 18 | }, 19 | "about": { 20 | "title": "Über openpv.de", 21 | "description": "Weitere Informationen zu openpv.de: Die Website für die Online-Berechnung von Verschattung und Wirtschaftlichkeit deiner Solaranlage.", 22 | "introduction": "Solaranlagen leisten einen bedeutenden Beitrag zur Energiewende. Deutschland strebt an, die installierte PV-Leistung bis 2030 zu verdreifachen. Hierbei spielen Dach- und Balkonanlagen eine essenzielle Rolle, da sie eine dezentrale Energieversorgung ermöglichen, ohne zusätzlichen Flächenverbrauch zu verursachen. Insbesondere durch Balkonkraftwerke ist es auch Mieter*innen ohne eigenes Wohneigentum möglich, einen erheblichen Teil des genutzten Stroms selbst zu erzeugen. Doch vielen möglichen Nutzer*innen fehlt das Hintergrundwissen, um die Eignung ihres Gebäudes für Photovoltaik einschätzen zu können. Hier wollen wir mit OpenPV helfen, um eine einfache Analyse des PV-Potentials am eigenen Gebäude vornehmen zu können. Das Ziel von OpenPV ist es, deutschlandweit mit wenigen Klicks das Solarpotential von Gebäuden basierend auf einer Adresse zu simulieren und darzustellen.", 23 | "generalDescription": { 24 | "h": "Wie funktioniert openpv.de?", 25 | "p": "Auf der Startseite kannst du dein Gebäude entweder über das Suchfeld oder die Karte auwählen. Mit einem Klick auf 'Start' werden die Gebäude des gewählten Standorts geladen und die Verschattungssimulation in deinem Browser ausgeführt. Nach erfolgreichem Abschluss der Simulation wird das PV-Potential deines Gebäudes angezeigt - gelb steht für ein gutes Potential, dunkelblau für ein schlechtes Potential mit viel Verschattung. Anschließend kannst du eine PV-Anlage einzeichnen oder die Optionen der Simulation anpassen." 26 | }, 27 | "data": { 28 | "h": "Welche Daten verwenden wir?", 29 | "p1": "Wir nutzen verschiedene offene Datensätze für die PV-Simulation. Dies sind die 3D-Gebäudedaten (LOD2-Daten) der verschiedenen Landesvermessungsämter, Die jährlichen Einstrahlungsdaten für Deutschland der direkten und diffusen Strahlung des DWD", 30 | "p2": "das Geländemodell von sonny", 31 | "p3": "sowie die Basiskarte des BKG" 32 | }, 33 | "whyOpenSource": { 34 | "h": "Warum ist OpenPV Open Source?", 35 | "p": "Public Money, Public Code! Die Erstellung unserer Website wurde durch öffentliche Gelder ermöglicht, deshalb steht der Code öffentlich zur Verfügung. Open Source Code steigert außerdem das Vertrauen in die Website und die Simulationsergebnisse. Auch können andere Personen den Code anpassen und weiterverwenden." 36 | }, 37 | "team": { 38 | "h": "Wer sind wir?", 39 | "p": "Wir sind ein kleines Team von vier Personen. Wir arbeiten alle nebenberulich an OpenPV.", 40 | "link": "Das Team auf Github." 41 | }, 42 | "sponsors": { 43 | "h": "Unsere Sponsoren", 44 | "p": "Wir danken unseren Sponsoren! Falls Sie ein neues Feature wünschen oder uns unterstützen möchten, kontaktieren Sie uns: info[at]openpv.de" 45 | }, 46 | "steps": { 47 | "introduction": "Anleitung für die Nutzung von openpv.de:", 48 | "1": "Auf der Startseite von OpenPV wird im ersten Schritt ein Gebäude für die Simulation ausgewählt. Dies kann entweder über die Eingabe und Suche einer Adresse erfolgen, oder durch das Zoomen und Auswählen auf der Landkarte. Durch einen Linksklick auf das Gebäude wird ein Menü angezeigt, durch welches die Simulation gestartet werden kann.", 49 | "2": "Ist die Simulation gestartet, werden für das ausgewählte Gebäude alle relevanten Daten in den Browser geladen. Dies sind die 3D-Gebäudedaten des gewählten Gebäudes und der Gebäude aus der Nachbarschaft, das Geländemodell, sowie Daten zur Vegetation (falls vorhanden). Für die Betragsberechnung werden anschließend zwei Verschattungssimulationen durchgeführt, eine mit direkter und eine mit diffuser Einstrahlung. Für die direkte Einstrahlung wird über 100 zufällig gewählte Sonnenstände im Jahr gemittelt. Diese Zahl kann in den Optionen angepasst werden. Anhand der Koordinaten des Gebäudes werden die Klimadaten des Standorts vom deutschen Wetterdienst (DWD) verwendet, um die Einflüsse des lokalen Klimas zu berücksichtigen. Für die Verschattung werden das gewählte Gebäude sowie die Gebäude der direkten Nachbarschaft verwendet. Die Verschattung durch Hügel und Berge wird mit Hilfe eines Geländemodells mit einer Auflösung von 30m berücksichtigt. Die Verschattung durch Bäume kann nur in Bundesländern einbezogen werden, welche Daten zur Vegetation (Laserpunktwolken) offen zur Verfügung stellen. Das ist zum Beispiel in Bayern der Fall. Wenn Vegetation auf der Website angezeigt wird, so wird es auch für die Verschattungssimulation berücksichtigt. Wenn die Simulation beendet ist, wird das Ergebnis im Browser dargestellt. Dafür wird das gewählte Gebäude entsprechend des berechneten solaren Potentials eingefärbt. Zur besseren Orientierung werden auch das Gelände, eine Karte, die Gebäude der Nachbarschaft sowie die vorhandene Vegetation angezeigt.", 50 | "3": "Im nächsten Schritt kann über den Button “Neue PV-Anlage einzeichnen” eine Solaranlage auf dem Dach oder an der Fassade eingezeichnet werden. Durch das Einzeichnen der Eckpunkte kann so eine oder mehrere Solaranlagen mit beliebiger Form angelegt werden. Basierend auf der vorangegangenen Simulation wird anschließend ein geschätzter jährlicher Stromertrag für die eingezeichnete Anlage angegeben.", 51 | "4": "Für die eingezeichneten Anlagen kann im letzten Schritt eine Wirtschaftlichkeitsberechnung durchgeführt werden. Dabei werden ein Standardlastprofil mit dem jährlichen Verbrauch skaliert und ein Standarderzeugungsprofil mit der Leistung der Solaranlage skaliert. Anschließend wird für eine einjährige Zeitreihe mit stündlicher Auflösung der Verbrauch mit der Stromerzeugung abgeglichen. Falls ein Speicher vorhanden ist, kann dieser verwendet werden, um überschüssigen Strom zu speichern oder um Strom bereitzustellen. Der Anteil des Stroms, welcher dank der Solaranlage nicht mehr vom Netz bezogen werden muss, wird anschließend mit dem Strompreis multipliziert. Daraus ergeben sich dann die jährliche Ersparnisse." 52 | } 53 | }, 54 | "loadingMessage": { 55 | "tip1": "Mit Doppelklick kannst du ein anderes Gebäude auf der Karte auswählen und die Simulation neu starten.", 56 | "tip2": "Die Berechnung der Verschattung findet bei dir im Browser statt. Hast du ein großes Gebäude ausgewählt oder nutzt du einen alten PC, dann dauert die Berechnung länger.", 57 | "tip3": "Manchmal werden Teile des Gebäudes unter der Karte angezeigt. In diesem Fall kannst du die Karte ausblenden, um das ganze Gebäude zu sehen." 58 | }, 59 | "errorMessage": { 60 | "header": "Ein Fehler ist aufgetreten.", 61 | "wrongAdress": "Für den angegebenen Ort konnten keine Gebäude gefunden werden. Dies kann mehrere Gründe haben: 1. Am ausgewählten Ort steht kein Gebäude. 2. Am ausgewählten Ort steht ein neues Gebäude, welches noch nicht in unseren Daten vorhanden ist. 3. Der ausgewählte Ort liegt außerhalb von Deutschland oder in den Bundesländern Saarland / Mecklenburg-Vorpommern. Für diese Bundesländer haben wir leider keine Gebäudedaten." 62 | }, 63 | "WelcomeMessage": { 64 | "title": "OpenPV - Ihr Solarrechner für ganz Deutschland.", 65 | "introduction": "Lohnt sich eine Solaranlage auf meinem Dach oder Balkon? OpenPV ist ein kostenloses Tool für die Verschattungsberechnung und Ertragsprognose von Solaranalgen. Mit OpenPV können Sie das solare Potential ihres Gebäudes in wenigen Schritten und mit Hilfe von frei verfügbaren 3D-Gebäudedaten bestimmen.", 66 | "0": { 67 | "title": "Schritt 1: Gebäude auswählen", 68 | "text": "Auf der Startseite von OpenPV wird im ersten Schritt ein Gebäude für die Simulation ausgewählt. Dies kann entweder über die Eingabe und Suche einer Adresse erfolgen, oder durch das Zoomen und Auswählen auf der Landkarte. Durch einen Linksklick auf das Gebäude wird ein Menü angezeigt. Mit einem Klick auf 'Simulation starten' wird die Berechnung gestartet.", 69 | "alt": "Ein Screenshot der Suchmaske. Eine Adresse ist eingegeben. Auf der darunterliegenden Karte ist auf diese Adresse gezoomt. Ein Feld mit dem Button 'Simulation starten' wird angezeigt." 70 | }, 71 | "1": { 72 | "title": "Schritt 2: Ergebnisse anzeigen", 73 | "text": "Anschließend können Sie die Verschattungssimulation für das gewählte Gebäude betrachten. Helle Farben stellen einen guten Solarertrag dar, dunkle Flächen sind häufig verschattet. Interessieren sie sich für die genaueren Details der Verschattungssimulation? Dann gelangen sie durch einen Klick auf den Reiter 'Über openpv.de' zu einer detailierten Beschreibung.", 74 | "alt": "Ein Screenshot eines simulierten Gebäudes. Helle Farben stehen für einen guten solaren Ertrag, dunkle Farben für einen schlechteren Ertrag." 75 | }, 76 | "2": { 77 | "title": "Schritt 3: Solaranlage einzeichnen", 78 | "text": "Über 'Neue PV-Anlage einzeichnen' können Sie eine Solaranlage erstellen. Legen Sie dafür die Eckpunkte auf eine Fläche des Gebäudes und schließen sie die Anlage durch einen Klick auf 'Solaranlage erstellen' ab. Sie können nun die eingezeichnete Solaranlage sowie den geschätzten Jahresertrag sehen.", 79 | "alt": "" 80 | }, 81 | "3": { 82 | "title": "Schritt 4: Wirtschaftlichkeit der Solaranlage bestimmen", 83 | "text": "Anschließend können Sie über den Button 'Wirtschaftlichkeit der Anlage berechnen' die jährlich eingesparten Kosten durch den Eigenverbrauch des Stroms bestimmen. Dafür müssen Sie einige Daten angeben. Diese Daten bleiben bei Ihnen im Browser und werden zu keinem Zeitpunkt an unsere Server übermittelt.", 84 | "alt": "Ein Screenshot der Wirtschaftlichkeitsberechnung. Nach Angabe von jährlichem Stromverbrauch, Kapazität einer Batterie, und dem Strompreis pro kWh wird die jährliche Ersparnis berechnet." 85 | }, 86 | "4": { 87 | "title": "Extra: Wir sind Open Source und datensparsam!", 88 | "text": "Das Beste an OpenPV: Wir sind Open-Source, kostenlos, speichern keine Daten und verwenden keine Tracking-Cookies!", 89 | "alt": "" 90 | } 91 | }, 92 | "button": { 93 | "showMap": "Karte anzeigen", 94 | "start": "Start", 95 | "options": "Optionen", 96 | "simulateBuilding": "Gebäude simulieren", 97 | "createPVSystem": "PV-Anlage erstellen", 98 | "drawPVSystem": "Neue PV-Anlage einzeichnen", 99 | "drawPVSystemHover": "Ecken der PV-Anlage mit Klicks in der Karte einzeichnen und anschließend die Anlage erstellen.", 100 | "cancel": "Abbrechen", 101 | "deleteLastPoint": "Letzen Punkt löschen", 102 | "more": "Mehr..." 103 | }, 104 | "noSearchResults": { 105 | "title": "Keine Ergebnisse", 106 | "description": "Die Ortssuche lieferte keine Ergebnisse." 107 | }, 108 | "startSimulation": "Simulation starten", 109 | "Search": "Suchen", 110 | "map": { 111 | "coordinates": "Koordinaten", 112 | "userSelection": "Manuelle Auswahl" 113 | }, 114 | "Footer": { 115 | "privacyPolicy": "Datenschutz" 116 | }, 117 | "mapControlHelp": { 118 | "button": "Hilfe", 119 | "title": "Bedienungsanleitung", 120 | "leftMouse": "Linke Maustaste: Bewegung auf der Karte", 121 | "rightMouse": "Rechte Maustaste: Rotation der Karte", 122 | "wheel": "Mausrad: Vergrößern und Verkleinern", 123 | "doubleClick": "Doppelklick: Auswählen von Gebäude oder PV-Anlage", 124 | "touch": { 125 | "leftMouse": "Ein Finger: Bewegung auf der Karte", 126 | "rightMouse": "Zwei Finger: Rotation der Karte", 127 | "wheel": "Zwei Finger: Vergrößern und Verkleinern", 128 | "doubleClick": " " 129 | } 130 | }, 131 | "colorLegend": { 132 | "button": "Farbskala", 133 | "description": "Ertrag in kWh pro kWp und Jahr" 134 | }, 135 | "savingsCalculation": { 136 | "notificationLabel": "Für die ausgewählte PV-Anlage:", 137 | "button": "Wirtschaftlichkeit berechnen", 138 | "consumptionTitle": "Jährlicher Stromverbrauch in kWh", 139 | "consumptionHelperInfo": "Als grober Schätzwert gilt: Rechne pro Person im Haushalt mit 800 kWh, für eine Wärmepumpe 2000 kWh und pro Elektroauto weitere 2000 kWh.", 140 | "consumptionHelperLabel": "[So berechnen Sie ihren Stromverbrauch]", 141 | "storageTitle": "Batteriekapazität in kWh", 142 | "electricityPriceTitle": "Strompreis in Cent", 143 | "electricityPricePlaceholder": "Preis pro kWh in cent", 144 | "disclaimer": "Die berechneten Erträge und Einsparungen dienen nur als grobe Orientierung. Die Angaben sind ohne Gewähr und ersetzen keine individuelle Berechnung und Beratung vor Ort!", 145 | "results": { 146 | "production": "Jährliche Stromerzeugung der Solaranlage: ", 147 | "consumption": "Jährlicher Eigenverbrauch: ", 148 | "savings": "Jährliche Einsparungen: " 149 | }, 150 | "calculate": "Berechnen" 151 | }, 152 | "navigation": { "products": "Produkte und Leistungen" }, 153 | "adbox": { 154 | "button": "Wie installiere ich eine Solaranlage?", 155 | "title": "Was kann ich jetzt machen?", 156 | "introduction": "Es ist gut, dass Sie sich für eine Solaranlage interessieren. Mit einer eigenen Anlage leisten Sie einen wichtgen Beitrag zur Energiewende. Hier sammeln wir weiterführende Links, die Ihnen bei der Planung ihrer Anlage helfen.", 157 | "balkonsolar": "Infos zu Balkonsolaranlagen", 158 | "companies": "Installateure in Ihrer Nähe", 159 | "bbe": "Kein eigenes Dach? Tritt doch einer Bürgerenergiegenossenschaft bei!" 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /public/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "OpenPV - A free solar calculator", 3 | "mainDescription": "Analyze the solar potential of your building – open source, using freely available 3D data, all directly in your browser. Privacy? Absolutely: no unnecessary data collection, no tracking.", 4 | "sidebar": { 5 | "header": "Goal of this website", 6 | "mainText": "The tool will then calculate the potential for a solar system on your roof and facade area.", 7 | "numberSimulations": "Number of Sun positions to average", 8 | "numberSimulationsHover": "For the simulation, we average over different sun positions, which are evenly distributed throughout the year. The larger you choose this number, the more accurate the simulation becomes. However, the simulation also takes longer." 9 | }, 10 | "yes": "yes", 11 | "no": "no", 12 | "next": "Next", 13 | "previous": "Previous", 14 | "close": "Close", 15 | "delete": "Delete", 16 | "searchField": { 17 | "placeholder": "Enter your address or coordinates - e.g. Lange Point 20, Freising" 18 | }, 19 | "about": { 20 | "title": "About the Project", 21 | "description": "More information about openpv.de: The website for online calculation of shading and economic efficiency of your solar system.", 22 | "introduction": "Solar systems make a significant contribution to the energy transition. Germany aims to triple the installed PV capacity by 2030. Roof and balcony systems play an essential role in this, as they enable decentralized energy supply without causing additional land consumption. Balcony power plants, in particular, allow tenants without their own property to generate a considerable part of their electricity themselves. However, many potential users lack the background knowledge to assess the suitability of their building for photovoltaics. This is where we want to help with OpenPV, to enable a simple analysis of the PV potential on one's own building. The goal of OpenPV is to simulate and display the solar potential of buildings across Germany with just a few clicks, based on an address.", 23 | "generalDescription": { 24 | "h": "How does openpv.de work?", 25 | "p": "On the homepage, you can select your building either via the search field or the map. With a click on 'Start', the buildings of the selected location are loaded and the shading simulation is executed in your browser. After successful completion of the simulation, the PV potential of your building is displayed - yellow stands for good potential, dark blue for poor potential with a lot of shading. Afterwards, you can draw a PV system or adjust the simulation options." 26 | }, 27 | "data": { 28 | "h": "What data do we use?", 29 | "p1": "We use various open datasets for the PV simulation. These are the 3D building data (LOD2 data) from various state surveying offices, the annual irradiation data for Germany of direct and diffuse radiation from the DWD (German Weather Service),", 30 | "p2": "the terrain model from sonny,", 31 | "p3": "as well as the base map from the BKG (Federal Agency for Cartography and Geodesy)" 32 | }, 33 | "whyOpenSource": { 34 | "h": "Why is OpenPV Open Source?", 35 | "p": "Public Money, Public Code! The creation of our website was made possible through public funds, which is why the code is publicly available. Open source code also increases trust in the website and the simulation results. Additionally, other people can modify and reuse the code." 36 | }, 37 | "team": { 38 | "h": "Who are we?", 39 | "p": "We are a small team of four people. We all work on OpenPV as a side project.", 40 | "link": "The team on Github." 41 | }, 42 | "sponsors": { 43 | "h": "Our Sponsors", 44 | "p": "We thank our sponsors! If you wish for a new feature or want to support us, please contact us: info[at]openpv.de" 45 | }, 46 | "steps": { 47 | "introduction": "Instructions for using openpv.de:", 48 | "1": "On the OpenPV homepage, the first step is to select a building for the simulation. This can be done either by entering and searching for an address or by zooming and selecting on the map. Left-clicking on the building displays a menu through which the simulation can be started.", 49 | "2": "Once the simulation is started, all relevant data for the selected building is loaded into the browser. This includes the 3D building data of the chosen building and the buildings in the neighborhood, the terrain model, and vegetation data (if available). For the yield calculation, two shading simulations are then performed, one with direct and one with diffuse radiation. For direct radiation, an average is taken over 100 randomly selected sun positions throughout the year. This number can be adjusted in the options. Based on the building's coordinates, climate data for the location from the German Weather Service (DWD) is used to account for local climate influences. For shading, the selected building and the buildings in the immediate vicinity are used. Shading by hills and mountains is taken into account using a terrain model with a resolution of 30m. Shading by trees can only be included in federal states that provide open access to vegetation data (laser point clouds). This is the case in Bavaria, for example. If vegetation is displayed on the website, it is also considered for the shading simulation. When the simulation is complete, the result is displayed in the browser. The chosen building is colored according to the calculated solar potential. For better orientation, the terrain, a map, the neighboring buildings, and the existing vegetation are also displayed.", 50 | "3": "In the next step, a solar system can be drawn on the roof or façade using the 'Draw new PV system' button. By drawing the corner points, one or more solar systems of any shape can be created. Based on the previous simulation, an estimated annual electricity yield is then given for the drawn system.", 51 | "4": "In the last step, an economic feasibility calculation can be performed for the drawn systems. A standard load profile is scaled with the annual consumption, and a standard generation profile is scaled with the power of the solar system. Then, for a one-year time series with hourly resolution, consumption is compared with electricity generation. If a storage system is present, it can be used to store excess electricity or to provide electricity. The portion of electricity that no longer needs to be drawn from the grid thanks to the solar system is then multiplied by the electricity price. This results in the annual savings." 52 | } 53 | }, 54 | "loadingMessage": { 55 | "tip1": "Double-click to select a different building on the map and restart the simulation.", 56 | "tip2": "The shadow calculation is done in your browser. If you have selected a large building or are using an old PC, the calculation will take longer.", 57 | "tip3": "Sometimes parts of the building are displayed below the map. In this case, you can hide the map to see the entire building." 58 | }, 59 | "errorMessage": { 60 | "header": "An Error occured.", 61 | "wrongAdress": "No buildings could be found for the specified location. This could be due to several reasons: 1. There is no building at the selected location. 2. There is a new building at the selected location that is not yet included in our data. 3. The selected location is outside of Germany or in the federal states of Saarland or Mecklenburg-Vorpommern. Unfortunately, we do not have building data for these federal states." 62 | }, 63 | "WelcomeMessage": { 64 | "title": "Welcome to OpenPV", 65 | "firstPage": "Is a solar system worth it for my roof or balcony? With OpenPV, you can determine the solar potential of your building in just a few steps with the help of openly available 3D building data. Simply enter your address in the search field and click Start.", 66 | "secondPage": "On the map, you can view the results of the shading simulation. You can select additional buildings for simulation by double-clicking.", 67 | "thirdPage": "In Options -> Draw PV System, you can create a solar system and calculate the annual yield.", 68 | "fourthPage": "The best thing about OpenPV: We are open-source, free, store no data, and use no cookies!" 69 | }, 70 | "button": { 71 | "showMap": "Show Map", 72 | "start": "Start", 73 | "options": "Options", 74 | "simulateBuilding": "Simulate Building", 75 | "createPVSystem": "Create PV System", 76 | "drawPVSystem": "Draw a new PV system", 77 | "drawPVSystemHover": "Create new edges of the PV system by clicking. Afterwards click on the 'Create PV System' button.", 78 | "cancel": "Cancel", 79 | "deleteLastPoint": "Delete Last Point", 80 | "startSimulation": "Start simulation", 81 | "Search": "Search", 82 | "more": "More..." 83 | }, 84 | "noSearchResults": { 85 | "title": "No Results", 86 | "description": "The location search did not yield any results." 87 | }, 88 | "map": { 89 | "coordinates": "Coordinates", 90 | "userSelection": "Manual Selection" 91 | }, 92 | 93 | "Footer": { 94 | "privacyPolicy": "Privacy Policy" 95 | }, 96 | "mapControlHelp": { 97 | "button": "Help", 98 | "title": "User Manual", 99 | "leftMouse": "Left Mouse Button: Move on the map", 100 | "rightMouse": "Right Mouse Button: Rotate the map", 101 | "wheel": "Mouse Wheel: Zoom in and out", 102 | "doubleClick": "Double click: Select a builing or PV system", 103 | "touch": { 104 | "leftMouse": "One finger: Move on the map", 105 | "rightMouse": "Two fingers: Rotate the map", 106 | "wheel": "Two fingers: Zoom in and out", 107 | "doubleClick": " " 108 | } 109 | }, 110 | "colorLegend": { 111 | "button": "Color scale", 112 | "description": "Yield in kWh per kWp and year" 113 | }, 114 | "savingsCalculation": { 115 | "notificationLabel": "Continue with chosen pv system:", 116 | "button": "Calculate Earnings", 117 | "consumptionTitle": "Annual electricity consumption in kWh", 118 | "consumptionHelperInfo": "As a rough estimate: Calculate 800 kWh per person in the household, 2000 kWh for a heat pump, and an additional 2000 kWh per electric car.", 119 | "consumptionHelperLabel": "[Calculate your electricity consumption]", 120 | "storageTitle": "Electricity Storage capacity in kWh", 121 | "electricityPriceTitle": "Electricity price in cents", 122 | "electricityPricePlaceholder": "Price per kWh in cents", 123 | "disclaimer": "The calculated yields and savings are only for rough guidance. The information is without guarantee and does not replace individual calculation and on-site consultation!", 124 | "results": { 125 | "production": "Annual electricity generation of the solar system: ", 126 | "consumption": "Annual self-consumption: ", 127 | "savings": "Annual savings: " 128 | }, 129 | "calculate": "Calculate" 130 | }, 131 | "navigation": { "products": "Our Products" }, 132 | "adbox": { 133 | "button": "How do I install a PV system?", 134 | "title": "What can I do now?", 135 | "introduction": "It's great that you're interested in a solar system. With your own system, you make an important contribution to the energy transition. Here, we collect further links to help you plan your system.", 136 | "balkonsolar": "Information on balcony solar systems", 137 | "companies": "Installers near you", 138 | "bbe": "No own roof? Join a community energy cooperative!" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /data 3 | 4 | Sitemap: https://openpv.de/sitemap.xml -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | https://openpv.de/ 8 | 9 | 2024-08-01 10 | 11 | monthly 12 | 13 | 0.8 14 | 15 | 16 | 17 | 18 | 19 | https://openpv.de/about 20 | 21 | 2024-08-01 22 | 23 | monthly 24 | 25 | 0.2 26 | 27 | 28 | 29 | 30 | 31 | https://openpv.de/impressum 32 | 33 | 2024-08-01 34 | 35 | monthly 36 | 37 | 0.2 38 | 39 | 40 | 41 | 42 | 43 | https://openpv.de/datenschutz 44 | 45 | 2024-08-01 46 | 47 | monthly 48 | 49 | 0.2 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/Main.jsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react' 2 | import PropTypes from 'prop-types' 3 | import React from 'react' 4 | import { Helmet, HelmetProvider } from 'react-helmet-async' 5 | import { useTranslation } from 'react-i18next' 6 | 7 | import Navigation from './components/Template/Navigation' 8 | 9 | const Main = (props) => { 10 | const { t } = useTranslation() 11 | return ( 12 | 13 | 18 | {props.title && {props.title}} 19 | 20 | 21 | 22 | 23 | 24 | {props.children} 25 | 26 | 27 | ) 28 | } 29 | 30 | Main.propTypes = { 31 | children: PropTypes.oneOfType([ 32 | PropTypes.arrayOf(PropTypes.node), 33 | PropTypes.node, 34 | ]), 35 | fullPage: PropTypes.bool, 36 | title: PropTypes.string, 37 | description: PropTypes.string, 38 | } 39 | 40 | Main.defaultProps = { 41 | children: null, 42 | fullPage: false, 43 | title: null, 44 | description: 'Ermittle das Potential für eine Solaranlage.', 45 | } 46 | 47 | export default Main 48 | 49 | const Layout = ({ children }) => { 50 | return ( 51 | 65 | 76 | {children} 77 | 78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /src/components/ErrorMessages/WrongAdress.jsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@chakra-ui/react' 2 | import React from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | 5 | function WrongAdress() { 6 | const { t } = useTranslation() 7 | return ( 8 | 9 | 10 | {t('errorMessage.header')} 11 | {t('errorMessage.wrongAdress')} 12 | 13 | 14 | ) 15 | } 16 | 17 | export default WrongAdress 18 | -------------------------------------------------------------------------------- /src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { 3 | DialogActionTrigger, 4 | DialogBody, 5 | DialogCloseTrigger, 6 | DialogContent, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogRoot, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from '@/components/ui/dialog' 13 | 14 | import i18n from 'i18next' 15 | import React from 'react' 16 | import { useTranslation } from 'react-i18next' 17 | import { attributions, licenseLinks } from '../data/dataLicense' 18 | 19 | const WrapperForLaptopDevice = ({ children }) => { 20 | return ( 21 |
22 |
{children}
23 |
24 | ) 25 | } 26 | 27 | const WrapperForTouchDevice = ({ children }) => { 28 | return ( 29 |
30 |
31 | 32 | 33 | 36 | 37 | 38 | 39 | License Information 40 | 41 | 42 |

{children}

43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 |
52 |
53 |
54 | ) 55 | } 56 | 57 | export default function Footer({ federalState, frontendState }) { 58 | const attr = federalState ? attributions[federalState] : undefined 59 | const changeLanguage = (lng) => { 60 | i18n.changeLanguage(lng) 61 | } 62 | const { t } = useTranslation() 63 | 64 | const Wrapper = window.isTouchDevice 65 | ? WrapperForTouchDevice 66 | : WrapperForLaptopDevice 67 | 68 | const footerContent = ( 69 | <> 70 | {(frontendState == 'Map' || 71 | frontendState == 'Results' || 72 | frontendState == 'DrawPV') && ( 73 |

74 | Basiskarte ©{' '} 75 | 80 | BKG 81 | 82 |  ( 83 | 88 | dl-de/by-2-0 89 | 90 | ) | Geländemodell:  91 | 96 | © Sonny 97 | 98 |  ( 99 | 104 | CC-BY-4.0 105 | 106 | ), erstellt aus 107 | 112 | verschiedenen Quellen 113 | 114 |

115 | )} 116 | {federalState && ( 117 | <> 118 |

123 | Gebäudedaten ©{' '} 124 | 125 | {attr.attribution} 126 | 127 |  ( 128 | 133 | {attr.license} 134 | 135 | ) 136 |

137 | 138 | )} 139 |

140 | ©  141 | 146 | Team OpenPV 147 | 148 | {' | '} 149 | Impressum 150 | {' | '} 151 | {t('Footer.privacyPolicy')} 152 | {' | '} 153 | { 156 | e.preventDefault() 157 | changeLanguage('en') 158 | }} 159 | > 160 | English 161 | 162 | {' | '} 163 | { 166 | e.preventDefault() 167 | changeLanguage('de') 168 | }} 169 | > 170 | German 171 | 172 |

173 | 174 | ) 175 | 176 | return {footerContent} 177 | } 178 | -------------------------------------------------------------------------------- /src/components/MapPopup.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Text } from '@chakra-ui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | import { Popup } from 'react-map-gl/maplibre' 5 | import { useNavigate } from 'react-router-dom' 6 | 7 | export default function MapPopup({ lat, lon, display_name }) { 8 | const { t } = useTranslation() 9 | 10 | const navigate = useNavigate() 11 | const action = () => { 12 | navigate(`/simulation/${lon}/${lat}`) 13 | } 14 | 15 | const [visible, setVisible] = useState(true) 16 | useEffect(() => { 17 | console.log('effect changed') 18 | setVisible(true) 19 | }, [lat, lon]) 20 | 21 | return ( 22 | <> 23 | {visible && ( 24 | setVisible(false)} 30 | > 31 | 32 | {display_name} 33 | 34 | 37 | 38 | )} 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/PVSimulation/SavingsCalculation.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Collapse, 5 | FormControl, 6 | FormLabel, 7 | Input, 8 | ListItem, 9 | Modal, 10 | ModalBody, 11 | ModalCloseButton, 12 | ModalContent, 13 | ModalFooter, 14 | ModalHeader, 15 | ModalOverlay, 16 | Text, 17 | Tooltip, 18 | UnorderedList, 19 | useDisclosure, 20 | } from '@chakra-ui/react' 21 | import React, { useState } from 'react' 22 | import { useTranslation } from 'react-i18next' 23 | 24 | function SavingCalculation({ 25 | selectedPVSystem, 26 | setSelectedPVSystem, 27 | onCloseAlert, 28 | }) { 29 | const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: false }) 30 | const { isOpen: isOpenResultFade, onToggle: onToggleResultFade } = 31 | useDisclosure({ defaultIsOpen: false }) 32 | const { t } = useTranslation() 33 | const [annualConsumption, setAnnualConsumption] = useState('3000') 34 | const [storageCapacity, setStorageCapacity] = useState('0') 35 | const [electricityPrice, setElectricityPrice] = useState('30') 36 | const [selfConsumption, setSelfConsumption] = useState(0) 37 | const [annualSavings, setAnnualSavings] = useState(0) 38 | 39 | // Helper function to normalize input with different decimal separators 40 | const normalizeInput = (value) => { 41 | return value.replace(',', '.') 42 | } 43 | 44 | // Helper function to handle numeric input changes 45 | const handleNumericChange = (setter) => (e) => { 46 | const value = e.target.value 47 | if (value === '' || /^[0-9]*[.,]?[0-9]*$/.test(value)) { 48 | setter(value) 49 | } 50 | } 51 | 52 | let pvProduction 53 | if (selectedPVSystem.length > 0) { 54 | pvProduction = Math.round( 55 | selectedPVSystem.reduce( 56 | (previous, current) => previous + current.annualYield, 57 | 0, 58 | ), 59 | ) 60 | } 61 | 62 | async function handleCalculateSaving() { 63 | async function calculateSaving({ 64 | pvProduction, 65 | consumptionHousehold, 66 | storageCapacity, 67 | electricityPrice, 68 | setSelfConsumption, 69 | setAnnualSavings, 70 | }) { 71 | const response = await fetch( 72 | 'https://www.openpv.de/data/savings_calculation/cons_prod.json', 73 | ) 74 | const data = await response.json() 75 | 76 | const normalizedConsumption = data['Consumption'] 77 | const normalizedProduction = data['Production'] 78 | 79 | const result = {} 80 | let currentStorageLevel = 0 81 | for (const timestamp in normalizedConsumption) { 82 | const consumptionValue = 83 | (normalizedConsumption[timestamp] * consumptionHousehold) / 1000 84 | const productionValue = 85 | (normalizedProduction[timestamp] * pvProduction) / 1000 86 | 87 | let selfConsumption = 0 88 | let excessProduction = 0 89 | 90 | if (productionValue > consumptionValue) { 91 | selfConsumption = consumptionValue 92 | excessProduction = productionValue - consumptionValue 93 | 94 | // Charge the storage 95 | const availableStorageSpace = storageCapacity - currentStorageLevel 96 | const chargedAmount = Math.min( 97 | excessProduction, 98 | availableStorageSpace, 99 | ) 100 | currentStorageLevel += chargedAmount 101 | } else { 102 | const productionDeficit = consumptionValue - productionValue 103 | 104 | // Use storage if available 105 | const usedFromStorage = Math.min( 106 | productionDeficit, 107 | currentStorageLevel, 108 | ) 109 | currentStorageLevel -= usedFromStorage 110 | 111 | selfConsumption = productionValue + usedFromStorage 112 | } 113 | 114 | result[timestamp] = selfConsumption 115 | } 116 | 117 | let selfConsumedElectricity = Object.values(result).reduce( 118 | (acc, val) => acc + val, 119 | 0, 120 | ) 121 | 122 | setSelfConsumption(Math.round(selfConsumedElectricity)) 123 | setAnnualSavings( 124 | Math.round((selfConsumedElectricity * electricityPrice) / 100), 125 | ) 126 | } 127 | 128 | await calculateSaving({ 129 | pvProduction: pvProduction, 130 | consumptionHousehold: parseFloat(normalizeInput(annualConsumption)), 131 | storageCapacity: parseFloat(normalizeInput(storageCapacity)), 132 | electricityPrice: parseFloat(normalizeInput(electricityPrice)), 133 | setSelfConsumption: setSelfConsumption, 134 | setAnnualSavings: setAnnualSavings, 135 | }) 136 | } 137 | 138 | const initialRef = React.useRef(null) 139 | 140 | return ( 141 | <> 142 | {selectedPVSystem.length > 0 && ( 143 | 151 | )} 152 | { 155 | onClose() 156 | onCloseAlert() 157 | setSelectedPVSystem([]) 158 | }} 159 | size='xl' 160 | > 161 | 162 | 163 | {t('savingsCalculation.button')} 164 | 165 | 166 | <> 167 | 168 | 169 | {t('savingsCalculation.consumptionTitle')} 170 | 173 | 174 | {t('savingsCalculation.consumptionHelperLabel')} 175 | 176 | 177 | 178 | 183 | 184 |
185 | 186 | {t('savingsCalculation.storageTitle')} 187 | 191 | 192 |
193 | 194 | 195 | {t('savingsCalculation.electricityPriceTitle')} 196 | 197 | 204 | 205 | 206 | 207 | 215 | {t('savingsCalculation.disclaimer')} 216 | 217 | 218 | {t('savingsCalculation.results.production')} 219 | 220 | {pvProduction} kWh 221 | 222 | 223 | 224 | {t('savingsCalculation.results.consumption')} 225 | 226 | {selfConsumption} kWh 227 | 228 | 229 | 230 | {t('savingsCalculation.results.savings')} 231 | 232 | {annualSavings}€ 233 | 234 | 235 | 236 | 237 | 238 | 239 |
240 | 241 |
242 | 243 | 244 | 255 | 256 |
257 |
258 | 259 | ) 260 | } 261 | 262 | export default SavingCalculation 263 | -------------------------------------------------------------------------------- /src/components/PVSimulation/SearchField.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Input, List } from '@chakra-ui/react' 2 | import React, { useEffect, useRef, useState } from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | import { processAddress } from '../../simulation/location' 5 | 6 | export default function SearchField({ callback }) { 7 | const [inputValue, setInputValue] = useState('') 8 | const [suggestions, setSuggestions] = useState([]) 9 | const [suggestionsVisible, setSuggestionsVisible] = useState(false) 10 | // isSelectedAdress is used so that if an adress is already selected, 11 | // the autocomplete does stop to run 12 | const [isSelectedAdress, setIsSelectedAdress] = useState(false) 13 | const suggestionsRef = useRef([]) 14 | const inputRef = useRef() 15 | const formRef = useRef() 16 | const [focusedIndex, setFocusedIndex] = useState(-1) 17 | window.searchFieldInput = inputValue 18 | const { t } = useTranslation() 19 | 20 | useEffect(() => { 21 | const handleClickOutside = (event) => { 22 | if (formRef.current && !formRef.current.contains(event.target)) { 23 | setSuggestionsVisible(false) 24 | setFocusedIndex(-1) 25 | } 26 | } 27 | 28 | document.addEventListener('mousedown', handleClickOutside) 29 | document.addEventListener('touchstart', handleClickOutside) 30 | return () => { 31 | document.removeEventListener('mousedown', handleClickOutside) 32 | document.removeEventListener('touchstart', handleClickOutside) 33 | } 34 | }) 35 | 36 | useEffect(() => { 37 | const fetchSuggestions = async () => { 38 | if (inputValue.length < 3) { 39 | // If the input is deleted or replaced with one 40 | // charakter, the autocomplete should start again 41 | setIsSelectedAdress(false) 42 | } 43 | if (isSelectedAdress) { 44 | return 45 | } 46 | if (inputValue.length > 2) { 47 | try { 48 | const inputValueParts = inputValue.split(' ') 49 | let streetAddressNumber = null 50 | 51 | // Find the street address number 52 | for (let inputPart of inputValueParts) { 53 | if (inputPart[inputPart.length - 1] === ',') { 54 | //drop last character (ie the comma) 55 | inputPart = inputPart.slice(0, -1) 56 | } 57 | if (inputPart.length == 5) { 58 | // continue if it has the length of a zip code 59 | continue 60 | } 61 | if (/^\d{1,3}[a-zA-Z]?$/.test(inputPart)) { 62 | // regex chatches numbers with 1-3 digits plus one charater 63 | streetAddressNumber = inputPart 64 | break 65 | } 66 | } 67 | 68 | const response = await fetch( 69 | `https://photon.komoot.io/api/?q=${encodeURIComponent( 70 | inputValue, 71 | )}&bbox=5.98865807458,47.3024876979,15.0169958839,54.983104153&limit=5&lang=de&layer=street`, 72 | ) 73 | const data = await response.json() 74 | console.log('data', data) 75 | 76 | setSuggestions( 77 | data.features.map((feature) => { 78 | let suggestion = feature.properties.name 79 | if (streetAddressNumber) { 80 | suggestion += ' ' + streetAddressNumber 81 | } 82 | suggestion += 83 | ', ' + 84 | feature.properties.postcode + 85 | ' ' + 86 | feature.properties.city 87 | return suggestion 88 | }), 89 | ) 90 | } catch (error) { 91 | console.error('Error fetching suggestions:', error) 92 | } 93 | } else { 94 | setSuggestions([]) 95 | } 96 | setSuggestionsVisible(suggestions.length > 0) 97 | } 98 | 99 | const debounceTimer = setTimeout(fetchSuggestions, 200) 100 | return () => clearTimeout(debounceTimer) 101 | }, [inputValue, isSelectedAdress]) 102 | 103 | const handleSubmit = async (event) => { 104 | event.preventDefault() 105 | const locations = await processAddress(inputValue) 106 | console.warn(locations) 107 | callback(locations) 108 | } 109 | 110 | const handleSuggestionClick = (suggestion) => { 111 | setInputValue(suggestion) 112 | processAddress(suggestion).then((locations) => { 113 | console.warn(locations) 114 | callback(locations) 115 | }) 116 | setSuggestions([]) 117 | setIsSelectedAdress(true) 118 | } 119 | 120 | const handleKeyDown = (event) => { 121 | if (event.key === 'ArrowDown') { 122 | event.preventDefault() 123 | setFocusedIndex((prevIndex) => 124 | prevIndex < suggestions.length - 1 ? prevIndex + 1 : prevIndex, 125 | ) 126 | } else if (event.key === 'ArrowUp') { 127 | event.preventDefault() 128 | setFocusedIndex((prevIndex) => (prevIndex > -1 ? prevIndex - 1 : -1)) 129 | } else if (event.key === 'Enter' && focusedIndex > -1) { 130 | event.preventDefault() 131 | handleSuggestionClick(suggestions[focusedIndex]) 132 | } 133 | } 134 | 135 | useEffect(() => { 136 | if (focusedIndex > -1 && suggestionsRef.current[focusedIndex]) { 137 | suggestionsRef.current[focusedIndex].focus() 138 | } else if (focusedIndex === -1) { 139 | inputRef.current.focus() 140 | } 141 | }, [focusedIndex]) 142 | 143 | return ( 144 |
155 |
156 | setInputValue(evt.target.value)} 161 | onKeyDown={handleKeyDown} 162 | margin={'5px'} 163 | autoComplete='street-address' 164 | /> 165 | 173 |
174 | {suggestionsVisible && ( 175 | 188 | {suggestions.map((suggestion, index) => ( 189 | (suggestionsRef.current[index] = elem)} 191 | key={index} 192 | p={2} 193 | style={{ paddingLeft: '1em' }} 194 | cursor='pointer' 195 | _hover={{ backgroundColor: 'gray.100' }} 196 | backgroundColor={focusedIndex === index ? 'gray.100' : 'white'} 197 | onClick={() => handleSuggestionClick(suggestion)} 198 | onKeyDown={handleKeyDown} 199 | color={'black'} 200 | > 201 | {suggestion} 202 | 203 | ))} 204 | 205 | )} 206 |
207 | ) 208 | } 209 | -------------------------------------------------------------------------------- /src/components/Template/LoadingBar.jsx: -------------------------------------------------------------------------------- 1 | import { ProgressBar, ProgressRoot } from '@/components/ui/progress' 2 | import React, { useEffect, useState } from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | 5 | const LoadingBar = ({ progress }) => { 6 | const { t } = useTranslation() 7 | const numberTips = 3 8 | const [shownTip, setShownTip] = useState(0) 9 | 10 | useEffect(() => { 11 | // Set a random tip when the component mounts 12 | const randomTip = Math.floor(Math.random() * numberTips) + 1 13 | setShownTip(randomTip) 14 | }, []) 15 | 16 | return ( 17 |
26 |

27 | {t('loadingMessage.tip' + shownTip.toString())} 28 |

29 |
30 | 31 | 32 | 33 |
34 |
35 | ) 36 | } 37 | 38 | export default LoadingBar 39 | -------------------------------------------------------------------------------- /src/components/Template/Navigation.jsx: -------------------------------------------------------------------------------- 1 | import { Link, Tabs } from '@chakra-ui/react' 2 | import React from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | import { useLocation } from 'react-router-dom' 5 | 6 | const Navigation = () => { 7 | const { t } = useTranslation() 8 | const location = useLocation() 9 | const isActive = (path) => location.pathname === path 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | OpenPV 17 | 18 | 19 | 20 | 21 | {t('about.title')} 22 | 23 | 24 | 25 | 26 | {t('navigation.products')} 27 | 28 | 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | export default Navigation 36 | -------------------------------------------------------------------------------- /src/components/Template/WelcomeMessage.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | AccordionItem, 3 | AccordionItemContent, 4 | AccordionItemTrigger, 5 | AccordionRoot, 6 | } from '@/components/ui/accordion' 7 | import { 8 | DialogBody, 9 | DialogCloseTrigger, 10 | DialogContent, 11 | DialogHeader, 12 | DialogRoot, 13 | DialogTitle, 14 | } from '@/components/ui/dialog' 15 | import { Box, Image } from '@chakra-ui/react' 16 | import React, { useState } from 'react' 17 | import { useTranslation } from 'react-i18next' 18 | 19 | function WelcomeMessageBoxElement({ image, text }) { 20 | return ( 21 | 27 | {image && ( 28 | {image.alt} 33 | )} 34 | 35 | {text} 36 | 37 | ) 38 | } 39 | 40 | function WelcomeMessage() { 41 | const { t } = useTranslation() 42 | const [open, setOpen] = useState(true) 43 | 44 | return ( 45 | setOpen(e.open)}> 46 | 47 | 48 | {t('WelcomeMessage.title')} 49 | 50 | 51 | 52 |

{t(`WelcomeMessage.introduction`)}

53 |
54 | {Array.from({ length: 5 }, (_, index) => ( 55 | 56 | 57 | 58 | {t(`WelcomeMessage.${index}.title`)} 59 | 60 | 61 | 68 | 69 | 70 | 71 | ))} 72 |
73 | 74 |
75 |
76 | ) 77 | } 78 | 79 | export default WelcomeMessage 80 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Controls/CustomMapControl.jsx: -------------------------------------------------------------------------------- 1 | import { MapControls } from '@react-three/drei' 2 | import { useFrame, useThree } from '@react-three/fiber' 3 | import React, { useEffect, useRef } from 'react' 4 | import * as THREE from 'three' 5 | 6 | function CustomMapControl(props) { 7 | const controlsRef = useRef() 8 | const raycaster = useRef(new THREE.Raycaster()) 9 | const mouse = useRef(new THREE.Vector2()) 10 | const { gl, camera, scene } = useThree() 11 | 12 | let lastTap = 0 13 | 14 | const handleInteraction = (event) => { 15 | event.preventDefault() 16 | 17 | /** 18 | * Returns the list of intersected objects. An intersected object is an object 19 | * that lies directly below the mouse cursor. 20 | */ 21 | const getIntersects = () => { 22 | const isTouch = window.isTouchDevice 23 | const clientX = isTouch ? event.touches[0].clientX : event.clientX 24 | const clientY = isTouch ? event.touches[0].clientY : event.clientY 25 | 26 | const rect = event.target.getBoundingClientRect() 27 | mouse.current.x = ((clientX - rect.left) / rect.width) * 2 - 1 28 | mouse.current.y = (-(clientY - rect.top) / rect.height) * 2 + 1 29 | 30 | raycaster.current.setFromCamera(mouse.current, camera) 31 | 32 | return raycaster.current.intersectObjects(scene.children, true) 33 | } 34 | const intersects = getIntersects() 35 | 36 | if (intersects.length === 0) { 37 | console.log('No children in the intersected mesh.') 38 | return 39 | } 40 | 41 | // Filter out Sprites (ie the labels of PV systems) 42 | let i = 0 43 | while (i < intersects.length && intersects[i].object.type === 'Sprite') { 44 | i++ 45 | } 46 | if (i === intersects.length) { 47 | console.log('Only Sprite objects found in intersections.') 48 | return 49 | } 50 | 51 | let intersectedMesh = intersects[i].object 52 | console.log('Intersected Mesh', intersectedMesh) 53 | 54 | if (!intersectedMesh) return 55 | if (!intersectedMesh.geometry.name) { 56 | console.log( 57 | "There is a mesh, but it has no name so I don't know what to do.", 58 | ) 59 | return 60 | } 61 | if ( 62 | intersectedMesh.geometry.name.includes('surrounding') || 63 | intersectedMesh.geometry.name.includes('background') 64 | ) { 65 | props.setSelectedMesh([intersectedMesh]) 66 | } 67 | if (intersectedMesh.geometry.name.includes('pvSystem')) { 68 | props.setSelectedPVSystem([intersectedMesh.geometry]) 69 | } 70 | } 71 | 72 | const handleDoubleClick = (event) => { 73 | handleInteraction(event) 74 | } 75 | 76 | useEffect(() => { 77 | const canvas = gl.domElement 78 | 79 | canvas.addEventListener('dblclick', handleDoubleClick) 80 | 81 | return () => { 82 | canvas.removeEventListener('dblclick', handleDoubleClick) 83 | } 84 | }, [camera, scene]) 85 | 86 | useFrame(() => { 87 | if (controlsRef.current) { 88 | controlsRef.current.update() 89 | } 90 | }) 91 | 92 | return ( 93 | 110 | ) 111 | } 112 | 113 | export default CustomMapControl 114 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Controls/DrawPVControl.jsx: -------------------------------------------------------------------------------- 1 | import { useFrame, useThree } from '@react-three/fiber' 2 | import { useEffect, useRef } from 'react' 3 | import * as THREE from 'three' 4 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 5 | import { createPVSystem } from '../Meshes/PVSystems' 6 | 7 | const DrawPVControl = ({ 8 | middle, 9 | setPVPoints, 10 | setPVSystems, 11 | setSelectedPVSystem, 12 | simulationMeshes, 13 | setFrontendState, 14 | }) => { 15 | const { camera, gl, scene } = useThree() 16 | const raycaster = useRef(new THREE.Raycaster()) 17 | const mouse = useRef(new THREE.Vector2()) 18 | const controls = useRef() 19 | let pvPointsRef = [] 20 | 21 | useEffect(() => { 22 | // Initialize OrbitControls 23 | controls.current = new OrbitControls(camera, gl.domElement) 24 | controls.current.target = middle 25 | controls.current.mouseButtons = { 26 | MIDDLE: THREE.MOUSE.DOLLY, 27 | RIGHT: THREE.MOUSE.ROTATE, 28 | } 29 | controls.current.screenSpacePanning = false 30 | controls.current.maxPolarAngle = Math.PI / 2 31 | controls.current.update() 32 | 33 | // Clean up on unmount 34 | return () => { 35 | controls.current.dispose() 36 | } 37 | }, [camera, gl, middle]) 38 | 39 | const onPointerDown = (event) => { 40 | if (event.button !== 0) return 41 | 42 | const rect = event.target.getBoundingClientRect() 43 | mouse.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 44 | mouse.current.y = (-(event.clientY - rect.top) / rect.height) * 2 + 1 45 | 46 | raycaster.current.setFromCamera(mouse.current, camera) 47 | 48 | const intersects = raycaster.current.intersectObjects(scene.children, true) 49 | 50 | if (intersects.length > 0) { 51 | const intersection = intersects[0] 52 | if (intersection.object.type == 'Points') { 53 | // User clicked on a previously drawn point. Now we need 54 | // to check if this was the first point from the list 55 | // and if three points already exist. Then we can draw the 56 | // PV System. 57 | 58 | // Also important to understand this behaviour here: Check out 59 | // https://github.com/open-pv/website/pull/430 60 | 61 | if ( 62 | arePointsEqual( 63 | pvPointsRef[0].point, 64 | intersection.object.geometry.attributes.position.array, 65 | ) && 66 | pvPointsRef.length > 2 67 | ) { 68 | createPVSystem({ 69 | setPVSystems, 70 | setSelectedPVSystem, 71 | pvPoints: pvPointsRef, 72 | setPVPoints, 73 | simulationMeshes, 74 | }) 75 | setFrontendState('Results') 76 | } 77 | } 78 | const point = intersection.point 79 | if (!intersection.face) { 80 | // Catch the error where sometimes the intersection 81 | // is undefined. By this no dot is drawn, but also 82 | // no error is thrown 83 | console.log('Intersection.face was null.') 84 | return undefined 85 | } 86 | const normal = intersection.face.normal 87 | .clone() 88 | .transformDirection(intersection.object.matrixWorld) 89 | 90 | setPVPoints((prevPoints) => { 91 | const newPoints = [...prevPoints, { point, normal }] 92 | pvPointsRef = newPoints // Keep ref updated 93 | return newPoints 94 | }) 95 | } 96 | } 97 | 98 | useEffect(() => { 99 | // Add event listener 100 | gl.domElement.addEventListener('pointerdown', onPointerDown) 101 | 102 | // Clean up 103 | return () => { 104 | gl.domElement.removeEventListener('pointerdown', onPointerDown) 105 | } 106 | }, [gl]) 107 | 108 | useFrame(() => { 109 | if (controls.current) controls.current.update() 110 | }) 111 | 112 | return null // This component does not render anything visible 113 | } 114 | 115 | export default DrawPVControl 116 | 117 | /** 118 | * Compares two points, where one is an object and one is a list. 119 | * The function allows a 1% deviation. 120 | * @param {} p1 First Point as object with x,y,z attribute 121 | * @param {} p2 Second point as list with three elements 122 | * @returns 123 | */ 124 | function arePointsEqual(p1, p2) { 125 | return Math.hypot(p1.x - p2[0], p1.y - p2[1], p1.z - p2[2]) < 0.01 126 | } 127 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Meshes/HighlitedPVSystem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as THREE from 'three' 3 | 4 | export function HighlightedPVSystem({ geometries }) { 5 | return ( 6 | <> 7 | {geometries.map((geometry, index) => ( 8 | 18 | ))} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Meshes/HiglightedMesh.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as THREE from 'three' 3 | 4 | export function HighlightedMesh({ meshes }) { 5 | return ( 6 | <> 7 | {meshes.map((mesh, index) => ( 8 | 18 | ))} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Meshes/PVSystems.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import { useFrame } from 'react-three-fiber' 3 | import * as THREE from 'three' 4 | import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js' 5 | import TextSprite from '../TextSprite' 6 | 7 | export const PVSystems = ({ pvSystems }) => { 8 | return ( 9 | <> 10 | {pvSystems.map((geometry) => ( 11 | 12 | ))} 13 | 14 | ) 15 | } 16 | 17 | export function createPVSystem({ 18 | setPVSystems, 19 | setSelectedPVSystem, 20 | pvPoints, 21 | setPVPoints, 22 | simulationMeshes, 23 | }) { 24 | const points = pvPoints.map((obj) => obj.point) 25 | if (pvPoints.length < 3) { 26 | return 27 | } 28 | const geometry = new THREE.BufferGeometry() 29 | const trianglesWithNormals = triangulate(pvPoints) 30 | const triangles = [] 31 | const bufferTriangles = [] 32 | const normalOffset = 0.1 // Adjust this value as needed 33 | 34 | for (const { a, b, c } of trianglesWithNormals) { 35 | const shift = (element) => ({ 36 | x: element.point.x + element.normal.x * normalOffset, 37 | y: element.point.y + element.normal.y * normalOffset, 38 | z: element.point.z + element.normal.z * normalOffset, 39 | }) 40 | 41 | const sa = shift(a) 42 | const sb = shift(b) 43 | const sc = shift(c) 44 | 45 | triangles.push({ a: a.point, b: b.point, c: c.point }) 46 | bufferTriangles.push(sa.x, sa.y, sa.z, sb.x, sb.y, sb.z, sc.x, sc.y, sc.z) 47 | } 48 | 49 | geometry.setAttribute( 50 | 'position', 51 | new THREE.Float32BufferAttribute(bufferTriangles, 3), 52 | ) 53 | geometry.name = 'pvSystem' 54 | 55 | let subdividedTriangles = [] 56 | const triangleSubdivisionThreshold = 0.8 57 | triangles.forEach((triangle) => { 58 | subdividedTriangles = subdividedTriangles.concat( 59 | subdivideTriangle(triangle, triangleSubdivisionThreshold), 60 | ) 61 | }) 62 | 63 | const geometries = [] 64 | 65 | simulationMeshes.forEach((mesh) => { 66 | const geom = mesh.geometry.clone() 67 | geom.applyMatrix4(mesh.matrixWorld) 68 | geometries.push(geom) 69 | }) 70 | const simulationGeometry = BufferGeometryUtils.mergeGeometries( 71 | geometries, 72 | true, 73 | ) 74 | const polygonPrefilteringCutoff = 10 75 | const prefilteredPolygons = filterPolygonsByDistance( 76 | simulationGeometry, 77 | points, 78 | polygonPrefilteringCutoff, 79 | ) 80 | const newVertices = [] 81 | const newColors = [] 82 | const newIntensities = [] 83 | subdividedTriangles.forEach((triangle) => { 84 | newVertices.push(triangle.a.x, triangle.a.y, triangle.a.z) 85 | newVertices.push(triangle.b.x, triangle.b.y, triangle.b.z) 86 | newVertices.push(triangle.c.x, triangle.c.y, triangle.c.z) 87 | }) 88 | for (let i = 0; i < newVertices.length; i += 3) { 89 | const vertex = new THREE.Vector3( 90 | newVertices[i], 91 | newVertices[i + 1], 92 | newVertices[i + 2], 93 | ) 94 | const closestPolygon = findClosestPolygon( 95 | vertex, 96 | prefilteredPolygons, 97 | polygonPrefilteringCutoff, 98 | ) 99 | if (closestPolygon) { 100 | const projectedVertex = projectOntoTriangle(vertex, closestPolygon) 101 | const color = getColorAtPointOnTriangle(projectedVertex, closestPolygon) 102 | const intensity = getIntensityAtPointOnTriangle( 103 | projectedVertex, 104 | closestPolygon, 105 | ) 106 | newColors.push(color.r, color.g, color.b) 107 | newIntensities.push(intensity) 108 | } else { 109 | newColors.push(1, 1, 1) 110 | newIntensities.push(-1) 111 | } 112 | } 113 | const polygonArea = calculatePolygonArea(triangles) 114 | const polygonIntensity = calculatePolygonIntensity( 115 | newVertices, 116 | newIntensities, 117 | ) 118 | const annualYield = polygonArea * polygonIntensity 119 | 120 | geometry.annualYield = annualYield 121 | geometry.area = polygonArea 122 | 123 | setPVSystems((prevSystems) => [...prevSystems, geometry]) 124 | setPVPoints([]) 125 | setSelectedPVSystem([geometry]) 126 | } 127 | 128 | const PVSystem = ({ geometry }) => { 129 | const textRef = useRef() 130 | 131 | const center = calculateCenter(geometry.attributes.position.array) 132 | 133 | useFrame(({ camera }) => { 134 | if (textRef.current) { 135 | textRef.current.quaternion.copy(camera.quaternion) 136 | } 137 | }) 138 | 139 | return ( 140 | <> 141 | 153 | 154 | 160 | 161 | ) 162 | } 163 | 164 | const calculateCenter = (points) => { 165 | const length = points.length / 3 166 | const sum = points.reduce( 167 | (acc, value, index) => { 168 | acc[index % 3] += value 169 | return acc 170 | }, 171 | [0, 0, 0], 172 | ) 173 | return new THREE.Vector3(sum[0] / length, sum[1] / length, sum[2] / length) 174 | } 175 | 176 | function subdivideTriangle(triangle, threshold) { 177 | const distance = (p1, p2) => 178 | Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2 + (p2.z - p1.z) ** 2) 179 | 180 | const midPoint = (p1, p2) => ({ 181 | x: (p1.x + p2.x) / 2, 182 | y: (p1.y + p2.y) / 2, 183 | z: (p1.z + p2.z) / 2, 184 | }) 185 | 186 | const aToB = distance(triangle.a, triangle.b) 187 | const bToC = distance(triangle.b, triangle.c) 188 | const cToA = distance(triangle.c, triangle.a) 189 | 190 | const area = calculateTriangleArea(triangle) 191 | 192 | if (area < threshold) { 193 | return [triangle] 194 | } 195 | 196 | const abMid = midPoint(triangle.a, triangle.b) 197 | const bcMid = midPoint(triangle.b, triangle.c) 198 | const caMid = midPoint(triangle.c, triangle.a) 199 | 200 | return subdivideTriangle({ a: triangle.a, b: abMid, c: caMid }, threshold) 201 | .concat(subdivideTriangle({ a: abMid, b: triangle.b, c: bcMid }, threshold)) 202 | .concat(subdivideTriangle({ a: caMid, b: bcMid, c: triangle.c }, threshold)) 203 | .concat(subdivideTriangle({ a: abMid, b: bcMid, c: caMid }, threshold)) 204 | } 205 | 206 | function calculatePolygonArea(polygon) { 207 | let totalArea = 0 208 | 209 | polygon.forEach((triangle) => { 210 | totalArea += calculateTriangleArea(triangle) 211 | }) 212 | 213 | return totalArea 214 | } 215 | 216 | function calculateTriangleArea(triangle) { 217 | const { a, b, c } = triangle 218 | 219 | const ab = new THREE.Vector3().subVectors(b, a) 220 | const ac = new THREE.Vector3().subVectors(c, a) 221 | const crossProduct = new THREE.Vector3().crossVectors(ab, ac) 222 | const area = 0.5 * crossProduct.length() 223 | 224 | return area 225 | } 226 | 227 | function findClosestPolygon(vertex, polygons, polygonPrefilteringCutoff) { 228 | let closestPolygon = null 229 | let minDistance = Infinity 230 | 231 | polygons.forEach((polygon) => { 232 | const [v0, v1, v2] = polygon.vertices 233 | const distance = 234 | vertex.distanceTo(v0) + vertex.distanceTo(v1) + vertex.distanceTo(v2) 235 | if (distance < minDistance) { 236 | minDistance = distance 237 | closestPolygon = polygon 238 | } 239 | }) 240 | 241 | if (minDistance >= polygonPrefilteringCutoff) { 242 | console.error( 243 | `Error: Trying to create a polygon with a distance longer than the threshold (${minDistance})`, 244 | ) 245 | } 246 | 247 | return closestPolygon 248 | } 249 | 250 | function filterPolygonsByDistance(geometry, points, threshold) { 251 | const filteredPolygons = [] 252 | 253 | if (!geometry.isBufferGeometry) return 254 | 255 | const positions = geometry.attributes.position.array 256 | const colors = geometry.attributes.color 257 | ? geometry.attributes.color.array 258 | : null 259 | const intensities = geometry.attributes.intensities 260 | ? geometry.attributes.intensities.array 261 | : null 262 | 263 | for (let i = 0; i < positions.length; i += 9) { 264 | const v0 = new THREE.Vector3( 265 | positions[i], 266 | positions[i + 1], 267 | positions[i + 2], 268 | ) 269 | const v1 = new THREE.Vector3( 270 | positions[i + 3], 271 | positions[i + 4], 272 | positions[i + 5], 273 | ) 274 | const v2 = new THREE.Vector3( 275 | positions[i + 6], 276 | positions[i + 7], 277 | positions[i + 8], 278 | ) 279 | 280 | const color0 = colors 281 | ? new THREE.Color(colors[i], colors[i + 1], colors[i + 2]) 282 | : new THREE.Color(1, 1, 1) 283 | const color1 = colors 284 | ? new THREE.Color(colors[i + 3], colors[i + 4], colors[i + 5]) 285 | : new THREE.Color(1, 1, 1) 286 | const color2 = colors 287 | ? new THREE.Color(colors[i + 6], colors[i + 7], colors[i + 8]) 288 | : new THREE.Color(1, 1, 1) 289 | 290 | const intensity1 = intensities ? intensities[i / 9] : -1000 291 | const intensity2 = intensities ? intensities[i / 9] : -1000 292 | const intensity3 = intensities ? intensities[i / 9] : -1000 293 | 294 | let minDistance = Infinity 295 | points.forEach((point) => { 296 | const distance = Math.min( 297 | point.distanceTo(v0), 298 | point.distanceTo(v1), 299 | point.distanceTo(v2), 300 | ) 301 | if (distance < minDistance) { 302 | minDistance = distance 303 | } 304 | }) 305 | 306 | if (minDistance < threshold) { 307 | const normal = new THREE.Triangle(v0, v1, v2).getNormal( 308 | new THREE.Vector3(), 309 | ) 310 | filteredPolygons.push({ 311 | vertices: [v0, v1, v2], 312 | colors: [color0, color1, color2], 313 | normal, 314 | intensities: [intensity1, intensity2, intensity3], 315 | }) 316 | } 317 | } 318 | 319 | return filteredPolygons 320 | } 321 | 322 | function projectOntoTriangle(vertex, triangle) { 323 | const [v0, v1, v2] = triangle.vertices 324 | const normal = triangle.normal.clone().normalize() 325 | 326 | const d = v0.dot(normal) 327 | const t = (d - vertex.dot(normal)) / normal.dot(normal) 328 | const projection = vertex.clone().add(normal.clone().multiplyScalar(t)) 329 | 330 | return projection 331 | } 332 | 333 | function getColorAtPointOnTriangle(point, triangle) { 334 | const [v0, v1, v2] = triangle.vertices 335 | const normal = triangle.normal.clone().normalize() 336 | 337 | const areaABC = normal.dot( 338 | new THREE.Vector3().crossVectors(v1.clone().sub(v0), v2.clone().sub(v0)), 339 | ) 340 | const areaPBC = normal.dot( 341 | new THREE.Vector3().crossVectors( 342 | v1.clone().sub(point), 343 | v2.clone().sub(point), 344 | ), 345 | ) 346 | const areaPCA = normal.dot( 347 | new THREE.Vector3().crossVectors( 348 | v2.clone().sub(point), 349 | v0.clone().sub(point), 350 | ), 351 | ) 352 | 353 | const u = areaPBC / areaABC 354 | const v = areaPCA / areaABC 355 | const w = 1 - u - v 356 | 357 | const color0 = triangle.colors[0] 358 | const color1 = triangle.colors[1] 359 | const color2 = triangle.colors[2] 360 | 361 | const r = u * color0.r + v * color1.r + w * color2.r 362 | const g = u * color0.g + v * color1.g + w * color2.g 363 | const b = u * color0.b + v * color1.b + w * color2.b 364 | 365 | return new THREE.Color(r, g, b) 366 | } 367 | 368 | function getIntensityAtPointOnTriangle(point, triangle) { 369 | const [v0, v1, v2] = triangle.vertices 370 | const normal = triangle.normal.clone().normalize() 371 | 372 | const areaABC = normal.dot( 373 | new THREE.Vector3().crossVectors(v1.clone().sub(v0), v2.clone().sub(v0)), 374 | ) 375 | const areaPBC = normal.dot( 376 | new THREE.Vector3().crossVectors( 377 | v1.clone().sub(point), 378 | v2.clone().sub(point), 379 | ), 380 | ) 381 | const areaPCA = normal.dot( 382 | new THREE.Vector3().crossVectors( 383 | v2.clone().sub(point), 384 | v0.clone().sub(point), 385 | ), 386 | ) 387 | 388 | const u = areaPBC / areaABC 389 | const v = areaPCA / areaABC 390 | const w = 1 - u - v 391 | 392 | const intensity0 = triangle.intensities[0] 393 | const intensity1 = triangle.intensities[1] 394 | const intensity2 = triangle.intensities[2] 395 | 396 | const intensityAtPoint = u * intensity0 + v * intensity1 + w * intensity2 397 | 398 | return intensityAtPoint 399 | } 400 | function calculatePolygonIntensity(vertices, intensities) { 401 | const numTriangles = vertices.length / 9 402 | let totalIntensity = 0 403 | let totalArea = 0 404 | 405 | for (let i = 0; i < numTriangles; i++) { 406 | const triangle = { 407 | a: new THREE.Vector3( 408 | vertices[i * 9], 409 | vertices[i * 9 + 1], 410 | vertices[i * 9 + 2], 411 | ), 412 | b: new THREE.Vector3( 413 | vertices[i * 9 + 3], 414 | vertices[i * 9 + 4], 415 | vertices[i * 9 + 5], 416 | ), 417 | c: new THREE.Vector3( 418 | vertices[i * 9 + 6], 419 | vertices[i * 9 + 7], 420 | vertices[i * 9 + 8], 421 | ), 422 | intensities: [ 423 | intensities[i * 3], 424 | intensities[i * 3 + 1], 425 | intensities[i * 3 + 2], 426 | ], 427 | } 428 | 429 | const triangleArea = calculateTriangleArea(triangle) 430 | const triangleIntensity = calculateTriangleIntensity(triangle) 431 | totalIntensity += triangleIntensity * triangleArea 432 | totalArea += triangleArea 433 | } 434 | 435 | const averageIntensity = totalIntensity / totalArea 436 | return averageIntensity 437 | } 438 | 439 | function calculateTriangleIntensity(triangle) { 440 | const intensities = triangle.intensities 441 | const averageIntensity = 442 | (intensities[0] + intensities[1] + intensities[2]) / 3 443 | return averageIntensity 444 | } 445 | 446 | // Takes a sequence of points [[x, y, z], ...] and 447 | // returns a sequence of triangles [[x1, y1, z1, x2, ...], ...], 448 | // making sure to generate a valid triangulation of the polygon 449 | // Highly inefficient implementation, but we don't triangulate many polygons so it should be fine 450 | export function triangulate(points) { 451 | if (points.length == 3) { 452 | return [{ a: points[0], b: points[1], c: points[2] }] 453 | } else if (points.length < 3) { 454 | return [] 455 | } 456 | 457 | // As the triangle is in 3d-space anyways, we can just assume that vertices are given in CCW order 458 | const pt = (i) => points[(i + points.length) % points.length] 459 | 460 | const ab = sub(pt(1).point, pt(0).point) 461 | const ac = sub(pt(2).point, pt(0).point) 462 | const normal = new THREE.Vector3().crossVectors(ab, ac) 463 | 464 | let countNegative = 0 465 | let countPositive = 0 466 | 467 | // Taking inspiration from a polygon triangulation based on the two ears theorem 468 | // However, in R3, things can get a bit more wonky... 469 | // https://en.wikipedia.org/wiki/Two_ears_theorem#Relation_to_triangulations 470 | const makeTriplet = (left, vertex, right) => { 471 | const det = determinant( 472 | sub(vertex.point, left.point), 473 | sub(vertex.point, right.point), 474 | normal, 475 | ) 476 | 477 | if (det > 0) { 478 | countPositive += 1 479 | } else { 480 | countNegative += 1 481 | } 482 | 483 | return { left: left, vertex: vertex, right: right, det } 484 | } 485 | 486 | const triplets = points.map((cur, i) => 487 | makeTriplet(pt(i - 1), cur, pt(i + 1)), 488 | ) 489 | 490 | if (countPositive < countNegative) { 491 | // negative det => convex vertex, so we flip all determinants 492 | for (let t of triplets) { 493 | t.det = -t.det 494 | } 495 | } 496 | 497 | const concaveVertices = triplets.filter((t) => t.det < 0).map((t) => t.vertex) 498 | 499 | let anyEar = false 500 | for (let t of triplets) { 501 | // Idea: Define the 3d analogue of a polygon ear by looking at triples and projecting the 502 | // remaining points onto the plane spanned by that particular triangle 503 | // An ear is any triangle having no concave vertices lying inside it 504 | const containedConcaveVertices = concaveVertices 505 | .filter((v) => v != t.left && v != t.vertex && v != t.right) 506 | .filter((v) => 507 | pointInsideTriangle( 508 | v.point, 509 | t.left.point, 510 | t.vertex.point, 511 | t.right.point, 512 | ), 513 | ) 514 | 515 | t.isEar = t.det > 0 && containedConcaveVertices.length == 0 516 | if (t.isEar) { 517 | anyEar = true 518 | } 519 | } 520 | 521 | // Prevent infinite loop 522 | if (!anyEar) { 523 | console.warn('No ear found in ear clipping!') 524 | triplets[0].isEar = true 525 | } 526 | 527 | for (let ear of triplets.filter((t) => t.isEar)) { 528 | const remainingPoints = triplets 529 | .filter((t) => t != ear) 530 | .map((t) => t.vertex) 531 | return [{ a: ear.left, b: ear.vertex, c: ear.right }].concat( 532 | triangulate(remainingPoints), 533 | ) 534 | } 535 | } 536 | 537 | function determinant(v1, v2, v3) { 538 | const matrix = new THREE.Matrix3() 539 | matrix.set( 540 | v1.x, 541 | v2.x, 542 | v3.x, // First column 543 | v1.y, 544 | v2.y, 545 | v3.y, // Second column 546 | v1.z, 547 | v2.z, 548 | v3.z, // Third column 549 | ) 550 | return matrix.determinant() 551 | } 552 | 553 | function sub(v1, v2) { 554 | return new THREE.Vector3().subVectors(v1, v2) 555 | } 556 | 557 | function cross(v1, v2) { 558 | return new THREE.Vector3().crossVectors(v1, v2) 559 | } 560 | 561 | export function pointInsideTriangle(point, v1, v2, v3) { 562 | const normal = cross(sub(v1, v2), sub(v2, v3)) 563 | const n1 = cross(normal, sub(v1, v2)) 564 | const n2 = cross(normal, sub(v2, v3)) 565 | const n3 = cross(normal, sub(v3, v1)) 566 | 567 | const d1 = Math.sign(n1.dot(sub(v1, point))) 568 | const d2 = Math.sign(n2.dot(sub(v2, point))) 569 | const d3 = Math.sign(n3.dot(sub(v3, point))) 570 | 571 | // Inside if all 3 have the same sign 572 | return d1 == d2 && d2 == d3 573 | } 574 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Meshes/SimulationMesh.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as THREE from 'three' 3 | 4 | const SimulationMesh = ({ meshes }) => { 5 | return ( 6 | <> 7 | {meshes.map((mesh, index) => ( 8 | 9 | 10 | 11 | ))} 12 | 13 | ) 14 | } 15 | 16 | export default SimulationMesh 17 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Meshes/SurroundingMesh.jsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | 3 | const SurroundingMesh = ({ geometries }) => { 4 | return ( 5 | <> 6 | {geometries.map((geometry, index) => ( 7 | 8 | 13 | 14 | ))} 15 | 16 | ) 17 | } 18 | 19 | export default SurroundingMesh 20 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Meshes/VegetationMesh.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import * as THREE from 'three' 3 | 4 | const VegetationMesh = ({ geometries }) => { 5 | return ( 6 | <> 7 | {geometries.map((geometry, index) => ( 8 | 9 | 15 | 16 | ))} 17 | 18 | ) 19 | } 20 | 21 | export default VegetationMesh 22 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/PointsAndEdges.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import * as THREE from 'three' 3 | 4 | const PointsAndEdges = ({ points }) => { 5 | const pointsAndEdges = useMemo(() => { 6 | // Create points 7 | const pointMeshes = points.map((point, index) => { 8 | const pointGeometry = new THREE.BufferGeometry().setFromPoints([ 9 | point.point, 10 | ]) 11 | const pointMaterial = new THREE.PointsMaterial({ 12 | color: '#333333', 13 | size: 10, 14 | sizeAttenuation: false, 15 | }) 16 | return ( 17 | 22 | ) 23 | }) 24 | 25 | // Create edges 26 | const edgeGeometry = new THREE.BufferGeometry() 27 | const edgePositions = [] 28 | for (let i = 0; i < points.length - 1; i++) { 29 | edgePositions.push( 30 | points[i].point.x, 31 | points[i].point.y, 32 | points[i].point.z, 33 | ) 34 | edgePositions.push( 35 | points[i + 1].point.x, 36 | points[i + 1].point.y, 37 | points[i + 1].point.z, 38 | ) 39 | } 40 | edgeGeometry.setAttribute( 41 | 'position', 42 | new THREE.Float32BufferAttribute(edgePositions, 3), 43 | ) 44 | const edgeMaterial = new THREE.LineBasicMaterial({ 45 | color: '#333333', 46 | }) 47 | const edges = ( 48 | 49 | ) 50 | 51 | return [...pointMeshes, edges] 52 | }, [points]) 53 | 54 | return <>{pointsAndEdges} 55 | } 56 | 57 | export default PointsAndEdges 58 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Scene.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react' 2 | import { Canvas } from 'react-three-fiber' 3 | import * as THREE from 'three' 4 | 5 | import CustomMapControl from './Controls/CustomMapControl' 6 | import DrawPVControl from './Controls/DrawPVControl' 7 | import { HighlightedPVSystem } from './Meshes/HighlitedPVSystem' 8 | import { HighlightedMesh } from './Meshes/HiglightedMesh' 9 | import { PVSystems } from './Meshes/PVSystems' 10 | import SimulationMesh from './Meshes/SimulationMesh' 11 | import SurroundingMesh from './Meshes/SurroundingMesh' 12 | import VegetationMesh from './Meshes/VegetationMesh' 13 | import Overlay from './Overlay' 14 | import PointsAndEdges from './PointsAndEdges' 15 | import Terrain from './Terrain' 16 | 17 | const Scene = ({ 18 | frontendState, 19 | setFrontendState, 20 | geometries, 21 | simulationMeshes, 22 | setSimulationMeshes, 23 | vegetationGeometries, 24 | geoLocation, 25 | }) => { 26 | // showTerrain decides if the underlying Map is visible or not 27 | const [showTerrain, setShowTerrain] = useState(true) 28 | // A list of visible PV Systems - they get visible after they are drawn on a building and calculated 29 | const [pvSystems, setPVSystems] = useState([]) 30 | // pvPoints are the red points that appear when drawing PV systems 31 | const [pvPoints, setPVPoints] = useState([]) 32 | // highlighted meshes for resimulation 33 | const [selectedMesh, setSelectedMesh] = useState([]) 34 | // highlighted PVSystems for deletion or calculation 35 | const [selectedPVSystem, setSelectedPVSystem] = useState([]) 36 | 37 | window.setPVPoints = setPVPoints 38 | const position = [ 39 | simulationMeshes[0].middle.x, 40 | simulationMeshes[0].middle.y - 40, 41 | simulationMeshes[0].middle.z + 80, 42 | ] 43 | const cameraRef = useRef() 44 | return ( 45 | <> 46 | 64 | 65 | 76 | 77 | 78 | 79 | 80 | 81 | {geometries.surrounding.length > 0 && ( 82 | 83 | )} 84 | {geometries.background.length > 0 && ( 85 | 86 | )} 87 | 88 | {simulationMeshes.length > 0 && ( 89 | 90 | )} 91 | {selectedMesh && } 92 | {selectedPVSystem && ( 93 | 94 | )} 95 | {simulationMeshes.length > 0 && frontendState == 'Results' && ( 96 | 101 | )} 102 | {frontendState == 'DrawPV' && ( 103 | 112 | )} 113 | {frontendState == 'DrawPV' && } 114 | 115 | {pvSystems.length > 0 && } 116 | 117 | {vegetationGeometries && ( 118 | <> 119 | {vegetationGeometries.background && 120 | vegetationGeometries.background.length > 0 && ( 121 | 122 | )} 123 | {vegetationGeometries.surrounding && 124 | vegetationGeometries.surrounding.length > 0 && ( 125 | 126 | )} 127 | 128 | )} 129 | 130 | {simulationMeshes.length > 0 && } 131 | 132 | 133 | ) 134 | } 135 | 136 | export default Scene 137 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Terrain.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import * as THREE from 'three' 3 | import { coordinatesXY15, xyzBounds } from '../../simulation/location' 4 | import { SONNY_DEM } from '../../simulation/elevation' 5 | 6 | /** Load an OSM map tile and return it as a THREE Mesh 7 | */ 8 | const TerrainTile = (props) => { 9 | const zoom = props.zoom 10 | const tx = props.x 11 | const ty = props.y 12 | const divisions = props.divisions 13 | 14 | const url = `https://sgx.geodatenzentrum.de/wmts_basemapde/tile/1.0.0/de_basemapde_web_raster_farbe/default/GLOBAL_WEBMERCATOR/${zoom}/${ty}/${tx}.png` 15 | 16 | let [geometry, setGeometry] = useState(null) 17 | let [material, setMaterial] = useState(null) 18 | let [meshLoaded, setMeshLoaded] = useState(false) 19 | 20 | let mesh = ( 21 | <>{meshLoaded && } 22 | ) 23 | 24 | useEffect(() => { 25 | async function fetchData() { 26 | const mapFuture = new THREE.TextureLoader().loadAsync(url) 27 | 28 | // Size of the world map in meters 29 | const [x0, y0, x1, y1] = xyzBounds(tx, ty, zoom) 30 | let vertices = [] 31 | let uvs = [] 32 | let indices = [] 33 | let i = 0 34 | 35 | const row = divisions + 1 36 | for (let ty = 0; ty <= divisions; ty++) { 37 | for (let tx = 0; tx <= divisions; tx++) { 38 | const x = x0 + (tx / divisions) * (x1 - x0) 39 | const y = y0 + (ty / divisions) * (y1 - y0) 40 | vertices.push(SONNY_DEM.toPoint3D(x, y)) 41 | // UV mapping for the texture 42 | uvs.push(tx / divisions, 1.0 - ty / divisions) 43 | // Triangle indices 44 | if (tx > 0 && ty > 0) { 45 | indices.push( 46 | i - row - 1, 47 | i - 1, 48 | i - row, // 1st triangle 49 | i - row, 50 | i - 1, 51 | i, // 2nd triangle 52 | ) 53 | } 54 | i += 1 55 | } 56 | } 57 | 58 | vertices = await Promise.all(vertices) 59 | const vertexBuffer = new Float32Array(vertices.flatMap((x) => x.point)) 60 | const normalBuffer = new Float32Array(vertices.flatMap((x) => x.normal)) 61 | const uvBuffer = new Float32Array(uvs) 62 | const indexBuffer = new Uint32Array(indices) 63 | const geometry = new THREE.BufferGeometry() 64 | geometry.setAttribute( 65 | 'position', 66 | new THREE.BufferAttribute(vertexBuffer, 3), 67 | ) 68 | geometry.setAttribute( 69 | 'normal', 70 | new THREE.BufferAttribute(normalBuffer, 3), 71 | ) 72 | geometry.setAttribute('uv', new THREE.BufferAttribute(uvBuffer, 2)) 73 | geometry.setIndex(new THREE.BufferAttribute(indexBuffer, 1)) 74 | 75 | setGeometry(geometry) 76 | const map = await mapFuture 77 | map.colorSpace = THREE.SRGBColorSpace 78 | setMaterial( 79 | new THREE.MeshBasicMaterial({ 80 | map: await mapFuture, 81 | side: THREE.FrontSide, 82 | }), 83 | ) 84 | setMeshLoaded(true) 85 | } 86 | fetchData() 87 | }, []) 88 | 89 | return mesh 90 | } 91 | 92 | const Terrain = ({ visible }) => { 93 | const [x, y] = coordinatesXY15 94 | const [tiles, setTiles] = useState([]) // State to manage tiles 95 | const tx = Math.floor(x * 16) 96 | const ty = Math.floor(y * 16) 97 | 98 | let xys = [] 99 | for (let dx = -11; dx <= 11; dx++) { 100 | for (let dy = -11; dy <= 11; dy++) { 101 | xys.push({ dx, dy, divisions: 2 }) 102 | } 103 | } 104 | 105 | xys.sort((a, b) => a.dx * a.dx + a.dy * a.dy - (b.dx * b.dx + b.dy * b.dy)) 106 | useEffect(() => { 107 | let currentTiles = [] 108 | 109 | // Function to load tiles progressively 110 | const loadTiles = (index) => { 111 | if (index < xys.length) { 112 | const { dx, dy, divisions } = xys[index] 113 | const key = `${tx + dx}-${ty + dy}-${19}` 114 | currentTiles.push( 115 | , 122 | ) 123 | 124 | setTiles([...currentTiles]) // Update the state with the new set of tiles 125 | 126 | // Schedule the next tile load 127 | setTimeout(() => loadTiles(index + 1), 0) // Adjust the timeout for desired loading speed 128 | } 129 | } 130 | 131 | loadTiles(0) // Start loading tiles 132 | 133 | return () => { 134 | setTiles([]) // Clean up on component unmount 135 | } 136 | }, [tx, ty]) // Dependency array to reset when the coordinates change 137 | 138 | return {tiles} 139 | } 140 | 141 | export default Terrain 142 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/TextSprite.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react' 2 | import * as THREE from 'three' 3 | 4 | const TextSprite = ({ text, position }) => { 5 | const spriteRef = useRef() 6 | 7 | useEffect(() => { 8 | const canvas = document.createElement('canvas') 9 | const context = canvas.getContext('2d') 10 | const canvasRatio = 7 11 | canvas.width = 128 * canvasRatio 12 | canvas.height = 128 13 | 14 | context.font = '55px Arial' 15 | context.fillStyle = 'rgba(0, 0, 0, 0.3)' 16 | context.fillRect(0, 0, canvas.width, canvas.height) 17 | 18 | const lines = text.split('\n') 19 | context.font = '55px Arial' 20 | context.fillStyle = 'white' 21 | lines.forEach((line, index) => { 22 | context.fillText(line, 10, 60 + index * 60) 23 | }) 24 | 25 | const texture = new THREE.CanvasTexture(canvas) 26 | const spriteMaterial = new THREE.SpriteMaterial({ 27 | map: texture, 28 | depthTest: false, 29 | }) 30 | 31 | spriteRef.current.material = spriteMaterial 32 | spriteRef.current.position.copy(position) 33 | spriteRef.current.scale.set(canvasRatio, 1, 1) 34 | spriteRef.current.renderOrder = 999 35 | }, [text, position]) 36 | 37 | return 38 | } 39 | 40 | export default TextSprite 41 | -------------------------------------------------------------------------------- /src/components/ui/accordion.jsx: -------------------------------------------------------------------------------- 1 | import { Accordion, HStack } from '@chakra-ui/react' 2 | import * as React from 'react' 3 | import { LuChevronDown } from 'react-icons/lu' 4 | 5 | export const AccordionItemTrigger = React.forwardRef( 6 | function AccordionItemTrigger(props, ref) { 7 | const { children, indicatorPlacement = 'end', ...rest } = props 8 | return ( 9 | 10 | {indicatorPlacement === 'start' && ( 11 | 12 | 13 | 14 | )} 15 | 16 | {children} 17 | 18 | {indicatorPlacement === 'end' && ( 19 | 20 | 21 | 22 | )} 23 | 24 | ) 25 | }, 26 | ) 27 | 28 | export const AccordionItemContent = React.forwardRef( 29 | function AccordionItemContent(props, ref) { 30 | return ( 31 | 32 | 33 | 34 | ) 35 | }, 36 | ) 37 | 38 | export const AccordionRoot = Accordion.Root 39 | export const AccordionItem = Accordion.Item 40 | -------------------------------------------------------------------------------- /src/components/ui/button.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | AbsoluteCenter, 3 | Button as ChakraButton, 4 | Span, 5 | Spinner, 6 | } from '@chakra-ui/react' 7 | import * as React from 'react' 8 | 9 | export const Button = React.forwardRef(function Button(props, ref) { 10 | const { loading, disabled, loadingText, children, ...rest } = props 11 | return ( 12 | 13 | {loading && !loadingText ? ( 14 | <> 15 | 16 | 17 | 18 | {children} 19 | 20 | ) : loading && loadingText ? ( 21 | <> 22 | 23 | {loadingText} 24 | 25 | ) : ( 26 | children 27 | )} 28 | 29 | ) 30 | }) 31 | -------------------------------------------------------------------------------- /src/components/ui/close-button.jsx: -------------------------------------------------------------------------------- 1 | function _nullishCoalesce(lhs, rhsFn) { 2 | if (lhs != null) { 3 | return lhs 4 | } else { 5 | return rhsFn() 6 | } 7 | } 8 | import { IconButton as ChakraIconButton } from '@chakra-ui/react' 9 | import * as React from 'react' 10 | import { LuX } from 'react-icons/lu' 11 | 12 | export const CloseButton = React.forwardRef(function CloseButton(props, ref) { 13 | return ( 14 | 15 | {_nullishCoalesce(props.children, () => ( 16 | 17 | ))} 18 | 19 | ) 20 | }) 21 | -------------------------------------------------------------------------------- /src/components/ui/color-mode.jsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ClientOnly, IconButton, Skeleton } from '@chakra-ui/react' 4 | import { ThemeProvider, useTheme } from 'next-themes' 5 | 6 | import * as React from 'react' 7 | import { LuMoon, LuSun } from 'react-icons/lu' 8 | 9 | export function ColorModeProvider(props) { 10 | return ( 11 | 12 | ) 13 | } 14 | 15 | export function useColorMode() { 16 | const { resolvedTheme, setTheme } = useTheme() 17 | const toggleColorMode = () => { 18 | setTheme(resolvedTheme === 'light' ? 'dark' : 'light') 19 | } 20 | return { 21 | colorMode: resolvedTheme, 22 | setColorMode: setTheme, 23 | toggleColorMode, 24 | } 25 | } 26 | 27 | export function useColorModeValue(light, dark) { 28 | const { colorMode } = useColorMode() 29 | return colorMode === 'dark' ? dark : light 30 | } 31 | 32 | export function ColorModeIcon() { 33 | const { colorMode } = useColorMode() 34 | return colorMode === 'dark' ? : 35 | } 36 | 37 | export const ColorModeButton = React.forwardRef( 38 | function ColorModeButton(props, ref) { 39 | const { toggleColorMode } = useColorMode() 40 | return ( 41 | }> 42 | 56 | 57 | 58 | 59 | ) 60 | }, 61 | ) 62 | -------------------------------------------------------------------------------- /src/components/ui/data-list.jsx: -------------------------------------------------------------------------------- 1 | import { DataList as ChakraDataList, Link } from '@chakra-ui/react' 2 | import * as React from 'react' 3 | 4 | export const DataListRoot = ChakraDataList.Root 5 | 6 | export const DataListItem = React.forwardRef(function DataListItem(props, ref) { 7 | const { label, info, value, href, children, grow, ...rest } = props 8 | return ( 9 | 10 | 11 | {label} 12 | 13 | 14 | 15 | {value} 16 | 17 | 18 | {children} 19 | 20 | ) 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/ui/dialog.jsx: -------------------------------------------------------------------------------- 1 | import { Dialog as ChakraDialog, Portal } from '@chakra-ui/react' 2 | import { CloseButton } from './close-button' 3 | import * as React from 'react' 4 | 5 | export const DialogContent = React.forwardRef( 6 | function DialogContent(props, ref) { 7 | const { 8 | children, 9 | portalled = true, 10 | portalRef, 11 | backdrop = true, 12 | ...rest 13 | } = props 14 | 15 | return ( 16 | 17 | {backdrop && } 18 | 19 | 20 | {children} 21 | 22 | 23 | 24 | ) 25 | }, 26 | ) 27 | 28 | export const DialogCloseTrigger = React.forwardRef( 29 | function DialogCloseTrigger(props, ref) { 30 | return ( 31 | 38 | 39 | {props.children} 40 | 41 | 42 | ) 43 | }, 44 | ) 45 | 46 | export const DialogRoot = ChakraDialog.Root 47 | export const DialogFooter = ChakraDialog.Footer 48 | export const DialogHeader = ChakraDialog.Header 49 | export const DialogBody = ChakraDialog.Body 50 | export const DialogBackdrop = ChakraDialog.Backdrop 51 | export const DialogTitle = ChakraDialog.Title 52 | export const DialogDescription = ChakraDialog.Description 53 | export const DialogTrigger = ChakraDialog.Trigger 54 | export const DialogActionTrigger = ChakraDialog.ActionTrigger 55 | -------------------------------------------------------------------------------- /src/components/ui/field.jsx: -------------------------------------------------------------------------------- 1 | import { Field as ChakraField } from '@chakra-ui/react' 2 | import * as React from 'react' 3 | 4 | export const Field = React.forwardRef(function Field(props, ref) { 5 | const { label, children, helperText, errorText, optionalText, ...rest } = 6 | props 7 | return ( 8 | 9 | {label && ( 10 | 11 | {label} 12 | 13 | 14 | )} 15 | {children} 16 | {helperText && ( 17 | {helperText} 18 | )} 19 | {errorText && {errorText}} 20 | 21 | ) 22 | }) 23 | -------------------------------------------------------------------------------- /src/components/ui/number-input.jsx: -------------------------------------------------------------------------------- 1 | import { NumberInput as ChakraNumberInput } from '@chakra-ui/react' 2 | import * as React from 'react' 3 | 4 | export const NumberInputRoot = React.forwardRef( 5 | function NumberInput(props, ref) { 6 | const { children, ...rest } = props 7 | return ( 8 | 9 | {children} 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | }, 17 | ) 18 | 19 | export const NumberInputField = ChakraNumberInput.Input 20 | export const NumberInputScrubber = ChakraNumberInput.Scrubber 21 | export const NumberInputLabel = ChakraNumberInput.Label 22 | -------------------------------------------------------------------------------- /src/components/ui/progress.jsx: -------------------------------------------------------------------------------- 1 | import { Progress as ChakraProgress } from '@chakra-ui/react' 2 | import * as React from 'react' 3 | 4 | export const ProgressBar = React.forwardRef(function ProgressBar(props, ref) { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | }) 11 | 12 | export const ProgressLabel = React.forwardRef( 13 | function ProgressLabel(props, ref) { 14 | const { children, info, ...rest } = props 15 | return ( 16 | 17 | {children} 18 | 19 | ) 20 | }, 21 | ) 22 | 23 | export const ProgressRoot = ChakraProgress.Root 24 | export const ProgressValueText = ChakraProgress.ValueText 25 | -------------------------------------------------------------------------------- /src/components/ui/provider.jsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ChakraProvider, defaultSystem } from '@chakra-ui/react' 4 | import { ColorModeProvider } from './color-mode' 5 | 6 | export function Provider(props) { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/ui/slider.jsx: -------------------------------------------------------------------------------- 1 | function _nullishCoalesce(lhs, rhsFn) { 2 | if (lhs != null) { 3 | return lhs 4 | } else { 5 | return rhsFn() 6 | } 7 | } 8 | function _optionalChain(ops) { 9 | let lastAccessLHS = undefined 10 | let value = ops[0] 11 | let i = 1 12 | while (i < ops.length) { 13 | const op = ops[i] 14 | const fn = ops[i + 1] 15 | i += 2 16 | if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { 17 | return undefined 18 | } 19 | if (op === 'access' || op === 'optionalAccess') { 20 | lastAccessLHS = value 21 | value = fn(value) 22 | } else if (op === 'call' || op === 'optionalCall') { 23 | value = fn((...args) => value.call(lastAccessLHS, ...args)) 24 | lastAccessLHS = undefined 25 | } 26 | } 27 | return value 28 | } 29 | import { Slider as ChakraSlider, For, HStack } from '@chakra-ui/react' 30 | import * as React from 'react' 31 | 32 | export const Slider = React.forwardRef(function Slider(props, ref) { 33 | const { marks: marksProp, label, showValue, ...rest } = props 34 | const value = _nullishCoalesce(props.defaultValue, () => props.value) 35 | 36 | const marks = _optionalChain([ 37 | marksProp, 38 | 'optionalAccess', 39 | (_2) => _2.map, 40 | 'call', 41 | (_3) => 42 | _3((mark) => { 43 | if (typeof mark === 'number') return { value: mark, label: undefined } 44 | return mark 45 | }), 46 | ]) 47 | 48 | const hasMarkLabel = !!_optionalChain([ 49 | marks, 50 | 'optionalAccess', 51 | (_4) => _4.some, 52 | 'call', 53 | (_5) => _5((mark) => mark.label), 54 | ]) 55 | 56 | return ( 57 | 58 | {label && !showValue && {label}} 59 | {label && showValue && ( 60 | 61 | {label} 62 | 63 | 64 | )} 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ) 74 | }) 75 | 76 | function SliderThumbs(props) { 77 | const { value } = props 78 | return ( 79 | 80 | {(_, index) => ( 81 | 82 | 83 | 84 | )} 85 | 86 | ) 87 | } 88 | 89 | const SliderMarks = React.forwardRef(function SliderMarks(props, ref) { 90 | const { marks } = props 91 | if (!_optionalChain([marks, 'optionalAccess', (_6) => _6.length])) return null 92 | 93 | return ( 94 | 95 | {marks.map((mark, index) => { 96 | const value = typeof mark === 'number' ? mark : mark.value 97 | const label = typeof mark === 'number' ? undefined : mark.label 98 | return ( 99 | 100 | 101 | {label} 102 | 103 | ) 104 | })} 105 | 106 | ) 107 | }) 108 | -------------------------------------------------------------------------------- /src/components/ui/switch.jsx: -------------------------------------------------------------------------------- 1 | function _optionalChain(ops) { 2 | let lastAccessLHS = undefined 3 | let value = ops[0] 4 | let i = 1 5 | while (i < ops.length) { 6 | const op = ops[i] 7 | const fn = ops[i + 1] 8 | i += 2 9 | if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { 10 | return undefined 11 | } 12 | if (op === 'access' || op === 'optionalAccess') { 13 | lastAccessLHS = value 14 | value = fn(value) 15 | } else if (op === 'call' || op === 'optionalCall') { 16 | value = fn((...args) => value.call(lastAccessLHS, ...args)) 17 | lastAccessLHS = undefined 18 | } 19 | } 20 | return value 21 | } 22 | import { Switch as ChakraSwitch } from '@chakra-ui/react' 23 | import * as React from 'react' 24 | 25 | export const Switch = React.forwardRef(function Switch(props, ref) { 26 | const { inputProps, children, rootRef, trackLabel, thumbLabel, ...rest } = 27 | props 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | {thumbLabel && ( 35 | _.off, 40 | ])} 41 | > 42 | {_optionalChain([thumbLabel, 'optionalAccess', (_2) => _2.on])} 43 | 44 | )} 45 | 46 | {trackLabel && ( 47 | 48 | {trackLabel.on} 49 | 50 | )} 51 | 52 | {children != null && {children}} 53 | 54 | ) 55 | }) 56 | -------------------------------------------------------------------------------- /src/components/ui/toaster.jsx: -------------------------------------------------------------------------------- 1 | function _optionalChain(ops) { 2 | let lastAccessLHS = undefined 3 | let value = ops[0] 4 | let i = 1 5 | while (i < ops.length) { 6 | const op = ops[i] 7 | const fn = ops[i + 1] 8 | i += 2 9 | if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { 10 | return undefined 11 | } 12 | if (op === 'access' || op === 'optionalAccess') { 13 | lastAccessLHS = value 14 | value = fn(value) 15 | } else if (op === 'call' || op === 'optionalCall') { 16 | value = fn((...args) => value.call(lastAccessLHS, ...args)) 17 | lastAccessLHS = undefined 18 | } 19 | } 20 | return value 21 | } 22 | ;('use client') 23 | 24 | import { 25 | Toaster as ChakraToaster, 26 | Portal, 27 | Spinner, 28 | Stack, 29 | Toast, 30 | createToaster, 31 | } from '@chakra-ui/react' 32 | 33 | export const toaster = createToaster({ 34 | placement: 'bottom-end', 35 | pauseOnPageIdle: true, 36 | }) 37 | 38 | export const Toaster = () => { 39 | return ( 40 | 41 | 42 | {(toast) => ( 43 | 44 | {toast.type === 'loading' ? ( 45 | 46 | ) : ( 47 | 48 | )} 49 | 50 | {toast.title && {toast.title}} 51 | {toast.description && ( 52 | {toast.description} 53 | )} 54 | 55 | {toast.action && ( 56 | {toast.action.label} 57 | )} 58 | {_optionalChain([ 59 | toast, 60 | 'access', 61 | (_) => _.meta, 62 | 'optionalAccess', 63 | (_2) => _2.closable, 64 | ]) && } 65 | 66 | )} 67 | 68 | 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/data/constants.js: -------------------------------------------------------------------------------- 1 | export const c0 = [0, 0, 0.2] 2 | export const c1 = [1, 0.2, 0.1] 3 | export const c2 = [1, 1, 0.1] 4 | -------------------------------------------------------------------------------- /src/data/dataLicense.js: -------------------------------------------------------------------------------- 1 | export const attributions = { 2 | BB: { 3 | attribution: 'GeoBasis-DE/LGB', 4 | license: 'dl-de/by-2-0', 5 | link: 'https://geoportal.brandenburg.de/', 6 | }, 7 | BY: { 8 | attribution: 'Bayerische Vermessungsverwaltung – www.geodaten.bayern.de', 9 | license: 'cc/by-4-0', 10 | link: 'https://geodaten.bayern.de/opengeodata/OpenDataDetail.html?pn=lod2', 11 | }, 12 | BW: { 13 | attribution: 'Datenquelle: LGL, www.lgl-bw.de', 14 | license: 'dl-de/by-2-0', 15 | link: 'https://www.lgl-bw.de/Produkte/3D-Produkte/3D-Gebaeudemodelle/', 16 | }, 17 | BE: { 18 | attribution: 19 | 'Geoportal Berlin / 3D-Gebäudemodelle im Level of Detail 2 (LoD 2)', 20 | license: 'dl-de/by-2-0', 21 | link: 'https://www.berlin.de/sen/sbw/stadtdaten/geoportal/geoportal-daten-und-dienste/', 22 | }, 23 | HB: { 24 | attribution: 'Landesamt GeoInformation Bremen', 25 | license: 'cc/by-4-0', 26 | link: 'https://geoportal.bremen.de/geoportal/', 27 | }, 28 | HE: { 29 | attribution: 'Hessische Verwaltung für Bodenmanagement und Geoinformation', 30 | license: 'dl-de/zero-2-0', 31 | link: 'https://gds.hessen.de/INTERSHOP/web/WFS/HLBG-Geodaten-Site/de_DE/-/EUR/ViewDownloadcenter-Start?path=3D-Daten/3D-Geb%C3%A4udemodelle/3D-Geb%C3%A4udemodelle%20LoD2', 32 | }, 33 | HH: { 34 | attribution: 35 | 'Freie und Hansestadt Hamburg, Landesbetrieb Geoinformation und Vermessung (LGV)', 36 | license: 'dl-de/by-2-0', 37 | link: 'https://metaver.de/trefferanzeige?docuuid=2C1F2EEC-CF9F-4D8B-ACAC-79D8C1334D5E&q=3D-Geb%C3%A4udemodell+LoD2&f=type%3Aopendata%3B', 38 | }, 39 | MV: { 40 | attribution: 'GeoBasis-DE/M-V', 41 | license: 'cc/by-4-0', 42 | link: 'https://www.geoportal-mv.de/portal/Geowebdienste/INSPIRE-Themen/Gebaeude', 43 | }, 44 | NI: { 45 | attribution: 'Quelle: LGLN 2024', 46 | license: 'cc/by-4-0', 47 | link: 'https://metaver.de/trefferanzeige?docuuid=6c1ab9c0-02c0-4f0d-98af-caf9fec83cc3&q=3D-Geb%C3%A4udemodell+LoD2&rstart=10&f=type%3Aopendata%3B', 48 | }, 49 | NW: { 50 | attribution: 'Geobasis NRW', 51 | license: 'dl-de/zero-2-0', 52 | link: 'https://www.geoportal.nrw/?activetab=map#/datasets/iso/5d9a8abc-dfd0-4dda-b8fa-165cce4d8065', 53 | }, 54 | SH: { 55 | attribution: 'GeoBasis-DE/LVermGeo SH', 56 | license: 'cc/by-4-0', 57 | link: 'https://geodaten.schleswig-holstein.de/gaialight-sh/_apps/dladownload/dl-lod2.html', 58 | }, 59 | SL: { 60 | attribution: 'GeoBasis DE/LVGL-SL (2024)', 61 | license: 'dl-de/by-2-0', 62 | link: 'https://geoportal.saarland.de/spatial-objects/407', 63 | }, 64 | SN: { 65 | attribution: 'Landesamt für Geobasisinformation Sachsen (GeoSN)', 66 | license: 'dl-de/by-2-0', 67 | link: 'https://www.geodaten.sachsen.de/downloadbereich-digitale-3d-stadtmodelle-4875.html', 68 | }, 69 | ST: { 70 | attribution: 'GeoBasis-DE/LVermGeo ST', 71 | license: 'dl-de/by-2-0', 72 | link: 'https://metaver.de/trefferanzeige?docuuid=4D2501AB-6888-4B8A-A706-6B0755947B13&q=3D-Geb%C3%A4udemodell+LoD2&f=type%3Aopendata%3B', 73 | }, 74 | TH: { 75 | attribution: 'GDI-Th', 76 | license: 'dl-de/by-2-0', 77 | link: 'https://geoportal.thueringen.de/gdi-th/download-offene-geodaten/download-3d-gebaeudedaten', 78 | }, 79 | RP: { 80 | attribution: 'GeoBasis-DE/LVermGeoRP (2024)', 81 | license: 'dl-de/by-2-0', 82 | link: 'https://metaportal.rlp.de/gui/html/0b28684d-b2ce-4b0b-b080-928025588c61', 83 | }, 84 | } 85 | 86 | export const licenseLinks = { 87 | 'dl-de/by-2-0': 'https://www.govdata.de/dl-de/by-2-0', 88 | 'dl-de/zero-2-0': 'https://www.govdata.de/dl-de/zero-2-0', 89 | 'cc/by-4-0': 'https://creativecommons.org/licenses/by/4.0/deed', 90 | 'cc/by-3-0': 'https://creativecommons.org/licenses/by/3.0/deed', 91 | } 92 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next' 2 | import { initReactI18next } from 'react-i18next' 3 | 4 | import Backend from 'i18next-http-backend' 5 | 6 | i18n 7 | // load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales) 8 | // learn more: https://github.com/i18next/i18next-http-backend 9 | // want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn 10 | .use(Backend) 11 | .use(initReactI18next) 12 | // init i18next 13 | // for all options read: https://www.i18next.com/overview/configuration-options 14 | .init({ 15 | fallbackLng: 'de', 16 | }) 17 | 18 | export default i18n 19 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import { Provider } from '@/components/ui/provider' 2 | import React, { Suspense, lazy } from 'react' 3 | import { createRoot, hydrateRoot } from 'react-dom/client' 4 | import { BrowserRouter, Route, Routes } from 'react-router-dom' 5 | import './i18n' // needs to be bundled 6 | import Main from './Main' // fallback for lazy pages 7 | import './static/css/main.css' // All of our styles 8 | 9 | const { PUBLIC_URL } = process.env 10 | 11 | // Every route - we lazy load so that each page can be chunked 12 | // NOTE that some of these chunks are very small. We should optimize 13 | // which pages are lazy loaded in the future. 14 | const Map = lazy(() => import('./pages/Map')) 15 | const Simulation = lazy(() => import('./pages/Simulation')) 16 | const NotFound = lazy(() => import('./pages/NotFound')) 17 | const Impressum = lazy(() => import('./pages/Impressum')) 18 | const Datenschutz = lazy(() => import('./pages/Datenschutz')) 19 | const About = lazy(() => import('./pages/About')) 20 | 21 | window.isTouchDevice = isTouchDevice() 22 | 23 | // See https://reactjs.org/docs/strict-mode.html 24 | const StrictApp = () => ( 25 | 26 | 27 | }> 28 | 29 | } /> 30 | } /> 31 | } /> 32 | } /> 33 | } /> 34 | } /> 35 | } /> 36 | 37 | 38 | 39 | 40 | ) 41 | 42 | const rootElement = document.getElementById('root') 43 | 44 | // hydrate is required by react-snap. 45 | if (rootElement.hasChildNodes()) { 46 | hydrateRoot(rootElement, ) 47 | } else { 48 | const root = createRoot(rootElement) 49 | root.render() 50 | } 51 | 52 | function isTouchDevice() { 53 | const isTouch = 54 | 'ontouchstart' in window || 55 | navigator.maxTouchPoints > 0 || 56 | navigator.msMaxTouchPoints > 0 57 | const isCoarse = window.matchMedia('(pointer: coarse)').matches 58 | if (isTouch && isCoarse) { 59 | console.log('The device is of type touch.') 60 | } else { 61 | console.log('The device is a laptop.') 62 | } 63 | return isTouch && isCoarse 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/About.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | AccordionItem, 3 | AccordionItemContent, 4 | AccordionItemTrigger, 5 | AccordionRoot, 6 | } from '@/components/ui/accordion' 7 | import { 8 | Box, 9 | Card, 10 | Heading, 11 | Image, 12 | Link, 13 | SimpleGrid, 14 | Text, 15 | } from '@chakra-ui/react' 16 | import React from 'react' 17 | import { useTranslation } from 'react-i18next' 18 | import Footer from '../components/Footer' 19 | 20 | import Main from '../Main' 21 | 22 | const About = () => { 23 | const { t } = useTranslation() 24 | return ( 25 | <> 26 |
27 | 28 | 29 | {t('about.title')} 30 | 31 | 32 | {t('about.introduction')} 33 | 34 | 35 | 36 | {t('about.generalDescription.h')} 37 | 38 | 39 |

{t('about.generalDescription.p')}

40 | 41 | {t('about.steps.introduction')} 42 | 43 | 44 |
  • {t('about.steps.1')}
  • 45 |
  • {t('about.steps.2')}
  • 46 |
  • {t('about.steps.3')}
  • 47 |
  • {t('about.steps.4')}
  • 48 |
    49 | 61 |
    62 |
    63 | 64 | {t('about.data.h')} 65 | 66 | {t('about.data.p1')}{' '} 67 | 72 | {'[CC-BY-4.0]'} 73 | 74 | {', '} 75 | {t('about.data.p2')}{' '} 76 | 77 | {'[CC-BY-4.0]'} 78 | 79 | {', '} 80 | {t('about.data.p3')}{' '} 81 | 86 | {'[DL-DE/BY-2-0]'} 87 | 88 | {'. '} 89 | 90 | 91 | 92 | 93 | {t('about.whyOpenSource.h')} 94 | 95 | 96 | {t('about.whyOpenSource.p')} 97 | 98 | 99 | 100 | {t('about.team.h')} 101 | 102 |

    {t('about.team.p')}

    103 | 108 | {t('about.team.link')} 109 | 110 |
    111 |
    112 | 113 | 114 | 115 | {t('about.sponsors.h')} 116 | 117 | 118 |

    {t('about.sponsors.p')}

    119 | 128 |
    129 |
    130 |
    131 |
    132 |
    133 |
    134 |