├── public ├── robots.txt ├── images │ ├── about │ │ ├── bmbf.jpg │ │ ├── ptf.png │ │ └── about1.png │ ├── WelcomeMessage0.png │ ├── WelcomeMessage1.png │ ├── WelcomeMessage2.png │ ├── WelcomeMessage3.png │ └── favicon │ │ ├── favicon.ico │ │ ├── mstile-70x70.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-144x144.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x150.png │ │ ├── mstile-310x310.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── browserconfig.xml │ │ ├── site.webmanifest │ │ └── safari-pinned-tab.svg ├── draco │ ├── draco_decoder.wasm │ ├── gltf │ │ └── draco_decoder.wasm │ └── README.md ├── sitemap.xml └── locales │ ├── en │ └── translation.json │ └── de │ └── translation.json ├── src ├── components │ ├── context.jsx │ ├── ui │ │ ├── provider.jsx │ │ ├── close-button.jsx │ │ ├── progress.jsx │ │ ├── field.jsx │ │ ├── number-input.jsx │ │ ├── data-list.jsx │ │ ├── button.jsx │ │ ├── accordion.jsx │ │ ├── color-mode.jsx │ │ ├── dialog.jsx │ │ ├── switch.jsx │ │ └── toaster.jsx │ ├── ErrorMessages │ │ └── WrongAdress.jsx │ ├── ThreeViewer │ │ ├── Meshes │ │ │ ├── VegetationMesh.jsx │ │ │ ├── HighlitedPVSystem.jsx │ │ │ ├── BuildingMesh.jsx │ │ │ └── PVSystems.jsx │ │ ├── TextSprite.jsx │ │ ├── PointsAndEdges.jsx │ │ ├── Scene.jsx │ │ ├── Terrain.jsx │ │ └── Controls │ │ │ ├── CustomMapControl.jsx │ │ │ └── DrawPVControl.jsx │ ├── Template │ │ ├── Navigation.jsx │ │ ├── LoadingBar.jsx │ │ └── WelcomeMessage.jsx │ ├── MapPopup.jsx │ ├── Footer.jsx │ └── PVSimulation │ │ ├── SearchField.jsx │ │ └── SavingsCalculation.jsx ├── data │ ├── constants.js │ └── dataLicense.js ├── pages │ ├── NotFound.jsx │ ├── Simulation.jsx │ ├── Map.jsx │ ├── Impressum.jsx │ ├── About.jsx │ └── Datenschutz.jsx ├── i18n.js ├── static │ └── css │ │ └── main.css ├── simulation │ ├── preprocessing.js │ ├── location.js │ ├── main.js │ ├── processVegetationTiffs.js │ ├── download.js │ └── elevation.js ├── Main.jsx └── index.jsx ├── .prettierrc.yml ├── .vscode └── settings.json ├── .github ├── dependabot.yml └── workflows │ ├── linting.yml │ ├── build.yml │ └── test-build.yml ├── .devcontainer └── devcontainer.json ├── vite.config.js ├── RELEASE-PROCEDURE.md ├── .gitignore ├── package.json ├── index.html └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /data 3 | 4 | Sitemap: https://openpv.de/sitemap.xml -------------------------------------------------------------------------------- /public/images/about/bmbf.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/about/bmbf.jpg -------------------------------------------------------------------------------- /public/images/about/ptf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/about/ptf.png -------------------------------------------------------------------------------- /public/draco/draco_decoder.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/draco/draco_decoder.wasm -------------------------------------------------------------------------------- /public/images/about/about1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/about/about1.png -------------------------------------------------------------------------------- /public/images/WelcomeMessage0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/WelcomeMessage0.png -------------------------------------------------------------------------------- /public/images/WelcomeMessage1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/WelcomeMessage1.png -------------------------------------------------------------------------------- /public/images/WelcomeMessage2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/WelcomeMessage2.png -------------------------------------------------------------------------------- /public/images/WelcomeMessage3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/WelcomeMessage3.png -------------------------------------------------------------------------------- /public/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/favicon.ico -------------------------------------------------------------------------------- /public/draco/gltf/draco_decoder.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/draco/gltf/draco_decoder.wasm -------------------------------------------------------------------------------- /public/images/favicon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/mstile-70x70.png -------------------------------------------------------------------------------- /src/components/context.jsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | export const SceneContext = createContext(null) 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/mstile-144x144.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/mstile-310x150.png -------------------------------------------------------------------------------- /public/images/favicon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/mstile-310x310.png -------------------------------------------------------------------------------- /public/images/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | jsxSingleQuote: true 4 | endOfLine: 'lf' 5 | trailingComma: 'all' 6 | tabWidth: 2 7 | -------------------------------------------------------------------------------- /public/images/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/images/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-pv/website/HEAD/public/images/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[css]": { 3 | "editor.suggest.insertMode": "replace" 4 | }, 5 | "editor.formatOnSave": true, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | } 8 | -------------------------------------------------------------------------------- /public/images/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2f728f 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OpenPV", 3 | "image": "mcr.microsoft.com/devcontainers/javascript-node:latest", 4 | "features": { 5 | "ghcr.io/devcontainers/features/github-cli:1": {}, 6 | "ghcr.io/stuartleeks/dev-container-features/shell-history:0": {}, 7 | "ghcr.io/devcontainers/features/node:1": { 8 | "version": "lts", 9 | "nvmVersion": "latest" 10 | } 11 | }, 12 | "postCreateCommand": "npm install", 13 | "forwardPorts": [5173], 14 | "workspaceFolder": "/workspace", 15 | "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind" 16 | } 17 | -------------------------------------------------------------------------------- /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/pages/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Helmet, HelmetProvider } from 'react-helmet-async' 3 | import { Link } from 'react-router-dom' 4 | 5 | const PageNotFound = () => ( 6 | 7 |
8 | 9 | 13 | 14 |

Page Not Found

15 |

16 | Return home. 17 |

18 |
19 |
20 | ) 21 | 22 | export default PageNotFound 23 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Meshes/HighlitedPVSystem.jsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import * as THREE from 'three' 3 | import { SceneContext } from '../../context' 4 | 5 | export function HighlightedPVSystem() { 6 | const sceneContext = useContext(SceneContext) 7 | return ( 8 | <> 9 | {sceneContext.selectedPVSystem.map((geometry, index) => ( 10 | 20 | ))} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import path from 'path' 3 | import { defineConfig, loadEnv } from 'vite' 4 | 5 | export default defineConfig(({ mode }) => { 6 | const env = loadEnv(mode, process.cwd(), '') 7 | return { 8 | define: { 9 | 'process.env.PUBLIC_URL': JSON.stringify(env.PUBLIC_URL || ''), 10 | }, 11 | plugins: [react()], 12 | resolve: { 13 | alias: { 14 | '@': path.resolve(__dirname, './src'), 15 | }, 16 | }, 17 | build: { 18 | outDir: 'dist', 19 | }, 20 | server: { 21 | host: true, 22 | port: Number(env.PORT) || 5173, 23 | // Allow other devices (devcontainer / host) to connect 24 | strictPort: false, 25 | }, 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /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/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/ThreeViewer/Meshes/BuildingMesh.jsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | /** 3 | * Renders building. 4 | * 5 | * - If `building.type` == "simulation", it is rendered as‑is. 6 | * - Otherwise a simple `` with the supplied geometry and a single 7 | * Lambert material is created. 8 | */ 9 | export const BuildingMesh = ({ building }) => { 10 | if (building.type == 'simulation') 11 | return 12 | 13 | // Fallback: create a basic mesh from the geometry for surrounding buildings. 14 | return ( 15 | 16 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | .aider* 51 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/static/css/main.css: -------------------------------------------------------------------------------- 1 | .content { 2 | display: grid; 3 | flex-grow: 2; 4 | min-width: 0; 5 | min-height: 0; 6 | overflow: hidden; 7 | } 8 | 9 | .content > * { 10 | grid-row: 1; 11 | grid-column: 1; 12 | min-width: 0; 13 | min-height: 0; 14 | overflow: hidden; 15 | } 16 | 17 | .attribution { 18 | color: #3c3b3b; 19 | font-family: ('Raleway', Helvetica, sans-serif); 20 | font-size: 0.5em; 21 | font-weight: 400; 22 | letter-spacing: 0.25em; 23 | text-transform: uppercase; 24 | padding: 10px; 25 | background-color: #ffffffa0; 26 | width: fit-content; 27 | font-size: 0.6em; 28 | display: flex; 29 | flex-direction: column; 30 | gap: 10px; 31 | min-width: 0; 32 | min-height: 0; 33 | overflow: hidden; 34 | position: fixed; 35 | bottom: 0; 36 | left: 0; 37 | } 38 | 39 | p.copyright { 40 | margin: 0; 41 | } 42 | 43 | .mapview { 44 | width: 100%; 45 | height: 100%; 46 | } 47 | 48 | .error-message { 49 | padding: 10px; 50 | display: flex; 51 | flex-direction: column; 52 | align-items: center; 53 | } 54 | 55 | button.maplibregl-popup-close-button { 56 | font-size: 2em; 57 | padding: 5px; 58 | } 59 | 60 | .maplibregl-popup-content { 61 | font-size: 1.5em; 62 | } 63 | 64 | #footer-on-hover { 65 | right: 0; 66 | left: auto; 67 | z-index: 9999; 68 | } 69 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.7.3", 17 | "i18next-http-backend": "^3.0.2", 18 | "maplibre-gl": "^4.7.1", 19 | "next-themes": "^0.4.4", 20 | "proj4": "^2.15.0", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-helmet-async": "^2.0.5", 24 | "react-i18next": "^15.5.1", 25 | "react-icons": "^5.4.0", 26 | "react-map-gl": "^7.1.8", 27 | "react-router-dom": "^7.5.2", 28 | "react-three-fiber": "^6.0.13", 29 | "three": "^0.172.0" 30 | }, 31 | "devDependencies": { 32 | "@vitejs/plugin-react": "^4.3.4", 33 | "gh-pages": "^6.3.0", 34 | "prettier": "3.5.3", 35 | "vite": "^6.4.1" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 16 | 22 | 28 | 29 | 34 | 35 | 36 | 40 | 41 | 42 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/simulation/preprocessing.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | 3 | /** 4 | * Process an array of building objects and assign a `type` to each one. 5 | * 6 | * Input: 7 | * buildings: [ 8 | * { id: Number, type: 'background', geometry: THREE.BufferGeometry }, 9 | * ... 10 | * ] 11 | * 12 | * The function calculates the centre of each geometry, determines its distance 13 | * from the simulation centre, and updates the `type` field to one of: 14 | * - 'simulation' (inside the simulation radius) 15 | * - 'surrounding' (inside the shading cutoff but outside the simulation radius) 16 | * - 'background' (outside the shading cutoff) 17 | * 18 | * The same building objects are returned (mutated in‑place) so that a single 19 | * state can hold all building information. 20 | */ 21 | export function processGeometries(buildings, simulationCenter, shadingCutoff) { 22 | const simulationRadius = 10 23 | const simulationRadius2 = simulationRadius * simulationRadius 24 | const cutoff2 = shadingCutoff * shadingCutoff 25 | 26 | // Step 1: compute bounding boxes and centre points for each building 27 | for (let b of buildings) { 28 | b.geometry.computeBoundingBox() 29 | const center = new THREE.Vector3() 30 | b.geometry.boundingBox.getCenter(center) 31 | b._center = center // store temporarily for later distance checks 32 | } 33 | 34 | // Step 2 – assign type based on distance from the simulation centre 35 | for (let b of buildings) { 36 | const d2 = 37 | (b._center.x - simulationCenter.x) ** 2 + 38 | (b._center.y - simulationCenter.y) ** 2 39 | 40 | if (d2 <= simulationRadius2) { 41 | b.type = 'simulation' 42 | } else if (d2 <= cutoff2) { 43 | b.type = 'surrounding' 44 | } else { 45 | b.type = 'background' 46 | } 47 | delete b._center 48 | } 49 | return buildings 50 | } 51 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/PointsAndEdges.jsx: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react' 2 | import * as THREE from 'three' 3 | import { SceneContext } from '../context' 4 | 5 | const PointsAndEdges = () => { 6 | const sceneContext = useContext(SceneContext) 7 | const pointsAndEdges = useMemo(() => { 8 | // Create points 9 | const pointMeshes = sceneContext.pvPoints.map((point, index) => { 10 | const pointGeometry = new THREE.BufferGeometry().setFromPoints([ 11 | point.point, 12 | ]) 13 | const pointMaterial = new THREE.PointsMaterial({ 14 | color: '#333333', 15 | size: 10, 16 | sizeAttenuation: false, 17 | }) 18 | return ( 19 | 24 | ) 25 | }) 26 | 27 | // Create edges 28 | const edgeGeometry = new THREE.BufferGeometry() 29 | const edgePositions = [] 30 | for (let i = 0; i < sceneContext.pvPoints.length - 1; i++) { 31 | edgePositions.push( 32 | sceneContext.pvPoints[i].point.x, 33 | sceneContext.pvPoints[i].point.y, 34 | sceneContext.pvPoints[i].point.z, 35 | ) 36 | edgePositions.push( 37 | sceneContext.pvPoints[i + 1].point.x, 38 | sceneContext.pvPoints[i + 1].point.y, 39 | sceneContext.pvPoints[i + 1].point.z, 40 | ) 41 | } 42 | edgeGeometry.setAttribute( 43 | 'position', 44 | new THREE.Float32BufferAttribute(edgePositions, 3), 45 | ) 46 | const edgeMaterial = new THREE.LineBasicMaterial({ 47 | color: '#333333', 48 | }) 49 | const edges = ( 50 | 51 | ) 52 | 53 | return [...pointMeshes, edges] 54 | }, [sceneContext.pvPoints]) 55 | 56 | return <>{pointsAndEdges} 57 | } 58 | 59 | export default PointsAndEdges 60 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | ### Local deployment 17 | 18 | If you want to deploy this website locally, you need to follow these steps: 19 | 20 | 1. Clone the repository and enter it. 21 | 2. Make sure that you have [node](https://nodejs.org/en) and the node package manager npm installed. Check this by running 22 | ``` 23 | node --version 24 | npm --version 25 | ``` 26 | 3. Install all required packages from `package.json` by running 27 | ```shell 28 | npm install 29 | ``` 30 | 4. To build the code and host it in a development environment, run 31 | ```shell 32 | npm run dev 33 | ``` 34 | and visit [localhost:5173](http://localhost:5173). 35 | 36 | ### Devcontainer 37 | 38 | You can run this project inside a VS Code devcontainer. This sets up a reproducible development environment with Node and common tools. 39 | 40 | Steps: 41 | 42 | 1. Clone the repository. 43 | 2. Install the Remote - Containers extension in VS Code. 44 | 3. Open the repository in VS Code. 45 | 4. Press F1 and choose `Remote-Containers: Reopen in Container`. 46 | 5. After the container builds, the extensions from the devcontainer will be installed and `npm install` will run automatically. 47 | 6. Start the dev server inside the container with: 48 | 49 | ```bash 50 | npm run dev 51 | ``` 52 | 53 | and visit [localhost:5173](http://localhost:5173). 54 | 55 | ## How does this work? 56 | 57 | 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). 58 | 59 | ## Funding 60 | 61 | We thank our sponsors. 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /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/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/simulation/location.js: -------------------------------------------------------------------------------- 1 | /** x, y tile coordinates in WebMercator XYZ tiling at zoom level X=15 2 | */ 3 | export var coordinatesXY15, coordinatesLonLat, coordinatesWebMercator 4 | 5 | export async function processAddress(searchString) { 6 | let url = 7 | 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=' 8 | .concat(searchString) 9 | .concat('+Germany') 10 | let response = await fetchCoordinates(url) 11 | if (!response) { 12 | url = 13 | 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q='.concat( 14 | searchString.split(' ').join('+'), 15 | ) 16 | response = await fetchCoordinates(url) 17 | } 18 | return response.map((obj) => { 19 | console.log(obj.boundingbox) 20 | let [lat0, lat1, lon0, lon1] = obj.boundingbox.map(parseFloat) 21 | return { 22 | lat: obj.lat, 23 | lon: obj.lon, 24 | key: obj.place_id, 25 | addressType: obj.addresstype, 26 | boundingBox: [lon0, lat0, lon1, lat1], 27 | display_name: format_address(obj.address), 28 | } 29 | }) 30 | } 31 | 32 | function format_address(address) { 33 | const part1 = (address.road || '') + ' ' + (address.house_number || '') 34 | const part2 = (address.postcode || '') + ' ' + (address.city || '') 35 | 36 | if (part1 != ' ' && part2 != ' ') { 37 | return part1 + ', ' + part2 38 | } else { 39 | return part1 + part2 40 | } 41 | } 42 | 43 | async function fetchCoordinates(url) { 44 | try { 45 | const response = await fetch(url) 46 | if (!response.ok) 47 | throw new Error(`Request failed with status ${response.status}`) 48 | const responseData = await response.json() 49 | return responseData 50 | } catch (error) { 51 | console.error('Error:', error) 52 | return [] 53 | } 54 | } 55 | 56 | export function projectToWebMercator(lon, lat) { 57 | coordinatesLonLat = [lon, lat] 58 | const lat_rad = (lat * Math.PI) / 180.0 59 | const n = Math.pow(2, 15) 60 | const xtile = n * ((lon + 180) / 360) 61 | const ytile = 62 | (n * (1 - Math.log(Math.tan(lat_rad) + 1 / Math.cos(lat_rad)) / Math.PI)) / 63 | 2 64 | coordinatesXY15 = [xtile, ytile] 65 | coordinatesWebMercator = [ 66 | 1222.992452 * xtile - 20037508.34, 67 | 20037508.34 - 1222.992452 * ytile, 68 | ] 69 | return [xtile, ytile] 70 | } 71 | 72 | export function xyzBounds(x, y, z) { 73 | const map_size = 40075016.68 74 | const tile_size = map_size / Math.pow(2, z) 75 | const x0 = tile_size * x - map_size / 2 76 | const x1 = x0 + tile_size 77 | const y0 = map_size / 2 - tile_size * y 78 | const y1 = y0 - tile_size 79 | 80 | return [x0, y0, x1, y1] 81 | } 82 | -------------------------------------------------------------------------------- /src/pages/Simulation.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import WrongAdress from '../components/ErrorMessages/WrongAdress' 4 | import Footer from '../components/Footer' 5 | import LoadingBar from '../components/Template/LoadingBar' 6 | import Scene from '../components/ThreeViewer/Scene' 7 | import Main from '../Main' 8 | import { mainSimulation } from '../simulation/main' 9 | 10 | function Index() { 11 | const location = useParams() 12 | 13 | // frontendState defines the general state of the frontend (Results, Loading, DrawPV) 14 | const [frontendState, setFrontendState] = useState('Loading') 15 | 16 | // simulationProgress is used for the loading bar 17 | const [simulationProgress, setSimulationProgress] = useState(0) 18 | 19 | // The federal State where the material comes from, ie "BY" 20 | const [federalState, setFederalState] = useState(false) 21 | window.setFederalState = setFederalState 22 | 23 | // Buildings state – holds an array of building objects with 24 | // {id:int, 25 | // type:["simulation", "background", "surrounding"], 26 | // geometry: Threejs geometry (all buildings), 27 | // mesh: Threejs colored mesh (only simulated buildings)} 28 | const [buildings, setBuildings] = useState([]) 29 | 30 | // expose setters for the simulation core 31 | window.setBuildings = setBuildings 32 | window.setFrontendState = setFrontendState 33 | window.setSimulationProgress = setSimulationProgress 34 | 35 | const [vegetationGeometries, setVegetationGeometries] = useState([]) 36 | window.setVegetationGeometries = setVegetationGeometries 37 | 38 | const loadAndSimulate = async () => { 39 | await mainSimulation(location) 40 | setFrontendState('Results') 41 | } 42 | 43 | useEffect(() => { 44 | loadAndSimulate() 45 | }, []) 46 | 47 | return ( 48 |
49 |
50 | {frontendState == 'ErrorAdress' && } 51 | 52 | {(frontendState == 'Results' || frontendState == 'DrawPV') && ( 53 | 60 | )} 61 | 62 | {frontendState == 'Loading' && ( 63 | 64 | )} 65 |
66 |
67 |
68 | ) 69 | } 70 | 71 | export default Index 72 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/pages/Map.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useState } from 'react' 2 | import Main from '../Main' 3 | 4 | import { toaster } from '@/components/ui/toaster' 5 | import 'maplibre-gl/dist/maplibre-gl.css' 6 | import { useTranslation } from 'react-i18next' 7 | import { Map, NavigationControl } from 'react-map-gl/maplibre' 8 | import Footer from '../components/Footer' 9 | import MapPopup from '../components/MapPopup' 10 | import SearchField from '../components/PVSimulation/SearchField' 11 | import WelcomeMessage from '../components/Template/WelcomeMessage' 12 | 13 | function Index() { 14 | const { t } = useTranslation() 15 | 16 | const basemap_source = { 17 | id: 'basemap-source', 18 | type: 'raster', 19 | tiles: [ 20 | 'https://sgx.geodatenzentrum.de/wmts_basemapde/tile/1.0.0/de_basemapde_web_raster_farbe/default/GLOBAL_WEBMERCATOR/{z}/{y}/{x}.png', 21 | ], 22 | attribution: ` 23 | Basiskarte © 24 | 25 | BKG 26 | 27 |  ( 28 | 29 | dl-de/by-2-0 30 | 31 | ) 32 | `, 33 | } 34 | const basemap_layer = { 35 | id: 'basemap', 36 | type: 'raster', 37 | source: 'basemap-source', 38 | // minzoom: 0, 39 | // maxzoom: 22, 40 | } 41 | 42 | const boundingBox = [5.98, 47.3, 15.1, 55.0] 43 | 44 | const [viewState, setViewState] = useState({ 45 | bounds: boundingBox, 46 | zoom: 6, 47 | }) 48 | 49 | const [mapMarkers, setMapMarkers] = useState([]) 50 | 51 | const searchCallback = (locations) => { 52 | if (locations.length == 0) { 53 | console.error('No search results!') 54 | toaster.create({ 55 | title: t('noSearchResults.title'), 56 | description: t('noSearchResults.description'), 57 | status: 'error', 58 | duration: 4000, 59 | isClosable: true, 60 | }) 61 | } else { 62 | // Use only the first one 63 | const location = locations[0] 64 | let bounds = location.boundingBox 65 | 66 | if (location.addressType === 'building') { 67 | // Only add popup when search result is a building! 68 | setMapMarkers([]) 69 | } 70 | console.log('bounds') 71 | console.log(bounds) 72 | mapRef.current.fitBounds(bounds, { 73 | maxZoom: 17, 74 | speed: 2, 75 | }) 76 | } 77 | } 78 | 79 | const mapRef = useRef() 80 | const setMapRef = useCallback((current) => { 81 | mapRef.current = current 82 | if (current !== null) { 83 | current.getMap().dragRotate.disable() 84 | current.getMap().touchZoomRotate.disableRotation() 85 | } 86 | }, []) 87 | 88 | // Handling map click for manual location selection 89 | const [clickPoint, setClickPoint] = useState(null) 90 | const mapClick = useCallback((evt) => { 91 | console.log(evt) 92 | const { lng, lat } = evt.lngLat 93 | setClickPoint([lat, lng]) 94 | }) 95 | 96 | return ( 97 |
98 |
99 |
100 | 101 |
102 |
103 | 104 |
105 | setViewState(evt.viewState)} 112 | onClick={mapClick} 113 | attributionControl={false} 114 | maxBounds={[-10, 35, 30, 65]} 115 | > 116 | <>{mapMarkers} 117 | {clickPoint && ( 118 | 124 | )} 125 | 126 | 127 |
128 |
129 |
130 | ) 131 | } 132 | 133 | export default Index 134 | -------------------------------------------------------------------------------- /src/simulation/main.js: -------------------------------------------------------------------------------- 1 | import { ShadingScene, colormaps } from '@openpv/simshady' 2 | import * as THREE from 'three' 3 | import { c0, c1, c2 } from '../data/constants' 4 | import { 5 | createSkydomeURL, 6 | downloadBuildings, 7 | getFederalState, 8 | } from './download' 9 | import { VEGETATION_DEM } from './elevation' 10 | import { coordinatesWebMercator } from './location' 11 | import { processGeometries } from './preprocessing' 12 | import { processVegetationData } from './processVegetationTiffs' 13 | 14 | export async function mainSimulation(location) { 15 | // Clear previous attributions if any 16 | if (window.setAttribution) { 17 | for (let attributionSetter of Object.values(window.setAttribution)) { 18 | attributionSetter(false) 19 | } 20 | } 21 | 22 | if (typeof location !== 'undefined' && location != null) { 23 | // Download raw building objects (each has {id, type, geometry}) 24 | const buildingObjects = await downloadBuildings(location) 25 | processGeometries(buildingObjects, new THREE.Vector3(0, 0, 0), 80) 26 | window.setBuildings(buildingObjects) 27 | 28 | const simulationBuildings = buildingObjects.filter( 29 | (b) => b.type === 'simulation', 30 | ) 31 | 32 | if (simulationBuildings.length == 0) { 33 | window.setFrontendState('ErrorAdress') 34 | return {} 35 | } 36 | 37 | const scene = new ShadingScene() 38 | buildingObjects 39 | .filter((b) => b.type === 'simulation') 40 | .forEach((b) => scene.addSimulationGeometry(b.geometry)) 41 | 42 | buildingObjects 43 | .filter((b) => b.type === 'surrounding') 44 | .forEach((b) => scene.addShadingGeometry(b.geometry)) 45 | 46 | scene.addColorMap( 47 | colormaps.interpolateThreeColors({ c0: c0, c1: c1, c2: c2 }), 48 | ) 49 | 50 | const irradianceUrl = createSkydomeURL(location.lat, location.lon) 51 | await scene.addSolarIrradianceFromURL(irradianceUrl) 52 | 53 | if (getFederalState() == 'BY') { 54 | const [cx, cy] = coordinatesWebMercator 55 | const bufferDistance = 200 // 1km buffer, adjust as needed 56 | const bbox = [ 57 | cx - bufferDistance, 58 | cy - bufferDistance, 59 | cx + bufferDistance, 60 | cy + bufferDistance, 61 | ] 62 | 63 | const vegetationHeightmap = await VEGETATION_DEM.getGridPoints(...bbox) 64 | 65 | console.log('Processing vegetation geometries...') 66 | const vegetationGeometries = await processVegetationData( 67 | vegetationHeightmap, 68 | new THREE.Vector3(0, 0, 0), 69 | 30, 70 | 80, 71 | ) 72 | 73 | console.log('Vegetation Geometries processed successfully') 74 | console.log( 75 | `Number of surrounding geometries: ${vegetationGeometries.surrounding.length}`, 76 | ) 77 | console.log( 78 | `Number of background geometries: ${vegetationGeometries.background.length}`, 79 | ) 80 | 81 | window.setVegetationGeometries(vegetationGeometries) 82 | 83 | console.log('Adding vegetation geometries to the scene...') 84 | vegetationGeometries.surrounding.forEach((geom) => { 85 | scene.addShadingGeometry(geom) 86 | }) 87 | console.log('Vegetation geometries added to the scene') 88 | 89 | console.log('Vegetation processing completed') 90 | } 91 | 92 | function loadingBarWrapperFunction(progress, total) { 93 | return window.setSimulationProgress((progress * 100) / total) 94 | } 95 | 96 | const simulationMesh = await scene.calculate({ 97 | // .21 is the efficiency of a solar panel 98 | // .78 is the coverage factor of panels on a roof 99 | 100 | solarToElectricityConversionEfficiency: 0.21 * 0.78, 101 | 102 | progressCallback: loadingBarWrapperFunction, 103 | }) 104 | 105 | // Attach the resulting simulation mesh to each simulation building. 106 | simulationBuildings.forEach((b) => { 107 | b.mesh = simulationMesh.clone() 108 | }) 109 | 110 | // Store the centre point of the mesh on the first simulation building for camera positioning. 111 | if (simulationBuildings.length > 0) { 112 | const middle = new THREE.Vector3() 113 | simulationMesh.geometry.computeBoundingBox() 114 | simulationMesh.geometry.boundingBox.getCenter(middle) 115 | simulationBuildings[0].simulationMiddle = middle 116 | } 117 | return {} 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Scene.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react' 2 | import { Canvas } from 'react-three-fiber' 3 | import * as THREE from 'three' 4 | 5 | import { SceneContext } from '../context' 6 | import CustomMapControl from './Controls/CustomMapControl' 7 | import DrawPVControl from './Controls/DrawPVControl' 8 | import { BuildingMesh } from './Meshes/BuildingMesh' 9 | import { HighlightedPVSystem } from './Meshes/HighlitedPVSystem' 10 | import { PVSystems } from './Meshes/PVSystems' 11 | import VegetationMesh from './Meshes/VegetationMesh' 12 | import Overlay from './Overlay' 13 | import PointsAndEdges from './PointsAndEdges' 14 | import Terrain from './Terrain' 15 | 16 | const Scene = ({ 17 | frontendState, 18 | setFrontendState, 19 | buildings, 20 | vegetationGeometries, 21 | geoLocation, 22 | }) => { 23 | // showTerrain decides if the underlying Map is visible or not 24 | const [showTerrain, setShowTerrain] = useState(true) 25 | // A list of visible PV Systems - they get visible after they are drawn on a building and calculated 26 | const [pvSystems, setPVSystems] = useState([]) 27 | // pvPoints are the red points that appear when drawing PV systems 28 | const [pvPoints, setPVPoints] = useState([]) 29 | // highlighted PVSystems for deletion or calculation 30 | const [selectedPVSystem, setSelectedPVSystem] = useState([]) 31 | const [slope, setSlope] = useState('') 32 | const [azimuth, setAzimuth] = useState('') 33 | 34 | window.setPVPoints = setPVPoints 35 | 36 | // Determine camera start position based on the first simulation building (if any) 37 | let position = [0, 0, 0] 38 | const firstSimBuilding = buildings.find((b) => b.type === 'simulation') 39 | if (firstSimBuilding && firstSimBuilding.simulationMiddle) { 40 | const m = firstSimBuilding.simulationMiddle 41 | position = [m.x, m.y - 40, m.z + 80] 42 | } 43 | 44 | const cameraRef = useRef() 45 | // Derive grouped building arrays from the unified buildings state 46 | const simulationBuildings = buildings.filter((b) => b.type === 'simulation') 47 | 48 | return ( 49 | 66 | 71 | 72 | 83 | 84 | 85 | 86 | 87 | 88 | {buildings.length > 0 && 89 | buildings.map((b) => )} 90 | 91 | {selectedPVSystem && } 92 | {simulationBuildings.length > 0 && frontendState == 'Results' && ( 93 | 94 | )} 95 | {frontendState == 'DrawPV' && } 96 | {frontendState == 'DrawPV' && } 97 | 98 | {pvSystems.length > 0 && } 99 | 100 | {vegetationGeometries && ( 101 | <> 102 | {vegetationGeometries.background && 103 | vegetationGeometries.background.length > 0 && ( 104 | 105 | )} 106 | {vegetationGeometries.surrounding && 107 | vegetationGeometries.surrounding.length > 0 && ( 108 | 109 | )} 110 | 111 | )} 112 | 113 | {simulationBuildings.length > 0 && } 114 | 115 | 116 | ) 117 | } 118 | 119 | export default Scene 120 | -------------------------------------------------------------------------------- /src/simulation/processVegetationTiffs.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { mercator2meters } from './download' 3 | import { coordinatesWebMercator } from './location' 4 | import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js' 5 | import { SONNY_DEM } from './elevation' 6 | 7 | export async function processVegetationData( 8 | vegetationGrid, 9 | simulationCenter, 10 | vegetationSimulationCutoff, 11 | vegetationViewingCutoff, 12 | ) { 13 | const simulationCutoffSquared = 14 | vegetationSimulationCutoff * vegetationSimulationCutoff 15 | const viewingCutoffSquared = vegetationViewingCutoff * vegetationViewingCutoff 16 | 17 | // Ensure simulationCenter has x and y properties 18 | const centerX = simulationCenter.x || 0 19 | const centerY = simulationCenter.y || 0 20 | 21 | let surroundingTriangles = [] 22 | let backgroundTriangles = [] 23 | let surroundingNormals = [] 24 | let backgroundNormals = [] 25 | 26 | let i = 0 27 | for (let y = 0; y < vegetationGrid.length - 1; y++) { 28 | for (let x = 0; x < vegetationGrid.length - 1; x++) { 29 | const p00 = vegetationGrid[y][x] 30 | const p10 = vegetationGrid[y][x + 1] 31 | const p01 = vegetationGrid[y + 1][x] 32 | const p11 = vegetationGrid[y + 1][x + 1] 33 | 34 | // Triangle candidates 35 | // KH: Need to clone here to avoid the elevation-filling algo to fill the entire area 36 | // (I dare you to try and remove the structuredClone and see what happens :) 37 | const tris = [ 38 | structuredClone([p10, p00, p01]), 39 | structuredClone([p10, p01, p11]), 40 | ] 41 | for (let [a, b, c] of tris) { 42 | i += 1 43 | // If all heights are 0, don't render triangle 44 | if (a.point[2] <= 0 && b.point[2] <= 0 && c.point[2] <= 0) { 45 | continue 46 | } 47 | 48 | const max_height = Math.max(a.point[2], b.point[2], c.point[2]) 49 | // Fill 0 values with actual elevation at that point 50 | const xyscale = mercator2meters() 51 | const [cx, cy] = coordinatesWebMercator 52 | 53 | const old_heights = [a.point[2], b.point[2], c.point[2]] 54 | let fillcount = 0 55 | for (let pt of [a, b, c]) { 56 | // if (pt.point[2] <= max_height - 20) { 57 | if (pt.point[2] <= 0) { 58 | fillcount++ 59 | const mercator_x = pt.point[0] / xyscale + cx 60 | const mercator_y = pt.point[1] / xyscale + cy 61 | const pt3d = await SONNY_DEM.toPoint3D(mercator_x, mercator_y) 62 | 63 | pt.point[2] = pt3d.point[2] 64 | } 65 | } 66 | 67 | if (fillcount == 3) { 68 | console.log('Wrongly filled:', old_heights) 69 | } 70 | 71 | const mx = (a.point[0] + b.point[0] + c.point[0]) / 3 72 | const my = (a.point[1] + b.point[1] + c.point[1]) / 3 73 | 74 | const d2 = 75 | (centerX - mx) * (centerX - mx) + (centerY - my) * (centerY - my) 76 | if (d2 <= viewingCutoffSquared) { 77 | if (d2 <= simulationCutoffSquared) { 78 | surroundingTriangles.push(...a.point, ...b.point, ...c.point) 79 | surroundingNormals.push(...a.normal, ...b.normal, ...c.normal) 80 | } else { 81 | backgroundTriangles.push(...a.point, ...b.point, ...c.point) 82 | backgroundNormals.push(...a.normal, ...b.normal, ...c.normal) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | const geometries = { 90 | surrounding: [], 91 | background: [], 92 | } 93 | 94 | if (surroundingTriangles.length > 0) { 95 | let surroundingGeom = new THREE.BufferGeometry() 96 | const surroundingPos = new THREE.BufferAttribute( 97 | new Float32Array(surroundingTriangles), 98 | 3, 99 | ) 100 | const surroundingNor = new THREE.BufferAttribute( 101 | new Float32Array(surroundingNormals), 102 | 3, 103 | ) 104 | surroundingGeom.setAttribute('position', surroundingPos) 105 | surroundingGeom.setAttribute('normal', surroundingNor) 106 | geometries.surrounding.push(surroundingGeom) 107 | } 108 | 109 | if (backgroundTriangles.length > 0) { 110 | let backgroundGeom = new THREE.BufferGeometry() 111 | const backgroundPos = new THREE.BufferAttribute( 112 | new Float32Array(backgroundTriangles), 113 | 3, 114 | ) 115 | const backgroundNor = new THREE.BufferAttribute( 116 | new Float32Array(backgroundNormals), 117 | 3, 118 | ) 119 | backgroundGeom.setAttribute('position', backgroundPos) 120 | backgroundGeom.setAttribute('normal', backgroundNor) 121 | geometries.background.push(backgroundGeom) 122 | } 123 | 124 | return geometries 125 | } 126 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Terrain.jsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react' 2 | import * as THREE from 'three' 3 | import { SONNY_DEM } from '../../simulation/elevation' 4 | import { coordinatesXY15, xyzBounds } from '../../simulation/location' 5 | import { SceneContext } from '../context' 6 | 7 | /** Load an OSM map tile and return it as a THREE Mesh 8 | */ 9 | const TerrainTile = (props) => { 10 | const zoom = props.zoom 11 | const tx = props.x 12 | const ty = props.y 13 | const divisions = props.divisions 14 | 15 | const url = `https://sgx.geodatenzentrum.de/wmts_basemapde/tile/1.0.0/de_basemapde_web_raster_farbe/default/GLOBAL_WEBMERCATOR/${zoom}/${ty}/${tx}.png` 16 | 17 | let [geometry, setGeometry] = useState(null) 18 | let [material, setMaterial] = useState(null) 19 | let [meshLoaded, setMeshLoaded] = useState(false) 20 | 21 | let mesh = ( 22 | <>{meshLoaded && } 23 | ) 24 | 25 | useEffect(() => { 26 | async function fetchData() { 27 | const mapFuture = new THREE.TextureLoader().loadAsync(url) 28 | 29 | // Size of the world map in meters 30 | const [x0, y0, x1, y1] = xyzBounds(tx, ty, zoom) 31 | let vertices = [] 32 | let uvs = [] 33 | let indices = [] 34 | let i = 0 35 | 36 | const row = divisions + 1 37 | for (let ty = 0; ty <= divisions; ty++) { 38 | for (let tx = 0; tx <= divisions; tx++) { 39 | const x = x0 + (tx / divisions) * (x1 - x0) 40 | const y = y0 + (ty / divisions) * (y1 - y0) 41 | vertices.push(SONNY_DEM.toPoint3D(x, y)) 42 | // UV mapping for the texture 43 | uvs.push(tx / divisions, 1.0 - ty / divisions) 44 | // Triangle indices 45 | if (tx > 0 && ty > 0) { 46 | indices.push( 47 | i - row - 1, 48 | i - 1, 49 | i - row, // 1st triangle 50 | i - row, 51 | i - 1, 52 | i, // 2nd triangle 53 | ) 54 | } 55 | i += 1 56 | } 57 | } 58 | 59 | vertices = await Promise.all(vertices) 60 | const vertexBuffer = new Float32Array(vertices.flatMap((x) => x.point)) 61 | const normalBuffer = new Float32Array(vertices.flatMap((x) => x.normal)) 62 | const uvBuffer = new Float32Array(uvs) 63 | const indexBuffer = new Uint32Array(indices) 64 | const geometry = new THREE.BufferGeometry() 65 | geometry.setAttribute( 66 | 'position', 67 | new THREE.BufferAttribute(vertexBuffer, 3), 68 | ) 69 | geometry.setAttribute( 70 | 'normal', 71 | new THREE.BufferAttribute(normalBuffer, 3), 72 | ) 73 | geometry.setAttribute('uv', new THREE.BufferAttribute(uvBuffer, 2)) 74 | geometry.setIndex(new THREE.BufferAttribute(indexBuffer, 1)) 75 | 76 | setGeometry(geometry) 77 | const map = await mapFuture 78 | map.colorSpace = THREE.SRGBColorSpace 79 | setMaterial( 80 | new THREE.MeshBasicMaterial({ 81 | map: await mapFuture, 82 | side: THREE.FrontSide, 83 | }), 84 | ) 85 | setMeshLoaded(true) 86 | } 87 | fetchData() 88 | }, []) 89 | 90 | return mesh 91 | } 92 | 93 | const Terrain = () => { 94 | const sceneContext = useContext(SceneContext) 95 | const [x, y] = coordinatesXY15 96 | const [tiles, setTiles] = useState([]) // State to manage tiles 97 | const tx = Math.floor(x * 16) 98 | const ty = Math.floor(y * 16) 99 | 100 | let xys = [] 101 | for (let dx = -11; dx <= 11; dx++) { 102 | for (let dy = -11; dy <= 11; dy++) { 103 | xys.push({ dx, dy, divisions: 2 }) 104 | } 105 | } 106 | 107 | xys.sort((a, b) => a.dx * a.dx + a.dy * a.dy - (b.dx * b.dx + b.dy * b.dy)) 108 | useEffect(() => { 109 | let currentTiles = [] 110 | 111 | // Function to load tiles progressively 112 | const loadTiles = (index) => { 113 | if (index < xys.length) { 114 | const { dx, dy, divisions } = xys[index] 115 | const key = `${tx + dx}-${ty + dy}-${19}` 116 | currentTiles.push( 117 | , 124 | ) 125 | 126 | setTiles([...currentTiles]) // Update the state with the new set of tiles 127 | 128 | // Schedule the next tile load 129 | setTimeout(() => loadTiles(index + 1), 0) // Adjust the timeout for desired loading speed 130 | } 131 | } 132 | 133 | loadTiles(0) // Start loading tiles 134 | 135 | return () => { 136 | setTiles([]) // Clean up on component unmount 137 | } 138 | }, [tx, ty]) // Dependency array to reset when the coordinates change 139 | 140 | return {tiles} 141 | } 142 | 143 | export default Terrain 144 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Controls/CustomMapControl.jsx: -------------------------------------------------------------------------------- 1 | import { MapControls } from '@react-three/drei' 2 | import { useFrame, useThree } from '@react-three/fiber' 3 | import { useContext, useEffect, useRef } from 'react' 4 | import * as THREE from 'three' 5 | import { SceneContext } from '../../context' 6 | 7 | function CustomMapControl() { 8 | const sceneContext = useContext(SceneContext) 9 | const controlsRef = useRef() 10 | const raycaster = useRef(new THREE.Raycaster()) 11 | const mouse = useRef(new THREE.Vector2()) 12 | const { gl, camera, scene } = useThree() 13 | 14 | /** 15 | * Returns the list of intersected objects. An intersected object is an object 16 | * that lies directly below the mouse cursor. 17 | */ 18 | const getIntersects = (event) => { 19 | const isTouch = window.isTouchDevice 20 | const clientX = isTouch ? event.touches[0].clientX : event.clientX 21 | const clientY = isTouch ? event.touches[0].clientY : event.clientY 22 | 23 | const rect = event.target.getBoundingClientRect() 24 | mouse.current.x = ((clientX - rect.left) / rect.width) * 2 - 1 25 | mouse.current.y = (-(clientY - rect.top) / rect.height) * 2 + 1 26 | 27 | raycaster.current.setFromCamera(mouse.current, camera) 28 | 29 | return raycaster.current.intersectObjects(scene.children, true) 30 | } 31 | 32 | /** 33 | * Filter out Sprites (ie the labels of PV systems). 34 | * Returns the first element of the intersects list that is not a sprite. 35 | */ 36 | const ignoreSprites = (intersects) => { 37 | let i = 0 38 | while (i < intersects.length && intersects[i].object.type === 'Sprite') { 39 | i++ 40 | } 41 | if (i === intersects.length) { 42 | console.log('Only Sprite objects found in intersections.') 43 | return 44 | } 45 | return intersects[i] 46 | } 47 | 48 | const handleMouseMove = (event) => { 49 | event.preventDefault() 50 | const intersects = getIntersects(event) 51 | const intersected = ignoreSprites(intersects) 52 | if (!intersected) return 53 | const intersectedFace = intersected.face 54 | const [slope, azimuth] = calculateSlopeAzimuthFromNormal( 55 | intersectedFace.normal, 56 | ) 57 | sceneContext.setSlope(Math.round(slope)) 58 | sceneContext.setAzimuth(Math.round(azimuth)) 59 | } 60 | 61 | // Attach mouse move listener once 62 | useEffect(() => { 63 | const canvas = gl.domElement 64 | canvas.addEventListener('mousemove', handleMouseMove) 65 | return () => { 66 | canvas.removeEventListener('mousemove', handleMouseMove) 67 | } 68 | }, [gl]) 69 | 70 | // Update controls each frame 71 | useFrame(() => { 72 | if (controlsRef.current) { 73 | controlsRef.current.update() 74 | } 75 | }) 76 | 77 | // ------------------------------------------------- 78 | // Determine the initial target for the map controls. 79 | // This should happen only once, after the building data 80 | // has been loaded, and must not be overwritten by later 81 | // renders or user interactions. 82 | // ------------------------------------------------- 83 | const initialTarget = useRef(new THREE.Vector3(0, 0, 0)) 84 | const targetSet = useRef(false) 85 | 86 | // Run when building data changes. Set the target only the first time 87 | // we have a simulation building with a stored middle point. 88 | useEffect(() => { 89 | if (targetSet.current) return 90 | 91 | const firstSimBuilding = sceneContext.buildings?.find( 92 | (b) => b.type === 'simulation', 93 | ) 94 | if (firstSimBuilding && firstSimBuilding.simulationMiddle) { 95 | const m = firstSimBuilding.simulationMiddle 96 | initialTarget.current.set(m.x, m.y, m.z) 97 | 98 | // If the controls already exist, update its internal target immediately. 99 | if (controlsRef.current) { 100 | controlsRef.current.target.copy(initialTarget.current) 101 | controlsRef.current.update() 102 | } 103 | 104 | targetSet.current = true 105 | } 106 | }, [sceneContext.buildings]) 107 | 108 | return ( 109 | 126 | ) 127 | } 128 | 129 | export default CustomMapControl 130 | 131 | const calculateSlopeAzimuthFromNormal = (normal) => { 132 | const up = new THREE.Vector3(0, 0, 1) 133 | const angleRad = normal.angleTo(up) 134 | const slope = THREE.MathUtils.radToDeg(angleRad) 135 | 136 | // Swap y and x in atan to get clockwise angle from y-axis 137 | const azimuthRad = Math.atan2(normal.x, normal.y) 138 | let azimuth = THREE.MathUtils.radToDeg(azimuthRad) 139 | if (azimuth < 0) { 140 | azimuth += 360 141 | } 142 | 143 | return [slope, azimuth] 144 | } 145 | -------------------------------------------------------------------------------- /src/components/ThreeViewer/Controls/DrawPVControl.jsx: -------------------------------------------------------------------------------- 1 | import { useFrame, useThree } from '@react-three/fiber' 2 | import { useContext, useEffect, useRef } from 'react' 3 | import * as THREE from 'three' 4 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 5 | import { SceneContext } from '../../context' 6 | import { createPVSystem } from '../Meshes/PVSystems' 7 | 8 | const DrawPVControl = () => { 9 | const sceneContext = useContext(SceneContext) 10 | const { camera, gl, scene } = useThree() 11 | const raycaster = useRef(new THREE.Raycaster()) 12 | const mouse = useRef(new THREE.Vector2()) 13 | const controls = useRef() 14 | let pvPointsRef = [] 15 | 16 | // Helper to get the first simulation building (if any) 17 | const getFirstSimulationBuilding = () => { 18 | return sceneContext.buildings?.find((b) => b.type === 'simulation') || null 19 | } 20 | 21 | // Initialise OrbitControls with the middle point of the first simulation building 22 | useEffect(() => { 23 | const firstSimBuilding = getFirstSimulationBuilding() 24 | const target = firstSimBuilding?.simulationMiddle 25 | ? new THREE.Vector3( 26 | firstSimBuilding.simulationMiddle.x, 27 | firstSimBuilding.simulationMiddle.y, 28 | firstSimBuilding.simulationMiddle.z, 29 | ) 30 | : new THREE.Vector3(0, 0, 0) 31 | 32 | controls.current = new OrbitControls(camera, gl.domElement) 33 | controls.current.target.copy(target) 34 | controls.current.mouseButtons = { 35 | MIDDLE: THREE.MOUSE.DOLLY, 36 | RIGHT: THREE.MOUSE.ROTATE, 37 | } 38 | controls.current.screenSpacePanning = false 39 | controls.current.maxPolarAngle = Math.PI / 2 40 | controls.current.update() 41 | 42 | // Clean up on unmount 43 | return () => { 44 | controls.current.dispose() 45 | } 46 | }, [camera, gl, sceneContext.buildings]) 47 | 48 | const onPointerDown = (event) => { 49 | if (event.button !== 0) return 50 | 51 | const rect = event.target.getBoundingClientRect() 52 | mouse.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 53 | mouse.current.y = (-(event.clientY - rect.top) / rect.height) * 2 + 1 54 | 55 | raycaster.current.setFromCamera(mouse.current, camera) 56 | 57 | const intersects = raycaster.current.intersectObjects(scene.children, true) 58 | 59 | if (intersects.length > 0) { 60 | const intersection = intersects[0] 61 | if (intersection.object.type == 'Points') { 62 | // User clicked on a previously drawn point. Now we need 63 | // to check if this was the first point from the list 64 | // and if three points already exist. Then we can draw the 65 | // PV System. 66 | 67 | // Also important to understand this behaviour here: Check out 68 | // https://github.com/open-pv/website/pull/430 69 | 70 | if ( 71 | arePointsEqual( 72 | pvPointsRef[0].point, 73 | intersection.object.geometry.attributes.position.array, 74 | ) && 75 | pvPointsRef.length > 2 76 | ) { 77 | createPVSystem({ 78 | setPVSystems: sceneContext.setPVSystems, 79 | setSelectedPVSystem: sceneContext.setSelectedPVSystem, 80 | pvPoints: pvPointsRef, 81 | setPVPoints: sceneContext.setPVPoints, 82 | simulationBuildings: 83 | sceneContext.buildings?.filter((b) => b.type === 'simulation') || 84 | [], 85 | }) 86 | setFrontendState('Results') 87 | } 88 | } 89 | const point = intersection.point 90 | if (!intersection.face) { 91 | // Catch the error where sometimes the intersection 92 | // is undefined. By this no dot is drawn, but also 93 | // no error is thrown 94 | console.log('Intersection.face was null.') 95 | return undefined 96 | } 97 | const normal = intersection.face.normal 98 | .clone() 99 | .transformDirection(intersection.object.matrixWorld) 100 | 101 | setPVPoints((prevPoints) => { 102 | const newPoints = [...prevPoints, { point, normal }] 103 | pvPointsRef = newPoints // Keep ref updated 104 | return newPoints 105 | }) 106 | } 107 | } 108 | 109 | useEffect(() => { 110 | // Add event listener 111 | gl.domElement.addEventListener('pointerdown', onPointerDown) 112 | 113 | // Clean up 114 | return () => { 115 | gl.domElement.removeEventListener('pointerdown', onPointerDown) 116 | } 117 | }, [gl, sceneContext.buildings]) 118 | 119 | // Update controls each frame 120 | useFrame(() => { 121 | if (controls.current) controls.current.update() 122 | }) 123 | 124 | return null // This component does not render anything visible 125 | } 126 | 127 | export default DrawPVControl 128 | 129 | /** 130 | * Compares two points, where one is an object and one is a list. 131 | * The function allows a 1% deviation. 132 | * @param {} p1 First Point as object with x,y,z attribute 133 | * @param {} p2 Second point as list with three elements 134 | * @returns 135 | */ 136 | function arePointsEqual(p1, p2) { 137 | return Math.hypot(p1.x - p2[0], p1.y - p2[1], p1.z - p2[2]) < 0.01 138 | } 139 | -------------------------------------------------------------------------------- /src/pages/Impressum.jsx: -------------------------------------------------------------------------------- 1 | import { Card, Heading } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | import Main from '../Main' 5 | 6 | const Impressum = () => { 7 | return ( 8 |
9 | 10 | 11 | Impressum 12 | 13 | 14 |

15 | Martin Großhauser
16 | Arnulfstraße 138
17 | 80634 München
18 | info@openpv.de
19 |

20 |

Haftung für Inhalte

21 |

22 | Alle Inhalte unseres Internetauftritts wurden mit größter Sorgfalt 23 | und nach bestem Gewissen erstellt. Für die Richtigkeit, 24 | Vollständigkeit und Aktualität der Inhalte können wir jedoch keine 25 | Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG 26 | für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen 27 | verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter 28 | jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde 29 | Informationen zu überwachen oder nach Umständen zu forschen, die auf 30 | eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur 31 | Entfernung oder Sperrung der Nutzung von Informationen nach den 32 | allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche 33 | Haftung ist jedoch erst ab dem Zeitpunkt der Kenntniserlangung einer 34 | konkreten Rechtsverletzung möglich. Bei Bekanntwerden von den o.g. 35 | Rechtsverletzungen werden wir diese Inhalte unverzüglich entfernen. 36 |

37 |

Haftungsbeschränkung für externe Links

38 |

39 | Unsere Webseite enthält Links auf externe Webseiten Dritter. Auf die 40 | Inhalte dieser direkt oder indirekt verlinkten Webseiten haben wir 41 | keinen Einfluss. Daher können wir für die „externen Links“ auch 42 | keine Gewähr auf Richtigkeit der Inhalte übernehmen. Für die Inhalte 43 | der externen Links sind die jeweilige Anbieter oder Betreiber 44 | (Urheber) der Seiten verantwortlich. Die externen Links wurden zum 45 | Zeitpunkt der Linksetzung auf eventuelle Rechtsverstöße überprüft 46 | und waren im Zeitpunkt der Linksetzung frei von rechtswidrigen 47 | Inhalten. Eine ständige inhaltliche Überprüfung der externen Links 48 | ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht 49 | möglich. Bei direkten oder indirekten Verlinkungen auf die Webseiten 50 | Dritter, die außerhalb unseres Verantwortungsbereichs liegen, würde 51 | eine Haftungsverpflichtung ausschließlich in dem Fall nur bestehen, 52 | wenn wir von den Inhalten Kenntnis erlangen und es uns technisch 53 | möglich und zumutbar wäre, die Nutzung im Falle rechtswidriger 54 | Inhalte zu verhindern. Diese Haftungsausschlusserklärung gilt auch 55 | innerhalb des eigenen Internetauftrittes „Name Ihrer Domain“ 56 | gesetzten Links und Verweise von Fragestellern, Blogeinträgern, 57 | Gästen des Diskussionsforums. Für illegale, fehlerhafte oder 58 | unvollständige Inhalte und insbesondere für Schäden, die aus der 59 | Nutzung oder Nichtnutzung solcherart dargestellten Informationen 60 | entstehen, haftet allein der Diensteanbieter der Seite, auf welche 61 | verwiesen wurde, nicht derjenige, der über Links auf die jeweilige 62 | Veröffentlichung lediglich verweist. Werden uns Rechtsverletzungen 63 | bekannt, werden die externen Links durch uns unverzüglich entfernt. 64 |

65 |

Urheberrecht

66 |

67 | Die auf unserer Webseite veröffentlichen Inhalte und Werke 68 | unterliegen dem deutschen Urheberrecht 69 | (http://www.gesetze-im-internet.de/bundesrecht/urhg/gesamt.pdf) . 70 | Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der 71 | Verwertung des geistigen Eigentums in ideeller und materieller Sicht 72 | des Urhebers außerhalb der Grenzen des Urheberrechtes bedürfen der 73 | vorherigen schriftlichen Zustimmung des jeweiligen Urhebers i.S.d. 74 | Urhebergesetzes 75 | (http://www.gesetze-im-internet.de/bundesrecht/urhg/gesamt.pdf ). 76 | Downloads und Kopien dieser Seite sind nur für den privaten und 77 | nicht kommerziellen Gebrauch erlaubt. Sind die Inhalte auf unserer 78 | Webseite nicht von uns erstellt wurden, sind die Urheberrechte 79 | Dritter zu beachten. Die Inhalte Dritter werden als solche kenntlich 80 | gemacht. Sollten Sie trotzdem auf eine Urheberrechtsverletzung 81 | aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei 82 | Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte 83 | unverzüglich entfernen. 84 |

85 |
86 |
87 |
88 | ) 89 | } 90 | 91 | export default Impressum 92 | -------------------------------------------------------------------------------- /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/simulation/download.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { Matrix4 } from 'three' 3 | import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js' 4 | import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' 5 | import { attributions } from '../data/dataLicense' 6 | import { coordinatesLonLat, projectToWebMercator } from './location' 7 | 8 | let federalState = null 9 | 10 | export function getFederalState() { 11 | return federalState 12 | } 13 | 14 | export function tile2meters() { 15 | return 1222.992452 * mercator2meters() 16 | } 17 | 18 | export function mercator2meters() { 19 | const lat = coordinatesLonLat[1] 20 | return Math.cos((lat * Math.PI) / 180.0) 21 | } 22 | 23 | const dracoLoader = new DRACOLoader() 24 | dracoLoader.setDecoderPath('/draco/') 25 | dracoLoader.preload() 26 | const gltfLoader = new GLTFLoader() 27 | gltfLoader.setDRACOLoader(dracoLoader) 28 | 29 | let _globalBuildingId = 0 30 | 31 | function getFileNames(lon, lat) { 32 | let [x, y] = projectToWebMercator(lon, lat) 33 | 34 | const x0 = Math.round(x) - 1 35 | const x1 = Math.round(x) 36 | const y0 = Math.round(y) - 1 37 | const y1 = Math.round(y) 38 | 39 | let downloads = [ 40 | { tile: { x: x0, y: y0 }, center: { x, y } }, 41 | { tile: { x: x1, y: y0 }, center: { x, y } }, 42 | { tile: { x: x0, y: y1 }, center: { x, y } }, 43 | { tile: { x: x1, y: y1 }, center: { x, y } }, 44 | ] 45 | return downloads 46 | } 47 | 48 | /** 49 | * Download building data for a given location. 50 | * Returns an array of building objects: 51 | * { id: Number, type: 'background', geometry: THREE.BufferGeometry } 52 | */ 53 | export async function downloadBuildings(loc) { 54 | const filenames = getFileNames(Number(loc.lon), Number(loc.lat)) 55 | const promises = filenames.map((filename) => downloadBuildingTile(filename)) 56 | const results = await Promise.all(promises) 57 | 58 | // `results` is an array of arrays (one per tile). Flatten it and return. 59 | return results.flat() 60 | } 61 | 62 | /** 63 | * Download a single tile, convert the GLB into a list of building objects. 64 | * Each building gets a unique `id` and a default `type` of "background". 65 | */ 66 | async function downloadBuildingTile(download_spec) { 67 | const { tile, center } = download_spec 68 | const url = `https://maps.heidler.info/germany-draco/15-${tile.x}-${tile.y}.glb` 69 | 70 | try { 71 | const data = await gltfLoader.loadAsync(url) 72 | let buildingObjects = [] 73 | 74 | for (let scene of data.scenes) { 75 | for (let child of scene.children) { 76 | let geometry = child.geometry 77 | 78 | const scale2tile = new Matrix4() 79 | scale2tile.makeScale(1 / 8192, 1 / 8192, 1.0) 80 | const translate = new Matrix4() 81 | translate.makeTranslation(tile.x - center.x, tile.y - center.y, 0.0) 82 | const scale2meters = new Matrix4() 83 | // Flip sign of Y axis (in WebMercator, Y+ points down, but we need it to point up) 84 | scale2meters.makeScale(tile2meters(), -tile2meters(), 1.0) 85 | 86 | const tx = scale2meters 87 | tx.multiply(translate) 88 | tx.multiply(scale2tile) 89 | geometry.applyMatrix4(tx) 90 | 91 | // Essentially all of our code assumes that the geometries are not indexed 92 | // This makes sure of that 93 | geometry = geometry.toNonIndexed() 94 | 95 | let buildings = {} 96 | const position = geometry.attributes.position.array 97 | const normal = geometry.attributes.normal.array 98 | const feature_ids = geometry.attributes._feature_id_0.array 99 | 100 | for (let i = 0; i < geometry.attributes.position.count; i++) { 101 | const key = feature_ids[i] 102 | if (!buildings.hasOwnProperty(key)) { 103 | buildings[key] = { 104 | position: [], 105 | normal: [], 106 | } 107 | } 108 | for (let j = 0; j < 3; j++) { 109 | buildings[key].position.push(position[3 * i + j]) 110 | buildings[key].normal.push(normal[3 * i + j]) 111 | } 112 | } 113 | 114 | // Convert each grouped building into a BufferGeometry and wrap it 115 | for (let { position, normal } of Object.values(buildings)) { 116 | const buildingGeometry = new THREE.BufferGeometry() 117 | const posAttr = new THREE.BufferAttribute( 118 | new Float32Array(position), 119 | 3, 120 | ) 121 | const normAttr = new THREE.BufferAttribute( 122 | new Float32Array(normal), 123 | 3, 124 | ) 125 | buildingGeometry.setAttribute('position', posAttr) 126 | buildingGeometry.setAttribute('normal', normAttr) 127 | 128 | buildingObjects.push({ 129 | id: ++_globalBuildingId, 130 | type: 'background', // default type; will be updated later by preprocessing 131 | geometry: buildingGeometry, 132 | }) 133 | } 134 | } 135 | } 136 | 137 | // Parse Bundesländer (federal state) information 138 | const buffer = await data.parser.getDependency('bufferView', 0) 139 | const ids = new TextDecoder().decode(buffer) 140 | for (const bundesland of Object.keys(attributions)) { 141 | if (ids.includes(`DE${bundesland}`)) { 142 | window.setFederalState(bundesland) 143 | federalState = bundesland 144 | } 145 | } 146 | 147 | return buildingObjects 148 | } catch (error) { 149 | console.warn(error) 150 | return [] 151 | } 152 | } 153 | 154 | export const createSkydomeURL = (lat, lon) => { 155 | function roundToNearest(value, multiple) { 156 | return Math.round(value / multiple) * multiple 157 | } 158 | const roundedLat = roundToNearest(lat, 0.2) 159 | const roundedLon = roundToNearest(lon, 0.2) 160 | 161 | // Format to one decimal place to avoid floating-point inaccuracies 162 | const formattedLat = roundedLat.toFixed(1) 163 | const formattedLon = roundedLon.toFixed(1) 164 | 165 | // Create the dynamic URL with the properly formatted values 166 | return `https://api.openpv.de/skymaps/irradiance_${formattedLat}_${formattedLon}_2018_yearly.json` 167 | } 168 | -------------------------------------------------------------------------------- /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 |