diff --git a/.github/doc/app.png b/.github/doc/app.png index 90c5d07..66ccbcf 100644 Binary files a/.github/doc/app.png and b/.github/doc/app.png differ diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 0000000..4f58d91 --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,225 @@ +name: CI/CD - Tests and Coverage + +on: + push: + branches: [ master, dev/*, develop ] + pull_request: + branches: [ master ] + +permissions: + contents: read + pull-requests: write + checks: write + statuses: write + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + matrix: + java-version: [ 17, 21 ] + os: [ ubuntu-latest ] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'corretto' + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: Setup virtual display for JavaFX tests + run: | + sudo apt-get update + sudo apt-get install -y xvfb + export DISPLAY=:99 + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + + - name: Run tests with coverage + run: | + export DISPLAY=:99 + mvn clean test -Dmaven.test.failure.ignore=false -Dheadless=true + env: + MAVEN_OPTS: "-Xmx1024m" + + - name: Generate JaCoCo coverage reports + run: | + mvn org.jacoco:jacoco-maven-plugin:0.8.8:report -pl jlmap-fx,jlmap-vaadin + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: | + ./jlmap-fx/target/site/jacoco/jacoco.xml + ./jlmap-vaadin/target/site/jacoco/jacoco.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + if: matrix.java-version == '17' + + - name: Generate test summary + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Test Results (Java ${{ matrix.java-version }}) + path: '**/target/surefire-reports/TEST-*.xml' + reporter: java-junit + fail-on-error: false + + - name: Comment coverage on PR + if: github.event_name == 'pull_request' && matrix.java-version == '17' + uses: madrapps/jacoco-report@v1.6.1 + with: + paths: | + ${{ github.workspace }}/jlmap-fx/target/site/jacoco/jacoco.xml + ${{ github.workspace }}/jlmap-vaadin/target/site/jacoco/jacoco.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 70 + min-coverage-changed-files: 80 + title: 'Code Coverage Report' + update-comment: true + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: '**/target/surefire-reports/TEST-*.xml' + check_name: 'Test Results (Java ${{ matrix.java-version }})' + comment_title: 'Test Results (Java ${{ matrix.java-version }})' + fail_on: 'nothing' + + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-java-${{ matrix.java-version }} + path: | + **/target/surefire-reports/ + **/target/site/jacoco/ + retention-days: 30 + + sonarqube: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'pull_request' || github.ref_name == 'master' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'corretto' + + - name: Cache SonarQube packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: Setup virtual display for JavaFX tests + run: | + sudo apt-get update + sudo apt-get install -y xvfb + export DISPLAY=:99 + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + + - name: Build and analyze with SonarQube + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + DISPLAY: :99 + run: | + mvn clean verify sonar:sonar \ + -Dsonar.projectKey=makbn_java_leaflet \ + -Dsonar.organization=makbn \ + -Dsonar.host.url=https://sonarcloud.io \ + -Dsonar.coverage.jacoco.xmlReportPaths=**/target/site/jacoco/jacoco.xml + if: env.SONAR_TOKEN != null + + security-scan: + runs-on: ubuntu-latest + needs: test + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'corretto' + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: Run dependency check + run: | + mvn org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=8 + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: target/dependency-check-report.sarif + + build-status: + runs-on: ubuntu-latest + needs: [ test, sonarqube, security-scan ] + if: always() + + steps: + - name: Check build status + run: | + if [[ "${{ needs.test.result }}" == "success" ]]; then + echo "✅ Tests passed" + else + echo "❌ Tests failed" + exit 1 + fi + + if [[ "${{ needs.sonarqube.result }}" == "success" || "${{ needs.sonarqube.result }}" == "skipped" ]]; then + echo "✅ Code quality check passed or skipped" + else + echo "⚠️ Code quality check failed" + fi + + if [[ "${{ needs.security-scan.result }}" == "success" ]]; then + echo "🔒 Security scan passed" + else + echo "⚠️ Security scan failed" + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 00973c4..e3bfe2e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ application.properties /src/main/java/META-INF/ /src/main/java/io/github/makbn/jlmap/META-INF/ /src/main/resources/final.geo.json +**/.DS_Store +jlmap-vaadin/node_modules/ +jlmap-vaadin/src/frontend/generated/ +jlmap-vaadin/package-lock.json \ No newline at end of file diff --git a/.run/Vaadin Demo.run.xml b/.run/Vaadin Demo.run.xml new file mode 100644 index 0000000..0c63bfc --- /dev/null +++ b/.run/Vaadin Demo.run.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/.run/jlmap [javafx_run].run.xml b/.run/jlmap-fx [javafx_run].run.xml similarity index 78% rename from .run/jlmap [javafx_run].run.xml rename to .run/jlmap-fx [javafx_run].run.xml index de65e34..4284dfe 100644 --- a/.run/jlmap [javafx_run].run.xml +++ b/.run/jlmap-fx [javafx_run].run.xml @@ -1,5 +1,5 @@ - + @@ -23,7 +23,7 @@ diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..34ef071 --- /dev/null +++ b/CLA.md @@ -0,0 +1,43 @@ +# Contributor License Agreement (CLA) + +**JL (Java Leaflet) Map Project** + +Thank you for your interest in contributing to **JL (Java Leaflet) Map** (“the Project”). +To clarify the intellectual property rights granted with contributions, you agree to the following terms by submitting a +contribution (including code, documentation, or other material) to the Project. + +## 1. Copyright License + +You grant the Project Maintainers a **perpetual, worldwide, non-exclusive, transferable, sublicensable, royalty-free +license** to use, reproduce, modify, publicly display, publicly perform, sublicense, and distribute your contributions +and derivative works. + +## 2. Relicensing Rights + +You expressly allow the Project Maintainers to **relicense your contributions under any license of their choosing** in +the future, including permissive, copyleft, dual, or commercial licenses. + +## 3. Patent License + +You grant the Project Maintainers and recipients of the Project a perpetual, worldwide, non-exclusive, royalty-free +patent license to make, use, sell, offer for sale, import, and otherwise transfer your contributions. + +## 4. Representations and Warranties + +By submitting a contribution, you represent that: + +- The contribution is your original work, or you have the right to submit it. +- You are legally entitled to grant the above licenses. +- The contribution does not violate any third-party rights. + +## 5. Definitions + +- **“Contribution”** means any source code, object code, patch, documentation, or other material submitted to the + Project. +- **“Project Maintainers”** currently means the copyright holders of JL (Java Leaflet) Map and any successors they + designate. + +## 6. Acceptance + +By submitting a contribution to the Project (e.g., via pull request, patch, or other means), you **agree to the terms of +this Contributor License Agreement.** \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b2a140..214feb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,37 +2,43 @@ First off — thanks for checking out this project. -While this is a small and focused library, and I'm not actively contributing, you're still welcome to open issues, suggest improvements, or fork it for your own use. +While this is a small and focused library, and I'm not actively contributing, you're still welcome to open issues, +suggest improvements, or fork it for your own use. ## Guidelines -If you do want to contribute, here are a few quick guidelines to follow to keep things clean and consistent: +If you do want to contribute, please read the CLA first, and then here are a few quick guidelines to follow to keep +things clean and consistent: ### 1. Keep It Aligned with the Project Scope -This library is meant to stay minimal, maintainable, and aligned with its current goals. If you're adding something, ask: +This library is meant to stay minimal, maintainable, and aligned with its current goals. If you're adding something, +ask: + - Does this keep the project simple? -- Does it make it easier to integrate JL Map - Java Leaflet into existing projects? +- Does it make it easier to integrate JL (Java Leaflet) Map into existing projects? - Is it a core use case? If you're not sure, open an issue first before writing code — happy to talk about it. ### 2. Code Style -Stick to standard Java conventions. If you're using an IDE like IntelliJ, the default formatter should be fine. We also added spotless since v2.2.1. Some specific preferences: +Stick to standard Java conventions. If you're using an IDE like IntelliJ, the default formatter should be fine. We also +added spotless since v2.2.1. Some specific preferences: - Keep every block of the code as small as possible, break it down if possible - Avoid unnecessary abstractions. - Avoid unnecessary documentation. -- Keep the code, variable/class/method/etc naming self explanatory -- Don’t go overboard creating tons of classes and interfaces just to perfectly follow a specific design or pattern—they're guidelines, not laws of nature. +- Keep the code, variable/class/method/etc naming self explanatory. +- Don’t go overboard creating tons of classes and interfaces just to perfectly follow a specific design or + pattern—they're guidelines, not laws of nature. - Prefer immutability where possible. - Javadoc ,at least, public APIs. ### 3. Commit Messages -- Keep commit messages short and meaningful. -- Avoid using any special characters. -- Focus on delivering enough context than being grammatically correct. +- Keep commit messages short and meaningful. +- Avoid using any special characters. +- Focus on delivering enough context than being grammatically correct. -Thank you. +Thank you. \ No newline at end of file diff --git a/Feature.md b/Feature.md new file mode 100644 index 0000000..cae3175 --- /dev/null +++ b/Feature.md @@ -0,0 +1,148 @@ +# jlmap Feature Matrix + +This document compares the available features and options between the JavaFX and Vaadin versions of the jlmap project. +It is intended to help users and developers understand which features are fully supported, partially supported, or not +available in each version. + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|----------------------------|:------:|:------:|---------------------------------------------------------------------------------| +| Map Providers | ✅ | ✅ | OSM, MapTiler, etc. supported in both | +| Add Marker | ✅ | ✅ | Both support adding/removing markers | +| Add Popup | ✅ | ✅ | Both support popups | +| Add Polyline | ✅ | ✅ | Both support polylines | +| Add MultiPolyline | ✅ | ✅ | Both support multi-polylines | +| Add Polygon | ✅ | ✅ | Both support polygons (with holes) | +| Add Circle | ✅ | ✅ | Both support circles | +| Add Circle Marker | ✅ | ✅ | Both support circle markers | +| Add Image Overlay | ✅ | ✅ | Both support image overlays (JLImageOverlay) | +| Add GeoJSON | ✅ | ✅ | Both support loading GeoJSON from URL, file, or string | +| Remove Marker | ✅ | ✅ | Both support removing markers | +| Remove Popup | ✅ | ✅ | Both support removing popups | +| Remove Polyline | ✅ | ✅ | Both support removing polylines | +| Remove Polygon | ✅ | ✅ | Both support removing polygons | +| Remove Circle | ✅ | ✅ | Both support removing circles | +| Remove Image Overlay | ⚠️ | ⚠️ | JavaFX: Not clearly exposed; Vaadin: not clearly exposed | +| Remove GeoJSON | ✅ | ✅ | Both support removing GeoJSON by id | +| Set View/Center | ✅ | ✅ | Both support setting map center | +| Set Zoom | ✅ | ✅ | Both support set/zoomIn/zoomOut | +| Fit Bounds | ✅ | ✅ | Both support fitBounds | +| Fit World | ✅ | ✅ | Both support fitWorld | +| Pan To | ✅ | ✅ | Both support panTo | +| Fly To | ✅ | ✅ | Both support flyTo | +| Event Listeners | ✅ | ✅ | Both support map and object event listeners | +| Custom Map Options | ✅ | ✅ | Both support custom options via JLMapOption/JLOptions | +| Layer Control | ⚠️ | ⚠️ | Basic support; advanced layer control (toggle, group) not fully exposed | +| UI Customization | ⚠️ | ⚠️ | JavaFX: via JavaFX API; Vaadin: via Vaadin API, but not all Leaflet UI features | +| Map Blur/Effects | ✅ | ❌ | JavaFX: supports blur via JavaFX; Vaadin: not available | +| Responsive Layout | ✅ | ✅ | Both support responsive layouts | +| Map Callbacks | ✅ | ✅ | Both support callback handlers | +| Drag & Drop | ✅ | ✅ | Both support drag events for markers, etc. | +| Tooltip Support | ⚠️ | ⚠️ | Not clearly exposed in API, but possible via custom JS | +| Custom Icons | ✅ | ✅ | Both support custom marker icons | +| Z-Index/Layer Order | ✅ | ✅ | Supported via options | +| Animation | ⚠️ | ⚠️ | JavaFX: possible via JavaFX; Vaadin: limited to Leaflet/JS animations | +| Print/Export | ❌ | ❌ | Not available out of the box | +| Updating objects | ✅ | ✅ | Updating objects' properties works for both plarfrom | +| Reflect Clientside Changes | ⚠️ | ⚠️ | Not all the changes from client side is being reflected on the server side | + +**Legend:** + +- ✅ Available +- ⚠️ Partially Available (see Description/Notes) +- ❌ Not Available + +This table is based on the current state of the codebase and may change as features are added or improved. For more +details, see the API documentation or source code. + +# JLMarker Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|-----------------|:------:|:------:|------------------------------------------------------| +| Add Marker | ✅ | ✅ | Both support adding markers | +| Remove Marker | ✅ | ✅ | Both support removing markers | +| Draggable | ✅ | ✅ | Both support draggable markers | +| Custom Icon | ✅ | ✅ | Both support custom marker icons | +| Popup/Tooltip | ✅ | ✅ | Both support popups; tooltips possible via custom JS | +| Event Listeners | ✅ | ✅ | Click, drag, move, etc. | +| Z-Index | ✅ | ✅ | Supported via options | +| Animation | ⚠️ | ⚠️ | JavaFX: possible via JavaFX; Vaadin: limited | + +- Animation can be handled on the server side using framework features or by updating the objects' properties + periodically. + +# JLCircle Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|-------------------|:------:|:------:|-------------------------------| +| Add Circle | ✅ | ✅ | Both support adding circles | +| Remove Circle | ✅ | ✅ | Both support removing circles | +| Set Radius | ✅ | ✅ | Both support setting radius | +| Set Center | ✅ | ✅ | Both support setting center | +| Fill/Stroke Color | ✅ | ✅ | Both support via options | +| Opacity | ✅ | ✅ | Both support via options | +| Event Listeners | ✅ | ✅ | Click, drag, move, etc. | + +# JLCircleMarker Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|----------------------|:------:|:------:|--------------------------------------| +| Add Circle Marker | ✅ | ✅ | Both support adding circle markers | +| Remove Circle Marker | ✅ | ✅ | Both support removing circle markers | +| Set Radius | ✅ | ✅ | Both support setting radius | +| Fill/Stroke Color | ✅ | ✅ | Both support via options | +| Opacity | ✅ | ✅ | Both support via options | +| Event Listeners | ✅ | ✅ | Click, drag, move, etc. | + +# JLPolyline Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|---------------------|:------:|:------:|---------------------------------| +| Add Polyline | ✅ | ✅ | Both support adding polylines | +| Remove Polyline | ✅ | ✅ | Both support removing polylines | +| Set Vertices | ✅ | ✅ | Both support setting vertices | +| Stroke Color/Weight | ✅ | ✅ | Both support via options | +| Opacity | ✅ | ✅ | Both support via options | +| Event Listeners | ✅ | ✅ | Click, drag, move, etc. | + +# JLMultiPolyline Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|----------------------|:------:|:------:|---------------------------------------| +| Add MultiPolyline | ✅ | ✅ | Both support adding multi-polylines | +| Remove MultiPolyline | ✅ | ✅ | Both support removing multi-polylines | +| Set Vertices | ✅ | ✅ | Both support setting vertices | +| Stroke Color/Weight | ✅ | ✅ | Both support via options | +| Opacity | ✅ | ✅ | Both support via options | +| Event Listeners | ✅ | ✅ | Click, drag, move, etc. | + +# JLPolygon Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|-------------------|:------:|:------:|-------------------------------------------| +| Add Polygon | ✅ | ✅ | Both support adding polygons (with holes) | +| Remove Polygon | ✅ | ✅ | Both support removing polygons | +| Set Vertices | ✅ | ✅ | Both support setting vertices | +| Fill/Stroke Color | ✅ | ✅ | Both support via options | +| Opacity | ✅ | ✅ | Both support via options | +| Event Listeners | ✅ | ✅ | Click, drag, move, etc. | + +# JLImageOverlay Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|----------------------|:------:|:------:|------------------------------------| +| Add Image Overlay | ✅ | ✅ | Both support adding image overlays | +| Remove Image Overlay | ⚠️ | ⚠️ | Not clearly exposed in API | +| Set Bounds | ✅ | ✅ | Both support setting bounds | +| Set Image URL | ✅ | ✅ | Both support setting image URL | +| Opacity | ✅ | ✅ | Both support via options | +| Z-Index | ✅ | ✅ | Both support via options | +| Event Listeners | ❌ | ❌ | Not available | + +# JLGeoJson Feature Matrix + +| Feature/Option | JavaFX | Vaadin | Description/Notes | +|-----------------|:------:|:------:|-------------------------------------------------------------------------------------| +| Add GeoJSON | ✅ | ✅ | Both support loading GeoJSON from URL, file, or string | +| Remove GeoJSON | ✅ | ✅ | Both support removing GeoJSON by id | +| Style Features | ✅ | ✅️ | Limited but supported; depends on the complexity of the GeoJSON content and options | +| Event Listeners | ✅️ | ✅️ | Limited; depends on implementation | diff --git a/LICENSE b/LICENSE index f288702..ee9ed41 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,504 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 - Copyright (C) 2007 Free Software Foundation, Inc. + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + Preamble - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with this License. - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. END OF TERMS AND CONDITIONS - How to Apply These Terms to Your New Programs + How to Apply These Terms to Your New Libraries - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. - + Copyright (C) - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. - This program is distributed in the hope that it will be useful, + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. - You should have received a copy of the GNU General Public License - along with this program. If not, see . + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + USA Also add information on how to contact you by electronic and paper mail. - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random + Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! \ No newline at end of file diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..0e1d6a1 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,506 @@ +# Migration Guide: JavaFX Module (v1.x → v2.0.0) + +This guide helps you migrate your JavaFX applications from Java Leaflet v1.x to v2.0.0. The migration is **minimal** and focused - most of your existing code will work without changes. + +## 🚀 **What's New in v2.0.0** + +- **Multi-Module Architecture**: Clean separation between API, JavaFX, and Vaadin implementations +- **Vaadin Support**: New Vaadin component implementation alongside existing JavaFX support +- **Unified API**: Consistent interface across different UI frameworks with new JLMap interface +- **Enhanced Event Handling**: Refactored event system with improved map event handling and better center/bounds + calculations +- **Modern Map Providers**: New JLMapProvider system replacing legacy MapType enumeration +- **Enhanced Object Model**: New JLObjectBase implementation with improved callback handling +- **Enhanced Modularity**: Better separation of concerns and extensibility +- **Modern Java**: Full Java 17+ and JPMS support + +## 🔄 **Migration Overview** + +| Component | v1.x | v2.0.0 | Change Required | +|--------------------|-----------------------------------|--------------------------------------|--------------------| +| **Main Class** | `io.github.makbn.jlmap.JLMapView` | `io.github.makbn.jlmap.fx.JLMapView` | ✅ **Yes** | +| **Maven Artifact** | `jlmap` | `jlmap-fx` | ✅ **Yes** | +| **Map Provider** | `JLProperties.MapType` | `JLMapProvider` | ⚠️ **Recommended** | +| **API Classes** | `io.github.makbn.jlmap.*` | `io.github.makbn.jlmap.*` | ❌ **No** | +| **Usage Code** | Most existing code | Most existing code | ❌ **No** | + +## 📋 **Step-by-Step Migration** + +### **Step 1: Update Maven Dependency** + +**Before (v1.x):** +```xml + + io.github.makbn + jlmap + 1.9.5 + +``` + +**After (v2.0.0):** +```xml + + io.github.makbn + jlmap-fx + 2.0.0 + +``` + +### **Step 2: Update Import Statement** + +**Before (v1.x):** +```java +import io.github.makbn.jlmap.JLMapView; +``` + +**After (v2.0.0):** +```java +import io.github.makbn.jlmap.fx.JLMapView; +``` + +### **Step 3: Update Module Declaration (if using JPMS)** + +**Before (v1.x):** +```java +module your.module.name { + requires io.github.makbn.jlmap; + // ... other requires +} +``` + +**After (v2.0.0):** +```java +module your.module.name { + requires io.github.makbn.jlmap.fx; + // ... other requires +} +``` + +## 📖 **Complete Migration Examples** + +### **Example 1: Basic Map Setup** + +**Before (v1.x):** +```java +import io.github.makbn.jlmap.JLMapView; +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.model.JLLatLng; +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.layout.AnchorPane; +import javafx.stage.Stage; + +public class MapExample extends Application { + + @Override + public void start(Stage stage) { + // Create a map view + JLMapView map = JLMapView.builder() + .mapType(JLProperties.MapType.OSM_MAPNIK) + .startCoordinate(JLLatLng.builder() + .lat(51.044) + .lng(114.07) + .build()) + .showZoomController(true) + .build(); + + // Create the scene + AnchorPane root = new AnchorPane(map); + Scene scene = new Scene(root, 800, 600); + + stage.setTitle("Java Leaflet Map"); + stage.setScene(scene); + stage.show(); + } + + public static void main(String[] args) { + launch(args); + } +} +``` + +**After (v2.0.0):** +```java +import io.github.makbn.jlmap.fx.JLMapView; // ← Only this import changes +import io.github.makbn.jlmap.JLProperties; // ← No change +import io.github.makbn.jlmap.model.JLLatLng; // ← No change +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.layout.AnchorPane; +import javafx.stage.Stage; + +public class MapExample extends Application { + + @Override + public void start(Stage stage) { + // Create a map view with modern provider (recommended) + JLMapView map = JLMapView.builder() + .jlMapProvider(JLMapProvider.OSM_MAPNIK.build()) // New provider system + .startCoordinate(JLLatLng.builder() + .lat(51.044) + .lng(114.07) + .build()) + .showZoomController(true) + .build(); + + // OR continue using legacy mapType (still supported) + JLMapView mapLegacy = JLMapView.builder() + .mapType(JLProperties.MapType.OSM_MAPNIK) // Legacy approach still works + .startCoordinate(JLLatLng.builder() + .lat(51.044) + .lng(114.07) + .build()) + .showZoomController(true) + .build(); + + // Create the scene - EXACTLY the same code! + AnchorPane root = new AnchorPane(map); + Scene scene = new Scene(root, 800, 600); + + stage.setTitle("Java Leaflet Map"); + stage.setScene(scene); + stage.show(); + } + + public static void main(String[] args) { + launch(args); + } +} +``` + +### **Example 2: Advanced Map Usage** + +**Before (v1.x):** +```java +import io.github.makbn.jlmap.JLMapView; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLOptions; +import io.github.makbn.jlmap.listener.OnJLMapViewListener; + +public class AdvancedMapExample { + + public void setupMap() { + JLMapView map = JLMapView.builder() + .mapType(JLProperties.MapType.OSM_MAPNIK) + .startCoordinate(new JLLatLng(51.04530, -114.06283)) + .showZoomController(true) + .build(); + + // Add markers + map.getUiLayer().addMarker( + new JLLatLng(51.04530, -114.06283), + "Calgary", + true + ); + + // Add shapes + map.getVectorLayer().addCircle( + new JLLatLng(51.04530, -114.06283), + 30000, + JLOptions.DEFAULT + ); + + // Set view + map.setView(new JLLatLng(10, 10)); + map.getControlLayer().setZoom(5); + } +} +``` + +**After (v2.0.0):** +```java +import io.github.makbn.jlmap.fx.JLMapView; // ← Only this import changes +import io.github.makbn.jlmap.model.JLLatLng; // ← No change +import io.github.makbn.jlmap.model.JLOptions; // ← No change +import io.github.makbn.jlmap.listener.OnJLMapViewListener; // ← No change + +public class AdvancedMapExample { + + public void setupMap() { + // Modern provider approach (recommended) + JLMapView map = JLMapView.builder() + .jlMapProvider(JLMapProvider.OSM_MAPNIK.build()) // New provider system + .startCoordinate(new JLLatLng(51.04530, -114.06283)) + .showZoomController(true) + .build(); + + // EXACTLY the same code! + map.getUiLayer().addMarker( + new JLLatLng(51.04530, -114.06283), + "Calgary", + true + ); + + // EXACTLY the same code! + map.getVectorLayer().addCircle( + new JLLatLng(51.04530, -114.06283), + 30000, + JLOptions.DEFAULT + ); + + // EXACTLY the same code! + map.setView(new JLLatLng(10, 10)); + map.getControlLayer().setZoom(5); + } +} +``` + +## Listeners + +Version 1.x had two different listeners, `OnJLObjectActionListener` for JL Objects, and `OnJLMapViewListener` for the +map itself. With version 2.x to make it more simple +these two listeners have been replaced by `OnJLActionListener` that only offers one functional method. + +**Before (v1.x):** + +```jshelllanguage + +// map listener + map.setMapListener(new OnJLMapViewListener() { + @Override + public void mapLoadedSuccessfully(@NonNull JLMapView mapView) { + // ... + } + + @Override + public void mapFailed() { + // ... + } + + @Override + public void onAction(Event event) { + // ... + } + }); + + // jl object listener + map.getVectorLayer().addPolygon(vertices).setOnActionListener(new OnJLObjectActionListener<>() { + @Override + public void click(JLPolygon jlPolygon, Action action) { + // ... + } + + @Override + public void move(JLPolygon jlPolygon, Action action) { + // ... + } + }); + +``` + +**After (v2.0.0):** + +```jshelllanguage + // map listener + mapView.setOnActionListener((source, event) -> { + if (event instanceof MapEvent mapEvent && mapEvent.action() == JLAction.MAP_LOADED) { + // ... + } else if (event instanceof ClickEvent clickEvent) { + //... + } + }); + + // jl object listener + marker.setOnActionListener((jlMarker, event1) -> { + if (event1 instanceof MoveEvent) { + // ... + } else if (event1 instanceof ClickEvent) { + // ... + } + }); +``` + +## 🔍 **What Stays the Same** + +✅ **No changes needed for:** + +### **API Classes** + +- `io.github.makbn.jlmap.JLProperties` (constants and legacy MapType enum) +- `io.github.makbn.jlmap.model.*` (JLLatLng, JLOptions, JLColor, etc.) +- `io.github.makbn.jlmap.listener.*` (OnJLMapViewListener, OnJLObjectActionListener, etc.) +- `io.github.makbn.jlmap.layer.leaflet.*` (interfaces) +- `io.github.makbn.jlmap.geojson.*` (GeoJSON support) +- `io.github.makbn.jlmap.exception.*` (exceptions) + +### **Enhanced Features (Backward Compatible)** + +- **JLMap Interface**: New unified interface for both JavaFX and Vaadin implementations +- **JLObjectBase**: Enhanced base class with improved event handling +- **Event System**: Refactored with better parameter passing and callback handling + +### **Functionality** +- Builder pattern usage +- Method calls and API usage +- Event handling and listeners +- Layer management (UI, Vector, Control, GeoJSON) +- Model classes and builders +- Properties and configuration +- Map interactions and controls + +## 🏗️ **Project Structure Changes** + +### **v1.x (Single Module)** +``` +java_leaflet/ +├── src/ +│ └── main/java/io/github/makbn/jlmap/ +│ ├── JLMapView.java ← Main class +│ ├── JLProperties.java ← Properties +│ ├── model/ ← Models +│ ├── layer/ ← Layers +│ └── listener/ ← Listeners +└── pom.xml +``` + +### **v2.0.0 (Multi-Module)** +``` +java_leaflet/ +├── jlmap-parent/ ← Parent POM +├── jlmap-api/ ← Core API +│ └── src/main/java/io/github/makbn/jlmap/ +│ ├── JLProperties.java ← Properties (same) +│ ├── model/ ← Models (same) +│ ├── layer/ ← Layers (same) +│ └── listener/ ← Listeners (same) +├── jlmap-fx/ ← JavaFX Implementation +│ └── src/main/java/io/github/makbn/jlmap/fx/ +│ └── JLMapView.java ← Main class (moved here) +├── jlmap-vaadin/ ← Vaadin Implementation +└── jlmap-vaadin-demo/ ← Vaadin Demo +``` + +## 🆕 **New Features & Enhancements** + +### **Modern Map Provider System** + +Replace legacy `JLProperties.MapType` with new `JLMapProvider` system: + +**New Approach (Recommended):** + +```java +// Use built-in providers +JLMapProvider osmProvider = JLMapProvider.OSM_MAPNIK.build(); +JLMapProvider topoProvider = JLMapProvider.OPEN_TOPO.build(); + +// For providers requiring API keys +JLMapProvider mapTilerProvider = JLMapProvider.MAP_TILER + .parameter(new JLMapOption.Parameter("key", "your-api-key")) + .build(); + +// Use with map +JLMapView map = JLMapView.builder() + .jlMapProvider(osmProvider) + .startCoordinate(new JLLatLng(35.63, 51.45)) + .build(); +``` + +**Legacy Approach (Still Supported):** + +```java +// Old enum-based approach still works +JLMapView map = JLMapView.builder() + .mapType(JLProperties.MapType.OSM_MAPNIK) + .startCoordinate(new JLLatLng(35.63, 51.45)) + .build(); +``` + +### **Enhanced Event Handling** + +v2.0.0 includes improved event handling with better parameter passing: + +- Enhanced map center and bounds calculations +- Improved callback registration system +- Better event parameter handling for user interactions + +## 🚨 **Common Migration Issues** + +### **Issue 1: Class Not Found** +``` +Error: cannot find symbol: class JLMapView +``` + +**Solution:** Update import to `io.github.makbn.jlmap.fx.JLMapView` + +### **Issue 2: Module Not Found** +``` +Error: module not found: io.github.makbn.jlmap +``` + +**Solution:** Update module-info.java to `requires io.github.makbn.jlmap.fx;` + +### **Issue 3: Maven Dependency Resolution** +``` +Error: Could not resolve dependency io.github.makbn:jlmap +``` + +**Solution:** Change artifactId from `jlmap` to `jlmap-fx` + +### **Issue 4: Deprecated MapType Usage** + +``` +Warning: JLProperties.MapType may be deprecated in future versions +``` + +**Solution:** Migrate to `JLMapProvider` system for future compatibility + +## 🧪 **Testing Your Migration** + +### **1. Build Test** +```bash +mvn clean compile +``` + +### **2. Runtime Test** +```bash +mvn javafx:run +``` + +### **3. Module Test (if using JPMS)** +```bash +jar --describe-module --file target/jlmap-fx-2.0.0.jar +``` + +## 📚 **Additional Resources** + +- **API Documentation**: See the `jlmap-api` module for core interfaces +- **JavaFX Examples**: See the `jlmap-fx` module for JavaFX usage +- **Vaadin Examples**: See the `jlmap-vaadin-demo` for Vaadin usage +- **Leaflet Documentation**: [https://leafletjs.com/](https://leafletjs.com/) + +## 🎯 **Migration Checklist** + +### **Essential Changes (Required)** +- [ ] Update Maven dependency from `jlmap` to `jlmap-fx` +- [ ] Update import from `io.github.makbn.jlmap.JLMapView` to `io.github.makbn.jlmap.fx.JLMapView` +- [ ] Update module-info.java (if using JPMS) from `requires io.github.makbn.jlmap` to `requires io.github.makbn.jlmap.fx` +- [ ] Test compilation with `mvn clean compile` +- [ ] Test runtime with `mvn javafx:run` +- [ ] Verify all existing functionality works as expected + +### **Recommended Upgrades (Optional)** + +- [ ] Migrate from `JLProperties.MapType` to `JLMapProvider` system +- [ ] Review and test enhanced event handling features +- [ ] Update any custom map provider configurations +- [ ] Consider using new JLMap interface for better abstraction + +## 💡 **Pro Tips** + +1. **Search and Replace**: Use your IDE's search and replace to update all `JLMapView` imports at once +2. **Incremental Testing**: Test each change individually to isolate any issues +3. **Backup**: Keep a backup of your working v1.x code until migration is complete +4. **Version Control**: Commit your changes incrementally to track progress + +## 🤝 **Need Help?** + +If you encounter issues during migration: + +1. **Check the README**: [README.md](README.md) for comprehensive project information +2. **Review Examples**: Look at the demo applications in `jlmap-fx` and `jlmap-vaadin-demo` +3. **Check Dependencies**: Ensure all required dependencies are properly configured +4. **Verify Java Version**: Ensure you're using Java 17 or higher + +--- + +**Remember**: The migration is designed to be minimal. If you're making extensive changes to your code, you might be doing something wrong. The goal is to change as little as possible while gaining the benefits of the new modular architecture. \ No newline at end of file diff --git a/README.md b/README.md index 6ce8205..d3a4338 100644 --- a/README.md +++ b/README.md @@ -1,231 +1,339 @@ # Java Leaflet (JLeaflet) -A Java library for integrating Leaflet maps into JavaFX applications with full Java Platform Module System (JPMS) support. +A Java library for integrating Leaflet maps into Java applications with full Java Platform Module System (JPMS) support. +Now supporting both **JavaFX** and **Vaadin** implementations with a unified API. -* Current version: **v1.9.5** -* Next version (Vaadin and JavaFx): **v2.0.0** https://github.com/makbn/java_leaflet/tree/dev/v2.0.0 +* Current version: **v2.0.0** Project Source Code: https://github.com/makbn/java_leaflet +Project Wiki: https://github.com/makbn/java_leaflet/wiki ![Java-Leaflet Test](https://github.com/makbn/java_leaflet/blob/master/.github/doc/app.png?raw=true) -> Leaflet is the leading open-source JavaScript library for mobile-friendly interactive maps. Weighing just about 38 KB of JS, it has all the mapping features most > developers ever need. -> Leaflet is designed with simplicity, performance and usability in mind. It works efficiently across all major desktop and mobile platforms, can be extended with > lots of plugins, has a beautiful, easy to use and well-documented API and a simple, readable source code that is a joy to contribute to. +> Leaflet is the leading open-source JavaScript library for mobile-friendly interactive maps. Weighing just about 38 KB +> of JS, it has all the mapping features most developers ever need. +> Leaflet is designed with simplicity, performance and usability in mind. It works efficiently across all major desktop +> and mobile platforms, can be extended with lots of plugins, has a beautiful, easy to use and well-documented API and a +> simple, readable source code that is a joy to contribute to. -## Features +## 🏗️ Project Structure +This project is now organized as a multi-module Maven project: + +``` +java_leaflet/ +├── jlmap-parent/ # Parent POM +├── jlmap-api/ # Core API and abstractions +├── jlmap-fx/ # JavaFX implementation +├── jlmap-vaadin/ # Vaadin component implementation +└── jlmap-vaadin-demo/ # Vaadin demo application +``` + +### Module Overview + +- **`jlmap-api`**: Core abstractions, interfaces, and models used by all implementations +- **`jlmap-fx`**: JavaFX-specific implementation using WebView +- **`jlmap-vaadin`**: Vaadin component implementation for web applications +- **`jlmap-vaadin-demo`**: Complete Vaadin demo application showcasing the fluent API + +## ✨ Features + +- **Multi-Framework Support**: JavaFX and Vaadin implementations - **Java Platform Module System (JPMS) Compatible**: Fully modularized for Java 17+ -- **JavaFX Integration**: Native JavaFX WebView integration -- **Multiple Map Providers**: Support for OpenStreetMap, Mapnik, and other tile providers +- **Unified API**: Consistent interface across different UI frameworks +- **Multiple Map Providers**: Support for OpenStreetMap, Mapnik, and other tile providers with the ability to add custom + providers - **Interactive Features**: Markers, polygons, polylines, circles, and more -- **Event Handling**: Comprehensive event system for map interactions -- **GeoJSON Support**: Load and display GeoJSON data +- **Event Handling**: Comprehensive bi-directional event system for map interactions, receiving events from client and + sending commands to the map +- **GeoJSON Support**: Load and display GeoJSON data with support for custom styling and filtering - **Customizable**: Extensive customization options for map appearance and behavior +- **Fluent API**: Builder pattern and method chaining for easy configuration +- **Context Menus**: Support for native context menus on map and objects for both implementations + +The goal is to match almost all the native Leaflet features across both implementations while maintaining a clean and +modular architecture. +However, some features may be available at the moment. To see which features are supported in each implementation, +refer to the [Feature Comparison Table](Feature.md). -## Requirements +## 📋 Requirements - **Java**: 17 or higher -- **JavaFX**: 19.0.2.1 or higher - **Maven**: 3.6+ (for building) +- **JavaFX**: 19.0.2.1 or higher (for JavaFX implementation) +- **Vaadin**: 24 or higher (for Vaadin implementation) -## Module Information - -This project is fully modularized using the Java Platform Module System (JPMS). The module name is `io.github.makbn.jlmap`. - -### Module Dependencies - -The module requires the following dependencies: +## 🚀 Quick Start -- **JavaFX Modules**: `javafx.controls`, `javafx.base`, `javafx.swing`, `javafx.web`, `javafx.graphics` -- **JDK Modules**: `jdk.jsobject` -- **Logging**: `org.apache.logging.log4j`, `org.apache.logging.log4j.core` -- **JSON Processing**: `com.google.gson`, `com.fasterxml.jackson.databind` -- **Annotations**: `org.jetbrains.annotations`, `lombok` +### JavaFX Implementation -### Module Exports +Add the JavaFX dependency to your `pom.xml`: -The following packages are exported for public use: - -- `io.github.makbn.jlmap` - Main package -- `io.github.makbn.jlmap.layer` - Layer management -- `io.github.makbn.jlmap.layer.leaflet` - Leaflet-specific layer interfaces -- `io.github.makbn.jlmap.listener` - Event listeners -- `io.github.makbn.jlmap.model` - Data models -- `io.github.makbn.jlmap.exception` - Custom exceptions -- `io.github.makbn.jlmap.geojson` - GeoJSON support +```xml -## Installation + + io.github.makbn + jlmap-fx + 2.0.0 + +``` -### Maven +### Vaadin Implementation -Add the following dependency to your `pom.xml`: +Add the Vaadin dependency to your `pom.xml`: ```xml + io.github.makbn - jlmap - 1.9.5 + jlmap-vaadin + 2.0.0 ``` -### Module Path +Also rememebr to allow the module in your properties file: -When running your application, ensure you include the module in your module path: - -```bash -mvn javafx:run +```properties +# For more information https://vaadin.com/docs/latest/flow/integrations/spring/configuration#special-configuration-parameters +vaadin.allowed-packages=io.github.makbn.jlmap.vaadin ``` -## Quick Start +Read more about Vaadin +configuration [here!](https://vaadin.com/docs/latest/flow/integrations/spring/configuration#configure-the-scanning-of-packages) + +## 📖 Usage Examples -### Basic Map Setup +### JavaFX Implementation ```java -import io.github.makbn.jlmap.*; +import io.github.makbn.jlmap.fx.JLMapView; +import io.github.makbn.jlmap.JLProperties; import io.github.makbn.jlmap.model.JLLatLng; import javafx.application.Application; import javafx.scene.Scene; import javafx.scene.layout.AnchorPane; import javafx.stage.Stage; -public class MapExample extends Application { - +public class JavaFXMapExample extends Application { + @Override public void start(Stage stage) { // Create a map view JLMapView map = JLMapView.builder() - .mapType(JLProperties.MapType.OSM_MAPNIK) + .jlMapProvider(JLMapProvider.MAP_TILER.parameter(new JLMapOption.Parameter("key", MAP_API_KEY)).build()) .startCoordinate(JLLatLng.builder() .lat(51.044) .lng(114.07) .build()) .showZoomController(true) .build(); - + // Create the scene AnchorPane root = new AnchorPane(map); Scene scene = new Scene(root, 800, 600); - - stage.setTitle("Java Leaflet Map"); + + stage.setTitle("Java Leaflet Map (JavaFX)"); stage.setScene(scene); stage.show(); } - + public static void main(String[] args) { launch(args); } } ``` -Based on Leaflet JS, you can interact with map in different layers. in this project, you can access different functions with this layer: +### Vaadin Implementation + +```java +import io.github.makbn.jlmap.vaadin.JLMapView; +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.model.JLLatLng; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; + +@Route("") +public class VaadinMapExample extends VerticalLayout { + + public VaadinMapExample() { + setSizeFull(); + + // Create a map view + mapView = JLMapView.builder() + .jlMapProvider(JLMapProvider.MAP_TILER.parameter(new JLMapOption.Parameter("key", MAP_API_KEY)).build()) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) // Paris + .showZoomController(false) + .build(); + + add(map); + expand(map); + } +} +``` + +## 🎯 Core API Features + +### Map Control + +```jshelllanguage +// Change the current coordinate + mapView.setView(JLLatLng.builder() + .lng(48.864716) + .lat(2.349014) + .build()); + // Map zoom functionalities + map. -* `map` for direct changes on map -* `map.getUiLayer()` for changes on UI layer like markers. -* `map.getVectorLayer()` represents the Vector layer on Leaflet map. -* `map.getControlLayer()` represents the control layer for setting the zoom level. -* `map.getGeoJsonLayer()` represents the GeoJson layer. + getControlLayer().setZoom(5); + map.getControlLayer().zoomIn(2); + map.getControlLayer().zoomOut(1); +``` ### Adding Markers -```java +```jshelllanguage // Add a marker to the UI layer -map.getUiLayer() - .addMarker(JLLatLng.builder() - .lat(35.63) - .lng(51.45) - .build(), "Tehran", true); + JLMarker marker = map.getUiLayer() + .addMarker(JLLatLng.builder() + .lat(35.63) + .lng(51.45) + .build(), "Tehran", true); + +// Add event listeners + marker.setOnActionListener((jlMarker, event) -> { + if (event instanceof ClickEvent) { + log.info("Marker clicked"); + } + }); + + marker.remove(); // Remove the marker ``` +### Adding GeoJSON -Controlling map's zoom level and coordinate: -```java -// change the current coordinate -map.setView(JLLatLng.builder() - .lng(10) - .lat(10) - .build()); - -// map zoom functionalities -map.getControlLayer().setZoom(5); -map.getControlLayer().zoomIn(2); -map.getControlLayer().zoomOut(1); +```jshelllanguage +import io.github.makbn.jlmap.model.JLGeoJsonOptions; + +// Load GeoJSON with custom styling + JLGeoJsonOptions options = JLGeoJsonOptions.builder() + .styleFunction(features -> JLOptions.builder() + .fill(true) + .fillColor(JLColor.fromHex((String) features.get(0).get("fill"))) + .fillOpacity((Double) features.get(0).get("fill-opacity")) + .stroke(true) + .color(JLColor.fromHex((String) features.get(0).get("stroke"))) + .build()) + .build(); + + JLGeoJson styledGeoJson = map.getGeoJsonLayer() + .addFromUrl("https://example.com/data.geojson", options); ``` -### Adding Shapes +Read more about examples in +the [Examples and Tutorials](https://github.com/makbn/java_leaflet/wiki/Examples-and-Tutorials) page. -```java -// Add a circle -map.getVectorLayer() - .addCircle(JLLatLng.builder() - .lat(35.63) - .lng(51.45) - .build(), - 30000, - JLOptions.builder() - .color(Color.BLACK) - .build()); +### Layer Management + +The API provides access to different map layers: + +- **`map.getUiLayer()`**: UI elements like markers, popups +- **`map.getVectorLayer()`**: Vector graphics (polygons, polylines, circles) +- **`map.getControlLayer()`**: Map controls (zoom, pan, bounds) +- **`map.getGeoJsonLayer()`**: GeoJSON data loading and display + +## 🏃‍♂️ Running the Demos + +### JavaFX Demo + +```bash +cd jlmap-fx +mvn javafx:run ``` -you can add a listener for some Objects on the map: +### Vaadin Demo -```java -marker.setOnActionListener(new OnJLObjectActionListener() { - @Override - public void click(JLMarker object, Action action) { - System.out.println("object click listener for marker:" + object); - } - - @Override - public void move(JLMarker object, Action action) { - System.out.println("object move listener for marker:" + object); - } - }); +```bash +cd jlmap-vaadin-demo +mvn spring-boot:run ``` -## Building from Source +Then open your browser to `http://localhost:8080` + +## 🔧 Building from Source ### Prerequisites - Java 17 or higher - Maven 3.6+ +- Node.js (for Vaadin frontend compilation) ### Build Commands ```bash -# Clean and compile -mvn clean compile +# Build all modules +mvn clean install + +# Build specific module +mvn clean install -pl jlmap-api +mvn clean install -pl jlmap-fx +mvn clean install -pl jlmap-vaadin # Run tests mvn test # Package mvn package - -# Install to local repository -mvn install ``` -### Module-Aware Building +If you're migrating from version 1.x: -The project uses Maven's module-aware compilation. The `module-info.java` file defines the module structure and dependencies. +1. **Update Dependencies**: Change from `jlmap` to `jlmap-fx` or `jlmap-vaadin` +2. **Package Updates**: Update imports to use the new module structure +3. **Module Declaration**: Ensure your project has proper module configuration +4. **Build Configuration**: Update Maven configuration for the new dependencies +** [Complete Migration Guide](MIGRATION_GUIDE.md)** - Detailed step-by-step instructions for migrating from v1.x to +v2.0.0 -## Migration from Non-Modular Version +### Example Migration -If you're migrating from a non-modular version: +**Before (v1.x):** -1. **Update Dependencies**: Ensure all dependencies are module-compatible -2. **Module Declaration**: Add `module-info.java` to your project -3. **Import Updates**: Update any internal JavaFX imports -4. **Build Configuration**: Update Maven configuration for module support +```xml + + + io.github.makbn + jlmap + 1.9.5 + +``` + +**After (v2.0.0):** + +```xml + + + io.github.makbn + jlmap-fx + 2.0.0 + + + + +io.github.makbn +jlmap-vaadin +2.0.0 + +``` ## Troubleshooting ### Common Issues -1. **Module Not Found**: Ensure the module is in your module path -2. **Internal API Access**: Some JavaFX internal APIs are no longer accessible -3. **Lombok Issues**: Ensure annotation processing is properly configured +1. **Module Not Found**: Ensure the correct module is in your dependencies +2. **JavaFX Issues**: Verify JavaFX is properly configured for your Java version +3. **Vaadin Issues**: Ensure Node.js is installed for frontend compilation +4. **Lombok Issues**: Verify annotation processing is properly configured ### Module Path Issues @@ -233,7 +341,8 @@ If you encounter module path issues, verify: ```bash # Check if the module is properly packaged -jar --describe-module --file target/jlmap-1.9.5.jar +jar --describe-module --file target/jlmap-fx-2.0.0.jar +jar --describe-module --file target/jlmap-vaadin-2.0.0.jar ``` ## Contributing @@ -246,33 +355,32 @@ jar --describe-module --file target/jlmap-1.9.5.jar ## License -This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. +Since v2.0.0 This project is licensed under the GNU LESSER GENERAL PUBLIC LICENSE Version 2.1 - see +the [LICENSE](LICENSE) file for details. ## Author -**Mehdi Akbarian Rastaghi** (@makbn) - -## Changelog - -### Version 1.9.5 -- **Major**: Upgraded to Java Platform Module System (JPMS) -- **Major**: Updated to Java 17 compatibility -- **Major**: Removed internal JavaFX API dependencies -- **Enhancement**: Improved module structure and encapsulation -- **Enhancement**: Updated Maven configuration for module support -- **Fix**: Resolved Lombok annotation processing in module environment - -## TODO - -- [X] Adding GeoJson Support -- [ ] Adding better support for Map providers -- [ ] Adding SVG support -- [ ] Adding animation support -- [ ] Separating JS and HTML -- [ ] Publishing package on GitHub - - -**Disclaimer**: I've implemented this project for one of my academic paper in the area of geo-visualization. So, im not contributing actively! One more thing, I'm not a Javascript developer! - -### Previous Versions -See the [GitHub Branches](https://github.com/makbn/java_leaflet/branches) for previous version information. +**Matt Akbarian** (@makbn) + +## Roadmap + +- [X] Multi-module architecture +- [X] Vaadin implementation +- [X] Unified API design +- [X] Enhanced modularity +- [X] Enhanced GeoJSON support +- [X] Better map provider support +- [X] Support receiving events on Map and Objects +- [X] Support calling methods on JLObjects to set or update value on Js side +- [ ] Publish to Vaadin Directory +- [ ] SVG support +- [ ] Animation support +- [ ] implement object specific `JLOptions` +- [ ] Performance optimizations + +## Additional Resources + +- **API Documentation**: See the `jlmap-api` module for core interfaces +- **JavaFX Examples**: See the `jlmap-fx` module for JavaFX usage +- **Vaadin Examples**: See the `jlmap-vaadin-demo` for Vaadin usage +- **Leaflet Documentation**: [https://leafletjs.com/](https://leafletjs.com/) diff --git a/SIMPLE_GEOJSON_EXAMPLE.md b/SIMPLE_GEOJSON_EXAMPLE.md new file mode 100644 index 0000000..f61b309 --- /dev/null +++ b/SIMPLE_GEOJSON_EXAMPLE.md @@ -0,0 +1,227 @@ +# Simple GeoJSON Usage Examples + +This document demonstrates the simplified GeoJSON functional approach in Java Leaflet, based on real examples from the +demo applications. + +## Basic Usage + +### Loading from URL (JavaFX Example) + +```java +// Simple GeoJSON from URL with basic styling +JLGeoJson geoJsonObject = map.getGeoJsonLayer() + .addFromUrl("https://pkgstore.datahub.io/examples/geojson-tutorial/example/data/db696b3bf628d9a273ca9907adcea5c9/example.geojson", + JLGeoJsonOptions.builder() + .styleFunction(properties -> JLOptions.builder() + .color(JLColor.ORANGE) + .weight(2) + .fillColor(JLColor.PURPLE) + .fillOpacity(0.5) + .build()) + .build()); +``` + +### Loading from File (Vaadin Example) + +```java +// Simple file loading with default styling +mapView.getGeoJsonLayer().addFromFile(uploadedFile); +``` + +### Loading US Outline from URL (Vaadin Example) + +```java +// Loading a real-world GeoJSON dataset +mapView.getGeoJsonLayer().addFromUrl("https://eric.clst.org/assets/wiki/uploads/Stuff/gz_2010_us_outline_5m.json"); +``` + +## Advanced Styling with Property-Based Functions + +### Real-World Styling Example (Vaadin) + +```java +// Create GeoJSON options with style function based on feature properties +JLGeoJsonOptions options = JLGeoJsonOptions.builder() + .styleFunction(features -> JLOptions.builder() + .fill(true) + .fillColor(JLColor.fromHex((String) features.get(0).get("fill"))) + .fillOpacity((Double) features.get(0).get("fill-opacity")) + .stroke(true) + .color(JLColor.fromHex((String) features.get(0).get("stroke"))) + .build()) + .build(); + +// Apply to uploaded GeoJSON file +JLGeoJson geoJson = mapView.getGeoJsonLayer().addFromFile(uploadedFile, options); +``` + +## Functional Filtering with Real Data + +### ID-Based Filtering Example (Vaadin) + +```java +// Filter features based on their ID (show only even IDs) +JLGeoJsonOptions options = JLGeoJsonOptions.builder() + .filter(features -> { + Map featureProperties = features.get(0); + // Show features with even IDs only + return ((Integer) featureProperties.get("id")) % 2 == 0; + }) + .build(); + +JLGeoJson geoJson = mapView.getGeoJsonLayer().addFromFile(uploadedFile, options); +``` + +## Combined Styling and Filtering + +### Complete Real-World Example (Vaadin) + +```java +// Complete example with both styling and filtering +JLGeoJsonOptions options = JLGeoJsonOptions.builder() + .styleFunction(features -> JLOptions.builder() + .fill(true) + .fillColor(JLColor.fromHex((String) features.get(0).get("fill"))) + .fillOpacity((Double) features.get(0).get("fill-opacity")) + .stroke(true) + .color(JLColor.fromHex((String) features.get(0).get("stroke"))) + .build()) + .filter(features -> { + Map featureProperties = features.get(0); + // Show features with population > 1M, rivers longer than 500km, or parks larger than 100 hectares + return ((Integer) featureProperties.get("id")) % 2 == 0; + }) + .build(); + +// First fly to location to see the features +mapView.getControlLayer().flyTo(new JLLatLng(51.76, -114.06), 5); + +JLGeoJson geoJson = mapView.getGeoJsonLayer().addFromFile(uploadedFile, options); +geoJson.setOnActionListener((jlGeoJson, event) -> + Notification.show("GeoJSON Feature clicked: " + event)); +``` + +## Interactive Examples + +### Simple GeoJSON with Click Handler + +```java +// Load GeoJSON with click event handling +JLGeoJson geoJson = map.getGeoJsonLayer() + .addFromUrl("https://your-geojson-url.com/data.json", + JLGeoJsonOptions.builder() + .styleFunction(properties -> JLOptions.builder() + .color(JLColor.BLUE) + .weight(3) + .fillOpacity(0.3) + .build()) + .build()); + +// Add click handler to the GeoJSON layer +geoJson.setOnActionListener((source, event) -> { + System.out.println("GeoJSON clicked: " + event); +}); +``` + +### Context-Aware Navigation + +```java +// Fly to specific location before loading GeoJSON +mapView.getControlLayer().flyTo(new JLLatLng(40.7831, -73.9712), 12); // NYC +// Then load relevant GeoJSON data +mapView.getGeoJsonLayer().addFromUrl("https://nyc-geojson-data.com/boroughs.json"); +``` + +## Supported GeoJSON Sources + +### 1. From URL + +```java +// HTTP/HTTPS URLs +geoJsonLayer.addFromUrl("https://example.com/data.geojson"); +``` + +### 2. From File + +```java +// Local file or uploaded file +geoJsonLayer.addFromFile(new File("path/to/data.geojson")); +``` + +### 3. From String Content + +```java +// GeoJSON as string +String geoJsonString = "{ \"type\": \"FeatureCollection\", ... }"; +geoJsonLayer.addFromContent(geoJsonString); +``` + +## Property Access Patterns + +When working with feature properties in your styling and filtering functions: + +```java +// Access nested properties +Map featureProperties = features.get(0); +String name = (String) featureProperties.get("name"); +Integer population = (Integer) featureProperties.get("population"); +Double area = (Double) featureProperties.get("area"); +String hexColor = (String) featureProperties.get("fill"); +Double opacity = (Double) featureProperties.get("fill-opacity"); +``` + +## Real GeoJSON URLs for Testing + +These URLs from the demo applications work with the examples above: + +1. **Tutorial Example**: + `https://pkgstore.datahub.io/examples/geojson-tutorial/example/data/db696b3bf628d9a273ca9907adcea5c9/example.geojson` +2. **US Outline**: `https://eric.clst.org/assets/wiki/uploads/Stuff/gz_2010_us_outline_5m.json` + +## How It Works + +When Leaflet processes your GeoJSON: + +1. **Style Function**: For each feature, Leaflet calls the JavaScript function, which proxies back to your Java + `styleFunction` +2. **Filter Function**: For each feature, Leaflet calls the JavaScript function, which proxies back to your Java + `filter` predicate +3. **Callback**: Your Java functions receive the feature properties as a `Map` and return the + appropriate styling or filtering result + +This approach gives you: + +- Type safety with compile-time checking +- Full Java IDE support (autocomplete, refactoring) +- Simple, readable code +- No need to write JavaScript strings +- Access to real feature properties for dynamic styling +- Interactive event handling capabilities + +## Framework-Specific Examples + +### JavaFX Implementation + +```java +import io.github.makbn.jlmap.fx.JLMapView; + +JLMapView map = JLMapView.builder() + .jlMapProvider(JLMapProvider.OSM_MAPNIK.build()) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) + .build(); + +JLGeoJson geoJson = map.getGeoJsonLayer().addFromUrl(url, options); +``` + +### Vaadin Implementation + +```java +import io.github.makbn.jlmap.vaadin.JLMapView; + +JLMapView mapView = JLMapView.builder() + .jlMapProvider(JLMapProvider.OSM_MAPNIK.build()) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) + .build(); + +JLGeoJson geoJson = mapView.getGeoJsonLayer().addFromFile(file, options); +``` \ No newline at end of file diff --git a/jlmap-api/pom.xml b/jlmap-api/pom.xml new file mode 100644 index 0000000..fa0ccf2 --- /dev/null +++ b/jlmap-api/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + io.github.makbn + jlmap-parent + 2.0.0 + + + jlmap-api + jar + Java Leaflet (JLeaflet) - API + Abstraction layer for Java Leaflet map components + + + + GNU Lesser General Public License (LGPL) Version 2.1 or later + https://www.gnu.org/licenses/lgpl-2.1.html + https://github.com/makbn/java_leaflet + + + + + 17 + 17 + UTF-8 + + + + + + 3.13.0 + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + + org.jacoco + jacoco-maven-plugin + + + src/main/java + src/test/java + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + org.slf4j + slf4j-api + 2.0.16 + + + com.google.code.gson + gson + 2.13.1 + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + de.grundid.opendatalab + geojson-jackson + 1.14 + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.jetbrains + annotations + 24.0.1 + compile + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + 3.27.4 + test + + + \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/JLMap.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/JLMap.java new file mode 100644 index 0000000..40c64b8 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/JLMap.java @@ -0,0 +1,274 @@ +package io.github.makbn.jlmap; + +import io.github.makbn.jlmap.element.menu.JLHasContextMenu; +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.exception.JLMapNotReadyException; +import io.github.makbn.jlmap.layer.leaflet.*; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLObject; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * Core interface representing a Java Leaflet map instance with unified API across UI frameworks. + *

+ * This interface provides a consistent abstraction layer for map operations in both JavaFX and Vaadin + * implementations, allowing developers to write framework-agnostic mapping code. + *

+ *

+ * The interface provides access to various layers (UI, Vector, Control, GeoJSON) and map manipulation + * methods such as view setting, zooming, and retrieving map state information. + *

+ * + * @param The framework-specific type for web engine operations + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public interface JLMap extends JLObject>, JLHasContextMenu> { + + /** + * Returns the underlying web engine used for JavaScript execution and map rendering. + *

+ * The web engine handles the communication between Java code and the Leaflet JavaScript library, + * enabling dynamic map operations and event handling. + *

+ * + * @return the web engine instance specific to the UI framework implementation + */ + JLWebEngine getJLEngine(); + + /** + * Initializes and adds the JavaScript map controller to the document. + *

+ * This method sets up the necessary JavaScript infrastructure for map operations, + * including event handlers and communication bridges between Java and JavaScript layers. + *

+ *

+ * Typically called internally during map initialization and should not be invoked manually. + *

+ */ + void addControllerToDocument(); + + /** + * Returns the internal registry of map layers by their class types. + *

+ * This method provides access to the underlying layer management system, mapping + * layer interface classes to their concrete implementations. + *

+ *

+ * Note: This is primarily for internal use. Access layers through + * the dedicated getter methods instead: {@link #getUiLayer()}, {@link #getVectorLayer()}, + * {@link #getControlLayer()}, {@link #getGeoJsonLayer()}. + *

+ * + * @return a map of layer classes to their instances + */ + HashMap, LeafletLayer> getLayers(); + + /** + * Provides access to the UI layer for managing markers, popups, and overlays. + *

+ * The UI layer handles user interface elements that appear above the map content, + * including markers with custom icons, popups with HTML content, and image overlays. + *

+ * + * @return the UI layer interface for adding and managing user interface elements + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default LeafletUILayerInt getUiLayer() { + checkMapState(); + return getLayerInternal(LeafletUILayerInt.class); + } + + /** + * Provides access to the vector layer for managing geometric shapes and paths. + *

+ * The vector layer handles geometric elements such as circles, polygons, polylines, + * and other vectorial shapes that can be styled and made interactive. + *

+ * + * @return the vector layer interface for adding and managing geometric shapes + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default LeafletVectorLayerInt getVectorLayer() { + checkMapState(); + return getLayerInternal(LeafletVectorLayerInt.class); + } + + /** + * Provides access to the control layer for map navigation and view management. + *

+ * The control layer handles map manipulation operations such as zooming, panning, + * setting bounds, and controlling the map's viewport and navigation state. + *

+ * + * @return the control layer interface for map navigation and view control + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default LeafletControlLayerInt getControlLayer() { + checkMapState(); + return getLayerInternal(LeafletControlLayerInt.class); + } + + /** + * Provides access to the GeoJSON layer for managing geographic data layers. + *

+ * The GeoJSON layer handles the loading, styling, and interaction with GeoJSON + * geographic data, supporting both simple displays and advanced features like + * custom styling functions and data filtering. + *

+ * + * @return the GeoJSON layer interface for managing geographic data + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default LeafletGeoJsonLayerInt getGeoJsonLayer() { + checkMapState(); + return getLayerInternal(LeafletGeoJsonLayerInt.class); + } + + + /** + * Smoothly pans the map view to the specified geographical coordinates. + *

+ * This method performs an animated transition to center the map on the given location + * while maintaining the current zoom level. The pan operation uses the default + * animation duration and easing settings. + *

+ * + * @param latLng the target geographical coordinates to pan to + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default void setView(JLLatLng latLng) { + checkMapState(); + getJLEngine() + .executeScript(String.format("this.map.panTo([%f, %f]);", + latLng.getLat(), latLng.getLng())); + } + + /** + * Smoothly pans the map view to the specified geographical coordinates with custom animation duration. + *

+ * This method performs an animated transition to center the map on the given location + * while maintaining the current zoom level, using the specified animation duration. + *

+ * + * @param latLng the target geographical coordinates to pan to + * @param duration the animation duration in milliseconds + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default void setView(JLLatLng latLng, int duration) { + checkMapState(); + getJLEngine() + .executeScript(String.format("this.map.panTo([%f, %f], %d);", + latLng.getLat(), latLng.getLng(), duration)); + } + + /** + * Retrieves the current zoom level of the map. + *

+ * Zoom levels typically range from 0 (world view) to 18+ (building level detail), + * depending on the tile provider. Each zoom level represents a doubling of the scale. + *

+ * + * @return the current zoom level as an integer + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default int getZoom() { + checkMapState(); + Object result = getJLEngine() + .executeScript("this.map.getZoom();"); + return Integer.parseInt(result.toString()); + } + + /** + * Sets the zoom level of the map with animation. + *

+ * This method smoothly animates the map to the specified zoom level. + * Zoom levels typically range from 0 (world view) to 18+ (building level detail). + * Values outside the supported range will be clamped to valid bounds. + *

+ * + * @param zoomLevel the target zoom level (typically 0-19) + * @throws JLMapNotReadyException if the map is not properly initialized + */ + default void setZoom(int zoomLevel) { + checkMapState(); + getJLEngine() + .executeScript(String.format("this.map.setZoom(%d);", zoomLevel)); + } + + /** + * Retrieves the current center coordinates of the map view. + *

+ * This method returns the geographical coordinates (latitude and longitude) + * that correspond to the center point of the current map viewport. + *

+ * + * @return the center coordinates as a {@link JLLatLng} object + * @throws JLMapNotReadyException if the map is not properly initialized + * @throws NumberFormatException if the coordinate parsing fails + */ + default JLLatLng getCenter() { + checkMapState(); + Object result = getJLEngine() + .executeScript("this.map.getCenter();"); + String[] coords = result.toString().split(","); + double lat = Double.parseDouble(coords[0].trim()); + double lng = Double.parseDouble(coords[1].trim()); + return new JLLatLng(lat, lng); + } + + /** + * Checks if the map is ready for operations. + * + * @throws JLMapNotReadyException if the map is not ready + */ + default void checkMapState() { + if (getJLEngine() == null) { + throw new JLMapNotReadyException("Map engine is not initialized"); + } + } + + private @Nullable M getLayerInternal(@NonNull Class layerClass) { + return getLayers().entrySet() + .stream() + .filter(entry -> layerClass.isAssignableFrom(entry.getKey())) + .map(Map.Entry::getValue) + .map(layerClass::cast) + .findFirst() + .orElse(null); + } + + @Override + default JLServerToClientTransporter getTransport() { + throw new UnsupportedOperationException("Use getJLEngine() instead"); + } + + @Override + default JLMap self() { + return this; + } + + @Override + default JLMap setJLObjectOpacity(double opacity) { + getJLEngine() + .executeScript(String.format("this.map.setOpacity(%f);", opacity)); + return this; + } + + @Override + default JLMap setZIndexOffset(int offset) { + getJLEngine() + .executeScript(String.format("this.map.setZIndex(%d);", offset)); + return this; + } + + @Override + default String getJLId() { + return "jl-map-view"; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/JLMapEventHandler.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/JLMapEventHandler.java new file mode 100644 index 0000000..0cc1453 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/JLMapEventHandler.java @@ -0,0 +1,119 @@ +package io.github.makbn.jlmap; + +import io.github.makbn.jlmap.listener.event.*; +import io.github.makbn.jlmap.model.*; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Objects; +import java.util.Set; + +/** + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +public class JLMapEventHandler { + public static final String MAP_TYPE = "map"; + public static final String MAP_UUID = "main_map"; + + HashMap>, HashMap>> jlObjects; + + HashMap>[]> classMap; + Set eventHandlers = Set.of( + new JLDragEventHandler(), + new JLInteractionEventHandler(), + new JLStatusChangeEventHandler(), + new JLLayerEventHandler() + ); + + public JLMapEventHandler() { + this.jlObjects = new HashMap<>(); + this.classMap = new HashMap<>(); + initClassMap(); + } + + @SuppressWarnings("unchecked") + private void initClassMap() { + classMap.put(JLMarker.class.getSimpleName().toLowerCase(), new Class[]{JLMarker.class}); + classMap.put(JLPopup.class.getSimpleName().toLowerCase(), new Class[]{JLPopup.class}); + classMap.put(JLCircleMarker.class.getSimpleName().toLowerCase(), new Class[]{JLCircleMarker.class}); + classMap.put(JLCircle.class.getSimpleName().toLowerCase(), new Class[]{JLCircle.class}); + classMap.put(JLPolyline.class.getSimpleName().toLowerCase(), new Class[]{JLPolyline.class}); + classMap.put(JLMultiPolyline.class.getSimpleName().toLowerCase(), new Class[]{JLMultiPolyline.class}); + classMap.put(JLPolygon.class.getSimpleName().toLowerCase(), new Class[]{JLPolygon.class}); + classMap.put(JLGeoJson.class.getSimpleName().toLowerCase(), new Class[]{JLGeoJson.class}); + } + + /** + * @param functionName name of source function from js + * @param jlType name of object class + * @param uuid id of object + * @param param1 additional param + * @param param2 additional param + * @param param3 additional param + */ + @SuppressWarnings("all") + public void functionCalled(JLMap mapView, String functionName, Object jlType, Object uuid, + Object param1, Object param2, Object param3) { + log.debug("function: {} jlType: {} uuid: {} param1: {} param2: {} param3: {}", + functionName, jlType, uuid, param1, param2, param3); + try { + //get target class of Leaflet layer in JL Application + Class[] targetClasses = classMap.get(jlType); + if (targetClasses == null) { + targetClasses = classMap.get(jlType.toString().replace("jl", "")); + } + //function called by an known class + if (targetClasses != null) { + //one Leaflet class may map to multiple class in JL Application + // like ployLine mapped to JLPolyline and JLMultiPolyline + Arrays.stream(targetClasses) + .filter(jlObjects::containsKey) + .map(targetClass -> jlObjects.get(targetClass).get(String.valueOf(uuid))) + .filter(Objects::nonNull) + .filter(jlObject -> Objects.nonNull(jlObject.getOnActionListener())) + .forEach(jlObject -> invokeEventHandler(functionName, jlType, uuid, param1, param2, param3, mapView, jlObject)); + } else if (MAP_TYPE.equals(jlType) && MAP_UUID.equals(uuid) && mapView.getOnActionListener() != null) { + eventHandlers.stream() + .filter(hadler -> hadler.canHandle(functionName)) + .forEach(hadler -> hadler.handle(mapView, mapView, functionName, mapView.getOnActionListener(), + jlType, uuid, param1, param2, param3)); + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + private void invokeEventHandler(String functionName, Object jlType, Object uuid, Object param1, Object param2, Object param3, JLMap map, JLObject jlObject) { + eventHandlers.stream() + .filter(handler -> handler.canHandle(functionName)) + .forEach(handler -> handler.handle(map, jlObject, functionName, + jlObject.getOnActionListener(), jlType, uuid, param1, param2, param3)); + } + + public void addJLObject(@NonNull String key, @NonNull JLObject object) { + if (jlObjects.containsKey(object.getClass())) { + jlObjects.get(object.getClass()) + .put(key, object); + } else { + HashMap> map = new HashMap<>(); + map.put(key, object); + //noinspection unchecked + jlObjects.put((Class>) object.getClass(), map); + } + } + + public void remove(@NonNull Class> targetClass, @NonNull String key) { + if (!jlObjects.containsKey(targetClass)) + return; + JLObject object = jlObjects.get(targetClass).remove(key); + if (object != null) { + log.error("{} id: {} removed", targetClass.getSimpleName(), object.getJLId()); + } + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/JLProperties.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/JLProperties.java new file mode 100644 index 0000000..0035362 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/JLProperties.java @@ -0,0 +1,22 @@ +package io.github.makbn.jlmap; + +import lombok.NoArgsConstructor; + +/** + * @author Matt Akbarian (@makbn) + */ +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public final class JLProperties { + public static final int INIT_MIN_WIDTH = 1024; + public static final int INIT_MIN_HEIGHT = 576; + public static final int EARTH_RADIUS = 6367; + public static final int DEFAULT_CIRCLE_RADIUS = 200; + public static final int DEFAULT_CIRCLE_MARKER_RADIUS = 10; + public static final int DEFAULT_MAX_ZOOM = 19; + public static final int INIT_MIN_WIDTH_STAGE = INIT_MIN_WIDTH; + public static final int INIT_MIN_HEIGHT_STAGE = INIT_MIN_HEIGHT; + public static final double START_ANIMATION_RADIUS = 10; + public static final double DEFAULT_INITIAL_LATITUDE = 0.00; + public static final double DEFAULT_INITIAL_LONGITUDE = 0.00; + public static final int DEFAULT_INITIAL_ZOOM = 5; +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLContextMenu.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLContextMenu.java new file mode 100644 index 0000000..3e1146c --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLContextMenu.java @@ -0,0 +1,391 @@ +package io.github.makbn.jlmap.element.menu; + +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.listener.event.Event; +import io.github.makbn.jlmap.model.JLObject; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.experimental.FieldDefaults; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Represents a context menu for JL map objects. + *

+ * The context menu provides a platform-independent way to add interactive menus + * to map elements. It uses mediators to handle platform-specific implementations + * while maintaining a consistent API across different UI frameworks (JavaFX, Vaadin). + *

+ *

Key Features:

+ *
    + *
  • Platform Independence: Uses mediators for framework-specific rendering
  • + *
  • Dynamic Menu Items: Add, remove, and modify menu items at runtime
  • + *
  • Event Handling: Supports both menu lifecycle and item selection events
  • + *
  • Thread Safety: Uses concurrent collections for safe multi-threaded access
  • + *
+ *

Event Flow:

+ *
    + *
  1. Context Menu Opens: OnJLActionListener receives open event
  2. + *
  3. User Selects Item: OnJLContextMenuItemListener receives selection
  4. + *
  5. Context Menu Closes: OnJLActionListener receives close event
  6. + *
+ *

Usage Example:

+ *
{@code
+ * JLMarker marker = JLMarker.builder()
+ *     .latLng(new JLLatLng(51.5, -0.09))
+ *     .build();
+ *
+ * JLContextMenu menu = marker.getContextMenu();
+ * menu.addItem("Edit", "edit-icon")
+ *     .addItem("Delete", "delete-icon")
+ *     .addItem("Info", "info-icon")
+ *     .setOnMenuItemListener(item -> {
+ *         switch (item.getId()) {
+ *             case "Edit" -> editMarker(marker);
+ *             case "Delete" -> deleteMarker(marker);
+ *             case "Info" -> showMarkerInfo(marker);
+ *         }
+ *     });
+ * }
+ * + * @param the type of JL object this context menu belongs to + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLContextMenu> { + + /** + * The JL object that owns this context menu. + */ + @NonNull + final T owner; + + /** + * Thread-safe map of menu items indexed by their IDs. + * Uses ConcurrentHashMap to support safe concurrent access during + * menu operations and item modifications. + */ + @NonNull + final Map menuItems = new ConcurrentHashMap<>(); + + /** + * Listener for menu item selection events. + * This listener is called when a user selects a specific menu item. + */ + @Getter + OnJLContextMenuItemListener onMenuItemListener; + + /** + * Whether the context menu is currently enabled. + * Disabled menus will not be displayed even if they contain items. + */ + @Getter + @Setter + boolean enabled = true; + + /** + * Creates a new context menu for the specified owner object. + * + * @param owner the JL object that will own this context menu + */ + public JLContextMenu(@NonNull T owner) { + this.owner = owner; + } + + /** + * Adds a menu item with the specified text. + *

+ * Creates a new menu item with the given text and an auto-generated ID. + * The ID will be derived from the text by removing spaces and converting + * to lowercase for consistent identification. + *

+ * + * @param text the display text for the menu item + * @return this context menu instance for method chaining + * @throws IllegalArgumentException if text is null or empty + */ + @NonNull + public JLContextMenu addItem(@NonNull String text) { + String id = text.replaceAll("\\s+", "").toLowerCase(); + return addItem(id, text); + } + + /** + * Adds a menu item with the specified ID and text. + *

+ * Creates a new menu item with the given ID and display text. + * If a menu item with the same ID already exists, it will be replaced. + *

+ * + * @param id unique identifier for the menu item + * @param text display text for the menu item + * @return this context menu instance for method chaining + * @throws IllegalArgumentException if id or text is null or empty + */ + @NonNull + public JLContextMenu addItem(@NonNull String id, @NonNull String text) { + JLMenuItem item = JLMenuItem.of(id, text); + menuItems.put(id, item); + notifyMenuUpdated(); + return this; + } + + /** + * Adds a menu item with the specified ID, text, and icon. + *

+ * Creates a new menu item with all primary properties specified. + * If a menu item with the same ID already exists, it will be replaced. + *

+ * + * @param id unique identifier for the menu item + * @param text display text for the menu item + * @param icon icon or image reference for the menu item + * @return this context menu instance for method chaining + * @throws IllegalArgumentException if id or text is null or empty + */ + @NonNull + public JLContextMenu addItem(@NonNull String id, @NonNull String text, String icon) { + JLMenuItem item = JLMenuItem.of(id, text, icon); + menuItems.put(id, item); + notifyMenuUpdated(); + return this; + } + + /** + * Adds a pre-built menu item to the context menu. + *

+ * Allows adding fully configured menu items with custom properties. + * If a menu item with the same ID already exists, it will be replaced. + *

+ * + * @param item the menu item to add + * @return this context menu instance for method chaining + * @throws IllegalArgumentException if item is null + */ + @NonNull + public JLContextMenu addItem(@NonNull JLMenuItem item) { + menuItems.put(item.getId(), item); + notifyMenuUpdated(); + return this; + } + + /** + * Removes a menu item with the specified ID. + *

+ * If no menu item exists with the given ID, this method has no effect. + *

+ * + * @param id the ID of the menu item to remove + * @return this context menu instance for method chaining + */ + @NonNull + public JLContextMenu removeItem(@NonNull String id) { + if (menuItems.remove(id) != null) { + notifyMenuUpdated(); + } + return this; + } + + /** + * Removes all menu items from the context menu. + *

+ * After calling this method, the context menu will be empty and + * will not be displayed until new items are added. + *

+ * + * @return this context menu instance for method chaining + */ + @NonNull + public JLContextMenu clearItems() { + if (!menuItems.isEmpty()) { + menuItems.clear(); + notifyMenuUpdated(); + } + return this; + } + + /** + * Retrieves a menu item by its ID. + *

+ * Returns the menu item with the specified ID, or null if no such + * item exists in the context menu. + *

+ * + * @param id the ID of the menu item to retrieve + * @return the menu item with the specified ID, or null if not found + */ + public JLMenuItem getItem(@NonNull String id) { + return menuItems.get(id); + } + + /** + * Updates an existing menu item with new properties. + *

+ * Finds the menu item with the matching ID and replaces it with the + * updated version. If no item exists with the given ID, the new item + * will be added to the menu. + *

+ * + * @param updatedItem the updated menu item + * @return this context menu instance for method chaining + * @throws IllegalArgumentException if updatedItem is null + */ + @NonNull + public JLContextMenu updateItem(@NonNull JLMenuItem updatedItem) { + menuItems.put(updatedItem.getId(), updatedItem); + notifyMenuUpdated(); + return this; + } + + /** + * Returns an unmodifiable collection of all menu items. + *

+ * The returned collection reflects the current state of the menu items + * and will be updated as items are added or removed. However, the + * collection itself cannot be modified directly. + *

+ * + * @return an unmodifiable collection of all menu items + */ + @NonNull + public Collection getItems() { + return Collections.unmodifiableCollection(menuItems.values()); + } + + /** + * Returns an unmodifiable collection of all visible menu items. + *

+ * Filters the menu items to include only those that are marked as visible. + * This is useful for determining what items should actually be displayed + * to the user. + *

+ * + * @return an unmodifiable collection of visible menu items + */ + @NonNull + public Collection getVisibleItems() { + return menuItems.values().stream() + .filter(JLMenuItem::isVisible) + .toList(); + } + + /** + * Checks if the context menu has any visible items. + *

+ * Returns true if there is at least one menu item that is marked as visible. + * This is useful for determining whether the context menu should be displayed. + *

+ * + * @return true if there are visible menu items, false otherwise + */ + public boolean hasVisibleItems() { + return menuItems.values().stream().anyMatch(JLMenuItem::isVisible); + } + + /** + * Returns the number of menu items in the context menu. + *

+ * Includes both visible and hidden items in the count. + *

+ * + * @return the total number of menu items + */ + public int getItemCount() { + return menuItems.size(); + } + + /** + * Returns the number of visible menu items in the context menu. + *

+ * Counts only the items that are marked as visible. + *

+ * + * @return the number of visible menu items + */ + public int getVisibleItemCount() { + return (int) menuItems.values().stream().filter(JLMenuItem::isVisible).count(); + } + + /** + * Sets the listener for menu item selection events. + *

+ * The listener will be called whenever a user selects a menu item from + * the context menu. Only one listener can be active at a time; setting + * a new listener will replace the previous one. + *

+ * + * @param listener the listener for menu item selection events, or null to remove + * @return this context menu instance for method chaining + */ + @NonNull + public JLContextMenu setOnMenuItemListener(OnJLContextMenuItemListener listener) { + this.onMenuItemListener = listener; + return this; + } + + /** + * Handles menu item selection events. + *

+ * This method is called internally when a menu item is selected. + * It delegates to the registered menu item listener if one exists. + *

+ *

+ * Internal API: This method is intended for use by + * the context menu mediator implementations and should not be called + * directly by application code. + *

+ * + * @param selectedItem the menu item that was selected + */ + public void handleMenuItemSelection(@NonNull JLMenuItem selectedItem) { + if (onMenuItemListener != null) { + onMenuItemListener.onMenuItemSelected(selectedItem); + } + } + + /** + * Handles context menu lifecycle events. + *

+ * This method is called when the context menu is opened or closed, + * allowing the owner object to respond to these events through its + * OnJLActionListener. + *

+ *

+ * Internal API: This method is intended for use by + * the context menu mediator implementations and should not be called + * directly by application code. + *

+ * + * @param event the context menu event (open/close) + */ + public void handleContextMenuEvent(@NonNull Event event) { + OnJLActionListener listener = owner.getOnActionListener(); + if (listener != null) { + listener.onAction(owner, event); + } + } + + /** + * Notifies that the menu structure has been updated. + *

+ * This method is called internally whenever menu items are added, removed, + * or modified. It can be used by mediator implementations to refresh + * the display or update platform-specific menu representations. + *

+ *

+ * Internal API: This method is intended for use by + * the context menu mediator implementations and should not be called + * directly by application code. + *

+ */ + protected void notifyMenuUpdated() { + // This method can be overridden by mediator implementations + // to respond to menu changes + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLContextMenuMediator.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLContextMenuMediator.java new file mode 100644 index 0000000..f0ff798 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLContextMenuMediator.java @@ -0,0 +1,125 @@ +package io.github.makbn.jlmap.element.menu; + +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.model.JLObject; +import lombok.NonNull; + +/** + * Platform-independent mediator interface for context menu implementations. + *

+ * This interface abstracts the platform-specific context menu implementations + * (JavaFX, Vaadin, etc.) from the common JL context menu API. Each UI framework + * should provide its own implementation of this mediator to handle platform-specific + * menu rendering, event handling, and user interactions. + *

+ *

Mediator Pattern Benefits:

+ *
    + *
  • Platform Independence: JL objects work with any UI framework
  • + *
  • Separation of Concerns: Business logic separate from UI rendering
  • + *
  • Extensibility: Easy to add support for new UI frameworks
  • + *
  • Testability: Can provide mock implementations for testing
  • + *
+ *

Implementation Requirements:

+ *

+ * Concrete mediators must: + *

+ *
    + *
  • Render context menus using platform-specific UI components
  • + *
  • Handle user interaction events (right-click, long-press, etc.)
  • + *
  • Forward menu item selections to the appropriate listeners
  • + *
  • Manage menu lifecycle (show, hide, update) efficiently
  • + *
  • Be thread-safe for multithreaded environments
  • + *
+ *

Service Provider Interface:

+ *

+ * Implementations should be discoverable via Java's ServiceLoader mechanism + * by providing a service configuration file in META-INF/services/. + *

+ *

Example Service Configuration:

+ *
+ * File: META-INF/services/io.github.makbn.jlmap.element.menu.JLContextMenuMediator
+ * Content: io.github.makbn.jlmap.vaadin.menu.VaadinContextMenuMediator
+ * 
+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public interface JLContextMenuMediator { + + + /** + * Shows the context menu for the specified JL object at the given coordinates. + *

+ * This method is typically called in response to user gestures like right-click + * or long-press. The mediator should display the context menu using platform-specific + * popup or menu components at the specified screen coordinates. + *

+ *

+ * Coordinate System: Coordinates are typically relative to + * the map view or screen, depending on the platform implementation. + *

+ * + * @param the type of JL object + * @param object the JL object whose context menu should be shown + * @param x the x-coordinate where the menu should appear + * @param y the y-coordinate where the menu should appear + * @throws IllegalArgumentException if object is null + * @throws IllegalStateException if no context menu is registered for the object + */ + > void showContextMenu(@NonNull JLMap map, @NonNull JLObject object, double x, double y); + + /** + * Hides the context menu for the specified JL object. + *

+ * This method is called when the context menu should be dismissed, either + * programmatically or in response to user actions like clicking outside + * the menu area. + *

+ * + * @param the type of JL object + * @param object the JL object whose context menu should be hidden + * @throws IllegalArgumentException if object is null + */ + > void hideContextMenu(@NonNull JLMap map, @NonNull JLObject object); + + /** + * Checks if the mediator supports the specified JL object type. + *

+ * This method allows the service locator to determine which mediator + * should handle a particular JL object. Mediators may support all object + * types or be specialized for specific object categories. + *

+ *

Example Use Cases:

+ *
    + *
  • General mediators support all JLObject types
  • + *
  • Specialized mediators only support markers or polygons
  • + *
  • Framework-specific mediators support objects within their UI context
  • + *
+ * + * @param objectType the class type of the JL object + * @return true if this mediator can handle the specified object type + * @throws IllegalArgumentException if objectType is null + */ + boolean supportsObjectType(@NonNull Class> objectType); + + /** + * Returns a human-readable name for this mediator implementation. + *

+ * This method is useful for debugging, logging, and development tools + * that need to identify which mediator is being used. + *

+ *

Naming Convention:

+ *

+ * Names should include the target platform or framework: + *

+ *
    + *
  • "Vaadin Context Menu Mediator"
  • + *
  • "JavaFX Context Menu Mediator"
  • + *
  • "Mock Context Menu Mediator"
  • + *
+ * + * @return a descriptive name for this mediator + */ + @NonNull + String getName(); +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLHasContextMenu.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLHasContextMenu.java new file mode 100644 index 0000000..2f89316 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLHasContextMenu.java @@ -0,0 +1,122 @@ +package io.github.makbn.jlmap.element.menu; + +import io.github.makbn.jlmap.model.JLObject; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +/** + * Interface for JL objects that support context menu functionality. + *

+ * This interface enables map elements (markers, polygons, polylines, etc.) to have + * context menus that can be displayed when users right-click or long-press on them. + * The context menu is implemented using platform-specific mediators to avoid + * dependency on Leaflet's internal context menu implementation. + *

+ *

Context Menu Lifecycle:

+ *
    + *
  • Creation: Context menu is created when first accessed
  • + *
  • Display: Menu shows on right-click/long-press events
  • + *
  • Selection: User selects menu items triggering listeners
  • + *
  • Closing: Menu closes automatically or by user action
  • + *
+ *

Usage Example:

+ *
{@code
+ * JLMarker marker = JLMarker.builder()
+ *     .latLng(new JLLatLng(51.5, -0.09))
+ *     .build();
+ *
+ * JLContextMenu contextMenu = marker.getContextMenu();
+ * contextMenu.addItem("Edit", "edit-icon")
+ *           .addItem("Delete", "delete-icon")
+ *           .setOnMenuItemListener(item -> {
+ *               switch (item.getId()) {
+ *                   case "Edit" -> editMarker(marker);
+ *                   case "Delete" -> marker.remove();
+ *               }
+ *           });
+ * }
+ * + * @param the type of the implementing JL object + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public interface JLHasContextMenu> { + + /** + * Retrieves the context menu for this JL object. + *

+ * Returns the context menu associated with this object. If no context menu + * has been created yet, a new one will be instantiated. The context menu + * allows adding/removing menu items and setting up event listeners. + *

+ *

+ * Note: The context menu is created lazily when first accessed. + * This ensures optimal memory usage and performance for objects that may not + * require context menu functionality. + *

+ * + * @return the context menu instance for this object, never null + */ + @Nullable + JLContextMenu getContextMenu(); + + void setContextMenu(@NonNull JLContextMenu contextMenu); + + @NonNull + JLContextMenu addContextMenu(); + + /** + * Checks if this object has a context menu with visible items. + *

+ * Returns true if a context menu exists and contains at least one menu item + * that should be displayed to the user. This is useful for determining + * whether to show context menu indicators or enable right-click functionality. + *

+ *

Implementation Note:

+ *

+ * Objects should return false if: + *

+ *
    + *
  • No context menu has been created
  • + *
  • Context menu exists but has no items
  • + *
  • All menu items are hidden or disabled
  • + *
+ * + * @return true if the object has a visible context menu with items, false otherwise + */ + boolean hasContextMenu(); + + /** + * Checks if the context menu is currently enabled for this object. + *

+ * Returns true if the context menu will be displayed when users perform + * context menu gestures on this object. A disabled context menu will not + * appear regardless of whether it exists and contains menu items. + *

+ * + * @return true if the context menu is enabled, false otherwise + */ + boolean isContextMenuEnabled(); + + /** + * Enables or disables the context menu for this object. + *

+ * Controls whether the context menu should be displayed when users perform + * context menu gestures (right-click, long-press, etc.) on this object. + * When disabled, the context menu will not appear even if it exists and + * contains menu items. + *

+ *

+ * Default State: Context menus are enabled by default when created. + *

+ *

Use Cases:

+ *
    + *
  • Temporarily disable context menu during editing operations
  • + *
  • Enable/disable based on user permissions or application state
  • + *
  • Provide conditional context menu availability
  • + *
+ * + * @param enabled true to enable the context menu, false to disable it + */ + void setContextMenuEnabled(boolean enabled); +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLMenuItem.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLMenuItem.java new file mode 100644 index 0000000..73304e5 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/JLMenuItem.java @@ -0,0 +1,153 @@ +package io.github.makbn.jlmap.element.menu; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import lombok.experimental.FieldDefaults; + +/** + * Represents a single item in a context menu. + *

+ * Each menu item has a unique identifier, display text, optional icon, and can be + * enabled/disabled or shown/hidden. Menu items are immutable once created, promoting + * consistent state management and thread safety. + *

+ *

Menu Item Properties:

+ *
    + *
  • ID: Unique identifier for programmatic access
  • + *
  • Text: User-visible display text
  • + *
  • Icon: Optional icon or image reference
  • + *
  • Enabled: Whether the item can be selected
  • + *
  • Visible: Whether the item appears in the menu
  • + *
+ *

Usage Example:

+ *
{@code
+ * JLMenuItem editItem = JLMenuItem.builder()
+ *     .id("edit")
+ *     .text("Edit Properties")
+ *     .icon("edit-icon.png")
+ *     .enabled(true)
+ *     .visible(true)
+ *     .build();
+ *
+ * JLMenuItem deleteItem = JLMenuItem.builder()
+ *     .id("delete")
+ *     .text("Delete Item")
+ *     .icon("delete-icon.png")
+ *     .enabled(canDelete)
+ *     .visible(hasDeletePermission)
+ *     .build();
+ * }
+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@Value +@Builder(toBuilder = true) +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLMenuItem { + + /** + * Unique identifier for this menu item. + *

+ * The ID is used to identify the menu item when handling selection events + * and for programmatic access. It should be unique within the context menu + * and remain constant throughout the item's lifecycle. + *

+ */ + @NonNull + String id; + + /** + * Display text shown to the user. + *

+ * This is the human-readable text that appears in the context menu. + * It should be descriptive and indicate the action that will be performed + * when the menu item is selected. + *

+ */ + @NonNull + String text; + + /** + * Optional icon or image reference for this menu item. + *

+ * The icon can be a file path, URL, CSS class name, or any other identifier + * that the underlying UI framework can interpret as an icon. The specific + * format depends on the platform implementation (JavaFX, Vaadin, etc.). + *

+ *

Icon Format Examples:

+ *
    + *
  • File Path: "icons/edit.png"
  • + *
  • CSS Class: "fa-edit" (FontAwesome)
  • + *
  • URL: "https://example.com/icon.svg"
  • + *
+ */ + String icon; + + /** + * Whether this menu item can be selected by the user. + *

+ * Disabled items typically appear grayed out and do not respond to + * click events. They remain visible but cannot be activated. + *

+ *

+ * Default: true (enabled) + *

+ */ + @Builder.Default + boolean enabled = true; + + /** + * Whether this menu item is visible in the context menu. + *

+ * Hidden items do not appear in the menu at all. This is useful for + * conditionally showing menu items based on user permissions, application + * state, or other dynamic factors. + *

+ *

+ * Default: true (visible) + *

+ */ + @Builder.Default + boolean visible = true; + + /** + * Creates a simple menu item with just ID and text. + *

+ * This is a convenience factory method for creating basic menu items + * without icons or custom enabled/visible states. + *

+ * + * @param id unique identifier for the menu item + * @param text display text for the menu item + * @return a new menu item instance + */ + public static JLMenuItem of(@NonNull String id, @NonNull String text) { + return JLMenuItem.builder() + .id(id) + .text(text) + .build(); + } + + /** + * Creates a menu item with ID, text, and icon. + *

+ * This is a convenience factory method for creating menu items with + * the most commonly used properties. + *

+ * + * @param id unique identifier for the menu item + * @param text display text for the menu item + * @param icon icon or image reference for the menu item + * @return a new menu item instance + */ + public static JLMenuItem of(@NonNull String id, @NonNull String text, String icon) { + return JLMenuItem.builder() + .id(id) + .text(text) + .icon(icon) + .build(); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/OnJLContextMenuItemListener.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/OnJLContextMenuItemListener.java new file mode 100644 index 0000000..e81cd9b --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/element/menu/OnJLContextMenuItemListener.java @@ -0,0 +1,37 @@ +package io.github.makbn.jlmap.element.menu; + +/** + * Listener interface for handling context menu item selection events. + *

+ * This listener is triggered when a user selects a specific menu item from a context menu. + * It provides the selected menu item to the implementing code for further processing. + *

+ *

Usage Example:

+ *
{@code
+ * JLContextMenu contextMenu = marker.getContextMenu();
+ * contextMenu.setOnMenuItemListener(selectedItem -> {
+ *     System.out.println("Selected: " + selectedItem.getText());
+ *     if (selectedItem.getId().equals("delete")) {
+ *         marker.remove();
+ *     }
+ * });
+ * }
+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@FunctionalInterface +public interface OnJLContextMenuItemListener { + + /** + * Called when a menu item is selected from the context menu. + *

+ * This method is invoked whenever a user clicks on a menu item in the context menu. + * The selected menu item is passed as a parameter, allowing the listener to + * determine which action was chosen and respond accordingly. + *

+ * + * @param selectedItem the menu item that was selected by the user + */ + void onMenuItemSelected(JLMenuItem selectedItem); +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLClientToServerTransporter.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLClientToServerTransporter.java new file mode 100644 index 0000000..9b61a84 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLClientToServerTransporter.java @@ -0,0 +1,45 @@ +package io.github.makbn.jlmap.engine; + +import io.github.makbn.jlmap.model.JLObject; + +/** + * Generic bridge for JavaScript-to-Java direct method calls on JLObjects. + * This allows JavaScript to call Java methods directly on specific object instances. + * + * @author Matt Akbarian (@makbn) + */ +public interface JLClientToServerTransporter { + + /** + * Calls a method on a specific JLObject instance. + * + * @param objectId The unique identifier of the JLObject + * @param methodName The name of the method to call + * @param args Arguments to pass to the method (JSON serialized) + * @return The result of the method call (JSON serialized), or null for void methods + */ + String callObjectMethod(String objectId, String methodName, String... args); + + /** + * Registers a JLObject so it can be called from JavaScript. + * + * @param objectId Unique identifier for the object + * @param object The JLObject instance + */ + void registerObject(String objectId, JLObject object); + + /** + * Unregisters a JLObject. + * + * @param objectId The unique identifier of the object to remove + */ + void unregisterObject(String objectId); + + /** + * Gets the JavaScript code to inject that enables calling Java methods. + * This should be executed once to set up the bridge. + * + * @return JavaScript code for the bridge + */ + String getJavaScriptBridge(); +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLClientToServerTransporterBase.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLClientToServerTransporterBase.java new file mode 100644 index 0000000..1f14a52 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLClientToServerTransporterBase.java @@ -0,0 +1,151 @@ +package io.github.makbn.jlmap.engine; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.makbn.jlmap.model.JLGeoJson; +import io.github.makbn.jlmap.model.JLObject; +import io.github.makbn.jlmap.model.JLOptions; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Base implementation of the JLObjectBridge that handles method calling logic. + * Platform-specific implementations extend this to provide the JavaScript integration. + * + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public abstract class JLClientToServerTransporterBase implements JLClientToServerTransporter { + + Map> registeredObjects = new ConcurrentHashMap<>(); + ObjectMapper objectMapper = new ObjectMapper(); + Function engineConsumer; + + protected JLClientToServerTransporterBase(Function engineConsumer) { + this.engineConsumer = engineConsumer; + } + + @NonNull + protected T execute(String script) { + return engineConsumer.apply(script); + } + + @Override + public void registerObject(String objectId, JLObject object) { + registeredObjects.put(objectId, object); + log.debug("Registered object {} of type {}", objectId, object.getClass().getSimpleName()); + } + + @Override + public void unregisterObject(String objectId) { + registeredObjects.remove(objectId); + log.debug("Unregistered object {}", objectId); + } + + @Override + public String callObjectMethod(String objectId, String methodName, String... args) { + try { + JLObject object = registeredObjects.get(objectId); + if (object == null) { + log.warn("No object registered with ID: {}", objectId); + return null; + } + + return invokeMethod(object, methodName, args); + } catch (Exception e) { + log.error("Error calling method {} on object {}: {}", methodName, objectId, e.getMessage()); + return null; + } + } + + private String invokeMethod(JLObject object, String methodName, String... args) throws Exception { + switch (methodName) { + case "callStyleFunction" -> { + if (object instanceof JLGeoJson geoJson && args.length > 0) { + List> properties = parsePropertiesMap(args[0]); + JLOptions result = geoJson.callStyleFunction(properties); + return serializeJLOptions(result); + } + } + case "callFilterFunction" -> { + if (object instanceof JLGeoJson geoJson && args.length > 0) { + List> properties = parsePropertiesMap(args[0]); + boolean result = geoJson.callFilterFunction(properties); + return String.valueOf(result); + } + } + default -> log.warn("Unknown method: {} on object type: {}", methodName, object.getClass().getSimpleName()); + } + return null; + } + + + private List> parsePropertiesMap(String jsonProperties) throws JsonProcessingException { + List jsonStrings = objectMapper.readValue(jsonProperties, new TypeReference<>() { + }); + + return jsonStrings.stream() + .map(this::convertToMap) + .toList(); + } + + private Map convertToMap(String item) { + try { + return objectMapper.readValue(item, new TypeReference<>() { + }); + } catch (Exception e) { + log.error("Error converting object {} to Map: {}", item, e.getMessage()); + return Collections.emptyMap(); + } + } + + private String serializeJLOptions(JLOptions options) throws JsonProcessingException { + Map optionsMap = new HashMap<>(); + + if (options.getColor() != null) { + optionsMap.put("color", options.getColor().toHexString()); + } + if (options.getFillColor() != null) { + optionsMap.put("fillColor", options.getFillColor().toHexString()); + } + optionsMap.put("weight", options.getWeight()); + optionsMap.put("opacity", options.getOpacity()); + optionsMap.put("fillOpacity", options.getFillOpacity()); + optionsMap.put("stroke", options.isStroke()); + optionsMap.put("fill", options.isFill()); + optionsMap.put("smoothFactor", options.getSmoothFactor()); + + return objectMapper.writeValueAsString(optionsMap); + } + + @Override + @SuppressWarnings("all") + public String getJavaScriptBridge() { + //language=js + return """ + // JLObjectBridge - Generic JavaScript-to-Java bridge + window.jlObjectBridge = { + call: async function(objectId, methodName, ...args) { + return this._callJava(objectId, methodName, args); + }, + + _callJava: async function(objectId, methodName, args) { + // This will be overridden by platform-specific implementations + console.warn('JLObjectBridge not properly initialized for platform'); + return null; + } + }; + """; + } +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLServerToClientTransporter.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLServerToClientTransporter.java new file mode 100644 index 0000000..7b4942c --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLServerToClientTransporter.java @@ -0,0 +1,175 @@ +package io.github.makbn.jlmap.engine; + +import io.github.makbn.jlmap.exception.JLException; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Core transport interface for executing JavaScript operations from Java server-side code. + *

+ * This interface defines the communication bridge between Java objects and the JavaScript + * Leaflet map running in a web view or browser. It enables Java code to execute JavaScript + * functions, modify map elements, and retrieve results asynchronously. + *

+ *

Architecture Overview:

+ *

+ * The transporter operates as a functional interface that: + *

+ *
    + *
  • Sends Commands: Translates Java method calls to JavaScript execution
  • + *
  • Handles Results: Converts JavaScript return values back to Java objects
  • + *
  • Manages Async Operations: Provides CompletableFuture-based async execution
  • + *
+ *

Implementation Notes:

+ *

+ * Concrete implementations handle framework-specific details: + *

+ *
    + *
  • JavaFX: Uses WebEngine.executeScript() for JavaScript execution
  • + *
  • Vaadin: Uses Element.executeJs() for client-side JavaScript calls
  • + *
+ *

Usage Example:

+ *
{@code
+ * // Void operation (no return value)
+ * JLTransportRequest voidRequest = JLTransportRequest.voidCall(circle, "setRadius", 1000);
+ * transporter.execute(voidRequest); // CompletableFuture
+ *
+ * // Returnable operation (with result)
+ * JLTransportRequest returnRequest = JLTransportRequest.returnableCall(circle, "getBounds", JLBounds.class);
+ * CompletableFuture bounds = transporter.execute(returnRequest);
+ * bounds.thenAccept(b -> System.out.println("Bounds: " + b));
+ * }
+ *

+ * Thread Safety: Implementations must ensure thread-safe execution, + * typically by marshaling calls to the appropriate UI thread. + *

+ * + * @param The framework-specific result type (e.g., Object for JavaFX, JsonValue for Vaadin) + * @author Matt Akbarian (@makbn) + * @since 1.0.0 + */ +@FunctionalInterface +public interface JLServerToClientTransporter { + + /** + * Provides the core transport function for executing JavaScript operations. + *

+ * This method returns a function that takes a {@link JLTransportRequest} and + * executes the corresponding JavaScript operation, returning the raw result + * in the framework-specific format. + *

+ *

+ * Implementation Responsibility: Concrete implementations must: + *

+ *
    + *
  • Generate proper JavaScript code from the transport request
  • + *
  • Execute the JavaScript in the web view/browser context
  • + *
  • Return the raw result for further processing
  • + *
  • Handle execution errors appropriately
  • + *
+ * + * @return a function that executes JavaScript operations and returns raw results + */ + Function serverToClientTransport(); + + /** + * Converts raw JavaScript execution results to typed Java objects. + *

+ * This method handles the conversion from framework-specific raw results + * (e.g., JavaScript objects, JSON values) to strongly-typed Java objects. + * The default implementation throws {@link UnsupportedOperationException} + * and must be overridden by concrete implementations. + *

+ *

+ * Implementation Notes: Concrete implementations should: + *

+ *
    + *
  • Parse the raw result based on the target class type
  • + *
  • Handle primitive types, collections, and custom objects
  • + *
  • Provide appropriate error handling for conversion failures
  • + *
  • Return a completed or failed CompletableFuture
  • + *
+ *

Example Implementation Pattern:

+ *
{@code
+     * public  CompletableFuture covertResult(Object result, Class clazz) {
+     *     try {
+     *         if (clazz == String.class) {
+     *             return CompletableFuture.completedFuture(clazz.cast(result.toString()));
+     *         } else if (clazz == JLBounds.class) {
+     *             // Parse bounds from JSON string
+     *             return CompletableFuture.completedFuture(clazz.cast(parseBounds(result)));
+     *         }
+     *         // Handle other types...
+     *     } catch (Exception e) {
+     *         return CompletableFuture.failedFuture(e);
+     *     }
+     * }
+     * }
+ * + * @param the target Java type for conversion + * @param result the raw result from JavaScript execution + * @param clazz the target class for type conversion + * @return a CompletableFuture containing the converted result + * @throws JLException if conversion fails or is not supported + */ + default CompletableFuture covertResult(T result, Class clazz) { + try { + throw new UnsupportedOperationException("Not implemented"); + } catch (Exception e) { + throw new JLException("Error converting transport result", e); + } + } + + /** + * Executes a transport request and returns a CompletableFuture with the result. + *

+ * This is the main execution method that coordinates the JavaScript operation + * and result conversion. It handles both void operations (no return value) + * and returnable operations (with typed results). + *

+ *

Execution Flow:

+ *
    + *
  1. Void Operations: Execute JavaScript without expecting return value
  2. + *
  3. Returnable Operations: Execute JavaScript and convert result to target type
  4. + *
  5. Error Handling: Wrap execution errors in {@link JLException}
  6. + *
+ *

Usage Examples:

+ *
{@code
+     * // Void operation (e.g., setting properties)
+     * JLTransportRequest setRadius = JLTransportRequest.voidCall(circle, "setRadius", 1000);
+     * CompletableFuture voidResult = transporter.execute(setRadius);
+     *
+     * // Returnable operation (e.g., getting values)
+     * JLTransportRequest getBounds = JLTransportRequest.returnableCall(circle, "getBounds", JLBounds.class);
+     * CompletableFuture boundsResult = transporter.execute(getBounds);
+     * boundsResult.thenAccept(bounds -> {
+     *     System.out.println("Circle bounds: " + bounds);
+     * });
+     * }
+ *

+ * Thread Safety: This method must be safe to call from any thread, + * with implementations ensuring JavaScript execution occurs on the appropriate UI thread. + *

+ * + * @param the expected return type for returnable operations + * @param transport the transport request containing operation details + * @return a CompletableFuture that will complete with the operation result + * @throws JLException if the transport operation fails or no transporter is available + */ + default CompletableFuture execute(JLTransportRequest transport) { + if (transport.clazz() != Void.class) { + // Returnable operation - execute and convert result + T raw = serverToClientTransport().apply(transport); + if (raw == null) { + throw new JLException("No client to server transport found"); + } + return covertResult(raw, transport.getCastedClazz()); + } else { + // Void operation - execute without result conversion + serverToClientTransport().apply(transport); + return CompletableFuture.completedFuture(null); + } + } + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLTransportRequest.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLTransportRequest.java new file mode 100644 index 0000000..3055fb3 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLTransportRequest.java @@ -0,0 +1,91 @@ +package io.github.makbn.jlmap.engine; + + +import io.github.makbn.jlmap.model.JLObject; +import lombok.NonNull; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Immutable transport request for JavaScript method invocation on map objects. + *

+ * Encapsulates the target object, method name, return type, and parameters for + * JavaScript execution via the transport layer. Supports both void operations + * and typed return values. + *

+ * + * @param self the target JL object for method invocation + * @param function the JavaScript method name to invoke + * @param clazz the expected return type (Void.class for void operations) + * @param params method parameters (converted to JavaScript arguments) + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public record JLTransportRequest(JLObject self, String function, Class clazz, Object... params) { + + /** + * Creates a void transport request with no return value. + * + * @param self the target object + * @param function the method name + * @param params method parameters + * @return transport request for void operation + */ + public static JLTransportRequest voidCall(@NonNull JLObject self, @NonNull String function, Object... params) { + return new JLTransportRequest(self, function, Void.class, params); + } + + /** + * Creates a returnable transport request with typed return value. + * + * @param self the target object + * @param function the method name + * @param clazz the expected return type + * @param params method parameters + * @return transport request for typed operation + */ + public static JLTransportRequest returnableCall(@NonNull JLObject self, @NonNull String function, Class clazz, Object... params) { + return new JLTransportRequest(self, function, clazz, params); + } + + /** + * Type-safe cast of the return type class. + * + * @param the target type + * @return the class cast to the target type + */ + @SuppressWarnings("unchecked") + public Class getCastedClazz() { + return (Class) clazz; + } + + /** + * Equality based on function name and parameters (excludes self and clazz). + */ + @Override + public boolean equals(Object o) { + if (!(o instanceof JLTransportRequest that)) return false; + return Objects.equals(function, that.function) && Objects.deepEquals(params, that.params); + } + + /** + * Hash code based on function name and parameters. + */ + @Override + public int hashCode() { + return Objects.hash(function, Arrays.hashCode(params)); + } + + /** + * String representation showing function name and parameters. + */ + @NonNull + @Override + public String toString() { + return "JLTransport{" + + "function='" + function() + '\'' + + ", params=" + Arrays.toString(params()) + + '}'; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLWebEngine.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLWebEngine.java new file mode 100644 index 0000000..be4a0e1 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/engine/JLWebEngine.java @@ -0,0 +1,27 @@ +package io.github.makbn.jlmap.engine; + +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; + +/** + * @author Matt Akbarian (@makbn) + */ +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@FieldDefaults(makeFinal = true, level = AccessLevel.PROTECTED) +public abstract class JLWebEngine { + Class defaultClass; + public abstract T executeScript(String script, Class type); + + public abstract Status getStatus(); + + public C executeScript(@NonNull String script) { + return this.executeScript(script, defaultClass); + } + + public enum Status { + SUCCEEDED, + FAILED + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLConversionException.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLConversionException.java new file mode 100644 index 0000000..28b27e9 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLConversionException.java @@ -0,0 +1,21 @@ +package io.github.makbn.jlmap.exception; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.experimental.FieldDefaults; + +/** + * JLMap exception class for converting js results + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLConversionException extends JLException { + transient Object rawValue; + + public JLConversionException(Throwable cause, Object rawValue) { + super(cause); + this.rawValue = rawValue; + } +} diff --git a/src/main/java/io/github/makbn/jlmap/exception/JLException.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLException.java similarity index 57% rename from src/main/java/io/github/makbn/jlmap/exception/JLException.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLException.java index 1dd4d1b..0a350ef 100644 --- a/src/main/java/io/github/makbn/jlmap/exception/JLException.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLException.java @@ -2,14 +2,19 @@ /** * Internal JLMap application's Exception base class. - * @author Mehdi Akbarian Rastaghi (@makbn) + * + * @author Matt Akbarian (@makbn) */ - -public class JLException extends RuntimeException{ +public class JLException extends RuntimeException { public JLException(String message) { super(message); } + public JLException(Throwable cause) { super(cause); } + + public JLException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/io/github/makbn/jlmap/exception/JLGeoJsonParserException.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLGeoJsonParserException.java similarity index 84% rename from src/main/java/io/github/makbn/jlmap/exception/JLGeoJsonParserException.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLGeoJsonParserException.java index 563450a..79af02b 100644 --- a/src/main/java/io/github/makbn/jlmap/exception/JLGeoJsonParserException.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLGeoJsonParserException.java @@ -3,7 +3,7 @@ import lombok.Builder; /** - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ public class JLGeoJsonParserException extends JLException { diff --git a/src/main/java/io/github/makbn/jlmap/exception/JLMapNotReadyException.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLMapNotReadyException.java similarity index 63% rename from src/main/java/io/github/makbn/jlmap/exception/JLMapNotReadyException.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLMapNotReadyException.java index 0d1baac..7197291 100644 --- a/src/main/java/io/github/makbn/jlmap/exception/JLMapNotReadyException.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/exception/JLMapNotReadyException.java @@ -4,11 +4,8 @@ /** * JLMap Exception which is thrown when changing the map before it gets ready! - * Leaflet map gets fully ready after the {@link javafx.concurrent.Worker.State} - * of {@link javafx.scene.web.WebEngine} changes to - * {@link javafx.concurrent.Worker.State#SUCCEEDED} * - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ public class JLMapNotReadyException extends JLException { private static final String MAP_IS_NOT_READY_YET = "Map is not ready yet!"; @@ -17,4 +14,8 @@ public class JLMapNotReadyException extends JLException { public JLMapNotReadyException() { super(MAP_IS_NOT_READY_YET); } + + public JLMapNotReadyException(String message) { + super(message); + } } diff --git a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonContent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonContent.java similarity index 94% rename from src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonContent.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonContent.java index 5c2dfcd..84dd655 100644 --- a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonContent.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonContent.java @@ -6,7 +6,7 @@ import lombok.experimental.FieldDefaults; /** - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) public class JLGeoJsonContent extends JLGeoJsonSource { diff --git a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonFile.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonFile.java similarity index 94% rename from src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonFile.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonFile.java index b3dc972..73ac98b 100644 --- a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonFile.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonFile.java @@ -10,7 +10,7 @@ import java.nio.file.Files; /** - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) public class JLGeoJsonFile extends JLGeoJsonSource { diff --git a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonSource.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonSource.java similarity index 92% rename from src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonSource.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonSource.java index 14d5b05..4aafc35 100644 --- a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonSource.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonSource.java @@ -3,6 +3,7 @@ import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; import io.github.makbn.jlmap.exception.JLGeoJsonParserException; +import io.github.makbn.jlmap.model.JLGeoJson; import lombok.AccessLevel; import lombok.experimental.FieldDefaults; import lombok.experimental.NonFinal; @@ -10,7 +11,7 @@ /** * The base abstract class for a GeoJSON data source. Implementations of this class are expected * to provide functionality for loading and accessing GeoJSON objects. - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) * * @param source type */ @@ -26,7 +27,7 @@ public abstract class JLGeoJsonSource { * The GeoJSON object loaded from this source. */ @NonFinal - JLGeoJsonObject geoJsonObject; + JLGeoJson geoJsonObject; /** * Initializes a new instance of {@code JLGeoJsonSource} and sets up the Gson object. diff --git a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonURL.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonURL.java similarity index 96% rename from src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonURL.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonURL.java index b839888..54e95a7 100644 --- a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonURL.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonURL.java @@ -9,7 +9,7 @@ import java.net.URL; /** - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ public class JLGeoJsonURL extends JLGeoJsonSource { diff --git a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletControlLayerInt.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletControlLayerInt.java similarity index 51% rename from src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletControlLayerInt.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletControlLayerInt.java index 09fefcb..fd72225 100644 --- a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletControlLayerInt.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletControlLayerInt.java @@ -4,35 +4,68 @@ import io.github.makbn.jlmap.model.JLLatLng; /** - * The {@code LeafletControlLayerInt} interface defines methods for controlling - * the zoom and view of a Leaflet map. Leaflet is a popular JavaScript library - * for creating interactive maps, and this interface provides a Java API for - * manipulating the map's zoom level, view, and geographical bounds. + * Interface for programmatic control of map navigation, zooming, and viewport management. + *

+ * This interface provides methods to manipulate the map's view state, including zoom levels, + * geographic bounds, and animated transitions. All operations are smoothly animated unless + * otherwise specified. + *

+ *

+ * The control layer handles the following key operations: + *

+ *
    + *
  • Zoom Control: Increase, decrease, or set specific zoom levels
  • + *
  • View Management: Pan to coordinates and set geographic bounds
  • + *
  • Bounds Fitting: Automatically adjust view to fit geographic areas
  • + *
  • Constraints: Set minimum and maximum zoom limits
  • + *
  • Flight Animation: Smooth animated transitions between locations
  • + *
+ *

+ * Thread Safety: All methods should be called from the UI thread of the + * respective framework (JavaFX Application Thread or Vaadin UI thread). + *

* - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) + * @since 2.0.0 */ -public interface LeafletControlLayerInt { +public interface LeafletControlLayerInt extends LeafletLayer { /** - * Increases the zoom of the map by delta + * Smoothly increases the map zoom level by the specified amount. + *

+ * The map will animate to the new zoom level while maintaining the current center point. + * If the resulting zoom level exceeds the maximum allowed zoom, it will be clamped. + *

* + * @param delta the number of zoom levels to increase (must be positive) * @see leafletjs.com/reference.html#map-zoomin */ void zoomIn(int delta); /** - * Decreases the zoom of the map by delta + * Smoothly decreases the map zoom level by the specified amount. + *

+ * The map will animate to the new zoom level while maintaining the current center point. + * If the resulting zoom level is below the minimum allowed zoom, it will be clamped. + *

* + * @param delta the number of zoom levels to decrease (must be positive) * @see - * leafletjs.com/reference.html#map-zoomout + * leafletjs.com/reference.html#map-zoomout */ void zoomOut(int delta); /** - * Sets the zoom of the map. + * Animates the map to the specified zoom level. + *

+ * The map will smoothly transition to the new zoom level while keeping the current + * center point fixed. Values outside the allowed zoom range will be automatically + * clamped to valid bounds. + *

* + * @param level the target zoom level (typically 0-19, depending on tile provider) * @see - * leafletjs.com/reference.html#map-setzoom + * leafletjs.com/reference.html#map-setzoom */ void setZoom(int level); @@ -41,7 +74,7 @@ public interface LeafletControlLayerInt { * stationary (e.g. used internally for scroll zoom and double-click zoom) * * @see - * leafletjs.com/reference.html#map-setzoomaround + * leafletjs.com/reference.html#map-setzoomaround */ void setZoomAround(JLLatLng latLng, int zoom); @@ -52,7 +85,7 @@ public interface LeafletControlLayerInt { * * @param bounds The geographical bounds to fit. * @see - * leafletjs.com/reference.html#map-fitbounds + * leafletjs.com/reference.html#map-fitbounds */ void fitBounds(JLBounds bounds); @@ -61,27 +94,27 @@ public interface LeafletControlLayerInt { * zoom level possible. * * @see - * leafletjs.com/reference.html#map-fitworld + * leafletjs.com/reference.html#map-fitworld */ void fitWorld(); /** - * Pans the map to a given center. + * Pans the map to a given latLng. * - * @param latLng The new center of the map. + * @param latLng The new latLng of the map. * @see - * leafletjs.com/reference.html#map-panto + * leafletjs.com/reference.html#map-panto */ void panTo(JLLatLng latLng); /** - * Sets the view of the map (geographical center and zoom) performing a + * Sets the view of the map (geographical latLng and zoom) performing a * smooth pan-zoom animation. * - * @param latLng The new center of the map. + * @param latLng The new latLng of the map. * @param zoom The new zoom level (optional). * @see - * leafletjs.com/reference.html#map-flyto + * leafletjs.com/reference.html#map-flyto */ void flyTo(JLLatLng latLng, int zoom); @@ -91,7 +124,7 @@ public interface LeafletControlLayerInt { * * @param bounds The bounds to fit the map view to. * @see - * leafletjs.com/reference.html#map-flytobounds + * leafletjs.com/reference.html#map-flytobounds */ void flyToBounds(JLBounds bounds); @@ -100,7 +133,7 @@ public interface LeafletControlLayerInt { * * @param bounds The geographical bounds to restrict the map view to. * @see - * leafletjs.com/reference.html#map-setmaxbounds + * leafletjs.com/reference.html#map-setmaxbounds */ void setMaxBounds(JLBounds bounds); @@ -109,7 +142,7 @@ public interface LeafletControlLayerInt { * * @param zoom The minimum zoom level. * @see - * leafletjs.com/reference.html#map-setminzoom + * leafletjs.com/reference.html#map-setminzoom */ void setMinZoom(int zoom); @@ -118,7 +151,7 @@ public interface LeafletControlLayerInt { * * @param zoom The maximum zoom level. * @see - * leafletjs.com/reference.html#map-setmaxzoom + * leafletjs.com/reference.html#map-setmaxzoom */ void setMaxZoom(int zoom); @@ -127,7 +160,7 @@ public interface LeafletControlLayerInt { * * @param bounds The bounds to pan inside. * @see - * leafletjs.com/reference.html#map-paninsidebounds + * leafletjs.com/reference.html#map-paninsidebounds */ void panInsideBounds(JLBounds bounds); @@ -136,7 +169,7 @@ public interface LeafletControlLayerInt { * * @param latLng The geographical point to make visible. * @see - * leafletjs.com/reference.html#map-paninside + * leafletjs.com/reference.html#map-paninside */ void panInside(JLLatLng latLng); diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletGeoJsonLayerInt.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletGeoJsonLayerInt.java new file mode 100644 index 0000000..95162b4 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletGeoJsonLayerInt.java @@ -0,0 +1,91 @@ +package io.github.makbn.jlmap.layer.leaflet; + +import io.github.makbn.jlmap.exception.JLException; +import io.github.makbn.jlmap.model.JLGeoJson; +import io.github.makbn.jlmap.model.JLGeoJsonOptions; +import lombok.NonNull; + +import java.io.File; + +/** + * The {@code LeafletGeoJsonLayerInt} interface defines methods for adding + * and managing GeoJSON data layers in a Leaflet map. + *

+ * Implementations of this interface should provide methods to add GeoJSON + * data from various sources, such as files, URLs, or raw content, as well + * as the ability to remove GeoJSON objects from the map. + *

+ * Enhanced to support advanced GeoJSON features including custom styling, + * filtering, and point-to-layer functions. + * + * @author Matt Akbarian (@makbn) + */ +public interface LeafletGeoJsonLayerInt extends LeafletLayer { + + /** + * Adds a GeoJSON object from a file to the Leaflet map. + * + * @param file The {@link File} object representing the GeoJSON file to be added. + * @return The {@link JLGeoJson} representing the added GeoJSON data. + * @throws JLException If there is an error while adding the GeoJSON data. + */ + JLGeoJson addFromFile(@NonNull File file) throws JLException; + + /** + * Adds a GeoJSON object from a file to the Leaflet map with custom options. + * + * @param file The {@link File} object representing the GeoJSON file to be added. + * @param options Custom styling and configuration options for the GeoJSON layer. + * @return The {@link JLGeoJson} representing the added GeoJSON data. + * @throws JLException If there is an error while adding the GeoJSON data. + */ + JLGeoJson addFromFile(@NonNull File file, @NonNull JLGeoJsonOptions options) throws JLException; + + /** + * Adds a GeoJSON object from a URL to the Leaflet map. + * + * @param url The URL of the GeoJSON data to be added. + * @return The {@link JLGeoJson} representing the added GeoJSON data. + * @throws JLException If there is an error while adding the GeoJSON data. + */ + JLGeoJson addFromUrl(@NonNull String url) throws JLException; + + /** + * Adds a GeoJSON object from a URL to the Leaflet map with custom options. + * + * @param url The URL of the GeoJSON data to be added. + * @param options Custom styling and configuration options for the GeoJSON layer. + * @return The {@link JLGeoJson} representing the added GeoJSON data. + * @throws JLException If there is an error while adding the GeoJSON data. + */ + JLGeoJson addFromUrl(@NonNull String url, @NonNull JLGeoJsonOptions options) throws JLException; + + /** + * Adds a GeoJSON object from raw content to the Leaflet map. + * + * @param content The raw GeoJSON content to be added. + * @return The {@link JLGeoJson} representing the added GeoJSON data. + * @throws JLException If there is an error while adding the GeoJSON data. + */ + JLGeoJson addFromContent(@NonNull String content) throws JLException; + + /** + * Adds a GeoJSON object from raw content to the Leaflet map with custom options. + * + * @param content The raw GeoJSON content to be added. + * @param options Custom styling and configuration options for the GeoJSON layer. + * @return The {@link JLGeoJson} representing the added GeoJSON data. + * @throws JLException If there is an error while adding the GeoJSON data. + */ + JLGeoJson addFromContent(@NonNull String content, @NonNull JLGeoJsonOptions options) throws JLException; + + /** + * Removes a GeoJSON object from the Leaflet map. + * + * @param id of the {@link JLGeoJson} to be removed from the map. + * @return {@code true} if the removal was successful, {@code false} + * if the object was not found or could not be removed. + */ + boolean removeGeoJson(@NonNull String id); + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletLayer.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletLayer.java new file mode 100644 index 0000000..b8b943c --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletLayer.java @@ -0,0 +1,4 @@ +package io.github.makbn.jlmap.layer.leaflet; + +public interface LeafletLayer { +} diff --git a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletUILayerInt.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletUILayerInt.java similarity index 66% rename from src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletUILayerInt.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletUILayerInt.java index 394412f..4be9b79 100644 --- a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletUILayerInt.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletUILayerInt.java @@ -1,9 +1,6 @@ package io.github.makbn.jlmap.layer.leaflet; -import io.github.makbn.jlmap.model.JLLatLng; -import io.github.makbn.jlmap.model.JLMarker; -import io.github.makbn.jlmap.model.JLOptions; -import io.github.makbn.jlmap.model.JLPopup; +import io.github.makbn.jlmap.model.*; /** * The {@code LeafletUILayerInt} interface defines methods for adding and @@ -12,17 +9,17 @@ * and this interface provides a Java API for working with user interface * elements within Leaflet. * - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ -public interface LeafletUILayerInt { +public interface LeafletUILayerInt extends LeafletLayer { /** * Adds a marker to the Leaflet map at the specified geographical * coordinates. * - * @param latLng The geographical coordinates (latitude and longitude) - * where the marker should be placed. - * @param text The text content associated with the marker. + * @param latLng The geographical coordinates (latitude and longitude) + * where the marker should be placed. + * @param text The text content associated with the marker. * @param draggable {@code true} if the marker should be draggable, * {@code false} otherwise. * @return The {@link JLMarker} representing the added marker on the map. @@ -35,9 +32,8 @@ public interface LeafletUILayerInt { * @param id The unique identifier of the marker to be removed. * @return {@code true} if the marker was successfully removed, * {@code false} if the marker with the specified identifier was not found. - * */ - boolean removeMarker(int id); + boolean removeMarker(String id); /** * Adds a popup to the Leaflet map at the specified geographical @@ -68,9 +64,20 @@ public interface LeafletUILayerInt { * * @param id The unique identifier of the popup to be removed. * @return {@code true} if the popup was successfully removed, - * {@code false} if the popup with the specified identifier - * was not found. + * {@code false} if the popup with the specified identifier + * was not found. + */ + boolean removePopup(String id); + + /** + * Instantiates an image overlay object given the URL of the image and the geographical bounds it is tied to. + * Read more about it on Leaflet ImageOverlay + * + * @param bounds the geographical bounds the image is tied to. + * @param imageUrl URL of the image to be used as an overlay. (can be local or remote URL) + * @param options theming options for JLImageOverlay. all options are not available! + * @return The {@link JLImageOverlay} representing the added image overlay on the map. */ - boolean removePopup(int id); + JLImageOverlay addImage(JLBounds bounds, String imageUrl, JLOptions options); } diff --git a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletVectorLayerInt.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletVectorLayerInt.java similarity index 90% rename from src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletVectorLayerInt.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletVectorLayerInt.java index 0655f13..ef3d0ac 100644 --- a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletVectorLayerInt.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletVectorLayerInt.java @@ -8,9 +8,9 @@ * Leaflet map. Leaflet is a popular JavaScript library for creating interactive maps, and * this interface provides a Java API for working with vector-based layers within Leaflet. * - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ -public interface LeafletVectorLayerInt { +public interface LeafletVectorLayerInt extends LeafletLayer{ /** * Adds a polyline to the Leaflet map with the provided array of vertices. @@ -39,7 +39,7 @@ public interface LeafletVectorLayerInt { * polyline with the specified identifier was not found. */ - boolean removePolyline(int id); + boolean removePolyline(String id); /** * Adds a multi-polyline to the Leaflet map with the provided array of arrays of vertices. @@ -67,7 +67,7 @@ public interface LeafletVectorLayerInt { * @return {@code true} if the multi-polyline was successfully removed, {@code false} if the * multi-polyline with the specified identifier was not found. */ - boolean removeMultiPolyline(int id); + boolean removeMultiPolyline(String id); /** * Adds a polygon to the Leaflet map with the provided array of arrays of vertices and custom options. @@ -95,12 +95,12 @@ public interface LeafletVectorLayerInt { * @return {@code true} if the polygon was successfully removed, {@code false} if the * polygon with the specified identifier was not found. */ - boolean removePolygon(int id); + boolean removePolygon(String id); /** - * Adds a circle to the Leaflet map with the provided center coordinates, radius, and custom options. + * Adds a circle to the Leaflet map with the provided latLng coordinates, radius, and custom options. * - * @param center The geographical coordinates (latitude and longitude) of the circle's center. + * @param center The geographical coordinates (latitude and longitude) of the circle's latLng. * @param radius The radius of the circle in meters. * @param options Custom options for configuring the appearance and behavior of the circle. * @return The {@link JLCircle} representing the added circle on the map. @@ -108,9 +108,9 @@ public interface LeafletVectorLayerInt { JLCircle addCircle(JLLatLng center, int radius, JLOptions options); /** - * Adds a circle to the Leaflet map with the provided center coordinates and radius. + * Adds a circle to the Leaflet map with the provided latLng coordinates and radius. * - * @param center The geographical coordinates (latitude and longitude) of the circle's center. + * @param center The geographical coordinates (latitude and longitude) of the circle's latLng. * @return The {@link JLCircle} representing the added circle on the map. */ JLCircle addCircle(JLLatLng center); @@ -122,12 +122,12 @@ public interface LeafletVectorLayerInt { * @return {@code true} if the circle was successfully removed, {@code false} if the * circle with the specified identifier was not found. */ - boolean removeCircle(int id); + boolean removeCircle(String id); /** - * Adds a circle marker to the Leaflet map with the provided center coordinates, radius, and custom options. + * Adds a circle marker to the Leaflet map with the provided latLng coordinates, radius, and custom options. * - * @param center The geographical coordinates (latitude and longitude) of the circle marker's center. + * @param center The geographical coordinates (latitude and longitude) of the circle marker's latLng. * @param radius The radius of the circle marker in pixels. * @param options Custom options for configuring the appearance and behavior of the circle marker. * @return The {@link JLCircleMarker} representing the added circle marker on the map. @@ -135,9 +135,9 @@ public interface LeafletVectorLayerInt { JLCircleMarker addCircleMarker(JLLatLng center, int radius, JLOptions options); /** - * Adds a circle marker to the Leaflet map with the provided center coordinates and radius. + * Adds a circle marker to the Leaflet map with the provided latLng coordinates and radius. * - * @param center The geographical coordinates (latitude and longitude) of the circle marker's center. + * @param center The geographical coordinates (latitude and longitude) of the circle marker's latLng. * @return The {@link JLCircleMarker} representing the added circle marker on the map. */ JLCircleMarker addCircleMarker(JLLatLng center); @@ -149,6 +149,6 @@ public interface LeafletVectorLayerInt { * @return {@code true} if the circle marker was successfully removed, {@code false} if the * circle marker with the specified identifier was not found. */ - boolean removeCircleMarker(int id); + boolean removeCircleMarker(String id); } diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/JLAction.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/JLAction.java new file mode 100644 index 0000000..580f153 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/JLAction.java @@ -0,0 +1,81 @@ +package io.github.makbn.jlmap.listener; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; + +/** + * @author Matt Akbarian (@makbn) + */ +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public enum JLAction { + MAP_LOADED("load"), + MAP_FAILED("failed"), + /** + * Zoom level changes continuously + */ + ZOOM("zoom"), + /** + * Zoom level stats to change + */ + ZOOM_START("zoomstart"), + /** + * Zoom leve changes end + */ + ZOOM_END("zoomend"), + + /** + * Element is being moved + */ + MOVE("move"), + /** + * User starts to move the element + */ + MOVE_START("movestart"), + /** + * User ends to move the layer + */ + MOVE_END("moveend"), + + /** + * The element is being dragged + */ + DRAG("drag"), + /** + * User starts to drag + */ + DRAG_START("dragstart"), + /** + * User drag ends + */ + DRAG_END("dragend"), + /** + * User click on the layer + */ + CLICK("click"), + /** + * User double-clicks (or double-taps) the layer. + */ + DOUBLE_CLICK("dblclick"), + /** + * Fired after the layer is added to a map + */ + ADD("add"), + /** + * Fired after the layer is removed from a map + */ + REMOVE("remove"), + /** + * Fired when the map is resized. + */ + RESIZE("resize"), + /** + * Fired when a context menu is requested. + */ + CONTEXT_MENU("contextmenu"); + + String jsEventName; +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/OnJLActionListener.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/OnJLActionListener.java new file mode 100644 index 0000000..894f32a --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/OnJLActionListener.java @@ -0,0 +1,11 @@ +package io.github.makbn.jlmap.listener; + +import io.github.makbn.jlmap.listener.event.Event; + +/** + * @author Matt Akbarian (@makbn) + */ +public interface OnJLActionListener { + + void onAction(T source, Event event); +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ClickEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ClickEvent.java new file mode 100644 index 0000000..f494d64 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ClickEvent.java @@ -0,0 +1,33 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLLatLng; + +/** + * Represents a click or double-click event on a map layer or object, analogous to Leaflet's + * click and + * dblclick events. + *

+ * This event is fired when the user clicks or double-clicks a map layer or object. It contains information about + * the action (CLICK or DOUBLE_CLICK) and the geographic coordinates where the event occurred. + *

+ *
    + *
  • action: The {@link JLAction} performed (CLICK or DOUBLE_CLICK).
  • + *
  • center: The geographic coordinates ({@link JLLatLng}) where the event occurred.
  • + *
+ *

+ * Usage in {@link JLInteractionEventHandler}: + *

    + *
  • When a layer is clicked, a ClickEvent is fired with JLAction.CLICK and the click coordinates.
  • + *
  • When a layer is double-clicked, a ClickEvent is fired with JLAction.DOUBLE_CLICK and the coordinates.
  • + *
+ *

+ * This event allows listeners to react to user interactions, such as showing popups, selecting features, or triggering custom logic. + *

+ * @see Leaflet click event + * @see Leaflet dblclick event + * + * @author Matt Akbarian (@makbn) + */ +public record ClickEvent(JLAction action, JLLatLng center) implements Event { +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ContextMenuEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ContextMenuEvent.java new file mode 100644 index 0000000..a687bef --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ContextMenuEvent.java @@ -0,0 +1,25 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; + +/** + * Event representing context menu lifecycle actions (open/close). + *

+ * This event is fired when a context menu is opened, allowing + * listeners to respond to context menu state changes. The event includes + * the coordinates where the menu was triggered and the type of action. + * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public record ContextMenuEvent(JLAction action, JLLatLng latLng, JLBounds jlBounds, double x, + double y) implements Event { + + public ContextMenuEvent(JLAction action, JLLatLng latLng, JLBounds jlBounds, double... position) { + this(action, latLng, jlBounds, + position != null && position.length > 0 ? position[0] : 0D, + position != null && position.length > 1 ? position[1] : 0D); + } +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/DragEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/DragEvent.java new file mode 100644 index 0000000..b4b2ee5 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/DragEvent.java @@ -0,0 +1,41 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; + +/** + * Represents a drag event for a map marker or object, analogous to Leaflet's + * drag, + * dragstart, and + * dragend events. + *

+ * This event is fired when a marker or object is dragged, starts dragging, or ends dragging. + * It contains information about the action (DRAG, DRAG_START, DRAG_END), the new center coordinate, + * the map bounds, and the zoom level at the time of the event. + *

+ *
    + *
  • action: The {@link JLAction} performed
  • + *
  • center: The new coordinate of the marker/object after the drag event.
  • + *
  • bounds: The map bounds after the drag event.
  • + *
  • zoomLevel: The zoom level of the map after the drag event.
  • + *
+ *

+ * Usage in {@link JLDragEventHandler}: + *

    + *
  • When a marker/object is dragged, a DragEvent is fired with JLAction.DRAG.
  • + *
  • When dragging starts, a DragEvent is fired with JLAction.DRAG_START.
  • + *
  • When dragging ends, a DragEvent is fired with JLAction.DRAG_END.
  • + *
+ *

+ * This event allows listeners to react to drag actions, such as updating UI, analytics, or custom logic. + *

+ * @see Leaflet drag event + * @see Leaflet dragstart event + * @see Leaflet dragend event + * + * @author Matt Akbarian (@makbn) + */ +public record DragEvent(JLAction action, JLLatLng center, + JLBounds bounds, int zoomLevel) implements Event { +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/Event.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/Event.java new file mode 100644 index 0000000..6e2a74f --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/Event.java @@ -0,0 +1,28 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; + +/** + * Base interface for all map interaction events in JLMap. + *

+ * Subclasses of {@code Event} represent specific types of user or programmatic interactions with the map. + * Each event contains a {@link JLAction} describing the type of action performed. + *

+ *

+ * Known subclasses: + *

    + *
  • {@link MoveEvent} – Fired when the map is moved (see Leaflet move events).
  • + *
  • {@link ResizeEvent} – Fired when the map container is resized (Leaflet resize event).
  • + *
  • {@link ZoomEvent} – Fired when the map zoom level changes (Leaflet zoom event).
  • + *
  • {@link LayerEvent} – Fired when a layer is added or removed.
  • + *
  • {@link DragEvent} – Fired when a marker or object is dragged (Leaflet drag events).
  • + *
  • {@link ClickEvent} – Fired when a map layer or object is clicked or double-clicked (Leaflet click events).
  • + *
+ *

+ * Implementations of this interface are used in event handlers to provide detailed context about map interactions. + *

+ * @author Matt Akbarian (@makbn) + */ +public interface Event { + JLAction action(); +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLDragEventHandler.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLDragEventHandler.java new file mode 100644 index 0000000..1a86c0a --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLDragEventHandler.java @@ -0,0 +1,95 @@ +package io.github.makbn.jlmap.listener.event; + +import com.google.gson.Gson; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLObject; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import java.util.Set; + +/** + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLDragEventHandler implements JLEventHandler> { + /** + * Fired when the marker is moved via setLatLng or by dragging. Old and new coordinates are included in event arguments as oldLatLng, latlng. + */ + public static final String FUNCTION_MOVE = "move"; + /** + * Fired when the marker starts moving (because of dragging). + */ + public static final String FUNCTION_MOVE_START = "movestart"; + /** + * Fired when the marker stops moving (because of dragging). + */ + public static final String FUNCTION_MOVE_END = "moveend"; + + /** + * Fired repeatedly while the user drags the marker. + */ + public static final String FUNCTION_DRAG = "drag"; + + /** + * Fired when the user starts dragging the marker. + */ + public static final String FUNCTION_DRAG_START = "dragstart"; + /** + * Fired when the user stops dragging the marker. + */ + public static final String FUNCTION_DRAG_END = "dragend"; + + public static final Set FUNCTIONS = Set.of(FUNCTION_MOVE, FUNCTION_MOVE_START, FUNCTION_MOVE_END, FUNCTION_DRAG, FUNCTION_DRAG_START, FUNCTION_DRAG_END); + + Gson gson = new Gson(); + + @Override + public void handle(@NonNull JLMap map, @NonNull JLObject source, @NonNull String functionName, + OnJLActionListener> listener, Object param1, Object param2, + Object param3, Object param4, Object param5) { + switch (functionName) { + case FUNCTION_MOVE -> listener + .onAction(source, getMoveEvent(JLAction.MOVE, param4, param5, param3)); + case FUNCTION_MOVE_START -> listener + .onAction(source, getMoveEvent(JLAction.MOVE_START, param4, param5, param3)); + case FUNCTION_MOVE_END -> listener + .onAction(source, getMoveEvent(JLAction.MOVE_END, param4, param5, param3)); + case FUNCTION_DRAG -> listener + .onAction(source, getDragEvent(JLAction.DRAG, param4, param5, param3)); + case FUNCTION_DRAG_START -> listener + .onAction(source, getDragEvent(JLAction.DRAG_START, param4, param5, param3)); + case FUNCTION_DRAG_END -> listener + .onAction(source, getDragEvent(JLAction.DRAG_END, param4, param5, param3)); + + default -> log.error("{} not implemented!", functionName); + } + } + + private @NotNull MoveEvent getMoveEvent(JLAction action, Object param4, Object param5, Object param3) { + return new MoveEvent(action, + gson.fromJson(String.valueOf(param4), JLLatLng.class), + gson.fromJson(String.valueOf(param5), JLBounds.class), + Integer.parseInt(String.valueOf(param3))); + } + + private @NotNull DragEvent getDragEvent(JLAction action, Object param4, Object param5, Object param3) { + return new DragEvent(action, + gson.fromJson(String.valueOf(param4), JLLatLng.class), + gson.fromJson(String.valueOf(param5), JLBounds.class), + Integer.parseInt(String.valueOf(param3))); + } + + @Override + public boolean canHandle(@NonNull String functionName) { + return FUNCTIONS.contains(functionName); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLEventHandler.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLEventHandler.java new file mode 100644 index 0000000..ab34560 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLEventHandler.java @@ -0,0 +1,16 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import lombok.NonNull; + +/** + * @author Matt Akbarian (@makbn) + */ +public interface JLEventHandler { + + void handle(@NonNull JLMap map, @NonNull T source, @NonNull String functionName, OnJLActionListener listener, Object param1, Object param2, + Object param3, Object param4, Object param5); + + boolean canHandle(@NonNull String functionName); +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLInteractionEventHandler.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLInteractionEventHandler.java new file mode 100644 index 0000000..e997061 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLInteractionEventHandler.java @@ -0,0 +1,83 @@ +package io.github.makbn.jlmap.listener.event; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.element.menu.JLContextMenuMediator; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLObject; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.ServiceLoader; +import java.util.Set; + +/** + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLInteractionEventHandler implements JLEventHandler> { + /** + * Fired when the user clicks (or taps) the layer. + */ + private static final String FUNCTION_CLICK = "click"; + /** + * Fired when the user double-clicks (or double-taps) the layer. + */ + private static final String FUNCTION_DOUBLE_CLICK = "dblclick"; + private static final String FUNCTION_CONTEXT_MENU = "contextmenu"; + + public static final Set FUNCTIONS = Set.of(FUNCTION_CLICK, FUNCTION_DOUBLE_CLICK, FUNCTION_CONTEXT_MENU); + + Gson gson; + JLContextMenuMediator contextMenuMediator; + + public JLInteractionEventHandler() { + this.gson = new Gson(); + this.contextMenuMediator = ServiceLoader.load(JLContextMenuMediator.class).findFirst().orElseThrow(); + } + + @Override + public void handle(@NonNull JLMap map, @NonNull JLObject source, @NonNull String functionName, OnJLActionListener> listener, Object param1, Object param2, Object param3, Object param4, Object param5) { + switch (functionName) { + case FUNCTION_CLICK -> listener + .onAction(source, new ClickEvent(JLAction.CLICK, gson.fromJson(String.valueOf(param4), JLLatLng.class))); + case FUNCTION_DOUBLE_CLICK -> listener + .onAction(source, new ClickEvent(JLAction.DOUBLE_CLICK, gson.fromJson(String.valueOf(param4), JLLatLng.class))); + case FUNCTION_CONTEXT_MENU -> handleContextMenuEvent(map, source, listener, param4, param5); + default -> log.error("{} not implemented!", functionName); + } + } + + private void handleContextMenuEvent(@NonNull JLMap map, @NonNull JLObject source, OnJLActionListener> listener, Object param4, Object param5) { + ContextMenuEvent event = new ContextMenuEvent(JLAction.CONTEXT_MENU, gson.fromJson(String.valueOf(param4), JLLatLng.class), + gson.fromJson(String.valueOf(param5), JLBounds.class), getPosition(String.valueOf(param4))); + + contextMenuMediator.showContextMenu(map, source, event.x(), event.y()); + listener.onAction(source, event); + } + + + private double[] getPosition(String positionJson) { + try { + JsonObject jsonObject = JsonParser.parseString(positionJson).getAsJsonObject(); + return new double[]{jsonObject.get("x").getAsDouble(), jsonObject.get("y").getAsDouble()}; + } catch (Exception e) { + log.error("Error parsing position from {}", positionJson, e); + return new double[]{0, 0}; + } + } + + + @Override + public boolean canHandle(@NonNull String functionName) { + return FUNCTIONS.contains(functionName); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLLayerEventHandler.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLLayerEventHandler.java new file mode 100644 index 0000000..a7136b7 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLLayerEventHandler.java @@ -0,0 +1,72 @@ +package io.github.makbn.jlmap.listener.event; + +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLLayerEventHandler implements JLEventHandler { + /** + * Fired after the layer is added to a map + */ + public static final String FUNCTION_ADD = "add"; + /** + * Fired after the layer is removed from a map + */ + public static final String FUNCTION_REMOVE = "remove"; + + public static final Set FUNCTIONS = Set.of(FUNCTION_ADD, FUNCTION_REMOVE); + + Gson gson = new Gson(); + + @Override + public void handle(@NonNull JLMap map, @NonNull Object source, @NonNull String functionName, OnJLActionListener listener, + Object param1, Object param2, Object param3, Object param4, Object param5) { + switch (functionName) { + case FUNCTION_ADD -> listener + .onAction(source, new LayerEvent(JLAction.ADD, getJlLatLng((String) param4), gson.fromJson(String.valueOf(param5), JLBounds.class))); + case FUNCTION_REMOVE -> listener + .onAction(source, new LayerEvent(JLAction.REMOVE, getJlLatLng((String) param4), gson.fromJson(String.valueOf(param5), JLBounds.class))); + default -> log.error("{} not implemented!", functionName); + } + } + + @NonNull + private List> getJlLatLng(String latLngString) { + try { + if (JsonParser.parseString(latLngString).isJsonArray()) { + Type listType = new TypeToken>>() { + }.getType(); + return gson.fromJson(latLngString, listType); + } else { + return List.of(List.of(gson.fromJson(latLngString, JLLatLng.class))); + } + } catch (Exception e) { + log.error(e.getMessage()); + return Collections.emptyList(); + } + } + + @Override + public boolean canHandle(@NonNull String functionName) { + return FUNCTIONS.contains(functionName); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLStatusChangeEventHandler.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLStatusChangeEventHandler.java new file mode 100644 index 0000000..cff27a2 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/JLStatusChangeEventHandler.java @@ -0,0 +1,73 @@ +package io.github.makbn.jlmap.listener.event; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.model.JLBounds; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import java.util.Set; + +/** + * Handles status change events for the map, such as zoom and resize actions. + *

+ * This event handler listens for map status changes and dispatches corresponding events: + * @see ZoomEvent + * @see ResizeEvent + * + * @author Matt Akbarian (@makbn) + */ +@Slf4j +public class JLStatusChangeEventHandler implements JLEventHandler { + public static final String FUNCTION_ZOOM = "zoom"; + public static final String FUNCTION_ZOOM_START = "zoomstart"; + public static final String FUNCTION_ZOOM_END = "zoomend"; + public static final String FUNCTION_RESIZE = "resize"; + public static final Set FUNCTIONS = Set.of(FUNCTION_ZOOM, FUNCTION_ZOOM_START, FUNCTION_ZOOM_END, + FUNCTION_RESIZE); + + Gson gson = new Gson(); + + @Override + public void handle(@NonNull JLMap map, @NonNull Object source, @NonNull String functionName, OnJLActionListener listener, Object param1, Object param2, Object param3, Object param4, Object param5) { + switch (functionName) { + case FUNCTION_ZOOM -> listener + .onAction(source, new ZoomEvent(JLAction.ZOOM, gson.fromJson(String.valueOf(param3), Integer.class), gson.fromJson(String.valueOf(param5), JLBounds.class))); + case FUNCTION_ZOOM_START -> listener + .onAction(source, new ZoomEvent(JLAction.ZOOM_START, gson.fromJson(String.valueOf(param3), Integer.class), gson.fromJson(String.valueOf(param5), JLBounds.class))); + case FUNCTION_ZOOM_END -> listener + .onAction(source, new ZoomEvent(JLAction.ZOOM_END, gson.fromJson(String.valueOf(param3), Integer.class), gson.fromJson(String.valueOf(param5), JLBounds.class))); + case FUNCTION_RESIZE -> listener + .onAction(source, new ResizeEvent(JLAction.RESIZE, + getDimension(param4, false, "Width"), + getDimension(param4, false, "Height"), + getDimension(param4, true, "Width"), + getDimension(param4, true, "Height"), + gson.fromJson(String.valueOf(param3), Integer.class))); + default -> log.error("{} not implemented!", functionName); + } + } + + /** + * Extracts the dimension value from the JSON object for new or old width/height. + * + * @param json the JSON object as string + * @param forOld true for old dimension, false for new + * @param dimension "Width" or "Height" + * @return the dimension value as int + */ + private int getDimension(Object json, boolean forOld, String dimension) { + String field = (forOld ? "old" : "new") + dimension; + JsonElement dimensionElement = JsonParser.parseString(String.valueOf(json)); + return dimensionElement.getAsJsonObject().get(field).getAsInt(); + } + + @Override + public boolean canHandle(@NonNull String functionName) { + return FUNCTIONS.contains(functionName); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/LayerEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/LayerEvent.java new file mode 100644 index 0000000..810ae03 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/LayerEvent.java @@ -0,0 +1,31 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; + +import java.util.List; + +/** + * Represents an event related to a map layer, such as adding or removing a layer. + *

+ * This event is used in {@link JLLayerEventHandler} to signal when a layer is added or removed from the map. + * The event contains: + *

    + *
  • action: The {@link JLAction} performed (ADD or REMOVE).
  • + *
  • latLng: The coordinates of the layer, as a list of lists of {@link JLLatLng} (for polygons, polylines, etc.).
  • + *
  • bounds: The {@link JLBounds} of the layer after the action.
  • + *
+ *

+ * Usage in {@link JLLayerEventHandler}: + *

    + *
  • When a layer is added, a LayerEvent is fired with JLAction.ADD, the layer's coordinates, and bounds.
  • + *
  • When a layer is removed, a LayerEvent is fired with JLAction.REMOVE, the layer's coordinates, and bounds.
  • + *
+ *

+ * This event allows listeners to react to layer changes, such as updating UI, analytics, or custom logic. + *

+ * @author Matt Akbarian (@makbn) + */ +public record LayerEvent(JLAction action, List> latLng, JLBounds bounds) implements Event { +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/MapEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/MapEvent.java new file mode 100644 index 0000000..b4cee9b --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/MapEvent.java @@ -0,0 +1,6 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; + +public record MapEvent(JLAction action) implements Event { +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/MoveEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/MoveEvent.java new file mode 100644 index 0000000..ddb2dc2 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/MoveEvent.java @@ -0,0 +1,35 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; + +/** + * Represents a map movement event, similar to Leaflet's move, movestart, and moveend events. + *

+ * This event is fired when the map view is moved, including panning or programmatic changes to the center. + * It contains information about the movement action, the new center coordinate, the map bounds, and the zoom level. + *

+ *
    + *
  • action: The JLAction that triggered the movement.
  • + *
  • center: The new center coordinate of the map after movement.
  • + *
  • bounds: The new bounds of the map after movement.
  • + *
  • zoomLevel: The current zoom level of the map.
  • + *
+ *

+ * This event can be used to handle UI updates, analytics, or custom logic when the map is moved. It is analogous to: + *

    + *
  • move: Fired repeatedly while the map is moving.
  • + *
  • movestart: Fired once when movement starts.
  • + *
  • moveend: Fired once when movement ends.
  • + *
+ *

+ * @see Leaflet move event + * @see Leaflet movestart event + * @see Leaflet moveend event + * + * @author Matt Akbarian (@makbn) + */ +public record MoveEvent(JLAction action, JLLatLng center, + JLBounds bounds, int zoomLevel) implements Event { +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ResizeEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ResizeEvent.java new file mode 100644 index 0000000..c7511ae --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ResizeEvent.java @@ -0,0 +1,28 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; + +/** + * Represents a resize event for the map view. + *

+ * This event is fired when the map container is resized. It contains information about the new and old dimensions + * of the map, as well as the current zoom level. This is similar to Leaflet's ResizeEvent. + *

+ *
    + *
  • action: The JLAction that triggered the event.
  • + *
  • newWidth: The new width of the map container in pixels.
  • + *
  • newHeight: The new height of the map container in pixels.
  • + *
  • oldWidth: The previous width of the map container in pixels.
  • + *
  • oldHeight: The previous height of the map container in pixels.
  • + *
  • zoom: The current zoom level of the map.
  • + *
+ *

+ * This event can be used to handle UI adjustments or custom logic when the map size changes. + *

+ * + * @author Matt Akbarian (@makbn) + * @see Leaflet ResizeEvent Documentation + */ +public record ResizeEvent(JLAction action, int newWidth, int newHeight, int oldWidth, int oldHeight, int zoom) + implements Event { +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ZoomEvent.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ZoomEvent.java new file mode 100644 index 0000000..ad82c2c --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/listener/event/ZoomEvent.java @@ -0,0 +1,27 @@ +package io.github.makbn.jlmap.listener.event; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLBounds; + +/** + * Represents a zoom event for the map view, analogous to Leaflet's + * zoom event. + *

+ * This event is fired when the map zoom level changes, either by user interaction or programmatically. + * It contains information about the triggering action, the new zoom level, and the map bounds after zooming. + *

+ *
    + *
  • action: The JLAction that triggered the zoom.
  • + *
  • zoomLevel: The new zoom level of the map after the event.
  • + *
  • bounds: The map bounds after the zoom event.
  • + *
+ *

+ * This event can be used to handle UI updates, analytics, or custom logic when the map zoom level changes. + *

+ * @see Leaflet zoom event + * + * @author Matt Akbarian (@makbn) + * */ +public record ZoomEvent(JLAction action, int zoomLevel, JLBounds bounds) implements Event { + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapProvider.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapProvider.java new file mode 100644 index 0000000..ba47b3f --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapProvider.java @@ -0,0 +1,194 @@ +package io.github.makbn.jlmap.map; + +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.model.JLMapOption; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Singular; +import lombok.experimental.FieldDefaults; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Modern map tile provider configuration system for Java Leaflet. + *

+ * This class replaces the legacy {@code JLProperties.MapType} enum system with a more flexible + * and extensible approach to configuring map tile sources. It supports custom parameters, + * attribution text, and various built-in providers. + *

+ *

+ * The provider system supports both parameter-free providers (like OpenStreetMap) and + * providers requiring API keys or custom configuration (like MapTiler). + *

+ *

Usage Examples:

+ *
{@code
+ * // Built-in provider without parameters
+ * JLMapProvider osmProvider = JLMapProvider.OSM_MAPNIK.build();
+ *
+ * // Provider with API key
+ * JLMapProvider mapTilerProvider = JLMapProvider.MAP_TILER
+ *     .parameter(new JLMapOption.Parameter("key", "your-api-key"))
+ *     .build();
+ *
+ * // Custom provider
+ * JLMapProvider customProvider = JLMapProvider.builder()
+ *     .name("Custom Provider")
+ *     .url("https://custom.tiles.example.com/{z}/{x}/{y}.png")
+ *     .attribution("© Custom Maps")
+ *     .maxZoom(18)
+ *     .build();
+ * }
+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@Builder(toBuilder = true) +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLMapProvider implements JLMapProviderInt { + /** + * Built-in provider for OpenStreetMap standard tiles - no API key required + */ + public static final JLMapProvider.JLMapProviderBuilder OSM_MAPNIK = new JLMapProvider("OpenStreetMap.Mapnik", + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "© OpenStreetMap contributors", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>()).toBuilder(); + /** + * Built-in provider for MapTiler - requires API key parameter + */ + public static final JLMapProvider.JLMapProviderBuilder MAP_TILER = new JLMapProvider("MapTiler", + "https://api.maptiler.com/maps/aquarelle/256/{z}/{x}/{y}.png", + "© " + + "MapTiler © OpenStreetMap contributors", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>(), + Set.of("key")).toBuilder(); + /** + * Human-readable name of the map provider (e.g., "OpenStreetMap.Mapnik") + */ + String name; + public static final JLMapProvider.JLMapProviderBuilder WATER_COLOR = new JLMapProvider("Stamen.WaterColor", + "https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg", + "© OpenStreetMap contributors", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>()).toBuilder(); + /** + * Tile server URL template with {z}, {x}, {y} placeholders for zoom, x, and y coordinates + */ + String url; + /** + * Attribution text to display on the map (typically copyright and data source information) + */ + String attribution; + + public JLMapProvider(String name, String url, String attribution, int maxZoom, Set parameters, Set requiredParameter) { + this.name = name; + this.url = url; + this.attribution = attribution; + this.maxZoom = maxZoom; + this.parameters = parameters; + this.requiredParameter = requiredParameter; + } + + public JLMapProvider(String name, String url, String attribution, int maxZoom, Set parameters) { + this(name, url, attribution, maxZoom, parameters, Collections.emptySet()); + } + /** + * Maximum zoom level supported by this tile provider + */ + int maxZoom; + + public static final JLMapProvider.JLMapProviderBuilder OSM_GERMAN = new JLMapProvider("OpenStreetMap.German", + "https://tile.openstreetmap.de/{z}/{x}/{y}.png", + "© OpenStreetMap contributors", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>()).toBuilder(); + /** + * Set of optional parameters (e.g., API keys) required by the tile provider + */ + @Singular + Set parameters; + + public static final JLMapProvider.JLMapProviderBuilder OSM_FRENCH = new JLMapProvider("OpenStreetMap.French", + "https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png", + "© OpenStreetMap contributors", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>()).toBuilder(); + + public static final JLMapProvider.JLMapProviderBuilder OSM_HOT = new JLMapProvider("OpenStreetMap.HOT", + "https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", + "© OpenStreetMap contributors, " + + "Tiles style by Humanitarian " + + "OpenStreetMap Team hosted by " + + "OpenStreetMap France", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>()).toBuilder(); + + public static final JLMapProvider.JLMapProviderBuilder OSM_CYCLE = new JLMapProvider("OpenStreetMap.CyclOSM", + "https://{s}.tile.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png", + "© OpenStreetMap contributors, " + + "Tiles style by Humanitarian " + + "OpenStreetMap Team hosted by " + + "OpenStreetMap France", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>()).toBuilder(); + + public static final JLMapProvider.JLMapProviderBuilder OPEN_TOPO = new JLMapProvider("OpenTopoMap", + "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", + "Map data: © OpenStreetMap " + + "contributors, SRTM | Map style: © " + + "OpenTopoMap " + + "(CC-BY-SA)", + JLProperties.DEFAULT_MAX_ZOOM, + new HashSet<>()).toBuilder(); + /** + * Set of required parameter names that must be provided for this provider to function + */ + Set requiredParameter; + + /** + * Returns the default map provider (OpenStreetMap Mapnik). + *

+ * This is a convenience method that provides a ready-to-use map provider + * that works without any additional configuration or API keys. + *

+ * + * @return a configured {@link JLMapProvider} instance using OpenStreetMap tiles + */ + public static JLMapProvider getDefault() { + return OSM_MAPNIK.build(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getUrl() { + return url; + } + + @Override + public String getAttribution() { + return attribution; + } + + @Override + public int getMaxZoom() { + return maxZoom; + } + + @Override + public Set getRequiredParametersName() { + return requiredParameter; + } + + @Override + public Set getParameters() { + return parameters; + } +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapProviderInt.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapProviderInt.java new file mode 100644 index 0000000..251e1b2 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapProviderInt.java @@ -0,0 +1,53 @@ +package io.github.makbn.jlmap.map; + +import io.github.makbn.jlmap.exception.JLException; +import io.github.makbn.jlmap.model.JLMapOption; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +public interface JLMapProviderInt { + String getName(); + + String getUrl(); + + String getAttribution(); + + int getMaxZoom(); + + Set getParameters(); + + Set getRequiredParametersName(); + + default String getMapProviderAddress() { + StringBuilder fullUrl = new StringBuilder(getUrl()); + + Set availableParameters = Optional.ofNullable(getParameters()) + .stream() + .flatMap(Set::stream) + .map(JLMapOption.Parameter::key) + .collect(Collectors.toSet()); + + if (!Optional.ofNullable(getRequiredParametersName()) + .stream() + .flatMap(Set::stream) + .allMatch(availableParameters::contains)) { + throw new JLException("Missing required parameters for map provider: " + getRequiredParametersName()); + } + + if (getParameters() != null && !getParameters().isEmpty()) { + fullUrl.append("?"); + for (JLMapOption.Parameter entry : getParameters()) { + String encodedKey = URLEncoder.encode(entry.key(), StandardCharsets.UTF_8); + String encodedValue = URLEncoder.encode(entry.value(), StandardCharsets.UTF_8); + fullUrl.append(encodedKey).append("=").append(encodedValue).append("&"); + } + fullUrl.setLength(fullUrl.length() - 1); + } + + return fullUrl.toString(); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapRenderer.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapRenderer.java new file mode 100644 index 0000000..b28c63d --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/map/JLMapRenderer.java @@ -0,0 +1,10 @@ +package io.github.makbn.jlmap.map; + +import io.github.makbn.jlmap.model.JLMapOption; +import lombok.NonNull; + +public interface JLMapRenderer { + + @NonNull + String render(@NonNull JLMapOption option); +} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLBounds.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLBounds.java similarity index 95% rename from src/main/java/io/github/makbn/jlmap/model/JLBounds.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLBounds.java index 6fa95d0..4d25227 100644 --- a/src/main/java/io/github/makbn/jlmap/model/JLBounds.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLBounds.java @@ -1,5 +1,6 @@ package io.github.makbn.jlmap.model; +import com.google.gson.annotations.SerializedName; import lombok.Builder; import lombok.Getter; import lombok.Setter; @@ -9,7 +10,7 @@ /** * Represents a rectangular geographical area on a map. * - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ @Getter @Setter @@ -18,10 +19,12 @@ public class JLBounds { /** * the north-east point of the bounds. */ + @SerializedName(value = "_northEast", alternate = "northEast") private JLLatLng northEast; /** * the south-west point of the bounds. */ + @SerializedName(value = "_southWest", alternate = "southWest") private JLLatLng southWest; /** diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLCircle.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLCircle.java new file mode 100644 index 0000000..3e1a00f --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLCircle.java @@ -0,0 +1,189 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.util.concurrent.CompletableFuture; + +/** + * Represents a circular vector overlay on the map with geographic radius. + *

+ * A circle is defined by a center point ({@link JLLatLng}) and a radius in meters. + * Unlike {@link JLCircleMarker}, which uses pixel-based radius, JLCircle maintains + * its geographic size regardless of zoom level. + *

+ *

+ * Circles can be styled, made interactive, and dynamically modified after creation. + * They support all standard vector layer operations including click events, styling + * changes, and geometric transformations. + *

+ *

Usage Examples:

+ *
{@code
+ * // Create a basic circle
+ * JLCircle circle = map.getVectorLayer().addCircle(
+ *     new JLLatLng(40.7831, -73.9712), // New York City
+ *     1000, // 1km radius
+ *     JLOptions.builder().fillColor(JLColor.BLUE).fillOpacity(0.3).build()
+ * );
+ *
+ * // Make it interactive
+ * circle.setOnActionListener((circle, event) -> {
+ *     if (event.action() == JLAction.CLICK) {
+ *         circle.setRadius(circle.getRadius() * 1.5); // Expand on click
+ *     }
+ * });
+ *
+ * // Update properties dynamically
+ * circle.setLatLng(new JLLatLng(40.7589, -73.9851)); // Move to Times Square
+ * circle.setRadius(500); // Shrink to 500m
+ * circle.setStyle(JLOptions.builder().fillColor(JLColor.RED).build());
+ * }
+ *

+ * Performance Note: Circles with very large radii may impact performance, + * especially when many are displayed simultaneously. + *

+ * + * @author Matt Akbarian (@makbn) + * @see JLCircleMarker for pixel-based circular markers + * @see JLOptions for styling options + * @since 1.0.0 + */ +@Getter +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public final class JLCircle extends JLObjectBase { + + /** + * Geographic radius of the circle in meters. + *

+ * This value represents the actual ground distance from the center to the edge. + * The visual size of the circle will change as the user zooms in/out to maintain + * this geographic accuracy. + *

+ */ + double radius; + + /** + * Center coordinates of the circle on the map. + *

+ * Defines the geographic location (latitude/longitude) where the circle is centered. + * Can be updated dynamically using {@link #setLatLng(JLLatLng)}. + *

+ */ + JLLatLng latLng; + + /** + * Visual styling options for the circle. + *

+ * Controls appearance including fill color, stroke color, opacity, and other + * visual properties. Not all {@link JLOptions} properties apply to circles. + * See Leaflet documentation for circle-specific styling options. + *

+ */ + JLOptions options; + + @Builder + public JLCircle(String id, double radius, JLLatLng latLng, JLOptions options, JLServerToClientTransporter transport) { + super(id, transport); + this.radius = radius; + this.latLng = latLng; + this.options = options; + } + + /** + * @inheritDoc + */ + @Override + public JLCircle self() { + return this; + } + + /** + * Updates the radius of the circle with smooth animation. + *

+ * Changes the geographic radius while maintaining the current center position. + * The circle will visually resize on the map with animation. + *

+ *

Example:

+ *
{@code
+     * circle.setRadius(2000); // Expand to 2km radius
+     * }
+ * + * @param radius the new radius in meters (must be positive) + * @return this circle instance for method chaining + */ + @NonNull + public JLCircle setRadius(double radius) { + transport.execute(JLTransportRequest.voidCall(this, "setRadius", radius)); + this.radius = radius; + return this; + } + + /** + * Converts the circle to GeoJSON format asynchronously. + *

+ * Returns a {@link CompletableFuture} that will complete with the GeoJSON + * string representation of this circle. The GeoJSON will include the circle's + * geometry and any associated properties. + *

+ *

Example:

+ *
{@code
+     * circle.toGeoJSON().thenAccept(geoJson -> {
+     *     System.out.println("Circle GeoJSON: " + geoJson);
+     *     // Process the GeoJSON string
+     * });
+     * }
+ * + * @return a {@link CompletableFuture} that will complete with the GeoJSON string + */ + @NonNull + public CompletableFuture toGeoJSON() { + return transport.execute(JLTransportRequest.returnableCall(this, "toGeoJSON", String.class)); + } + + /** + * Moves the circle to a new geographic location with smooth animation. + *

+ * Changes the center coordinates while maintaining the current radius and styling. + * The circle will smoothly transition to the new position on the map. + *

+ *

Example:

+ *
{@code
+     * // Move circle to London
+     * circle.setLatLng(new JLLatLng(51.5074, -0.1278));
+     * }
+ * + * @param latLng the new center coordinates (must not be null) + * @return this circle instance for method chaining + * @throws NullPointerException if latLng is null + */ + @NonNull + public JLCircle setLatLng(@NonNull JLLatLng latLng) { + transport.execute(JLTransportRequest.voidCall(this, "setLatLng", latLng.toString())); + this.latLng = latLng; + return this; + } + + /** + * Calculates and returns the geographic bounds of the circle asynchronously. + *

+ * Returns a {@link CompletableFuture} that will complete with a {@link JLBounds} + * object representing the smallest rectangle that completely contains the circle. + * This is useful for viewport calculations and spatial operations. + *

+ *

Example:

+ *
{@code
+     * circle.getBounds().thenAccept(bounds -> {
+     *     map.getControlLayer().fitBounds(bounds); // Zoom to fit the circle
+     * });
+     * }
+ * + * @return a {@link CompletableFuture} that will complete with the circle's bounds + */ + @NonNull + public CompletableFuture getBounds() { + return transport.execute(JLTransportRequest.returnableCall(this, "getBounds", JLBounds.class)); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLCircleMarker.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLCircleMarker.java new file mode 100644 index 0000000..a85dc52 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLCircleMarker.java @@ -0,0 +1,112 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.util.concurrent.CompletableFuture; + +/** + * A circle of a fixed size with radius specified in pixels. + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public final class JLCircleMarker extends JLObjectBase { + /** + * Radius of the circle marker, in pixels + */ + double radius; + /** + * Coordinates of the JLCircleMarker on the map + */ + JLLatLng latLng; + /** + * theming options for JLCircleMarker. all options are not available! + */ + JLOptions options; + + @Builder + public JLCircleMarker(String id, double radius, JLLatLng latLng, JLOptions options, JLServerToClientTransporter transport) { + super(id, transport); + this.radius = radius; + this.latLng = latLng; + this.options = options; + } + + @Override + public JLCircleMarker self() { + return this; + } + + /** + * Converts the circle marker to GeoJSON format asynchronously. + *

+ * Returns a {@link CompletableFuture} that will complete with the GeoJSON + * string representation of this circle. The GeoJSON will include the circle's + * geometry and any associated properties. + *

+ *

Example:

+ *
{@code
+     * circleMarker.toGeoJSON().thenAccept(geoJson -> {
+     *     System.out.println("Circle GeoJSON: " + geoJson);
+     *     // Process the GeoJSON string
+     * });
+     * }
+ * + * @return a {@link CompletableFuture} that will complete with the GeoJSON string + */ + @NonNull + public CompletableFuture toGeoJSON() { + return transport.execute(JLTransportRequest.returnableCall(this, "toGeoJSON", String.class)); + } + + /** + * Moves the circle to a new geographic location. + *

+ * Changes the center coordinates while maintaining the current radius and styling. + * The circle will smoothly transition to the new position on the map. + *

+ *

Example:

+ *
{@code
+     * // Move circle to London
+     * circleMarker.setLatLng(new JLLatLng(51.5074, -0.1278));
+     * }
+ * + * @param latLng the new center coordinates (must not be null) + * @return this circle instance for method chaining + * @throws NullPointerException if latLng is null + */ + @NonNull + public JLCircleMarker setLatLng(@NonNull JLLatLng latLng) { + transport.execute(JLTransportRequest.voidCall(this, "setLatLng", latLng.toString())); + this.latLng = latLng; + return this; + } + + /** + * Sets the radius of the circleMarker. + *

+ * Changes the geographic radius while maintaining the current center position. + * The circle will visually resize on the map with animation. + *

+ *

Example:

+ *
{@code
+     * circleMarker.setRadius(2000);
+     * }
+ * + * @param radius the new radius in pixels (must be positive) + * @return this circle instance for method chaining + */ + @NonNull + public JLCircleMarker setRadius(double radius) { + transport.execute(JLTransportRequest.voidCall(this, "setRadius", radius)); + this.radius = radius; + return this; + } + + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLColor.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLColor.java new file mode 100644 index 0000000..6322a8c --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLColor.java @@ -0,0 +1,66 @@ +package io.github.makbn.jlmap.model; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +/** + * Color abstraction for map styling. + * + * @author Matt Akbarian (@makbn) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLColor { + double redParameter; + double greenParameter; + double blueParameter; + double opacity; + + public JLColor(double red, double green, double blue) { + this(red, green, blue, 1.0); + } + + /** + * Converts the color to a hex string representation. + * + * @return hex color string (e.g., "#FF0000") + */ + public String toHexString() { + int r = (int) (redParameter * 255); + int g = (int) (greenParameter * 255); + int b = (int) (blueParameter * 255); + return String.format("#%02X%02X%02X", r, g, b); + } + + /** + * Creates a color from hex string. + * + * @param hex hex color string (e.g., "#FF0000") + * @return JLColor instance + */ + public static JLColor fromHex(String hex) { + if (hex.startsWith("#")) { + hex = hex.substring(1); + } + int r = Integer.parseInt(hex.substring(0, 2), 16); + int g = Integer.parseInt(hex.substring(2, 4), 16); + int b = Integer.parseInt(hex.substring(4, 6), 16); + return new JLColor(r / 255.0, g / 255.0, b / 255.0, 1.0); + } + + // Predefined colors + public static final JLColor RED = new JLColor(1.0, 0.0, 0.0); + public static final JLColor GREEN = new JLColor(0.0, 1.0, 0.0); + public static final JLColor BLUE = new JLColor(0.0, 0.0, 1.0); + public static final JLColor BLACK = new JLColor(0.0, 0.0, 0.0); + public static final JLColor WHITE = new JLColor(1.0, 1.0, 1.0); + public static final JLColor YELLOW = new JLColor(1.0, 1.0, 0.0); + public static final JLColor ORANGE = new JLColor(1.0, 0.5, 0.0); + public static final JLColor PURPLE = new JLColor(0.5, 0.0, 0.5); + public static final JLColor GRAY = new JLColor(0.5, 0.5, 0.5); +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLGeoJson.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLGeoJson.java new file mode 100644 index 0000000..b8bffc4 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLGeoJson.java @@ -0,0 +1,67 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.FieldDefaults; + +import java.util.List; +import java.util.Map; + +/** + * Represents a GeoJSON layer in a Leaflet map. + * Supports advanced styling, filtering, and interaction options. + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +public final class JLGeoJson extends JLObjectBase { + /** + * GeoJSON content as a string + */ + String geoJsonContent; + /** + * the styling and configuration options for this GeoJSON layer. + */ + JLGeoJsonOptions geoJsonOptions; + + @Builder + public JLGeoJson(String id, String geoJsonContent, JLGeoJsonOptions geoJsonOptions, JLServerToClientTransporter transport) { + super(id, transport); + this.geoJsonContent = geoJsonContent; + this.geoJsonOptions = geoJsonOptions != null ? geoJsonOptions : JLGeoJsonOptions.getDefault(); + } + + @Override + public JLGeoJson self() { + return this; + } + + /** + * Calls the style function for a feature. This is called by the JavaScript callback. + * + * @param featureProperties The feature properties from Leaflet + * @return The styling options + */ + public JLOptions callStyleFunction(List> featureProperties) { + if (geoJsonOptions != null && geoJsonOptions.getStyleFunction() != null) { + return geoJsonOptions.getStyleFunction().apply(featureProperties); + } + return JLOptions.DEFAULT; + } + + /** + * Calls the filter function for a feature. This is called by the JavaScript callback. + * + * @param featureProperties The feature properties from Leaflet + * @return true if the feature should be included + */ + public boolean callFilterFunction(List> featureProperties) { + if (geoJsonOptions != null && geoJsonOptions.getFilter() != null) { + return geoJsonOptions.getFilter().test(featureProperties); + } + return true; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLGeoJsonOptions.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLGeoJsonOptions.java new file mode 100644 index 0000000..6331604 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLGeoJsonOptions.java @@ -0,0 +1,124 @@ +package io.github.makbn.jlmap.model; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * Configuration options for GeoJSON layers with functional styling and filtering capabilities. + *

+ * This class enables sophisticated GeoJSON layer customization through Java lambda functions + * that are automatically proxied to JavaScript. It supports dynamic styling based on feature + * properties and selective feature filtering. + *

+ *

Usage Examples:

+ *
{@code
+ * // Style features based on type property
+ * JLGeoJsonOptions options = JLGeoJsonOptions.builder()
+ *     .styleFunction(features -> {
+ *         String type = (String) features.get(0).get("type");
+ *         return switch (type) {
+ *             case "park" -> JLOptions.builder().fillColor(JLColor.GREEN).build();
+ *             case "water" -> JLOptions.builder().fillColor(JLColor.BLUE).build();
+ *             default -> JLOptions.DEFAULT;
+ *         };
+ *     })
+ *     .filter(features -> {
+ *         // Only show active features
+ *         return Boolean.TRUE.equals(features.get(0).get("active"));
+ *     })
+ *     .build();
+ *
+ * // Apply to GeoJSON layer
+ * JLGeoJson geoJson = map.getGeoJsonLayer().addFromUrl("data.geojson", options);
+ * }
+ *

+ * Function Parameters: Both {@code styleFunction} and {@code filter} receive + * a {@code List>} where {@code features.get(0)} contains the current + * feature's properties as key-value pairs. + *

+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@Getter +@Setter +@Builder(toBuilder = true) +@FieldDefaults(level = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public final class JLGeoJsonOptions { + + /** + * Function to dynamically style GeoJSON features based on their properties. + *

+ * This function is called by Leaflet for each feature during rendering. The call is + * automatically proxied back to this Java function, enabling type-safe styling logic. + *

+ *

+ * Parameters: Receives a {@code List>} where + * {@code features.get(0)} contains the feature's properties map. + *

+ *

+ * Return Value: Must return a {@link JLOptions} object defining + * the visual styling (colors, opacity, stroke, etc.) for the feature. + *

+ *

Example:

+ *
{@code
+     * styleFunction = features -> {
+     *     Map properties = features.get(0);
+     *     String landUse = (String) properties.get("landuse");
+     *     return switch (landUse) {
+     *         case "residential" -> JLOptions.builder().fillColor(JLColor.YELLOW).build();
+     *         case "industrial" -> JLOptions.builder().fillColor(JLColor.GRAY).build();
+     *         case "forest" -> JLOptions.builder().fillColor(JLColor.GREEN).build();
+     *         default -> JLOptions.DEFAULT;
+     *     };
+     * };
+     * }
+ */ + Function>, JLOptions> styleFunction; + /** + * Predicate to conditionally include or exclude GeoJSON features from display. + *

+ * This function is called by Leaflet for each feature to determine visibility. + * Features returning {@code false} will not be rendered on the map. + *

+ *

+ * Parameters: Receives a {@code List>} where + * {@code features.get(0)} contains the feature's properties map. + *

+ *

+ * Return Value: Must return {@code true} to show the feature, + * or {@code false} to hide it. + *

+ *

Example:

+ *
{@code
+     * filter = features -> {
+     *     Map properties = features.get(0);
+     *     Integer population = (Integer) properties.get("population");
+     *     Boolean isActive = (Boolean) properties.get("active");
+     *     // Show only active features with population > 10,000
+     *     return Boolean.TRUE.equals(isActive) && population != null && population > 10000;
+     * };
+     * }
+ */ + Predicate>> filter; + + /** + * Creates default GeoJSON options with no styling or filtering applied. + *

+ * Features will be rendered with Leaflet's default styling and all features + * will be visible. This is equivalent to {@code JLGeoJsonOptions.builder().build()}. + *

+ * + * @return a default {@link JLGeoJsonOptions} instance + */ + public static JLGeoJsonOptions getDefault() { + return JLGeoJsonOptions.builder().build(); + } +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLIcon.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLIcon.java new file mode 100644 index 0000000..498e17b --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLIcon.java @@ -0,0 +1,38 @@ +package io.github.makbn.jlmap.model; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.FieldDefaults; + +/** + * @author Matt Akbarian (@makbn) + */ +@Getter +@Builder +@AllArgsConstructor +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +public class JLIcon { + String iconUrl; + String shadowUrl; + JLPoint iconSize; + JLPoint iconAnchor; + JLPoint popupAnchor; + JLPoint shadowSize; + JLPoint shadowAnchor; + + + @Override + public String toString() { + return "L.icon({" + + "iconUrl: '" + getIconUrl() + '\'' + + (getShadowUrl() != null ? ", shadowUrl: '" + getShadowUrl() + '\'' : "") + + ", iconSize: " + getIconSize() + + ", iconAnchor: " + getIconAnchor() + + ", popupAnchor: " + getPopupAnchor() + + ", shadowSize: " + getShadowSize() + + ", shadowAnchor: " + getShadowAnchor() + + "})"; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLImageOverlay.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLImageOverlay.java new file mode 100644 index 0000000..da45ca4 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLImageOverlay.java @@ -0,0 +1,62 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import lombok.*; +import lombok.experimental.FieldDefaults; + +/** + * An image overlay object given the URL of the image and the geographical bounds it is tied to. + * * @author Matt Akbarian (@makbn)` + */ +@Getter +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public final class JLImageOverlay extends JLObjectBase { + + /** + * URL of the image to be used as an overlay. (can be local or remote URL) + */ + String imageUrl; + /** + * Coordinates of the JLMarker on the map + */ + JLBounds bounds; + /** + * theming options for JLImageOverlay. all options are not available! + */ + JLOptions options; + + @Builder(toBuilder = true) + private JLImageOverlay(String jLId, String imageUrl, JLBounds bounds, JLOptions options, JLServerToClientTransporter transport) { + super(jLId, transport); + this.imageUrl = imageUrl; + this.bounds = bounds; + this.options = options; + } + + + @Override + public JLImageOverlay self() { + return this; + } + + @NonNull + public JLImageOverlay setBounds(@NonNull JLBounds bounds) { + transport.execute(JLTransportRequest.voidCall(this, "setBounds", bounds.toString())); + this.bounds = bounds; + return this; + } + + @NonNull + public JLImageOverlay setUrl(@NonNull String imageUrl) { + transport.execute(JLTransportRequest.voidCall(this, "setUrl", "'%s'" .formatted(imageUrl))); + this.imageUrl = imageUrl; + return this; + } + + @NonNull + public JLLatLng getCenter() { + return bounds.getCenter(); + } +} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLLatLng.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLLatLng.java similarity index 81% rename from src/main/java/io/github/makbn/jlmap/model/JLLatLng.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLLatLng.java index 93cae27..fef16d7 100644 --- a/src/main/java/io/github/makbn/jlmap/model/JLLatLng.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLLatLng.java @@ -1,35 +1,45 @@ package io.github.makbn.jlmap.model; import io.github.makbn.jlmap.JLProperties; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; +import lombok.*; +import lombok.experimental.FieldDefaults; import java.util.Objects; /** * Represents a geographical point with a certain latitude and longitude. - * @author Mehdi Akbarian Rastaghi (@makbn) + * + * @author Matt Akbarian (@makbn) */ -@Getter @Setter -@Builder -@AllArgsConstructor +@Getter +@NoArgsConstructor(force = true) +@FieldDefaults(level = AccessLevel.PRIVATE) public class JLLatLng { - /** geographical given latitude in degrees */ - private final double lat; - /** geographical given longitude in degrees */ - private final double lng; + /** + * geographical given latitude in degrees + */ + double lat; + /** + * geographical given longitude in degrees + */ + double lng; + + @Builder + public JLLatLng(double lat, double lng) { + this.lat = lat; + this.lng = lng; + } /** * Calculate distance between two points in latitude and longitude taking * into account height difference.Uses Haversine method as its base. + * * @param dest Destination coordinate {{@link JLLatLng}} * @return Distance in Meters * @author David George */ - public double distanceTo(JLLatLng dest){ + public double distanceTo(JLLatLng dest) { double latDistance = Math.toRadians(dest.getLat() - lat); double lonDistance = Math.toRadians(dest.getLng() - lng); double a = Math.sin(latDistance / 2) @@ -61,7 +71,7 @@ public boolean equals(Object o) { /** * - * @param o The given point + * @param o The given point * @param maxMargin The margin of error * @return Returns true if the given {{@link JLLatLng}} point is at the * same position (within a small margin of error). diff --git a/src/main/java/io/github/makbn/jlmap/model/JLMapOption.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMapOption.java similarity index 63% rename from src/main/java/io/github/makbn/jlmap/model/JLMapOption.java rename to jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMapOption.java index db4a1ba..352722d 100644 --- a/src/main/java/io/github/makbn/jlmap/model/JLMapOption.java +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMapOption.java @@ -1,14 +1,14 @@ package io.github.makbn.jlmap.model; import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.map.JLMapProvider; import lombok.Builder; import lombok.NonNull; import lombok.Value; +import org.jetbrains.annotations.NotNull; import java.util.HashSet; import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; /** * The {@code JLMapOption} class represents options for configuring a Leaflet @@ -17,7 +17,7 @@ * This class allows you to specify various map configuration parameters, * such as the starting coordinates, map type, and additional parameters. * - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ @Builder @Value @@ -31,8 +31,8 @@ public class JLMapOption { @Builder.Default @NonNull JLLatLng startCoordinate = JLLatLng.builder() - .lat(0.00) - .lng(0.00) + .lat(JLProperties.DEFAULT_INITIAL_LATITUDE) + .lng(JLProperties.DEFAULT_INITIAL_LONGITUDE) .build(); /** * The map type for configuring the map's appearance and behavior. @@ -40,22 +40,7 @@ public class JLMapOption { */ @Builder.Default @NonNull - JLProperties.MapType mapType = JLProperties.MapType.getDefault(); - - /** - * Converts the map options to a query string format, including both - * map-specific parameters and additional parameters. - * - * @return The map options as a query string. - */ - @NonNull - public String toQueryString() { - return Stream.concat( - getParameters().stream(), additionalParameter.stream()) - .map(Parameter::toString) - .collect(Collectors.joining("&", - String.format("?mapid=%s&", getMapType().name()), "")); - } + JLMapProvider jlMapProvider = JLMapProvider.getDefault(); /** * Additional parameters to include in the map configuration. @@ -69,7 +54,22 @@ public String toQueryString() { * @return A set of map-specific parameters. */ public Set getParameters() { - return mapType.parameters(); + return getJlMapProvider().getParameters(); + } + + public boolean zoomControlEnabled() { + return getAdditionalParameter().stream() + .anyMatch(param -> param.key().equals("zoomControl") && + param.value().equals("true")); + } + + public int getInitialZoom() { + return getAdditionalParameter().stream() + .filter(param -> param.key().equals("initialZoom")) + .map(Parameter::value) + .mapToInt(Integer::valueOf) + .findFirst() + .orElse(JLProperties.DEFAULT_INITIAL_ZOOM); } /** @@ -78,7 +78,7 @@ public Set getParameters() { */ public record Parameter(String key, String value) { @Override - public String toString() { + public @NotNull String toString() { return String.format("%s=%s", key, value); } } diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMarker.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMarker.java new file mode 100644 index 0000000..729da45 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMarker.java @@ -0,0 +1,71 @@ +package io.github.makbn.jlmap.model; + + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.experimental.FieldDefaults; +import lombok.experimental.NonFinal; + +/** + * JLMarker is used to display clickable/draggable icons on the map! + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@EqualsAndHashCode(callSuper = true) +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public final class JLMarker extends JLObjectBase { + /** + * optional text for showing on created JLMarker tooltip. + */ + String text; + /** + * Coordinates of the JLMarker on the map + */ + @NonFinal + JLLatLng latLng; + + @NonFinal + JLIcon icon; + + @Builder + public JLMarker(String id, String text, JLLatLng latLng, JLServerToClientTransporter transport) { + super(id, transport); + this.text = text; + this.latLng = latLng; + } + + @Override + public JLMarker self() { + return this; + } + + /** + * Changes the marker position to the given point. + * + * @param latLng new position of the marker. + * @return the current instance of JLMarker. + */ + public JLMarker setLatLng(JLLatLng latLng) { + getTransport().execute(JLTransportRequest.voidCall(this, "setLatLng", latLng.toString())); + this.latLng = latLng; + return this; + } + + /** + * Changes the marker icon. + * + * @param icon new icon of the marker. + * Read more here! + * @return the current instance of JLMarker. + */ + public JLMarker setIcon(JLIcon icon) { + getTransport().execute(JLTransportRequest.voidCall(this, "setIcon", icon)); + this.icon = icon; + return this; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMultiPolyline.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMultiPolyline.java new file mode 100644 index 0000000..6a27d95 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLMultiPolyline.java @@ -0,0 +1,40 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import lombok.experimental.FieldDefaults; + +/** + * A class for drawing polyline overlays on a map + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public final class JLMultiPolyline extends JLObjectBase { + /** + * theming options for JLMultiPolyline. all options are not available! + */ + JLOptions options; + /** + * The array of {@link io.github.makbn.jlmap.model.JLLatLng} points + * of JLMultiPolyline + */ + JLLatLng[][] vertices; + + @Builder + public JLMultiPolyline(String id, JLOptions options, JLLatLng[][] vertices, JLServerToClientTransporter transport) { + super(id, transport); + this.options = options; + this.vertices = vertices; + } + + @Override + public JLMultiPolyline self() { + return this; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLObject.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLObject.java new file mode 100644 index 0000000..a61093b --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLObject.java @@ -0,0 +1,44 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.listener.OnJLActionListener; + +/** + * Represents basic object classes for interacting with Leaflet + * + * @author Matt Akbarian (@makbn) + */ + +public interface JLObject> { + + + OnJLActionListener getOnActionListener(); + + void setOnActionListener(OnJLActionListener listener); + + String getJLId(); + + T self(); + + JLServerToClientTransporter getTransport(); + + /** + * By default, marker images zIndex is set automatically based on its latitude. Use this option if you want + * to put the marker on top of all others (or below), specifying a high value like 1000 (or high + * negative value, respectively). + * Read more here! + * + * @param offset new zIndex offset of the marker. + * @return the current instance of JLMarker. + */ + T setZIndexOffset(int offset); + + /** + * Changes the marker opacity. + * Read more here! + * + * @param opacity value between 0.0 and 1.0. + * @return the current instance of JLMarker. + */ + T setJLObjectOpacity(double opacity); +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLObjectBase.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLObjectBase.java new file mode 100644 index 0000000..44f21c3 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLObjectBase.java @@ -0,0 +1,203 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.element.menu.JLContextMenu; +import io.github.makbn.jlmap.element.menu.JLHasContextMenu; +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import lombok.*; +import lombok.experimental.FieldDefaults; +import lombok.experimental.NonFinal; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.CompletableFuture; + + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@FieldDefaults(level = AccessLevel.PROTECTED, makeFinal = true) +public abstract class JLObjectBase> implements JLObject, JLHasContextMenu { + + /** + * id of object! this is an internal id for JLMap Application and not + * related to Leaflet! + */ + @Getter + String jLId; + + @Getter + JLServerToClientTransporter transport; + + @NonFinal + OnJLActionListener listener; + + @Getter + @Setter + @NonFinal + JLPopup popup; + + /** + * By default, marker images zIndex is set automatically based on its latitude. + * Use this option if you want to put the marker on top of all others (or below), + * specifying a high value like 1000 (or high negative value, respectively). + */ + @NonFinal + int zIndexOffset = 0; + /** + * The opacity of the marker. + */ + @NonFinal + double opacity = 1.0; + + /** + * Context menu for this object (lazily initialized). + */ + @NonFinal + JLContextMenu contextMenu; + + /** + * Whether the context menu is enabled for this object. + */ + @NonFinal + boolean contextMenuEnabled = true; + + @Override + public OnJLActionListener getOnActionListener() { + return listener; + } + + @Override + public void setOnActionListener(OnJLActionListener listener) { + this.listener = listener; + } + + /** + * {@inheritDoc} + */ + @Override + public T setZIndexOffset(int offset) { + getTransport().execute(JLTransportRequest.voidCall(this, "setZIndexOffset", offset)); + this.zIndexOffset = offset; + return self(); + } + + /** + * {@inheritDoc} + */ + @Override + public T setJLObjectOpacity(double opacity) { + getTransport().execute(JLTransportRequest.voidCall(this, "setOpacity", opacity)); + this.opacity = opacity; + return self(); + } + + /** + * Removes the layer from the map it is currently active on. + * + * @return removed object + * @see Leaflet docs + */ + public T remove() { + getTransport().execute(JLTransportRequest.voidCall(self(), "remove")); + return self(); + } + + /** + * Redraws the layer. Sometimes useful after you changed the coordinates that the path uses. + * + * @return the object itself for method chaining + * @see Leaflet docs + */ + public T redraw() { + getTransport().execute(JLTransportRequest.voidCall(this, "redraw")); + return self(); + } + + /** + * Changes the appearance of a Path based on the options in the given object. + * + * @param style new style options for the path + * @return the object itself for method chaining + * @see Leaflet docs + */ + public T setStyle(@NonNull JLOptions style) { + getTransport().execute(JLTransportRequest.voidCall(this, "setStyle", style.toString())); + return self(); + } + + /** + * Brings the layer to the top of all path layers. + * + * @return the object itself for method chaining + * @see Leaflet docs + */ + public T bringToFront() { + getTransport().execute(JLTransportRequest.voidCall(this, "bringToFront")); + return self(); + } + + /** + * Brings the layer to the bottom of all path layers. + * + * @return the object itself for method chaining + * @see Leaflet docs + */ + public T bringToBack() { + getTransport().execute(JLTransportRequest.voidCall(this, "bringToBack")); + return self(); + } + + /** + * Returns the attribution text of the layer. + * + * @return the attribution text of the layer + * @see Leaflet docs + */ + public CompletableFuture getAttribution() { + return getTransport().execute(JLTransportRequest.returnableCall(this, "getAttribution", String.class)); + } + + /** + * {@inheritDoc} + */ + @Override + @Nullable + public synchronized JLContextMenu getContextMenu() { + return contextMenu; + } + + @Override + public synchronized void setContextMenu(@NonNull JLContextMenu contextMenu) { + this.contextMenu = contextMenu; + } + + @NonNull + @Override + public synchronized JLContextMenu addContextMenu() { + this.contextMenu = new JLContextMenu<>(self()); + return getContextMenu(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean hasContextMenu() { + return contextMenu != null && contextMenu.hasVisibleItems(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isContextMenuEnabled() { + return contextMenuEnabled; + } + + /** + * {@inheritDoc} + */ + @Override + public void setContextMenuEnabled(boolean enabled) { + this.contextMenuEnabled = enabled; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLOptions.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLOptions.java new file mode 100644 index 0000000..664d371 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLOptions.java @@ -0,0 +1,109 @@ +package io.github.makbn.jlmap.model; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +/** + * Optional value for theming objects inside the map! + * Note that all options are not available for all objects! + * Read more at Leaflet Official Docs. + * @author Matt Akbarian (@makbn) + */ +@Getter +@Setter +@AllArgsConstructor +@Builder(toBuilder = true) +@FieldDefaults(level = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class JLOptions { + + /** Default value for theming options. */ + public static final JLOptions DEFAULT = JLOptions.builder().build(); + + /** Stroke color. Default is {{@link JLColor#BLUE}} */ + @Builder.Default + JLColor color = JLColor.BLUE; + + /** Fill color. Default is {{@link JLColor#BLUE}} */ + @Builder.Default + JLColor fillColor = JLColor.BLUE; + + /** Stroke width in pixels. Default is 3 */ + @Builder.Default + int weight = 3; + + /** + * Whether to draw stroke along the path. Set it to false for disabling + * borders on polygons or circles. + */ + @Builder.Default + boolean stroke = true; + + /** Whether to fill the path with color. Set it to false fo disabling + * filling on polygons or circles. + */ + @Builder.Default + boolean fill = true; + + /** + * Stroke or image opacity + */ + @Builder.Default + double opacity = 1.0; + + /** Fill opacity. */ + @Builder.Default + double fillOpacity = 0.2; + + /** How much to simplify the polyline on each zoom level. + * greater value means better performance and smoother + * look, and smaller value means more accurate representation. + */ + @Builder.Default + double smoothFactor = 1.0; + + /** Controls the presence of a close button in the popup. + */ + @Builder.Default + boolean closeButton = true; + + /** Set it to false if you want to override the default behavior + * of the popup closing when another popup is opened. + */ + @Builder.Default + boolean autoClose = true; + + /** Whether the marker is draggable with mouse/touch or not. + */ + @Builder.Default + boolean draggable = false; + + JLObject parent; + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + if (color != null) { + sb.append("color: '").append(color.toHexString()).append("', "); + } + if (fillColor != null) { + sb.append("fillColor: '").append(fillColor.toHexString()).append("', "); + } + + sb.append("weight: ").append(weight).append(", "); + sb.append("stroke: ").append(stroke).append(", "); + sb.append("fill: ").append(fill).append(", "); + sb.append("opacity: ").append(opacity).append(", "); + sb.append("fillOpacity: ").append(fillOpacity).append(", "); + sb.append("smoothFactor: ").append(smoothFactor).append(", "); + sb.append("closeButton: ").append(closeButton).append(", "); + sb.append("autoClose: ").append(autoClose).append(", "); + sb.append("draggable: ").append(draggable); + + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPoint.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPoint.java new file mode 100644 index 0000000..b48bab6 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPoint.java @@ -0,0 +1,24 @@ +package io.github.makbn.jlmap.model; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +/** + * Represents a point with x and y coordinates in pixels. + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@Setter +@Builder +@AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLPoint { + double x; + double y; + + @Override + public String toString() { + return "[" + getX() + ", " + getY() + "]"; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPolygon.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPolygon.java new file mode 100644 index 0000000..3c101d8 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPolygon.java @@ -0,0 +1,69 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.util.concurrent.CompletableFuture; + +/** + * A class for drawing polygon overlays on the map. + * Note that points you pass when creating a polygon shouldn't + * have an additional last point equal to the first one. + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public final class JLPolygon extends JLObjectBase { + + /** + * theming options for JLMultiPolyline. all options are not available! + */ + JLOptions options; + + /** + * The arrays of lat-lng, with the first array representing the outer + * shape and the other arrays representing holes in the outer shape. + * Additionally, you can pass a multidimensional array to represent + * a MultiPolygon shape. + */ + JLLatLng[][][] vertices; + + @Builder + public JLPolygon(String id, JLOptions options, JLLatLng[][][] vertices, JLServerToClientTransporter transport) { + super(id, transport); + this.options = options; + this.vertices = vertices; + } + + @Override + public JLPolygon self() { + return this; + } + + /** + * Converts the polygon to GeoJSON format asynchronously. + *

+ * Returns a {@link CompletableFuture} that will complete with the GeoJSON + * string representation of this polygon. The GeoJSON will include the polygon's + * geometry and any associated properties. + *

+ *

Example:

+ *
{@code
+     * polygon.toGeoJSON().thenAccept(geoJson -> {
+     *     System.out.println("Polygon GeoJSON: " + geoJson);
+     *     // Process the GeoJSON string
+     * });
+     * }
+ * + * @return a {@link CompletableFuture} that will complete with the GeoJSON string + */ + @NonNull + public CompletableFuture toGeoJSON() { + return transport.execute(JLTransportRequest.returnableCall(this, "toGeoJSON", String.class)); + } + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPolyline.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPolyline.java new file mode 100644 index 0000000..4c5dde3 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPolyline.java @@ -0,0 +1,62 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.util.concurrent.CompletableFuture; + +/** + * A class for drawing polyline overlays on the map. + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public final class JLPolyline extends JLObjectBase { + /** + * theming options for JLPolyline. all options are not available! + */ + JLOptions options; + /** + * The array of {@link io.github.makbn.jlmap.model.JLLatLng} points of + * JLPolyline + */ + JLLatLng[] vertices; + + @Builder + public JLPolyline(String id, JLOptions options, JLLatLng[] vertices, JLServerToClientTransporter transport) { + super(id, transport); + this.options = options; + this.vertices = vertices; + } + + @Override + public JLPolyline self() { + return this; + } + + /** + * Converts the polyline to GeoJSON format asynchronously. + *

+ * Returns a {@link CompletableFuture} that will complete with the GeoJSON + * string representation of this polyline. The GeoJSON will include the polyline's + * geometry and any associated properties. + *

+ *

Example:

+ *
{@code
+     * polyline.toGeoJSON().thenAccept(geoJson -> {
+     *     System.out.println("Polyline GeoJSON: " + geoJson);
+     *     // Process the GeoJSON string
+     * });
+     * }
+ * + * @return a {@link CompletableFuture} that will complete with the GeoJSON string + */ + @NonNull + public CompletableFuture toGeoJSON() { + return transport.execute(JLTransportRequest.returnableCall(this, "toGeoJSON", String.class)); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPopup.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPopup.java new file mode 100644 index 0000000..32598b6 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/JLPopup.java @@ -0,0 +1,46 @@ +package io.github.makbn.jlmap.model; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import lombok.*; +import lombok.experimental.FieldDefaults; +import lombok.experimental.NonFinal; + +/** + * Used to open popups in certain places of the map. + * + * @author Matt Akbarian (@makbn) + */ +@Getter +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public final class JLPopup extends JLObjectBase { + /** + * Content of the popup. + */ + String text; + /** + * Coordinates of the popup on the map. + */ + JLLatLng latLng; + /** + * Theming options for JLPopup. all options are not available! + */ + JLOptions options; + + @Setter + @NonFinal + JLObject parent; + + @Builder + public JLPopup(String id, String text, JLLatLng latLng, JLOptions options, JLServerToClientTransporter transport) { + super(id, transport); + this.text = text; + this.latLng = latLng; + this.options = options; + } + + @Override + public JLPopup self() { + return this; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCallbackBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCallbackBuilder.java new file mode 100644 index 0000000..404997d --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCallbackBuilder.java @@ -0,0 +1,74 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.listener.JLAction; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLCallbackBuilder { + List callbacks = new ArrayList<>(); + String varName; + String elementType; + + public JLCallbackBuilder(String elementType, String varName) { + this.varName = varName; + this.elementType = elementType; + } + + private static @NotNull String getCallbackFunction(JLAction event) { + //language=JavaScript + return switch (event) { + case ADD, REMOVE -> """ + this.%3$s.on('%1$s', e => { + e.sourceTarget.getElement().setAttribute('id', e.target.uuid); + this.jlMapElement.$server.eventHandler('%1$s', '%2$s', e.target.uuid, this.map.getZoom(), + JSON.stringify((typeof e.target.getLatLng === "function") ? + e.target.getLatLng() : + (typeof e.target.getLatLngs === "function") ? + e.target.getLatLngs() : + {"lat": 0, "lng": 0}), + JSON.stringify(this.map.getBounds())); + + }); + """; + case RESIZE -> """ + this.map.on('%1$s', e => this.jlMapElement.$server.eventHandler('%1$s', '%2$s', this.%3$s.uuid, this.map.getZoom(), + JSON.stringify({"oldWidth": e.oldSize.x, "oldHeight": e.oldSize.y, "newWidth": e.newSize.x, "newHeight": e.newSize.y}), + JSON.stringify(this.map.getBounds()) + )); + """; + case CONTEXT_MENU -> """ + this.%3$s.on('%1$s', e => { + this.jlMapElement.$server.eventHandler('%1$s', '%2$s', e.target.uuid, this.map.getZoom(), + JSON.stringify({"x": e.containerPoint.x, "y": e.containerPoint.y, "lat": e.latlng.lat, "lng": e.latlng.lng}), + JSON.stringify(this.map.getBounds())); + L.DomEvent.stopPropagation(e); + }); + """; + default -> """ + this.%3$s.on('%1$s', e => this.jlMapElement.$server.eventHandler('%1$s', '%2$s', e.target.uuid, this.map.getZoom(), + JSON.stringify((typeof e.target.getLatLng === "function") ? + { "lat": e.target.getLatLng().lat, "lng": e.target.getLatLng().lng } : + {"lat": e.latlng.lat, "lng": e.latlng.lng}), + JSON.stringify(this.map.getBounds()) + )); + """; + }; + } + + public JLCallbackBuilder on(JLAction event) { + callbacks.add(String.format(getCallbackFunction(event), event.getJsEventName(), elementType, varName)); + return this; + } + + public List build() { + return callbacks; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCircleBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCircleBuilder.java new file mode 100644 index 0000000..d0ed428 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCircleBuilder.java @@ -0,0 +1,72 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLCircle; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLOptions; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLCircleBuilder extends JLObjectBuilder { + double lat; + double lng; + double radius; + + public JLCircleBuilder setLat(double lat) { + this.lat = lat; + return this; + } + + public JLCircleBuilder setLng(double lng) { + this.lng = lng; + return this; + } + + public JLCircleBuilder setRadius(double r) { + this.radius = r; + return this; + } + + @Override + protected String getElementVarName() { + return uuid; + } + + @Override + protected String getElementType() { + return JLCircle.class.getSimpleName().toLowerCase(); + } + + @Override + public String buildJsElement() { + return String.format(""" + let %1$s = L.circle([%2$f, %3$f], { radius: %4$f, %5$s }); + this.%1$s = %1$s; + %1$s.uuid = '%6$s'; + // callback start + %7$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), lat, lng, radius, + renderOptions(), getElementVarName(), + renderCallbacks()); + } + + @Override + public JLCircle buildJLObject() { + return JLCircle.builder() + .id(getElementVarName()) + .radius(radius) + .latLng(JLLatLng.builder() + .lat(lat) + .lng(lng) + .build()) + .options(JLOptions.DEFAULT) + .transport(transporter) + .build(); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCircleMarkerBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCircleMarkerBuilder.java new file mode 100644 index 0000000..c64e1f0 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLCircleMarkerBuilder.java @@ -0,0 +1,75 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLCircleMarker; +import io.github.makbn.jlmap.model.JLLatLng; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLCircleMarkerBuilder extends JLObjectBuilder { + double lat; + double lng; + double radius = 10; // default Leaflet radius + + public JLCircleMarkerBuilder setLat(double lat) { + this.lat = lat; + return this; + } + + public JLCircleMarkerBuilder setLng(double lng) { + this.lng = lng; + return this; + } + + public JLCircleMarkerBuilder setRadius(double radius) { + this.radius = radius; + return this; + } + + @Override + protected String getElementVarName() { + return uuid; + } + + @Override + protected String getElementType() { + return JLCircleMarker.class.getSimpleName().toLowerCase(); + } + + @Override + public String buildJsElement() { + return String.format(""" + let %1$s = L.circleMarker([%2$f, %3$f], { "radius": %4$f, %5$s }); + this.%1$s = %1$s; + %1$s.uuid = '%6$s'; + // callback start + %7$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), + lat, lng, + radius, + renderOptions(), + getElementVarName(), + renderCallbacks()); + } + + @Override + public JLCircleMarker buildJLObject() { + return JLCircleMarker.builder() + .id(uuid) + .latLng(JLLatLng.builder() + .lng(lng) + .lat(lat) + .build()) + .options(jlOptions) + .radius(radius) + .transport(transporter) + .build(); + } + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLGeoJsonObjectBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLGeoJsonObjectBuilder.java new file mode 100644 index 0000000..3ad2c41 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLGeoJsonObjectBuilder.java @@ -0,0 +1,114 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.engine.JLClientToServerTransporter; +import io.github.makbn.jlmap.model.JLGeoJson; +import io.github.makbn.jlmap.model.JLGeoJsonOptions; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +import java.util.ArrayList; +import java.util.List; + +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLGeoJsonObjectBuilder extends JLObjectBuilder { + String geoJson; + JLGeoJsonOptions geoJsonOptions; + JLClientToServerTransporter serverToClient; + + @Override + protected String getElementType() { + return JLGeoJson.class.getSimpleName().toLowerCase(); + } + + @Override + protected String getElementVarName() { + return uuid; + } + + public JLGeoJsonObjectBuilder setGeoJson(String geoJson) { + this.geoJson = geoJson; + return this; + } + + public JLGeoJsonObjectBuilder withGeoJsonOptions(JLGeoJsonOptions geoJsonOptions) { + this.geoJsonOptions = geoJsonOptions; + return this; + } + + public JLGeoJsonObjectBuilder withBridge(JLClientToServerTransporter bridge) { + this.serverToClient = bridge; + return this; + } + + @Override + public String buildJsElement() { + return String.format(""" + let %1$s = L.geoJSON(%2$s, { %3$s }); + this.%1$s = %1$s; + %1$s.uuid = '%1$s'; + // callback start + %4$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), geoJson, renderGeoJsonOptions(), renderCallbacks()); + } + + private String renderGeoJsonOptions() { + List optionParts = new ArrayList<>(); + + // Add base style options if no custom style function is provided + if (geoJsonOptions == null || geoJsonOptions.getStyleFunction() == null) { + String baseOptions = renderOptions(); + if (!baseOptions.isEmpty()) { + optionParts.add(baseOptions); + } + } + + if (geoJsonOptions != null) { + // Add style function callback using bridge + if (geoJsonOptions.getStyleFunction() != null) { + //language=js + optionParts.add(""" + onEachFeature: function(feature, layer) { + window.jlObjectBridge.call('%1$s', 'callFilterFunction', JSON.stringify(feature)).then(filterResult => { + if (!filterResult || filterResult === 'false') { + layer.remove(); + } else { + window.jlObjectBridge.call('%1$s', 'callStyleFunction', JSON.stringify(feature.properties)).then(styleResult => { + console.log(styleResult); + layer.setStyle(styleResult ? JSON.parse(styleResult) : {}); + }); + } + }); + } + """.formatted(uuid)); + } + + // Add filter function callback using bridge + if (geoJsonOptions.getFilter() != null) { + optionParts.add("filter: function(feature, layer) { " + + "var result = window.jlObjectBridge.call('" + uuid + "', 'callFilterFunction', JSON.stringify(feature)); " + + "return true; " + + "}"); + } + + } + + return String.join(", ", optionParts); + } + + @Override + public JLGeoJson buildJLObject() { + JLGeoJson geoJsonObject = JLGeoJson.builder() + .id(uuid) + .geoJsonContent(geoJson) + .geoJsonOptions(geoJsonOptions) + .transport(transporter) + .build(); + + serverToClient.registerObject(uuid, geoJsonObject); + + return geoJsonObject; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLImageOverlayBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLImageOverlayBuilder.java new file mode 100644 index 0000000..1c68199 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLImageOverlayBuilder.java @@ -0,0 +1,92 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLImageOverlay; +import io.github.makbn.jlmap.model.JLLatLng; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +import java.util.ArrayList; +import java.util.List; + +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLImageOverlayBuilder extends JLObjectBuilder { + String imageUrl; + List bounds = new ArrayList<>(); + + public JLImageOverlayBuilder setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + return this; + } + + /** + * Set bounds using two double arrays: [lat, lng] for southwest and northeast corners. + * First item is southwest and second is northeast. + */ + public JLImageOverlayBuilder setBounds(List bounds) { + if (bounds == null || bounds.size() != 2) + throw new IllegalArgumentException("Bounds must have exactly two coordinates (SW, NE)"); + this.bounds = bounds; + return this; + } + + @Override + protected String getElementVarName() { + return uuid; + } + + @Override + protected String getElementType() { + return JLImageOverlay.class.getSimpleName().toLowerCase(); + } + + @Override + public String buildJsElement() { + if (imageUrl == null || bounds.size() != 2) return ""; + String boundsJs = String.format("[[%f, %f], [%f, %f]]", bounds.get(0)[0], bounds.get(0)[1], bounds.get(1)[0], bounds.get(1)[1]); + return String.format(""" + let %1$s = L.imageOverlay('%2$s', %3$s, { %4$s }); + this.%1$s = %1$s; + %1$s.uuid = '%5$s'; + // callback start + %6$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), + imageUrl, + boundsJs, + renderOptions(), + getElementVarName(), + renderCallbacks() + ); + } + + @Override + public JLImageOverlay buildJLObject() { + // first element is southWest [lat, lng] + var southWest = JLLatLng.builder() + .lat(bounds.get(0)[0]) + .lng(bounds.get(0)[1]) + .build(); + + // second element is northEast [lat, lng] + var northEast = JLLatLng.builder() + .lat(bounds.get(1)[0]) + .lng(bounds.get(1)[1]) + .build(); + + var jlBounds = JLBounds.builder() + .northEast(northEast) + .southWest(southWest) + .build(); + + return JLImageOverlay.builder() + .jLId(uuid) + .imageUrl(imageUrl) + .bounds(jlBounds) + .options(jlOptions) + .transport(transporter) + .build(); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMapBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMapBuilder.java new file mode 100644 index 0000000..845ba69 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMapBuilder.java @@ -0,0 +1,4 @@ +package io.github.makbn.jlmap.model.builder; + +public class JLMapBuilder { +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMarkerBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMarkerBuilder.java new file mode 100644 index 0000000..51ef710 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMarkerBuilder.java @@ -0,0 +1,70 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMarker; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLMarkerBuilder extends JLObjectBuilder { + double lat; + double lng; + String text; + + public JLMarkerBuilder setLat(double lat) { + this.lat = lat; + return this; + } + + public JLMarkerBuilder setLng(double lng) { + this.lng = lng; + return this; + } + + public JLMarkerBuilder setText(String text) { + this.text = text; + return this; + } + + @Override + protected String getElementVarName() { + return uuid; + } + + @Override + protected String getElementType() { + return JLMarker.class.getSimpleName().toLowerCase(); + } + + @Override + public String buildJsElement() { + return String.format(""" + let %1$s = L.marker([%2$f, %3$f], { %4$s }); + this.%1$s = %1$s; + %1$s.uuid = '%5$s'; + // callback start + %6$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), lat, lng, + renderOptions(), getElementVarName(), + renderCallbacks()); + } + + @Override + public JLMarker buildJLObject() { + return JLMarker.builder() + .id(getElementVarName()) + .latLng(JLLatLng.builder() + .lat(lat) + .lng(lng) + .build()) + .text(text) + .transport(transporter) + .build(); + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMultiPolylineBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMultiPolylineBuilder.java new file mode 100644 index 0000000..285b785 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLMultiPolylineBuilder.java @@ -0,0 +1,92 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMultiPolyline; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLMultiPolylineBuilder extends JLObjectBuilder { + + List> latlngGroups = new ArrayList<>(); + + public JLMultiPolylineBuilder addLine(List latlngs) { + latlngGroups.add(latlngs); + return this; + } + + @Override + protected String getElementVarName() { + return uuid; + } + + @Override + protected String getElementType() { + return JLMultiPolyline.class.getSimpleName().toLowerCase(); + } + + @Override + public String buildJsElement() { + // Convert coordinates to JS format + StringBuilder coords = new StringBuilder("["); + for (int i = 0; i < latlngGroups.size(); i++) { + if (i > 0) coords.append(","); + coords.append("["); + List line = latlngGroups.get(i); + for (int j = 0; j < line.size(); j++) { + if (j > 0) coords.append(","); + double[] pt = line.get(j); + coords.append(String.format("[%f,%f]", pt[0], pt[1])); + } + coords.append("]"); + } + coords.append("]"); + + return String.format(""" + let %1$s = L.polyline(%2$s, { %3$s }); + this.%1$s = %1$s; + %1$s.uuid = '%4$s'; + // callback start + %5$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), + coords, + renderOptions(), + getElementVarName(), + renderCallbacks()); + } + + @Override + public JLMultiPolyline buildJLObject() { + return JLMultiPolyline.builder() + .id(uuid) + .options(jlOptions) + .transport(transporter) + .vertices(toVertices(latlngGroups)) + .build(); + } + + + private JLLatLng[][] toVertices(List> latlngGroups) { + JLLatLng[][] result = new JLLatLng[latlngGroups.size()][]; + for (int i = 0; i < latlngGroups.size(); i++) { + List group = latlngGroups.get(i); + JLLatLng[] vertices = new JLLatLng[group.size()]; + for (int j = 0; j < group.size(); j++) { + double[] coords = group.get(j); + vertices[j] = new JLLatLng(coords[0], coords[1]); + } + result[i] = vertices; + } + return result; + } + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLObjectBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLObjectBuilder.java new file mode 100644 index 0000000..fc6afc0 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLObjectBuilder.java @@ -0,0 +1,86 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.model.JLObject; +import io.github.makbn.jlmap.model.JLOptions; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * @author Matt Akbarian (@makbn) + */ +abstract class JLObjectBuilder, T extends JLObjectBuilder> { + protected String uuid; + protected JLOptions jlOptions; + @Nullable + protected JLServerToClientTransporter transporter; + protected final Map options = new LinkedHashMap<>(); + protected final List callbacks = new ArrayList<>(); + + @SuppressWarnings("unchecked") + protected final T self() { + return (T) this; + } + + public T setUuid(@NonNull String uuid) { + this.uuid = uuid; + return self(); + } + + @Nullable + protected JLServerToClientTransporter getTransporter() { + return transporter; + } + + public T withOptions(@NonNull JLOptions jlOptions) { + this.jlOptions = jlOptions; + options.clear(); + JLOptionsBuilder builder = new JLOptionsBuilder(); + builder.setOption(jlOptions); + options.putAll(builder.build()); + return self(); + } + + public T withCallbacks(Consumer config) { + JLCallbackBuilder cb = new JLCallbackBuilder(getElementType(), getElementVarName()); + config.accept(cb); + callbacks.addAll(cb.build()); + return self(); + } + + public T setTransporter(@Nullable JLServerToClientTransporter transporter) { + this.transporter = transporter; + return self(); + } + + protected abstract String getElementVarName(); + + protected abstract String getElementType(); + + protected String renderOptions() { + return options.entrySet().stream() + .filter(entry -> entry.getValue() != null) + .map(e -> e.getKey() + ": " + getValue(e.getValue())) + .collect(Collectors.joining(", ")); + } + + private String getValue(@NonNull Object value) { + if (value instanceof String stringValue) { + return "\"" + stringValue + "\""; + } else { + return Objects.toString(value); + } + } + + protected String renderCallbacks() { + return String.join("\n", callbacks); + } + + public abstract String buildJsElement(); + + public abstract M buildJLObject(); +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLOptionsBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLOptionsBuilder.java new file mode 100644 index 0000000..4e95429 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLOptionsBuilder.java @@ -0,0 +1,43 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLOptions; +import lombok.NonNull; +import lombok.SneakyThrows; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author Matt Akbarian (@makbn) + */ +public class JLOptionsBuilder { + + private JLOptions jlOptions; + + Map build() { + return Arrays.stream(jlOptions.getClass().getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers()) && method.getParameterCount() == 0) + .filter(method -> method.getReturnType().isPrimitive() || method.getReturnType().isEnum() || method.getReturnType().equals(String.class)) + .filter(method -> method.getName().matches("(^get.*|^is.*)")) + .collect(Collectors.toMap(this::getKey, this::getValue)); + } + + private String getKey(@NotNull Method method) { + String fieldName = method.getName().replace("get", "").replace("is", ""); + return Character.toLowerCase(fieldName.charAt(0)) + (fieldName.length() > 1 ? fieldName.substring(1) : ""); + } + + @SneakyThrows + private Object getValue(Method method) { + return method.invoke(jlOptions); + } + + public JLOptionsBuilder setOption(@NonNull JLOptions options) { + this.jlOptions = options; + return this; + } +} \ No newline at end of file diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPolygonBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPolygonBuilder.java new file mode 100644 index 0000000..fb55826 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPolygonBuilder.java @@ -0,0 +1,91 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLPolygon; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLPolygonBuilder extends JLObjectBuilder { + + List> latlngGroups = new ArrayList<>(); + + public JLPolygonBuilder addLatLngGroup(List group) { + this.latlngGroups.add(group); + return this; + } + + public JLPolygonBuilder addLatLng(double lat, double lng) { + if (latlngGroups.isEmpty()) { + latlngGroups.add(new ArrayList<>()); + } + latlngGroups.get(latlngGroups.size() - 1).add(new double[]{lat, lng}); + return this; + } + + @Override + protected String getElementVarName() { + return uuid; + } + + @Override + protected String getElementType() { + return JLPolygon.class.getSimpleName().toLowerCase(); + } + + @Override + public String buildJsElement() { + String latlngsJs = latlngGroups.stream() + .map(group -> group.stream() + .map(coord -> String.format("[%f, %f]", coord[0], coord[1])) + .collect(Collectors.joining(",", "[", "]")) + ).collect(Collectors.joining(",", "[", "]")); + + return String.format(""" + let %1$s = L.polygon(%2$s, { %3$s }); + this.%1$s = %1$s; + %1$s.uuid = '%4$s'; + // callback start + %5$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), + latlngsJs, + renderOptions(), + getElementVarName(), + renderCallbacks()); + } + + @Override + public JLPolygon buildJLObject() { + return JLPolygon.builder() + .id(uuid) + .options(jlOptions) + .transport(transporter) + .vertices(toVertices(latlngGroups)) + .build(); + } + + private static JLLatLng[][][] toVertices(List> latlngGroups) { + if (latlngGroups == null) { + return new JLLatLng[0][][]; + } + + return latlngGroups.stream() + .map(group -> group.stream() + .map(coord -> new JLLatLng(coord[0], coord[1])) + .toArray(JLLatLng[]::new) + ) + .map(ring -> new JLLatLng[][]{ring}) // wrap each ring as a polygon + .toArray(JLLatLng[][][]::new); + } + +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPolylineBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPolylineBuilder.java new file mode 100644 index 0000000..d01dabd --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPolylineBuilder.java @@ -0,0 +1,87 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLPolyline; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLPolylineBuilder extends JLObjectBuilder { + List latlngs = new ArrayList<>(); + + public JLPolylineBuilder addLatLng(double lat, double lng) { + latlngs.add(new double[]{lat, lng}); + return this; + } + + public JLPolylineBuilder addLatLngs(List points) { + latlngs.addAll(points); + return this; + } + + + @Override + protected String getElementVarName() { + return uuid; + } + + @Override + protected String getElementType() { + return JLPolyline.class.getTypeName().toLowerCase(); + } + + @Override + public String buildJsElement() { + String latlngArray = latlngs.stream() + .map(coord -> "[" + coord[0] + "," + coord[1] + "]") + .collect(Collectors.joining(",", "[", "]")); + + return String.format(""" + let %1$s = L.polyline(%2$s, { %3$s }); + this.%1$s = %1$s; + %1$s.uuid = '%4$s'; + // callback start + %5$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), + latlngArray, + renderOptions(), + getElementVarName(), + renderCallbacks()); + } + + @Override + public JLPolyline buildJLObject() { + return JLPolyline.builder() + .id(uuid) + .options(jlOptions) + .transport(transporter) + .vertices(toVertices(latlngs)) + .build(); + } + + private static JLLatLng[] toVertices(List latlngs) { + if (latlngs == null) { + return new JLLatLng[0]; + } + + JLLatLng[] vertices = new JLLatLng[latlngs.size()]; + for (int i = 0; i < latlngs.size(); i++) { + double[] pair = latlngs.get(i); + if (pair == null || pair.length < 2) { + throw new IllegalArgumentException("Each element must be a double array of length 2 [lat, lng]"); + } + vertices[i] = new JLLatLng(pair[0], pair[1]); + } + return vertices; + } +} diff --git a/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPopupBuilder.java b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPopupBuilder.java new file mode 100644 index 0000000..977ffa1 --- /dev/null +++ b/jlmap-api/src/main/java/io/github/makbn/jlmap/model/builder/JLPopupBuilder.java @@ -0,0 +1,84 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLPopup; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import org.jetbrains.annotations.NotNull; + +/** + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JLPopupBuilder extends JLObjectBuilder { + double lat; + double lng; + String content; + + public JLPopupBuilder setLat(double lat) { + this.lat = lat; + return this; + } + + public JLPopupBuilder setLng(double lng) { + this.lng = lng; + return this; + } + + public JLPopupBuilder setContent(String content) { + this.content = content; + return this; + } + + @Override + protected String getElementVarName() { + return uuid; + } + + + @Override + protected String getElementType() { + return JLPopup.class.getSimpleName().toLowerCase(); + } + + @Override + public String buildJsElement() { + return String.format(""" + let %1$s = L.popup({ %2$s }) + .setLatLng([%3$f, %4$f]) + .setContent(%5$s); + this.%1$s = %1$s; + %1$s.uuid = '%6$s'; + // callback start + %7$s + // callback end + %1$s.addTo(this.map); + """, + getElementVarName(), + renderOptions(), + lat, lng, + sanitizeContent(true), + getElementVarName(), + renderCallbacks()); + } + + private @NotNull String sanitizeContent(boolean wrap) { + var sanitized = content != null ? content.replace("\"", "\\\"") + .replaceAll("]*?>.*?", "") : ""; + return wrap ? "\"" + sanitized + "\"" : sanitized; + } + + @Override + public JLPopup buildJLObject() { + return JLPopup.builder() + .id(uuid) + .text(sanitizeContent(false)) + .latLng(JLLatLng.builder() + .lat(lat) + .lng(lng) + .build()) + .options(jlOptions) + .transport(transporter) + .build(); + } +} diff --git a/jlmap-api/src/main/java/module-info.java b/jlmap-api/src/main/java/module-info.java new file mode 100644 index 0000000..5200b61 --- /dev/null +++ b/jlmap-api/src/main/java/module-info.java @@ -0,0 +1,33 @@ +module io.github.makbn.jlmap.api { + // JDK modules + requires jdk.jsobject; + + // Logging + requires org.slf4j; + + // Annotations + requires static org.jetbrains.annotations; + requires static lombok; + + // JSON processing + requires com.google.gson; + requires com.fasterxml.jackson.databind; + + // Exports for public API + exports io.github.makbn.jlmap; + exports io.github.makbn.jlmap.layer.leaflet; + exports io.github.makbn.jlmap.listener; + exports io.github.makbn.jlmap.listener.event; + exports io.github.makbn.jlmap.model; + exports io.github.makbn.jlmap.map; + exports io.github.makbn.jlmap.model.builder; + exports io.github.makbn.jlmap.exception; + exports io.github.makbn.jlmap.geojson; + exports io.github.makbn.jlmap.engine; + exports io.github.makbn.jlmap.element.menu; + + opens io.github.makbn.jlmap.model to com.google.gson; + opens io.github.makbn.jlmap.element.menu to com.google.gson; + + uses io.github.makbn.jlmap.element.menu.JLContextMenuMediator; +} \ No newline at end of file diff --git a/jlmap-api/src/test/java/io/github/makbn/jlmap/element/menu/JLContextMenuTest.java b/jlmap-api/src/test/java/io/github/makbn/jlmap/element/menu/JLContextMenuTest.java new file mode 100644 index 0000000..fa925e5 --- /dev/null +++ b/jlmap-api/src/test/java/io/github/makbn/jlmap/element/menu/JLContextMenuTest.java @@ -0,0 +1,653 @@ +package io.github.makbn.jlmap.element.menu; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.event.ContextMenuEvent; +import io.github.makbn.jlmap.listener.event.Event; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMarker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for JLContextMenu. + * Tests the context menu functionality for JL objects. + * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +class JLContextMenuTest { + + private JLMarker owner; + private JLContextMenu contextMenu; + + @BeforeEach + void setUp() { + owner = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test Marker") + .transport(null) + .build(); + contextMenu = new JLContextMenu<>(owner); + } + + // === Constructor Tests === + + @Test + void constructor_withValidOwner_shouldInitializeSuccessfully() { + // When + JLContextMenu menu = new JLContextMenu<>(owner); + + // Then + assertThat(menu).isNotNull(); + assertThat(menu.getItemCount()).isZero(); + assertThat(menu.isEnabled()).isTrue(); + } + + @Test + void constructor_withNullOwner_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> new JLContextMenu<>(null)) + .isInstanceOf(NullPointerException.class); + } + + // === Add Item Tests === + + @Test + void addItem_withTextOnly_shouldCreateItemWithGeneratedId() { + // When + contextMenu.addItem("Edit Marker"); + + // Then + assertThat(contextMenu.getItemCount()).isEqualTo(1); + assertThat(contextMenu.getItem("editmarker")).isNotNull(); + assertThat(contextMenu.getItem("editmarker").getText()).isEqualTo("Edit Marker"); + } + + @Test + void addItem_withIdAndText_shouldCreateItemWithSpecifiedId() { + // When + contextMenu.addItem("edit", "Edit Marker"); + + // Then + assertThat(contextMenu.getItemCount()).isEqualTo(1); + assertThat(contextMenu.getItem("edit")).isNotNull(); + assertThat(contextMenu.getItem("edit").getText()).isEqualTo("Edit Marker"); + } + + @Test + void addItem_withIdTextAndIcon_shouldCreateItemWithAllProperties() { + // When + contextMenu.addItem("edit", "Edit Marker", "https://example.com/icon.png"); + + // Then + JLMenuItem item = contextMenu.getItem("edit"); + assertThat(item).isNotNull(); + assertThat(item.getId()).isEqualTo("edit"); + assertThat(item.getText()).isEqualTo("Edit Marker"); + assertThat(item.getIcon()).isEqualTo("https://example.com/icon.png"); + } + + @Test + void addItem_withMenuItem_shouldAddMenuItem() { + // Given + JLMenuItem menuItem = JLMenuItem.of("custom", "Custom Item", "icon.png"); + + // When + contextMenu.addItem(menuItem); + + // Then + assertThat(contextMenu.getItemCount()).isEqualTo(1); + assertThat(contextMenu.getItem("custom")).isEqualTo(menuItem); + } + + @Test + void addItem_withDuplicateId_shouldReplaceExistingItem() { + // Given + contextMenu.addItem("edit", "Edit"); + + // When + contextMenu.addItem("edit", "Edit Updated"); + + // Then + assertThat(contextMenu.getItemCount()).isEqualTo(1); + assertThat(contextMenu.getItem("edit").getText()).isEqualTo("Edit Updated"); + } + + @Test + void addItem_withNullText_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.addItem((String) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addItem_shouldReturnContextMenuForChaining() { + // When + JLContextMenu result = contextMenu.addItem("edit", "Edit"); + + // Then + assertThat(result).isSameAs(contextMenu); + } + + // === Remove Item Tests === + + @Test + void removeItem_withExistingId_shouldRemoveItem() { + // Given + contextMenu.addItem("edit", "Edit"); + contextMenu.addItem("delete", "Delete"); + + // When + contextMenu.removeItem("edit"); + + // Then + assertThat(contextMenu.getItemCount()).isEqualTo(1); + assertThat(contextMenu.getItem("edit")).isNull(); + assertThat(contextMenu.getItem("delete")).isNotNull(); + } + + @Test + void removeItem_withNonExistingId_shouldDoNothing() { + // Given + contextMenu.addItem("edit", "Edit"); + + // When + contextMenu.removeItem("nonexistent"); + + // Then + assertThat(contextMenu.getItemCount()).isEqualTo(1); + } + + @Test + void removeItem_shouldReturnContextMenuForChaining() { + // Given + contextMenu.addItem("edit", "Edit"); + + // When + JLContextMenu result = contextMenu.removeItem("edit"); + + // Then + assertThat(result).isSameAs(contextMenu); + } + + // === Clear Items Tests === + + @Test + void clearItems_withMultipleItems_shouldRemoveAllItems() { + // Given + contextMenu.addItem("edit", "Edit"); + contextMenu.addItem("delete", "Delete"); + contextMenu.addItem("info", "Info"); + + // When + contextMenu.clearItems(); + + // Then + assertThat(contextMenu.getItemCount()).isZero(); + } + + @Test + void clearItems_withNoItems_shouldDoNothing() { + // When + contextMenu.clearItems(); + + // Then + assertThat(contextMenu.getItemCount()).isZero(); + } + + @Test + void clearItems_shouldReturnContextMenuForChaining() { + // Given + contextMenu.addItem("edit", "Edit"); + + // When + JLContextMenu result = contextMenu.clearItems(); + + // Then + assertThat(result).isSameAs(contextMenu); + } + + // === Get Item Tests === + + @Test + void getItem_withExistingId_shouldReturnItem() { + // Given + contextMenu.addItem("edit", "Edit"); + + // When + JLMenuItem item = contextMenu.getItem("edit"); + + // Then + assertThat(item).isNotNull(); + assertThat(item.getId()).isEqualTo("edit"); + } + + @Test + void getItem_withNonExistingId_shouldReturnNull() { + // When + JLMenuItem item = contextMenu.getItem("nonexistent"); + + // Then + assertThat(item).isNull(); + } + + // === Update Item Tests === + + @Test + void updateItem_withExistingId_shouldUpdateItem() { + // Given + contextMenu.addItem("edit", "Edit"); + JLMenuItem updatedItem = JLMenuItem.of("edit", "Edit Updated", "new-icon.png"); + + // When + contextMenu.updateItem(updatedItem); + + // Then + JLMenuItem item = contextMenu.getItem("edit"); + assertThat(item.getText()).isEqualTo("Edit Updated"); + assertThat(item.getIcon()).isEqualTo("new-icon.png"); + } + + @Test + void updateItem_withNonExistingId_shouldAddItem() { + // Given + JLMenuItem newItem = JLMenuItem.of("new", "New Item"); + + // When + contextMenu.updateItem(newItem); + + // Then + assertThat(contextMenu.getItemCount()).isEqualTo(1); + assertThat(contextMenu.getItem("new")).isNotNull(); + } + + @Test + void updateItem_shouldReturnContextMenuForChaining() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // When + JLContextMenu result = contextMenu.updateItem(item); + + // Then + assertThat(result).isSameAs(contextMenu); + } + + // === Get Items Tests === + + @Test + void getItems_shouldReturnAllItems() { + // Given + contextMenu.addItem("edit", "Edit"); + contextMenu.addItem("delete", "Delete"); + contextMenu.addItem("info", "Info"); + + // When + Collection items = contextMenu.getItems(); + + // Then + assertThat(items).hasSize(3); + } + + @Test + void getItems_shouldReturnUnmodifiableCollection() { + // Given + contextMenu.addItem("edit", "Edit"); + + // When + Collection items = contextMenu.getItems(); + + // Then + assertThatThrownBy(() -> items.add(JLMenuItem.of("new", "New"))) + .isInstanceOf(UnsupportedOperationException.class); + } + + // === Get Visible Items Tests === + + @Test + void getVisibleItems_shouldReturnOnlyVisibleItems() { + // Given + JLMenuItem visibleItem = JLMenuItem.builder() + .id("edit") + .text("Edit") + .visible(true) + .build(); + contextMenu.addItem(visibleItem); + + JLMenuItem hiddenItem = JLMenuItem.builder() + .id("delete") + .text("Delete") + .visible(false) + .build(); + contextMenu.addItem(hiddenItem); + + // When + Collection visibleItems = contextMenu.getVisibleItems(); + + // Then + assertThat(visibleItems).hasSize(1); + assertThat(visibleItems).contains(visibleItem); + assertThat(visibleItems).doesNotContain(hiddenItem); + } + + // === Has Visible Items Tests === + + @Test + void hasVisibleItems_withVisibleItems_shouldReturnTrue() { + // Given + contextMenu.addItem("edit", "Edit"); + + // When/Then + assertThat(contextMenu.hasVisibleItems()).isTrue(); + } + + @Test + void hasVisibleItems_withOnlyHiddenItems_shouldReturnFalse() { + // Given + JLMenuItem hiddenItem = JLMenuItem.builder() + .id("edit") + .text("Edit") + .visible(false) + .build(); + contextMenu.addItem(hiddenItem); + + // When/Then + assertThat(contextMenu.hasVisibleItems()).isFalse(); + } + + @Test + void hasVisibleItems_withNoItems_shouldReturnFalse() { + // When/Then + assertThat(contextMenu.hasVisibleItems()).isFalse(); + } + + // === Item Count Tests === + + @Test + void getItemCount_withMultipleItems_shouldReturnCorrectCount() { + // Given + contextMenu.addItem("edit", "Edit"); + contextMenu.addItem("delete", "Delete"); + contextMenu.addItem("info", "Info"); + + // When/Then + assertThat(contextMenu.getItemCount()).isEqualTo(3); + } + + @Test + void getItemCount_withNoItems_shouldReturnZero() { + // When/Then + assertThat(contextMenu.getItemCount()).isZero(); + } + + @Test + void getVisibleItemCount_shouldReturnCountOfVisibleItems() { + // Given + contextMenu.addItem("edit", "Edit"); + JLMenuItem hiddenItem = JLMenuItem.builder() + .id("delete") + .text("Delete") + .visible(false) + .build(); + contextMenu.addItem(hiddenItem); + + // When/Then + assertThat(contextMenu.getVisibleItemCount()).isEqualTo(1); + } + + // === Enabled Tests === + + @Test + void isEnabled_byDefault_shouldReturnTrue() { + // When/Then + assertThat(contextMenu.isEnabled()).isTrue(); + } + + @Test + void setEnabled_withFalse_shouldDisableMenu() { + // When + contextMenu.setEnabled(false); + + // Then + assertThat(contextMenu.isEnabled()).isFalse(); + } + + @Test + void setEnabled_withTrue_shouldEnableMenu() { + // Given + contextMenu.setEnabled(false); + + // When + contextMenu.setEnabled(true); + + // Then + assertThat(contextMenu.isEnabled()).isTrue(); + } + + // === Listener Tests === + + @Test + void setOnMenuItemListener_shouldSetListener() { + // Given + OnJLContextMenuItemListener listener = item -> { + }; + + // When + contextMenu.setOnMenuItemListener(listener); + + // Then + assertThat(contextMenu.getOnMenuItemListener()).isSameAs(listener); + } + + @Test + void setOnMenuItemListener_shouldReturnContextMenuForChaining() { + // Given + OnJLContextMenuItemListener listener = item -> { + }; + + // When + contextMenu.setOnMenuItemListener(listener); + + // Then + assertThat(contextMenu.getOnMenuItemListener()).isSameAs(listener); + } + + @Test + void handleMenuItemSelection_withListener_shouldInvokeListener() { + // Given + final boolean[] listenerCalled = {false}; + contextMenu.setOnMenuItemListener(item -> listenerCalled[0] = true); + JLMenuItem menuItem = JLMenuItem.of("test", "Test"); + + // When + contextMenu.handleMenuItemSelection(menuItem); + + // Then + assertThat(listenerCalled[0]).isTrue(); + } + + @Test + void handleMenuItemSelection_withoutListener_shouldNotThrowException() { + // Given + JLMenuItem menuItem = JLMenuItem.of("test", "Test"); + + // When/Then - Should not throw exception + contextMenu.handleMenuItemSelection(menuItem); + } + + // === Method Chaining Tests === + + @Test + void contextMenu_shouldSupportMethodChaining() { + // When + JLContextMenu result = contextMenu + .addItem("edit", "Edit") + .addItem("delete", "Delete") + .addItem("info", "Info") + .setOnMenuItemListener(item -> { + }); + + contextMenu.setEnabled(true); + + // Then + assertThat(result).isSameAs(contextMenu); + assertThat(contextMenu.getItemCount()).isEqualTo(3); + assertThat(contextMenu.getOnMenuItemListener()).isNotNull(); + assertThat(contextMenu.isEnabled()).isTrue(); + } + + // === Null Parameter Tests for @NonNull Validation === + + @Test + void addItem_withNullId_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.addItem(null, "Edit")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addItem_withNullIdAndIcon_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.addItem(null, "Edit", "icon.png")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addItem_withNullTextInTwoParamMethod_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.addItem("edit", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addItem_withNullTextInThreeParamMethod_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.addItem("edit", null, "icon.png")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addItem_withNullMenuItem_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.addItem((JLMenuItem) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removeItem_withNullId_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.removeItem(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void getItem_withNullId_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.getItem(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void updateItem_withNullMenuItem_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.updateItem(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void handleMenuItemSelection_withNullMenuItem_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.handleMenuItemSelection(null)) + .isInstanceOf(NullPointerException.class); + } + + // === Event Handling Tests === + + @Test + void handleContextMenuEvent_withListenerAndEvent_shouldInvokeOwnerListener() { + // Given + final boolean[] listenerCalled = {false}; + owner.setOnActionListener((obj, event) -> listenerCalled[0] = true); + Event testEvent = new ContextMenuEvent( + JLAction.CONTEXT_MENU, + new JLLatLng(51.5, -0.09), + null, + 100.0, + 200.0 + ); + + // When + contextMenu.handleContextMenuEvent(testEvent); + + // Then + assertThat(listenerCalled[0]).isTrue(); + } + + @Test + void handleContextMenuEvent_withoutListener_shouldNotThrowException() { + // Given + Event testEvent = new ContextMenuEvent( + JLAction.CONTEXT_MENU, + new JLLatLng(51.5, -0.09), + null, + 100.0, + 200.0 + ); + + // When/Then - Should not throw exception + contextMenu.handleContextMenuEvent(testEvent); + } + + @Test + void handleContextMenuEvent_withNullEvent_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> contextMenu.handleContextMenuEvent(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void handleContextMenuEvent_withListenerReceivesCorrectEvent() { + // Given + final Event[] receivedEvent = {null}; + owner.setOnActionListener((obj, event) -> receivedEvent[0] = event); + Event testEvent = new ContextMenuEvent( + JLAction.CONTEXT_MENU, + new JLLatLng(51.5, -0.09), + null, + 100.0, + 200.0 + ); + + // When + contextMenu.handleContextMenuEvent(testEvent); + + // Then + assertThat(receivedEvent[0]).isEqualTo(testEvent); + } + + @Test + void handleContextMenuEvent_withListenerReceivesCorrectOwner() { + // Given + final JLMarker[] receivedOwner = {null}; + owner.setOnActionListener((obj, event) -> receivedOwner[0] = obj); + Event testEvent = new ContextMenuEvent( + JLAction.CONTEXT_MENU, + new JLLatLng(51.5, -0.09), + null, + 100.0, + 200.0 + ); + + // When + contextMenu.handleContextMenuEvent(testEvent); + + // Then + assertThat(receivedOwner[0]).isEqualTo(owner); + } +} diff --git a/jlmap-api/src/test/java/io/github/makbn/jlmap/element/menu/JLMenuItemTest.java b/jlmap-api/src/test/java/io/github/makbn/jlmap/element/menu/JLMenuItemTest.java new file mode 100644 index 0000000..ad74dc5 --- /dev/null +++ b/jlmap-api/src/test/java/io/github/makbn/jlmap/element/menu/JLMenuItemTest.java @@ -0,0 +1,355 @@ +package io.github.makbn.jlmap.element.menu; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for JLMenuItem. + * Tests the menu item functionality. + * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +class JLMenuItemTest { + + // === Factory Method Tests === + + @Test + void of_withIdAndText_shouldCreateMenuItem() { + // When + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // Then + assertThat(item).isNotNull(); + assertThat(item.getId()).isEqualTo("edit"); + assertThat(item.getText()).isEqualTo("Edit"); + assertThat(item.getIcon()).isNull(); + assertThat(item.isVisible()).isTrue(); + assertThat(item.isEnabled()).isTrue(); + } + + @Test + void of_withIdTextAndIcon_shouldCreateMenuItemWithIcon() { + // When + JLMenuItem item = JLMenuItem.of("edit", "Edit", "https://example.com/icon.png"); + + // Then + assertThat(item).isNotNull(); + assertThat(item.getId()).isEqualTo("edit"); + assertThat(item.getText()).isEqualTo("Edit"); + assertThat(item.getIcon()).isEqualTo("https://example.com/icon.png"); + } + + @Test + void of_withNullId_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> JLMenuItem.of(null, "Edit")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void of_withNullText_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> JLMenuItem.of("edit", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void of_withBlankId_shouldAcceptBlankId() { + // When + JLMenuItem item = JLMenuItem.of("", "Edit"); + + // Then - Current implementation accepts blank ID + // This documents the current behavior + assertThat(item.getId()).isEmpty(); + } + + @Test + void of_withBlankText_shouldAcceptBlankText() { + // When + JLMenuItem item = JLMenuItem.of("edit", ""); + + // Then - Current implementation accepts blank text + // This documents the current behavior + assertThat(item.getText()).isEmpty(); + } + + @Test + void of_withWhitespaceId_shouldAcceptWhitespaceId() { + // When + JLMenuItem item = JLMenuItem.of(" ", "Edit"); + + // Then - Current implementation accepts whitespace ID + // This documents the current behavior + assertThat(item.getId()).isEqualTo(" "); + } + + @Test + void of_withWhitespaceText_shouldAcceptWhitespaceText() { + // When + JLMenuItem item = JLMenuItem.of("edit", " "); + + // Then - Current implementation accepts whitespace text + // This documents the current behavior + assertThat(item.getText()).isEqualTo(" "); + } + + // === Visibility Tests === + + @Test + void isVisible_byDefault_shouldReturnTrue() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // When/Then + assertThat(item.isVisible()).isTrue(); + } + + @Test + void builder_withVisibleFalse_shouldCreateHiddenItem() { + // When + JLMenuItem item = JLMenuItem.builder() + .id("edit") + .text("Edit") + .visible(false) + .build(); + + // Then + assertThat(item.isVisible()).isFalse(); + } + + @Test + void toBuilder_withVisibleFalse_shouldCreateHiddenItem() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // When + JLMenuItem modifiedItem = item.toBuilder() + .visible(false) + .build(); + + // Then + assertThat(modifiedItem.isVisible()).isFalse(); + assertThat(item.isVisible()).isTrue(); // Original unchanged + } + + // === Enabled Tests === + + @Test + void isEnabled_byDefault_shouldReturnTrue() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // When/Then + assertThat(item.isEnabled()).isTrue(); + } + + @Test + void builder_withEnabledFalse_shouldCreateDisabledItem() { + // When + JLMenuItem item = JLMenuItem.builder() + .id("edit") + .text("Edit") + .enabled(false) + .build(); + + // Then + assertThat(item.isEnabled()).isFalse(); + } + + @Test + void toBuilder_withEnabledFalse_shouldCreateDisabledItem() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // When + JLMenuItem modifiedItem = item.toBuilder() + .enabled(false) + .build(); + + // Then + assertThat(modifiedItem.isEnabled()).isFalse(); + assertThat(item.isEnabled()).isTrue(); // Original unchanged + } + + // === Icon Tests === + + @Test + void toBuilder_shouldAllowUpdatingIcon() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // When + JLMenuItem modifiedItem = item.toBuilder() + .icon("https://example.com/new-icon.png") + .build(); + + // Then + assertThat(modifiedItem.getIcon()).isEqualTo("https://example.com/new-icon.png"); + assertThat(item.getIcon()).isNull(); // Original unchanged + } + + @Test + void toBuilder_withNullIcon_shouldAcceptNullIcon() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit", "old-icon.png"); + + // When + JLMenuItem modifiedItem = item.toBuilder() + .icon(null) + .build(); + + // Then + assertThat(modifiedItem.getIcon()).isNull(); + } + + // === Text Tests === + + @Test + void toBuilder_shouldAllowUpdatingText() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + + // When + JLMenuItem modifiedItem = item.toBuilder() + .text("Edit Updated") + .build(); + + // Then + assertThat(modifiedItem.getText()).isEqualTo("Edit Updated"); + assertThat(item.getText()).isEqualTo("Edit"); // Original unchanged + } + + // === Equals and HashCode Tests === + + @Test + void equals_withAllFieldsEqual_shouldReturnTrue() { + // Given + JLMenuItem item1 = JLMenuItem.of("edit", "Edit"); + JLMenuItem item2 = JLMenuItem.of("edit", "Edit"); + + // When/Then + assertThat(item1).isEqualTo(item2); + } + + @Test + void equals_withDifferentText_shouldReturnFalse() { + // Given + JLMenuItem item1 = JLMenuItem.of("edit", "Edit"); + JLMenuItem item2 = JLMenuItem.of("edit", "Edit Different"); + + // When/Then - @Value uses all fields for equals + assertThat(item1).isNotEqualTo(item2); + } + + @Test + void equals_withDifferentId_shouldReturnFalse() { + // Given + JLMenuItem item1 = JLMenuItem.of("edit", "Edit"); + JLMenuItem item2 = JLMenuItem.of("delete", "Edit"); + + // When/Then + assertThat(item1).isNotEqualTo(item2); + } + + @Test + void hashCode_withAllFieldsEqual_shouldReturnSameHashCode() { + // Given + JLMenuItem item1 = JLMenuItem.of("edit", "Edit"); + JLMenuItem item2 = JLMenuItem.of("edit", "Edit"); + + // When/Then + assertThat(item1.hashCode()).isEqualTo(item2.hashCode()); + } + + // === toString Tests === + + @Test + void toString_shouldContainIdAndText() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit Marker"); + + // When + String result = item.toString(); + + // Then + assertThat(result).contains("edit"); + assertThat(result).contains("Edit Marker"); + } + + // === Immutability Tests === + + @Test + void menuItem_shouldBeImmutable() { + // Given + JLMenuItem item = JLMenuItem.of("edit", "Edit"); + String originalId = item.getId(); + String originalText = item.getText(); + + // When - Create modified version using toBuilder + JLMenuItem modifiedItem = item.toBuilder() + .text("Updated") + .icon("new-icon.png") + .visible(false) + .enabled(false) + .build(); + + // Then - Original should be unchanged + assertThat(item.getId()).isEqualTo(originalId); + assertThat(item.getText()).isEqualTo(originalText); + assertThat(item.isVisible()).isTrue(); + assertThat(item.isEnabled()).isTrue(); + assertThat(item.getIcon()).isNull(); + + // And modified should have new values + assertThat(modifiedItem.getText()).isEqualTo("Updated"); + assertThat(modifiedItem.getIcon()).isEqualTo("new-icon.png"); + assertThat(modifiedItem.isVisible()).isFalse(); + assertThat(modifiedItem.isEnabled()).isFalse(); + } + + // === Null Parameter Tests for @NonNull Validation (Three-param method) === + + @Test + void of_withNullIdAndIcon_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> JLMenuItem.of(null, "Edit", "icon.png")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void of_withNullTextAndIcon_shouldThrowException() { + // When/Then + assertThatThrownBy(() -> JLMenuItem.of("edit", null, "icon.png")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void of_withNullIcon_shouldAcceptNullIcon() { + // When + JLMenuItem item = JLMenuItem.of("edit", "Edit", null); + + // Then + assertThat(item.getIcon()).isNull(); + } + + @Test + void of_withBlankIcon_shouldAcceptBlankIcon() { + // When + JLMenuItem item = JLMenuItem.of("edit", "Edit", ""); + + // Then + assertThat(item.getIcon()).isEmpty(); + } + + @Test + void of_withWhitespaceIcon_shouldAcceptWhitespaceIcon() { + // When + JLMenuItem item = JLMenuItem.of("edit", "Edit", " "); + + // Then + assertThat(item.getIcon()).isEqualTo(" "); + } +} diff --git a/jlmap-api/src/test/java/io/github/makbn/jlmap/model/JLGeoJsonOptionsTest.java b/jlmap-api/src/test/java/io/github/makbn/jlmap/model/JLGeoJsonOptionsTest.java new file mode 100644 index 0000000..7c5af94 --- /dev/null +++ b/jlmap-api/src/test/java/io/github/makbn/jlmap/model/JLGeoJsonOptionsTest.java @@ -0,0 +1,73 @@ +package io.github.makbn.jlmap.model; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test class for JLGeoJsonOptions. + * + * @author Matt Akbarian (@makbn) + */ +class JLGeoJsonOptionsTest { + + @Test + void shouldCreateDefaultOptions() { + JLGeoJsonOptions options = JLGeoJsonOptions.getDefault(); + + assertThat(options).isNotNull(); + assertThat(options.getStyleFunction()).isNull(); + assertThat(options.getFilter()).isNull(); + } + + @Test + void shouldCreateOptionsWithStyleFunction() { + JLGeoJsonOptions options = JLGeoJsonOptions.builder().styleFunction(properties -> JLOptions.builder().color(JLColor.RED).build()) + .build(); + + assertThat(options.getStyleFunction()).isNotNull(); + + // Test the function + Map testProperties = Map.of("type", "test"); + JLOptions result = options.getStyleFunction().apply(List.of(testProperties)); + assertThat(result.getColor()).isEqualTo(JLColor.RED); + } + + + @Test + void shouldCreateOptionsWithFilter() { + JLGeoJsonOptions options = JLGeoJsonOptions.builder().filter((properties -> + "active".equals(properties.get(0).get("status")))).build(); + + assertThat(options.getFilter()).isNotNull(); + + // Test the filter function + Map activeProps = Map.of("status", "active"); + Map inactiveProps = Map.of("status", "inactive"); + + assertThat(options.getFilter().test(List.of(activeProps))).isTrue(); + assertThat(options.getFilter().test(List.of(inactiveProps))).isFalse(); + } + + @Test + void shouldBuildOptionsWithStyleAndFilter() { + JLOptions baseStyle = JLOptions.builder() + .color(JLColor.RED) + .weight(5) + .opacity(0.8) + .build(); + + JLGeoJsonOptions options = JLGeoJsonOptions.builder() + .styleFunction(properties -> baseStyle) + .filter(properties -> Boolean.TRUE.equals(properties.get(0).get("visible"))) + .build(); + + assertThat(options.getStyleFunction().apply(Collections.emptyList())).isEqualTo(baseStyle); + assertThat(options.getStyleFunction()).isNotNull(); + assertThat(options.getFilter()).isNotNull(); + } +} \ No newline at end of file diff --git a/jlmap-api/src/test/java/io/github/makbn/jlmap/model/builder/JLCircleBuilderTest.java b/jlmap-api/src/test/java/io/github/makbn/jlmap/model/builder/JLCircleBuilderTest.java new file mode 100644 index 0000000..a8f19e4 --- /dev/null +++ b/jlmap-api/src/test/java/io/github/makbn/jlmap/model/builder/JLCircleBuilderTest.java @@ -0,0 +1,50 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLOptions; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JLCircleBuilderTest { + + @Test + void builder_withOptions_buildCircle() { + var elementUniqueName = "circle"; + + var circleBuilder = new JLCircleBuilder() + .setUuid(elementUniqueName) + .setLat(10.2) + .setLng(20.1) + .setRadius(13) + .setTransporter(() -> transport -> { + return null; + }) + .withOptions(JLOptions.DEFAULT) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.MOVE); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + }); + + + assertThat(circleBuilder.buildJsElement() + .trim().replaceAll("( +|\r|\n)", " ")) + .contains("let circle = L.circle([10.200000, 20.100000]") + .contains("radius: 13.000000") + .contains("fillOpacity: 0.2") + .contains("draggable: false") + .contains("closeButton: true") + .contains("smoothFactor: 1.0") + .contains("weight: 3") + .contains("fill: true") + .contains("opacity: 1.0") + .contains("stroke: true") + .contains("autoClose: true") + .contains("circle.uuid = 'circle'") + .contains("this.circle.on('move'") + .contains("this.circle.on('add',") + .contains("this.circle.on('remove',") + .contains("circle.addTo(this.map)"); + } +} diff --git a/jlmap-api/src/test/java/io/github/makbn/jlmap/model/builder/JLOptionsBuilderTest.java b/jlmap-api/src/test/java/io/github/makbn/jlmap/model/builder/JLOptionsBuilderTest.java new file mode 100644 index 0000000..514d1da --- /dev/null +++ b/jlmap-api/src/test/java/io/github/makbn/jlmap/model/builder/JLOptionsBuilderTest.java @@ -0,0 +1,53 @@ +package io.github.makbn.jlmap.model.builder; + +import io.github.makbn.jlmap.model.JLOptions; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.within; + +class JLOptionsBuilderTest { + + @Test + void optionBuilder_default_shouldGenerateTheMap() { + JLOptionsBuilder builder = new JLOptionsBuilder(); + builder.setOption(JLOptions.DEFAULT); + + assertThat(builder.build()).isNotNull() + .isNotEmpty() + .satisfies(actualMap -> { + assertThat(actualMap).containsEntry("autoClose", true); + assertThat(actualMap).containsEntry("closeButton", true); + assertThat(actualMap).containsEntry("draggable", false); + assertThat(actualMap).containsEntry("fill", true); + assertThat(actualMap).containsEntry("fillOpacity", 0.2).hasEntrySatisfying("fillOpacity", value -> + assertThat((Double) value).isCloseTo(0.2, within(0.00001))); + assertThat(actualMap).containsEntry("opacity", 1.0).hasEntrySatisfying("opacity", value -> + assertThat((Double) value).isCloseTo(1.0, within(0.00001))); + assertThat(actualMap).containsEntry("smoothFactor", 1.0).hasEntrySatisfying("smoothFactor", value -> + assertThat((Double) value).isCloseTo(1.0, within(0.00001))); + assertThat(actualMap).containsEntry("stroke", true); + assertThat(actualMap).containsEntry("weight", 3); + }); + } + + @Test + void toString_shouldGenerateLeafletStyleString() { + JLOptions options = JLOptions.DEFAULT; + String str = options.toString(); + assertThat(str) + .contains("color: '") + .contains("fillColor: '") + .contains("weight: 3") + .contains("stroke: true") + .contains("fill: true") + .contains("opacity: 1.0") + .contains("fillOpacity: 0.2") + .contains("smoothFactor: 1.0") + .contains("closeButton: true") + .contains("autoClose: true") + .contains("draggable: false") + .startsWith("{") + .endsWith("}"); + } +} diff --git a/jlmap-fx/pom.xml b/jlmap-fx/pom.xml new file mode 100644 index 0000000..696b7a0 --- /dev/null +++ b/jlmap-fx/pom.xml @@ -0,0 +1,272 @@ + + + 4.0.0 + + + io.github.makbn + jlmap-parent + 2.0.0 + + + jlmap-fx + jar + Java Leaflet (JLeaflet) - JavaFX Implementation + JavaFX implementation for Java Leaflet map components + + + + GNU Lesser General Public License (LGPL) Version 2.1 or later + https://www.gnu.org/licenses/lgpl-2.1.html + https://github.com/makbn/java_leaflet + + + + + 17 + 17 + UTF-8 + + + + + + 3.13.0 + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + --add-modules + javafx.controls,javafx.base,javafx.swing,javafx.web,javafx.graphics,jdk.jsobject + --add-opens + javafx.web/com.sun.javafx.webkit=ALL-UNNAMED + + + + org.projectlombok + lombok + ${lombok.version} + + + true + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + io.github.makbn.jlmap.fx.demo.LeafletTestJFX + + + + maven-assembly-plugin + + + package + + single + + + + + + + true + io.github.makbn.jlmap.fx.demo.LeafletTestJFX + + + + jar-with-dependencies + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + true + io.github.makbn.jlmap.fx.demo.LeafletTestJFX + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + false + + --add-opens javafx.graphics/com.sun.javafx.application=ALL-UNNAMED + --add-opens javafx.graphics/com.sun.javafx.application=org.testfx.junit5 + --add-opens javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED + --add-opens javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-opens javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED + --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.util=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + --add-opens java.base/java.text=ALL-UNNAMED + --add-opens java.desktop/java.awt.font=ALL-UNNAMED + --add-opens javafx.web/com.sun.javafx.webkit=ALL-UNNAMED + --add-exports javafx.graphics/com.sun.glass.ui=ALL-UNNAMED + --add-exports javafx.graphics/com.sun.glass.ui=org.testfx.monocle + -Djava.awt.headless=true + -Dtestfx.robot=glass + -Dtestfx.headless=true + -Dprism.order=sw + -Dprism.text=t2k + -Dprism.quantum.multithreaded=false + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + io.github.makbn.jlmap.fx.demo.LeafletTestJFX + + --module-path ${project.basedir}/../../sdk/javafx-sdk-17.0.16/lib + --add-modules + javafx.controls,javafx.base,javafx.swing,javafx.web,javafx.graphics,jdk.jsobject + --add-opens + javafx.web/com.sun.javafx.webkit=ALL-UNNAMED + + + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + + prepare-agent + + + + report + test + + report + + + + + + src/main/java + src/test/java + + + + + + io.github.makbn + jlmap-api + ${project.version} + + + ch.qos.logback + logback-classic + 1.5.18 + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + com.j2html + j2html + 1.6.0 + + + + + org.openjfx + javafx-controls + ${javafx.version} + + + org.openjfx + javafx-base + ${javafx.version} + + + org.openjfx + javafx-swing + ${javafx.version} + + + org.openjfx + javafx-web + ${javafx.version} + + + org.openjfx + javafx-graphics + ${javafx.version} + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + 3.27.4 + test + + + org.mockito + mockito-core + 5.7.0 + test + + + org.mockito + mockito-junit-jupiter + 5.7.0 + test + + + + org.testfx + testfx-core + 4.0.18 + test + + + org.testfx + testfx-junit5 + 4.0.18 + test + + + + org.testfx + openjfx-monocle + jdk-12.0.1+2 + test + + + + org.awaitility + awaitility + 4.2.0 + test + + + \ No newline at end of file diff --git a/src/main/java/io/github/makbn/jlmap/JLMapView.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/JLMapView.java similarity index 57% rename from src/main/java/io/github/makbn/jlmap/JLMapView.java rename to jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/JLMapView.java index 6c5c8cf..0f111ba 100644 --- a/src/main/java/io/github/makbn/jlmap/JLMapView.java +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/JLMapView.java @@ -1,9 +1,21 @@ -package io.github.makbn.jlmap; +package io.github.makbn.jlmap.fx; -import io.github.makbn.jlmap.engine.JLJavaFXEngine; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.element.menu.JLContextMenu; import io.github.makbn.jlmap.engine.JLWebEngine; -import io.github.makbn.jlmap.layer.*; -import io.github.makbn.jlmap.listener.OnJLMapViewListener; +import io.github.makbn.jlmap.fx.engine.JLJavaFXEngine; +import io.github.makbn.jlmap.fx.internal.JLFxMapRenderer; +import io.github.makbn.jlmap.fx.layer.JLControlLayer; +import io.github.makbn.jlmap.fx.layer.JLGeoJsonLayer; +import io.github.makbn.jlmap.fx.layer.JLUiLayer; +import io.github.makbn.jlmap.fx.layer.JLVectorLayer; +import io.github.makbn.jlmap.layer.leaflet.LeafletLayer; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.listener.event.MapEvent; +import io.github.makbn.jlmap.map.JLMapProvider; import io.github.makbn.jlmap.model.JLLatLng; import io.github.makbn.jlmap.model.JLMapOption; import javafx.animation.Interpolator; @@ -21,6 +33,7 @@ import javafx.util.Duration; import lombok.AccessLevel; import lombok.Builder; +import lombok.Getter; import lombok.NonNull; import lombok.experimental.FieldDefaults; import lombok.experimental.NonFinal; @@ -29,47 +42,48 @@ import org.jetbrains.annotations.Nullable; import java.awt.*; -import java.io.*; +import java.io.File; +import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Files; import java.util.HashMap; import java.util.Objects; -import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; /** - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ @Slf4j @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) -public class JLMapView extends AnchorPane implements JLMapController { +public class JLMapView extends AnchorPane implements JLMap { JLMapOption mapOption; - JLWebEngine jlWebEngine; + JLWebEngine jlWebEngine; + @Getter WebView webView; - JLMapCallbackHandler jlMapCallbackHandler; - @NonFinal - HashMap, JLLayer> layers; + JLMapEventHandler jlMapCallbackHandler; + HashMap, LeafletLayer> layers; + @NonFinal boolean controllerAdded = false; @NonFinal @Nullable - OnJLMapViewListener mapListener; + OnJLActionListener> mapListener; @Builder - public JLMapView(@NonNull JLProperties.MapType mapType, + public JLMapView(@NonNull JLMapProvider jlMapProvider, @NonNull JLLatLng startCoordinate, boolean showZoomController) { super(); this.mapOption = JLMapOption.builder() .startCoordinate(startCoordinate) - .mapType(mapType) + .jlMapProvider(jlMapProvider) .additionalParameter(Set.of(new JLMapOption.Parameter("zoomControl", Objects.toString(showZoomController)))) .build(); + this.layers = new HashMap<>(); this.webView = new WebView(); this.jlWebEngine = new JLJavaFXEngine(webView.getEngine()); - this.jlMapCallbackHandler = new JLMapCallbackHandler(mapListener); + this.jlMapCallbackHandler = new JLMapEventHandler(); initialize(); } @@ -81,20 +95,21 @@ private void removeMapBlur() { private void initialize() { webView.getEngine().onStatusChangedProperty().addListener((observable, oldValue, newValue) - -> log.debug(String.format("Old Value: %s\tNew Value: %s", oldValue, newValue))); + -> log.debug("Old Value: {} New Value: {}", oldValue, newValue)); webView.getEngine().onErrorProperty().addListener((observable, oldValue, newValue) - -> log.debug(String.format("Old Value: %s\tNew Value: %s", oldValue, newValue))); + -> log.debug("Old Value: {} New Value: {}}", oldValue, newValue)); webView.getEngine().getLoadWorker().stateProperty().addListener((observable, oldValue, newValue) -> { checkForBrowsing(webView.getEngine()); if (newValue == Worker.State.FAILED) { log.info("failed to load!"); } else if (newValue == Worker.State.SUCCEEDED) { removeMapBlur(); - webView.getEngine().executeScript("removeNativeAttr()"); addControllerToDocument(); + webView.getEngine().setOnError(webErrorEvent -> log.error(webErrorEvent.getMessage())); + webView.getEngine().setOnAlert(webErrorEvent -> log.error(webErrorEvent.getData())); if (mapListener != null) { - mapListener.mapLoadedSuccessfully(this); + mapListener.onAction(this, new MapEvent(JLAction.MAP_LOADED)); } } else { @@ -104,21 +119,20 @@ private void initialize() { // Note: WebConsoleListener is an internal JavaFX API and not available in the module system // Web console logging is disabled for module compatibility - webView.getEngine().getLoadWorker().exceptionProperty().addListener((observableValue, throwable, t1) -> - log.error("obeservable valuie: {}, exception: {}", observableValue, t1.toString())); + log.error("observable value: {}, exception: {}", observableValue, t1.toString())); File index = null; - try (InputStream in = getClass().getResourceAsStream("/index.html")) { - BufferedReader reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(in))); + try { index = File.createTempFile("jlmapindex", ".html"); - Files.write(index.toPath(), reader.lines().collect(Collectors.joining("\n")).getBytes()); + index.deleteOnExit(); + Files.write(index.toPath(), new JLFxMapRenderer().render(mapOption).getBytes()); } catch (IOException e) { log.error(e.getMessage(), e); } webView.getEngine() - .load(String.format("file:%s%s", Objects.requireNonNull(index).getAbsolutePath(), mapOption.toQueryString())); + .load(String.format("file:%s", Objects.requireNonNull(index).getAbsolutePath())); setBackground(new Background(new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY))); getChildren().add(webView); @@ -128,7 +142,7 @@ private void initialize() { private void checkForBrowsing(WebEngine engine) { String address = engine.getLoadWorker().getMessage().trim(); - log.debug("link: " + address); + log.debug("link: {}", address); if (address.contains("http://") || address.contains("https://")) { engine.getLoadWorker().cancel(); try { @@ -146,6 +160,52 @@ private void checkForBrowsing(WebEngine engine) { } } + /** + * @inheritDoc + */ + @Override + public OnJLActionListener> getOnActionListener() { + return mapListener; + } + + /** + * @inheritDoc + */ + @Override + public void setOnActionListener(OnJLActionListener> listener) { + this.mapListener = listener; + } + + @Override + public @NonNull JLContextMenu> addContextMenu() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public @NonNull JLContextMenu> getContextMenu() { + return null; + } + + @Override + public void setContextMenu(@NonNull JLContextMenu> contextMenu) { + + } + + @Override + public boolean hasContextMenu() { + return false; + } + + @Override + public boolean isContextMenuEnabled() { + return false; + } + + @Override + public void setContextMenuEnabled(boolean enabled) { + + } + @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) private static class MapTransition extends Transition { WebView webView; @@ -183,40 +243,46 @@ private void customizeWebviewStyles() { setBottomAnchor(this, 0.5); } + /** + * @inheritDoc + */ @Override - public HashMap, JLLayer> getLayers() { - if (layers == null) { - layers = new HashMap<>(); - - layers.put(JLUiLayer.class, new JLUiLayer(jlWebEngine, jlMapCallbackHandler)); - layers.put(JLVectorLayer.class, new JLVectorLayer(jlWebEngine, jlMapCallbackHandler)); - layers.put(JLControlLayer.class, new JLControlLayer(jlWebEngine, jlMapCallbackHandler)); - layers.put(JLGeoJsonLayer.class, new JLGeoJsonLayer(jlWebEngine, jlMapCallbackHandler)); - } + public HashMap, LeafletLayer> getLayers() { + layers.clear(); + layers.put(JLUiLayer.class, new JLUiLayer(jlWebEngine, jlMapCallbackHandler)); + layers.put(JLVectorLayer.class, new JLVectorLayer(jlWebEngine, jlMapCallbackHandler)); + layers.put(JLControlLayer.class, new JLControlLayer(jlWebEngine, jlMapCallbackHandler)); + layers.put(JLGeoJsonLayer.class, new JLGeoJsonLayer(jlWebEngine, jlMapCallbackHandler)); return layers; } + /** + * @inheritDoc + */ @Override public void addControllerToDocument() { JSObject window = (JSObject) webView.getEngine().executeScript("window"); if (!controllerAdded) { - window.setMember("app", jlMapCallbackHandler); + // passing this to javascript is a security concern, should be reviewed later + window.setMember("serverCallback", this); log.debug("controller added to js scripts"); controllerAdded = true; } - webView.getEngine().setOnError(webErrorEvent -> log.error(webErrorEvent.getMessage())); } + @SuppressWarnings("unused") + public void functionCalled(String functionName, String jlType, String uuid, + String param1, String param2, String param3) { + jlMapCallbackHandler.functionCalled(this, functionName, jlType, uuid, param1, param2, param3); + } + + /** + * @inheritDoc + */ @Override - public JLWebEngine getJLEngine() { + public JLWebEngine getJLEngine() { return jlWebEngine; } - public Optional getMapListener() { - return Optional.ofNullable(mapListener); - } - public void setMapListener(@NonNull OnJLMapViewListener mapListener) { - this.mapListener = mapListener; - } } \ No newline at end of file diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/demo/LeafletTestJFX.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/demo/LeafletTestJFX.java new file mode 100644 index 0000000..dadf46c --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/demo/LeafletTestJFX.java @@ -0,0 +1,217 @@ +package io.github.makbn.jlmap.fx.demo; + +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.fx.JLMapView; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.listener.event.ClickEvent; +import io.github.makbn.jlmap.listener.event.MapEvent; +import io.github.makbn.jlmap.listener.event.MoveEvent; +import io.github.makbn.jlmap.listener.event.ZoomEvent; +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.*; +import javafx.application.Application; +import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Background; +import javafx.scene.paint.Color; +import javafx.stage.Screen; +import javafx.stage.Stage; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Matt Akbarian (@makbn) + */ +@Slf4j +public class LeafletTestJFX extends Application { + + public static final String MAP_API_KEY = "rNGhTaIpQWWH7C6QGKzF"; + + @Override + public void start(Stage stage) { + //building a new map view + final JLMapView map = JLMapView + .builder() + .jlMapProvider(JLMapProvider.MAP_TILER.parameter(new JLMapOption.Parameter("key", MAP_API_KEY)).build()) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) // Paris + .showZoomController(true) + .startCoordinate(JLLatLng.builder() + .lat(51.044) + .lng(-114.07) + .build()) + .build(); + //creating a window + AnchorPane root = new AnchorPane(map); + root.setBackground(Background.EMPTY); + root.setMinHeight(JLProperties.INIT_MIN_HEIGHT_STAGE); + root.setMinWidth(JLProperties.INIT_MIN_WIDTH_STAGE); + Scene scene = new Scene(root); + + stage.setMinHeight(JLProperties.INIT_MIN_HEIGHT_STAGE); + stage.setMinWidth(JLProperties.INIT_MIN_WIDTH_STAGE); + scene.setFill(Color.TRANSPARENT); + stage.setTitle("Java-Leaflet Test"); + stage.setScene(scene); + stage.show(); + + Rectangle2D primScreenBounds = Screen.getPrimary().getVisualBounds(); + stage.setX((primScreenBounds.getWidth() - stage.getWidth()) / 2); + stage.setY(100); + + //set listener fo map events + map.setOnActionListener((source, event) -> { + log.info("onActionReceived!: {}", event); + if (event instanceof MoveEvent moveEvent) { + log.info("move event: {} c: {} \t bounds: {} \t z: {}", moveEvent.action(), moveEvent.center(), + moveEvent.bounds(), moveEvent.zoomLevel()); + } else if (event instanceof ClickEvent clickEvent) { + log.info("click event: {}", clickEvent.center()); + map.getUiLayer().addPopup(clickEvent.center(), "New Click Event!", JLOptions.builder() + .closeButton(false) + .autoClose(false).build()); + } else if (event instanceof ZoomEvent zoomEvent) { + log.info("zoom event: {}", zoomEvent.zoomLevel()); + } else if (event instanceof MapEvent mapEvent && mapEvent.action() == JLAction.MAP_LOADED) { + loadDemoElement(map); + } + }); + } + + private void loadDemoElement(JLMapView map) { + log.info("map loaded!"); + addMultiPolyline(map); + addPolyline(map); + addPolygon(map); + + // Add marker with context menu + JLMarker calgaryMarker = map.getUiLayer() + .addMarker(JLLatLng.builder() + .lat(51.0447) + .lng(-114.0719) + .build(), "Calgary", true); + + // Add context menu to the marker + calgaryMarker.addContextMenu() + .addItem("delete", "Delete Marker", "https://img.icons8.com/material-outlined/24/000000/trash--v1.png") + .addItem("info", "Show Info", "https://img.icons8.com/material-outlined/24/000000/info--v1.png") + .setOnMenuItemListener(item -> { + log.info("Context menu item selected: {}", item.getText()); + switch (item.getId()) { + case "delete" -> calgaryMarker.remove(); + case "info" -> map.getUiLayer().addPopup( + JLLatLng.builder().lat(51.0447).lng(-114.0719).build(), + "Calgary - AB", + JLOptions.builder().autoClose(true).build() + ); + } + }); + + calgaryMarker.setOnActionListener(getListener()); + + map.getVectorLayer() + .addCircleMarker(JLLatLng.builder() + .lat(51.0447) + .lng(-114.0719) + .build()); + + map.getVectorLayer() + .addCircle(JLLatLng.builder() + .lat(35.63) + .lng(51.45) + .build(), 30000, JLOptions.DEFAULT); + + // JLImageOverlay demo: Eiffel Tower image over Paris + JLBounds eiffelBounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(47.857).lng(3.293).build()) + .northEast(JLLatLng.builder().lat(49.860).lng(1.298).build()) + .build(); + map.getUiLayer().addImage( + eiffelBounds, + "https://img.favpng.com/1/24/8/eiffel-tower-eiffel-tower-illustrated-landmark-L5szYqrZ_t.jpg", + JLOptions.DEFAULT + ); + + // map zoom functionalities + map.getControlLayer().setZoom(3); + map.getControlLayer().zoomIn(2); + map.getControlLayer().zoomOut(3); + + JLGeoJson geoJsonObject = map.getGeoJsonLayer() + .addFromUrl("https://pkgstore.datahub.io/examples/geojson-tutorial/example/data/db696b3bf628d9a273ca9907adcea5c9/example.geojson", JLGeoJsonOptions.builder() + .styleFunction(properties -> JLOptions.builder() + .color(JLColor.ORANGE) + .weight(2) + .fillColor(JLColor.PURPLE) + .fillOpacity(0.5) + .build()) + + .build()); + + + log.info("geojson loaded! id: {}", geoJsonObject.getJLId()); + } + + private OnJLActionListener getListener() { + return (jlMarker, event) -> log.info("object {} event for marker:{}", event.action(), jlMarker); + } + + private void addMultiPolyline(JLMapView map) { + JLLatLng[][] verticesT = new JLLatLng[2][]; + + verticesT[0] = new JLLatLng[]{ + new JLLatLng(41.509, 20.08), + new JLLatLng(31.503, -10.06), + new JLLatLng(21.51, -0.047) + }; + + verticesT[1] = new JLLatLng[]{ + new JLLatLng(51.509, 10.08), + new JLLatLng(55.503, 15.06), + new JLLatLng(42.51, 20.047) + }; + + map.getVectorLayer().addMultiPolyline(verticesT); + } + + private void addPolyline(JLMapView map) { + JLLatLng[] vertices = new JLLatLng[]{ + new JLLatLng(51.509, -0.08), + new JLLatLng(51.503, -0.06), + new JLLatLng(51.51, -0.047) + }; + + map.getVectorLayer().addPolyline(vertices); + } + + private void addPolygon(JLMapView map) { + + JLLatLng[][][] vertices = new JLLatLng[2][][]; + + vertices[0] = new JLLatLng[2][]; + vertices[1] = new JLLatLng[1][]; + //first part + vertices[0][0] = new JLLatLng[]{ + new JLLatLng(37, -109.05), + new JLLatLng(41, -109.03), + new JLLatLng(41, -102.05), + new JLLatLng(37, -102.04) + }; + //hole inside the first part + vertices[0][1] = new JLLatLng[]{ + new JLLatLng(37.29, -108.58), + new JLLatLng(40.71, -108.58), + new JLLatLng(40.71, -102.50), + new JLLatLng(37.29, -102.50) + }; + //second part + vertices[1][0] = new JLLatLng[]{ + new JLLatLng(41, -111.03), + new JLLatLng(45, -111.04), + new JLLatLng(45, -104.05), + new JLLatLng(41, -104.05) + }; + map.getVectorLayer().addPolygon(vertices).setOnActionListener((source, event) -> + log.info("{} event for: {}", event.action(), source)); + } +} \ No newline at end of file diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/element/menu/JavaFXContextMenuMediator.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/element/menu/JavaFXContextMenuMediator.java new file mode 100644 index 0000000..14a73b4 --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/element/menu/JavaFXContextMenuMediator.java @@ -0,0 +1,189 @@ +package io.github.makbn.jlmap.fx.element.menu; + +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.element.menu.JLContextMenuMediator; +import io.github.makbn.jlmap.element.menu.JLHasContextMenu; +import io.github.makbn.jlmap.fx.JLMapView; +import io.github.makbn.jlmap.model.JLObject; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +/** + * JavaFX-specific implementation of the context menu mediator. + *

+ * This mediator handles context menu functionality for JL objects within + * JavaFX applications. It creates and manages JavaFX ContextMenu components + * and handles the integration between JL context menus and JavaFX's UI framework. + *

+ *

Features:

+ *
    + *
  • Native Integration: Uses JavaFX's ContextMenu component
  • + *
  • Event Handling: Proper event delegation and lifecycle management
  • + *
  • WebView Integration: Specialized handling for map content in WebView
  • + *
  • Thread Safety: Safe for use in JavaFX's single-threaded architecture
  • + *
+ *

JavaFX Integration:

+ *

+ * This mediator integrates with JavaFX's scene graph by: + *

+ *
    + *
  • Finding the appropriate JavaFX node for each JL object
  • + *
  • Attaching mouse event handlers to detect right-click
  • + *
  • Creating and managing ContextMenu instances
  • + *
  • Handling menu item clicks and event propagation
  • + *
+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@Slf4j +@FieldDefaults(level = lombok.AccessLevel.PRIVATE) +public class JavaFXContextMenuMediator implements JLContextMenuMediator { + public static final double ICON_SIZE = 14.0; + ContextMenu universalContextMenu; + boolean mouseHandlerRegistered = false; + long lastMenuShowTime = 0; + + /** + * {@inheritDoc} + */ + @Override + public synchronized > void showContextMenu(@NonNull JLMap map, @NonNull JLObject object, double x, double y) { + log.debug("Showing context menu for object: {} at ({}, {})", object.getJLId(), x, y); + ensureContextMenuInitialized(); + + if (!(object instanceof JLHasContextMenu objectWithContextMenu)) { + return; + } + + logContextMenuDebugInfo(objectWithContextMenu); + + if (shouldShowContextMenu(objectWithContextMenu)) { + populateMenuItems(objectWithContextMenu); + displayContextMenu(map, x, y); + } + } + + private void ensureContextMenuInitialized() { + if (universalContextMenu == null) { + universalContextMenu = new ContextMenu(); + } + } + + private void logContextMenuDebugInfo(JLHasContextMenu objectWithContextMenu) { + log.debug("Object is JLHasContextMenu: true"); + log.debug("Has context menu: {}, Enabled: {}", objectWithContextMenu.hasContextMenu(), objectWithContextMenu.isContextMenuEnabled()); + log.debug("Context menu object: {}", objectWithContextMenu.getContextMenu()); + if (objectWithContextMenu.getContextMenu() != null) { + log.debug("Context menu items count: {}", objectWithContextMenu.getContextMenu().getItemCount()); + } + } + + private boolean shouldShowContextMenu(JLHasContextMenu objectWithContextMenu) { + return objectWithContextMenu.hasContextMenu() && objectWithContextMenu.isContextMenuEnabled(); + } + + private void populateMenuItems(JLHasContextMenu objectWithContextMenu) { + universalContextMenu.getItems().clear(); + Objects.requireNonNull(objectWithContextMenu.getContextMenu()).getItems().forEach(item -> { + log.debug("Adding menu item: {}", item.getText()); + MenuItem menuItem = createMenuItem(item, objectWithContextMenu); + universalContextMenu.getItems().add(menuItem); + }); + } + + private MenuItem createMenuItem(io.github.makbn.jlmap.element.menu.JLMenuItem item, JLHasContextMenu objectWithContextMenu) { + MenuItem menuItem = new MenuItem(item.getText()); + menuItem.setOnAction(e -> + Objects.requireNonNull(objectWithContextMenu.getContextMenu()).getOnMenuItemListener().onMenuItemSelected(item)); + if (item.getIcon() != null && !item.getIcon().isBlank()) { + menuItem.setGraphic(createIcon(item.getIcon())); + } + return menuItem; + } + + private void displayContextMenu(@NonNull JLMap map, double x, double y) { + if (!(map instanceof JLMapView jlMapView)) { + log.warn("Map is not a JLMapView: {}", map.getClass()); + return; + } + + log.debug("Showing context menu with {} items at WebView-relative position ({}, {})", + universalContextMenu.getItems().size(), x, y); + + registerClickHandlerIfNeeded(jlMapView); + showMenuAtPosition(jlMapView, x, y); + } + + private void registerClickHandlerIfNeeded(JLMapView jlMapView) { + if (mouseHandlerRegistered) { + return; + } + + jlMapView.getWebView().setOnMouseClicked(event -> { + long timeSinceShow = System.currentTimeMillis() - lastMenuShowTime; + if (universalContextMenu != null && universalContextMenu.isShowing() && timeSinceShow > 100) { + log.debug("Hiding context menu due to WebView click (time since show: {}ms)", timeSinceShow); + universalContextMenu.hide(); + } + }); + mouseHandlerRegistered = true; + } + + private void showMenuAtPosition(JLMapView jlMapView, double x, double y) { + javafx.geometry.Point2D screenCoords = jlMapView.getWebView().localToScreen(x, y); + if (screenCoords != null) { + log.debug("Screen coordinates: ({}, {})", screenCoords.getX(), screenCoords.getY()); + lastMenuShowTime = System.currentTimeMillis(); + universalContextMenu.show(jlMapView.getWebView(), screenCoords.getX(), screenCoords.getY()); + } else { + log.warn("Could not convert coordinates to screen space"); + } + } + + private ImageView createIcon(String url) { + Image image = new Image(url); + ImageView imageView = new ImageView(image); + imageView.setFitWidth(ICON_SIZE); + imageView.setFitHeight(ICON_SIZE); + imageView.setPreserveRatio(true); + return imageView; + } + + /** + * {@inheritDoc} + */ + @Override + public synchronized > void hideContextMenu(@NonNull JLMap map, @NonNull JLObject object) { + log.debug("Hiding context menu for object: {}", object.getJLId()); + if (universalContextMenu != null) { + universalContextMenu.hide(); + universalContextMenu.getItems().clear(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supportsObjectType(@NonNull Class> objectType) { + // Support all JL object types in JavaFX environment + return true; + } + + /** + * {@inheritDoc} + */ + @Override + @NonNull + public String getName() { + return "JavaFX Context Menu Mediator"; + } +} \ No newline at end of file diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFXClientToServerTransporter.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFXClientToServerTransporter.java new file mode 100644 index 0000000..a5fd261 --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFXClientToServerTransporter.java @@ -0,0 +1,65 @@ +package io.github.makbn.jlmap.fx.engine; + +import io.github.makbn.jlmap.engine.JLClientToServerTransporterBase; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import netscape.javascript.JSObject; + +import java.util.function.Function; + +/** + * JavaFX-specific implementation of the JLObjectBridge. + * Uses JSObject to enable direct synchronous calls from JavaScript to Java. + * + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLJavaFXClientToServerTransporter extends JLClientToServerTransporterBase { + + public JLJavaFXClientToServerTransporter(Function executor) { + super(executor); + initializeBridge(); + } + + private void initializeBridge() { + execute(getJavaScriptBridge()); + JSObject window = (JSObject) execute("window"); + // Set the bridge instance as a global variable + window.setMember("jlObjectBridgeJava", this); + log.debug("JavaFX JLObjectBridge initialized"); + } + + /** + * Method callable from JavaScript to invoke Java methods on registered objects. + * This allows JavaScript to directly call Java methods synchronously. + */ + @SuppressWarnings("unused") + public String callFromJavaScript(String objectId, String methodName, String argsJson) { + try { + String[] args = argsJson != null ? new String[]{argsJson} : new String[0]; + String result = callObjectMethod(objectId, methodName, args); + + return result; + } catch (Exception e) { + log.error("Error in JavaFX bridge call: {}", e.getMessage()); + return null; + } + } + + @Override + public String getJavaScriptBridge() { + //language=JavaScript + return super.getJavaScriptBridge() + """ + + // JavaFX-specific bridge implementation + window.jlObjectBridge._callJava = function(objectId, methodName, args) { + // Call the Java method directly via the exposed bridge object + return window.jlObjectBridgeJava.callFromJavaScript(objectId, methodName, JSON.stringify(args)); + }; + + console.log('JavaFX JLObjectBridge ready'); + """; + } +} \ No newline at end of file diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFXEngine.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFXEngine.java new file mode 100644 index 0000000..ec6236c --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFXEngine.java @@ -0,0 +1,37 @@ +package io.github.makbn.jlmap.fx.engine; + +import io.github.makbn.jlmap.engine.JLWebEngine; +import javafx.scene.web.WebEngine; +import lombok.NonNull; + +import java.util.Optional; + +/** + * @author Matt Akbarian (@makbn) + */ +public class JLJavaFXEngine extends JLWebEngine { + private final WebEngine jfxEngine; + + public JLJavaFXEngine(WebEngine jfxEngine) { + super(Object.class); + this.jfxEngine = jfxEngine; + } + + @Override + public T executeScript(@NonNull String script, @NonNull Class type) { + return Optional.ofNullable(jfxEngine.executeScript(script)) + .map(result -> { + if (type.isInstance(result)) { + return type.cast(result); + } else { + throw new IllegalArgumentException("Cannot cast result to " + type.getName()); + } + }) + .orElse(null); + } + + @Override + public Status getStatus() { + return jfxEngine.getLoadWorker().getState().name().equals("SUCCEEDED") ? Status.SUCCEEDED : Status.FAILED; + } +} diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFxServerToClientTransporter.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFxServerToClientTransporter.java new file mode 100644 index 0000000..804a166 --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/engine/JLJavaFxServerToClientTransporter.java @@ -0,0 +1,74 @@ +package io.github.makbn.jlmap.fx.engine; + +import com.google.gson.reflect.TypeToken; +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; + +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; + +/** + * JavaFX-specific implementation of the server-to-client transport interface. + *

+ * This implementation uses JavaFX's WebEngine.executeScript() to execute JavaScript + * operations and provides basic type conversion for common result types. It leverages + * JavaFX's synchronous JavaScript execution model with CompletableFuture wrapping for + * consistent async API. + *

+ *

Implementation Details:

+ *
    + *
  • Execution Context: Uses WebEngine.executeScript() for synchronous JavaScript execution
  • + *
  • Result Handling: Converts JavaScript objects to Java primitives and strings
  • + *
  • Type Support: Basic string conversion with fallback to parent implementation
  • + *
  • Thread Model: Must be called from JavaFX Application Thread
  • + *
+ *

Supported Type Conversions:

+ *
    + *
  • String: Direct conversion via String.valueOf()
  • + *
  • Null Values: Handled gracefully with null CompletableFuture
  • + *
  • Other Types: Delegated to parent implementation
  • + *
+ *

+ * Thread Safety: Must be executed on the JavaFX Application Thread. + * Concrete implementations should ensure proper thread marshaling. + *

+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public abstract class JLJavaFxServerToClientTransporter implements JLServerToClientTransporter { + + /** + * Converts JavaFX WebEngine execution results to typed Java objects. + *

+ * This implementation provides basic type conversion with special handling for: + *

+ *
    + *
  • Null Results: Returns completed future with null value
  • + *
  • String Types: Converts any result to string representation
  • + *
  • Other Types: Delegates to parent implementation
  • + *
+ *

+ * Note: JavaFX's WebEngine.executeScript() returns raw JavaScript + * objects which may need additional conversion for complex types. + *

+ * + * @inheritDoc + */ + @Override + public CompletableFuture covertResult(Object result, Class resultType) { + // Handle null results gracefully + if (result == null) { + return CompletableFuture.completedFuture(null); + } + + // Special handling for String type - convert any result to string + Type type = new TypeToken>() { + }.getType(); + if (type.getTypeName().equals("String")) { + return CompletableFuture.completedFuture((M) String.valueOf(result)); + } + + // Delegate complex type conversion to parent implementation + return JLServerToClientTransporter.super.covertResult(result, resultType); + } +} diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/internal/JLFxMapRenderer.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/internal/JLFxMapRenderer.java new file mode 100644 index 0000000..3ca0368 --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/internal/JLFxMapRenderer.java @@ -0,0 +1,139 @@ +package io.github.makbn.jlmap.fx.internal; + +import io.github.makbn.jlmap.map.JLMapRenderer; +import io.github.makbn.jlmap.model.JLMapOption; +import lombok.NonNull; + +import static j2html.TagCreator.*; + +public class JLFxMapRenderer implements JLMapRenderer { + + private static final String LANG = "EN"; + private static final String TITLE = "JLMap Java - Leaflet"; + private static final String CSS_LEAFLET = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; + private static final String SCRIPT_LEAFLET = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; + private static final String SCRIPT_LEAFLET_PROVIDER = "https://cdn.jsdelivr.net/npm/leaflet-providers@2.0.0/leaflet-providers.min.js"; + + + @NonNull + @Override + public String render(@NonNull JLMapOption option) { + return document().render() + html().withLang(LANG).with( + head().with( + title(TITLE), + meta().withCharset("utf-8"), + meta().withName("viewport").withContent("width=device-width, initial-scale=1.0"), + link() + .withRel("stylesheet") + .withHref(CSS_LEAFLET) + .attr("integrity", "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=") + .attr("crossorigin", ""), + script() + .withSrc(SCRIPT_LEAFLET) + .attr("integrity", "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=") + .attr("crossorigin", ""), + script() + .withSrc(SCRIPT_LEAFLET_PROVIDER), + script(jsRelayFunction()), + script(mapHelperFunctions()), + script(clientToServerEventHandler()) + ), + body().withStyle("margin: 0; background-color: #191a1a;").with( + div() + .withId("jl-map-view") + .withClass("leaflet-container leaflet-retina") + .withStyle("width: 100%; min-height: 100vh; height: 100vh; position: relative; background-color: #191a1a;"), + script(initializeMap(option)) + ) + ).render(); + } + + @NonNull + private String mapHelperFunctions() { + // language=js + return """ + function getCenterOfElement(event, mapElement) { + return JSON.stringify(event.latlng ? event.latlng: { + lat: mapElement.getCenter().lat, + lng: mapElement.getCenter().lng + }); + } + + function getMapBounds(mapElement) { + return JSON.stringify({ + "northEast": { + "lat": mapElement.getBounds().getNorthEast().lat, + "lng": mapElement.getBounds().getNorthEast().lng, + }, + "southWest": { + "lat": mapElement.getBounds().getSouthWest().lat, + "lng": mapElement.getBounds().getSouthWest().lng, + } + }); + } + """; + } + + + @NonNull + private String jsRelayFunction() { + //language=js + return """ + function jlMapServerCallbackDelegate(functionName, param1, param2, param3, param4, param5) { + //do nothing + } + + let fun = jlMapServerCallbackDelegate; + jlMapServerCallbackDelegate = function () { + if ('serverCallback' in window) { + serverCallback.functionCalled(arguments[0], arguments[1], arguments[2], arguments[3], arguments[4], arguments[5]); + } + fun.apply(this, arguments); + } + """; + } + + @NonNull + public String initializeMap(@NonNull JLMapOption option) { + //language=js + return """ + this.jlMapElement = document.querySelector('#jl-map-view'); + this.map = L.map(this.jlMapElement, {zoomControl: %b}).setView([%s, %s], %d); + + L.tileLayer('%s').addTo(this.map); + + this.outr_eventHandler = eventHandler; + this.jlMapElement.$server = { + eventHandler: (functionType, jlType, uuid, param1, param2, param3) => + this.outr_eventHandler(functionType, jlType, uuid, param1, param2, param3) + }; + + this.map.jlid = 'main_map'; + + this.map.on('click', e => eventHandler('click', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('move', e => eventHandler('move', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('movestart', e => eventHandler('movestart', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('moveend', e => eventHandler('moveend', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('zoom', e => eventHandler('zoom', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('zoomstart', e => eventHandler('zoomstart', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('zoomend', e => eventHandler('zoomend', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('resize', e => eventHandler('resize', 'map', 'main_map', this.map.getZoom(), JSON.stringify({"oldWidth": e.oldSize.x, "oldHeight": e.oldSize.y, "newWidth": e.newSize.x, "newHeight": e.newSize.y}), getMapBounds(this.map))); + + """.formatted(option.zoomControlEnabled(), + option.getStartCoordinate().getLat(), + option.getStartCoordinate().getLng(), + option.getInitialZoom(), + option.getJlMapProvider().getMapProviderAddress()); + } + + @NonNull + private String clientToServerEventHandler() { + //language=js + return """ + function eventHandler(functionType, jlType, uuid, param1, param2, param3) { + jlMapServerCallbackDelegate(functionType, jlType, uuid, param1, param2, param3); + } + """; + } + +} diff --git a/src/main/java/io/github/makbn/jlmap/layer/JLControlLayer.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLControlLayer.java similarity index 62% rename from src/main/java/io/github/makbn/jlmap/layer/JLControlLayer.java rename to jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLControlLayer.java index f3dbd36..262cdd0 100644 --- a/src/main/java/io/github/makbn/jlmap/layer/JLControlLayer.java +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLControlLayer.java @@ -1,6 +1,6 @@ -package io.github.makbn.jlmap.layer; +package io.github.makbn.jlmap.fx.layer; -import io.github.makbn.jlmap.JLMapCallbackHandler; +import io.github.makbn.jlmap.JLMapEventHandler; import io.github.makbn.jlmap.engine.JLWebEngine; import io.github.makbn.jlmap.layer.leaflet.LeafletControlLayerInt; import io.github.makbn.jlmap.model.JLBounds; @@ -9,92 +9,92 @@ /** * Represents the Control layer on Leaflet map. * - * @author Mehdi Akbarian Rastaghi (@makbn) + * @author Matt Akbarian (@makbn) */ public class JLControlLayer extends JLLayer implements LeafletControlLayerInt { - public JLControlLayer(JLWebEngine engine, - JLMapCallbackHandler callbackHandler) { + public JLControlLayer(JLWebEngine engine, + JLMapEventHandler callbackHandler) { super(engine, callbackHandler); } @Override public void zoomIn(int delta) { - engine.executeScript(String.format("getMap().zoomIn(%d)", delta)); + engine.executeScript(String.format("this.map.zoomIn(%d)", delta)); } @Override public void zoomOut(int delta) { - engine.executeScript(String.format("getMap().zoomOut(%d)", delta)); + engine.executeScript(String.format("this.map.zoomOut(%d)", delta)); } @Override public void setZoom(int level) { - engine.executeScript(String.format("getMap().setZoom(%d)", level)); + engine.executeScript(String.format("this.map.setZoom(%d)", level)); } @Override public void setZoomAround(JLLatLng latLng, int zoom) { engine.executeScript( - String.format("getMap().setZoomAround(L.latLng(%f, %f), %d)", + String.format("this.map.setZoomAround(L.latLng(%f, %f), %d)", latLng.getLat(), latLng.getLng(), zoom)); } @Override public void fitBounds(JLBounds bounds) { - engine.executeScript(String.format("getMap().fitBounds(%s)", + engine.executeScript(String.format("this.map.fitBounds(%s)", bounds.toString())); } @Override public void fitWorld() { - engine.executeScript("getMap().fitWorld()"); + engine.executeScript("this.map.fitWorld()"); } @Override public void panTo(JLLatLng latLng) { - engine.executeScript(String.format("getMap().panTo(L.latLng(%f, %f))", + engine.executeScript(String.format("this.map.panTo(L.latLng(%f, %f))", latLng.getLat(), latLng.getLng())); } @Override public void flyTo(JLLatLng latLng, int zoom) { engine.executeScript( - String.format("getMap().flyTo(L.latLng(%f, %f), %d)", + String.format("this.map.flyTo(L.latLng(%f, %f), %d)", latLng.getLat(), latLng.getLng(), zoom)); } @Override public void flyToBounds(JLBounds bounds) { - engine.executeScript(String.format("getMap().flyToBounds(%s)", + engine.executeScript(String.format("this.map.flyToBounds(%s)", bounds.toString())); } @Override public void setMaxBounds(JLBounds bounds) { - engine.executeScript(String.format("getMap().setMaxBounds(%s)", + engine.executeScript(String.format("this.map.setMaxBounds(%s)", bounds.toString())); } @Override public void setMinZoom(int zoom) { - engine.executeScript(String.format("getMap().setMinZoom(%d)", zoom)); + engine.executeScript(String.format("this.map.setMinZoom(%d)", zoom)); } @Override public void setMaxZoom(int zoom) { - engine.executeScript(String.format("getMap().setMaxZoom(%d)", zoom)); + engine.executeScript(String.format("this.map.setMaxZoom(%d)", zoom)); } @Override public void panInsideBounds(JLBounds bounds) { - engine.executeScript(String.format("getMap().panInsideBounds(%s)", + engine.executeScript(String.format("this.map.panInsideBounds(%s)", bounds.toString())); } @Override public void panInside(JLLatLng latLng) { engine.executeScript( - String.format("getMap().panInside(L.latLng(%f, %f))", + String.format("this.map.panInside(L.latLng(%f, %f))", latLng.getLat(), latLng.getLng())); } } diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLGeoJsonLayer.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLGeoJsonLayer.java new file mode 100644 index 0000000..40c27aa --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLGeoJsonLayer.java @@ -0,0 +1,162 @@ +package io.github.makbn.jlmap.fx.layer; + +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLClientToServerTransporter; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.exception.JLException; +import io.github.makbn.jlmap.fx.engine.JLJavaFXClientToServerTransporter; +import io.github.makbn.jlmap.geojson.JLGeoJsonContent; +import io.github.makbn.jlmap.geojson.JLGeoJsonFile; +import io.github.makbn.jlmap.geojson.JLGeoJsonURL; +import io.github.makbn.jlmap.layer.leaflet.LeafletGeoJsonLayerInt; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLGeoJson; +import io.github.makbn.jlmap.model.JLGeoJsonOptions; +import io.github.makbn.jlmap.model.builder.JLGeoJsonObjectBuilder; +import lombok.NonNull; + +import java.io.File; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * JavaFX implementation of the GeoJSON layer for managing geographic data overlays. + *

+ * This implementation provides GeoJSON support for JavaFX-based maps, handling + * the loading, styling, and interaction with geographic data from various sources. + * It leverages JavaFX's WebEngine for JavaScript execution and synchronous communication. + *

+ *

Implementation Details:

+ *
    + *
  • Data Loading: Supports files, URLs, and direct content strings
  • + *
  • JavaScript Bridge: Uses JLJavaFXClientToServerTransporter for callbacks
  • + *
  • Event Handling: Supports click, double-click, add, and remove events
  • + *
  • Thread Model: Executes on JavaFX Application Thread
  • + *
+ *

+ * Thread Safety: All operations must be performed on the JavaFX Application Thread. + *

+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public class JLGeoJsonLayer extends JLLayer implements LeafletGeoJsonLayerInt { + + /** + * URL-based GeoJSON loader for remote data sources + */ + JLGeoJsonURL fromUrl; + + /** File-based GeoJSON loader for local data sources */ + JLGeoJsonFile fromFile; + + /** Content-based GeoJSON loader for direct string input */ + JLGeoJsonContent fromContent; + + /** Atomic counter for generating unique element IDs */ + AtomicInteger idGenerator; + + /** JavaScript-to-Java communication bridge for event callbacks */ + JLClientToServerTransporter clientToServer; + + /** + * Constructs a new JavaFX GeoJSON layer with the specified engine and callback handler. + *

+ * Initializes all data loaders, ID generator, and sets up the JavaScript-to-Java + * communication bridge for handling user interactions and events. + *

+ * + * @param engine the JavaFX web engine for JavaScript execution + * @param callbackHandler the event handler for managing object callbacks + */ + public JLGeoJsonLayer(JLWebEngine engine, JLMapEventHandler callbackHandler) { + super(engine, callbackHandler); + this.fromUrl = new JLGeoJsonURL(); + this.fromFile = new JLGeoJsonFile(); + this.idGenerator = new AtomicInteger(); + this.fromContent = new JLGeoJsonContent(); + // Initialize the JavaScript-to-Java bridge + this.clientToServer = new JLJavaFXClientToServerTransporter(engine::executeScript); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromFile(@NonNull File file) throws JLException { + String json = fromFile.load(file); + return addGeoJson(json, null); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromFile(@NonNull File file, @NonNull JLGeoJsonOptions options) throws JLException { + String json = fromFile.load(file); + return addGeoJson(json, options); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromUrl(@NonNull String url) throws JLException { + String json = fromUrl.load(url); + return addGeoJson(json, null); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromUrl(@NonNull String url, @NonNull JLGeoJsonOptions options) throws JLException { + String json = fromUrl.load(url); + return addGeoJson(json, options); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromContent(@NonNull String content) throws JLException { + String json = fromContent.load(content); + return addGeoJson(json, null); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromContent(@NonNull String content, @NonNull JLGeoJsonOptions options) throws JLException { + String json = fromContent.load(content); + return addGeoJson(json, options); + } + + /** @inheritDoc */ + @Override + public boolean removeGeoJson(@NonNull String id) { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLGeoJson.class, id); + return true; + } + + /** + * Internal method to add GeoJSON content to the map with optional styling. + *

+ * Creates a unique element name, builds the JavaScript representation, and + * registers the object for event callbacks. Sets up standard interaction + * events (click, double-click, add, remove). + *

+ * + * @param geoJsonContent the GeoJSON string content to add + * @param options optional styling and configuration options (may be null) + * @return the created JLGeoJson object + */ + private JLGeoJson addGeoJson(String geoJsonContent, JLGeoJsonOptions options) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLGeoJsonObjectBuilder builder = new JLGeoJsonObjectBuilder() + .setUuid(elementUniqueName) + .setGeoJson(geoJsonContent) + .withGeoJsonOptions(options) + .withBridge(clientToServer) + .setTransporter(getTransporter()) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + }); + JLGeoJson geoJson = builder.buildJLObject(); + engine.executeScript(builder.buildJsElement()); + callbackHandler.addJLObject(elementUniqueName, geoJson); + return geoJson; + } +} diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLLayer.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLLayer.java new file mode 100644 index 0000000..f212d42 --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLLayer.java @@ -0,0 +1,64 @@ +package io.github.makbn.jlmap.fx.layer; + +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.fx.engine.JLJavaFxServerToClientTransporter; +import io.github.makbn.jlmap.layer.leaflet.LeafletLayer; +import io.github.makbn.jlmap.model.JLObject; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Represents the basic layer. + * + * @author Matt Akbarian (@makbn) + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@FieldDefaults(level = AccessLevel.PROTECTED) +public abstract class JLLayer implements LeafletLayer { + JLWebEngine engine; + JLMapEventHandler callbackHandler; + String componentSessionId = "_" + UUID.randomUUID().toString().replace("-", "") + "_"; + + + protected JLLayer(JLWebEngine engine, JLMapEventHandler callbackHandler) { + this.engine = engine; + this.callbackHandler = callbackHandler; + } + + @NotNull + protected String getElementUniqueName(@NonNull Class> markerClass, int id) { + return markerClass.getSimpleName() + componentSessionId + id; + } + + @NonNull + protected final String removeLayerWithUUID(@NonNull String uuid) { + return String.format("this.map.removeLayer(this.%s)", uuid); + } + + + protected @NotNull JLJavaFxServerToClientTransporter getTransporter() { + return new JLJavaFxServerToClientTransporter() { + @Override + public Function serverToClientTransport() { + return transport -> { + // Generate JavaScript method call: this.objectId.methodName(param1,param2,...) + String script = "this.%1$s.%2$s(%3$s)".formatted(transport.self().getJLId(), transport.function(), + transport.params().length > 0 ? Arrays.stream(transport.params()).map(String::valueOf).collect(Collectors.joining(",")) : ""); + return engine.executeScript(script); + }; + } + }; + } + + +} diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLUiLayer.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLUiLayer.java new file mode 100644 index 0000000..dc6b3f1 --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLUiLayer.java @@ -0,0 +1,168 @@ +package io.github.makbn.jlmap.fx.layer; + +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.layer.leaflet.LeafletUILayerInt; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.model.builder.JLImageOverlayBuilder; +import io.github.makbn.jlmap.model.builder.JLMarkerBuilder; +import io.github.makbn.jlmap.model.builder.JLPopupBuilder; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents the UI layer on Leaflet map. + * + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLUiLayer extends JLLayer implements LeafletUILayerInt { + AtomicInteger idGenerator; + + public JLUiLayer(JLWebEngine engine, JLMapEventHandler callbackHandler) { + super(engine, callbackHandler); + this.idGenerator = new AtomicInteger(); + } + + /** + * Add a {{@link JLMarker}} to the map with given text as content and {{@link JLLatLng}} as position. + * + * @param latLng position on the map. + * @param text content of the related popup if available! + * @return the instance of added {{@link JLMarker}} on the map. + */ + @Override + public JLMarker addMarker(JLLatLng latLng, String text, boolean draggable) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLMarkerBuilder markerBuilder = new JLMarkerBuilder() + .setUuid(elementUniqueName) + .setLat(latLng.getLat()) + .setLng(latLng.getLng()) + .setText(text) + .setTransporter(getTransporter()) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.MOVE); + jlCallbackBuilder.on(JLAction.MOVE_START); + jlCallbackBuilder.on(JLAction.MOVE_END); + jlCallbackBuilder.on(JLAction.DRAG); + jlCallbackBuilder.on(JLAction.DRAG_START); + jlCallbackBuilder.on(JLAction.DRAG_END); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + }) + .withOptions(JLOptions.DEFAULT.toBuilder().draggable(draggable).build()); + + engine.executeScript(markerBuilder.buildJsElement()); + JLMarker marker = markerBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, marker); + return marker; + } + + /** + * Remove a {{@link JLMarker}} from the map. + * + * @param id of the marker for removing. + * @return {{@link Boolean#TRUE}} if removed successfully. + */ + @Override + public boolean removeMarker(String id) { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLMarker.class, id); + return true; + } + + /** + * Add a {{@link JLPopup}} to the map with given text as content and + * {@link JLLatLng} as position. + * + * @param latLng position on the map. + * @param text content of the popup. + * @param options see {{@link JLOptions}} for customizing + * @return the instance of added {{@link JLPopup}} on the map. + */ + @Override + public JLPopup addPopup(JLLatLng latLng, String text, JLOptions options) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLPopupBuilder popupBuilder = new JLPopupBuilder() + .setUuid(elementUniqueName) + .setLat(latLng.getLat()) + .setLng(latLng.getLng()) + .setContent(text) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + }) + .setTransporter(getTransporter()); + engine.executeScript(popupBuilder.buildJsElement()); + JLPopup popup = popupBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, popup); + return popup; + } + + /** + * Add popup with {{@link JLOptions#DEFAULT}} options + * + * @see JLUiLayer#addPopup(JLLatLng, String, JLOptions) + */ + @Override + public JLPopup addPopup(JLLatLng latLng, String text) { + return addPopup(latLng, text, JLOptions.DEFAULT); + } + + /** + * Remove a {@link JLPopup} from the map. + * + * @param id of the marker for removing. + * @return true if removed successfully. + */ + @Override + public boolean removePopup(String id) { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLPopup.class, id); + return true; + } + + /** + * Adds an image overlay to the map at the specified bounds with the given image URL and options. + * + * @param bounds the geographical bounds the image is tied to + * @param imageUrl URL of the image to be used as an overlay + * @param options theming options for JLImageOverlay + * @return the instance of added {@link JLImageOverlay} on the map + */ + @Override + public JLImageOverlay addImage(JLBounds bounds, String imageUrl, JLOptions options) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLImageOverlayBuilder imageBuilder = new JLImageOverlayBuilder() + .setUuid(elementUniqueName) + .setImageUrl(imageUrl) + .setBounds(List.of( + new double[]{bounds.getSouthWest().getLat(), bounds.getSouthWest().getLng()}, + new double[]{bounds.getNorthEast().getLat(), bounds.getNorthEast().getLng()} + )) + .setTransporter(getTransporter()) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + }) + .withOptions(options); + engine.executeScript(imageBuilder.buildJsElement()); + JLImageOverlay overlay = imageBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, overlay); + return overlay; + } +} diff --git a/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLVectorLayer.java b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLVectorLayer.java new file mode 100644 index 0000000..9096924 --- /dev/null +++ b/jlmap-fx/src/main/java/io/github/makbn/jlmap/fx/layer/JLVectorLayer.java @@ -0,0 +1,321 @@ +package io.github.makbn.jlmap.fx.layer; + +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.layer.leaflet.LeafletVectorLayerInt; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.model.builder.*; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents the Vector layer on Leaflet map. + * + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLVectorLayer extends JLLayer implements LeafletVectorLayerInt { + AtomicInteger idGenerator; + + public JLVectorLayer(JLWebEngine engine, JLMapEventHandler callbackHandler) { + super(engine, callbackHandler); + this.idGenerator = new AtomicInteger(); + } + + /** + * Drawing polyline overlays on the map with {@link JLOptions#DEFAULT} + * options + * + * @see JLVectorLayer#addPolyline(JLLatLng[], JLOptions) + */ + @Override + public JLPolyline addPolyline(JLLatLng[] vertices) { + return addPolyline(vertices, JLOptions.DEFAULT); + } + + /** + * Drawing polyline overlays on the map. + * + * @param vertices arrays of LatLng points + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLPolyline} to map + */ + @Override + public JLPolyline addPolyline(JLLatLng[] vertices, JLOptions options) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLPolylineBuilder builder = new JLPolylineBuilder() + .setUuid(elementUniqueName) + .withOptions(options) + .addLatLngs(Arrays.stream(vertices).map(latLng -> new double[]{latLng.getLat(), latLng.getLng()}).toList()) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + }) + .setTransporter(getTransporter()); + engine.executeScript(builder.buildJsElement()); + JLPolyline polyline = builder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, polyline); + return polyline; + } + + /** + * Remove a polyline from the map by id. + * + * @param id of polyline + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removePolyline(String id) { + engine.executeScript(removeLayerWithUUID(id)); + + callbackHandler.remove(JLPolyline.class, id); + callbackHandler.remove(JLMultiPolyline.class, id); + return true; + } + + /** + * Drawing multi polyline overlays on the map with + * {@link JLOptions#DEFAULT} options. + * + * @return the added {@link JLMultiPolyline} to map + * @see JLVectorLayer#addMultiPolyline(JLLatLng[][], JLOptions) + */ + @Override + public JLMultiPolyline addMultiPolyline(JLLatLng[][] vertices) { + return addMultiPolyline(vertices, JLOptions.DEFAULT); + } + + /** + * Drawing MultiPolyline shape overlays on the map with + * multi-dimensional array. + * + * @param vertices arrays of LatLng points + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLMultiPolyline} to map + */ + @Override + public JLMultiPolyline addMultiPolyline(JLLatLng[][] vertices, JLOptions options) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLMultiPolylineBuilder builder = new JLMultiPolylineBuilder() + .setUuid(elementUniqueName) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + }) + .setTransporter(getTransporter()); + for (JLLatLng[] group : vertices) { + List groupList = new ArrayList<>(); + for (JLLatLng v : group) { + groupList.add(new double[]{v.getLat(), v.getLng()}); + } + builder.addLine(groupList); + } + engine.executeScript(builder.buildJsElement()); + JLMultiPolyline multiPolyline = builder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, multiPolyline); + return multiPolyline; + } + + /** + * Remove a multi polyline from the map by id. + * + * @param id of multi polyline + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removeMultiPolyline(String id) { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLMultiPolyline.class, id); + return true; + } + + /** + * Drawing polygon overlays on the map with {@link JLOptions#DEFAULT} + * options. + * + * @see JLVectorLayer#addPolygon(JLLatLng[][][], JLOptions) + */ + @Override + public JLPolygon addPolygon(JLLatLng[][][] vertices, JLOptions options) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLPolygonBuilder builder = new JLPolygonBuilder() + .setUuid(elementUniqueName) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + }) + .setTransporter(getTransporter()); + for (JLLatLng[][] group : vertices) { + List groupList = new ArrayList<>(); + for (JLLatLng[] ring : group) { + for (JLLatLng v : ring) { + groupList.add(new double[]{v.getLat(), v.getLng()}); + } + } + builder.addLatLngGroup(groupList); + } + engine.executeScript(builder.buildJsElement()); + JLPolygon polygon = builder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, polygon); + return polygon; + } + + /** + * Drawing polygon overlays on the map with {@link JLOptions#DEFAULT} + * options. + * + * @see JLVectorLayer#addPolygon(JLLatLng[][][], JLOptions) + */ + @Override + public JLPolygon addPolygon(JLLatLng[][][] vertices) { + return addPolygon(vertices, JLOptions.DEFAULT); + } + + /** + * Remove a polygon from the map by id. + * + * @param id of polygon + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removePolygon(String id) { + String result = engine.executeScript(removeLayerWithUUID(id)).toString(); + + callbackHandler.remove(JLPolygon.class, id); + + return Boolean.parseBoolean(result); + } + + /** + * Drawing circle overlays on the map. + * + * @param center latLng point of circle + * @param radius radius of circle in meters + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLCircle} to map + */ + @Override + public JLCircle addCircle(JLLatLng center, int radius, JLOptions options) { + var elementUniqueName = getElementUniqueName(JLCircle.class, idGenerator.incrementAndGet()); + + var circleBuilder = new JLCircleBuilder() + .setUuid(elementUniqueName) + .setLat(center.getLat()) + .setLng(center.getLng()) + .setRadius(radius) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.MOVE); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + }); + + engine.executeScript(circleBuilder.buildJsElement()); + var circle = circleBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, circle); + return circle; + } + + /** + * Drawing circle overlays on the map with {@link JLOptions#DEFAULT} + * options. + * + * @see JLVectorLayer#addCircle(JLLatLng, int, JLOptions) + */ + @Override + public JLCircle addCircle(JLLatLng center) { + return addCircle(center, 1000, JLOptions.DEFAULT); + } + + /** + * Remove a circle from the map by id. + * + * @param id of circle + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removeCircle(String id) { + String result = engine.executeScript(removeLayerWithUUID(id)).toString(); + + callbackHandler.remove(JLCircle.class, id); + + return Boolean.parseBoolean(result); + } + + /** + * Drawing circle marker overlays on the map. + * + * @param center latLng point of circle marker + * @param radius radius of circle marker in pixels + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLCircleMarker} to map + */ + @Override + public JLCircleMarker addCircleMarker(JLLatLng center, int radius, JLOptions options) { + var elementUniqueName = getElementUniqueName(JLCircleMarker.class, idGenerator.incrementAndGet()); + + var circleMarkerBuilder = new JLCircleMarkerBuilder() + .setUuid(elementUniqueName) + .setLat(center.getLat()) + .setLng(center.getLng()) + .setRadius(radius) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.MOVE); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + }); + + engine.executeScript(circleMarkerBuilder.buildJsElement()); + var circleMarker = circleMarkerBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, circleMarker); + return circleMarker; + } + + /** + * Drawing circle marker overlays on the map with {@link JLOptions#DEFAULT} + * options. + * + * @see JLVectorLayer#addCircleMarker(JLLatLng, int, JLOptions) + */ + @Override + public JLCircleMarker addCircleMarker(JLLatLng center) { + return addCircleMarker(center, JLProperties.DEFAULT_CIRCLE_MARKER_RADIUS, JLOptions.DEFAULT); + } + + /** + * Remove a circle marker from the map by id. + * + * @param id of circle marker + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removeCircleMarker(String id) { + String result = engine.executeScript(removeLayerWithUUID(id)).toString(); + + callbackHandler.remove(JLCircleMarker.class, id); + + return Boolean.parseBoolean(result); + } +} diff --git a/jlmap-fx/src/main/java/module-info.java b/jlmap-fx/src/main/java/module-info.java new file mode 100644 index 0000000..246b859 --- /dev/null +++ b/jlmap-fx/src/main/java/module-info.java @@ -0,0 +1,45 @@ +module io.github.makbn.jlmap.fx { + // API dependency + requires io.github.makbn.jlmap.api; + + // JavaFX modules + requires javafx.controls; + requires javafx.base; + requires javafx.swing; + requires javafx.web; + requires javafx.graphics; + + // JDK modules + requires jdk.jsobject; + + // Logging + requires org.slf4j; + + // JSON processing + requires com.google.gson; + requires com.fasterxml.jackson.databind; + + // Annotations + requires static org.jetbrains.annotations; + requires static lombok; + requires com.j2html; + + // Exports for public API + exports io.github.makbn.jlmap.fx; + exports io.github.makbn.jlmap.fx.demo; + exports io.github.makbn.jlmap.fx.element.menu; + exports io.github.makbn.jlmap.fx.engine; + exports io.github.makbn.jlmap.fx.layer; + exports io.github.makbn.jlmap.fx.internal; + + // Service providers + provides io.github.makbn.jlmap.element.menu.JLContextMenuMediator + with io.github.makbn.jlmap.fx.element.menu.JavaFXContextMenuMediator; + + // Opens for reflection (if needed by frameworks) + opens io.github.makbn.jlmap.fx to javafx.graphics, io.github.makbn.jlmap.fx.test; + opens io.github.makbn.jlmap.fx.engine to javafx.graphics, io.github.makbn.jlmap.fx.test; + opens io.github.makbn.jlmap.fx.demo to javafx.graphics, io.github.makbn.jlmap.fx.test; + opens io.github.makbn.jlmap.fx.layer to javafx.graphics, io.github.makbn.jlmap.fx.test; + opens io.github.makbn.jlmap.fx.internal to javafx.graphics, io.github.makbn.jlmap.fx.test; +} \ No newline at end of file diff --git a/jlmap-fx/src/main/resources/META-INF/services/io.github.makbn.jlmap.element.menu.JLContextMenuMediator b/jlmap-fx/src/main/resources/META-INF/services/io.github.makbn.jlmap.element.menu.JLContextMenuMediator new file mode 100644 index 0000000..7eacb15 --- /dev/null +++ b/jlmap-fx/src/main/resources/META-INF/services/io.github.makbn.jlmap.element.menu.JLContextMenuMediator @@ -0,0 +1 @@ +io.github.makbn.jlmap.fx.element.menu.JavaFXContextMenuMediator \ No newline at end of file diff --git a/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/element/menu/JavaFXContextMenuMediatorTest.java b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/element/menu/JavaFXContextMenuMediatorTest.java new file mode 100644 index 0000000..67c69e1 --- /dev/null +++ b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/element/menu/JavaFXContextMenuMediatorTest.java @@ -0,0 +1,271 @@ +package io.github.makbn.jlmap.fx.test.element.menu; + +import io.github.makbn.jlmap.element.menu.JLContextMenu; +import io.github.makbn.jlmap.fx.JLMapView; +import io.github.makbn.jlmap.fx.element.menu.JavaFXContextMenuMediator; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMarker; +import javafx.scene.web.WebView; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.testfx.framework.junit5.ApplicationExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for JavaFXContextMenuMediator. + * Tests the context menu mediator functionality for JavaFX implementation. + * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@ExtendWith({MockitoExtension.class, ApplicationExtension.class}) +class JavaFXContextMenuMediatorTest { + + @Mock + private JLMapView mockMapView; + + @Mock + private WebView mockWebView; + + private JavaFXContextMenuMediator mediator; + + @BeforeEach + void setUp() { + mediator = new JavaFXContextMenuMediator(); + // Note: Stubbing is only added when needed in tests that use it + } + + // === Constructor and Basic Tests === + + @Test + void constructor_shouldInitializeSuccessfully() { + // When + JavaFXContextMenuMediator newMediator = new JavaFXContextMenuMediator(); + + // Then + assertThat(newMediator).isNotNull(); + assertThat(newMediator.getName()).isEqualTo("JavaFX Context Menu Mediator"); + } + + @Test + void getName_shouldReturnCorrectName() { + // When + String name = mediator.getName(); + + // Then + assertThat(name).isEqualTo("JavaFX Context Menu Mediator"); + } + + @Test + void supportsObjectType_shouldReturnTrueForAllTypes() { + // When/Then + assertThat(mediator.supportsObjectType(JLMarker.class)).isTrue(); + } + + // === showContextMenu Tests === + + @Test + void showContextMenu_withObjectWithoutContextMenu_shouldNotShowMenu() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + // When + mediator.showContextMenu(mockMapView, marker, 100, 100); + + // Then - No menu should be created since marker has no context menu + // This is verified by the lack of exceptions and the method completing + assertThat(marker.getContextMenu()).isNull(); + } + + @Test + void showContextMenu_withDisabledContextMenu_shouldNotShowMenu() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("test", "Test Item"); + marker.setContextMenuEnabled(false); + + // When + mediator.showContextMenu(mockMapView, marker, 100, 100); + + // Then - Menu should not be shown because it's disabled + assertThat(marker.isContextMenuEnabled()).isFalse(); + } + + @Test + void showContextMenu_withEnabledContextMenuButNoItems_shouldNotShowMenu() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + marker.addContextMenu(); // Empty context menu + + // When + mediator.showContextMenu(mockMapView, marker, 100, 100); + + // Then - Menu should not be shown because it has no items + assertThat(marker.hasContextMenu()).isFalse(); + } + + @Test + void showContextMenu_withValidContextMenu_shouldPopulateMenuItems() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit") + .addItem("delete", "Delete") + .addItem("info", "Info"); + + // Note: Can't fully test showing the menu in unit tests without a JavaFX scene + // This test verifies the setup is correct + assertThat(marker.hasContextMenu()).isTrue(); + assertThat(marker.isContextMenuEnabled()).isTrue(); + assertThat(contextMenu.getItemCount()).isEqualTo(3); + } + + // === hideContextMenu Tests === + + @Test + void hideContextMenu_withNullContextMenu_shouldNotThrowException() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + // When/Then - Should not throw exception + mediator.hideContextMenu(mockMapView, marker); + } + + @Test + void hideContextMenu_shouldClearMenuItems() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("test", "Test Item"); + + // When + mediator.hideContextMenu(mockMapView, marker); + + // Then - Menu should be hidden (can't verify fully without JavaFX scene) + // But we can verify the marker still has its context menu + assertThat(marker.getContextMenu()).isNotNull(); + } + + // === Multiple showContextMenu Calls === + + @Test + void showContextMenu_calledMultipleTimes_shouldMaintainContextMenuState() { + // Given + JLMarker marker1 = createMarkerWithContextMenu("marker1", "Item 1"); + JLMarker marker2 = createMarkerWithContextMenu("marker2", "Item 2"); + + // When/Then - Verify markers maintain their context menu state + // Note: Cannot test actual menu reuse without full JavaFX UI context + assertThat(marker1.hasContextMenu()).isTrue(); + assertThat(marker2.hasContextMenu()).isTrue(); + } + + // === Helper Methods === + + private JLMarker createMarkerWithContextMenu(String id, String menuItemText) { + JLMarker marker = JLMarker.builder() + .id(id) + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test Marker") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem(menuItemText, menuItemText); + + return marker; + } + + // === Context Menu Item Tests === + + @Test + void showContextMenu_withMenuItemWithIcon_shouldHandleIcon() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit", "https://img.icons8.com/material-outlined/24/000000/edit--v1.png"); + + // When/Then - Should not throw exception + // Note: Full verification requires JavaFX scene + assertThat(contextMenu.getItem("edit").getIcon()).isNotNull(); + } + + @Test + void showContextMenu_withMenuItemWithoutIcon_shouldHandleNullIcon() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit", null); + + // When/Then - Should not throw exception + assertThat(contextMenu.getItem("edit").getIcon()).isNull(); + } + + @Test + void showContextMenu_withMenuItemWithBlankIcon_shouldHandleBlankIcon() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit", ""); + + // When/Then - Should not throw exception + assertThat(contextMenu.getItem("edit").getIcon()).isEmpty(); + } +} diff --git a/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/engine/JLJavaFXEngineTest.java b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/engine/JLJavaFXEngineTest.java new file mode 100644 index 0000000..aef0dda --- /dev/null +++ b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/engine/JLJavaFXEngineTest.java @@ -0,0 +1,73 @@ +package io.github.makbn.jlmap.fx.test.engine; + +import io.github.makbn.jlmap.fx.engine.JLJavaFXEngine; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test for JLJavaFXEngine class. + * Note: These tests focus on testing the constructor and basic functionality without JavaFX mocking. + */ +class JLJavaFXEngineTest { + + @Test + void constructor_withNullWebEngine_shouldAcceptNullEngine() { + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLJavaFXEngine engine = new JLJavaFXEngine(null); + assertThat(engine).isNotNull(); + } + + @Test + void jlJavaFXEngine_shouldExtendJLWebEngine() { + // This test verifies the inheritance hierarchy without requiring JavaFX initialization + Class engineClass = JLJavaFXEngine.class; + + // Then + assertThat(engineClass.getSuperclass().getSimpleName()).isEqualTo("JLWebEngine"); + } + + @Test + void jlJavaFXEngine_shouldHaveCorrectConstructorParameter() { + // This test verifies the constructor exists with correct parameter types + boolean hasCorrectConstructor = false; + + try { + JLJavaFXEngine.class.getConstructor(javafx.scene.web.WebEngine.class); + hasCorrectConstructor = true; + } catch (NoSuchMethodException e) { + // Constructor not found + } + + // Then + assertThat(hasCorrectConstructor).isTrue(); + } + + @Test + void jlJavaFXEngine_shouldImplementRequiredMethods() { + // This test verifies required methods exist without requiring JavaFX initialization + Class engineClass = JLJavaFXEngine.class; + + boolean hasExecuteScriptMethod = false; + boolean hasGetStatusMethod = false; + + try { + engineClass.getDeclaredMethod("executeScript", String.class, Class.class); + hasExecuteScriptMethod = true; + } catch (NoSuchMethodException e) { + // Method not found + } + + try { + engineClass.getDeclaredMethod("getStatus"); + hasGetStatusMethod = true; + } catch (NoSuchMethodException e) { + // Method not found + } + + // Then + assertThat(hasExecuteScriptMethod).isTrue(); + assertThat(hasGetStatusMethod).isTrue(); + } +} \ No newline at end of file diff --git a/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/integration/JLMapViewIntegrationTest.java b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/integration/JLMapViewIntegrationTest.java new file mode 100644 index 0000000..cc38e95 --- /dev/null +++ b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/integration/JLMapViewIntegrationTest.java @@ -0,0 +1,670 @@ +package io.github.makbn.jlmap.fx.test.integration; + +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.fx.JLMapView; +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMapOption; +import io.github.makbn.jlmap.model.JLOptions; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Background; +import javafx.stage.Stage; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class JLMapViewIntegrationTest extends ApplicationTest { + JLMapView map; + + @Override + public void start(Stage stage) { + map = JLMapView + .builder() + .jlMapProvider(JLMapProvider.MAP_TILER + .parameter(new JLMapOption.Parameter("key", "rNGhTaIpQWWH7C6QGKzF")) + .build()) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) // Paris + .showZoomController(true) + .startCoordinate(JLLatLng.builder() + .lat(51.044) + .lng(-114.07) + .build()) + .build(); + AnchorPane root = new AnchorPane(map); + root.setBackground(Background.EMPTY); + root.setMinHeight(JLProperties.INIT_MIN_HEIGHT_STAGE); + root.setMinWidth(JLProperties.INIT_MIN_WIDTH_STAGE); + Scene scene = new Scene(root); + + stage.setScene(scene); + stage.show(); + stage.toFront(); + } + + + @Test + void jlMapView_addMarker_shouldAddMarkerAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + // Call your existing Java→JS bridge function to add marker + map.getUiLayer().addMarker(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "A Marker", false); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + // Inspect Leaflet state + Object markerCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Marker).length"""); + assertThat((Number) markerCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for marker addition"); + } + } + + @Test + void jlMapView_addPopup_shouldAddPopupAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + map.getUiLayer().addPopup(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Popup"); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object popupCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Popup).length"""); + assertThat((Number) popupCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for popup addition"); + } + } + + @Test + void jlMapView_addImageOverlay_shouldAddImageOverlayAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + map.getUiLayer().addImage( + JLBounds.builder() + .southWest(JLLatLng.builder().lat(50.0).lng(-120.0).build()) + .northEast(JLLatLng.builder().lat(55.0).lng(-110.0).build()) + .build(), + "https://example.com/image.png", + JLOptions.DEFAULT + ); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object imageOverlayCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.ImageOverlay).length"""); + assertThat((Number) imageOverlayCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for image overlay addition"); + } + } + + @Test + void jlMapView_addPolyline_shouldAddPolylineAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + map.getVectorLayer().addPolyline(new JLLatLng[]{ + JLLatLng.builder().lat(51.509).lng(-0.08).build(), + JLLatLng.builder().lat(51.503).lng(-0.06).build(), + JLLatLng.builder().lat(51.51).lng(-0.047).build() + }); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object polylineCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Polyline).length"""); + assertThat((Number) polylineCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for polyline addition"); + } + } + + @Test + void jlMapView_addMultiPolyline_shouldAddMultiPolylineAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + map.getVectorLayer().addMultiPolyline(new JLLatLng[][]{ + { + JLLatLng.builder().lat(41.509).lng(20.08).build(), + JLLatLng.builder().lat(31.503).lng(-10.06).build() + }, + { + JLLatLng.builder().lat(51.509).lng(10.08).build(), + JLLatLng.builder().lat(55.503).lng(15.06).build() + } + }); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object polylineCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Polyline).length"""); + assertThat((Number) polylineCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for multi-polyline addition"); + } + } + + @Test + void jlMapView_addPolygon_shouldAddPolygonAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + map.getVectorLayer().addPolygon(new JLLatLng[][][]{ + {{ + JLLatLng.builder().lat(37.0).lng(-109.05).build(), + JLLatLng.builder().lat(41.0).lng(-109.03).build(), + JLLatLng.builder().lat(41.0).lng(-102.05).build(), + JLLatLng.builder().lat(37.0).lng(-102.04).build() + }} + }); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object polygonCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Polygon).length"""); + assertThat((Number) polygonCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for polygon addition"); + } + } + + @Test + void jlMapView_addCircle_shouldAddCircleAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + map.getVectorLayer().addCircle(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build()); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object circleCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Circle).length"""); + assertThat((Number) circleCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for circle addition"); + } + } + + @Test + void jlMapView_addCircleMarker_shouldAddCircleMarkerAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + map.getVectorLayer().addCircleMarker(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build()); + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object circleMarkerCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.CircleMarker).length"""); + assertThat((Number) circleMarkerCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for circle marker addition"); + } + } + + @Test + void jlMapView_addGeoJsonFromContent_shouldAddGeoJsonAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + try { + map.getGeoJsonLayer().addFromContent(""" + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.4050, 52.5200] + }, + "properties": { + "name": "Berlin" + } + } + ] + }"""); + } catch (Exception e) { + // Handle exception in test + } + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + Object geoJsonCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.GeoJSON).length"""); + assertThat((Number) geoJsonCount).isEqualTo(1); + }); + } else { + throw new TimeoutException("Timed out waiting for GeoJSON addition"); + } + } + + @Test + void jlMapView_markerWithContextMenu_shouldHaveContextMenu() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + marker.addContextMenu() + .addItem("edit", "Edit Marker") + .addItem("delete", "Delete Marker") + .addItem("info", "Show Info"); + + markerRef[0] = marker; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + assertThat(marker.hasContextMenu()).isTrue(); + assertThat(marker.isContextMenuEnabled()).isTrue(); + assertThat(marker.getContextMenu()).isNotNull(); + assertThat(marker.getContextMenu().getItemCount()).isEqualTo(3); + }); + } else { + throw new TimeoutException("Timed out waiting for marker with context menu"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportEnableDisable() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + marker.addContextMenu() + .addItem("edit", "Edit Marker"); + + marker.setContextMenuEnabled(false); + markerRef[0] = marker; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + assertThat(marker.isContextMenuEnabled()).isFalse(); + + marker.setContextMenuEnabled(true); + assertThat(marker.isContextMenuEnabled()).isTrue(); + }); + } else { + throw new TimeoutException("Timed out waiting for marker context menu enable/disable test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportAddingRemovingItems() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + contextMenu.addItem("edit", "Edit Marker") + .addItem("delete", "Delete Marker") + .addItem("info", "Show Info"); + + markerRef[0] = marker; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + assertThat(contextMenu.getItemCount()).isEqualTo(3); + + contextMenu.removeItem("edit"); + assertThat(contextMenu.getItemCount()).isEqualTo(2); + assertThat(contextMenu.getItem("edit")).isNull(); + + contextMenu.addItem("new", "New Item"); + assertThat(contextMenu.getItemCount()).isEqualTo(3); + assertThat(contextMenu.getItem("new")).isNotNull(); + + contextMenu.clearItems(); + assertThat(contextMenu.getItemCount()).isZero(); + assertThat(marker.hasContextMenu()).isFalse(); + }); + } else { + throw new TimeoutException("Timed out waiting for marker context menu operations test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportMenuItemVisibility() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + + // Add visible item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("visible") + .text("Visible Item") + .visible(true) + .build()); + + // Add hidden item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("hidden") + .text("Hidden Item") + .visible(false) + .build()); + + markerRef[0] = marker; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + assertThat(contextMenu.getItemCount()).isEqualTo(2); + assertThat(contextMenu.getVisibleItemCount()).isEqualTo(1); + assertThat(contextMenu.hasVisibleItems()).isTrue(); + }); + } else { + throw new TimeoutException("Timed out waiting for marker context menu visibility test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportMenuItemUpdate() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + contextMenu.addItem("edit", "Edit Marker"); + + markerRef[0] = marker; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + // Update existing item + io.github.makbn.jlmap.element.menu.JLMenuItem updatedItem = + io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("edit") + .text("Edit Properties") + .icon("https://example.com/edit.png") + .build(); + + contextMenu.updateItem(updatedItem); + + io.github.makbn.jlmap.element.menu.JLMenuItem item = contextMenu.getItem("edit"); + assertThat(item).isNotNull(); + assertThat(item.getText()).isEqualTo("Edit Properties"); + assertThat(item.getIcon()).isEqualTo("https://example.com/edit.png"); + }); + } else { + throw new TimeoutException("Timed out waiting for marker context menu update test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportListenerCallback() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + final boolean[] listenerInvoked = {false}; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + contextMenu.addItem("test", "Test Item") + .setOnMenuItemListener(item -> { + listenerInvoked[0] = true; + }); + + markerRef[0] = marker; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + // Simulate menu item selection + io.github.makbn.jlmap.element.menu.JLMenuItem item = contextMenu.getItem("test"); + contextMenu.handleMenuItemSelection(item); + + assertThat(listenerInvoked[0]).isTrue(); + }); + } else { + throw new TimeoutException("Timed out waiting for marker context menu listener test"); + } + } + + @Test + void jlMapView_multipleMarkersWithContextMenu_shouldMaintainIndependentMenus() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] marker1Ref = new io.github.makbn.jlmap.model.JLMarker[1]; + final io.github.makbn.jlmap.model.JLMarker[] marker2Ref = new io.github.makbn.jlmap.model.JLMarker[1]; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker1 = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Marker 1", false); + + io.github.makbn.jlmap.model.JLMarker marker2 = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Marker 2", false); + + marker1.addContextMenu() + .addItem("edit", "Edit Marker 1") + .addItem("delete", "Delete Marker 1"); + + marker2.addContextMenu() + .addItem("view", "View Marker 2") + .addItem("share", "Share Marker 2"); + + marker1Ref[0] = marker1; + marker2Ref[0] = marker2; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker1 = marker1Ref[0]; + io.github.makbn.jlmap.model.JLMarker marker2 = marker2Ref[0]; + + assertThat(marker1.hasContextMenu()).isTrue(); + assertThat(marker2.hasContextMenu()).isTrue(); + + assertThat(marker1.getContextMenu().getItemCount()).isEqualTo(2); + assertThat(marker2.getContextMenu().getItemCount()).isEqualTo(2); + + assertThat(marker1.getContextMenu().getItem("edit")).isNotNull(); + assertThat(marker1.getContextMenu().getItem("view")).isNull(); + + assertThat(marker2.getContextMenu().getItem("view")).isNotNull(); + assertThat(marker2.getContextMenu().getItem("edit")).isNull(); + }); + } else { + throw new TimeoutException("Timed out waiting for multiple markers context menu test"); + } + } + + @Test + void jlMapView_markerContextMenu_withIconsAndVariousStates() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + + // Item with icon + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("edit") + .text("Edit") + .icon("https://img.icons8.com/material-outlined/24/000000/edit--v1.png") + .enabled(true) + .visible(true) + .build()); + + // Disabled item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("delete") + .text("Delete") + .enabled(false) + .visible(true) + .build()); + + // Hidden item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("admin") + .text("Admin Action") + .enabled(true) + .visible(false) + .build()); + + markerRef[0] = marker; + latch.countDown(); + }); + + if (latch.await(5, TimeUnit.SECONDS)) { + Platform.runLater(() -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + assertThat(contextMenu.getItemCount()).isEqualTo(3); + assertThat(contextMenu.getVisibleItemCount()).isEqualTo(2); + + io.github.makbn.jlmap.element.menu.JLMenuItem editItem = contextMenu.getItem("edit"); + assertThat(editItem.isEnabled()).isTrue(); + assertThat(editItem.isVisible()).isTrue(); + assertThat(editItem.getIcon()).isNotNull(); + + io.github.makbn.jlmap.element.menu.JLMenuItem deleteItem = contextMenu.getItem("delete"); + assertThat(deleteItem.isEnabled()).isFalse(); + assertThat(deleteItem.isVisible()).isTrue(); + + io.github.makbn.jlmap.element.menu.JLMenuItem adminItem = contextMenu.getItem("admin"); + assertThat(adminItem.isEnabled()).isTrue(); + assertThat(adminItem.isVisible()).isFalse(); + }); + } else { + throw new TimeoutException("Timed out waiting for marker context menu with various states test"); + } + } + +} diff --git a/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/internal/JLFxMapRendererTest.java b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/internal/JLFxMapRendererTest.java new file mode 100644 index 0000000..2c9f389 --- /dev/null +++ b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/internal/JLFxMapRendererTest.java @@ -0,0 +1,227 @@ +package io.github.makbn.jlmap.fx.test.internal; + +import io.github.makbn.jlmap.fx.internal.JLFxMapRenderer; +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMapOption; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.provider.Arguments; + +import java.util.Set; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class JLFxMapRendererTest { + + private JLFxMapRenderer renderer; + + private static Stream mapOptionVariations() { + return Stream.of( + Arguments.of( + createMapOptionWithCoords(45.0, -120.0, 10, true, JLMapProvider.getDefault()), + "45.0", "-120.0", "10", true, JLMapProvider.getDefault().getUrl() + ), + Arguments.of( + createMapOptionWithCoords(0.0, 0.0, 1, false, JLMapProvider.getDefault()), + "0.0", "0.0", "1", false, JLMapProvider.getDefault().getUrl() + ), + Arguments.of( + createMapOptionWithCoords(-33.8688, 151.2093, 15, true, JLMapProvider.getDefault()), + "-33.8688", "151.2093", "15", true, JLMapProvider.getDefault().getUrl() + ) + ); + } + + private static JLMapOption createDefaultMapOption() { + return createMapOptionWithCoords(52.5200, 13.4050, 13, true, JLMapProvider.getDefault()); + } + + private static JLMapOption createMapOptionWithCoords(double lat, double lng, int zoom, boolean zoomControl, JLMapProvider provider) { + return JLMapOption.builder() + .startCoordinate(JLLatLng.builder().lat(lat).lng(lng).build()) + .jlMapProvider(provider) + .additionalParameter(Set.of( + new JLMapOption.Parameter("zoomControl", String.valueOf(zoomControl)), + new JLMapOption.Parameter("initialZoom", String.valueOf(zoom)) + )) + .build(); + } + + @BeforeEach + void setUp() { + renderer = new JLFxMapRenderer(); + } + + @Test + void render_shouldGenerateValidHtmlDocument() { + // Given + JLMapOption option = createDefaultMapOption(); + + // When + String html = renderer.render(option); + + // Then + assertThat(html).isNotNull().isNotEmpty(); + assertThat(html).contains(""); + assertThat(html).contains(""); + assertThat(html).contains("JLMap Java - Leaflet"); + assertThat(html).contains(""); + } + + @Test + void render_shouldIncludeLeafletCssAndJavascript() { + // Given + JLMapOption option = createDefaultMapOption(); + + // When + String html = renderer.render(option); + + // Then + assertThat(html).contains("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"); + assertThat(html).contains("https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"); + assertThat(html).contains("https://cdn.jsdelivr.net/npm/leaflet-providers@2.0.0/leaflet-providers.min.js"); + } + + @Test + void render_shouldIncludeLeafletIntegrityAttributes() { + // Given + JLMapOption option = createDefaultMapOption(); + + // When + String html = renderer.render(option); + + // Then + assertThat(html).contains("sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="); + assertThat(html).contains("sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="); + assertThat(html).contains("crossorigin=\"\""); + } + + @Test + void render_shouldIncludeMapContainerDiv() { + // Given + JLMapOption option = createDefaultMapOption(); + + // When + String html = renderer.render(option); + + // Then + assertThat(html).contains("
eventHandler('click', 'map', 'main_map'"); + assertThat(html).contains("this.map.on('move', e => eventHandler('move', 'map', 'main_map'"); + assertThat(html).contains("this.map.on('movestart', e => eventHandler('movestart', 'map', 'main_map'"); + assertThat(html).contains("this.map.on('moveend', e => eventHandler('moveend', 'map', 'main_map'"); + assertThat(html).contains("this.map.on('zoom', e => eventHandler('zoom', 'map', 'main_map'"); + } + + @Test + void render_shouldIncludeMapSetup() { + // Given + JLMapOption option = createDefaultMapOption(); + + // When + String html = renderer.render(option); + + // Then + assertThat(html).contains("this.jlMapElement = document.querySelector('#jl-map-view');"); + assertThat(html).contains("this.outr_eventHandler = eventHandler;"); + assertThat(html).contains("this.map.jlid = 'main_map';"); + assertThat(html).contains("this.jlMapElement.$server = {"); + assertThat(html).contains("eventHandler: (functionType, jlType, uuid, param1, param2, param3)"); + } + + @Test + void render_shouldSetBodyStyle() { + // Given + JLMapOption option = createDefaultMapOption(); + + // When + String html = renderer.render(option); + + // Then + assertThat(html).contains("margin: 0; background-color: #191a1a;"); + } + + @Test + void render_shouldIncludeMetaTags() { + // Given + JLMapOption option = createDefaultMapOption(); + + // When + String html = renderer.render(option); + + // Then + assertThat(html).contains(" engine; + + @Mock + private JLMapEventHandler callbackHandler; + + private JLControlLayer controlLayer; + + @BeforeEach + void setUp() { + controlLayer = new JLControlLayer(engine, callbackHandler); + } + + // === Constructor Tests === + + @Test + void constructor_withNullEngine_shouldAcceptNullEngine() { + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLControlLayer layer = new JLControlLayer(null, callbackHandler); + assertThat(layer).isNotNull(); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLControlLayer layer = new JLControlLayer(engine, null); + assertThat(layer).isNotNull(); + } + + @Test + void controlLayer_shouldExtendJLLayer() { + // This test verifies the inheritance hierarchy + Class layerClass = JLControlLayer.class; + + // Then + assertThat(layerClass.getSuperclass().getSimpleName()).isEqualTo("JLLayer"); + } + + // === Zoom Tests === + + @Test + void zoomIn_shouldExecuteCorrectScript() { + // Given + int delta = 2; + + // When + controlLayer.zoomIn(delta); + + // Then + verify(engine).executeScript("this.map.zoomIn(2)"); + } + + @Test + void zoomOut_shouldExecuteCorrectScript() { + // Given + int delta = 3; + + // When + controlLayer.zoomOut(delta); + + // Then + verify(engine).executeScript("this.map.zoomOut(3)"); + } + + @Test + void setZoom_shouldExecuteCorrectScript() { + // Given + int zoomLevel = 10; + + // When + controlLayer.setZoom(zoomLevel); + + // Then + verify(engine).executeScript("this.map.setZoom(10)"); + } + + @Test + void setZoomAround_shouldExecuteCorrectScript() { + // Given + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int zoom = 15; + + // When + controlLayer.setZoomAround(latLng, zoom); + + // Then + verify(engine).executeScript("this.map.setZoomAround(L.latLng(52.520000, 13.405000), 15)"); + } + + @Test + void setZoomAround_withNullLatLng_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> controlLayer.setZoomAround(null, 10)) + .isInstanceOf(NullPointerException.class); + } + + // === Bounds Tests === + + @Test + void fitBounds_shouldExecuteCorrectScript() { + // Given + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + + // When + controlLayer.fitBounds(bounds); + + // Then + verify(engine).executeScript(argThat(script -> + script.startsWith("this.map.fitBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void fitBounds_withNullBounds_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> controlLayer.fitBounds(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void fitWorld_shouldExecuteCorrectScript() { + // When + controlLayer.fitWorld(); + + // Then + verify(engine).executeScript("this.map.fitWorld()"); + } + + @Test + void setMaxBounds_shouldExecuteCorrectScript() { + // Given + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(45.0).lng(-120.0).build()) + .northEast(JLLatLng.builder().lat(50.0).lng(-110.0).build()) + .build(); + + // When + controlLayer.setMaxBounds(bounds); + + // Then + verify(engine).executeScript(argThat(script -> + script.startsWith("this.map.setMaxBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void setMaxBounds_withNullBounds_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> controlLayer.setMaxBounds(null)) + .isInstanceOf(NullPointerException.class); + } + + // === Pan Tests === + + @Test + void panTo_shouldExecuteCorrectScript() { + // Given + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When + controlLayer.panTo(latLng); + + // Then + verify(engine).executeScript("this.map.panTo(L.latLng(52.520000, 13.405000))"); + } + + @Test + void panTo_withNullLatLng_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> controlLayer.panTo(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void flyTo_shouldExecuteCorrectScript() { + // Given + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int zoom = 12; + + // When + controlLayer.flyTo(latLng, zoom); + + // Then + verify(engine).executeScript("this.map.flyTo(L.latLng(52.520000, 13.405000), 12)"); + } + + @Test + void flyTo_withNullLatLng_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> controlLayer.flyTo(null, 10)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void flyToBounds_shouldExecuteCorrectScript() { + // Given + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + + // When + controlLayer.flyToBounds(bounds); + + // Then + verify(engine).executeScript(argThat(script -> + script.startsWith("this.map.flyToBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void panInsideBounds_shouldExecuteCorrectScript() { + // Given + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + + // When + controlLayer.panInsideBounds(bounds); + + // Then + verify(engine).executeScript(argThat(script -> + script.startsWith("this.map.panInsideBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void panInside_shouldExecuteCorrectScript() { + // Given + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When + controlLayer.panInside(latLng); + + // Then + verify(engine).executeScript("this.map.panInside(L.latLng(52.520000, 13.405000))"); + } + + // === Zoom Limits Tests === + + @Test + void setMinZoom_shouldExecuteCorrectScript() { + // Given + int zoom = 5; + + // When + controlLayer.setMinZoom(zoom); + + // Then + verify(engine).executeScript("this.map.setMinZoom(5)"); + } + + @Test + void setMaxZoom_shouldExecuteCorrectScript() { + // Given + int zoom = 18; + + // When + controlLayer.setMaxZoom(zoom); + + // Then + verify(engine).executeScript("this.map.setMaxZoom(18)"); + } + + // === Edge Cases === + + @Test + void zoomOperations_withZeroValues_shouldExecuteCorrectScript() { + // When + controlLayer.zoomIn(0); + controlLayer.zoomOut(0); + controlLayer.setZoom(0); + + // Then + verify(engine).executeScript("this.map.zoomIn(0)"); + verify(engine).executeScript("this.map.zoomOut(0)"); + verify(engine).executeScript("this.map.setZoom(0)"); + } + + @Test + void zoomOperations_withNegativeValues_shouldExecuteCorrectScript() { + // When + controlLayer.zoomIn(-1); + controlLayer.zoomOut(-2); + controlLayer.setZoom(-5); + + // Then + verify(engine).executeScript("this.map.zoomIn(-1)"); + verify(engine).executeScript("this.map.zoomOut(-2)"); + verify(engine).executeScript("this.map.setZoom(-5)"); + } + + @Test + void multipleOperations_shouldExecuteAllScripts() { + // Given + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int zoom = 10; + + // When + controlLayer.setZoom(zoom); + controlLayer.panTo(center); + controlLayer.setMinZoom(5); + controlLayer.setMaxZoom(18); + + // Then + verify(engine).executeScript("this.map.setZoom(10)"); + verify(engine).executeScript("this.map.panTo(L.latLng(52.520000, 13.405000))"); + verify(engine).executeScript("this.map.setMinZoom(5)"); + verify(engine).executeScript("this.map.setMaxZoom(18)"); + } +} \ No newline at end of file diff --git a/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLGeoJsonLayerTest.java b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLGeoJsonLayerTest.java new file mode 100644 index 0000000..3eea6c1 --- /dev/null +++ b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLGeoJsonLayerTest.java @@ -0,0 +1,357 @@ +package io.github.makbn.jlmap.fx.test.layer; + +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.exception.JLException; +import io.github.makbn.jlmap.fx.layer.JLGeoJsonLayer; +import io.github.makbn.jlmap.geojson.JLGeoJsonContent; +import io.github.makbn.jlmap.geojson.JLGeoJsonFile; +import io.github.makbn.jlmap.geojson.JLGeoJsonURL; +import io.github.makbn.jlmap.model.JLGeoJson; +import netscape.javascript.JSObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JLGeoJsonLayerTest { + + private static final String VALID_GEOJSON = """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.4050, 52.5200] + }, + "properties": { + "name": "Berlin" + } + } + ] + }"""; + @Mock + private JLWebEngine engine; + @Mock + private JLMapEventHandler callbackHandler; + @Mock + private JLGeoJsonFile mockGeoJsonFile; + @Mock + private JLGeoJsonURL mockGeoJsonURL; + @Mock + private JLGeoJsonContent mockGeoJsonContent; + @Mock + private JSObject mockWindow; + private JLGeoJsonLayer geoJsonLayer; + + @BeforeEach + void setUp() { + // Mock the window object for JavaFX bridge initialization + // Use lenient() to allow stubbing that may not always be called + lenient().when(engine.executeScript(anyString())).thenReturn(null); + when(engine.executeScript("window")).thenReturn(mockWindow); + + geoJsonLayer = new JLGeoJsonLayer(engine, callbackHandler); + + // Clear invocations from the constructor (bridge initialization) + clearInvocations(engine, callbackHandler); + + // Use reflection to inject mocks for testing + try { + var fromFileField = JLGeoJsonLayer.class.getDeclaredField("fromFile"); + fromFileField.setAccessible(true); + fromFileField.set(geoJsonLayer, mockGeoJsonFile); + + var fromUrlField = JLGeoJsonLayer.class.getDeclaredField("fromUrl"); + fromUrlField.setAccessible(true); + fromUrlField.set(geoJsonLayer, mockGeoJsonURL); + + var fromContentField = JLGeoJsonLayer.class.getDeclaredField("fromContent"); + fromContentField.setAccessible(true); + fromContentField.set(geoJsonLayer, mockGeoJsonContent); + } catch (Exception e) { + throw new RuntimeException("Failed to set up mocks", e); + } + } + + // === Constructor Tests === + + @Test + void constructor_withNullEngine_shouldThrowNullPointerException() { + // When/Then - Bridge initialization requires a non-null engine + assertThatThrownBy(() -> new JLGeoJsonLayer(null, callbackHandler)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + // Given - need to mock engine for bridge initialization + JLWebEngine mockEngine = mock(JLWebEngine.class); + JSObject mockWin = mock(JSObject.class); + lenient().when(mockEngine.executeScript(anyString())).thenReturn(null); + when(mockEngine.executeScript("window")).thenReturn(mockWin); + + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLGeoJsonLayer layer = new JLGeoJsonLayer(mockEngine, null); + assertThat(layer).isNotNull(); + } + + @Test + void geoJsonLayer_shouldExtendJLLayer() { + // This test verifies the inheritance hierarchy + Class layerClass = JLGeoJsonLayer.class; + + // Then + assertThat(layerClass.getSuperclass().getSimpleName()).isEqualTo("JLLayer"); + } + + // === addFromFile Tests === + + @Test + void addFromFile_withValidFile_shouldLoadFileAndExecuteScript() throws JLException { + // Given + File testFile = new File("test.geojson"); + when(mockGeoJsonFile.load(testFile)).thenReturn(VALID_GEOJSON); + + // When + JLGeoJson result = geoJsonLayer.addFromFile(testFile); + + // Then + verify(mockGeoJsonFile).load(testFile); + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.geoJSON"); + assertThat(script).contains("FeatureCollection"); + assertThat(script).contains("Berlin"); + assertThat(script).contains("addTo(this.map)"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getGeoJsonContent()).isEqualTo(VALID_GEOJSON); + } + + @Test + void addFromFile_whenFileLoadFails_shouldThrowJLException() throws JLException { + // Given + File testFile = new File("invalid.geojson"); + JLException expectedException = new JLException("File not found"); + when(mockGeoJsonFile.load(testFile)).thenThrow(expectedException); + + // When/Then + assertThatThrownBy(() -> geoJsonLayer.addFromFile(testFile)) + .isInstanceOf(JLException.class) + .hasMessage("File not found"); + + verify(mockGeoJsonFile).load(testFile); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromFile_withNullFile_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> geoJsonLayer.addFromFile(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(mockGeoJsonFile); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + // === addFromUrl Tests === + + @Test + void addFromUrl_withValidUrl_shouldLoadUrlAndExecuteScript() throws JLException { + // Given + String testUrl = "https://example.com/data.geojson"; + String simpleGeoJson = """ + { + "type": "Point", + "coordinates": [13.4050, 52.5200] + }"""; + when(mockGeoJsonURL.load(testUrl)).thenReturn(simpleGeoJson); + + // When + JLGeoJson result = geoJsonLayer.addFromUrl(testUrl); + + // Then + verify(mockGeoJsonURL).load(testUrl); + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.geoJSON"); + assertThat(script).contains("Point"); + assertThat(script).contains("13.4050, 52.5200"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getGeoJsonContent()).isEqualTo(simpleGeoJson); + } + + @Test + void addFromUrl_whenUrlLoadFails_shouldThrowJLException() throws JLException { + // Given + String testUrl = "https://invalid-url.com/data.geojson"; + JLException expectedException = new JLException("URL not accessible"); + when(mockGeoJsonURL.load(testUrl)).thenThrow(expectedException); + + // When/Then + assertThatThrownBy(() -> geoJsonLayer.addFromUrl(testUrl)) + .isInstanceOf(JLException.class) + .hasMessage("URL not accessible"); + + verify(mockGeoJsonURL).load(testUrl); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromUrl_withNullUrl_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> geoJsonLayer.addFromUrl(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(mockGeoJsonURL); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + // === addFromContent Tests === + + @Test + void addFromContent_withValidContent_shouldLoadContentAndExecuteScript() throws JLException { + // Given + String content = VALID_GEOJSON; + when(mockGeoJsonContent.load(content)).thenReturn(content); + + // When + JLGeoJson result = geoJsonLayer.addFromContent(content); + + // Then + verify(mockGeoJsonContent).load(content); + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.geoJSON"); + assertThat(script).contains("FeatureCollection"); + assertThat(script).contains("Berlin"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getGeoJsonContent()).isEqualTo(content); + } + + @Test + void addFromContent_whenContentLoadFails_shouldThrowJLException() throws JLException { + // Given + String content = "invalid json"; + JLException expectedException = new JLException("Invalid GeoJSON"); + when(mockGeoJsonContent.load(content)).thenThrow(expectedException); + + // When/Then + assertThatThrownBy(() -> geoJsonLayer.addFromContent(content)) + .isInstanceOf(JLException.class) + .hasMessage("Invalid GeoJSON"); + + verify(mockGeoJsonContent).load(content); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromContent_withNullContent_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> geoJsonLayer.addFromContent(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(mockGeoJsonContent); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + // === removeGeoJson Tests === + + @Test + void removeGeoJson_shouldExecuteRemoveScriptAndRemoveFromCallback() { + // Given + String geoJsonId = "testGeoJsonId"; + + // When + boolean result = geoJsonLayer.removeGeoJson(geoJsonId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testGeoJsonId)"); + verify(callbackHandler).remove(JLGeoJson.class, geoJsonId); + assertThat(result).isTrue(); + } + + @Test + void removeGeoJson_withNullId_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> geoJsonLayer.removeGeoJson(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + // === ID Generation Tests === + + @Test + void geoJsonLayer_shouldGenerateUniqueIds() throws JLException { + // Given + when(mockGeoJsonContent.load(anyString())).thenReturn(VALID_GEOJSON); + + // When + JLGeoJson geoJson1 = geoJsonLayer.addFromContent(VALID_GEOJSON); + JLGeoJson geoJson2 = geoJsonLayer.addFromContent(VALID_GEOJSON); + + // Then + assertThat(geoJson1.getJLId()).isNotEqualTo(geoJson2.getJLId()); + assertThat(geoJson1.getJLId()).startsWith("JLGeoJson"); + assertThat(geoJson2.getJLId()).startsWith("JLGeoJson"); + } + + // === Multiple Data Sources Test === + + @Test + void multipleGeoJsonOperations_shouldExecuteAllScripts() throws JLException { + // Given + when(mockGeoJsonContent.load(anyString())).thenReturn(VALID_GEOJSON); + when(mockGeoJsonFile.load(any(File.class))).thenReturn(VALID_GEOJSON); + when(mockGeoJsonURL.load(anyString())).thenReturn(VALID_GEOJSON); + + // When + JLGeoJson geoJson1 = geoJsonLayer.addFromContent(VALID_GEOJSON); + JLGeoJson geoJson2 = geoJsonLayer.addFromFile(new File("test.geojson")); + JLGeoJson geoJson3 = geoJsonLayer.addFromUrl("https://example.com/data.geojson"); + + geoJsonLayer.removeGeoJson(geoJson1.getJLId()); + + // Then + verify(engine, times(3)).executeScript(argThat(script -> script.contains("L.geoJSON"))); + verify(engine).executeScript(argThat(script -> script.contains("removeLayer"))); + + verify(callbackHandler, times(3)).addJLObject(anyString(), any(JLGeoJson.class)); + verify(callbackHandler).remove(JLGeoJson.class, geoJson1.getJLId()); + } +} \ No newline at end of file diff --git a/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLUiLayerTest.java b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLUiLayerTest.java new file mode 100644 index 0000000..5e40b98 --- /dev/null +++ b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLUiLayerTest.java @@ -0,0 +1,296 @@ +package io.github.makbn.jlmap.fx.test.layer; + +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.fx.layer.JLUiLayer; +import io.github.makbn.jlmap.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JLUiLayerTest { + + @Mock + private JLWebEngine engine; + + @Mock + private JLMapEventHandler callbackHandler; + + private JLUiLayer uiLayer; + + @BeforeEach + void setUp() { + uiLayer = new JLUiLayer(engine, callbackHandler); + } + + // === Constructor Tests === + + @Test + void constructor_withNullEngine_shouldAcceptNullEngine() { + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLUiLayer layer = new JLUiLayer(null, callbackHandler); + assertThat(layer).isNotNull(); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLUiLayer layer = new JLUiLayer(engine, null); + assertThat(layer).isNotNull(); + } + + @Test + void uiLayer_shouldExtendJLLayer() { + // This test verifies the inheritance hierarchy + Class layerClass = JLUiLayer.class; + + // Then + assertThat(layerClass.getSuperclass().getSimpleName()).isEqualTo("JLLayer"); + } + + // === Marker Tests === + + @Test + void addMarker_withNonDraggableMarker_shouldExecuteCorrectScript() { + // Given + JLLatLng position = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + String text = "Test Marker"; + boolean draggable = false; + + // When + JLMarker result = uiLayer.addMarker(position, text, draggable); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.marker"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("draggable: false"); + assertThat(script).contains("addTo(this.map)"); + assertThat(script).contains("on('move'"); + assertThat(script).contains("on('click'"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getText()).isEqualTo(text); + } + + @Test + void addMarker_withDraggableMarker_shouldExecuteScriptWithDraggableTrue() { + // Given + JLLatLng position = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + String text = "Draggable Marker"; + boolean draggable = true; + + // When + JLMarker result = uiLayer.addMarker(position, text, draggable); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("draggable: true"); + + assertThat(result).isNotNull(); + assertThat(result.getText()).isEqualTo(text); + } + + @Test + void addMarker_withNullPosition_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> uiLayer.addMarker(null, "Test", false)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addMarker_withNullText_shouldAcceptNullText() { + // Given + JLLatLng position = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When/Then - Null text validation is not implemented in the actual class + // This test documents the current behavior + JLMarker marker = uiLayer.addMarker(position, null, false); + assertThat(marker).isNotNull(); + } + + @Test + void removeMarker_shouldExecuteRemoveScript() { + // Given + String markerId = "testMarkerId"; + + // When + boolean result = uiLayer.removeMarker(markerId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testMarkerId)"); + verify(callbackHandler).remove(JLMarker.class, markerId); + assertThat(result).isTrue(); + } + + // === Popup Tests === + + @Test + void addPopup_withDefaultOptions_shouldExecuteCorrectScript() { + // Given + JLLatLng position = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + String text = "Test Popup"; + + // When + JLPopup result = uiLayer.addPopup(position, text); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.popup"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("addTo(this.map)"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getText()).isEqualTo(text); + } + + @Test + void addPopup_withCustomOptions_shouldIncludeOptionsInScript() { + // Given + JLLatLng position = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + String text = "Custom Popup"; + JLOptions options = JLOptions.builder() + .closeButton(false) + .autoClose(false) + .build(); + + // When + JLPopup result = uiLayer.addPopup(position, text, options); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("closeButton: false"); + assertThat(script).contains("autoClose: false"); + + assertThat(result).isNotNull(); + assertThat(result.getText()).isEqualTo(text); + } + + @Test + void addPopup_withNullPosition_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> uiLayer.addPopup(null, "Test")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addPopup_withNullText_shouldAcceptNullText() { + // Given + JLLatLng position = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When/Then - Null text validation is not implemented in the actual class + // This test documents the current behavior + JLPopup popup = uiLayer.addPopup(position, null); + assertThat(popup).isNotNull(); + } + + @Test + void removePopup_shouldExecuteRemoveScript() { + // Given + String popupId = "testPopupId"; + + // When + boolean result = uiLayer.removePopup(popupId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testPopupId)"); + verify(callbackHandler).remove(JLPopup.class, popupId); + assertThat(result).isTrue(); + } + + // === Image Overlay Tests === + + @Test + void addImage_shouldExecuteCorrectScript() { + // Given + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + String imageUrl = "https://example.com/image.png"; + JLOptions options = JLOptions.DEFAULT; + + // When + JLImageOverlay result = uiLayer.addImage(bounds, imageUrl, options); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.imageOverlay"); + assertThat(script).contains("https://example.com/image.png"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("[52.530000, 13.415000]"); + assertThat(script).contains("addTo(this.map)"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getImageUrl()).isEqualTo(imageUrl); + } + + @Test + void addImage_withNullBounds_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> uiLayer.addImage(null, "https://example.com/image.png", JLOptions.DEFAULT)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addImage_withNullImageUrl_shouldAcceptNullUrl() { + // Given + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + + // When/Then - Null URL validation is not implemented in the actual class + // This test documents the current behavior + JLImageOverlay image = uiLayer.addImage(bounds, null, JLOptions.DEFAULT); + assertThat(image).isNotNull(); + } + + // === ID Generation Tests === + + @Test + void uiLayer_shouldGenerateUniqueIds() { + // Given + JLLatLng position = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When + JLMarker marker1 = uiLayer.addMarker(position, "Marker 1", false); + JLMarker marker2 = uiLayer.addMarker(position, "Marker 2", false); + + // Then + assertThat(marker1.getJLId()).isNotEqualTo(marker2.getJLId()); + assertThat(marker1.getJLId()).startsWith("JLGeoJson"); + assertThat(marker2.getJLId()).startsWith("JLGeoJson"); + } +} \ No newline at end of file diff --git a/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLVectorLayerTest.java b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLVectorLayerTest.java new file mode 100644 index 0000000..0f3eb09 --- /dev/null +++ b/jlmap-fx/src/test/java/io/github/makbn/jlmap/fx/test/layer/JLVectorLayerTest.java @@ -0,0 +1,391 @@ +package io.github.makbn.jlmap.fx.test.layer; + +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.fx.layer.JLVectorLayer; +import io.github.makbn.jlmap.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JLVectorLayerTest { + + @Mock + private JLWebEngine engine; + + @Mock + private JLMapEventHandler callbackHandler; + + private JLVectorLayer vectorLayer; + + @BeforeEach + void setUp() { + vectorLayer = new JLVectorLayer(engine, callbackHandler); + } + + // === Constructor and Basic Tests === + + @Test + void constructor_withNullEngine_shouldAcceptNullEngine() { + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLVectorLayer layer = new JLVectorLayer(null, callbackHandler); + assertThat(layer).isNotNull(); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLVectorLayer layer = new JLVectorLayer(engine, null); + assertThat(layer).isNotNull(); + } + + @Test + void vectorLayer_shouldExtendJLLayer() { + // This test verifies the inheritance hierarchy + Class layerClass = JLVectorLayer.class; + + // Then + assertThat(layerClass.getSuperclass().getSimpleName()).isEqualTo("JLLayer"); + } + + // === Polyline Tests === + + @Test + void addPolyline_withValidVertices_shouldCreatePolylineAndExecuteScript() { + // Given + JLLatLng[] vertices = { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build() + }; + + // When + JLPolyline result = vectorLayer.addPolyline(vertices); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.polyline"); + assertThat(script).contains("[52.52,13.405]"); + assertThat(script).contains("[52.53,13.415]"); + assertThat(script).contains("addTo(this.map)"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + } + + @Test + void addPolyline_withCustomOptions_shouldIncludeOptionsInScript() { + // Given + JLLatLng[] vertices = { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build() + }; + JLOptions options = JLOptions.builder() + .color(JLColor.RED) + .weight(5) + .opacity(0.8) + .build(); + + // When + JLPolyline result = vectorLayer.addPolyline(vertices, options); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("weight: 5"); + assertThat(script).contains("opacity: 0.8"); + + assertThat(result).isNotNull(); + } + + @Test + void addPolyline_withNullVertices_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> vectorLayer.addPolyline(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addPolyline_withEmptyVertices_shouldAcceptEmptyArray() { + // Given + JLLatLng[] emptyVertices = new JLLatLng[0]; + + // When/Then - Empty vertices validation is not implemented in the actual class + // This test documents the current behavior + JLPolyline polyline = vectorLayer.addPolyline(emptyVertices); + assertThat(polyline).isNotNull(); + } + + @Test + void removePolyline_shouldExecuteRemoveScript() { + // Given + String polylineId = "testPolylineId"; + + // When + boolean result = vectorLayer.removePolyline(polylineId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testPolylineId)"); + verify(callbackHandler).remove(JLPolyline.class, polylineId); + verify(callbackHandler).remove(JLMultiPolyline.class, polylineId); + assertThat(result).isTrue(); + } + + // === Circle Tests === + + @Test + void addCircle_withDefaultOptions_shouldCreateCircleWithDefaults() { + // Given + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When + JLCircle result = vectorLayer.addCircle(center); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.circle"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("radius: 1000.000000"); // Default radius + assertThat(script).contains("on('move'"); + assertThat(script).contains("on('add'"); + assertThat(script).contains("on('remove'"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLCircle"); + } + + @Test + void addCircle_withCustomRadius_shouldUseCustomRadius() { + // Given + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int customRadius = 500; + + // When + JLCircle result = vectorLayer.addCircle(center, customRadius, JLOptions.DEFAULT); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("radius: 500.000000"); + + assertThat(result).isNotNull(); + } + + @Test + void addCircle_withNullCenter_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> vectorLayer.addCircle(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removeCircle_shouldExecuteRemoveScript() { + // Given + String circleId = "testCircleId"; + when(engine.executeScript(anyString())).thenReturn("true"); + + // When + boolean result = vectorLayer.removeCircle(circleId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testCircleId)"); + verify(callbackHandler).remove(JLCircle.class, circleId); + assertThat(result).isTrue(); + } + + // === Circle Marker Tests === + + @Test + void addCircleMarker_withDefaultOptions_shouldCreateCircleMarker() { + // Given + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When + JLCircleMarker result = vectorLayer.addCircleMarker(center); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.circleMarker"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("on('move'"); + assertThat(script).contains("on('click'"); + assertThat(script).contains("on('dblclick'"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLCircleMarker"); + } + + @Test + void addCircleMarker_withNullCenter_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> vectorLayer.addCircleMarker(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removeCircleMarker_shouldExecuteRemoveScript() { + // Given + String circleMarkerId = "testCircleMarkerId"; + when(engine.executeScript(anyString())).thenReturn("true"); + + // When + boolean result = vectorLayer.removeCircleMarker(circleMarkerId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testCircleMarkerId)"); + verify(callbackHandler).remove(JLCircleMarker.class, circleMarkerId); + assertThat(result).isTrue(); + } + + // === Polygon Tests === + + @Test + void addPolygon_withValidVertices_shouldCreatePolygon() { + // Given + JLLatLng[][][] vertices = { + { + { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build(), + JLLatLng.builder().lat(52.5400).lng(13.4250).build() + } + } + }; + + // When + JLPolygon result = vectorLayer.addPolygon(vertices); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.polygon"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("[52.530000, 13.415000]"); + assertThat(script).contains("[52.540000, 13.425000]"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + } + + @Test + void addPolygon_withNullVertices_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> vectorLayer.addPolygon(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removePolygon_shouldExecuteRemoveScript() { + // Given + String polygonId = "testPolygonId"; + when(engine.executeScript(anyString())).thenReturn("true"); + + // When + boolean result = vectorLayer.removePolygon(polygonId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testPolygonId)"); + verify(callbackHandler).remove(JLPolygon.class, polygonId); + assertThat(result).isTrue(); + } + + // === Multi-Polyline Tests === + + @Test + void addMultiPolyline_withValidVertices_shouldCreateMultiPolyline() { + // Given + JLLatLng[][] vertices = { + { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build() + }, + { + JLLatLng.builder().lat(52.5400).lng(13.4250).build(), + JLLatLng.builder().lat(52.5500).lng(13.4350).build() + } + }; + + // When + JLMultiPolyline result = vectorLayer.addMultiPolyline(vertices); + + // Then + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.polyline"); + assertThat(script).contains("[52.520000,13.405000]"); + assertThat(script).contains("[52.540000,13.425000]"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + } + + @Test + void addMultiPolyline_withNullVertices_shouldThrowNullPointerException() { + // When/Then + assertThatThrownBy(() -> vectorLayer.addMultiPolyline(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removeMultiPolyline_shouldExecuteRemoveScript() { + // Given + String multiPolylineId = "testMultiPolylineId"; + + // When + boolean result = vectorLayer.removeMultiPolyline(multiPolylineId); + + // Then + verify(engine).executeScript("this.map.removeLayer(this.testMultiPolylineId)"); + verify(callbackHandler).remove(JLMultiPolyline.class, multiPolylineId); + assertThat(result).isTrue(); + } + + // === ID Generation Tests === + + @Test + void vectorLayer_shouldGenerateUniqueIds() { + // Given + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + // When + JLCircle circle1 = vectorLayer.addCircle(center); + JLCircle circle2 = vectorLayer.addCircle(center); + + // Then + assertThat(circle1.getJLId()).isNotEqualTo(circle2.getJLId()); + assertThat(circle1.getJLId()).startsWith("JLCircle"); + assertThat(circle2.getJLId()).startsWith("JLCircle"); + } +} \ No newline at end of file diff --git a/jlmap-fx/src/test/java/module-info.java b/jlmap-fx/src/test/java/module-info.java new file mode 100644 index 0000000..44cc78b --- /dev/null +++ b/jlmap-fx/src/test/java/module-info.java @@ -0,0 +1,33 @@ +module io.github.makbn.jlmap.fx.test { + // API dependency + requires io.github.makbn.jlmap.api; + requires io.github.makbn.jlmap.fx; + + // JavaFX modules + requires javafx.controls; + requires javafx.base; + requires javafx.swing; + requires javafx.web; + requires javafx.graphics; + + requires jdk.jsobject; + requires org.slf4j; + requires com.google.gson; + requires com.fasterxml.jackson.databind; + + requires static org.jetbrains.annotations; + requires static lombok; + requires com.j2html; + requires org.junit.jupiter.api; + requires org.junit.jupiter.params; + requires org.assertj.core; + requires org.testfx.junit5; + requires org.mockito.junit.jupiter; + requires org.mockito; + // Opens for reflection as needed by frameworks + opens io.github.makbn.jlmap.fx.test.integration to org.testfx.junit5, org.junit.platform.commons; + opens io.github.makbn.jlmap.fx.test.internal to org.junit.platform.commons, org.mockito; + opens io.github.makbn.jlmap.fx.test.layer to org.junit.platform.commons, org.mockito; + opens io.github.makbn.jlmap.fx.test.engine to org.junit.platform.commons, org.mockito; + opens io.github.makbn.jlmap.fx.test.element.menu to org.junit.platform.commons, org.mockito; +} \ No newline at end of file diff --git a/jlmap-vaadin-demo/.gitignore b/jlmap-vaadin-demo/.gitignore new file mode 100644 index 0000000..c642287 --- /dev/null +++ b/jlmap-vaadin-demo/.gitignore @@ -0,0 +1,85 @@ +/target/ +.idea/ +.vscode/ +.settings +.project +.classpath + +*.iml +.DS_Store + +# The following files are generated/updated by vaadin-maven-plugin +node_modules/ +src/main/frontend/generated/ +pnpmfile.js +vite.generated.ts + +# Browser drivers for local integration tests +drivers/ +# Error screenshots generated by TestBench for failed integration tests +error-screenshots/ +webpack.generated.js + + +############################# +# Maven & Build Artifacts +############################# +target/ +!.mvn/wrapper/maven-wrapper.jar +.mvn/timing.properties +dependency-reduced-pom.xml +buildNumber.properties +release.properties + +############################# +# Java +############################# +*.class +*.log +*.hprof +*.pid + +############################# +# IDEs & Editors +############################# +# IntelliJ +**/.idea/ +**/*.iml +*.ipr +*.iws +out/ + +# Eclipse +**/.project +**/.classpath +.settings/ + +# VS Code +**/.vscode/ + +# Mac & Linux +**/.DS_Store +*.swp +*~ + +############################# +# Vaadin specific +############################# +# Node / npm +**/node_modules/ +npm-debug.log* +yarn-error.log* +**/pnpm-lock.yaml +**/package-lock.json + +# Vaadin generated stuff +frontend/generated/ +frontend/index.html +frontend/sw.ts +**/vite.generated.ts +vite.config.ts.timestamp +.vite/ + +# Production build artifacts +**/build/ +**/dist/!/package-lock.json diff --git a/jlmap-vaadin-demo/.mvn/wrapper/MavenWrapperDownloader.java b/jlmap-vaadin-demo/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000..8895dd4 --- /dev/null +++ b/jlmap-vaadin-demo/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,118 @@ +/* + * Copyright 2007-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import java.io.*; +import java.net.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.6"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is + * provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl + * property to use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download + * url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a + // custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if (mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if (mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if (!outputFile.getParentFile().exists()) { + if (!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/jlmap-vaadin-demo/.mvn/wrapper/maven-wrapper.jar b/jlmap-vaadin-demo/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..c1dd12f Binary files /dev/null and b/jlmap-vaadin-demo/.mvn/wrapper/maven-wrapper.jar differ diff --git a/jlmap-vaadin-demo/.mvn/wrapper/maven-wrapper.properties b/jlmap-vaadin-demo/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..8c79a83 --- /dev/null +++ b/jlmap-vaadin-demo/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/jlmap-vaadin-demo/.prettierrc.json b/jlmap-vaadin-demo/.prettierrc.json new file mode 100644 index 0000000..1399cf3 --- /dev/null +++ b/jlmap-vaadin-demo/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 120, + "bracketSameLine": true + } + \ No newline at end of file diff --git a/jlmap-vaadin-demo/README.md b/jlmap-vaadin-demo/README.md new file mode 100644 index 0000000..610cefd --- /dev/null +++ b/jlmap-vaadin-demo/README.md @@ -0,0 +1,51 @@ +# My App + +This project can be used as a starting point to create your own Vaadin application with Spring Boot. +It contains all the necessary configuration and some placeholder files to get you started. + +## Running the application + +Open the project in an IDE. You can download the [IntelliJ community edition](https://www.jetbrains.com/idea/download) if you do not have a suitable IDE already. +Once opened in the IDE, locate the `Application` class and run the main method using "Debug". + +For more information on installing in various IDEs, see [how to import Vaadin projects to different IDEs](https://vaadin.com/docs/latest/getting-started/import). + +If you install the Vaadin plugin for IntelliJ, you should instead launch the `Application` class using "Debug using HotswapAgent" to see updates in the Java code immediately reflected in the browser. + +## Deploying to Production + +The project is a standard Maven project. To create a production build, call + +``` +./mvnw clean package -Pproduction +``` + +If you have Maven globally installed, you can replace `./mvnw` with `mvn`. + +This will build a JAR file with all the dependencies and front-end resources,ready to be run. The file can be found in the `target` folder after the build completes. +You then launch the application using +``` +java -jar target/my-app-1.0-SNAPSHOT.jar +``` + +## Project structure + +- `MainLayout.java` in `src/main/java` contains the navigation setup (i.e., the + side/top bar and the main menu). This setup uses + [App Layout](https://vaadin.com/docs/components/app-layout). +- `views` package in `src/main/java` contains the server-side Java views of your application. +- `views` folder in `src/main/frontend` contains the client-side JavaScript views of your application. +- `themes` folder in `src/main/frontend` contains the custom CSS styles. + +## Useful links + +- Read the documentation at [vaadin.com/docs](https://vaadin.com/docs). +- Follow the tutorial at [vaadin.com/docs/latest/tutorial/overview](https://vaadin.com/docs/latest/tutorial/overview). +- Create new projects at [start.vaadin.com](https://start.vaadin.com/). +- Search UI components and their usage examples at [vaadin.com/docs/latest/components](https://vaadin.com/docs/latest/components). +- View use case applications that demonstrate Vaadin capabilities at [vaadin.com/examples-and-demos](https://vaadin.com/examples-and-demos). +- Build any UI without custom CSS by discovering Vaadin's set of [CSS utility classes](https://vaadin.com/docs/styling/lumo/utility-classes). +- Find a collection of solutions to common use cases at [cookbook.vaadin.com](https://cookbook.vaadin.com/). +- Find add-ons at [vaadin.com/directory](https://vaadin.com/directory). +- Ask questions on [Stack Overflow](https://stackoverflow.com/questions/tagged/vaadin) or join our [Forum](https://vaadin.com/forum). +- Report issues, create pull requests in [GitHub](https://github.com/vaadin). diff --git a/jlmap-vaadin-demo/package.json b/jlmap-vaadin-demo/package.json new file mode 100644 index 0000000..fccad68 --- /dev/null +++ b/jlmap-vaadin-demo/package.json @@ -0,0 +1,179 @@ +{ + "name": "no-name", + "license": "UNLICENSED", + "type": "module", + "dependencies": { + "@maptiler/leaflet-maptilersdk": "4.1.0", + "@polymer/polymer": "3.5.2", + "@vaadin/bundles": "24.8.4", + "@vaadin/common-frontend": "0.0.19", + "@vaadin/polymer-legacy-adapter": "24.8.4", + "@vaadin/react-components": "24.8.4", + "@vaadin/vaadin-development-mode-detector": "2.0.7", + "@vaadin/vaadin-lumo-styles": "24.8.4", + "@vaadin/vaadin-material-styles": "24.8.4", + "@vaadin/vaadin-themable-mixin": "24.8.4", + "@vaadin/vaadin-usage-statistics": "2.1.3", + "construct-style-sheets-polyfill": "3.1.0", + "date-fns": "2.29.3", + "leaflet": "1.9.4", + "lit": "3.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "7.6.1" + }, + "devDependencies": { + "@babel/preset-react": "7.27.1", + "@preact/signals-react-transform": "0.5.1", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.1.4", + "@types/react": "18.3.23", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "4.5.0", + "async": "3.2.6", + "glob": "11.0.2", + "magic-string": "0.30.17", + "rollup-plugin-brotli": "3.1.0", + "rollup-plugin-visualizer": "5.14.0", + "strip-css-comments": "5.0.0", + "transform-ast": "2.4.4", + "typescript": "5.8.3", + "vite": "6.3.5", + "vite-plugin-checker": "0.9.3", + "workbox-build": "7.3.0", + "workbox-core": "7.3.0", + "workbox-precaching": "7.3.0" + }, + "vaadin": { + "dependencies": { + "@maptiler/leaflet-maptilersdk": "4.1.0", + "@polymer/polymer": "3.5.2", + "@vaadin/bundles": "24.8.4", + "@vaadin/common-frontend": "0.0.19", + "@vaadin/polymer-legacy-adapter": "24.8.4", + "@vaadin/react-components": "24.8.4", + "@vaadin/vaadin-development-mode-detector": "2.0.7", + "@vaadin/vaadin-lumo-styles": "24.8.4", + "@vaadin/vaadin-material-styles": "24.8.4", + "@vaadin/vaadin-themable-mixin": "24.8.4", + "@vaadin/vaadin-usage-statistics": "2.1.3", + "construct-style-sheets-polyfill": "3.1.0", + "date-fns": "2.29.3", + "leaflet": "1.9.4", + "lit": "3.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "7.6.1" + }, + "devDependencies": { + "@babel/preset-react": "7.27.1", + "@preact/signals-react-transform": "0.5.1", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.1.4", + "@types/react": "18.3.23", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "4.5.0", + "async": "3.2.6", + "glob": "11.0.2", + "magic-string": "0.30.17", + "rollup-plugin-brotli": "3.1.0", + "rollup-plugin-visualizer": "5.14.0", + "strip-css-comments": "5.0.0", + "transform-ast": "2.4.4", + "typescript": "5.8.3", + "vite": "6.3.5", + "vite-plugin-checker": "0.9.3", + "workbox-build": "7.3.0", + "workbox-core": "7.3.0", + "workbox-precaching": "7.3.0" + }, + "hash": "670e78536b2289b1894aec405897760d9736faa5ce54c7ea1e95d4c3314eee7c" + }, + "overrides": { + "@vaadin/bundles": "$@vaadin/bundles", + "@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter", + "@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector", + "@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics", + "@vaadin/react-components": "$@vaadin/react-components", + "@vaadin/common-frontend": "$@vaadin/common-frontend", + "react-dom": "$react-dom", + "construct-style-sheets-polyfill": "$construct-style-sheets-polyfill", + "lit": "$lit", + "@polymer/polymer": "$@polymer/polymer", + "react": "$react", + "react-router": "$react-router", + "date-fns": "$date-fns", + "@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin", + "@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles", + "@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles", + "@vaadin/a11y-base": "24.8.4", + "@vaadin/accordion": "24.8.4", + "@vaadin/app-layout": "24.8.4", + "@vaadin/avatar": "24.8.4", + "@vaadin/avatar-group": "24.8.4", + "@vaadin/button": "24.8.4", + "@vaadin/card": "24.8.4", + "@vaadin/checkbox": "24.8.4", + "@vaadin/checkbox-group": "24.8.4", + "@vaadin/combo-box": "24.8.4", + "@vaadin/component-base": "24.8.4", + "@vaadin/confirm-dialog": "24.8.4", + "@vaadin/context-menu": "24.8.4", + "@vaadin/custom-field": "24.8.4", + "@vaadin/date-picker": "24.8.4", + "@vaadin/date-time-picker": "24.8.4", + "@vaadin/details": "24.8.4", + "@vaadin/dialog": "24.8.4", + "@vaadin/email-field": "24.8.4", + "@vaadin/field-base": "24.8.4", + "@vaadin/field-highlighter": "24.8.4", + "@vaadin/form-layout": "24.8.4", + "@vaadin/grid": "24.8.4", + "@vaadin/horizontal-layout": "24.8.4", + "@vaadin/icon": "24.8.4", + "@vaadin/icons": "24.8.4", + "@vaadin/input-container": "24.8.4", + "@vaadin/integer-field": "24.8.4", + "@vaadin/item": "24.8.4", + "@vaadin/list-box": "24.8.4", + "@vaadin/lit-renderer": "24.8.4", + "@vaadin/login": "24.8.4", + "@vaadin/markdown": "24.8.4", + "@vaadin/master-detail-layout": "24.8.4", + "@vaadin/menu-bar": "24.8.4", + "@vaadin/message-input": "24.8.4", + "@vaadin/message-list": "24.8.4", + "@vaadin/multi-select-combo-box": "24.8.4", + "@vaadin/notification": "24.8.4", + "@vaadin/number-field": "24.8.4", + "@vaadin/overlay": "24.8.4", + "@vaadin/password-field": "24.8.4", + "@vaadin/popover": "24.8.4", + "@vaadin/progress-bar": "24.8.4", + "@vaadin/radio-group": "24.8.4", + "@vaadin/scroller": "24.8.4", + "@vaadin/select": "24.8.4", + "@vaadin/side-nav": "24.8.4", + "@vaadin/split-layout": "24.8.4", + "@vaadin/tabs": "24.8.4", + "@vaadin/tabsheet": "24.8.4", + "@vaadin/text-area": "24.8.4", + "@vaadin/text-field": "24.8.4", + "@vaadin/time-picker": "24.8.4", + "@vaadin/tooltip": "24.8.4", + "@vaadin/upload": "24.8.4", + "@vaadin/router": "2.0.0", + "@vaadin/vertical-layout": "24.8.4", + "@vaadin/virtual-list": "24.8.4", + "@vaadin/board": "24.8.4", + "@vaadin/charts": "24.8.4", + "@vaadin/cookie-consent": "24.8.4", + "@vaadin/crud": "24.8.4", + "@vaadin/dashboard": "24.8.4", + "@vaadin/grid-pro": "24.8.4", + "@vaadin/map": "24.8.4", + "@vaadin/rich-text-editor": "24.8.4", + "@maptiler/leaflet-maptilersdk": "$@maptiler/leaflet-maptilersdk", + "leaflet": "$leaflet" + } +} \ No newline at end of file diff --git a/jlmap-vaadin-demo/pom.xml b/jlmap-vaadin-demo/pom.xml new file mode 100644 index 0000000..553cde1 --- /dev/null +++ b/jlmap-vaadin-demo/pom.xml @@ -0,0 +1,212 @@ + + + 4.0.0 + + jlmap-vaadin-demo + jar + Java Leaflet (JLeaflet) - Vaadin Demo + + + + GNU Lesser General Public License (LGPL) Version 2.1 or later + https://www.gnu.org/licenses/lgpl-2.1.html + https://github.com/makbn/java_leaflet + + + + + 17 + 24.8.5 + true + + + + org.springframework.boot + spring-boot-starter-parent + 3.5.4 + + + + + + Vaadin Directory + https://maven.vaadin.com/vaadin-addons + + false + + + + + + + + com.vaadin + vaadin-bom + ${vaadin.version} + pom + import + + + + + + + com.vaadin + vaadin-core + + + com.vaadin + vaadin-spring-boot-starter + + + org.parttio + line-awesome + 2.1.0 + + + + com.h2database + h2 + runtime + + + + io.github.makbn + jlmap-vaadin + 2.0.0 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-devtools + true + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin + vaadin-testbench-junit5 + test + + + + + spring-boot:run + + + org.springframework.boot + spring-boot-maven-plugin + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + prepare-frontend + + + + + + + + + + + production + + + + com.vaadin + vaadin-core + + + com.vaadin + vaadin-dev + + + + + + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + build-frontend + + compile + + + + + + + + + it + + + + org.springframework.boot + spring-boot-maven-plugin + + + start-spring-boot + pre-integration-test + + start + + + + stop-spring-boot + post-integration-test + + stop + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + false + true + + + + + + + + diff --git a/jlmap-vaadin-demo/src/main/bundles/README.md b/jlmap-vaadin-demo/src/main/bundles/README.md new file mode 100644 index 0000000..c9737d1 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/bundles/README.md @@ -0,0 +1,32 @@ +This directory is automatically generated by Vaadin and contains the pre-compiled +frontend files/resources for your project (frontend development bundle). + +It should be added to Version Control System and committed, so that other developers +do not have to compile it again. + +Frontend development bundle is automatically updated when needed: +- an npm/pnpm package is added with @NpmPackage or directly into package.json +- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript +- Vaadin add-on with front-end customizations is added +- Custom theme imports/assets added into 'theme.json' file +- Exported web component is added. + +If your project development needs a hot deployment of the frontend changes, +you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions): +- set `vaadin.frontend.hotdeploy=true` in `application.properties` +- configure `vaadin-maven-plugin`: +``` + + true + +``` +- configure `jetty-maven-plugin`: +``` + + + true + + +``` + +Read more [about Vaadin development mode](https://vaadin.com/docs/next/flow/configuration/development-mode#precompiled-bundle). \ No newline at end of file diff --git a/jlmap-vaadin-demo/src/main/bundles/dev.bundle b/jlmap-vaadin-demo/src/main/bundles/dev.bundle new file mode 100644 index 0000000..75af378 Binary files /dev/null and b/jlmap-vaadin-demo/src/main/bundles/dev.bundle differ diff --git a/jlmap-vaadin-demo/src/main/bundles/prod.bundle b/jlmap-vaadin-demo/src/main/bundles/prod.bundle new file mode 100644 index 0000000..168068d Binary files /dev/null and b/jlmap-vaadin-demo/src/main/bundles/prod.bundle differ diff --git a/jlmap-vaadin-demo/src/main/frontend/index.html b/jlmap-vaadin-demo/src/main/frontend/index.html new file mode 100644 index 0000000..91b01f6 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/frontend/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + +
+ + diff --git a/jlmap-vaadin-demo/src/main/frontend/themes/jlmap-vaadin-demo/styles.css b/jlmap-vaadin-demo/src/main/frontend/themes/jlmap-vaadin-demo/styles.css new file mode 100644 index 0000000..e5c57b5 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/frontend/themes/jlmap-vaadin-demo/styles.css @@ -0,0 +1,186 @@ +/* Leaflet map below overlays */ +.leaflet-container { + position: relative !important; + z-index: 0 !important; +} + +/* Vaadin overlays always on top */ +.v-overlay-container { + z-index: 3000 !important; +} + +/* Google-quality, pixel-perfect left menu styles */ +.jlmap-menu { + position: absolute; + top: 64px; + left: 40px; + z-index: 10; + background: rgba(255, 255, 255, 0.55); + border-radius: 18px; + box-shadow: 0 2px 16px 0 rgba(60, 64, 67, 0.10), 0 1.5px 4px 0 rgba(60, 64, 67, 0.10); + padding: 32px 20px 32px 20px; + min-width: 270px; + max-width: 320px; + max-height: calc(100vh - 128px); + display: flex; + flex-direction: column; + gap: 32px; + align-items: stretch; + pointer-events: auto; + overflow-y: auto; + -webkit-backdrop-filter: blur(24px) saturate(180%) brightness(1.15) contrast(1.15); + backdrop-filter: blur(24px) saturate(180%) brightness(1.15) contrast(1.15); + border: 1.5px solid rgba(255, 255, 255, 0.35); + bottom: 64px; +} + +.jlmap-menu::before { + /* Animated caustic SVG overlay for liquid glass effect */ + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + border-radius: inherit; + z-index: 1; + background: url('data:image/svg+xml;utf8,'); + background-size: 200% 200%; + background-repeat: no-repeat; + mix-blend-mode: lighten; + opacity: 0.45; + animation: jlmap-liquid-caustic 12s ease-in-out infinite alternate; +} + +@keyframes jlmap-liquid-caustic { + 0% { + background-position: 0% 0%; + } + 100% { + background-position: 100% 100%; + } +} + +.jlmap-menu::after { + /* Soft edge glow for depth */ + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + border-radius: inherit; + z-index: 2; + box-shadow: 0 0 32px 8px rgba(255, 255, 255, 0.18) inset, 0 0 0 2px rgba(255, 255, 255, 0.10) inset; +} + +.jlmap-menu > * { + position: relative; + z-index: 3; +} + +.jlmap-menu-content { + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 32px; +} + +.jlmap-menu-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.jlmap-menu-section-title { + font-size: 1.1rem; + font-weight: 600; + color: #202124; + margin-bottom: 6px; + letter-spacing: 0.01em; +} + +.jlmap-menu-item { + background: none; + border: none; + border-radius: 8px; + padding: 10px 14px; + font-size: 1rem; + color: #444; + text-align: left; + transition: background 0.15s, color 0.15s; + cursor: pointer; + outline: none; + font-family: inherit; +} + +.jlmap-menu-item:hover, .jlmap-menu-item:focus { + background: #ffffff; + color: #000000; +} + +.jlmap-menu-item:active { + background: #ffffff; + color: #000000; +} + +.jlmap-menu-footer { + flex-shrink: 0; + position: sticky; + bottom: 0; + left: 0; + width: 100%; + display: block; + margin-top: auto; + padding: 18px 0 0 0; + text-align: center; + font-size: 1.04rem; + font-weight: 500; + color: #174ea6; + background: linear-gradient(to top, rgba(255,255,255,0.32) 80%, rgba(255,255,255,0.01) 100%); + text-decoration: none; + border-top: 1px solid rgba(255,255,255,0.18); + z-index: 4; + transition: color 0.18s; +} + +.jlmap-menu-footer:hover, .jlmap-menu-footer:focus { + color: #0b254a; + text-decoration: underline; +} + +.jlmap-github-icon { + display: inline-flex; + vertical-align: middle; + margin-right: 8px; + height: 20px; + width: 20px; + align-items: center; + justify-content: center; +} + +@media (max-width: 700px) { + .jlmap-menu { + top: 16px; + left: 8px; + margin: 0; + padding: 12px 4px 12px 4px; + min-width: 160px; + max-width: 95vw; + max-height: calc(100vh - 32px); + bottom: 16px; + } + + .jlmap-menu-section-title { + font-size: 1rem; + } + + .jlmap-menu-item { + font-size: 0.97rem; + padding: 8px 8px; + } +} diff --git a/jlmap-vaadin-demo/src/main/frontend/themes/jlmap-vaadin-demo/theme.json b/jlmap-vaadin-demo/src/main/frontend/themes/jlmap-vaadin-demo/theme.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/frontend/themes/jlmap-vaadin-demo/theme.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/Application.java b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/Application.java new file mode 100644 index 0000000..f615fe6 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/Application.java @@ -0,0 +1,26 @@ +package io.github.makbn.vaadin.demo; + +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.Push; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.theme.Theme; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * The entry point of the Spring Boot application. + * Use the @PWA annotation make the application installable on phones, tablets + * and some desktop browsers. + * + * @author Matt Akbarian (@makbn) + */ +@Push +@SpringBootApplication +@PageTitle("JLMap Vaadin Demo") +@Theme(value = "jlmap-vaadin-demo") +public class Application implements AppShellConfigurator { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/AccordionMenuBuilder.java b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/AccordionMenuBuilder.java new file mode 100644 index 0000000..0aadad8 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/AccordionMenuBuilder.java @@ -0,0 +1,61 @@ +package io.github.makbn.vaadin.demo.views; + +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.accordion.Accordion; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author Matt Akbarian (@makbn) + */ +public class AccordionMenuBuilder { + + private final Accordion accordion; + private final Map menuContents = new LinkedHashMap<>(); + private String currentMenuName; + + public AccordionMenuBuilder(Accordion accordion) { + this.accordion = accordion; + } + + /** + * Starts a new menu (accordion panel) with the given title. + */ + public AccordionMenuBuilder menu(String name) { + VerticalLayout content = new VerticalLayout(); + content.setPadding(false); + content.setSpacing(false); + content.setMargin(false); + menuContents.put(name, content); + currentMenuName = name; + return this; + } + + /** + * Adds a clickable item (button) to the current menu. + */ + public AccordionMenuBuilder item(String name, ComponentEventListener> clickListener) { + if (currentMenuName == null) { + throw new IllegalStateException("Call menu(name) before adding items"); + } + Button button = new Button(name, clickListener); + button.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE); + button.setWidthFull(); + menuContents.get(currentMenuName).add(button); + return this; + } + + /** + * Builds the accordion with all menus and items. + */ + public Accordion build() { + accordion.getChildren().forEach(accordion::remove); + menuContents.forEach(accordion::add); + return accordion; + } +} diff --git a/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/DialogBuilder.java b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/DialogBuilder.java new file mode 100644 index 0000000..b0e33e4 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/DialogBuilder.java @@ -0,0 +1,103 @@ +package io.github.makbn.vaadin.demo.views; + +import com.vaadin.flow.component.Unit; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.component.upload.Upload; +import com.vaadin.flow.server.streams.FileUploadCallback; +import com.vaadin.flow.server.streams.FileUploadHandler; +import com.vaadin.flow.server.streams.TemporaryFileFactory; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * @author Matt Akbarian (@makbn) + */ +public class DialogBuilder { + + private final Dialog dialog; + private final FormLayout formLayout; + private final Map fieldComponents = new LinkedHashMap<>(); + private final Set uploadedFiles = new LinkedHashSet<>(); + + private DialogBuilder() { + this.dialog = new Dialog(); + this.dialog.setModal(true); + + this.formLayout = new FormLayout(); + dialog.add(formLayout); + } + + public static DialogBuilder builder() { + return new DialogBuilder(); + } + + public DialogBuilder textField(String label) { + TextField field = new TextField(label); + fieldComponents.put(label, field); + formLayout.add(field); + return this; + } + + public DialogBuilder numberField(String label) { + IntegerField field = new IntegerField(label); + fieldComponents.put(label, field); + formLayout.add(field); + return this; + } + + public DialogBuilder decimalField(String label) { + NumberField field = new NumberField(label); + fieldComponents.put(label, field); + formLayout.add(field); + return this; + } + + public DialogBuilder addUpload() { + FileUploadHandler inMemoryHandler = new FileUploadHandler( + (FileUploadCallback) (metadata, file) -> + uploadedFiles.add(file), new TemporaryFileFactory()); + + Upload upload = new Upload(inMemoryHandler); + upload.setMinHeight(200, Unit.PIXELS); + upload.setMinWidth(400, Unit.PIXELS); + upload.setMaxFileSize(100000000); + + fieldComponents.put("uploader", upload); + formLayout.add(upload); + return this; + } + + public void get(Consumer> okHandler) { + Button ok = new Button("OK", e -> { + Map values = new LinkedHashMap<>(); + fieldComponents.forEach((label, comp) -> { + if (comp instanceof TextField tf) { + values.put(label, tf.getValue()); + } else if (comp instanceof IntegerField nf) { + values.put(label, nf.getValue()); + } else if (comp instanceof NumberField df) { + values.put(label, df.getValue()); + } else if (comp instanceof Upload) { + values.put("uploadedFiles", uploadedFiles); + } + }); + okHandler.accept(values); + dialog.close(); + }); + + Button cancel = new Button("Cancel", e -> dialog.close()); + dialog.getFooter().add(ok, cancel); + + dialog.open(); + } +} diff --git a/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/HomeView.java b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/HomeView.java new file mode 100644 index 0000000..17d0c06 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/HomeView.java @@ -0,0 +1,583 @@ +package io.github.makbn.vaadin.demo.views; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.FlexLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import com.vaadin.flow.router.Route; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.listener.event.ClickEvent; +import io.github.makbn.jlmap.listener.event.Event; +import io.github.makbn.jlmap.listener.event.MoveEvent; +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.vaadin.JLMapView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; + +/** + * @author Matt Akbarian (@makbn) + */ +@Route("") +public class HomeView extends FlexLayout implements OnJLActionListener> { + public static final String GITHUB_URL = "https://github.com/makbn/java_leaflet"; + private static final String MAP_API_KEY = "rNGhTaIpQWWH7C6QGKzF"; + private static final String LATITUDE = "Latitude"; + private static final String LONGITUDE = "Longitude"; + private static final String MENU_ITEM_CLASS = "jlmap-menu-item"; + private static final int NOTIFICATION_DURATION = 2000; + private final transient Logger log = LoggerFactory.getLogger(getClass()); + private final AtomicInteger defaultZoomLevel = new AtomicInteger(5); + + + private JLMapView mapView; + private static final Map PROVIDERS = new LinkedHashMap<>(); + + static { + PROVIDERS.put("OSM Mapnik", JLMapProvider.OSM_MAPNIK.build()); + PROVIDERS.put("OSM German", JLMapProvider.OSM_GERMAN.build()); + PROVIDERS.put("OSM French", JLMapProvider.OSM_FRENCH.build()); + PROVIDERS.put("OSM HOT", JLMapProvider.OSM_HOT.build()); + PROVIDERS.put("OSM Cycle", JLMapProvider.OSM_CYCLE.build()); + PROVIDERS.put("OpenTopoMap", JLMapProvider.OPEN_TOPO.build()); + PROVIDERS.put("MapTiler", JLMapProvider.MAP_TILER.parameter(new JLMapOption.Parameter("key", MAP_API_KEY)).build()); + } + + public HomeView() { + setSizeFull(); + setFlexDirection(FlexDirection.ROW); + getStyle().set("position", "relative"); + // Create the map view + mapView = JLMapView.builder() + .jlMapProvider(JLMapProvider.MAP_TILER.parameter(new JLMapOption.Parameter("key", MAP_API_KEY)).build()) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) // Paris + .showZoomController(false) + .build(); + mapView.setOnActionListener(this); + mapView.setSizeFull(); + add(mapView); + + mapView.addContextMenu() + .addItem("Refresh Map", + "Refresh the map tiles", + "https://cdn-icons-png.flaticon.com/512/3031/3031712.png") + .addItem("Developer", + "Developer", + "https://cdn-icons-png.flaticon.com/512/7838/7838138.png") + .addItem("Github", + "Github Repository", + "https://cdn-icons-png.flaticon.com/512/25/25231.png") + .setOnMenuItemListener(item -> { + if ("Refresh Map".equals(item.getId())) { + Notification.show("Map refresh triggered"); + } else if ("Developer".equals(item.getId())) { + Notification.show("Map developed by Matt Akbarian (@makbn)", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } else { + getUI().ifPresent(ui -> ui.getPage().open(GITHUB_URL, "_blank")); + } + }); + + // --- PIXEL PERFECT JLMap MENU --- + VerticalLayout menuWrapper = new VerticalLayout(); + menuWrapper.setClassName("jlmap-menu"); + menuWrapper.setPadding(false); + menuWrapper.setSpacing(false); + menuWrapper.setWidth(null); + menuWrapper.setMinWidth("270px"); + menuWrapper.setMaxWidth("320px"); + menuWrapper.setHeightFull(); + menuWrapper.setAlignItems(Alignment.STRETCH); + + VerticalLayout menuContent = new VerticalLayout(); + menuContent.setClassName("jlmap-menu-content"); + menuContent.setPadding(false); + menuContent.setSpacing(false); + menuContent.setWidthFull(); + menuContent.setHeightFull(); + menuContent.getStyle().set("flex-grow", "1"); + menuContent.getStyle().set("overflow-y", "auto"); + + // Helper to create section + java.util.function.BiFunction section = (title, items) -> { + VerticalLayout sec = new VerticalLayout(); + sec.setClassName("jlmap-menu-section"); + sec.setPadding(false); + sec.setSpacing(false); + Span label = new Span(title); + label.setClassName("jlmap-menu-section-title"); + sec.add(label); + sec.add(items); + return sec; + }; + + addTileProviderComponent(menuContent); + addJourneyDemoButton(menuContent, section); + addControlSection(menuContent, section); + addUiSection(menuContent, section); + addGeoJsonSection(menuContent, section); + addVectorSection(menuContent, section); + + // --- GitHub Footer --- + Anchor githubLink = new Anchor(GITHUB_URL, ""); + githubLink.setTarget("_blank"); + githubLink.setClassName("jlmap-menu-footer"); + githubLink.getElement().setProperty("innerHTML", " View on GitHub"); + + // Compose menu + menuWrapper.add(menuContent, githubLink); + add(menuWrapper); + } + + private void addJourneyDemoButton(VerticalLayout menuContent, java.util.function.BiFunction section) { + Button journeyDemoButton = new Button("Journey Demo"); + journeyDemoButton.setClassName(MENU_ITEM_CLASS); + journeyDemoButton.addClickListener(e -> getUI().ifPresent(ui -> ui.navigate(MyTripToCanada.class))); + menuContent.add(); + menuContent.add(section.apply("Demo Journey & Animation", new Button[]{journeyDemoButton})); + } + + private void addVectorSection(VerticalLayout menuContent, BiFunction section) { + Button drawCircle = new Button("Draw Circle", e -> + DialogBuilder.builder() + .decimalField(LATITUDE) + .decimalField(LONGITUDE) + .numberField("Radius").get(event -> + { + JLCircle circle = mapView.getVectorLayer() + .addCircle(JLLatLng.builder() + .lat((Double) event.get(LATITUDE)) + .lng((Double) event.get(LONGITUDE)) + .build(), + (Integer) event.get("Radius"), + JLOptions.DEFAULT.toBuilder().draggable(true).build()); + + circle.setOnActionListener((jlCircle, jlEvent) -> { + if (jlEvent.action() == JLAction.CLICK) { + jlCircle.setLatLng(JLLatLng.builder().lng(10).lng(10).build()); + jlCircle.getBounds().whenComplete((response, throwable) -> + Notification.show(String.format("Circle bound is '%s'", response), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + } else { + jlCircle.toGeoJSON().whenComplete((response, throwable) -> + Notification.show(String.format("Circle Geo Json is '%s' and Event: %s", response, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + } + }); + + circle.addContextMenu() + .addItem("Remove", "Remove this circle") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + circle.remove(); + Notification.show("Circle removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + })); + + + Button drawCircleMarker = new Button("Draw Circle Marker", e -> + DialogBuilder.builder() + .decimalField(LATITUDE) + .decimalField(LONGITUDE) + .numberField("Radius (pixels)") + .get(event -> { + JLCircleMarker circleMarker = mapView.getVectorLayer() + .addCircleMarker(JLLatLng.builder() + .lat((Double) event.get(LATITUDE)) + .lng((Double) event.get(LONGITUDE)) + .build(), + (Integer) event.get("Radius (pixels)"), + JLOptions.DEFAULT.toBuilder() + .color(JLColor.RED) + .build()); + + circleMarker.setOnActionListener((jlCircleMarker, jlEvent) -> + Notification.show(String.format("Circle Marker '%s' Event: %s", jlCircleMarker, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + circleMarker.addContextMenu() + .addItem("Remove", "Remove this circle marker") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + circleMarker.remove(); + Notification.show("Circle marker removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + })); + Button drawSimplePolyline = new Button("Draw Simple Polyline", e -> { + JLLatLng[] vertices = {new JLLatLng(48.864716, 2.349014), new JLLatLng(52.520008, 13.404954), new JLLatLng(41.902783, 12.496366), new JLLatLng(40.416775, -3.703790)}; + JLPolyline polyline = mapView.getVectorLayer().addPolyline(vertices, JLOptions.DEFAULT.toBuilder().color(JLColor.BLUE).weight(5).build()); + polyline.setOnActionListener((jlPolyline, jlEvent) -> Notification.show(String.format("Polyline '%s' Event: %s", jlPolyline, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + polyline.addContextMenu() + .addItem("Remove", "Remove this polyline") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + polyline.remove(); + Notification.show("Polyline removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + Notification.show("European Cities Route Added!", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + }); + Button drawCustomPolyline = new Button("Draw Custom Polyline", e -> DialogBuilder.builder().decimalField("Start Latitude").decimalField("Start Longitude").decimalField("Mid Latitude").decimalField("Mid Longitude").decimalField("End Latitude").decimalField("End Longitude").get(event -> { + JLLatLng[] vertices = {new JLLatLng((Double) event.get("Start Latitude"), (Double) event.get("Start Longitude")), new JLLatLng((Double) event.get("Mid Latitude"), (Double) event.get("Mid Longitude")), new JLLatLng((Double) event.get("End Latitude"), (Double) event.get("End Longitude"))}; + JLPolyline polyline = mapView.getVectorLayer().addPolyline(vertices, JLOptions.DEFAULT.toBuilder().color(JLColor.GREEN).weight(3).build()); + polyline.setOnActionListener((jlPolyline, jlEvent) -> Notification.show(String.format("Custom Polyline '%s' Event: %s", jlPolyline, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + polyline.addContextMenu() + .addItem("Remove", "Remove this custom polyline") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + polyline.remove(); + Notification.show("Custom polyline removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + })); + Button drawMultiPolyline = new Button("Draw Multi-Polyline", e -> { + JLLatLng[][] routes = {{new JLLatLng(59.334591, 18.063240), new JLLatLng(60.169857, 24.938379), new JLLatLng(55.676097, 12.568337)}, {new JLLatLng(50.075538, 14.437800), new JLLatLng(47.497912, 19.040235), new JLLatLng(48.208174, 16.373819)}}; + JLMultiPolyline multiPolyline = mapView.getVectorLayer().addMultiPolyline(routes, JLOptions.DEFAULT.toBuilder().color(JLColor.PURPLE).weight(4).build()); + multiPolyline.setOnActionListener((jlMultiPolyline, jlEvent) -> + Notification.show(String.format("Multi-Polyline '%s' Event: %s", jlMultiPolyline, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + multiPolyline.addContextMenu() + .addItem("Remove", "Remove this multi-polyline") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + multiPolyline.remove(); + Notification.show("Multi-polyline removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + Notification.show("Multi-Route Network Added!"); + }); + Button drawTrianglePolygon = new Button("Draw Triangle Polygon", e -> { + JLLatLng[][][] triangleVertices = {{{new JLLatLng(48.864716, 2.349014), new JLLatLng(48.874716, 2.339014), new JLLatLng(48.854716, 2.339014), new JLLatLng(48.864716, 2.349014)}}}; + JLPolygon polygon = mapView.getVectorLayer().addPolygon(triangleVertices, JLOptions.DEFAULT.toBuilder().color(JLColor.ORANGE).fillColor(JLColor.YELLOW).fillOpacity(0.3).build()); + polygon.setOnActionListener((jlPolygon, jlEvent) -> + Notification.show(String.format("Triangle Polygon '%s' Event: %s", jlPolygon, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + polygon.addContextMenu() + .addItem("Remove", "Remove this triangle polygon") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + polygon.remove(); + Notification.show("Triangle polygon removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + Notification.show("Triangle Polygon Added around Paris!", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + }); + Button drawCustomPolygon = new Button("Draw Custom Polygon", e -> DialogBuilder.builder().decimalField("Center Latitude").decimalField("Center Longitude").decimalField("Size (degrees)").get(event -> { + Double centerLat = (Double) event.get("Center Latitude"); + Double centerLng = (Double) event.get("Center Longitude"); + Double size = (Double) event.get("Size (degrees)"); + JLLatLng[][][] squareVertices = {{{new JLLatLng(centerLat + size, centerLng - size), new JLLatLng(centerLat + size, centerLng + size), new JLLatLng(centerLat - size, centerLng + size), new JLLatLng(centerLat - size, centerLng - size), new JLLatLng(centerLat + size, centerLng - size)}}}; + JLPolygon polygon = mapView.getVectorLayer().addPolygon(squareVertices, JLOptions.DEFAULT.toBuilder().color(JLColor.RED).fillColor(new JLColor(0.0, 1.0, 1.0)).fillOpacity(0.5).weight(3).build()); + polygon.setOnActionListener((jlPolygon, jlEvent) -> + Notification.show(String.format("Custom Polygon '%s' Event: %s", jlPolygon, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + polygon.addContextMenu() + .addItem("Remove", "Remove this custom polygon") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + polygon.remove(); + Notification.show("Custom polygon removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + })); + Button drawPolygonWithHole = new Button("Draw Polygon with Hole", e -> { + JLLatLng[][][] donutVertices = {{{new JLLatLng(48.874716, 2.329014), new JLLatLng(48.874716, 2.369014), new JLLatLng(48.854716, 2.369014), new JLLatLng(48.854716, 2.329014), new JLLatLng(48.874716, 2.329014)}, {new JLLatLng(48.869716, 2.339014), new JLLatLng(48.869716, 2.359014), new JLLatLng(48.859716, 2.359014), new JLLatLng(48.859716, 2.339014), new JLLatLng(48.869716, 2.339014)}}}; + JLPolygon donutPolygon = mapView.getVectorLayer().addPolygon(donutVertices, JLOptions.DEFAULT.toBuilder().color(new JLColor(0.0, 0.5, 0.0)).fillColor(new JLColor(0.5, 1.0, 0.5)).fillOpacity(0.7).weight(2).build()); + donutPolygon.setOnActionListener((jlPolygon, jlEvent) -> + Notification.show(String.format("Donut Polygon '%s' Event: %s", jlPolygon, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + donutPolygon.addContextMenu() + .addItem("Remove", "Remove this donut polygon") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + donutPolygon.remove(); + Notification.show("Donut polygon removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + Notification.show("Donut-shaped Polygon Added!", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + }); + Button demoAllShapes = new Button("Demo All Vector Shapes", e -> { + JLCircle demoCircle = mapView.getVectorLayer().addCircle(new JLLatLng(48.864716, 2.349014), 5000, JLOptions.DEFAULT.toBuilder().color(JLColor.BLUE).fillOpacity(0.2).build()); + demoCircle.addContextMenu() + .addItem("Remove", "Remove demo circle") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + demoCircle.remove(); + Notification.show("Demo circle removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + JLCircleMarker demoCircleMarker = mapView.getVectorLayer().addCircleMarker(new JLLatLng(48.874716, 2.359014), 10, JLOptions.DEFAULT.toBuilder().color(JLColor.RED).build()); + demoCircleMarker.addContextMenu() + .addItem("Remove", "Remove demo circle marker") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + demoCircleMarker.remove(); + Notification.show("Demo circle marker removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + JLLatLng[] lineVertices = {new JLLatLng(48.854716, 2.339014), new JLLatLng(48.864716, 2.359014)}; + JLPolyline demoPolyline = mapView.getVectorLayer().addPolyline(lineVertices, JLOptions.DEFAULT.toBuilder().color(JLColor.GREEN).weight(3).build()); + demoPolyline.addContextMenu() + .addItem("Remove", "Remove demo polyline") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + demoPolyline.remove(); + Notification.show("Demo polyline removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + JLLatLng[][][] polygonVertices = {{{new JLLatLng(48.869716, 2.344014), new JLLatLng(48.869716, 2.354014), new JLLatLng(48.859716, 2.354014), new JLLatLng(48.859716, 2.344014), new JLLatLng(48.869716, 2.344014)}}}; + JLPolygon demoPolygon = mapView.getVectorLayer().addPolygon(polygonVertices, JLOptions.DEFAULT.toBuilder().color(JLColor.PURPLE).fillColor(JLColor.YELLOW).fillOpacity(0.4).build()); + demoPolygon.addContextMenu() + .addItem("Remove", "Remove demo polygon") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + demoPolygon.remove(); + Notification.show("Demo polygon removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + Notification.show("All vector shapes demonstrated! Check the map.", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + }); + for (Button b : new Button[]{drawCircle, drawCircleMarker, drawSimplePolyline, drawCustomPolyline, drawMultiPolyline, drawTrianglePolygon, drawCustomPolygon, drawPolygonWithHole, demoAllShapes}) + b.setClassName(MENU_ITEM_CLASS); + menuContent.add(section.apply("Vector Layer", new Button[]{drawCircle, drawCircleMarker, drawSimplePolyline, drawCustomPolyline, drawMultiPolyline, drawTrianglePolygon, drawCustomPolygon, drawPolygonWithHole, demoAllShapes})); + } + + private void addGeoJsonSection(VerticalLayout menuContent, BiFunction section) { + //noinspection unchecked + Button loadGeoJson = new Button("Load GeoJSON file", e -> + DialogBuilder.builder() + .addUpload() + .get(event -> + ((Set) event.get("uploadedFiles")).forEach(file -> { + JLGeoJson geoJson = mapView.getGeoJsonLayer().addFromFile(file); + geoJson.addContextMenu() + .addItem("Remove", "Remove this GeoJSON layer") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + geoJson.remove(); + Notification.show("GeoJSON layer removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + }))); + + + loadGeoJson.setClassName(MENU_ITEM_CLASS); + + Button addUsOutlineGeoJson = new Button("US Outline GeoJSON Url", e -> { + try { + JLGeoJson usOutlineGeoJson = mapView.getGeoJsonLayer().addFromUrl("https://eric.clst.org/assets/wiki/uploads/Stuff/gz_2010_us_outline_5m.json"); + usOutlineGeoJson.addContextMenu() + .addItem("Remove", "Remove US outline GeoJSON") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + usOutlineGeoJson.remove(); + Notification.show("US outline GeoJSON removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + Notification.show("US Outline GeoJSON added to map.", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } catch (Exception ex) { + Notification.show("Failed to load GeoJSON: " + ex.getMessage(), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + log.error("Failed to load GeoJSON", ex); + } + }); + addUsOutlineGeoJson.setClassName(MENU_ITEM_CLASS); + + Button flyToNYC = new Button("Fly to NYC", e -> { + // Fly to New York City to see the demo features + mapView.getControlLayer().flyTo(new JLLatLng(40.7831, -73.9712), 12); + Notification.show("Flying to New York City!", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + }); + flyToNYC.setClassName(MENU_ITEM_CLASS); + + Button demoStyleFunctions = new Button("Demo Style Functions", e -> + // Create a sample GeoJSON with different feature types + DialogBuilder.builder() + .addUpload() + .get(this::loadStyledGeoJson)); + demoStyleFunctions.setClassName(MENU_ITEM_CLASS); + + menuContent.add(section.apply("Geo Json Layer", new Button[]{loadGeoJson, addUsOutlineGeoJson, flyToNYC, demoStyleFunctions})); + } + + private void loadStyledGeoJson(Map event) { + // Create GeoJSON options with style function + JLGeoJsonOptions options = JLGeoJsonOptions.builder() + .styleFunction(features -> JLOptions.builder() + .fill(true) + .fillColor(JLColor.fromHex((String) features.get(0).get("fill"))) + .fillOpacity((Double) features.get(0).get("fill-opacity")) + .stroke(true) + .color(JLColor.fromHex((String) features.get(0).get("stroke"))) + .build()) + .filter(features -> { + Map featureProperties = features.get(0); + // Show features with population > 1M, rivers longer than 500km, or parks larger than 100 hectares + return ((Integer) featureProperties.get("id")) % 2 == 0; + }).build(); + + try { + // First fly to Canada to see the features + mapView.getControlLayer().flyTo(new JLLatLng(51.76, -114.06), 5); + + JLGeoJson geoJson = mapView.getGeoJsonLayer().addFromFile(((Set) event.get("uploadedFiles")).iterator().next(), options); + geoJson.setOnActionListener((jlGeoJson, event1) -> + Notification.show("GeoJSON Feature clicked: " + event1, NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + geoJson.addContextMenu() + .addItem("Remove", "Remove styled GeoJSON layer") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + geoJson.remove(); + Notification.show("Styled GeoJSON layer removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + } catch (Exception ex) { + Notification.show("Failed to add GeoJSON: " + ex.getMessage(), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + log.error("Failed to add styled GeoJSON", ex); + } + } + + private void addUiSection(VerticalLayout menuContent, BiFunction section) { + Button addMarker = new Button("Add Marker", e -> + DialogBuilder.builder() + .decimalField(LATITUDE) + .decimalField(LONGITUDE) + .textField("Text") + .get(event -> { + + JLMarker marker = mapView.getUiLayer().addMarker(JLLatLng.builder() + .lat((Double) event.get(LATITUDE)) + .lng((Double) event.get(LONGITUDE)) + .build(), (String) event.get("Text"), true); + + marker.setOnActionListener((jlMarker, event1) -> { + if (event1 instanceof MoveEvent) { + Notification.show("Marker moved: " + jlMarker + " -> " + event1.action(), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } else if (event1 instanceof ClickEvent) { + Notification.show("Marker clicked: " + jlMarker, NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + marker.getPopup().setOnActionListener((jlPopup, jlEvent) -> + Notification.show(String.format("Marker's Popup '%s' Event: %s", jlPopup, jlEvent), NOTIFICATION_DURATION, Notification.Position.BOTTOM_END)); + + marker.addContextMenu() + .addItem("Remove", "Remove this marker") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + marker.remove(); + Notification.show("Marker removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + })); + + addMarker.setClassName(MENU_ITEM_CLASS); + + // --- Add Eiffel Tower Overlay Button --- + Button addEiffelOverlay = new Button("Add Eiffel Tower Overlay", e -> { + // Eiffel Tower location in Paris + double swLat = 47.857; // Southwest corner + double swLng = 3.293; + double neLat = 49.860; // Northeast corner + double neLng = 1.298; + JLBounds bounds = JLBounds.builder() + .southWest(new JLLatLng(swLat, swLng)) + .northEast(new JLLatLng(neLat, neLng)) + .build(); + String imageUrl = "https://img.favpng.com/1/24/8/eiffel-tower-eiffel-tower-illustrated-landmark-L5szYqrZ_t.jpg"; + JLImageOverlay imageOverlay = mapView.getUiLayer().addImage(bounds, imageUrl, JLOptions.DEFAULT); + + imageOverlay.addContextMenu() + .addItem("Remove", "Remove Eiffel Tower overlay") + .setOnMenuItemListener(item -> { + if ("Remove".equals(item.getId())) { + imageOverlay.remove(); + Notification.show("Eiffel Tower overlay removed from map", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } + }); + + Notification.show("Eiffel Tower overlay added!", NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + }); + addEiffelOverlay.setClassName(MENU_ITEM_CLASS); + + menuContent.add(section.apply("UI Layer", new Button[]{addMarker, addEiffelOverlay})); + } + + private void addControlSection(VerticalLayout menuContent, BiFunction section) { + Button zoomIn = new Button("Zoom in", e -> mapView.getControlLayer().setZoom(defaultZoomLevel.addAndGet(1))); + Button zoomOut = new Button("Zoom out", e -> mapView.getControlLayer().setZoom(defaultZoomLevel.addAndGet(-1))); + Button fitWorld = new Button("Fit World", e -> mapView.getControlLayer().fitWorld()); + Button maxZoom = new Button("Max Zoom", e -> + DialogBuilder.builder().numberField("Max zoom level").get(event -> + mapView.getControlLayer().setMaxZoom((Integer) event.get("Max zoom level")))); + + Button minZoom = new Button("Min Zoom", e -> + DialogBuilder.builder().numberField("Min zoom level").get(event -> + mapView.getControlLayer().setMinZoom((Integer) event.get("Min zoom level")))); + Button flyTo = new Button("Fly to", e -> + DialogBuilder.builder() + .decimalField(LATITUDE) + .decimalField(LONGITUDE) + .numberField("Zoom level") + .get(event -> + mapView.getControlLayer().flyTo(JLLatLng.builder().lat((Double) event.get(LATITUDE)).lng((Double) event.get(LONGITUDE)).build(), (Integer) event.get("Zoom level")))); + + for (Button b : new Button[]{zoomIn, zoomOut, fitWorld, maxZoom, minZoom, flyTo}) + b.setClassName(MENU_ITEM_CLASS); + menuContent.add(section.apply("Control Layer", new Button[]{zoomIn, zoomOut, fitWorld, maxZoom, minZoom, flyTo})); + } + + private void addTileProviderComponent(VerticalLayout menuContent) { + ComboBox mapProviderComboBox = new ComboBox<>(); + mapProviderComboBox.setItems(PROVIDERS.keySet()); + mapProviderComboBox.setLabel("Map Provider"); + mapProviderComboBox.setValue("MapTiler"); + mapProviderComboBox.setWidthFull(); + mapProviderComboBox.getStyle().set("margin-bottom", "16px"); + mapProviderComboBox.addValueChangeListener(event -> { + String selected = event.getValue(); + if (selected != null && PROVIDERS.containsKey(selected)) { + JLMapProvider provider = PROVIDERS.get(selected); + // Remove old mapView + remove(mapView); + JLMapView newMapView = JLMapView.builder() + .jlMapProvider(provider) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) + .showZoomController(false) + .build(); + newMapView.setOnActionListener(this); + newMapView.setSizeFull(); + addComponentAtIndex(0, newMapView); + mapView = newMapView; + } + }); + menuContent.add(mapProviderComboBox); + } + + @Override + public void onAction(JLMap source, Event event) { + Notification.show("Map event: " + event, NOTIFICATION_DURATION, Notification.Position.BOTTOM_END); + } +} diff --git a/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/MyTripToCanada.java b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/MyTripToCanada.java new file mode 100644 index 0000000..ba6c929 --- /dev/null +++ b/jlmap-vaadin-demo/src/main/java/io/github/makbn/vaadin/demo/views/MyTripToCanada.java @@ -0,0 +1,542 @@ +package io.github.makbn.vaadin.demo.views; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.messages.MessageList; +import com.vaadin.flow.component.messages.MessageListItem; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.FlexLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.vaadin.JLMapView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * My Trip to Canada - An animated journey visualization + * + * @author Matt Akbarian (@makbn) + */ + +@Route("my-trip-to-canada") +public class MyTripToCanada extends VerticalLayout { + private static final String MAP_API_KEY = "rNGhTaIpQWWH7C6QGKzF"; + private static final Logger log = LoggerFactory.getLogger(MyTripToCanada.class); + private static final String TRANSPARENT = "#00FFFFFF"; + + // Journey coordinates + private static final JLLatLng SARI = new JLLatLng(36.5633, 53.0601); + private static final JLLatLng TEHRAN = new JLLatLng(35.6892, 51.3890); + private static final JLLatLng DOHA = new JLLatLng(25.2854, 51.5310); + private static final JLLatLng MONTREAL = new JLLatLng(45.5017, -73.5673); + private static final JLLatLng CALGARY = new JLLatLng(51.0447, -114.0719); + + // Custom icons for different stages of the journey + private static final JLIcon CAR_ICON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/3097/3097220.png") + .iconSize(new JLPoint(64, 64)) + .iconAnchor(new JLPoint(24, 24)) + .build(); + + private static final JLIcon AIRPLANE_ICON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/3182/3182857.png") + .iconSize(new JLPoint(64, 64)) + .shadowAnchor(new JLPoint(26, 26)) + .iconAnchor(new JLPoint(24, 24)) + .build(); + private static final JLIcon EAST_AIRPLANE_ICON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/1058/1058318.png") + .iconSize(new JLPoint(64, 64)) + .shadowAnchor(new JLPoint(26, 26)) + .iconAnchor(new JLPoint(24, 24)) + .build(); + + private static final JLIcon BALLOON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/1926/1926313.png") + .iconSize(new JLPoint(64, 64)) + .iconAnchor(new JLPoint(24, 24)) + .build(); + + private static final JLIcon BRIEFCASE_ICON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/5376/5376980.png") + .iconSize(new JLPoint(64, 64)) + .iconAnchor(new JLPoint(24, 24)) + .build(); + + private static final JLIcon DOCUMENT_ICON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/3127/3127363.png") + .iconSize(new JLPoint(64, 64)) + .iconAnchor(new JLPoint(24, 24)) + .build(); + private static final JLIcon PASSPORT_ICON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/18132/18132911.png") + .iconSize(new JLPoint(64, 64)) + .iconAnchor(new JLPoint(24, 24)) + .build(); + + private static final JLIcon HOUSE_ICON = JLIcon.builder() + .iconUrl("https://cdn-icons-png.flaticon.com/512/3750/3750400.png") + .iconSize(new JLPoint(64, 64)) + .iconAnchor(new JLPoint(24, 48)) + .build(); + + private final List messages = new ArrayList<>(); + private final JLMapView mapView; + private final MessageList messageList; + + private transient JLMarker currentMarker; + private transient JLPolyline currentPath; + + public MyTripToCanada() { + setSizeFull(); + setPadding(false); + setSpacing(false); + + // Main content layout with map + FlexLayout mainContent = new FlexLayout(); + mainContent.setSizeFull(); + mainContent.getStyle().set("position", "relative"); + + // Create the map view + mapView = JLMapView.builder() + .jlMapProvider(JLMapProvider.WATER_COLOR + .parameter(new JLMapOption.Parameter("key", MAP_API_KEY)) + .parameter(new JLMapOption.Parameter("initialZoom", "4")) + .build()) + .startCoordinate(SARI) + .showZoomController(false) + .build(); + mapView.setSizeFull(); + + // Create menu overlay (similar to HomeView) + VerticalLayout menuWrapper = new VerticalLayout(); + menuWrapper.setClassName("jlmap-menu"); + + menuWrapper.setPadding(false); + menuWrapper.setSpacing(false); + menuWrapper.setWidth(null); + menuWrapper.setMaxHeight("450px"); + menuWrapper.setMinWidth("280px"); + menuWrapper.setMaxWidth("350px"); + menuWrapper.setHeight(null); + menuWrapper.getStyle() + .set("position", "absolute") + .set("bottom", "20px") + .set("top", "80% !important") + .set("left", "20px") + .set("z-index", "1000") + .set("background", "rgba(255, 255, 255, 0.95)") + .set("border-radius", "8px") + .set("box-shadow", "0 4px 12px rgba(0,0,0,0.15)") + .set("padding", "16px"); + + // Menu content + VerticalLayout menuContent = new VerticalLayout(); + menuContent.setPadding(false); + menuContent.setSpacing(false); + menuContent.setWidthFull(); + + // Title + Span menuTitle = new Span("Java Leaflet: Vaadin"); + menuTitle.getStyle() + .set("font-size", "1.1em") + .set("font-weight", "600") + .set("color", "var(--lumo-header-text-color)") + .set("display", "block") + .set("margin-bottom", "12px"); + + // Buttons + Button startButton = new Button("✈️ Start Journey"); + startButton.setWidthFull(); + startButton.getStyle().set("margin-bottom", "8px"); + startButton.addClickListener(e -> { + startButton.setEnabled(false); + clearMessages(); + startJourney(); + }); + + Button resetButton = new Button("🔄 Reset"); + resetButton.setWidthFull(); + resetButton.addClickListener(e -> { + resetJourney(); + clearMessages(); + startButton.setEnabled(true); + }); + + Button backButton = new Button("⬅️ Back to Home"); + backButton.setWidthFull(); + backButton.addClickListener(e -> + UI.getCurrent().navigate(HomeView.class)); + + // GitHub footer + Anchor githubLink = new Anchor("https://github.com/makbn/java_leaflet", ""); + githubLink.setTarget("_blank"); + githubLink.getStyle() + .set("display", "flex") + .set("align-items", "center") + .set("justify-content", "center") + .set("margin-top", "12px") + .set("padding-top", "12px") + .set("border-top", "1px solid var(--lumo-contrast-10pct)") + .set("color", "var(--lumo-secondary-text-color)") + .set("text-decoration", "none") + .set("font-size", "0.875em"); + githubLink.getElement().setProperty("innerHTML", + "" + + "View on GitHub"); + + menuContent.add(menuTitle, startButton, resetButton, backButton, githubLink); + menuWrapper.add(menuContent); + + // Message list panel + VerticalLayout messagePanel = new VerticalLayout(); + messagePanel.setWidth("380px"); + messagePanel.getStyle() + .set("border-left", "1px solid var(--lumo-contrast-10pct)"); + messagePanel.setPadding(false); + messagePanel.setSpacing(false); + + H3 messageTitle = new H3("📝 Journey Log"); + messageTitle.getStyle() + .set("margin", "0") + .set("padding", "var(--lumo-space-m)"); + + messageList = new MessageList(); + messageList.setItems(messages); + + messagePanel.add(messageTitle, messageList); + messagePanel.expand(messageList); + + // Add components to main content + mainContent.add(mapView, messagePanel); + mainContent.expand(mapView); + + // Add menu overlay on top of map + mapView.getElement().appendChild(menuWrapper.getElement()); + + add(mainContent); + } + + private void startJourney() { + log.info("Starting journey animation"); + resetJourney(); + + addMessage("🎬", "Journey begins! Buckle up for an adventure!", "Driver"); + log.info("About to animate first segment: Sari to Tehran"); + + // Step 1: Car from Sari to Tehran (3 seconds) + animateSegment( + SARI, + TEHRAN, + CAR_ICON, + "#FF5722", + 3000, + 7, + "Sari, Iran", + "Tehran, Iran", + () -> { + addMessage("🚗", "Departed from beautiful Sari! Driving through scenic routes to Tehran...", "Driver"); + // Step 2: Briefcase and passport (1.5 seconds) + addMessage("🏙️", "Arrived in Tehran! Time to pack my bags and grab my passport!", "Driver"); + showTransition(TEHRAN, BRIEFCASE_ICON, 1500, () -> { + // Step 3: Airplane Tehran to Doha (4 seconds) + addMessage("✈️", "Taking off from Tehran! Soaring through the clouds to Doha...", "Qatar Airways"); + animateSegment( + TEHRAN, + DOHA, + AIRPLANE_ICON, + "#2196F3", + 4000, + 5, + "Tehran, Iran", + "Doha, Qatar", + () -> { + // Step 4: Transit in Doha (1.5 seconds) + addMessage("🛬", "Landed in Doha! Quick layover for coffee and passport check ☕", "Doha Airport"); + showTransition(DOHA, PASSPORT_ICON, 1500, () -> { + // Step 5: Airplane Doha to Montreal (5 seconds) + addMessage("🌍", "Crossing the Atlantic! Long flight ahead but Canada awaits! 🇨🇦", "Qatar Airways"); + animateSegment( + DOHA, + MONTREAL, + EAST_AIRPLANE_ICON, + "#2196F3", + 5000, + 3, + "Doha, Qatar", + "Montreal, Canada", + () -> { + // Step 6: Customs in Montreal (1.5 seconds) + addMessage("🍁", "Bonjour Montreal! Going through customs and immigration...", "YUL Airport"); + showTransition(MONTREAL, DOCUMENT_ICON, 1500, () -> { + // Step 7: Domestic flight to Calgary (4 seconds) + addMessage("🛫", "Domestic flight time! Heading west to the Rockies!", "Air Canada"); + animateSegment( + MONTREAL, + CALGARY, + BALLOON, + "#E91E63", + 8000, + 6, + "Montreal, Canada", + "Calgary, Canada", + () -> { + // Step 8: Arrived in Calgary + addMessage("🏠", "FINALLY HOME in Calgary! What an amazing journey! 🎉", "YYC Airport"); + showTransition(CALGARY, HOUSE_ICON, 2000, () -> { + addMessage("🎊", "Journey complete! Time to mow your lawn and shovel the snow! 🏔️", "HOA manager"); + Notification.show("🎉 Welcome to Calgary, Canada! Journey Complete!", + 5000, + Notification.Position.TOP_CENTER); + }); + } + ); + }); + } + ); + }); + } + ); + }); + } + ); + } + + private void animateSegment(JLLatLng start, JLLatLng end, JLIcon icon, String pathColor, + int duration, int zoomLevel, String departureName, String destinationName, + Runnable onComplete) { + log.info("Animating segment from {} to {} with icon {}", start, end, icon); + // Fly to show the route + JLLatLng midPoint = new JLLatLng( + (start.getLat() + end.getLat()) / 2, + (start.getLng() + end.getLng()) / 2 + ); + log.info("Flying to midpoint: {}", midPoint); + mapView.getControlLayer().flyTo(midPoint, zoomLevel); + + // Add popup at departure + JLPopup departurePopup = mapView.getUiLayer().addPopup(start, + "
📍 Departure: " + departureName + "
"); + + // Remove previous path if exists + if (currentPath != null) { + log.info("Removing previous path"); + currentPath.remove(); + } + + // Create animated path with more points for smoother animation (10x more) + JLLatLng[] pathPoints = createCurvedPath(start, end, 300); + log.info("Created path with {} points", pathPoints.length); + + + // Add popup at destination + JLPopup destinationPopup = mapView.getUiLayer().addPopup(end, + "
🎯 Destination: " + destinationName + "
"); + + // Animate marker along path + log.info("Starting marker animation"); + UI.getCurrent().push(); + animateMarkerAlongPath(icon, pathPoints, pathColor, duration, () -> { + // Remove popups after animation + departurePopup.remove(); + destinationPopup.remove(); + + // Call the original onComplete callback + if (onComplete != null) { + onComplete.run(); + } + }); + } + + private void animateMarkerAlongPath(JLIcon icon, JLLatLng[] path, String pathColor, int duration, Runnable onComplete) { + UI ui = UI.getCurrent(); + + // Increase animation steps to 200 for smoother animation (10x more than before) + int totalSteps = Math.min(200, path.length); + int delayPerStep = duration / totalSteps; + + log.info("Animating marker with icon {} along {} steps, delay per step: {}ms", icon, totalSteps, delayPerStep); + + // Create the marker once at starting position + if (currentMarker == null) { + currentMarker = mapView.getUiLayer().addMarker(path[0], null, false); + currentMarker.setIcon(icon); + } else { + currentMarker.setLatLng(path[0]); + currentMarker.setIcon(icon); + } + + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + AtomicInteger currentStep = new AtomicInteger(0); + + executor.scheduleAtFixedRate(() -> { + int step = currentStep.getAndIncrement(); + if (step == 1) { + ui.access(() -> { + currentPath = mapView.getVectorLayer().addPolyline( + path, + JLOptions.DEFAULT.toBuilder() + .fillColor(JLColor.fromHex(TRANSPARENT)) + .color(JLColor.fromHex(pathColor)) + .stroke(true) + .fill(false) + .weight(4) + .opacity(0.7) + .build() + ); + log.info("Added polyline to map"); + }); + } + + if (step <= totalSteps) { + // Calculate which point in the path to use + int pathIndex = (step * (path.length - 1)) / totalSteps; + JLLatLng position = path[pathIndex]; + + ui.access(() -> { + try { + if (currentMarker != null) { + log.debug("Step {}/{}: Moving marker to position {}", step, totalSteps, position); + currentMarker.setLatLng(position); + UI.getCurrent().push(); + } + } catch (Exception e) { + log.error("Error moving marker at step {}: {}", step, e.getMessage(), e); + } + }); + } else { + log.info("Animation complete, shutting down executor"); + executor.shutdown(); + if (onComplete != null) { + ui.access(onComplete::run); + } + } + }, 1000, delayPerStep, TimeUnit.MILLISECONDS); + } + + private void showTransition(JLLatLng position, JLIcon icon, int duration, Runnable onComplete) { + UI ui = UI.getCurrent(); + + log.info("Showing transition at {} with icon {}", position, icon); + + // Just update the marker position and icon, don't remove + if (currentMarker == null) { + currentMarker = mapView.getUiLayer().addMarker(position, null, false); + currentMarker.setIcon(icon); + } else { + currentMarker.setLatLng(position); + currentMarker.setIcon(icon); + } + + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + executor.schedule(() -> { + ui.access(() -> { + if (onComplete != null) { + onComplete.run(); + } + }); + executor.shutdown(); + }, duration, TimeUnit.MILLISECONDS); + } + + private JLLatLng[] createCurvedPath(JLLatLng start, JLLatLng end, int points) { + JLLatLng[] path = new JLLatLng[points]; + + // Create a smooth curved path using quadratic bezier curve + double midLat = (start.getLat() + end.getLat()) / 2; + double midLng = (start.getLng() + end.getLng()) / 2; + + // Add curvature + double offsetLat = (end.getLng() - start.getLng()) * 0.2; + double offsetLng = -(end.getLat() - start.getLat()) * 0.2; + + JLLatLng control = new JLLatLng(midLat + offsetLat, midLng + offsetLng); + + for (int i = 0; i < points; i++) { + double t = (double) i / (points - 1); + + // Bezier point + double lat = Math.pow(1 - t, 2) * start.getLat() + + 2 * (1 - t) * t * control.getLat() + + Math.pow(t, 2) * end.getLat(); + double lng = Math.pow(1 - t, 2) * start.getLng() + + 2 * (1 - t) * t * control.getLng() + + Math.pow(t, 2) * end.getLng(); + + // Bezier derivative (tangent vector) + double dLat = 2 * (1 - t) * (control.getLat() - start.getLat()) + + 2 * t * (end.getLat() - control.getLat()); + double dLng = 2 * (1 - t) * (control.getLng() - start.getLng()) + + 2 * t * (end.getLng() - control.getLng()); + + // Perpendicular vector (normal) + double normalLat = -dLng; + double normalLng = dLat; + + // Normalize the normal vector + double length = Math.sqrt(normalLat * normalLat + normalLng * normalLng); + if (length != 0) { + normalLat /= length; + normalLng /= length; + } + double distance = start.distanceTo(end) / 100000; // Adjust for map scale + // Add a sinusoidal offset for wiggle + double wiggleAmplitude = 0.2 * Math.log(distance); // Adjust amplitude + double wiggleFrequency = 1.5 * Math.log(distance); // Number of wiggles along the path + double wiggle = Math.sin(t * Math.PI * wiggleFrequency) * wiggleAmplitude; + + double finalLat = lat + normalLat * wiggle; + double finalLng = lng + normalLng * wiggle; + + path[i] = new JLLatLng(finalLat, finalLng); + } + + return path; + } + + + private void resetJourney() { + if (currentMarker != null) { + currentMarker.remove(); + currentMarker = null; + } + if (currentPath != null) { + currentPath.remove(); + currentPath = null; + } + + // Reset view to starting position + mapView.getControlLayer().flyTo(SARI, 4); + } + + private void addMessage(String icon, String message, String username) { + UI.getCurrent().access(() -> { + MessageListItem item = new MessageListItem( + icon + " " + message, + Instant.now(), + username + ); + + messages.add(item); + messageList.setItems(messages); + }); + } + + private void clearMessages() { + messages.clear(); + messageList.setItems(messages); + } +} diff --git a/jlmap-vaadin-demo/src/main/resources/META-INF/resources/icons/icon.png b/jlmap-vaadin-demo/src/main/resources/META-INF/resources/icons/icon.png new file mode 100644 index 0000000..df2ed4b Binary files /dev/null and b/jlmap-vaadin-demo/src/main/resources/META-INF/resources/icons/icon.png differ diff --git a/jlmap-vaadin-demo/src/main/resources/META-INF/resources/images/empty-plant.png b/jlmap-vaadin-demo/src/main/resources/META-INF/resources/images/empty-plant.png new file mode 100644 index 0000000..9777f26 Binary files /dev/null and b/jlmap-vaadin-demo/src/main/resources/META-INF/resources/images/empty-plant.png differ diff --git a/jlmap-vaadin-demo/src/main/resources/banner.txt b/jlmap-vaadin-demo/src/main/resources/banner.txt new file mode 100644 index 0000000..519a2bc --- /dev/null +++ b/jlmap-vaadin-demo/src/main/resources/banner.txt @@ -0,0 +1,6 @@ + __ __ _ + | \/ |_ _ / \ _ __ _ __ + | |\/| | | | | / _ \ | '_ \| '_ \ + | | | | |_| | / ___ \| |_) | |_) | + |_| |_|\__, | /_/ \_\ .__/| .__/ + |___/ |_| |_| diff --git a/jlmap-vaadin-demo/tsconfig.json b/jlmap-vaadin-demo/tsconfig.json new file mode 100644 index 0000000..e6840ae --- /dev/null +++ b/jlmap-vaadin-demo/tsconfig.json @@ -0,0 +1,39 @@ +// This TypeScript configuration file is generated by vaadin-maven-plugin. +// This is needed for TypeScript compiler to compile your TypeScript code in the project. +// It is recommended to commit this file to the VCS. +// You might want to change the configurations to fit your preferences +// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html +{ + "_version": "9.1", + "compilerOptions": { + "sourceMap": true, + "jsx": "react-jsx", + "inlineSources": true, + "module": "esNext", + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "baseUrl": "src/main/frontend", + "paths": { + "@vaadin/flow-frontend": ["generated/jar-resources"], + "@vaadin/flow-frontend/*": ["generated/jar-resources/*"], + "Frontend/*": ["*"] + } + }, + "include": [ + "src/main/frontend/**/*", + "types.d.ts" + ], + "exclude": [ + "src/main/frontend/generated/jar-resources/**" + ] +} diff --git a/jlmap-vaadin-demo/types.d.ts b/jlmap-vaadin-demo/types.d.ts new file mode 100644 index 0000000..eff230b --- /dev/null +++ b/jlmap-vaadin-demo/types.d.ts @@ -0,0 +1,17 @@ +// This TypeScript modules definition file is generated by vaadin-maven-plugin. +// You can not directly import your different static files into TypeScript, +// This is needed for TypeScript compiler to declare and export as a TypeScript module. +// It is recommended to commit this file to the VCS. +// You might want to change the configurations to fit your preferences +declare module '*.css?inline' { + import type { CSSResultGroup } from 'lit'; + const content: CSSResultGroup; + export default content; +} + +// Allow any CSS Custom Properties +declare module 'csstype' { + interface Properties { + [index: `--${string}`]: any; + } +} diff --git a/jlmap-vaadin-demo/vite.config.ts b/jlmap-vaadin-demo/vite.config.ts new file mode 100644 index 0000000..4d6a022 --- /dev/null +++ b/jlmap-vaadin-demo/vite.config.ts @@ -0,0 +1,9 @@ +import { UserConfigFn } from 'vite'; +import { overrideVaadinConfig } from './vite.generated'; + +const customConfig: UserConfigFn = (env) => ({ + // Here you can add custom Vite parameters + // https://vitejs.dev/config/ +}); + +export default overrideVaadinConfig(customConfig); diff --git a/jlmap-vaadin/README.md b/jlmap-vaadin/README.md new file mode 100644 index 0000000..7fc8261 --- /dev/null +++ b/jlmap-vaadin/README.md @@ -0,0 +1,93 @@ +# Java Leaflet (JLeaflet) - Vaadin Implementation + +This module provides a Vaadin implementation of the Java Leaflet API. It allows you to easily integrate Leaflet maps into your Vaadin applications while maintaining a consistent API with other implementations. + +## Features + +- Seamless integration with Vaadin Flow +- Consistent API with other JLeaflet implementations +- Support for markers, popups, and other map elements +- Event handling for map interactions + +## Requirements + +- Java 17 or higher +- Vaadin 24.3.5 or higher + +## Usage + +### Maven Dependency + +```xml + + io.github.makbn + jlmap-vaadin + 2.0.0 + +``` + +### Basic Example + +```java +// Create a map view +JLMapView mapView = JLMapView.builder() + .JLMapProvider(JLProperties.MapType.OSM) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) // Paris + .showZoomController(true) + .build(); + +// Add the map to your layout +layout.add(mapView); +layout.setSizeFull(); +mapView.setSizeFull(); + +// Add a marker +JLMarker marker = JLMarker.builder() + .latLng(new JLLatLng(48.864716, 2.349014)) + .popup("Hello, Paris!") + .build(); +mapView.getUiLayer().addMarker(marker); + +// Add a click listener to the marker +marker.setActionListener(new OnJLObjectActionListener() { + @Override + public void onClick(ClickEvent event) { + Notification.show("Marker clicked!"); + } +}); +``` + +In some cases adding map directly to `HtmlContainer` or `com.vaadin.flow.component.html.Main` deos not work as expected. +In such cases, you can wrap the map in a `com.vaadin.flow.component.orderedlayout.*` or as an example `VerticalLayout`! +Read more +here: [Vaadin Examples!](https://github.com/makbn/java_leaflet/wiki/Examples-and-Tutorials#vaadin-implementation). + +```java + +### Map Events + +You can listen for map events by implementing the `OnJLMapViewListener` interface: + +```java +public class MyView extends VerticalLayout implements OnJLMapViewListener { + + public MyView() { + JLMapView mapView = JLMapView.builder() + .JLMapProvider(JLProperties.MapType.OSM) + .startCoordinate(new JLLatLng(48.864716, 2.349014)) + .showZoomController(true) + .build(); + mapView.setMapViewListener(this); + add(mapView); + } + + @Override + public void mapLoadedSuccessfully(JLMapController mapController) { + Notification.show("Map loaded successfully!"); + } +} +``` + +## Demo Application + +A demo application is included in the `io.github.makbn.jlmap.vaadin.demo` package. You can run it to see the Vaadin implementation in action. \ No newline at end of file diff --git a/jlmap-vaadin/package.json b/jlmap-vaadin/package.json new file mode 100644 index 0000000..38f1224 --- /dev/null +++ b/jlmap-vaadin/package.json @@ -0,0 +1,179 @@ +{ + "name": "no-name", + "license": "UNLICENSED", + "type": "module", + "dependencies": { + "@maptiler/leaflet-maptilersdk": "4.1.0", + "@polymer/polymer": "3.5.2", + "@vaadin/bundles": "24.8.4", + "@vaadin/common-frontend": "0.0.19", + "@vaadin/polymer-legacy-adapter": "24.8.4", + "@vaadin/react-components": "24.8.4", + "@vaadin/vaadin-development-mode-detector": "2.0.7", + "@vaadin/vaadin-lumo-styles": "24.8.4", + "@vaadin/vaadin-material-styles": "24.8.4", + "@vaadin/vaadin-themable-mixin": "24.8.4", + "@vaadin/vaadin-usage-statistics": "2.1.3", + "construct-style-sheets-polyfill": "3.1.0", + "date-fns": "2.29.3", + "leaflet": "1.9.4", + "lit": "3.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "7.6.1" + }, + "devDependencies": { + "@babel/preset-react": "7.27.1", + "@preact/signals-react-transform": "0.5.1", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.1.4", + "@types/react": "18.3.23", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "4.5.0", + "async": "3.2.6", + "glob": "11.0.2", + "magic-string": "0.30.17", + "rollup-plugin-brotli": "3.1.0", + "rollup-plugin-visualizer": "5.14.0", + "strip-css-comments": "5.0.0", + "transform-ast": "2.4.4", + "typescript": "5.8.3", + "vite": "6.3.5", + "vite-plugin-checker": "0.9.3", + "workbox-build": "7.3.0", + "workbox-core": "7.3.0", + "workbox-precaching": "7.3.0" + }, + "overrides": { + "@vaadin/a11y-base": "24.8.4", + "@vaadin/accordion": "24.8.4", + "@vaadin/app-layout": "24.8.4", + "@vaadin/avatar": "24.8.4", + "@vaadin/avatar-group": "24.8.4", + "@vaadin/button": "24.8.4", + "@vaadin/card": "24.8.4", + "@vaadin/checkbox": "24.8.4", + "@vaadin/checkbox-group": "24.8.4", + "@vaadin/combo-box": "24.8.4", + "@vaadin/component-base": "24.8.4", + "@vaadin/confirm-dialog": "24.8.4", + "@vaadin/context-menu": "24.8.4", + "@vaadin/custom-field": "24.8.4", + "@vaadin/date-picker": "24.8.4", + "@vaadin/date-time-picker": "24.8.4", + "@vaadin/details": "24.8.4", + "@vaadin/dialog": "24.8.4", + "@vaadin/email-field": "24.8.4", + "@vaadin/field-base": "24.8.4", + "@vaadin/field-highlighter": "24.8.4", + "@vaadin/form-layout": "24.8.4", + "@vaadin/grid": "24.8.4", + "@vaadin/horizontal-layout": "24.8.4", + "@vaadin/icon": "24.8.4", + "@vaadin/icons": "24.8.4", + "@vaadin/input-container": "24.8.4", + "@vaadin/integer-field": "24.8.4", + "@vaadin/item": "24.8.4", + "@vaadin/list-box": "24.8.4", + "@vaadin/lit-renderer": "24.8.4", + "@vaadin/login": "24.8.4", + "@vaadin/markdown": "24.8.4", + "@vaadin/master-detail-layout": "24.8.4", + "@vaadin/menu-bar": "24.8.4", + "@vaadin/message-input": "24.8.4", + "@vaadin/message-list": "24.8.4", + "@vaadin/multi-select-combo-box": "24.8.4", + "@vaadin/notification": "24.8.4", + "@vaadin/number-field": "24.8.4", + "@vaadin/overlay": "24.8.4", + "@vaadin/password-field": "24.8.4", + "@vaadin/popover": "24.8.4", + "@vaadin/progress-bar": "24.8.4", + "@vaadin/radio-group": "24.8.4", + "@vaadin/scroller": "24.8.4", + "@vaadin/select": "24.8.4", + "@vaadin/side-nav": "24.8.4", + "@vaadin/split-layout": "24.8.4", + "@vaadin/tabs": "24.8.4", + "@vaadin/tabsheet": "24.8.4", + "@vaadin/text-area": "24.8.4", + "@vaadin/text-field": "24.8.4", + "@vaadin/time-picker": "24.8.4", + "@vaadin/tooltip": "24.8.4", + "@vaadin/upload": "24.8.4", + "@vaadin/router": "2.0.0", + "@vaadin/vertical-layout": "24.8.4", + "@vaadin/virtual-list": "24.8.4", + "@vaadin/board": "24.8.4", + "@vaadin/charts": "24.8.4", + "@vaadin/cookie-consent": "24.8.4", + "@vaadin/crud": "24.8.4", + "@vaadin/dashboard": "24.8.4", + "@vaadin/grid-pro": "24.8.4", + "@vaadin/map": "24.8.4", + "@vaadin/rich-text-editor": "24.8.4", + "@vaadin/bundles": "$@vaadin/bundles", + "@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter", + "@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector", + "@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics", + "@vaadin/react-components": "$@vaadin/react-components", + "@vaadin/common-frontend": "$@vaadin/common-frontend", + "react-dom": "$react-dom", + "construct-style-sheets-polyfill": "$construct-style-sheets-polyfill", + "lit": "$lit", + "@polymer/polymer": "$@polymer/polymer", + "react": "$react", + "react-router": "$react-router", + "date-fns": "$date-fns", + "@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin", + "@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles", + "@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles", + "@maptiler/leaflet-maptilersdk": "$@maptiler/leaflet-maptilersdk", + "leaflet": "$leaflet" + }, + "vaadin": { + "dependencies": { + "@maptiler/leaflet-maptilersdk": "4.1.0", + "@polymer/polymer": "3.5.2", + "@vaadin/bundles": "24.8.4", + "@vaadin/common-frontend": "0.0.19", + "@vaadin/polymer-legacy-adapter": "24.8.4", + "@vaadin/react-components": "24.8.4", + "@vaadin/vaadin-development-mode-detector": "2.0.7", + "@vaadin/vaadin-lumo-styles": "24.8.4", + "@vaadin/vaadin-material-styles": "24.8.4", + "@vaadin/vaadin-themable-mixin": "24.8.4", + "@vaadin/vaadin-usage-statistics": "2.1.3", + "construct-style-sheets-polyfill": "3.1.0", + "date-fns": "2.29.3", + "leaflet": "1.9.4", + "lit": "3.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "7.6.1" + }, + "devDependencies": { + "@babel/preset-react": "7.27.1", + "@preact/signals-react-transform": "0.5.1", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.1.4", + "@types/react": "18.3.23", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "4.5.0", + "async": "3.2.6", + "glob": "11.0.2", + "magic-string": "0.30.17", + "rollup-plugin-brotli": "3.1.0", + "rollup-plugin-visualizer": "5.14.0", + "strip-css-comments": "5.0.0", + "transform-ast": "2.4.4", + "typescript": "5.8.3", + "vite": "6.3.5", + "vite-plugin-checker": "0.9.3", + "workbox-build": "7.3.0", + "workbox-core": "7.3.0", + "workbox-precaching": "7.3.0" + }, + "hash": "670e78536b2289b1894aec405897760d9736faa5ce54c7ea1e95d4c3314eee7c" + } +} \ No newline at end of file diff --git a/jlmap-vaadin/pom.xml b/jlmap-vaadin/pom.xml new file mode 100644 index 0000000..01dbbff --- /dev/null +++ b/jlmap-vaadin/pom.xml @@ -0,0 +1,238 @@ + + + 4.0.0 + + + io.github.makbn + jlmap-parent + 2.0.0 + + + jlmap-vaadin + jar + Java Leaflet (JLeaflet) - Vaadin Implementation + Vaadin implementation for Java Leaflet map components + + + + GNU Lesser General Public License (LGPL) Version 2.1 or later + https://www.gnu.org/licenses/lgpl-2.1.html + https://github.com/makbn/java_leaflet + + + + + 17 + 17 + UTF-8 + 24.8.6 + 3.5.4 + + + + + release + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + META-INF/VAADIN/config/flow-build-info.json + + + + + + + + + + + + 3.13.0 + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + default-testCompile + test-compile + + testCompile + + + false + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + **/*Test.java + **/*Tests.java + + + true + + false + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + + prepare-agent + + + + report + test + + report + + + + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + prepare-frontend + + + + + + src/main/java + + + + + + com.vaadin + vaadin-bom + ${vaadin.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${org.springframework.boot.version} + pom + import + + + + + + + com.vaadin + vaadin-spring-boot-starter + + + + com.vaadin + hilla + + + + + + + io.github.makbn + jlmap-api + ${project.version} + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + com.vaadin + vaadin-core + + + + + org.junit.jupiter + junit-jupiter + test + + + + + org.assertj + assertj-core + 3.27.4 + test + + + + org.mockito + mockito-core + 5.7.0 + test + + + + org.mockito + mockito-junit-jupiter + 5.7.0 + test + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin + * + + + + + + + org.awaitility + awaitility + 4.2.1 + test + + + \ No newline at end of file diff --git a/jlmap-vaadin/src/main/frontend/index.html b/jlmap-vaadin/src/main/frontend/index.html new file mode 100644 index 0000000..5581921 --- /dev/null +++ b/jlmap-vaadin/src/main/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + +
+ + diff --git a/jlmap-vaadin/src/main/frontend/styles/global.css b/jlmap-vaadin/src/main/frontend/styles/global.css new file mode 100644 index 0000000..e69de29 diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/JLMapView.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/JLMapView.java new file mode 100644 index 0000000..f7c46b2 --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/JLMapView.java @@ -0,0 +1,305 @@ +package io.github.makbn.jlmap.vaadin; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClientCallable; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JavaScript; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dependency.NpmPackage; +import com.vaadin.flow.component.dependency.StyleSheet; +import com.vaadin.flow.component.orderedlayout.BoxSizing; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.element.menu.JLContextMenu; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.layer.leaflet.LeafletLayer; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.listener.OnJLActionListener; +import io.github.makbn.jlmap.listener.event.MapEvent; +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMapOption; +import io.github.makbn.jlmap.vaadin.engine.JLVaadinClientToServerTransporter; +import io.github.makbn.jlmap.vaadin.engine.JLVaadinEngine; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinControlLayer; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinGeoJsonLayer; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinUiLayer; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinVectorLayer; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.experimental.NonFinal; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Objects; +import java.util.Set; + +/** + * A Vaadin component that displays a Leaflet map. + * This component implements the JLMapController interface to provide + * a consistent API across different UI frameworks. + * + * @author Matt Akbarian (@makbn) + */ + +@NpmPackage(value = "leaflet", version = "1.9.4") +@Slf4j +@Tag("jl-map-view") +@JsModule("leaflet/dist/leaflet.js") +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +@StyleSheet("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css") +@JavaScript("https://unpkg.com/leaflet-providers@latest/leaflet-providers.js") +public class JLMapView extends VerticalLayout implements JLMap { + transient JLMapOption mapOption; + transient JLMapEventHandler jlMapCallbackHandler; + transient JLWebEngine jlWebEngine; + @Getter + transient HashMap, LeafletLayer> layers; + @NonFinal + transient boolean controllerAdded = false; + @NonFinal + transient boolean contextMenuEnabled = true; + @NonFinal + @Nullable + transient OnJLActionListener> mapListener; + @NonFinal + transient JLContextMenu> contextMenu; + + /** + * Creates a new JLMapView with the specified map type, starting coordinates, and zoom controller visibility. + * + * @param jlMapProvider the type of map to display + * @param startCoordinate the initial latLng coordinates of the map + * @param showZoomController whether to show the zoom controller + */ + @Builder + public JLMapView(@NonNull JLMapProvider jlMapProvider, + @NonNull JLLatLng startCoordinate, boolean showZoomController) { + super(); + setSizeFull(); + setMinHeight("100%"); + setMargin(false); + setSpacing(false); + setAlignItems(Alignment.CENTER); + setJustifyContentMode(JustifyContentMode.CENTER); + setBoxSizing(BoxSizing.CONTENT_BOX); + this.mapOption = JLMapOption.builder() + .startCoordinate(startCoordinate) + .jlMapProvider(jlMapProvider) + .additionalParameter(Set.of(new JLMapOption.Parameter("zoomControl", + Objects.toString(showZoomController)))) + .build(); + this.jlWebEngine = new JLVaadinEngine(this::getElement); + this.jlMapCallbackHandler = new JLMapEventHandler(); + this.layers = new HashMap<>(); + } + + /** + * Initializes the map when the component is attached to the DOM. + * + * @param attachEvent the attachment event + */ + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + log.debug("onAttach: {}", attachEvent); + getElement().executeJs(generateInitializeFunctionCall()); + initializeLayers(); + addControllerToDocument(); + if (mapListener != null) { + mapListener.onAction(this, new MapEvent(JLAction.MAP_LOADED)); + } + } + + @NonNull + @Override + public JLContextMenu> addContextMenu() { + if (contextMenu == null) { + contextMenu = new JLContextMenu<>(this); + } + return contextMenu; + } + + @Nullable + @Override + public JLContextMenu> getContextMenu() { + return contextMenu; + } + + @Override + public void setContextMenu(@NonNull JLContextMenu> contextMenu) { + this.contextMenu = contextMenu; + } + + @Override + public boolean hasContextMenu() { + return getContextMenu() != null; + } + + @Override + public boolean isContextMenuEnabled() { + return contextMenuEnabled; + } + + @Override + public void setContextMenuEnabled(boolean enabled) { + this.contextMenuEnabled = enabled; + } + + /** + * Generates the JavaScript function call to initialize the map. + * + * @return the JavaScript initialization string + */ + @SuppressWarnings("all") + private String generateInitializeFunctionCall() { + String call = """ + function getCenterOfElement(event, mapElement) { + return JSON.stringify(event.latlng ? event.latlng: { + lat: mapElement.getCenter().lat, + lng: mapElement.getCenter().lng + }); + } + + function getMapBounds(mapElement) { + return JSON.stringify({ + "northEast": { + "lat": mapElement.getBounds().getNorthEast().lat, + "lng": mapElement.getBounds().getNorthEast().lng, + }, + "southWest": { + "lat": mapElement.getBounds().getSouthWest().lat, + "lng": mapElement.getBounds().getSouthWest().lng, + } + }); + } + this.jlMapElement = document.querySelector('jl-map-view'); + this.map = L.map(this.jlMapElement, {zoomControl: %b}).setView([%s, %s], %d); + + L.tileLayer('%s') + .addTo(this.map); + + console.log('Map initialized with center: ', this.map.getCenter(), ' and zoom: ', this.map.getZoom()); + + this.map.on('click', e => this.jlMapElement.$server.eventHandler('click', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('move', e => this.jlMapElement.$server.eventHandler('move', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('movestart', e => this.jlMapElement.$server.eventHandler('movestart', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('moveend', e => this.jlMapElement.$server.eventHandler('moveend', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('zoom', e => this.jlMapElement.$server.eventHandler('zoom', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('zoomstart', e => this.jlMapElement.$server.eventHandler('zoomstart', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('zoomend', e => this.jlMapElement.$server.eventHandler('zoomend', 'map', 'main_map', this.map.getZoom(), getCenterOfElement(e, this.map), getMapBounds(this.map))); + this.map.on('resize', e => this.jlMapElement.$server.eventHandler('resize', 'map', 'main_map', this.map.getZoom(), JSON.stringify({"oldWidth": e.oldSize.x, "oldHeight": e.oldSize.y, "newWidth": e.newSize.x, "newHeight": e.newSize.y}), getMapBounds(this.map))); + this.map.on('contextmenu', e => { + this.jlMapElement.$server.eventHandler('contextmenu', 'map', 'main_map', this.map.getZoom(), JSON.stringify({"x": e.containerPoint.x, "y": e.containerPoint.y, "lat": e.latlng.lat, "lng": e.latlng.lng}), getMapBounds(this.map)); + L.DomEvent.stopPropagation(e); + }); + """; + + return call.formatted(mapOption.zoomControlEnabled(), + mapOption.getStartCoordinate().getLat(), + mapOption.getStartCoordinate().getLng(), + mapOption.getInitialZoom(), + mapOption.getJlMapProvider().getMapProviderAddress()); + } + + /** + * Initializes the map layers. + */ + private void initializeLayers() { + layers.clear(); + + layers.put(JLVaadinVectorLayer.class, new JLVaadinVectorLayer(jlWebEngine, jlMapCallbackHandler)); + layers.put(JLVaadinUiLayer.class, new JLVaadinUiLayer(jlWebEngine, jlMapCallbackHandler)); + layers.put(JLVaadinControlLayer.class, new JLVaadinControlLayer(jlWebEngine, jlMapCallbackHandler)); + layers.put(JLVaadinGeoJsonLayer.class, new JLVaadinGeoJsonLayer(jlWebEngine, jlMapCallbackHandler)); + } + + /** + * Called when the map is loaded successfully from JavaScript. + * Handles events from the client side. + * + * @param function the function name + * @param jlType the JL type + * @param uuid the unique identifier + * @param additionalParam1 additional parameter 1 + * @param additionalParam2 additional parameter 2 + * @param additionalParam3 additional parameter 3 + */ + @ClientCallable + @SuppressWarnings("unused") + public void eventHandler(String function, String jlType, String uuid, String additionalParam1, + String additionalParam2, String additionalParam3) { + jlMapCallbackHandler.functionCalled(this, String.valueOf(function), jlType, uuid, additionalParam1, additionalParam2, additionalParam3); + } + + /** + * Bridge method called from JavaScript to invoke Java methods on registered objects. + * This enables the JavaScript-to-Java bridge functionality. + * + * @param callId unique identifier for this call + * @param objectId the ID of the object to call + * @param methodName the method to invoke + * @param argsJson JSON-encoded arguments + */ + @ClientCallable + @SuppressWarnings("unused") + public String jlObjectBridgeCall(String callId, String objectId, String methodName, String argsJson) { + JLVaadinGeoJsonLayer geoJsonLayer = (JLVaadinGeoJsonLayer) layers.get(JLVaadinGeoJsonLayer.class); + if (geoJsonLayer != null && geoJsonLayer.getClientToServer() != null) { + return ((JLVaadinClientToServerTransporter) geoJsonLayer.getClientToServer()) + .jlObjectBridgeCall(callId, objectId, methodName, argsJson); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public JLWebEngine getJLEngine() { + return jlWebEngine; + } + + /** + * {@inheritDoc} + */ + @Override + public void addControllerToDocument() { + if (!controllerAdded) { + jlWebEngine.executeScript("window.jlController = this;"); + //language=JavaScript + jlWebEngine.executeScript(""" + if (typeof Event === 'function') { + window.dispatchEvent(new Event('resize')); + } else { + // fallback for older browsers + var evt = document.createEvent('UIEvents'); + evt.initUIEvent('resize', true, false, window, 0); + window.dispatchEvent(evt); + } + """); + controllerAdded = true; + } + } + + @Override + public OnJLActionListener> getOnActionListener() { + return mapListener; + } + + /** + * Sets the listener for map view events. + * + * @param listener the listener + */ + @Override + public void setOnActionListener(OnJLActionListener> listener) { + this.mapListener = listener; + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/element/menu/VaadinContextMenuMediator.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/element/menu/VaadinContextMenuMediator.java new file mode 100644 index 0000000..1d249ef --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/element/menu/VaadinContextMenuMediator.java @@ -0,0 +1,113 @@ +package io.github.makbn.jlmap.vaadin.element.menu; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.contextmenu.ContextMenu; +import com.vaadin.flow.component.html.Image; +import io.github.makbn.jlmap.JLMap; +import io.github.makbn.jlmap.element.menu.JLContextMenuMediator; +import io.github.makbn.jlmap.element.menu.JLHasContextMenu; +import io.github.makbn.jlmap.model.JLObject; +import io.github.makbn.jlmap.vaadin.JLMapView; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +/** + * Vaadin-specific implementation of the context menu mediator. + *

+ * This mediator handles context menu functionality for JL objects within + * Vaadin applications. It creates and manages Vaadin ContextMenu components + * and handles the integration between JL context menus and Vaadin's UI framework. + *

+ *

Features:

+ *
    + *
  • Native Integration: Uses Vaadin's ContextMenu component
  • + *
  • Event Handling: Proper event delegation and lifecycle management
  • + *
  • Component Mapping: Efficient mapping between JL objects and Vaadin components
  • + *
  • Thread Safety: Safe for use in Vaadin's server-side architecture
  • + *
+ *

Vaadin Integration:

+ *

+ * This mediator integrates with Vaadin's component tree by: + *

+ *
    + *
  • Finding the appropriate Vaadin component for each JL object
  • + *
  • Attaching ContextMenu to the component
  • + *
  • Handling right-click events through Vaadin's event system
  • + *
  • Managing component lifecycle and cleanup
  • + *
+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@Slf4j +@FieldDefaults(level = lombok.AccessLevel.PRIVATE) +public class VaadinContextMenuMediator implements JLContextMenuMediator { + public static final String ICON_SIZE = "14px"; + public static final String MARGIN_SIZE = "8px"; + ContextMenu universalContextMenu; + + /** + * {@inheritDoc} + */ + @Override + public synchronized > void showContextMenu(@NonNull JLMap map, @NonNull JLObject object, double x, double y) { + log.debug("Showing context menu for object: {} at ({}, {})", object.getJLId(), x, y); + if (universalContextMenu == null) { + universalContextMenu = new ContextMenu((JLMapView) map); + } + if (object instanceof JLHasContextMenu objectWithContextMenu + && objectWithContextMenu.hasContextMenu() && objectWithContextMenu.isContextMenuEnabled()) { + universalContextMenu.removeAll(); + Objects.requireNonNull(objectWithContextMenu.getContextMenu()).getItems().forEach(item -> { + var menuItem = universalContextMenu.addItem(item.getText(), e -> + objectWithContextMenu.getContextMenu().getOnMenuItemListener().onMenuItemSelected(item)); + if (item.getIcon() != null && !item.getIcon().isBlank()) { + menuItem.addComponentAsFirst(createIcon(item.getIcon())); + } + }); + } + } + + private Component createIcon(String url) { + Image image = new Image(url, ""); + image.setWidth(ICON_SIZE); + image.setHeight(ICON_SIZE); + image.getStyle().setMarginRight(MARGIN_SIZE); + + return image; + } + + /** + * {@inheritDoc} + */ + @Override + public synchronized > void hideContextMenu(@NonNull JLMap map, @NonNull JLObject object) { + log.debug("Hiding context menu for object: {}", object.getJLId()); + if (universalContextMenu != null) { + universalContextMenu.removeAll(); + universalContextMenu.removeFromParent(); + universalContextMenu.close(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supportsObjectType(@NonNull Class> objectType) { + // Support all JL object types in Vaadin environment + return true; + } + + /** + * {@inheritDoc} + */ + @Override + @NonNull + public String getName() { + return "Vaadin Context Menu Mediator"; + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinClientToServerTransporter.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinClientToServerTransporter.java new file mode 100644 index 0000000..4a51fcf --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinClientToServerTransporter.java @@ -0,0 +1,69 @@ +package io.github.makbn.jlmap.vaadin.engine; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.engine.JLClientToServerTransporterBase; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.function.Function; + +/** + * Vaadin-specific implementation of the JLObjectBridge. + * Uses server-side method calls with callback mechanism for synchronous-like behavior. + * + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLVaadinClientToServerTransporter extends JLClientToServerTransporterBase { + + public JLVaadinClientToServerTransporter(Function executor) { + super(executor); + initializeBridge(); + } + + private void initializeBridge() { + // Inject the base JavaScript bridge + execute(getJavaScriptBridge()); + + //language=JavaScript + execute(""" + // Vaadin-specific bridge implementation + window.jlObjectBridge._callJava = async function(objectId, methodName, args) { + var callId = 'call_' + Date.now() + '_' + Math.random(); + // Find the Vaadin component element (jl-map-view) + var vaadinElement = document.querySelector('jl-map-view'); + return vaadinElement.$server.jlObjectBridgeCall(callId, objectId, methodName, JSON.stringify(args)); + }; + + console.log('Vaadin JLObjectBridge ready'); + """); + + log.debug("Vaadin JLObjectBridge initialized"); + } + + /** + * Server-side method that JavaScript calls. + * This is called from the client-side via $server.jlObjectBridgeCall(). + */ + public String jlObjectBridgeCall(String callId, String objectId, String methodName, String argsJson) { + try { + String[] args = argsJson != null ? new String[]{argsJson} : new String[0]; + String result = callObjectMethod(objectId, methodName, args); + + return result; + } catch (Exception e) { + log.error("Error in Vaadin bridge call: {}", e.getMessage()); + return null; + } + } + + @Override + public String getJavaScriptBridge() { + return super.getJavaScriptBridge() + """ + + // Vaadin-specific bridge setup will be added by initializeBridge() + """; + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinEngine.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinEngine.java new file mode 100644 index 0000000..b852a27 --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinEngine.java @@ -0,0 +1,83 @@ +package io.github.makbn.jlmap.vaadin.engine; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.ElementAttachListener; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.exception.JLException; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.experimental.FieldDefaults; +import lombok.experimental.NonFinal; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Vaadin implementation of the JLWebEngine for executing JavaScript in Vaadin components. + * + * @author Matt Akbarian (@makbn) + */ +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLVaadinEngine extends JLWebEngine { + Supplier mapElement; + @NonFinal + Status currentStatus; + + /** + * Creates a new JLVaadinEngine with the specified Vaadin Page. + */ + public JLVaadinEngine(Supplier mapElement) { + super(PendingJavaScriptResult.class); + this.mapElement = mapElement; + this.mapElement.get().addAttachListener((ElementAttachListener) elementAttachEvent -> + currentStatus = Status.SUCCEEDED); + } + + /** + * Executes JavaScript code and attempts to cast the result to the specified type. + * Note: Due to Vaadin's asynchronous JavaScript execution, this method may not return + * actual values from the JavaScript execution. For operations requiring return values, + * consider using callbacks or other patterns. + * + * @param script the JavaScript code to execute + * @param type the class representing the expected return type + * @param the type parameter for the return value + * @return the result of the JavaScript execution cast to type T, or null if not available + */ + @SneakyThrows + @Override + public T executeScript(@NonNull String script, @NonNull Class type) { + if (mapElement.get() == null) { + throw new IllegalStateException("mapElement is null"); + } + return Optional.of(mapElement) + .map(Supplier::get) + .map(element -> element.executeJs(script)) + .map(type::cast) + .orElseThrow(() -> new JLException("Could not execute script " + script)); + } + + /** + * Gets the current status of the engine. + * Note: Vaadin doesn't provide a direct way to check the status of JavaScript execution. + * This implementation always returns the last known status. + * + * @return the current status of the engine + */ + @Override + public Status getStatus() { + return currentStatus; + } + + /** + * Sets the current status of the engine. + * This can be used by the application to update the status based on other indicators. + * + * @param status the new status to set + */ + public void setStatus(Status status) { + this.currentStatus = status; + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinServerToClientTransporter.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinServerToClientTransporter.java new file mode 100644 index 0000000..c403e61 --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/engine/JLVaadinServerToClientTransporter.java @@ -0,0 +1,102 @@ +package io.github.makbn.jlmap.vaadin.engine; + +import com.google.gson.Gson; +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import com.vaadin.flow.internal.JsonCodec; +import elemental.json.JsonType; +import io.github.makbn.jlmap.engine.JLServerToClientTransporter; +import io.github.makbn.jlmap.exception.JLConversionException; +import lombok.SneakyThrows; + +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +/** + * Vaadin-specific implementation of the server-to-client transport interface. + *

+ * This implementation uses Vaadin's {@link PendingJavaScriptResult} to execute JavaScript + * operations and convert results back to Java objects. It leverages Vaadin's JSON handling + * infrastructure and provides automatic type conversion for common data types. + *

+ *

Implementation Details:

+ *
    + *
  • Execution Context: Uses Vaadin Element.executeJs() for client-side execution
  • + *
  • Result Handling: Converts PendingJavaScriptResult to typed Java objects
  • + *
  • Type Support: Handles primitives, JSON objects, and custom classes via Gson
  • + *
  • Async Operations: All operations return CompletableFuture for non-blocking execution
  • + *
+ *

Supported Type Conversions:

+ *
    + *
  • Basic Types: String, Boolean, Double, Integer (via JsonCodec)
  • + *
  • JSON Objects: Complex objects converted via Gson
  • + *
  • String Serialization: JSON objects can be returned as JSON strings
  • + *
+ *

+ * Thread Safety: Operations are automatically marshaled to the Vaadin UI thread + * through the PendingJavaScriptResult mechanism. + *

+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +public abstract class JLVaadinServerToClientTransporter implements JLServerToClientTransporter { + + /** + * Set of basic types that can be converted directly via Vaadin's JsonCodec + */ + private static final Set> BASIC_TYPES = Set.of(String.class, Boolean.class, Double.class, Integer.class); + + /** + * Gson instance for converting complex JSON objects to Java classes + */ + Gson gson = new Gson(); + + /** + * Converts Vaadin's PendingJavaScriptResult to typed Java objects. + *

+ * This implementation handles three categories of type conversion: + *

+ *
    + *
  1. JSON Objects to String: Returns the raw JSON representation
  2. + *
  3. Basic Types: Uses Vaadin's JsonCodec for direct conversion
  4. + *
  5. Complex Objects: Uses Gson for JSON-to-object conversion
  6. + *
+ *

+ * The conversion process is asynchronous, with the CompletableFuture completing + * when the JavaScript execution finishes and the result is converted. + *

+ * + * @inheritDoc + */ + @Override + @SneakyThrows + public CompletableFuture covertResult(PendingJavaScriptResult result, Class clazz) { + CompletableFuture future = new CompletableFuture<>(); + + // Set up async callback when JavaScript execution completes + result.then(value -> { + try { + // Handle JSON objects requested as String - return raw JSON + if (value.getType() == JsonType.OBJECT && clazz == String.class) { + //noinspection unchecked + future.complete((M) value.toJson()); + } + // Handle basic types via Vaadin's JsonCodec + else if (BASIC_TYPES.contains(clazz)) { + M convertedValue = JsonCodec.decodeAs(value, clazz); + future.complete(convertedValue); + } + // Handle complex objects via Gson JSON parsing + else { + future.complete(gson.fromJson(value.toJson(), clazz)); + } + } catch (ClassCastException e) { + // Propagate conversion errors as JLConversionException + future.completeExceptionally(new JLConversionException(e, value.toNative())); + } + }); + return future; + } + + +} diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinControlLayer.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinControlLayer.java new file mode 100644 index 0000000..fe5556f --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinControlLayer.java @@ -0,0 +1,142 @@ +package io.github.makbn.jlmap.vaadin.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.layer.leaflet.LeafletControlLayerInt; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; + +/** + * Represents the Control layer on Leaflet map. + * + * @author Matt Akbarian (@makbn) + */ +public class JLVaadinControlLayer extends JLVaadinLayer implements LeafletControlLayerInt { + public JLVaadinControlLayer(JLWebEngine engine, JLMapEventHandler callbackHandler) { + super(engine, callbackHandler); + } + + /** + * {@inheritDoc} + */ + @Override + public void zoomIn(int delta) { + engine.executeScript(String.format("this.map.zoomIn(%d)", delta)); + } + + /** + * {@inheritDoc} + */ + @Override + public void zoomOut(int delta) { + engine.executeScript(String.format("this.map.zoomOut(%d)", delta)); + } + + /** + * {@inheritDoc} + */ + @Override + public void setZoom(int level) { + engine.executeScript(String.format("this.map.setZoom(%d)", level)); + } + + /** + * {@inheritDoc} + */ + @Override + public void setZoomAround(JLLatLng latLng, int zoom) { + engine.executeScript( + String.format("this.map.setZoomAround(L.latLng(%f, %f), %d)", + latLng.getLat(), latLng.getLng(), zoom)); + } + + /** + * {@inheritDoc} + */ + @Override + public void fitBounds(JLBounds bounds) { + engine.executeScript(String.format("this.map.fitBounds(%s)", + bounds.toString())); + } + + /** + * {@inheritDoc} + */ + @Override + public void fitWorld() { + engine.executeScript("this.map.fitWorld()"); + } + + /** + * {@inheritDoc} + */ + @Override + public void panTo(JLLatLng latLng) { + engine.executeScript(String.format("this.map.panTo(L.latLng(%f, %f))", + latLng.getLat(), latLng.getLng())); + } + + /** + * {@inheritDoc} + */ + @Override + public void flyTo(JLLatLng latLng, int zoom) { + engine.executeScript( + String.format("this.map.flyTo(L.latLng(%f, %f), %d)", + latLng.getLat(), latLng.getLng(), zoom)); + } + + /** + * {@inheritDoc} + */ + @Override + public void flyToBounds(JLBounds bounds) { + engine.executeScript(String.format("this.map.flyToBounds(%s)", + bounds.toString())); + } + + /** + * {@inheritDoc} + */ + @Override + public void setMaxBounds(JLBounds bounds) { + engine.executeScript(String.format("this.map.setMaxBounds(%s)", + bounds.toString())); + } + + /** + * {@inheritDoc} + */ + @Override + public void setMinZoom(int zoom) { + engine.executeScript(String.format("this.map.setMinZoom(%d)", zoom)); + } + + /** + * {@inheritDoc} + */ + @Override + public void setMaxZoom(int zoom) { + engine.executeScript(String.format("this.map.setMaxZoom(%d)", zoom)); + } + + /** + * {@inheritDoc} + */ + @Override + public void panInsideBounds(JLBounds bounds) { + engine.executeScript(String.format("this.map.panInsideBounds(%s)", + bounds.toString())); + } + + /** + * {@inheritDoc} + */ + @Override + public void panInside(JLLatLng latLng) { + engine.executeScript( + String.format("this.map.panInside(L.latLng(%f, %f))", + latLng.getLat(), latLng.getLng())); + } +} diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinGeoJsonLayer.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinGeoJsonLayer.java new file mode 100644 index 0000000..b215e98 --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinGeoJsonLayer.java @@ -0,0 +1,189 @@ +package io.github.makbn.jlmap.vaadin.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLClientToServerTransporter; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.exception.JLException; +import io.github.makbn.jlmap.geojson.JLGeoJsonContent; +import io.github.makbn.jlmap.geojson.JLGeoJsonFile; +import io.github.makbn.jlmap.geojson.JLGeoJsonURL; +import io.github.makbn.jlmap.layer.leaflet.LeafletGeoJsonLayerInt; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.JLGeoJson; +import io.github.makbn.jlmap.model.JLGeoJsonOptions; +import io.github.makbn.jlmap.model.builder.JLGeoJsonObjectBuilder; +import io.github.makbn.jlmap.vaadin.engine.JLVaadinClientToServerTransporter; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Vaadin implementation of the GeoJSON layer for managing geographic data overlays. + *

+ * This implementation provides GeoJSON support for Vaadin-based applications, handling + * the loading, styling, and interaction with geographic data from various sources. + * It leverages Vaadin's Element.executeJs() for asynchronous JavaScript execution + * and PendingJavaScriptResult for handling callbacks. + *

+ *

Implementation Details:

+ *
    + *
  • Data Loading: Supports files, URLs, and direct content strings
  • + *
  • JavaScript Bridge: Uses JLVaadinClientToServerTransporter for callbacks
  • + *
  • Event Handling: Supports click, double-click, add, and remove events
  • + *
  • Error Handling: Enhanced error handling with logging for Vaadin context
  • + *
  • Thread Model: Operates on Vaadin UI thread with async execution
  • + *
+ *

+ * Thread Safety: All operations are automatically marshaled to the Vaadin UI thread. + *

+ * + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLVaadinGeoJsonLayer extends JLVaadinLayer implements LeafletGeoJsonLayerInt { + + /** + * URL-based GeoJSON loader for remote data sources + */ + JLGeoJsonURL fromUrl; + + /** File-based GeoJSON loader for local data sources */ + JLGeoJsonFile fromFile; + + /** Content-based GeoJSON loader for direct string input */ + JLGeoJsonContent fromContent; + + /** Atomic counter for generating unique element IDs */ + AtomicInteger idGenerator; + + /** + * JavaScript-to-Java communication bridge for event callbacks. + *

+ * This transporter handles the conversion of JavaScript events to Java method calls, + * enabling interactive functionality for GeoJSON features. + *

+ */ + @Getter + JLClientToServerTransporter clientToServer; + + /** + * Constructs a new Vaadin GeoJSON layer with the specified engine and callback handler. + *

+ * Initializes all data loaders, ID generator, and sets up the JavaScript-to-Java + * communication bridge for handling user interactions and events. The bridge is + * configured to work with Vaadin's asynchronous JavaScript execution model. + *

+ * + * @param engine the Vaadin web engine for JavaScript execution + * @param callbackHandler the event handler for managing object callbacks + */ + public JLVaadinGeoJsonLayer(JLWebEngine engine, + JLMapEventHandler callbackHandler) { + super(engine, callbackHandler); + this.fromUrl = new JLGeoJsonURL(); + this.fromFile = new JLGeoJsonFile(); + this.fromContent = new JLGeoJsonContent(); + this.idGenerator = new AtomicInteger(); + this.clientToServer = new JLVaadinClientToServerTransporter(engine::executeScript); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromFile(@NonNull File file) throws JLException { + String json = fromFile.load(file); + return addGeoJson(json, null); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromFile(@NonNull File file, @NonNull JLGeoJsonOptions options) throws JLException { + String json = fromFile.load(file); + return addGeoJson(json, options); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromUrl(@NonNull String url) throws JLException { + String json = fromUrl.load(url); + return addGeoJson(json, null); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromUrl(@NonNull String url, @NonNull JLGeoJsonOptions options) throws JLException { + String json = fromUrl.load(url); + return addGeoJson(json, options); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromContent(@NonNull String content) throws JLException { + String json = fromContent.load(content); + return addGeoJson(json, null); + } + + /** @inheritDoc */ + @Override + public JLGeoJson addFromContent(@NonNull String content, @NonNull JLGeoJsonOptions options) throws JLException { + String json = fromContent.load(content); + return addGeoJson(json, options); + } + + /** + * Removes a GeoJSON object from the map by its unique identifier. + *

+ * Vaadin-specific behavior: This implementation includes + * enhanced error handling with logging, returning false if the removal fails + * rather than propagating exceptions. + *

+ * + * @inheritDoc + */ + @Override + public boolean removeGeoJson(@NonNull String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLGeoJson.class, id); + return true; + } catch (RuntimeException e) { + log.error("Failed to remove GeoJSON object with id: {}", id, e); + return false; + } + } + + /** + * Adds a GeoJSON object to the map from a JSON string. + * + * @param geoJson the GeoJSON string + * @param options custom styling and configuration options + * @return the added JLGeoJson object + */ + @NonNull + private JLGeoJson addGeoJson(@NonNull String geoJson, JLGeoJsonOptions options) { + String elementUniqueName = getElementUniqueName(JLGeoJson.class, idGenerator.incrementAndGet()); + JLGeoJsonObjectBuilder builder = new JLGeoJsonObjectBuilder() + .setTransporter(getTransporter()) + .setUuid(elementUniqueName) + .setGeoJson(geoJson) + .withGeoJsonOptions(options) + .withBridge(clientToServer) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + }); + var obj = builder.buildJLObject(); + engine.executeScript(builder.buildJsElement()); + callbackHandler.addJLObject(elementUniqueName, obj); + return obj; + } + +} diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinLayer.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinLayer.java new file mode 100644 index 0000000..ded0797 --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinLayer.java @@ -0,0 +1,121 @@ +package io.github.makbn.jlmap.vaadin.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLTransportRequest; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.layer.leaflet.LeafletLayer; +import io.github.makbn.jlmap.model.JLObject; +import io.github.makbn.jlmap.vaadin.engine.JLVaadinServerToClientTransporter; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.experimental.FieldDefaults; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Abstract base class for Vaadin-based map layers. + *

+ * Provides common infrastructure for Vaadin map layer implementations including + * JavaScript execution, element naming, and transport layer management. Uses + * Vaadin's {@link PendingJavaScriptResult} for asynchronous JavaScript operations. + *

+ *

Core Features:

+ *
    + *
  • Element Management: Unique naming and session-based identification
  • + *
  • JavaScript Execution: Vaadin Element.executeJs() integration
  • + *
  • Transport Layer: Server-to-client communication via JLVaadinServerToClientTransporter
  • + *
  • Event Handling: Callback management for user interactions
  • + *
+ * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@FieldDefaults(level = AccessLevel.PROTECTED, makeFinal = true) +public abstract class JLVaadinLayer implements LeafletLayer { + + /** + * Vaadin web engine for JavaScript execution with PendingJavaScriptResult + */ + JLWebEngine engine; + + /** Event handler for managing object callbacks and interactions */ + JLMapEventHandler callbackHandler; + + /** Unique session identifier for element naming collision avoidance */ + String componentSessionId = "_" + UUID.randomUUID().toString().replace("-", "") + "_"; + + /** + * Constructs a Vaadin layer with the specified engine and callback handler. + * + * @param engine the Vaadin web engine for JavaScript execution + * @param callbackHandler the event handler for managing object callbacks + */ + protected JLVaadinLayer(JLWebEngine engine, JLMapEventHandler callbackHandler) { + this.engine = engine; + this.callbackHandler = callbackHandler; + } + + /** + * Generates unique element names with session-based collision avoidance. + *

+ * Combines class simple name, session ID, and numeric ID to ensure uniqueness + * across multiple browser sessions and component instances. + *

+ * + * @param markerClass the JL object class for name prefix + * @param id the numeric identifier + * @return unique element name for JavaScript reference + */ + protected @NotNull String getElementUniqueName(@NonNull Class> markerClass, int id) { + return markerClass.getSimpleName() + componentSessionId + id; + } + + /** + * Generates JavaScript code to remove a layer by UUID. + *

+ * Creates the JavaScript statement to remove the specified layer from the map + * using Leaflet's removeLayer method. + *

+ * + * @param uuid the unique identifier of the layer to remove + * @return JavaScript code string for layer removal + */ + @NonNull + protected final String removeLayerWithUUID(@NonNull String uuid) { + return String.format("this.map.removeLayer(this.%s)", uuid); + } + + /** + * Creates a server-to-client transporter for JavaScript method invocation. + *

+ * Returns an anonymous implementation that generates JavaScript method calls + * from transport requests and executes them via the Vaadin engine. + *

+ * + * @return configured transporter for Vaadin JavaScript execution + */ + protected @NotNull JLVaadinServerToClientTransporter getTransporter() { + return new JLVaadinServerToClientTransporter() { + @Override + public Function serverToClientTransport() { + return transport -> { + // Generate JavaScript method call: this.objectId.methodName(param1,param2,...) + String script = "return this.%1$s.%2$s(%3$s);" .formatted(transport.self().getJLId(), transport.function(), + transport.params().length > 0 ? Arrays.stream(transport.params()).map(String::valueOf).collect(Collectors.joining(",")) : ""); + return engine.executeScript(script); + }; + } + }; + } + + /** @inheritDoc */ + @Override + public String toString() { + return super.toString(); + } +} diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinUiLayer.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinUiLayer.java new file mode 100644 index 0000000..97f9d4d --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinUiLayer.java @@ -0,0 +1,176 @@ +package io.github.makbn.jlmap.vaadin.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.layer.leaflet.LeafletUILayerInt; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.model.builder.JLImageOverlayBuilder; +import io.github.makbn.jlmap.model.builder.JLMarkerBuilder; +import io.github.makbn.jlmap.model.builder.JLPopupBuilder; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents the UI layer on Leaflet map. + * + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLVaadinUiLayer extends JLVaadinLayer implements LeafletUILayerInt { + AtomicInteger idGenerator; + + public JLVaadinUiLayer(JLWebEngine engine, JLMapEventHandler callbackHandler) { + super(engine, callbackHandler); + this.idGenerator = new AtomicInteger(); + } + + /** + * Add a {{@link JLMarker}} to the map with given text as content and {{@link JLLatLng}} as position. + * + * @param latLng position on the map. + * @param text content of the related popup if available! + * @return the instance of added {{@link JLMarker}} on the map. + */ + @Override + public JLMarker addMarker(JLLatLng latLng, String text, boolean draggable) { + String elementUniqueName = getElementUniqueName(JLMarker.class, idGenerator.incrementAndGet()); + + var markerBuilder = new JLMarkerBuilder() + .setUuid(elementUniqueName) + .setLat(latLng.getLat()) + .setLng(latLng.getLng()) + .setText(text) + .setTransporter(getTransporter()) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.MOVE); + jlCallbackBuilder.on(JLAction.MOVE_START); + jlCallbackBuilder.on(JLAction.MOVE_END); + jlCallbackBuilder.on(JLAction.DRAG); + jlCallbackBuilder.on(JLAction.DRAG_START); + jlCallbackBuilder.on(JLAction.DRAG_END); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + }) + .withOptions(JLOptions.DEFAULT.toBuilder().draggable(draggable).build()); + + engine.executeScript(markerBuilder.buildJsElement()); + var marker = markerBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, marker); + if (text != null && !text.trim().isEmpty()) { + var attachedPopup = addPopup(latLng, text, JLOptions.DEFAULT.toBuilder().parent(marker).build()); + marker.setPopup(attachedPopup); + } + return marker; + } + + /** + * Remove a {{@link JLMarker}} from the map. + * + * @param id of the marker for removing. + * @return {{@link Boolean#TRUE}} if removed successfully. + */ + @Override + public boolean removeMarker(String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLMarker.class, id); + } catch (RuntimeException e) { + log.error(e.getMessage(), e); + return false; + } + return true; + } + + /** + * Add a {{@link JLPopup}} to the map with given text as content and + * {@link JLLatLng} as position. + * + * @param latLng position on the map. + * @param text content of the popup. + * @param options see {{@link JLOptions}} for customizing + * @return the instance of added {{@link JLPopup}} on the map. + */ + @Override + public JLPopup addPopup(JLLatLng latLng, String text, JLOptions options) { + String elementUniqueName = getElementUniqueName(JLPopup.class, idGenerator.incrementAndGet()); + + JLPopupBuilder popupBuilder = new JLPopupBuilder() + .setUuid(elementUniqueName) + .setContent(text) + .setLat(latLng.getLat()) + .setLng(latLng.getLng()) + .setTransporter(getTransporter()) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + }).withOptions(options); + + engine.executeScript(popupBuilder.buildJsElement()); + var popup = popupBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, popup); + return popup; + } + + /** + * Add popup with {{@link JLOptions#DEFAULT}} options + * + * @see JLVaadinUiLayer#addPopup(JLLatLng, String, JLOptions) + */ + @Override + public JLPopup addPopup(JLLatLng latLng, String text) { + return addPopup(latLng, text, JLOptions.DEFAULT); + } + + /** + * Remove a {@link JLPopup} from the map. + * + * @param id of the marker for removing. + * @return true if removed successfully. + */ + @Override + public boolean removePopup(String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLPopup.class, id); + } catch (RuntimeException e) { + log.error(e.getMessage(), e); + return false; + } + return true; + } + + @Override + public JLImageOverlay addImage(JLBounds bounds, String imageUrl, JLOptions options) { + String elementUniqueName = getElementUniqueName(JLPopup.class, idGenerator.incrementAndGet()); + + JLImageOverlayBuilder imageBuilder = new JLImageOverlayBuilder() + .setUuid(elementUniqueName) + .setImageUrl(imageUrl) + .setBounds(List.of(new double[]{bounds.getSouthWest().getLat(), bounds.getSouthWest().getLng()}, + new double[]{bounds.getNorthEast().getLat(), bounds.getNorthEast().getLng()})) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + }); + + engine.executeScript(imageBuilder.buildJsElement()); + var imageOverlay = imageBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, imageOverlay); + return imageOverlay; + } +} diff --git a/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinVectorLayer.java b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinVectorLayer.java new file mode 100644 index 0000000..7402db1 --- /dev/null +++ b/jlmap-vaadin/src/main/java/io/github/makbn/jlmap/vaadin/layer/JLVaadinVectorLayer.java @@ -0,0 +1,362 @@ +package io.github.makbn.jlmap.vaadin.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.layer.leaflet.LeafletVectorLayerInt; +import io.github.makbn.jlmap.listener.JLAction; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.model.builder.*; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents the Vector layer on Leaflet map. + * + * @author Matt Akbarian (@makbn) + */ +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class JLVaadinVectorLayer extends JLVaadinLayer implements LeafletVectorLayerInt { + AtomicInteger idGenerator; + + public JLVaadinVectorLayer(JLWebEngine engine, + JLMapEventHandler callbackHandler) { + super(engine, callbackHandler); + this.idGenerator = new AtomicInteger(); + } + + /** + * Drawing polyline overlays on the map with {@link JLOptions#DEFAULT} + * options + * + * @see JLVaadinVectorLayer#addPolyline(JLLatLng[], JLOptions) + */ + @Override + public JLPolyline addPolyline(JLLatLng[] vertices) { + return addPolyline(vertices, JLOptions.DEFAULT); + } + + /** + * Drawing polyline overlays on the map. + * + * @param vertices arrays of LatLng points + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLPolyline} to map + */ + @Override + public JLPolyline addPolyline(JLLatLng[] vertices, JLOptions options) { + var elementUniqueName = getElementUniqueName(JLPolyline.class, idGenerator.incrementAndGet()); + + var polylineBuilder = new JLPolylineBuilder() + .setUuid(elementUniqueName) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + }); + + // Add vertices to the builder using the correct method + for (JLLatLng vertex : vertices) { + polylineBuilder.addLatLng(vertex.getLat(), vertex.getLng()); + } + + engine.executeScript(polylineBuilder.buildJsElement()); + var polyline = polylineBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, polyline); + return polyline; + } + + /** + * Remove a polyline from the map by id. + * + * @param id of polyline + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removePolyline(String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLPolyline.class, id); + } catch (RuntimeException e) { + log.error(e.getMessage(), e); + return false; + } + return true; + } + + /** + * Drawing multi polyline overlays on the map with + * {@link JLOptions#DEFAULT} options. + * + * @return the added {@link JLMultiPolyline} to map + * @see JLVaadinVectorLayer#addMultiPolyline(JLLatLng[][], JLOptions) + */ + @Override + public JLMultiPolyline addMultiPolyline(JLLatLng[][] vertices) { + return addMultiPolyline(vertices, JLOptions.DEFAULT); + } + + /** + * Drawing MultiPolyline shape overlays on the map with + * multi-dimensional array. + * + * @param vertices arrays of LatLng points + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLMultiPolyline} to map + */ + @Override + public JLMultiPolyline addMultiPolyline(JLLatLng[][] vertices, + JLOptions options) { + var elementUniqueName = getElementUniqueName(JLMultiPolyline.class, idGenerator.incrementAndGet()); + + var multiPolylineBuilder = new JLMultiPolylineBuilder() + .setUuid(elementUniqueName) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + }); + + // Add vertices arrays to the builder using the correct method + for (JLLatLng[] vertexArray : vertices) { + List line = new ArrayList<>(); + for (JLLatLng vertex : vertexArray) { + line.add(new double[]{vertex.getLat(), vertex.getLng()}); + } + multiPolylineBuilder.addLine(line); + } + + engine.executeScript(multiPolylineBuilder.buildJsElement()); + var multiPolyline = multiPolylineBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, multiPolyline); + return multiPolyline; + } + + /** + * Remove a multi polyline from the map by id. + * + * @param id of multi polyline + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removeMultiPolyline(String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLMultiPolyline.class, id); + } catch (RuntimeException e) { + log.error(e.getMessage(), e); + return false; + } + return true; + } + + /** + * Drawing polygon overlays on the map with {@link JLOptions#DEFAULT} + * options. + * + * @see JLVaadinVectorLayer#addPolygon(JLLatLng[][][], JLOptions) + */ + @Override + public JLPolygon addPolygon(JLLatLng[][][] vertices, JLOptions options) { + var elementUniqueName = getElementUniqueName(JLPolygon.class, idGenerator.incrementAndGet()); + + var polygonBuilder = new JLPolygonBuilder() + .setUuid(elementUniqueName) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + }); + + // Add vertices arrays to the builder using the correct method + for (JLLatLng[][] ringArray : vertices) { + List group = new ArrayList<>(); + for (JLLatLng[] ring : ringArray) { + for (JLLatLng vertex : ring) { + group.add(new double[]{vertex.getLat(), vertex.getLng()}); + } + } + polygonBuilder.addLatLngGroup(group); + } + + engine.executeScript(polygonBuilder.buildJsElement()); + var polygon = polygonBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, polygon); + return polygon; + } + + /** + * Drawing polygon overlays on the map with {@link JLOptions#DEFAULT} + * options. + * + * @see JLVaadinVectorLayer#addPolygon(JLLatLng[][][], JLOptions) + */ + @Override + public JLPolygon addPolygon(JLLatLng[][][] vertices) { + return addPolygon(vertices, JLOptions.DEFAULT); + } + + /** + * Remove a polygon from the map by id. + * + * @param id of polygon + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removePolygon(String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLPolygon.class, id); + } catch (RuntimeException e) { + log.error(e.getMessage(), e); + return false; + } + return true; + } + + /** + * Drawing circle overlays on the map. + * + * @param center latLng point of circle + * @param radius radius of circle in meters + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLCircle} to map + */ + @Override + public JLCircle addCircle(JLLatLng center, int radius, JLOptions options) { + var elementUniqueName = getElementUniqueName(JLCircle.class, idGenerator.incrementAndGet()); + + var circleBuilder = new JLCircleBuilder() + .setUuid(elementUniqueName) + .setLat(center.getLat()) + .setLng(center.getLng()) + .setRadius(radius) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.MOVE); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + }); + + engine.executeScript(circleBuilder.buildJsElement()); + var circle = circleBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, circle); + return circle; + } + + /** + * Drawing circle overlays on the map with {@link JLOptions#DEFAULT} + * options and {@link JLProperties#DEFAULT_CIRCLE_RADIUS}. + * + * @see JLVaadinVectorLayer#addCircle(JLLatLng, int, JLOptions) + */ + @Override + public JLCircle addCircle(JLLatLng center) { + return addCircle(center, JLProperties.DEFAULT_CIRCLE_RADIUS, JLOptions.DEFAULT); + } + + /** + * Remove a circle from the map by id. + * + * @param id of circle + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removeCircle(String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLCircle.class, id); + } catch (RuntimeException e) { + log.error(e.getMessage(), e); + return false; + } + return true; + } + + /** + * Drawing circle marker overlays on the map. + * + * @param center latLng point of circle marker + * @param radius radius of circle marker in pixels + * @param options see {@link JLOptions} for customizing + * @return the added {@link JLCircleMarker} to map + */ + @Override + public JLCircleMarker addCircleMarker(JLLatLng center, int radius, + JLOptions options) { + var elementUniqueName = getElementUniqueName(JLCircleMarker.class, idGenerator.incrementAndGet()); + + var circleMarkerBuilder = new JLCircleMarkerBuilder() + .setUuid(elementUniqueName) + .setLat(center.getLat()) + .setLng(center.getLng()) + .setRadius(radius) + .setTransporter(getTransporter()) + .withOptions(options) + .withCallbacks(jlCallbackBuilder -> { + jlCallbackBuilder.on(JLAction.MOVE); + jlCallbackBuilder.on(JLAction.ADD); + jlCallbackBuilder.on(JLAction.REMOVE); + jlCallbackBuilder.on(JLAction.CLICK); + jlCallbackBuilder.on(JLAction.DOUBLE_CLICK); + jlCallbackBuilder.on(JLAction.CONTEXT_MENU); + }); + + engine.executeScript(circleMarkerBuilder.buildJsElement()); + var circleMarker = circleMarkerBuilder.buildJLObject(); + callbackHandler.addJLObject(elementUniqueName, circleMarker); + return circleMarker; + } + + /** + * Drawing circle marker overlays on the map with {@link JLOptions#DEFAULT} + * options. + * + * @see JLVaadinVectorLayer#addCircleMarker(JLLatLng, int, JLOptions) + */ + @Override + public JLCircleMarker addCircleMarker(JLLatLng center) { + return addCircleMarker(center, 6, JLOptions.DEFAULT); + } + + /** + * Remove a circle marker from the map by id. + * + * @param id of circle marker + * @return {@link Boolean#TRUE} if removed successfully + */ + @Override + public boolean removeCircleMarker(String id) { + try { + engine.executeScript(removeLayerWithUUID(id)); + callbackHandler.remove(JLCircleMarker.class, id); + } catch (RuntimeException e) { + log.error(e.getMessage(), e); + return false; + } + return true; + } +} diff --git a/jlmap-vaadin/src/main/java/module-info.java b/jlmap-vaadin/src/main/java/module-info.java new file mode 100644 index 0000000..d73e70a --- /dev/null +++ b/jlmap-vaadin/src/main/java/module-info.java @@ -0,0 +1,53 @@ +module io.github.makbn.jlmap.vaadin { + // API dependency + requires io.github.makbn.jlmap.api; + + requires spring.boot; + requires spring.boot.autoconfigure; + requires spring.beans; + requires spring.web; + + //Vaadin UI components used + requires flow.server; + requires vaadin.grid.flow; + requires flow.data; + requires vaadin.text.field.flow; + requires vaadin.lumo.theme; + requires vaadin.ordered.layout.flow; + requires vaadin.context.menu.flow; + + // JDK modules + requires jdk.jsobject; + + // Logging + requires org.slf4j; + + // JSON processing + requires com.google.gson; + requires com.fasterxml.jackson.databind; + + // Annotations + requires static org.jetbrains.annotations; + requires static lombok; + requires vaadin.notification.flow; + requires flow.html.components; + requires vaadin.button.flow; + requires vaadin.spring; + requires gwt.elemental; + requires org.apache.tomcat.embed.core; + requires spring.context; + requires vaadin.icons.flow; + requires vaadin.avatar.flow; + + // Exports for public API + exports io.github.makbn.jlmap.vaadin; + exports io.github.makbn.jlmap.vaadin.element.menu; + + // Service providers + provides io.github.makbn.jlmap.element.menu.JLContextMenuMediator + with io.github.makbn.jlmap.vaadin.element.menu.VaadinContextMenuMediator; + + // Opens for reflection (if needed by frameworks) + opens io.github.makbn.jlmap.vaadin to com.vaadin.flow.server; + opens io.github.makbn.jlmap.vaadin.engine to com.vaadin.flow.server; +} \ No newline at end of file diff --git a/jlmap-vaadin/src/main/resources/META-INF/services/io.github.makbn.jlmap.element.menu.JLContextMenuMediator b/jlmap-vaadin/src/main/resources/META-INF/services/io.github.makbn.jlmap.element.menu.JLContextMenuMediator new file mode 100644 index 0000000..82087a0 --- /dev/null +++ b/jlmap-vaadin/src/main/resources/META-INF/services/io.github.makbn.jlmap.element.menu.JLContextMenuMediator @@ -0,0 +1 @@ +io.github.makbn.jlmap.vaadin.element.menu.VaadinContextMenuMediator \ No newline at end of file diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/element/menu/VaadinContextMenuMediatorTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/element/menu/VaadinContextMenuMediatorTest.java new file mode 100644 index 0000000..4e9b141 --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/element/menu/VaadinContextMenuMediatorTest.java @@ -0,0 +1,260 @@ +package io.github.makbn.jlmap.vaadin.test.element.menu; + +import io.github.makbn.jlmap.element.menu.JLContextMenu; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLMarker; +import io.github.makbn.jlmap.vaadin.JLMapView; +import io.github.makbn.jlmap.vaadin.element.menu.VaadinContextMenuMediator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for VaadinContextMenuMediator. + * Tests the context menu mediator functionality for Vaadin implementation. + * + * @author Matt Akbarian (@makbn) + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class VaadinContextMenuMediatorTest { + + @Mock + private JLMapView mockMapView; + + private VaadinContextMenuMediator mediator; + + @BeforeEach + void setUp() { + mediator = new VaadinContextMenuMediator(); + } + + // === Constructor and Basic Tests === + + @Test + void constructor_shouldInitializeSuccessfully() { + // When + VaadinContextMenuMediator newMediator = new VaadinContextMenuMediator(); + + // Then + assertThat(newMediator).isNotNull(); + assertThat(newMediator.getName()).isEqualTo("Vaadin Context Menu Mediator"); + } + + @Test + void getName_shouldReturnCorrectName() { + // When + String name = mediator.getName(); + + // Then + assertThat(name).isEqualTo("Vaadin Context Menu Mediator"); + } + + @Test + void supportsObjectType_shouldReturnTrueForAllTypes() { + // When/Then + assertThat(mediator.supportsObjectType(JLMarker.class)).isTrue(); + } + + // === showContextMenu Tests === + // Note: Full showContextMenu tests require Vaadin UI context which cannot be + // easily mocked. These tests verify the marker setup without actually showing menus. + + @Test + void showContextMenu_withObjectWithoutContextMenu_shouldNotThrowException() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + // When/Then - Should not throw exception even without context menu + // Note: Cannot test full functionality without Vaadin UI context + assertThat(marker.getContextMenu()).isNull(); + assertThat(marker.hasContextMenu()).isFalse(); + } + + @Test + void showContextMenu_withDisabledContextMenu_shouldHaveDisabledState() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("test", "Test Item"); + marker.setContextMenuEnabled(false); + + // When/Then - Verify state without requiring Vaadin UI + assertThat(marker.isContextMenuEnabled()).isFalse(); + assertThat(marker.hasContextMenu()).isTrue(); + } + + @Test + void showContextMenu_withEnabledContextMenuButNoItems_shouldNotHaveContextMenu() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + marker.addContextMenu(); // Empty context menu + + // When/Then - Empty context menu means no context menu + assertThat(marker.hasContextMenu()).isFalse(); + } + + @Test + void showContextMenu_withValidContextMenu_shouldPopulateMenuItems() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit") + .addItem("delete", "Delete") + .addItem("info", "Info"); + + // Note: Can't fully test showing the menu in unit tests without a Vaadin UI context + // This test verifies the setup is correct + assertThat(marker.hasContextMenu()).isTrue(); + assertThat(marker.isContextMenuEnabled()).isTrue(); + assertThat(contextMenu.getItemCount()).isEqualTo(3); + } + + // === hideContextMenu Tests === + + @Test + void hideContextMenu_withNullContextMenu_shouldNotThrowException() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + // When/Then - Should not throw exception + mediator.hideContextMenu(mockMapView, marker); + } + + @Test + void hideContextMenu_shouldClearMenuItems() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("test", "Test Item"); + + // When + mediator.hideContextMenu(mockMapView, marker); + + // Then - Menu should be hidden (can't verify fully without Vaadin UI context) + // But we can verify the marker still has its context menu + assertThat(marker.getContextMenu()).isNotNull(); + } + + // === Multiple showContextMenu Calls === + + @Test + void showContextMenu_calledMultipleTimes_shouldMaintainContextMenuState() { + // Given + JLMarker marker1 = createMarkerWithContextMenu("marker1", "Item 1"); + JLMarker marker2 = createMarkerWithContextMenu("marker2", "Item 2"); + + // When/Then - Verify markers maintain their context menu state + // Note: Cannot test actual Vaadin menu reuse without UI context + assertThat(marker1.hasContextMenu()).isTrue(); + assertThat(marker2.hasContextMenu()).isTrue(); + } + + // === Helper Methods === + + private JLMarker createMarkerWithContextMenu(String id, String menuItemText) { + JLMarker marker = JLMarker.builder() + .id(id) + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test Marker") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem(menuItemText, menuItemText); + + return marker; + } + + // === Context Menu Item Tests === + + @Test + void showContextMenu_withMenuItemWithIcon_shouldHandleIcon() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit", "https://img.icons8.com/material-outlined/24/000000/edit--v1.png"); + + // When/Then - Should not throw exception + // Note: Full verification requires Vaadin UI context + assertThat(contextMenu.getItem("edit").getIcon()).isNotNull(); + } + + @Test + void showContextMenu_withMenuItemWithoutIcon_shouldHandleNullIcon() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit", null); + + // When/Then - Should not throw exception + assertThat(contextMenu.getItem("edit").getIcon()).isNull(); + } + + @Test + void showContextMenu_withMenuItemWithBlankIcon_shouldHandleBlankIcon() { + // Given + JLMarker marker = JLMarker.builder() + .id("test-marker") + .latLng(new JLLatLng(51.5, -0.09)) + .text("Test") + .transport(null) + .build(); + + JLContextMenu contextMenu = marker.addContextMenu(); + contextMenu.addItem("edit", "Edit", ""); + + // When/Then - Should not throw exception + assertThat(contextMenu.getItem("edit").getIcon()).isEmpty(); + } +} diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/engine/JLVaadinEngineTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/engine/JLVaadinEngineTest.java new file mode 100644 index 0000000..b59c7bc --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/engine/JLVaadinEngineTest.java @@ -0,0 +1,54 @@ +package io.github.makbn.jlmap.vaadin.test.engine; + +import com.vaadin.flow.dom.Element; +import io.github.makbn.jlmap.vaadin.engine.JLVaadinEngine; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JLVaadinEngineTest { + + @Mock + private Supplier mockElementSupplier; + + @Mock + private Element mockElement; + + @Test + void jlVaadinEngine_shouldExtendJLWebEngine() { + when(mockElementSupplier.get()).thenReturn(mockElement); + JLVaadinEngine engine = new JLVaadinEngine(mockElementSupplier); + + assertThat(engine).isInstanceOf(io.github.makbn.jlmap.engine.JLWebEngine.class); + } + + @Test + void jlVaadinEngine_shouldHaveCorrectConstructorParameter() { + when(mockElementSupplier.get()).thenReturn(mockElement); + JLVaadinEngine engine = new JLVaadinEngine(mockElementSupplier); + + assertThat(engine).isNotNull(); + } + + @Test + void jlVaadinEngine_shouldImplementRequiredMethods() { + when(mockElementSupplier.get()).thenReturn(mockElement); + JLVaadinEngine engine = new JLVaadinEngine(mockElementSupplier); + + // Verify engine has executeScript method + assertThat(engine).extracting("class").satisfies(clazz -> { + try { + ((Class) clazz).getMethod("executeScript", String.class); + } catch (NoSuchMethodException e) { + throw new AssertionError("executeScript method not found"); + } + }); + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/integration/JLVaadinMapViewIntegrationTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/integration/JLVaadinMapViewIntegrationTest.java new file mode 100644 index 0000000..559f059 --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/integration/JLVaadinMapViewIntegrationTest.java @@ -0,0 +1,730 @@ +package io.github.makbn.jlmap.vaadin.test.integration; + +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.model.JLOptions; +import io.github.makbn.jlmap.vaadin.JLMapView; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for JLMapView that verify real map interactions + * and component additions. These tests follow the exact same pattern + * as the JavaFX integration tests but adapted for Vaadin. + */ +@ExtendWith(MockitoExtension.class) +class JLVaadinMapViewIntegrationTest { + + private JLMapView map; + + @BeforeEach + void setUp() { + map = JLMapView.builder() + .jlMapProvider(JLMapProvider.getDefault()) + .startCoordinate(JLLatLng.builder() + .lat(51.044) + .lng(-114.07) + .build()) + .showZoomController(true) + .build(); + + // Manually initialize layers since we're not attaching to DOM in tests + // This simulates what onAttach() does + try { + java.lang.reflect.Method initMethod = map.getClass().getDeclaredMethod("initializeLayers"); + initMethod.setAccessible(true); + initMethod.invoke(map); + } catch (Exception e) { + // If reflection fails, we can't test properly + throw new RuntimeException("Failed to initialize layers for testing", e); + } + } + + @Test + void jlMapView_addMarker_shouldAddMarkerAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + // Simulate Vaadin UI thread context - use Runnable instead of Platform.runLater + Runnable markerAddition = () -> { + map.getUiLayer().addMarker(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "A Marker", false); + latch.countDown(); + }; + + // Execute marker addition + markerAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + // In Vaadin, we would verify JavaScript execution - simulate the verification + Runnable verification = () -> { + Object markerCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Marker).length"""); + // Verify the JavaScript was executed (in real scenario this would return count) + assertThat(markerCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker addition"); + } + } + + @Test + void jlMapView_addPopup_shouldAddPopupAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable popupAddition = () -> { + map.getUiLayer().addPopup(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Popup"); + latch.countDown(); + }; + + popupAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object popupCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Popup).length"""); + assertThat(popupCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for popup addition"); + } + } + + @Test + void jlMapView_addImageOverlay_shouldAddImageOverlayAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable imageAddition = () -> { + map.getUiLayer().addImage( + JLBounds.builder() + .southWest(JLLatLng.builder().lat(50.0).lng(-120.0).build()) + .northEast(JLLatLng.builder().lat(55.0).lng(-110.0).build()) + .build(), + "https://example.com/image.png", + JLOptions.DEFAULT + ); + latch.countDown(); + }; + + imageAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object imageOverlayCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.ImageOverlay).length"""); + assertThat(imageOverlayCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for image overlay addition"); + } + } + + @Test + void jlMapView_addPolyline_shouldAddPolylineAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable polylineAddition = () -> { + map.getVectorLayer().addPolyline(new JLLatLng[]{ + JLLatLng.builder().lat(51.509).lng(-0.08).build(), + JLLatLng.builder().lat(51.503).lng(-0.06).build(), + JLLatLng.builder().lat(51.51).lng(-0.047).build() + }); + latch.countDown(); + }; + + polylineAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object polylineCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Polyline).length"""); + assertThat(polylineCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for polyline addition"); + } + } + + @Test + void jlMapView_addMultiPolyline_shouldAddMultiPolylineAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable multiPolylineAddition = () -> { + map.getVectorLayer().addMultiPolyline(new JLLatLng[][]{ + { + JLLatLng.builder().lat(41.509).lng(20.08).build(), + JLLatLng.builder().lat(31.503).lng(-10.06).build() + }, + { + JLLatLng.builder().lat(51.509).lng(10.08).build(), + JLLatLng.builder().lat(55.503).lng(15.06).build() + } + }); + latch.countDown(); + }; + + multiPolylineAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object polylineCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Polyline).length"""); + assertThat(polylineCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for multi-polyline addition"); + } + } + + @Test + void jlMapView_addPolygon_shouldAddPolygonAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable polygonAddition = () -> { + map.getVectorLayer().addPolygon(new JLLatLng[][][]{ + {{ + JLLatLng.builder().lat(37.0).lng(-109.05).build(), + JLLatLng.builder().lat(41.0).lng(-109.03).build(), + JLLatLng.builder().lat(41.0).lng(-102.05).build(), + JLLatLng.builder().lat(37.0).lng(-102.04).build() + }} + }); + latch.countDown(); + }; + + polygonAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object polygonCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Polygon).length"""); + assertThat(polygonCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for polygon addition"); + } + } + + @Test + void jlMapView_addCircle_shouldAddCircleAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable circleAddition = () -> { + map.getVectorLayer().addCircle(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build()); + latch.countDown(); + }; + + circleAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object circleCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.Circle).length"""); + assertThat(circleCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for circle addition"); + } + } + + @Test + void jlMapView_addCircleMarker_shouldAddCircleMarkerAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable circleMarkerAddition = () -> { + map.getVectorLayer().addCircleMarker(JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build()); + latch.countDown(); + }; + + circleMarkerAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object circleMarkerCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.CircleMarker).length"""); + assertThat(circleMarkerCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for circle marker addition"); + } + } + + @Test + void jlMapView_addGeoJsonFromContent_shouldAddGeoJsonAsLayer() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + Runnable geoJsonAddition = () -> { + try { + map.getGeoJsonLayer().addFromContent(""" + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.4050, 52.5200] + }, + "properties": { + "name": "Berlin" + } + } + ] + }"""); + } catch (Exception e) { + // Handle exception in test + } + latch.countDown(); + }; + + geoJsonAddition.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + Object geoJsonCount = map.getJLEngine().executeScript(""" + Object.keys(map._layers).filter(k => map._layers[k] instanceof L.GeoJSON).length"""); + assertThat(geoJsonCount).isNotNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for GeoJSON addition"); + } + } + + @Test + void jlMapView_markerWithContextMenu_shouldHaveContextMenu() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Runnable markerWithContextMenu = () -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + marker.addContextMenu() + .addItem("edit", "Edit Marker") + .addItem("delete", "Delete Marker") + .addItem("info", "Show Info"); + + markerRef[0] = marker; + latch.countDown(); + }; + + markerWithContextMenu.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + assertThat(marker.hasContextMenu()).isTrue(); + assertThat(marker.isContextMenuEnabled()).isTrue(); + assertThat(marker.getContextMenu()).isNotNull(); + assertThat(marker.getContextMenu().getItemCount()).isEqualTo(3); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker with context menu"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportEnableDisable() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Runnable markerContextMenuToggle = () -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + marker.addContextMenu() + .addItem("edit", "Edit Marker"); + + marker.setContextMenuEnabled(false); + markerRef[0] = marker; + latch.countDown(); + }; + + markerContextMenuToggle.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + assertThat(marker.isContextMenuEnabled()).isFalse(); + + marker.setContextMenuEnabled(true); + assertThat(marker.isContextMenuEnabled()).isTrue(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker context menu enable/disable test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportAddingRemovingItems() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Runnable markerContextMenuOperations = () -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + contextMenu.addItem("edit", "Edit Marker") + .addItem("delete", "Delete Marker") + .addItem("info", "Show Info"); + + markerRef[0] = marker; + latch.countDown(); + }; + + markerContextMenuOperations.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + assertThat(contextMenu.getItemCount()).isEqualTo(3); + + contextMenu.removeItem("edit"); + assertThat(contextMenu.getItemCount()).isEqualTo(2); + assertThat(contextMenu.getItem("edit")).isNull(); + + contextMenu.addItem("new", "New Item"); + assertThat(contextMenu.getItemCount()).isEqualTo(3); + assertThat(contextMenu.getItem("new")).isNotNull(); + + contextMenu.clearItems(); + assertThat(contextMenu.getItemCount()).isZero(); + assertThat(marker.hasContextMenu()).isFalse(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker context menu operations test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportMenuItemVisibility() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Runnable markerContextMenuVisibility = () -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + + // Add visible item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("visible") + .text("Visible Item") + .visible(true) + .build()); + + // Add hidden item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("hidden") + .text("Hidden Item") + .visible(false) + .build()); + + markerRef[0] = marker; + latch.countDown(); + }; + + markerContextMenuVisibility.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + assertThat(contextMenu.getItemCount()).isEqualTo(2); + assertThat(contextMenu.getVisibleItemCount()).isEqualTo(1); + assertThat(contextMenu.hasVisibleItems()).isTrue(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker context menu visibility test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportMenuItemUpdate() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Runnable markerContextMenuUpdate = () -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + contextMenu.addItem("edit", "Edit Marker"); + + markerRef[0] = marker; + latch.countDown(); + }; + + markerContextMenuUpdate.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + // Update existing item + io.github.makbn.jlmap.element.menu.JLMenuItem updatedItem = + io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("edit") + .text("Edit Properties") + .icon("https://example.com/edit.png") + .build(); + + contextMenu.updateItem(updatedItem); + + io.github.makbn.jlmap.element.menu.JLMenuItem item = contextMenu.getItem("edit"); + assertThat(item).isNotNull(); + assertThat(item.getText()).isEqualTo("Edit Properties"); + assertThat(item.getIcon()).isEqualTo("https://example.com/edit.png"); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker context menu update test"); + } + } + + @Test + void jlMapView_markerContextMenu_shouldSupportListenerCallback() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + final boolean[] listenerInvoked = {false}; + + Runnable markerContextMenuListener = () -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + contextMenu.addItem("test", "Test Item") + .setOnMenuItemListener(item -> { + listenerInvoked[0] = true; + }); + + markerRef[0] = marker; + latch.countDown(); + }; + + markerContextMenuListener.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + // Simulate menu item selection + io.github.makbn.jlmap.element.menu.JLMenuItem item = contextMenu.getItem("test"); + contextMenu.handleMenuItemSelection(item); + + assertThat(listenerInvoked[0]).isTrue(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker context menu listener test"); + } + } + + @Test + void jlMapView_multipleMarkersWithContextMenu_shouldMaintainIndependentMenus() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] marker1Ref = new io.github.makbn.jlmap.model.JLMarker[1]; + final io.github.makbn.jlmap.model.JLMarker[] marker2Ref = new io.github.makbn.jlmap.model.JLMarker[1]; + + Runnable multipleMarkersContextMenu = () -> { + io.github.makbn.jlmap.model.JLMarker marker1 = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Marker 1", false); + + io.github.makbn.jlmap.model.JLMarker marker2 = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Marker 2", false); + + marker1.addContextMenu() + .addItem("edit", "Edit Marker 1") + .addItem("delete", "Delete Marker 1"); + + marker2.addContextMenu() + .addItem("view", "View Marker 2") + .addItem("share", "Share Marker 2"); + + marker1Ref[0] = marker1; + marker2Ref[0] = marker2; + latch.countDown(); + }; + + multipleMarkersContextMenu.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker1 = marker1Ref[0]; + io.github.makbn.jlmap.model.JLMarker marker2 = marker2Ref[0]; + + assertThat(marker1.hasContextMenu()).isTrue(); + assertThat(marker2.hasContextMenu()).isTrue(); + + assertThat(marker1.getContextMenu().getItemCount()).isEqualTo(2); + assertThat(marker2.getContextMenu().getItemCount()).isEqualTo(2); + + assertThat(marker1.getContextMenu().getItem("edit")).isNotNull(); + assertThat(marker1.getContextMenu().getItem("view")).isNull(); + + assertThat(marker2.getContextMenu().getItem("view")).isNotNull(); + assertThat(marker2.getContextMenu().getItem("edit")).isNull(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for multiple markers context menu test"); + } + } + + @Test + void jlMapView_markerContextMenu_withIconsAndVariousStates() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final io.github.makbn.jlmap.model.JLMarker[] markerRef = new io.github.makbn.jlmap.model.JLMarker[1]; + + Runnable markerContextMenuWithStates = () -> { + io.github.makbn.jlmap.model.JLMarker marker = map.getUiLayer().addMarker( + JLLatLng.builder() + .lat(ThreadLocalRandom.current().nextDouble(-90.0, 90.0)) + .lng(ThreadLocalRandom.current().nextDouble(-180.0, 180.0)) + .build(), + "Test Marker", false); + + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.addContextMenu(); + + // Item with icon + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("edit") + .text("Edit") + .icon("https://img.icons8.com/material-outlined/24/000000/edit--v1.png") + .enabled(true) + .visible(true) + .build()); + + // Disabled item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("delete") + .text("Delete") + .enabled(false) + .visible(true) + .build()); + + // Hidden item + contextMenu.addItem(io.github.makbn.jlmap.element.menu.JLMenuItem.builder() + .id("admin") + .text("Admin Action") + .enabled(true) + .visible(false) + .build()); + + markerRef[0] = marker; + latch.countDown(); + }; + + markerContextMenuWithStates.run(); + + if (latch.await(5, TimeUnit.SECONDS)) { + Runnable verification = () -> { + io.github.makbn.jlmap.model.JLMarker marker = markerRef[0]; + io.github.makbn.jlmap.element.menu.JLContextMenu contextMenu = + marker.getContextMenu(); + + assertThat(contextMenu.getItemCount()).isEqualTo(3); + assertThat(contextMenu.getVisibleItemCount()).isEqualTo(2); + + io.github.makbn.jlmap.element.menu.JLMenuItem editItem = contextMenu.getItem("edit"); + assertThat(editItem.isEnabled()).isTrue(); + assertThat(editItem.isVisible()).isTrue(); + assertThat(editItem.getIcon()).isNotNull(); + + io.github.makbn.jlmap.element.menu.JLMenuItem deleteItem = contextMenu.getItem("delete"); + assertThat(deleteItem.isEnabled()).isFalse(); + assertThat(deleteItem.isVisible()).isTrue(); + + io.github.makbn.jlmap.element.menu.JLMenuItem adminItem = contextMenu.getItem("admin"); + assertThat(adminItem.isEnabled()).isTrue(); + assertThat(adminItem.isVisible()).isFalse(); + }; + verification.run(); + } else { + throw new TimeoutException("Timed out waiting for marker context menu with various states test"); + } + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/internal/JLVaadinMapRendererTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/internal/JLVaadinMapRendererTest.java new file mode 100644 index 0000000..8621fc6 --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/internal/JLVaadinMapRendererTest.java @@ -0,0 +1,132 @@ +package io.github.makbn.jlmap.vaadin.test.internal; + +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dependency.StyleSheet; +import io.github.makbn.jlmap.map.JLMapProvider; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.vaadin.JLMapView; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class JLVaadinMapRendererTest { + + private JLMapView mapView; + + @BeforeEach + void setUp() { + mapView = JLMapView.builder() + .jlMapProvider(JLMapProvider.getDefault()) + .startCoordinate(JLLatLng.builder().lat(51.505).lng(-0.09).build()) + .showZoomController(true) + .build(); + } + + @Test + void render_shouldGenerateValidHtmlDocument() { + // Test that Vaadin component generates valid structure + assertThat(mapView.getClass().getAnnotation(Tag.class).value()).isEqualTo("jl-map-view"); + assertThat(mapView).isNotNull(); + } + + @Test + void render_shouldIncludeLeafletCssAndJavascript() { + // Verify Vaadin annotations include Leaflet dependencies + assertThat(mapView.getClass().getAnnotation(StyleSheet.class)).isNotNull(); + assertThat(mapView.getClass().getAnnotation(JsModule.class)).isNotNull(); + } + + @Test + void render_shouldIncludeLeafletIntegrityAttributes() { + // Verify Vaadin uses secure Leaflet loading + StyleSheet styleSheet = mapView.getClass().getAnnotation(StyleSheet.class); + assertThat(styleSheet.value()).contains("leaflet.css"); + } + + @Test + void render_shouldIncludeMapContainerDiv() { + // Verify component acts as map container + assertThat(mapView.getClass().getAnnotation(Tag.class).value()).isEqualTo("jl-map-view"); + } + + @Test + void render_shouldIncludeMapHelperFunctions() { + // Test that initialization includes helper functions + String initScript = getInitializationScript(); + assertThat(initScript).contains("this.map"); + } + + @Test + void render_shouldIncludeJsRelayFunction() { + // Verify JavaScript relay function exists + assertThat(mapView).extracting("class").satisfies(clazz -> { + try { + ((Class) clazz).getMethod("eventHandler", String.class, String.class, String.class, String.class, String.class, String.class); + } catch (NoSuchMethodException e) { + throw new AssertionError("eventHandler method not found"); + } + }); + } + + @Test + void render_shouldIncludeClientToServerEventHandler() { + // Verify @ClientCallable annotation exists + try { + mapView.getClass().getMethod("eventHandler", String.class, String.class, String.class, String.class, String.class, String.class) + .getAnnotation(com.vaadin.flow.component.ClientCallable.class); + assertThat(true).isTrue(); // Method has @ClientCallable + } catch (NoSuchMethodException e) { + throw new AssertionError("ClientCallable eventHandler not found"); + } + } + + @Test + void render_shouldIncludeMapInitializationWithCorrectParameters() { + String initScript = getInitializationScript(); + assertThat(initScript).contains("L.map"); + assertThat(initScript).contains("setView"); + assertThat(initScript).contains("51.505"); + assertThat(initScript).contains("-0.09"); + } + + @Test + void render_shouldIncludeAllMapEventHandlers() { + // Verify component can handle events + assertThat(mapView).hasFieldOrProperty("jlMapCallbackHandler"); + } + + @Test + void render_shouldIncludeMapSetup() { + String initScript = getInitializationScript(); + assertThat(initScript).contains("L.tileLayer"); + assertThat(initScript).contains("addTo"); + } + + @Test + void render_shouldSetBodyStyle() { + // Verify component has proper styling + assertThat(mapView.getBoxSizing()).isEqualTo(com.vaadin.flow.component.orderedlayout.BoxSizing.CONTENT_BOX); + assertThat(mapView.getMinHeight()).isEqualTo("100%"); + } + + @Test + void render_shouldIncludeMetaTags() { + // Verify component has proper metadata + assertThat(mapView.getClass().getAnnotation(Tag.class).value()).isEqualTo("jl-map-view"); + } + + private String getInitializationScript() { + try { + java.lang.reflect.Method method = mapView.getClass().getDeclaredMethod("generateInitializeFunctionCall"); + method.setAccessible(true); + return (String) method.invoke(mapView); + } catch (Exception e) { + return ""; + } + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinControlLayerTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinControlLayerTest.java new file mode 100644 index 0000000..37d0c92 --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinControlLayerTest.java @@ -0,0 +1,301 @@ +package io.github.makbn.jlmap.vaadin.test.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.model.JLBounds; +import io.github.makbn.jlmap.model.JLLatLng; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinControlLayer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JLVaadinControlLayerTest { + + @Mock + private JLWebEngine mockEngine; + + @Mock + private JLMapEventHandler mockCallbackHandler; + + @Mock + private PendingJavaScriptResult mockJavaScriptResult; + + private JLVaadinControlLayer controlLayer; + + @BeforeEach + void setUp() { + controlLayer = new JLVaadinControlLayer(mockEngine, mockCallbackHandler); + } + + // === Constructor Tests === + + @Test + void constructor_withNullEngine_shouldAcceptNullEngine() { + JLVaadinControlLayer layer = new JLVaadinControlLayer(null, mockCallbackHandler); + assertThat(layer).isNotNull(); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + JLVaadinControlLayer layer = new JLVaadinControlLayer(mockEngine, null); + assertThat(layer).isNotNull(); + } + + @Test + void controlLayer_shouldExtendJLLayer() { + assertThat(controlLayer).isInstanceOf(io.github.makbn.jlmap.vaadin.layer.JLVaadinLayer.class); + } + + // === Zoom Tests === + + @Test + void zoomIn_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + int delta = 2; + + controlLayer.zoomIn(delta); + + verify(mockEngine).executeScript("this.map.zoomIn(2)"); + } + + @Test + void zoomOut_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + int delta = 3; + + controlLayer.zoomOut(delta); + + verify(mockEngine).executeScript("this.map.zoomOut(3)"); + } + + @Test + void setZoom_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + int zoomLevel = 10; + + controlLayer.setZoom(zoomLevel); + + verify(mockEngine).executeScript("this.map.setZoom(10)"); + } + + @Test + void setZoomAround_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int zoom = 15; + + controlLayer.setZoomAround(latLng, zoom); + + verify(mockEngine).executeScript("this.map.setZoomAround(L.latLng(52.520000, 13.405000), 15)"); + } + + @Test + void setZoomAround_withNullLatLng_shouldThrowNullPointerException() { + assertThatThrownBy(() -> controlLayer.setZoomAround(null, 10)) + .isInstanceOf(NullPointerException.class); + } + + // === Bounds Tests === + + @Test + void fitBounds_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + + controlLayer.fitBounds(bounds); + + verify(mockEngine).executeScript(argThat(script -> + script.startsWith("this.map.fitBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void fitBounds_withNullBounds_shouldThrowNullPointerException() { + assertThatThrownBy(() -> controlLayer.fitBounds(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void fitWorld_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + + controlLayer.fitWorld(); + + verify(mockEngine).executeScript("this.map.fitWorld()"); + } + + @Test + void setMaxBounds_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(45.0).lng(-120.0).build()) + .northEast(JLLatLng.builder().lat(50.0).lng(-110.0).build()) + .build(); + + controlLayer.setMaxBounds(bounds); + + verify(mockEngine).executeScript(argThat(script -> + script.startsWith("this.map.setMaxBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void setMaxBounds_withNullBounds_shouldThrowNullPointerException() { + assertThatThrownBy(() -> controlLayer.setMaxBounds(null)) + .isInstanceOf(NullPointerException.class); + } + + // === Pan Tests === + + @Test + void panTo_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + controlLayer.panTo(latLng); + + verify(mockEngine).executeScript("this.map.panTo(L.latLng(52.520000, 13.405000))"); + } + + @Test + void panTo_withNullLatLng_shouldThrowNullPointerException() { + assertThatThrownBy(() -> controlLayer.panTo(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void flyTo_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int zoom = 12; + + controlLayer.flyTo(latLng, zoom); + + verify(mockEngine).executeScript("this.map.flyTo(L.latLng(52.520000, 13.405000), 12)"); + } + + @Test + void flyTo_withNullLatLng_shouldThrowNullPointerException() { + assertThatThrownBy(() -> controlLayer.flyTo(null, 10)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void flyToBounds_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + + controlLayer.flyToBounds(bounds); + + verify(mockEngine).executeScript(argThat(script -> + script.startsWith("this.map.flyToBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void panInsideBounds_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(52.5200).lng(13.4050).build()) + .northEast(JLLatLng.builder().lat(52.5300).lng(13.4150).build()) + .build(); + + controlLayer.panInsideBounds(bounds); + + verify(mockEngine).executeScript(argThat(script -> + script.startsWith("this.map.panInsideBounds(") && + script.contains(bounds.toString()))); + } + + @Test + void panInside_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng latLng = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + controlLayer.panInside(latLng); + + verify(mockEngine).executeScript("this.map.panInside(L.latLng(52.520000, 13.405000))"); + } + + // === Zoom Limits Tests === + + @Test + void setMinZoom_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + int zoom = 5; + + controlLayer.setMinZoom(zoom); + + verify(mockEngine).executeScript("this.map.setMinZoom(5)"); + } + + @Test + void setMaxZoom_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + int zoom = 18; + + controlLayer.setMaxZoom(zoom); + + verify(mockEngine).executeScript("this.map.setMaxZoom(18)"); + } + + // === Edge Cases === + + @Test + void zoomOperations_withZeroValues_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + + controlLayer.zoomIn(0); + controlLayer.zoomOut(0); + controlLayer.setZoom(0); + + verify(mockEngine).executeScript("this.map.zoomIn(0)"); + verify(mockEngine).executeScript("this.map.zoomOut(0)"); + verify(mockEngine).executeScript("this.map.setZoom(0)"); + } + + @Test + void zoomOperations_withNegativeValues_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + + controlLayer.zoomIn(-1); + controlLayer.zoomOut(-2); + controlLayer.setZoom(-5); + + verify(mockEngine).executeScript("this.map.zoomIn(-1)"); + verify(mockEngine).executeScript("this.map.zoomOut(-2)"); + verify(mockEngine).executeScript("this.map.setZoom(-5)"); + } + + @Test + void multipleOperations_shouldExecuteAllScripts() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int zoom = 10; + + controlLayer.setZoom(zoom); + controlLayer.panTo(center); + controlLayer.setMinZoom(5); + controlLayer.setMaxZoom(18); + + verify(mockEngine).executeScript("this.map.setZoom(10)"); + verify(mockEngine).executeScript("this.map.panTo(L.latLng(52.520000, 13.405000))"); + verify(mockEngine).executeScript("this.map.setMinZoom(5)"); + verify(mockEngine).executeScript("this.map.setMaxZoom(18)"); + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinGeoJsonLayerTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinGeoJsonLayerTest.java new file mode 100644 index 0000000..e9d70ca --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinGeoJsonLayerTest.java @@ -0,0 +1,321 @@ +package io.github.makbn.jlmap.vaadin.test.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.exception.JLException; +import io.github.makbn.jlmap.geojson.JLGeoJsonContent; +import io.github.makbn.jlmap.geojson.JLGeoJsonFile; +import io.github.makbn.jlmap.geojson.JLGeoJsonURL; +import io.github.makbn.jlmap.model.JLGeoJson; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinGeoJsonLayer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JLVaadinGeoJsonLayerTest { + + private static final String VALID_GEOJSON = """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.4050, 52.5200] + }, + "properties": { + "name": "Berlin" + } + } + ] + }"""; + + @Mock + private JLWebEngine engine; + + @Mock + private JLMapEventHandler callbackHandler; + + @Mock + private PendingJavaScriptResult mockJavaScriptResult; + + @Mock + private JLGeoJsonFile mockGeoJsonFile; + + @Mock + private JLGeoJsonURL mockGeoJsonURL; + + @Mock + private JLGeoJsonContent mockGeoJsonContent; + + private JLVaadinGeoJsonLayer geoJsonLayer; + + @BeforeEach + void setUp() { + // Mock the bridge initialization + lenient().when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + + geoJsonLayer = new JLVaadinGeoJsonLayer(engine, callbackHandler); + + // Clear invocations from the constructor (bridge initialization) + clearInvocations(engine, callbackHandler); + + // Use reflection to inject mocks for testing + try { + var fromFileField = JLVaadinGeoJsonLayer.class.getDeclaredField("fromFile"); + fromFileField.setAccessible(true); + fromFileField.set(geoJsonLayer, mockGeoJsonFile); + + var fromUrlField = JLVaadinGeoJsonLayer.class.getDeclaredField("fromUrl"); + fromUrlField.setAccessible(true); + fromUrlField.set(geoJsonLayer, mockGeoJsonURL); + + var fromContentField = JLVaadinGeoJsonLayer.class.getDeclaredField("fromContent"); + fromContentField.setAccessible(true); + fromContentField.set(geoJsonLayer, mockGeoJsonContent); + } catch (Exception e) { + throw new RuntimeException("Failed to set up mocks", e); + } + } + + @Test + void constructor_withNullEngine_shouldThrowNullPointerException() { + // When/Then - Bridge initialization requires a non-null engine + assertThatThrownBy(() -> new JLVaadinGeoJsonLayer(null, callbackHandler)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + // Given - need to mock engine for bridge initialization + JLWebEngine mockEngine = mock(JLWebEngine.class); + PendingJavaScriptResult mockResult = mock(PendingJavaScriptResult.class); + lenient().when(mockEngine.executeScript(anyString())).thenReturn(mockResult); + + // When/Then - Constructor validation is not implemented in the actual class + // This test documents the current behavior + JLVaadinGeoJsonLayer layer = new JLVaadinGeoJsonLayer(mockEngine, null); + assertThat(layer).isNotNull(); + } + + @Test + void geoJsonLayer_shouldExtendJLLayer() { + assertThat(geoJsonLayer).isInstanceOf(io.github.makbn.jlmap.vaadin.layer.JLVaadinLayer.class); + } + + @Test + void addFromFile_withValidFile_shouldLoadFileAndExecuteScript() throws JLException { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + File testFile = new File("test.geojson"); + when(mockGeoJsonFile.load(testFile)).thenReturn(VALID_GEOJSON); + + JLGeoJson result = geoJsonLayer.addFromFile(testFile); + + verify(mockGeoJsonFile).load(testFile); + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.geoJSON"); + assertThat(script).contains("FeatureCollection"); + assertThat(script).contains("Berlin"); + assertThat(script).contains("addTo(this.map)"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getGeoJsonContent()).isEqualTo(VALID_GEOJSON); + } + + @Test + void addFromFile_whenFileLoadFails_shouldThrowJLException() throws JLException { + File testFile = new File("invalid.geojson"); + JLException expectedException = new JLException("File not found"); + when(mockGeoJsonFile.load(testFile)).thenThrow(expectedException); + + assertThatThrownBy(() -> geoJsonLayer.addFromFile(testFile)) + .isInstanceOf(JLException.class) + .hasMessage("File not found"); + + verify(mockGeoJsonFile).load(testFile); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromFile_withNullFile_shouldThrowNullPointerException() { + assertThatThrownBy(() -> geoJsonLayer.addFromFile(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(mockGeoJsonFile); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromUrl_withValidUrl_shouldLoadUrlAndExecuteScript() throws JLException { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String testUrl = "https://example.com/data.geojson"; + String simpleGeoJson = """ + { + "type": "Point", + "coordinates": [13.4050, 52.5200] + }"""; + when(mockGeoJsonURL.load(testUrl)).thenReturn(simpleGeoJson); + + JLGeoJson result = geoJsonLayer.addFromUrl(testUrl); + + verify(mockGeoJsonURL).load(testUrl); + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.geoJSON"); + assertThat(script).contains("Point"); + assertThat(script).contains("13.4050, 52.5200"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getGeoJsonContent()).isEqualTo(simpleGeoJson); + } + + @Test + void addFromUrl_whenUrlLoadFails_shouldThrowJLException() throws JLException { + String testUrl = "https://invalid-url.com/data.geojson"; + JLException expectedException = new JLException("URL not accessible"); + when(mockGeoJsonURL.load(testUrl)).thenThrow(expectedException); + + assertThatThrownBy(() -> geoJsonLayer.addFromUrl(testUrl)) + .isInstanceOf(JLException.class) + .hasMessage("URL not accessible"); + + verify(mockGeoJsonURL).load(testUrl); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromUrl_withNullUrl_shouldThrowNullPointerException() { + assertThatThrownBy(() -> geoJsonLayer.addFromUrl(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(mockGeoJsonURL); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromContent_withValidContent_shouldLoadContentAndExecuteScript() throws JLException { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String content = VALID_GEOJSON; + when(mockGeoJsonContent.load(content)).thenReturn(content); + + JLGeoJson result = geoJsonLayer.addFromContent(content); + + verify(mockGeoJsonContent).load(content); + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.geoJSON"); + assertThat(script).contains("FeatureCollection"); + assertThat(script).contains("Berlin"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLGeoJson"); + assertThat(result.getGeoJsonContent()).isEqualTo(content); + } + + @Test + void addFromContent_whenContentLoadFails_shouldThrowJLException() throws JLException { + String content = "invalid json"; + JLException expectedException = new JLException("Invalid GeoJSON"); + when(mockGeoJsonContent.load(content)).thenThrow(expectedException); + + assertThatThrownBy(() -> geoJsonLayer.addFromContent(content)) + .isInstanceOf(JLException.class) + .hasMessage("Invalid GeoJSON"); + + verify(mockGeoJsonContent).load(content); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void addFromContent_withNullContent_shouldThrowNullPointerException() { + assertThatThrownBy(() -> geoJsonLayer.addFromContent(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(mockGeoJsonContent); + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void removeGeoJson_shouldExecuteRemoveScriptAndRemoveFromCallback() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String geoJsonId = "testGeoJsonId"; + + boolean result = geoJsonLayer.removeGeoJson(geoJsonId); + + verify(engine).executeScript("this.map.removeLayer(this.testGeoJsonId)"); + verify(callbackHandler).remove(JLGeoJson.class, geoJsonId); + assertThat(result).isTrue(); + } + + @Test + void removeGeoJson_withNullId_shouldThrowNullPointerException() { + assertThatThrownBy(() -> geoJsonLayer.removeGeoJson(null)) + .isInstanceOf(NullPointerException.class); + + verifyNoInteractions(engine); + verifyNoInteractions(callbackHandler); + } + + @Test + void geoJsonLayer_shouldGenerateUniqueIds() throws JLException { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + when(mockGeoJsonContent.load(anyString())).thenReturn(VALID_GEOJSON); + + JLGeoJson geoJson1 = geoJsonLayer.addFromContent(VALID_GEOJSON); + JLGeoJson geoJson2 = geoJsonLayer.addFromContent(VALID_GEOJSON); + + assertThat(geoJson1.getJLId()).isNotEqualTo(geoJson2.getJLId()); + assertThat(geoJson1.getJLId()).startsWith("JLGeoJson"); + assertThat(geoJson2.getJLId()).startsWith("JLGeoJson"); + } + + @Test + void multipleGeoJsonOperations_shouldExecuteAllScripts() throws JLException { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + when(mockGeoJsonContent.load(anyString())).thenReturn(VALID_GEOJSON); + when(mockGeoJsonFile.load(any(File.class))).thenReturn(VALID_GEOJSON); + when(mockGeoJsonURL.load(anyString())).thenReturn(VALID_GEOJSON); + + JLGeoJson geoJson1 = geoJsonLayer.addFromContent(VALID_GEOJSON); + JLGeoJson geoJson2 = geoJsonLayer.addFromFile(new File("test.geojson")); + JLGeoJson geoJson3 = geoJsonLayer.addFromUrl("https://example.com/data.geojson"); + + geoJsonLayer.removeGeoJson(geoJson1.getJLId()); + + verify(engine, times(3)).executeScript(argThat(script -> script.contains("L.geoJSON"))); + verify(engine).executeScript(argThat(script -> script.contains("removeLayer"))); + + verify(callbackHandler, times(3)).addJLObject(anyString(), any(JLGeoJson.class)); + verify(callbackHandler).remove(JLGeoJson.class, geoJson1.getJLId()); + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinUiLayerTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinUiLayerTest.java new file mode 100644 index 0000000..c9b895e --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinUiLayerTest.java @@ -0,0 +1,220 @@ +package io.github.makbn.jlmap.vaadin.test.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinUiLayer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JLVaadinUiLayerTest { + + @Mock + private JLWebEngine mockEngine; + + @Mock + private JLMapEventHandler mockCallbackHandler; + + @Mock + private PendingJavaScriptResult mockJavaScriptResult; + + private JLVaadinUiLayer uiLayer; + + @BeforeEach + void setUp() { + uiLayer = new JLVaadinUiLayer(mockEngine, mockCallbackHandler); + } + + @Test + void constructor_withNullEngine_shouldAcceptNullEngine() { + JLVaadinUiLayer layer = new JLVaadinUiLayer(null, mockCallbackHandler); + assertThat(layer).isNotNull(); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + JLVaadinUiLayer layer = new JLVaadinUiLayer(mockEngine, null); + assertThat(layer).isNotNull(); + } + + @Test + void uiLayer_shouldExtendJLLayer() { + assertThat(uiLayer).isInstanceOf(io.github.makbn.jlmap.vaadin.layer.JLVaadinLayer.class); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void addMarker_withNonDraggableMarker_shouldExecuteCorrectScript(boolean addText) { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng position = JLLatLng.builder().lat(51.505).lng(-0.09).build(); + + JLMarker marker = uiLayer.addMarker(position, addText ? "Test Marker" : null, false); + + assertThat(marker).isNotNull(); + assertThat(marker.getLatLng()).isEqualTo(position); + assertThat(marker.getText()).isEqualTo(addText ? "Test Marker" : null); + verify(mockEngine, Mockito.times(addText ? 2 : 1)).executeScript(anyString()); + } + + @Test + void addMarker_withDraggableMarker_shouldExecuteScriptWithDraggableTrue() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng position = JLLatLng.builder().lat(51.505).lng(-0.09).build(); + + JLMarker marker = uiLayer.addMarker(position, "Draggable Marker", true); + + assertThat(marker).isNotNull(); + assertThat(marker.getLatLng()).isEqualTo(position); + assertThat(marker.getText()).isEqualTo("Draggable Marker"); + verify(mockEngine, Mockito.times(2)).executeScript(anyString()); + } + + @Test + void addMarker_withNullPosition_shouldThrowNullPointerException() { + assertThatThrownBy(() -> uiLayer.addMarker(null, "Test", false)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addMarker_withNullText_shouldAcceptNullText() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng position = JLLatLng.builder().lat(51.505).lng(-0.09).build(); + + JLMarker marker = uiLayer.addMarker(position, null, false); + + assertThat(marker).isNotNull(); + assertThat(marker.getLatLng()).isEqualTo(position); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void removeMarker_shouldExecuteRemoveScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + + boolean result = uiLayer.removeMarker("test-marker-id"); + + assertThat(result).isTrue(); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void addPopup_withDefaultOptions_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng position = JLLatLng.builder().lat(51.505).lng(-0.09).build(); + + JLPopup popup = uiLayer.addPopup(position, "Test Popup"); + + assertThat(popup).isNotNull(); + assertThat(popup.getLatLng()).isEqualTo(position); + assertThat(popup.getText()).isEqualTo("Test Popup"); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void addPopup_withCustomOptions_shouldIncludeOptionsInScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng position = JLLatLng.builder().lat(51.505).lng(-0.09).build(); + JLOptions options = JLOptions.builder().closeButton(false).build(); + + JLPopup popup = uiLayer.addPopup(position, "Custom Popup", options); + + assertThat(popup).isNotNull(); + assertThat(popup.getLatLng()).isEqualTo(position); + assertThat(popup.getText()).isEqualTo("Custom Popup"); + assertThat(popup.getOptions()).isEqualTo(options); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void addPopup_withNullPosition_shouldThrowNullPointerException() { + assertThatThrownBy(() -> uiLayer.addPopup(null, "Test")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addPopup_withNullText_shouldAcceptNullText() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng position = JLLatLng.builder().lat(51.505).lng(-0.09).build(); + + JLPopup popup = uiLayer.addPopup(position, null); + + assertThat(popup).isNotNull(); + assertThat(popup.getLatLng()).isEqualTo(position); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void removePopup_shouldExecuteRemoveScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + + boolean result = uiLayer.removePopup("test-popup-id"); + + assertThat(result).isTrue(); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void addImage_shouldExecuteCorrectScript() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(40.712).lng(-74.227).build()) + .northEast(JLLatLng.builder().lat(40.774).lng(-74.125).build()) + .build(); + String imageUrl = "https://example.com/image.png"; + + JLImageOverlay image = uiLayer.addImage(bounds, imageUrl, JLOptions.DEFAULT); + + assertThat(image).isNotNull(); + assertThat(image.getBounds()).isEqualTo(bounds); + assertThat(image.getImageUrl()).isEqualTo(imageUrl); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void addImage_withNullBounds_shouldThrowNullPointerException() { + assertThatThrownBy(() -> uiLayer.addImage(null, "test.png", JLOptions.DEFAULT)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addImage_withNullImageUrl_shouldAcceptNullUrl() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLBounds bounds = JLBounds.builder() + .southWest(JLLatLng.builder().lat(40.712).lng(-74.227).build()) + .northEast(JLLatLng.builder().lat(40.774).lng(-74.125).build()) + .build(); + + JLImageOverlay image = uiLayer.addImage(bounds, "some_url", JLOptions.DEFAULT); + + assertThat(image).isNotNull(); + assertThat(image.getBounds()).isEqualTo(bounds); + verify(mockEngine).executeScript(anyString()); + } + + @Test + void uiLayer_shouldGenerateUniqueIds() { + when(mockEngine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng position = JLLatLng.builder().lat(51.505).lng(-0.09).build(); + + JLMarker marker1 = uiLayer.addMarker(position, "Test 1", false); + JLMarker marker2 = uiLayer.addMarker(position, "Test 2", false); + + assertThat(marker1.getJLId()).isNotEqualTo(marker2.getJLId()); + assertThat(marker1.getJLId()).startsWith("JLMarker_"); + assertThat(marker2.getJLId()).startsWith("JLMarker_"); + } +} \ No newline at end of file diff --git a/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinVectorLayerTest.java b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinVectorLayerTest.java new file mode 100644 index 0000000..97421bb --- /dev/null +++ b/jlmap-vaadin/src/test/java/io/github/makbn/jlmap/vaadin/test/layer/JLVaadinVectorLayerTest.java @@ -0,0 +1,351 @@ +package io.github.makbn.jlmap.vaadin.test.layer; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import io.github.makbn.jlmap.JLMapEventHandler; +import io.github.makbn.jlmap.JLProperties; +import io.github.makbn.jlmap.engine.JLWebEngine; +import io.github.makbn.jlmap.model.*; +import io.github.makbn.jlmap.vaadin.layer.JLVaadinVectorLayer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JLVaadinVectorLayerTest { + + @Mock + private JLWebEngine engine; + + @Mock + private JLMapEventHandler callbackHandler; + + @Mock + private PendingJavaScriptResult mockJavaScriptResult; + + private JLVaadinVectorLayer vectorLayer; + + @BeforeEach + void setUp() { + vectorLayer = new JLVaadinVectorLayer(engine, callbackHandler); + } + + // === Constructor and Basic Tests === + + @Test + void constructor_withNullEngine_shouldAcceptNullEngine() { + JLVaadinVectorLayer layer = new JLVaadinVectorLayer(null, callbackHandler); + assertThat(layer).isNotNull(); + } + + @Test + void constructor_withNullCallbackHandler_shouldAcceptNullHandler() { + JLVaadinVectorLayer layer = new JLVaadinVectorLayer(engine, null); + assertThat(layer).isNotNull(); + } + + @Test + void vectorLayer_shouldExtendJLLayer() { + assertThat(vectorLayer).isInstanceOf(io.github.makbn.jlmap.vaadin.layer.JLVaadinLayer.class); + } + + // === Polyline Tests === + + @Test + void addPolyline_withValidVertices_shouldCreatePolylineAndExecuteScript() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng[] vertices = { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build() + }; + + JLPolyline result = vectorLayer.addPolyline(vertices); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.polyline"); + assertThat(script).contains("[52.52,13.405]"); + assertThat(script).contains("[52.53,13.415]"); + assertThat(script).contains("addTo(this.map)"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith(JLPolyline.class.getSimpleName()); + } + + @Test + void addPolyline_withCustomOptions_shouldIncludeOptionsInScript() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng[] vertices = { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build() + }; + JLOptions options = JLOptions.builder() + .color(JLColor.RED) + .weight(5) + .opacity(0.8) + .build(); + + JLPolyline result = vectorLayer.addPolyline(vertices, options); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("weight: 5"); + assertThat(script).contains("opacity: 0.8"); + + assertThat(result).isNotNull(); + } + + @Test + void addPolyline_withNullVertices_shouldThrowNullPointerException() { + assertThatThrownBy(() -> vectorLayer.addPolyline(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addPolyline_withEmptyVertices_shouldAcceptEmptyArray() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng[] emptyVertices = new JLLatLng[0]; + + JLPolyline polyline = vectorLayer.addPolyline(emptyVertices); + assertThat(polyline).isNotNull(); + } + + @Test + void removePolyline_shouldExecuteRemoveScript() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String polylineId = "testPolylineId"; + + boolean result = vectorLayer.removePolyline(polylineId); + + verify(engine).executeScript("this.map.removeLayer(this.testPolylineId)"); + verify(callbackHandler).remove(JLPolyline.class, polylineId); + assertThat(result).isTrue(); + } + + // === Circle Tests === + + @Test + void addCircle_withDefaultOptions_shouldCreateCircleWithDefaults() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + JLCircle result = vectorLayer.addCircle(center); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.circle"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("radius: " + JLProperties.DEFAULT_CIRCLE_RADIUS); + + assertThat(script).contains("on('add'"); + assertThat(script).contains("on('remove'"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLCircle"); + } + + @Test + void addCircle_withCustomRadius_shouldUseCustomRadius() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + int customRadius = 500; + + JLCircle result = vectorLayer.addCircle(center, customRadius, JLOptions.DEFAULT); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("radius: 500.000000"); + + assertThat(result).isNotNull(); + } + + @Test + void addCircle_withNullCenter_shouldThrowNullPointerException() { + assertThatThrownBy(() -> vectorLayer.addCircle(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removeCircle_shouldExecuteRemoveScript() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String circleId = "testCircleId"; + + boolean result = vectorLayer.removeCircle(circleId); + + verify(engine).executeScript("this.map.removeLayer(this.testCircleId)"); + verify(callbackHandler).remove(JLCircle.class, circleId); + assertThat(result).isTrue(); + } + + // === Circle Marker Tests === + + @Test + void addCircleMarker_withDefaultOptions_shouldCreateCircleMarker() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + JLCircleMarker result = vectorLayer.addCircleMarker(center); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.circleMarker"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("on('move'"); + assertThat(script).contains("on('click'"); + assertThat(script).contains("on('dblclick'"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLCircleMarker"); + } + + @Test + void addCircleMarker_withNullCenter_shouldThrowNullPointerException() { + assertThatThrownBy(() -> vectorLayer.addCircleMarker(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removeCircleMarker_shouldExecuteRemoveScript() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String circleMarkerId = "testCircleMarkerId"; + + boolean result = vectorLayer.removeCircleMarker(circleMarkerId); + + verify(engine).executeScript("this.map.removeLayer(this.testCircleMarkerId)"); + verify(callbackHandler).remove(JLCircleMarker.class, circleMarkerId); + assertThat(result).isTrue(); + } + + // === Polygon Tests === + + @Test + void addPolygon_withValidVertices_shouldCreatePolygon() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng[][][] vertices = { + { + { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build(), + JLLatLng.builder().lat(52.5400).lng(13.4250).build() + } + } + }; + + JLPolygon result = vectorLayer.addPolygon(vertices); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.polygon"); + assertThat(script).contains("[52.520000, 13.405000]"); + assertThat(script).contains("[52.530000, 13.415000]"); + assertThat(script).contains("[52.540000, 13.425000]"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLPolygon_"); + } + + @Test + void addPolygon_withNullVertices_shouldThrowNullPointerException() { + assertThatThrownBy(() -> vectorLayer.addPolygon(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removePolygon_shouldExecuteRemoveScript() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String polygonId = "testPolygonId"; + + boolean result = vectorLayer.removePolygon(polygonId); + + verify(engine).executeScript("this.map.removeLayer(this.testPolygonId)"); + verify(callbackHandler).remove(JLPolygon.class, polygonId); + assertThat(result).isTrue(); + } + + // === Multi-Polyline Tests === + + @Test + void addMultiPolyline_withValidVertices_shouldCreateMultiPolyline() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng[][] vertices = { + { + JLLatLng.builder().lat(52.5200).lng(13.4050).build(), + JLLatLng.builder().lat(52.5300).lng(13.4150).build() + }, + { + JLLatLng.builder().lat(52.5400).lng(13.4250).build(), + JLLatLng.builder().lat(52.5500).lng(13.4350).build() + } + }; + + JLMultiPolyline result = vectorLayer.addMultiPolyline(vertices); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + verify(engine).executeScript(scriptCaptor.capture()); + verify(callbackHandler).addJLObject(anyString(), eq(result)); + + String script = scriptCaptor.getValue(); + assertThat(script).contains("L.polyline"); + assertThat(script).contains("[52.520000,13.405000]"); + assertThat(script).contains("[52.540000,13.425000]"); + + assertThat(result).isNotNull(); + assertThat(result.getJLId()).startsWith("JLMultiPolyline_"); + } + + @Test + void addMultiPolyline_withNullVertices_shouldThrowNullPointerException() { + assertThatThrownBy(() -> vectorLayer.addMultiPolyline(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void removeMultiPolyline_shouldExecuteRemoveScript() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + String multiPolylineId = "testMultiPolylineId"; + + boolean result = vectorLayer.removeMultiPolyline(multiPolylineId); + + verify(engine).executeScript("this.map.removeLayer(this.testMultiPolylineId)"); + verify(callbackHandler).remove(JLMultiPolyline.class, multiPolylineId); + assertThat(result).isTrue(); + } + + // === ID Generation Tests === + + @Test + void vectorLayer_shouldGenerateUniqueIds() { + when(engine.executeScript(anyString())).thenReturn(mockJavaScriptResult); + JLLatLng center = JLLatLng.builder().lat(52.5200).lng(13.4050).build(); + + JLCircle circle1 = vectorLayer.addCircle(center); + JLCircle circle2 = vectorLayer.addCircle(center); + + assertThat(circle1.getJLId()).isNotEqualTo(circle2.getJLId()); + assertThat(circle1.getJLId()).startsWith("JLCircle"); + assertThat(circle2.getJLId()).startsWith("JLCircle"); + } +} \ No newline at end of file diff --git a/jlmap-vaadin/tsconfig.json b/jlmap-vaadin/tsconfig.json new file mode 100644 index 0000000..e6840ae --- /dev/null +++ b/jlmap-vaadin/tsconfig.json @@ -0,0 +1,39 @@ +// This TypeScript configuration file is generated by vaadin-maven-plugin. +// This is needed for TypeScript compiler to compile your TypeScript code in the project. +// It is recommended to commit this file to the VCS. +// You might want to change the configurations to fit your preferences +// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html +{ + "_version": "9.1", + "compilerOptions": { + "sourceMap": true, + "jsx": "react-jsx", + "inlineSources": true, + "module": "esNext", + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "baseUrl": "src/main/frontend", + "paths": { + "@vaadin/flow-frontend": ["generated/jar-resources"], + "@vaadin/flow-frontend/*": ["generated/jar-resources/*"], + "Frontend/*": ["*"] + } + }, + "include": [ + "src/main/frontend/**/*", + "types.d.ts" + ], + "exclude": [ + "src/main/frontend/generated/jar-resources/**" + ] +} diff --git a/jlmap-vaadin/types.d.ts b/jlmap-vaadin/types.d.ts new file mode 100644 index 0000000..eff230b --- /dev/null +++ b/jlmap-vaadin/types.d.ts @@ -0,0 +1,17 @@ +// This TypeScript modules definition file is generated by vaadin-maven-plugin. +// You can not directly import your different static files into TypeScript, +// This is needed for TypeScript compiler to declare and export as a TypeScript module. +// It is recommended to commit this file to the VCS. +// You might want to change the configurations to fit your preferences +declare module '*.css?inline' { + import type { CSSResultGroup } from 'lit'; + const content: CSSResultGroup; + export default content; +} + +// Allow any CSS Custom Properties +declare module 'csstype' { + interface Properties { + [index: `--${string}`]: any; + } +} diff --git a/jlmap-vaadin/vite.config.ts b/jlmap-vaadin/vite.config.ts new file mode 100644 index 0000000..4d6a022 --- /dev/null +++ b/jlmap-vaadin/vite.config.ts @@ -0,0 +1,9 @@ +import { UserConfigFn } from 'vite'; +import { overrideVaadinConfig } from './vite.generated'; + +const customConfig: UserConfigFn = (env) => ({ + // Here you can add custom Vite parameters + // https://vitejs.dev/config/ +}); + +export default overrideVaadinConfig(customConfig); diff --git a/jlmap-vaadin/vite.generated.ts b/jlmap-vaadin/vite.generated.ts new file mode 100644 index 0000000..92e68a6 --- /dev/null +++ b/jlmap-vaadin/vite.generated.ts @@ -0,0 +1,794 @@ +/** + * NOTICE: this is an auto-generated file + * + * This file has been generated by the `flow:prepare-frontend` maven goal. + * This file will be overwritten on every run. Any custom changes should be made to vite.config.ts + */ +import path from 'path'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, Stats } from 'fs'; +import { createHash } from 'crypto'; +import * as net from 'net'; + +import { processThemeResources } from './target/plugins/application-theme-plugin/theme-handle.js'; +import { rewriteCssUrls } from './target/plugins/theme-loader/theme-loader-utils.js'; +import { addFunctionComponentSourceLocationBabel } from './target/plugins/react-function-location-plugin/react-function-location-plugin.js'; +import settings from './target/vaadin-dev-server-settings.json'; +import { + AssetInfo, + ChunkInfo, + defineConfig, + mergeConfig, + OutputOptions, + PluginOption, + UserConfigFn +} from 'vite'; + +import * as rollup from 'rollup'; +import brotli from 'rollup-plugin-brotli'; +import checker from 'vite-plugin-checker'; +import postcssLit from './target/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js'; +import vaadinI18n from './target/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js'; +import serviceWorkerPlugin from './target/plugins/vite-plugin-service-worker'; + +import { createRequire } from 'module'; + +import { visualizer } from 'rollup-plugin-visualizer'; +import reactPlugin from '@vitejs/plugin-react'; + + + +// Make `require` compatible with ES modules +const require = createRequire(import.meta.url); + +const frontendFolder = path.resolve(__dirname, settings.frontendFolder); +const themeFolder = path.resolve(frontendFolder, settings.themeFolder); +const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput); +const devBundleFolder = path.resolve(__dirname, settings.devBundleOutput); +const devBundle = !!process.env.devBundle; +const jarResourcesFolder = path.resolve(__dirname, settings.jarResourcesFolder); +const themeResourceFolder = path.resolve(__dirname, settings.themeResourceFolder); +const projectPackageJsonFile = path.resolve(__dirname, 'package.json'); + +const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder; +const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput); +const statsFile = path.resolve(statsFolder, 'stats.json'); +const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html'); +const i18nFolder = path.resolve(__dirname, settings.i18nOutput); +const nodeModulesFolder = path.resolve(__dirname, 'node_modules'); +const webComponentTags = ''; + +const projectIndexHtml = path.resolve(frontendFolder, 'index.html'); + +const projectStaticAssetsFolders = [ + path.resolve(__dirname, 'src', 'main', 'resources', 'META-INF', 'resources'), + path.resolve(__dirname, 'src', 'main', 'resources', 'static'), + frontendFolder +]; + +// Folders in the project which can contain application themes +const themeProjectFolders = projectStaticAssetsFolders.map((folder) => path.resolve(folder, settings.themeFolder)); + +const themeOptions = { + devMode: false, + useDevBundle: devBundle, + // The following matches folder 'frontend/generated/themes/' + // (not 'frontend/themes') for theme in JAR that is copied there + themeResourceFolder: path.resolve(themeResourceFolder, settings.themeFolder), + themeProjectFolders: themeProjectFolders, + projectStaticAssetsOutputFolder: devBundle + ? path.resolve(devBundleFolder, '../assets') + : path.resolve(__dirname, settings.staticOutput), + frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder) +}; + +const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html')); + +const target = ['safari15', 'es2022']; + +// Block debug and trace logs. +console.trace = () => {}; +console.debug = () => {}; + +function statsExtracterPlugin(): PluginOption { + function collectThemeJsonsInFrontend(themeJsonContents: Record, themeName: string) { + const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json'); + if (existsSync(themeJson)) { + const themeJsonContent = readFileSync(themeJson, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + themeJsonContents[themeName] = themeJsonContent; + const themeJsonObject = JSON.parse(themeJsonContent); + if (themeJsonObject.parent) { + collectThemeJsonsInFrontend(themeJsonContents, themeJsonObject.parent); + } + } + } + + return { + name: 'vaadin:stats', + enforce: 'post', + async writeBundle(options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }) { + const modules = Object.values(bundle).flatMap((b) => (b.modules ? Object.keys(b.modules) : [])); + const nodeModulesFolders = modules + .map((id) => id.replace(/\\/g, '/')) + .filter((id) => id.startsWith(nodeModulesFolder.replace(/\\/g, '/'))) + .map((id) => id.substring(nodeModulesFolder.length + 1)); + const npmModules = nodeModulesFolders + .map((id) => id.replace(/\\/g, '/')) + .map((id) => { + const parts = id.split('/'); + if (id.startsWith('@')) { + return parts[0] + '/' + parts[1]; + } else { + return parts[0]; + } + }) + .sort() + .filter((value, index, self) => self.indexOf(value) === index); + const npmModuleAndVersion = Object.fromEntries(npmModules.map((module) => [module, getVersion(module)])); + const cvdls = Object.fromEntries( + npmModules + .filter((module) => getCvdlName(module) != null) + .map((module) => [module, { name: getCvdlName(module), version: getVersion(module) }]) + ); + + mkdirSync(path.dirname(statsFile), { recursive: true }); + const projectPackageJson = JSON.parse(readFileSync(projectPackageJsonFile, { encoding: 'utf-8' })); + + const entryScripts = Object.values(bundle) + .filter((bundle) => bundle.isEntry) + .map((bundle) => bundle.fileName); + + const generatedIndexHtml = path.resolve(buildOutputFolder, 'index.html'); + const customIndexData: string = readFileSync(projectIndexHtml, { encoding: 'utf-8' }); + const generatedIndexData: string = readFileSync(generatedIndexHtml, { + encoding: 'utf-8' + }); + + const customIndexRows = new Set(customIndexData.split(/[\r\n]/).filter((row) => row.trim() !== '')); + const generatedIndexRows = generatedIndexData.split(/[\r\n]/).filter((row) => row.trim() !== ''); + + const rowsGenerated: string[] = []; + generatedIndexRows.forEach((row) => { + if (!customIndexRows.has(row)) { + rowsGenerated.push(row); + } + }); + + //After dev-bundle build add used Flow frontend imports JsModule/JavaScript/CssImport + + const parseImports = (filename: string, result: Set): void => { + const content: string = readFileSync(filename, { encoding: 'utf-8' }); + const lines = content.split('\n'); + const staticImports = lines + .filter((line) => line.startsWith('import ')) + .map((line) => line.substring(line.indexOf("'") + 1, line.lastIndexOf("'"))) + .map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)); + const dynamicImports = lines + .filter((line) => line.includes('import(')) + .map((line) => line.replace(/.*import\(/, '')) + .map((line) => line.split(/'/)[1]) + .map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)); + + staticImports.forEach((staticImport) => result.add(staticImport)); + + dynamicImports.map((dynamicImport) => { + const importedFile = path.resolve(path.dirname(filename), dynamicImport); + parseImports(importedFile, result); + }); + }; + + const generatedImportsSet = new Set(); + parseImports( + path.resolve(themeOptions.frontendGeneratedFolder, 'flow', 'generated-flow-imports.js'), + generatedImportsSet + ); + const generatedImports = Array.from(generatedImportsSet).sort(); + + const frontendFiles: Record = {}; + frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex'); + + const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map']; + + const isThemeComponentsResource = (id: string) => + id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + && id.match(/.*\/jar-resources\/themes\/[^\/]+\/components\//); + + const isGeneratedWebComponentResource = (id: string) => + id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + && id.match(/.*\/flow\/web-components\//); + + const isFrontendResourceCollected = (id: string) => + !id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + || isThemeComponentsResource(id) + || isGeneratedWebComponentResource(id); + + // collects project's frontend resources in frontend folder, excluding + // 'generated' sub-folder, except for legacy shadow DOM stylesheets + // packaged in `theme/components/` folder + // and generated web component resources in `flow/web-components` folder. + modules + .map((id) => id.replace(/\\/g, '/')) + .filter((id) => id.startsWith(frontendFolder.replace(/\\/g, '/'))) + .filter(isFrontendResourceCollected) + .map((id) => id.substring(frontendFolder.length + 1)) + .map((line: string) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)) + .forEach((line: string) => { + // \r\n from windows made files may be used so change to \n + const filePath = path.resolve(frontendFolder, line); + if (projectFileExtensions.includes(path.extname(filePath))) { + const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + }); + + // collects frontend resources from the JARs + generatedImports + .filter((line: string) => line.includes('generated/jar-resources')) + .forEach((line: string) => { + let filename = line.substring(line.indexOf('generated')); + // \r\n from windows made files may be used ro remove to be only \n + const fileBuffer = readFileSync(path.resolve(frontendFolder, filename), { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + const hash = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + + const fileKey = line.substring(line.indexOf('jar-resources/') + 14); + frontendFiles[fileKey] = hash; + }); + // collects and hash rest of the Frontend resources excluding files in /generated/ and /themes/ + // and files already in frontendFiles. + let frontendFolderAlias = "Frontend"; + generatedImports + .filter((line: string) => line.startsWith(frontendFolderAlias + '/')) + .filter((line: string) => !line.startsWith(frontendFolderAlias + '/generated/')) + .filter((line: string) => !line.startsWith(frontendFolderAlias + '/themes/')) + .map((line) => line.substring(frontendFolderAlias.length + 1)) + .filter((line: string) => !frontendFiles[line]) + .forEach((line: string) => { + const filePath = path.resolve(frontendFolder, line); + if (projectFileExtensions.includes(path.extname(filePath)) && existsSync(filePath)) { + const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + }); + // If a index.ts exists hash it to be able to see if it changes. + if (existsSync(path.resolve(frontendFolder, 'index.ts'))) { + const fileBuffer = readFileSync(path.resolve(frontendFolder, 'index.ts'), { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + + const themeJsonContents: Record = {}; + const themesFolder = path.resolve(jarResourcesFolder, 'themes'); + if (existsSync(themesFolder)) { + readdirSync(themesFolder).forEach((themeFolder) => { + const themeJson = path.resolve(themesFolder, themeFolder, 'theme.json'); + if (existsSync(themeJson)) { + themeJsonContents[path.basename(themeFolder)] = readFileSync(themeJson, { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + } + }); + } + + collectThemeJsonsInFrontend(themeJsonContents, settings.themeName); + + let webComponents: string[] = []; + if (webComponentTags) { + webComponents = webComponentTags.split(';'); + } + + const stats = { + packageJsonDependencies: projectPackageJson.dependencies, + npmModules: npmModuleAndVersion, + bundleImports: generatedImports, + frontendHashes: frontendFiles, + themeJsonContents: themeJsonContents, + entryScripts, + webComponents, + cvdlModules: cvdls, + packageJsonHash: projectPackageJson?.vaadin?.hash, + indexHtmlGenerated: rowsGenerated + }; + writeFileSync(statsFile, JSON.stringify(stats, null, 1)); + } + }; +} +function vaadinBundlesPlugin(): PluginOption { + type ExportInfo = + | string + | { + namespace?: string; + source: string; + }; + + type ExposeInfo = { + exports: ExportInfo[]; + }; + + type PackageInfo = { + version: string; + exposes: Record; + }; + + type BundleJson = { + packages: Record; + }; + + const disabledMessage = 'Vaadin component dependency bundles are disabled.'; + + const modulesDirectory = nodeModulesFolder.replace(/\\/g, '/'); + + let vaadinBundleJson: BundleJson; + + function parseModuleId(id: string): { packageName: string; modulePath: string } { + const [scope, scopedPackageName] = id.split('/', 3); + const packageName = scope.startsWith('@') ? `${scope}/${scopedPackageName}` : scope; + const modulePath = `.${id.substring(packageName.length)}`; + return { + packageName, + modulePath + }; + } + + function getExports(id: string): string[] | undefined { + const { packageName, modulePath } = parseModuleId(id); + const packageInfo = vaadinBundleJson.packages[packageName]; + + if (!packageInfo) return; + + const exposeInfo: ExposeInfo = packageInfo.exposes[modulePath]; + if (!exposeInfo) return; + + const exportsSet = new Set(); + for (const e of exposeInfo.exports) { + if (typeof e === 'string') { + exportsSet.add(e); + } else { + const { namespace, source } = e; + if (namespace) { + exportsSet.add(namespace); + } else { + const sourceExports = getExports(source); + if (sourceExports) { + sourceExports.forEach((e) => exportsSet.add(e)); + } + } + } + } + return Array.from(exportsSet); + } + + function getExportBinding(binding: string) { + return binding === 'default' ? '_default as default' : binding; + } + + function getImportAssigment(binding: string) { + return binding === 'default' ? 'default: _default' : binding; + } + + return { + name: 'vaadin:bundles', + enforce: 'pre', + apply(config, { command }) { + if (command !== 'serve') return false; + + try { + const vaadinBundleJsonPath = require.resolve('@vaadin/bundles/vaadin-bundle.json'); + vaadinBundleJson = JSON.parse(readFileSync(vaadinBundleJsonPath, { encoding: 'utf8' })); + } catch (e: unknown) { + if (typeof e === 'object' && (e as { code: string }).code === 'MODULE_NOT_FOUND') { + vaadinBundleJson = { packages: {} }; + console.info(`@vaadin/bundles npm package is not found, ${disabledMessage}`); + return false; + } else { + throw e; + } + } + + const versionMismatches: Array<{ name: string; bundledVersion: string; installedVersion: string }> = []; + for (const [name, packageInfo] of Object.entries(vaadinBundleJson.packages)) { + let installedVersion: string | undefined = undefined; + try { + const { version: bundledVersion } = packageInfo; + const installedPackageJsonFile = path.resolve(modulesDirectory, name, 'package.json'); + const packageJson = JSON.parse(readFileSync(installedPackageJsonFile, { encoding: 'utf8' })); + installedVersion = packageJson.version; + if (installedVersion && installedVersion !== bundledVersion) { + versionMismatches.push({ + name, + bundledVersion, + installedVersion + }); + } + } catch (_) { + // ignore package not found + } + } + if (versionMismatches.length) { + console.info(`@vaadin/bundles has version mismatches with installed packages, ${disabledMessage}`); + console.info(`Packages with version mismatches: ${JSON.stringify(versionMismatches, undefined, 2)}`); + vaadinBundleJson = { packages: {} }; + return false; + } + + return true; + }, + async config(config) { + return mergeConfig( + { + optimizeDeps: { + exclude: [ + // Vaadin bundle + '@vaadin/bundles', + ...Object.keys(vaadinBundleJson.packages), + '@vaadin/vaadin-material-styles' + ] + } + }, + config + ); + }, + load(rawId) { + const [path, params] = rawId.split('?'); + if (!path.startsWith(modulesDirectory)) return; + + const id = path.substring(modulesDirectory.length + 1); + const bindings = getExports(id); + if (bindings === undefined) return; + + const cacheSuffix = params ? `?${params}` : ''; + const bundlePath = `@vaadin/bundles/vaadin.js${cacheSuffix}`; + + return `import { init as VaadinBundleInit, get as VaadinBundleGet } from '${bundlePath}'; +await VaadinBundleInit('default'); +const { ${bindings.map(getImportAssigment).join(', ')} } = (await VaadinBundleGet('./node_modules/${id}'))(); +export { ${bindings.map(getExportBinding).join(', ')} };`; + } + }; +} + +function themePlugin(opts: { devMode: boolean }): PluginOption { + const fullThemeOptions = { ...themeOptions, devMode: opts.devMode }; + return { + name: 'vaadin:theme', + config() { + processThemeResources(fullThemeOptions, console); + }, + configureServer(server) { + function handleThemeFileCreateDelete(themeFile: string, stats?: Stats) { + if (themeFile.startsWith(themeFolder)) { + const changed = path.relative(themeFolder, themeFile); + console.debug('Theme file ' + (!!stats ? 'created' : 'deleted'), changed); + processThemeResources(fullThemeOptions, console); + } + } + server.watcher.on('add', handleThemeFileCreateDelete); + server.watcher.on('unlink', handleThemeFileCreateDelete); + }, + handleHotUpdate(context) { + const contextPath = path.resolve(context.file); + const themePath = path.resolve(themeFolder); + if (contextPath.startsWith(themePath)) { + const changed = path.relative(themePath, contextPath); + + console.debug('Theme file changed', changed); + + if (changed.startsWith(settings.themeName)) { + processThemeResources(fullThemeOptions, console); + } + } + }, + async resolveId(id, importer) { + // force theme generation if generated theme sources does not yet exist + // this may happen for example during Java hot reload when updating + // @Theme annotation value + if ( + path.resolve(themeOptions.frontendGeneratedFolder, 'theme.js') === importer && + !existsSync(path.resolve(themeOptions.frontendGeneratedFolder, id)) + ) { + console.debug('Generate theme file ' + id + ' not existing. Processing theme resource'); + processThemeResources(fullThemeOptions, console); + return; + } + if (!id.startsWith(settings.themeFolder)) { + return; + } + for (const location of [themeResourceFolder, frontendFolder]) { + const result = await this.resolve(path.resolve(location, id)); + if (result) { + return result; + } + } + }, + async transform(raw, id, options) { + // rewrite urls for the application theme css files + const [bareId, query] = id.split('?'); + if ( + (!bareId?.startsWith(themeFolder) && !bareId?.startsWith(themeOptions.themeResourceFolder)) || + !bareId?.endsWith('.css') + ) { + return; + } + const resourceThemeFolder = bareId.startsWith(themeFolder) ? themeFolder : themeOptions.themeResourceFolder; + const [themeName] = bareId.substring(resourceThemeFolder.length + 1).split('/'); + return rewriteCssUrls(raw, path.dirname(bareId), path.resolve(resourceThemeFolder, themeName), console, opts); + } + }; +} + +function runWatchDog(watchDogPort: number, watchDogHost: string | undefined) { + const client = new net.Socket(); + client.setEncoding('utf8'); + client.on('error', function (err) { + console.log('Watchdog connection error. Terminating vite process...', err); + client.destroy(); + process.exit(0); + }); + client.on('close', function () { + client.destroy(); + runWatchDog(watchDogPort, watchDogHost); + }); + + client.connect(watchDogPort, watchDogHost || 'localhost'); +} + +const allowedFrontendFolders = [frontendFolder, nodeModulesFolder]; + +function showRecompileReason(): PluginOption { + return { + name: 'vaadin:why-you-compile', + handleHotUpdate(context) { + console.log('Recompiling because', context.file, 'changed'); + } + }; +} + +const DEV_MODE_START_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start/; +const DEV_MODE_CODE_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start([\s\S]*)vaadin-dev-mode:end\s+\*\*\//i; + +function preserveUsageStats() { + return { + name: 'vaadin:preserve-usage-stats', + + transform(src: string, id: string) { + if (id.includes('vaadin-usage-statistics')) { + if (src.includes('vaadin-dev-mode:start')) { + const newSrc = src.replace(DEV_MODE_START_REGEXP, '/*! vaadin-dev-mode:start'); + if (newSrc === src) { + console.error('Comment replacement failed to change anything'); + } else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) { + console.error('New comment fails to match original regexp'); + } else { + return { code: newSrc }; + } + } + } + + return { code: src }; + } + }; +} + +export const vaadinConfig: UserConfigFn = (env) => { + const devMode = env.mode === 'development'; + const productionMode = !devMode && !devBundle + + if (devMode && process.env.watchDogPort) { + // Open a connection with the Java dev-mode handler in order to finish + // vite when it exits or crashes. + runWatchDog(parseInt(process.env.watchDogPort), process.env.watchDogHost); + } + + return { + root: frontendFolder, + base: '', + publicDir: false, + resolve: { + alias: { + '@vaadin/flow-frontend': jarResourcesFolder, + Frontend: frontendFolder + }, + preserveSymlinks: true + }, + define: { + OFFLINE_PATH: settings.offlinePath, + VITE_ENABLED: 'true' + }, + server: { + host: '127.0.0.1', + strictPort: true, + fs: { + allow: allowedFrontendFolders + } + }, + build: { + minify: productionMode, + outDir: buildOutputFolder, + emptyOutDir: devBundle, + assetsDir: 'VAADIN/build', + target, + rollupOptions: { + input: { + indexhtml: projectIndexHtml, + + ...(hasExportedWebComponents ? { webcomponenthtml: path.resolve(frontendFolder, 'web-component.html') } : {}) + }, + output: { + // Workaround to enable dynamic imports with top-level await for + // commonjs modules, such as "atmosphere.js" in Hilla. Extracting + // Rollup's commonjs helpers into separate manual chunk avoids + // circular dependencies in this case. Caused + // - https://github.com/vitejs/vite/issues/10995 + // - https://github.com/rollup/rollup/issues/5884 + // - https://github.com/vitejs/vite/issues/19695 + // - https://github.com/vitejs/vite/issues/12209 + manualChunks: (id: string) => id.startsWith('\0commonjsHelpers.js') ? 'commonjsHelpers' : null + }, + onwarn: (warning: rollup.RollupLog, defaultHandler: rollup.LoggingFunction) => { + const ignoreEvalWarning = [ + 'generated/jar-resources/FlowClient.js', + 'generated/jar-resources/vaadin-spreadsheet/spreadsheet-export.js', + '@vaadin/charts/src/helpers.js' + ]; + if (warning.code === 'EVAL' && warning.id && !!ignoreEvalWarning.find((id) => warning.id?.endsWith(id))) { + return; + } + defaultHandler(warning); + } + } + }, + optimizeDeps: { + esbuildOptions: { + target, + }, + entries: [ + // Pre-scan entrypoints in Vite to avoid reloading on first open + 'generated/vaadin.ts' + ], + exclude: [ + '@vaadin/router', + '@vaadin/vaadin-license-checker', + '@vaadin/vaadin-usage-statistics', + 'workbox-core', + 'workbox-precaching', + 'workbox-routing', + 'workbox-strategies' + ] + }, + plugins: [ + productionMode && brotli(), + devMode && vaadinBundlesPlugin(), + devMode && showRecompileReason(), + settings.offlineEnabled && serviceWorkerPlugin({ + srcPath: settings.clientServiceWorkerSource, + }), + !devMode && statsExtracterPlugin(), + !productionMode && preserveUsageStats(), + themePlugin({ devMode }), + postcssLit({ + include: ['**/*.css', /.*\/.*\.css\?.*/], + exclude: [ + `${themeFolder}/**/*.css`, + new RegExp(`${themeFolder}/.*/.*\\.css\\?.*`), + `${themeResourceFolder}/**/*.css`, + new RegExp(`${themeResourceFolder}/.*/.*\\.css\\?.*`), + new RegExp('.*/.*\\?html-proxy.*') + ] + }), + // The React plugin provides fast refresh and debug source info + reactPlugin({ + include: '**/*.tsx', + babel: { + // We need to use babel to provide the source information for it to be correct + // (otherwise Babel will slightly rewrite the source file and esbuild generate source info for the modified file) + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: productionMode ? 'react' : 'Frontend/generated/jsx-dev-transform', + development: !productionMode + } + ] + ], + // React writes the source location for where components are used, this writes for where they are defined + plugins: [ + !productionMode && addFunctionComponentSourceLocationBabel(), + [ + 'module:@preact/signals-react-transform', + { + mode: 'all' // Needed to include translations which do not use something.value + } + ] + ].filter(Boolean) + } + }), + productionMode && vaadinI18n({ + cwd: __dirname, + meta: { + output: { + dir: i18nFolder, + }, + }, + }), + { + name: 'vaadin:force-remove-html-middleware', + configureServer(server) { + return () => { + server.middlewares.stack = server.middlewares.stack.filter((mw) => { + const handleName = `${mw.handle}`; + return !handleName.includes('viteHtmlFallbackMiddleware'); + }); + }; + }, + }, + hasExportedWebComponents && { + name: 'vaadin:inject-entrypoints-to-web-component-html', + transformIndexHtml: { + order: 'pre', + handler(_html, { path, server }) { + if (path !== '/web-component.html') { + return; + } + + return [ + { + tag: 'script', + attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` }, + injectTo: 'head' + } + ]; + } + } + }, + { + name: 'vaadin:inject-entrypoints-to-index-html', + transformIndexHtml: { + order: 'pre', + handler(_html, { path, server }) { + if (path !== '/index.html') { + return; + } + + const scripts = []; + + if (devMode) { + scripts.push({ + tag: 'script', + attrs: { type: 'module', src: `/generated/vite-devmode.ts`, onerror: "document.location.reload()" }, + injectTo: 'head' + }); + } + scripts.push({ + tag: 'script', + attrs: { type: 'module', src: '/generated/vaadin.ts' }, + injectTo: 'head' + }); + return scripts; + } + } + }, + + checker({ + typescript: true + }), + productionMode && visualizer({ brotliSize: true, filename: bundleSizeFile }) + ] + }; +}; + +export const overrideVaadinConfig = (customConfig: UserConfigFn) => { + return defineConfig((env) => mergeConfig(vaadinConfig(env), customConfig(env))); +}; +function getVersion(module: string): string { + const packageJson = path.resolve(nodeModulesFolder, module, 'package.json'); + return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).version; +} +function getCvdlName(module: string): string { + const packageJson = path.resolve(nodeModulesFolder, module, 'package.json'); + return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).cvdlName; +} diff --git a/pom.xml b/pom.xml index 9e179e9..af721f7 100644 --- a/pom.xml +++ b/pom.xml @@ -5,22 +5,39 @@ 4.0.0 io.github.makbn - jlmap - 1.9.5 - jar - Java Leaflet (JLeaflet) + jlmap-parent + 2.0.0 + pom + Java Leaflet (JLeaflet) - Parent + + Map for Vaadin: A Java library for integrating Leaflet maps into Vaadin + and JavaFX applications with full JPMS support. + + https://github.com/makbn/java_leaflet + + + jlmap-api + jlmap-fx + jlmap-vaadin + jlmap-vaadin-demo + 17 17 UTF-8 19.0.2.1 - 1.18.34 + 1.18.42 + 0.8.8 + + ${project.basedir}/jlmap-fx/target/site/jacoco/jacoco.xml,${project.basedir}/jlmap-vaadin/target/site/jacoco/jacoco.xml + + jacoco - Mehdi Akbarian Rastaghi + Matt Akbarian Rastaghi makbn mehdi74akbarian@gmail.com @@ -28,114 +45,17 @@ - GNU General Public License, Version 3.0 - https://www.gnu.org/licenses/gpl-3.0.html - repo + GNU Lesser General Public License (LGPL) Version 2.1 or later + https://www.gnu.org/licenses/lgpl-2.1.html + https://github.com/makbn/java_leaflet - - - - 3.13.0 - org.apache.maven.plugins - maven-compiler-plugin - - 17 - 17 - - --add-modules - javafx.controls,javafx.base,javafx.swing,javafx.web,javafx.graphics,jdk.jsobject - --add-opens - javafx.web/com.sun.javafx.webkit=ALL-UNNAMED - - - - org.projectlombok - lombok - ${lombok.version} - - - true - - - - org.openjfx - javafx-maven-plugin - 0.0.8 - - io.github.makbn.jlmap.demo.LeafletTestJFX - - - - maven-assembly-plugin - - - package - - single - - - - - - - true - io.github.makbn.jlmap.demo.LeafletTestJFX - - - - jar-with-dependencies - - - - - org.apache.maven.plugins - maven-jar-plugin - 3.3.0 - - - - true - io.github.makbn.jlmap.demo.LeafletTestJFX - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.1.2 - - - --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens java.base/java.util=ALL-UNNAMED - --add-opens java.base/java.lang.reflect=ALL-UNNAMED - --add-opens java.base/java.text=ALL-UNNAMED - --add-opens java.desktop/java.awt.font=ALL-UNNAMED - --add-opens javafx.web/com.sun.javafx.webkit=ALL-UNNAMED - - - - - org.codehaus.mojo - exec-maven-plugin - 3.1.0 - - io.github.makbn.jlmap.demo.LeafletTestJFX - - --module-path ${project.basedir}/../../sdk/javafx-sdk-17.0.16/lib - --add-modules - javafx.controls,javafx.base,javafx.swing,javafx.web,javafx.graphics,jdk.jsobject - --add-opens - javafx.web/com.sun.javafx.webkit=ALL-UNNAMED - - - - - src/main/java - src/test/java - + + scm:git:git://github.com/makbn/java_leaflet.git + scm:git:ssh://github.com/makbn/java_leaflet.git + https://github.com/makbn/java_leaflet + @@ -149,76 +69,124 @@ - - - org.openjfx - javafx-controls - ${javafx.version} - - - org.openjfx - javafx-base - ${javafx.version} - - - org.openjfx - javafx-swing - ${javafx.version} - - - org.openjfx - javafx-web - ${javafx.version} - - - org.openjfx - javafx-graphics - ${javafx.version} - - - org.projectlombok - lombok - ${lombok.version} - provided - - - org.slf4j - slf4j-api - 2.0.16 - - - com.google.code.gson - gson - 2.10.1 - - - com.fasterxml.jackson.core - jackson-databind - 2.15.2 - - - de.grundid.opendatalab - geojson-jackson - 1.14 - - - com.fasterxml.jackson.core - jackson-databind - - - - - org.jetbrains - annotations - 24.0.1 - compile - - - org.junit.jupiter - junit-jupiter - test - - + + + release + + + + org.sonatype.central + central-publishing-maven-plugin + + central + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + false + private + -Xdoclint:none + + + + attach-javadocs + package + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 + true + + central + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + prepare-agent + + + + report + test + + report + + + + + + org.owasp + dependency-check-maven + 9.0.7 + + + XML + HTML + SARIF + + 8 + + + + + diff --git a/src/main/.DS_Store b/src/main/.DS_Store deleted file mode 100644 index f9fd510..0000000 Binary files a/src/main/.DS_Store and /dev/null differ diff --git a/src/main/java/io/github/makbn/jlmap/JLMapCallbackHandler.java b/src/main/java/io/github/makbn/jlmap/JLMapCallbackHandler.java deleted file mode 100644 index 601a0f9..0000000 --- a/src/main/java/io/github/makbn/jlmap/JLMapCallbackHandler.java +++ /dev/null @@ -1,184 +0,0 @@ -package io.github.makbn.jlmap; - -import com.google.gson.Gson; -import io.github.makbn.jlmap.listener.OnJLMapViewListener; -import io.github.makbn.jlmap.listener.OnJLObjectActionListener; -import io.github.makbn.jlmap.listener.event.ClickEvent; -import io.github.makbn.jlmap.listener.event.MoveEvent; -import io.github.makbn.jlmap.listener.event.ZoomEvent; -import io.github.makbn.jlmap.model.*; -import lombok.AccessLevel; -import lombok.experimental.FieldDefaults; -import lombok.extern.slf4j.Slf4j; - -import java.io.Serializable; -import java.util.HashMap; -import java.util.Optional; - -/** - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Slf4j -@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) -public class JLMapCallbackHandler implements Serializable { - private static final String FUNCTION_MOVE = "move"; - private static final String FUNCTION_CLICK = "click"; - private static final String FUNCTION_ZOOM = "zoom"; - private static final String FUNCTION_MOVE_START = "movestart"; - private static final String FUNCTION_MOVE_END = "moveend"; - transient OnJLMapViewListener listener; - transient HashMap>, HashMap>> jlObjects; - transient Gson gson; - HashMap>[]> classMap; - - public JLMapCallbackHandler(OnJLMapViewListener listener) { - this.listener = listener; - this.jlObjects = new HashMap<>(); - this.gson = new Gson(); - this.classMap = new HashMap<>(); - initClassMap(); - } - - @SuppressWarnings("unchecked") - private void initClassMap() { - classMap.put("marker", new Class[]{JLMarker.class}); - classMap.put("marker_circle", new Class[]{JLCircleMarker.class}); - classMap.put("polyline", new Class[]{JLPolyline.class, JLMultiPolyline.class}); - classMap.put("polygon", new Class[]{JLPolygon.class}); - } - - /** - * @param functionName name of source function from js - * @param param1 name of object class - * @param param2 id of object - * @param param3 additional param - * @param param4 additional param - * @param param5 additional param - */ - @SuppressWarnings("all") - public void functionCalled(String functionName, Object param1, Object param2, - Object param3, Object param4, Object param5) { - log.debug(String.format("function: %s \tparam1: %s \tparam2: %s " + - "\tparam3: %s param4: %s \tparam5: %s%n" - , functionName, param1, param2, param3, param4, param5)); - try { - //get target class of Leaflet layer in JL Application - Class[] targetClasses = classMap.get(param1); - - //function called by an known class - if (targetClasses != null) { - //one Leaflet class may map to multiple class in JL Application - // like ployLine mapped to JLPolyline and JLMultiPolyline - for (Class targetClass : targetClasses) { - if (targetClass != null) { - //search for the other JLObject class if available - if (!jlObjects.containsKey(targetClass)) - break; - - JLObject jlObject = jlObjects.get(targetClass) - .get(Integer.parseInt(String.valueOf(param2))); - - //search for the other JLObject object if available - if (jlObject == null) - break; - - if (jlObject.getOnActionListener() == null) - return; - - //call listener and exit loop - if (callListenerOnObject(functionName, - (JLObject>) jlObject, param1, - param2, param3, param4, param5)) - return; - } - } - } else if (param1.equals("main_map") && getMapListener().isPresent()) { - switch (functionName) { - case FUNCTION_MOVE -> getMapListener() - .get() - .onAction(new MoveEvent(OnJLMapViewListener.Action.MOVE, - gson.fromJson(String.valueOf(param4), JLLatLng.class), - gson.fromJson(String.valueOf(param5), JLBounds.class), - Integer.parseInt(String.valueOf(param3)))); - case FUNCTION_MOVE_START -> getMapListener() - .get() - .onAction(new MoveEvent(OnJLMapViewListener.Action.MOVE_START, - gson.fromJson(String.valueOf(param4), JLLatLng.class), - gson.fromJson(String.valueOf(param5), JLBounds.class), - Integer.parseInt(String.valueOf(param3)))); - case FUNCTION_MOVE_END -> getMapListener() - .get() - .onAction(new MoveEvent(OnJLMapViewListener.Action.MOVE_END, - gson.fromJson(String.valueOf(param4), JLLatLng.class), - gson.fromJson(String.valueOf(param5), JLBounds.class), - Integer.parseInt(String.valueOf(param3)))); - case FUNCTION_CLICK -> getMapListener() - .get() - .onAction(new ClickEvent(gson.fromJson(String.valueOf(param3), - JLLatLng.class))); - - case FUNCTION_ZOOM -> getMapListener() - .get() - .onAction(new ZoomEvent(OnJLMapViewListener.Action.ZOOM, - Integer.parseInt(String.valueOf(param3)))); - default -> log.error(functionName + " not implemented!"); - } - } - } catch (Exception e) { - log.error(e.getMessage(), e); - } - } - - private boolean callListenerOnObject( - String functionName, JLObject> jlObject, Object... params) { - switch (functionName) { - case FUNCTION_MOVE -> { - jlObject.getOnActionListener() - .move(jlObject, OnJLObjectActionListener.Action.MOVE); - return true; - } - case FUNCTION_MOVE_START -> { - jlObject.getOnActionListener() - .move(jlObject, OnJLObjectActionListener.Action.MOVE_START); - return true; - } - case FUNCTION_MOVE_END -> { - //update coordinate of the JLObject - jlObject.update(FUNCTION_MOVE_END, gson.fromJson(String.valueOf(params[3]), JLLatLng.class)); - jlObject.getOnActionListener() - .move(jlObject, OnJLObjectActionListener.Action.MOVE_END); - return true; - } - case FUNCTION_CLICK -> { - jlObject.getOnActionListener() - .click(jlObject, OnJLObjectActionListener.Action.CLICK); - return true; - } - default -> log.error("{} not implemented!", functionName); - } - return false; - } - - @SuppressWarnings("unchecked") - public void addJLObject(JLObject object) { - if (jlObjects.containsKey(object.getClass())) { - jlObjects.get(object.getClass()) - .put(object.getId(), object); - } else { - HashMap> map = new HashMap<>(); - map.put(object.getId(), object); - jlObjects.put((Class>) object.getClass(), map); - } - } - - public void remove(Class> targetClass, int id) { - if (!jlObjects.containsKey(targetClass)) - return; - JLObject object = jlObjects.get(targetClass).remove(id); - if (object != null) log.error("{} id: {} removed", targetClass.getSimpleName(), object.getId()); - } - - private Optional getMapListener() { - return Optional.ofNullable(listener); - } -} diff --git a/src/main/java/io/github/makbn/jlmap/JLMapController.java b/src/main/java/io/github/makbn/jlmap/JLMapController.java deleted file mode 100644 index 2313926..0000000 --- a/src/main/java/io/github/makbn/jlmap/JLMapController.java +++ /dev/null @@ -1,84 +0,0 @@ -package io.github.makbn.jlmap; - -import io.github.makbn.jlmap.engine.JLWebEngine; -import io.github.makbn.jlmap.exception.JLMapNotReadyException; -import io.github.makbn.jlmap.layer.*; -import io.github.makbn.jlmap.model.JLLatLng; - -import java.util.HashMap; - -/** - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -interface JLMapController { - - JLWebEngine getJLEngine(); - - void addControllerToDocument(); - - HashMap, JLLayer> getLayers(); - - /** - * handle all functions for add/remove layers from UI layer - * - * @return current instance of {{@link JLUiLayer}} - */ - default JLUiLayer getUiLayer() { - checkMapState(); - return (JLUiLayer) getLayers().get(JLUiLayer.class); - } - - /** - * handle all functions for add/remove layers from Vector layer - * - * @return current instance of {{@link JLVectorLayer}} - */ - default JLVectorLayer getVectorLayer() { - checkMapState(); - return (JLVectorLayer) getLayers().get(JLVectorLayer.class); - } - - default JLControlLayer getControlLayer() { - checkMapState(); - return (JLControlLayer) getLayers().get(JLControlLayer.class); - } - - default JLGeoJsonLayer getGeoJsonLayer() { - checkMapState(); - return (JLGeoJsonLayer) getLayers().get(JLGeoJsonLayer.class); - } - - /** - * Sets the view of the map (geographical center). - * - * @param latLng Represents a geographical point with a certain latitude - * and longitude. - */ - default void setView(JLLatLng latLng) { - checkMapState(); - getJLEngine() - .executeScript(String.format("jlmap.panTo([%f, %f]);", - latLng.getLat(), latLng.getLng())); - } - - /** - * Sets the view of the map (geographical center) with animation duration. - * - * @param duration Represents the duration of transition animation. - * @param latLng Represents a geographical point with a certain latitude - * and longitude. - */ - default void setView(JLLatLng latLng, int duration) { - checkMapState(); - getJLEngine() - .executeScript(String.format("setLatLng(%f, %f,%d);", - latLng.getLat(), latLng.getLng(), duration)); - } - - private void checkMapState() { - if (getJLEngine() == null || getJLEngine().getStatus() != JLWebEngine.Status.SUCCEEDED) { - throw JLMapNotReadyException.builder().build(); - } - } - -} diff --git a/src/main/java/io/github/makbn/jlmap/JLProperties.java b/src/main/java/io/github/makbn/jlmap/JLProperties.java deleted file mode 100644 index 27a587c..0000000 --- a/src/main/java/io/github/makbn/jlmap/JLProperties.java +++ /dev/null @@ -1,35 +0,0 @@ -package io.github.makbn.jlmap; - -import io.github.makbn.jlmap.model.JLMapOption; - -import java.util.Collections; -import java.util.Set; - -/** - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -public class JLProperties { - public static final int INIT_MIN_WIDTH = 1024; - public static final int INIT_MIN_HEIGHT = 576; - public static final int EARTH_RADIUS = 6367; - public static final int DEFAULT_CIRCLE_RADIUS = 200; - public static final int DEFAULT_CIRCLE_MARKER_RADIUS = 10; - public static final int INIT_MIN_WIDTH_STAGE = INIT_MIN_WIDTH; - public static final int INIT_MIN_HEIGHT_STAGE = INIT_MIN_HEIGHT; - public static final double START_ANIMATION_RADIUS = 10; - - public record MapType(String name, Set parameters) { - - public MapType(String name) { - this(name, Collections.emptySet()); - } - - public static final MapType OSM_MAPNIK = new MapType("OpenStreetMap.Mapnik"); - public static final MapType OSM_HOT = new MapType("OpenStreetMap.HOT"); - public static final MapType OPEN_TOPO = new MapType("OpenTopoMap"); - - public static MapType getDefault() { - return OSM_MAPNIK; - } - } -} diff --git a/src/main/java/io/github/makbn/jlmap/demo/LeafletTestJFX.java b/src/main/java/io/github/makbn/jlmap/demo/LeafletTestJFX.java deleted file mode 100644 index 91b6781..0000000 --- a/src/main/java/io/github/makbn/jlmap/demo/LeafletTestJFX.java +++ /dev/null @@ -1,210 +0,0 @@ -package io.github.makbn.jlmap.demo; - -import io.github.makbn.jlmap.JLMapView; -import io.github.makbn.jlmap.JLProperties; -import io.github.makbn.jlmap.geojson.JLGeoJsonObject; -import io.github.makbn.jlmap.listener.OnJLMapViewListener; -import io.github.makbn.jlmap.listener.OnJLObjectActionListener; -import io.github.makbn.jlmap.listener.event.ClickEvent; -import io.github.makbn.jlmap.listener.event.Event; -import io.github.makbn.jlmap.listener.event.MoveEvent; -import io.github.makbn.jlmap.listener.event.ZoomEvent; -import io.github.makbn.jlmap.model.JLLatLng; -import io.github.makbn.jlmap.model.JLMarker; -import io.github.makbn.jlmap.model.JLOptions; -import io.github.makbn.jlmap.model.JLPolygon; -import javafx.application.Application; -import javafx.geometry.Rectangle2D; -import javafx.scene.Scene; -import javafx.scene.layout.AnchorPane; -import javafx.scene.layout.Background; -import javafx.scene.paint.Color; -import javafx.stage.Screen; -import javafx.stage.Stage; -import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; - - -/** - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Slf4j -public class LeafletTestJFX extends Application { - - @Override - public void start(Stage stage) { - //building a new map view - final JLMapView map = JLMapView - .builder() - .mapType(JLProperties.MapType.OSM_MAPNIK) - .showZoomController(true) - .startCoordinate(JLLatLng.builder() - .lat(51.044) - .lng(114.07) - .build()) - .build(); - //creating a window - AnchorPane root = new AnchorPane(map); - root.setBackground(Background.EMPTY); - root.setMinHeight(JLProperties.INIT_MIN_HEIGHT_STAGE); - root.setMinWidth(JLProperties.INIT_MIN_WIDTH_STAGE); - Scene scene = new Scene(root); - - stage.setMinHeight(JLProperties.INIT_MIN_HEIGHT_STAGE); - stage.setMinWidth(JLProperties.INIT_MIN_WIDTH_STAGE); - scene.setFill(Color.TRANSPARENT); - stage.setTitle("Java-Leaflet Test"); - stage.setScene(scene); - stage.show(); - - Rectangle2D primScreenBounds = Screen.getPrimary().getVisualBounds(); - stage.setX((primScreenBounds.getWidth() - stage.getWidth()) / 2); - stage.setY(100); - - //set listener fo map events - map.setMapListener(new OnJLMapViewListener() { - @Override - public void mapLoadedSuccessfully(@NonNull JLMapView mapView) { - log.info("map loaded!"); - addMultiPolyline(map); - addPolyline(map); - addPolygon(map); - - map.setView(JLLatLng.builder() - .lng(10) - .lat(10) - .build()); - map.getUiLayer() - .addMarker(JLLatLng.builder() - .lat(35.63) - .lng(51.45) - .build(), "Tehran", true) - .setOnActionListener(getListener()); - - map.getVectorLayer() - .addCircleMarker(JLLatLng.builder() - .lat(35.63) - .lng(40.45) - .build()); - - map.getVectorLayer() - .addCircle(JLLatLng.builder() - .lat(35.63) - .lng(51.45) - .build(), 30000, JLOptions.DEFAULT); - - // map zoom functionalities - map.getControlLayer().setZoom(5); - map.getControlLayer().zoomIn(2); - map.getControlLayer().zoomOut(1); - - - JLGeoJsonObject geoJsonObject = map.getGeoJsonLayer() - .addFromUrl("https://pkgstore.datahub.io/examples/geojson-tutorial/example/data/db696b3bf628d9a273ca9907adcea5c9/example.geojson"); - - } - - @Override - public void mapFailed() { - log.error("map failed!"); - } - - @Override - public void onAction(Event event) { - if (event instanceof MoveEvent moveEvent) { - log.info("move event: {} c: {} \t bounds: {} \t z: {}", moveEvent.action(), moveEvent.center(), - moveEvent.bounds(), moveEvent.zoomLevel()); - } else if (event instanceof ClickEvent clickEvent) { - log.info("click event: {}", clickEvent.center()); - map.getUiLayer().addPopup(clickEvent.center(), "New Click Event!", JLOptions.builder() - .closeButton(false) - .autoClose(false).build()); - } else if (event instanceof ZoomEvent zoomEvent) { - log.info("zoom event: {}", zoomEvent.zoomLevel()); - } - } - }); - } - - private OnJLObjectActionListener getListener() { - return new OnJLObjectActionListener() { - @Override - public void click(JLMarker object, Action action) { - log.info("object click listener for marker:" + object); - } - - @Override - public void move(JLMarker object, Action action) { - log.info("object move listener for marker:" + object); - } - }; - } - - private void addMultiPolyline(JLMapView map) { - JLLatLng[][] verticesT = new JLLatLng[2][]; - - verticesT[0] = new JLLatLng[]{ - new JLLatLng(41.509, 20.08), - new JLLatLng(31.503, -10.06), - new JLLatLng(21.51, -0.047) - }; - - verticesT[1] = new JLLatLng[]{ - new JLLatLng(51.509, 10.08), - new JLLatLng(55.503, 15.06), - new JLLatLng(42.51, 20.047) - }; - - map.getVectorLayer().addMultiPolyline(verticesT); - } - - private void addPolyline(JLMapView map) { - JLLatLng[] vertices = new JLLatLng[]{ - new JLLatLng(51.509, -0.08), - new JLLatLng(51.503, -0.06), - new JLLatLng(51.51, -0.047) - }; - - map.getVectorLayer().addPolyline(vertices); - } - - private void addPolygon(JLMapView map) { - - JLLatLng[][][] vertices = new JLLatLng[2][][]; - - vertices[0] = new JLLatLng[2][]; - vertices[1] = new JLLatLng[1][]; - //first part - vertices[0][0] = new JLLatLng[]{ - new JLLatLng(37, -109.05), - new JLLatLng(41, -109.03), - new JLLatLng(41, -102.05), - new JLLatLng(37, -102.04) - }; - //hole inside the first part - vertices[0][1] = new JLLatLng[]{ - new JLLatLng(37.29, -108.58), - new JLLatLng(40.71, -108.58), - new JLLatLng(40.71, -102.50), - new JLLatLng(37.29, -102.50) - }; - //second part - vertices[1][0] = new JLLatLng[]{ - new JLLatLng(41, -111.03), - new JLLatLng(45, -111.04), - new JLLatLng(45, -104.05), - new JLLatLng(41, -104.05) - }; - map.getVectorLayer().addPolygon(vertices).setOnActionListener(new OnJLObjectActionListener<>() { - @Override - public void click(JLPolygon jlPolygon, Action action) { - log.info("object click listener for jlPolygon: {}", jlPolygon); - } - - @Override - public void move(JLPolygon jlPolygon, Action action) { - log.info("object move listener for jlPolygon: {}", jlPolygon); - } - }); - } -} \ No newline at end of file diff --git a/src/main/java/io/github/makbn/jlmap/engine/JLJavaFXEngine.java b/src/main/java/io/github/makbn/jlmap/engine/JLJavaFXEngine.java deleted file mode 100644 index d7da0ae..0000000 --- a/src/main/java/io/github/makbn/jlmap/engine/JLJavaFXEngine.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.makbn.jlmap.engine; - -import javafx.scene.web.WebEngine; -import lombok.AccessLevel; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.experimental.FieldDefaults; - -import java.util.Optional; - -@RequiredArgsConstructor -@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) -public class JLJavaFXEngine extends JLWebEngine { - WebEngine jfxEngine; - - @Override - public T executeScript(@NonNull String script, @NonNull Class type) { - return Optional.ofNullable(jfxEngine.executeScript(script)) - .map(type::cast) - .orElse(null); - } - - @Override - public Status getStatus() { - return jfxEngine.getLoadWorker().getState().name().equals("SUCCEEDED") ? Status.SUCCEEDED : Status.FAILED; - } -} diff --git a/src/main/java/io/github/makbn/jlmap/engine/JLWebEngine.java b/src/main/java/io/github/makbn/jlmap/engine/JLWebEngine.java deleted file mode 100644 index 1bcde43..0000000 --- a/src/main/java/io/github/makbn/jlmap/engine/JLWebEngine.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.github.makbn.jlmap.engine; - -import lombok.NonNull; - -public abstract class JLWebEngine { - public abstract T executeScript(String script, Class type); - - public abstract Status getStatus(); - - public Object executeScript(@NonNull String script) { - return this.executeScript(script, Object.class); - } - - public enum Status { - SUCCEEDED, - FAILED - } -} diff --git a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonObject.java b/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonObject.java deleted file mode 100644 index 81a85a2..0000000 --- a/src/main/java/io/github/makbn/jlmap/geojson/JLGeoJsonObject.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.makbn.jlmap.geojson; - -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; -import lombok.experimental.FieldDefaults; -import lombok.experimental.NonFinal; - -/** - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) -public class JLGeoJsonObject { - @NonFinal - int id; - String geoJsonContent; - - @Builder - public JLGeoJsonObject(int id, String geoJsonContent) { - this.id = id; - this.geoJsonContent = geoJsonContent; - } -} diff --git a/src/main/java/io/github/makbn/jlmap/layer/JLGeoJsonLayer.java b/src/main/java/io/github/makbn/jlmap/layer/JLGeoJsonLayer.java deleted file mode 100644 index 965220f..0000000 --- a/src/main/java/io/github/makbn/jlmap/layer/JLGeoJsonLayer.java +++ /dev/null @@ -1,79 +0,0 @@ -package io.github.makbn.jlmap.layer; - -import io.github.makbn.jlmap.JLMapCallbackHandler; -import io.github.makbn.jlmap.engine.JLWebEngine; -import io.github.makbn.jlmap.exception.JLException; -import io.github.makbn.jlmap.geojson.JLGeoJsonContent; -import io.github.makbn.jlmap.geojson.JLGeoJsonFile; -import io.github.makbn.jlmap.geojson.JLGeoJsonObject; -import io.github.makbn.jlmap.geojson.JLGeoJsonURL; -import io.github.makbn.jlmap.layer.leaflet.LeafletGeoJsonLayerInt; -import lombok.NonNull; -import netscape.javascript.JSObject; - -import java.io.File; -import java.util.UUID; - -/** - * Represents the GeoJson (other) layer on Leaflet map. - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -public class JLGeoJsonLayer extends JLLayer implements LeafletGeoJsonLayerInt { - private static final String MEMBER_PREFIX = "geoJson"; - private static final String WINDOW_OBJ = "window"; - JLGeoJsonURL fromUrl; - JLGeoJsonFile fromFile; - JLGeoJsonContent fromContent; - JSObject window; - - public JLGeoJsonLayer(JLWebEngine engine, - JLMapCallbackHandler callbackHandler) { - super(engine, callbackHandler); - this.fromUrl = new JLGeoJsonURL(); - this.fromFile = new JLGeoJsonFile(); - this.fromContent = new JLGeoJsonContent(); - this.window = (JSObject) engine.executeScript(WINDOW_OBJ); - } - - @Override - public JLGeoJsonObject addFromFile(@NonNull File file) throws JLException { - String json = fromFile.load(file); - return addGeoJson(json); - } - - @Override - public JLGeoJsonObject addFromUrl(@NonNull String url) throws JLException { - String json = fromUrl.load(url); - return addGeoJson(json); - } - - @Override - public JLGeoJsonObject addFromContent(@NonNull String content) - throws JLException { - String json = fromContent.load(content); - return addGeoJson(json); - } - - @Override - public boolean removeGeoJson(@NonNull JLGeoJsonObject object) { - return Boolean.parseBoolean(engine.executeScript( - String.format("removeGeoJson(%d)", object.getId())).toString()); - } - - private JLGeoJsonObject addGeoJson(String jlGeoJsonObject) { - try { - String identifier = MEMBER_PREFIX + UUID.randomUUID(); - this.window.setMember(identifier, jlGeoJsonObject); - String returnedId = engine.executeScript( - String.format("addGeoJson(\"%s\")", identifier)).toString(); - - return JLGeoJsonObject.builder() - .id(Integer.parseInt(returnedId)) - .geoJsonContent(jlGeoJsonObject) - .build(); - } catch (Exception e) { - throw new JLException(e); - } - - } -} diff --git a/src/main/java/io/github/makbn/jlmap/layer/JLLayer.java b/src/main/java/io/github/makbn/jlmap/layer/JLLayer.java deleted file mode 100644 index 533ed65..0000000 --- a/src/main/java/io/github/makbn/jlmap/layer/JLLayer.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.makbn.jlmap.layer; - -import io.github.makbn.jlmap.JLMapCallbackHandler; -import io.github.makbn.jlmap.engine.JLWebEngine; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import lombok.experimental.FieldDefaults; - -/** - * Represents the basic layer. - * - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -@FieldDefaults(level = AccessLevel.PROTECTED) -public abstract class JLLayer { - JLWebEngine engine; - JLMapCallbackHandler callbackHandler; - - protected JLLayer(JLWebEngine engine, JLMapCallbackHandler callbackHandler) { - this.engine = engine; - this.callbackHandler = callbackHandler; - } -} diff --git a/src/main/java/io/github/makbn/jlmap/layer/JLUiLayer.java b/src/main/java/io/github/makbn/jlmap/layer/JLUiLayer.java deleted file mode 100644 index a5b791f..0000000 --- a/src/main/java/io/github/makbn/jlmap/layer/JLUiLayer.java +++ /dev/null @@ -1,92 +0,0 @@ -package io.github.makbn.jlmap.layer; - -import io.github.makbn.jlmap.JLMapCallbackHandler; -import io.github.makbn.jlmap.engine.JLWebEngine; -import io.github.makbn.jlmap.layer.leaflet.LeafletUILayerInt; -import io.github.makbn.jlmap.model.JLLatLng; -import io.github.makbn.jlmap.model.JLMarker; -import io.github.makbn.jlmap.model.JLOptions; -import io.github.makbn.jlmap.model.JLPopup; - -/** - * Represents the UI layer on Leaflet map. - * - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -public class JLUiLayer extends JLLayer implements LeafletUILayerInt { - - public JLUiLayer(JLWebEngine engine, JLMapCallbackHandler callbackHandler) { - super(engine, callbackHandler); - } - - /** - * Add a {{@link JLMarker}} to the map with given text as content and {{@link JLLatLng}} as position. - * - * @param latLng position on the map. - * @param text content of the related popup if available! - * @return the instance of added {{@link JLMarker}} on the map. - */ - @Override - public JLMarker addMarker(JLLatLng latLng, String text, boolean draggable) { - String result = engine.executeScript(String.format("addMarker(%f, %f, '%s', %b)", latLng.getLat(), latLng.getLng(), text, draggable)) - .toString(); - int index = Integer.parseInt(result); - JLMarker marker = new JLMarker(index, text, latLng); - callbackHandler.addJLObject(marker); - return marker; - } - - /** - * Remove a {{@link JLMarker}} from the map. - * - * @param id of the marker for removing. - * @return {{@link Boolean#TRUE}} if removed successfully. - */ - @Override - public boolean removeMarker(int id) { - String result = engine.executeScript(String.format("removeMarker(%d)", id)).toString(); - callbackHandler.remove(JLMarker.class, id); - return Boolean.parseBoolean(result); - } - - /** - * Add a {{@link JLPopup}} to the map with given text as content and - * {@link JLLatLng} as position. - * - * @param latLng position on the map. - * @param text content of the popup. - * @param options see {{@link JLOptions}} for customizing - * @return the instance of added {{@link JLPopup}} on the map. - */ - @Override - public JLPopup addPopup(JLLatLng latLng, String text, JLOptions options) { - String result = engine.executeScript(String.format("addPopup(%f, %f, \"%s\", %b , %b)", latLng.getLat(), latLng.getLng(), text, options.isCloseButton(), options.isAutoClose())) - .toString(); - - int index = Integer.parseInt(result); - return new JLPopup(index, text, latLng, options); - } - - /** - * Add popup with {{@link JLOptions#DEFAULT}} options - * - * @see JLUiLayer#addPopup(JLLatLng, String, JLOptions) - */ - @Override - public JLPopup addPopup(JLLatLng latLng, String text) { - return addPopup(latLng, text, JLOptions.DEFAULT); - } - - /** - * Remove a {@link JLPopup} from the map. - * - * @param id of the marker for removing. - * @return true if removed successfully. - */ - @Override - public boolean removePopup(int id) { - String result = engine.executeScript(String.format("removePopup(%d)", id)) - .toString(); - return Boolean.parseBoolean(result); - } -} diff --git a/src/main/java/io/github/makbn/jlmap/layer/JLVectorLayer.java b/src/main/java/io/github/makbn/jlmap/layer/JLVectorLayer.java deleted file mode 100644 index 2d6d057..0000000 --- a/src/main/java/io/github/makbn/jlmap/layer/JLVectorLayer.java +++ /dev/null @@ -1,323 +0,0 @@ -package io.github.makbn.jlmap.layer; - -import io.github.makbn.jlmap.JLMapCallbackHandler; -import io.github.makbn.jlmap.JLProperties; -import io.github.makbn.jlmap.engine.JLWebEngine; -import io.github.makbn.jlmap.layer.leaflet.LeafletVectorLayerInt; -import io.github.makbn.jlmap.model.*; -import javafx.scene.paint.Color; - -/** - * Represents the Vector layer on Leaflet map. - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -public class JLVectorLayer extends JLLayer implements LeafletVectorLayerInt { - - public JLVectorLayer(JLWebEngine engine, - JLMapCallbackHandler callbackHandler) { - super(engine, callbackHandler); - } - - /** - * Drawing polyline overlays on the map with {@link JLOptions#DEFAULT} - * options - * - * @see JLVectorLayer#addPolyline(JLLatLng[], JLOptions) - */ - @Override - public JLPolyline addPolyline(JLLatLng[] vertices) { - return addPolyline(vertices, JLOptions.DEFAULT); - } - - /** - * Drawing polyline overlays on the map. - * - * @param vertices arrays of LatLng points - * @param options see {@link JLOptions} for customizing - * @return the added {@link JLPolyline} to map - */ - @Override - public JLPolyline addPolyline(JLLatLng[] vertices, JLOptions options) { - String latlngs = convertJLLatLngToString(vertices); - String hexColor = convertColorToString(options.getColor()); - String result = engine.executeScript( - String.format("addPolyLine(%s, '%s', %d, %b, %f, %f)", - latlngs, hexColor, options.getWeight(), - options.isStroke(), options.getOpacity(), - options.getSmoothFactor())) - .toString(); - - int index = Integer.parseInt(result); - JLPolyline polyline = new JLPolyline(index, options, vertices); - callbackHandler.addJLObject(polyline); - return polyline; - } - - /** - * Remove a polyline from the map by id. - * - * @param id of polyline - * @return {@link Boolean#TRUE} if removed successfully - */ - @Override - public boolean removePolyline(int id) { - String result = engine.executeScript( - String.format("removePolyLine(%d)", id)).toString(); - - callbackHandler.remove(JLPolyline.class, id); - callbackHandler.remove(JLMultiPolyline.class, id); - - return Boolean.parseBoolean(result); - } - - /** - * Drawing multi polyline overlays on the map with - * {@link JLOptions#DEFAULT} options. - * - * @return the added {@link JLMultiPolyline} to map - * @see JLVectorLayer#addMultiPolyline(JLLatLng[][], JLOptions) - */ - @Override - public JLMultiPolyline addMultiPolyline(JLLatLng[][] vertices) { - return addMultiPolyline(vertices, JLOptions.DEFAULT); - } - - /** - * Drawing MultiPolyline shape overlays on the map with - * multi-dimensional array. - * - * @param vertices arrays of LatLng points - * @param options see {@link JLOptions} for customizing - * @return the added {@link JLMultiPolyline} to map - */ - @Override - public JLMultiPolyline addMultiPolyline(JLLatLng[][] vertices, - JLOptions options) { - String latlngs = convertJLLatLngToString(vertices); - String hexColor = convertColorToString(options.getColor()); - String result = engine.executeScript( - String.format("addPolyLine(%s, '%s', %d, %b, %f, %f)", - latlngs, hexColor, options.getWeight(), - options.isStroke(), options.getOpacity(), - options.getSmoothFactor())) - .toString(); - - int index = Integer.parseInt(result); - JLMultiPolyline multiPolyline = - new JLMultiPolyline(index, options, vertices); - callbackHandler.addJLObject(multiPolyline); - return multiPolyline; - } - - - /** - * @see JLVectorLayer#removePolyline(int) - */ - @Override - public boolean removeMultiPolyline(int id) { - return removePolyline(id); - } - - /** - * Drawing polygon overlays on the map. - * Note that points you pass when creating a polygon shouldn't have an additional - * last point equal to the first one. - * You can also pass an array of arrays of {{@link JLLatLng}}, - * with the first dimension representing the outer shape and the other - * dimension representing holes in the outer shape! - * Additionally, you can pass a multi-dimensional array to represent a MultiPolygon shape! - * - * @param vertices array of {{@link JLLatLng}} points - * @param options see {{@link JLOptions}} - * @return Instance of the created {{@link JLPolygon}} - */ - @Override - public JLPolygon addPolygon(JLLatLng[][][] vertices, JLOptions options) { - String latlngs = convertJLLatLngToString(vertices); - - String result = engine.executeScript(String.format( - "addPolygon(%s, '%s', '%s', %d, %b, %b, %f, %f, %f)", - latlngs, convertColorToString(options.getColor()), - convertColorToString(options.getFillColor()), - options.getWeight(), options.isStroke(), - options.isFill(), options.getOpacity(), - options.getFillOpacity(), options.getSmoothFactor())) - .toString(); - - int index = Integer.parseInt(result); - JLPolygon polygon = new JLPolygon(index, options, vertices); - callbackHandler.addJLObject(polygon); - return polygon; - } - - /** - * Drawing polygon overlays on the map with {@link JLOptions#DEFAULT} - * option. - * - * @see JLVectorLayer#addMultiPolyline(JLLatLng[][], JLOptions) - */ - @Override - public JLPolygon addPolygon(JLLatLng[][][] vertices) { - return addPolygon(vertices, JLOptions.DEFAULT); - } - - /** - * Remove a {{@link JLPolygon}} from the map by id. - * - * @param id of Polygon - * @return {{@link Boolean#TRUE}} if removed successfully - */ - @Override - public boolean removePolygon(int id) { - String result = engine.executeScript( - String.format("removePolygon(%d)", id)).toString(); - callbackHandler.remove(JLPolygon.class, id); - return Boolean.parseBoolean(result); - } - - /** - * Add a {@link JLCircle} to the map; - * - * @param center coordinate of the circle. - * @param radius radius of circle in meter. - * @param options see {@link JLOptions} - * @return the instance of created {@link JLCircle} - */ - @Override - public JLCircle addCircle(JLLatLng center, int radius, JLOptions options) { - String result = engine.executeScript(String.format( - "addCircle([%f, %f], %d, '%s', '%s', %d, %b, %b, %f, %f, %f)", - center.getLat(), center.getLng(), radius, - convertColorToString(options.getColor()), - convertColorToString(options.getFillColor()), - options.getWeight(), options.isStroke(), - options.isFill(), options.getOpacity(), - options.getFillOpacity(), options.getSmoothFactor())) - .toString(); - - int index = Integer.parseInt(result); - JLCircle circle = new JLCircle(index, radius, center, options); - callbackHandler.addJLObject(circle); - return circle; - } - - /** - * Add {{@link JLCircle}} to the map with {@link JLOptions#DEFAULT} options. - * Default value for radius is {@link JLProperties#DEFAULT_CIRCLE_RADIUS} - * - * @see JLVectorLayer#addCircle(JLLatLng, int, JLOptions) - */ - @Override - public JLCircle addCircle(JLLatLng center) { - return addCircle(center, JLProperties.DEFAULT_CIRCLE_RADIUS, - JLOptions.DEFAULT); - } - - /** - * Remove a {@link JLCircle} from the map by id. - * - * @param id of Circle - * @return {@link Boolean#TRUE} if removed successfully - */ - @Override - public boolean removeCircle(int id) { - String result = engine.executeScript(String.format("removeCircle(%d)", id)) - .toString(); - callbackHandler.remove(JLCircle.class, id); - return Boolean.parseBoolean(result); - } - - /** - * Add a {@link JLCircleMarker} to the map; - * - * @param center coordinate of the circle. - * @param radius radius of circle in meter. - * @param options see {@link JLOptions} - * @return the instance of created {@link JLCircleMarker} - */ - @Override - public JLCircleMarker addCircleMarker(JLLatLng center, int radius, - JLOptions options) { - String result = engine.executeScript(String.format( - "addCircleMarker([%f, %f], %d, '%s', '%s', %d, %b, %b, %f, %f, %f)", - center.getLat(), center.getLng(), radius, - convertColorToString(options.getColor()), - convertColorToString(options.getFillColor()), - options.getWeight(), options.isStroke(), - options.isFill(), options.getOpacity(), - options.getFillOpacity(), options.getSmoothFactor())) - .toString(); - - int index = Integer.parseInt(result); - JLCircleMarker circleMarker = - new JLCircleMarker(index, radius, center, options); - callbackHandler.addJLObject(circleMarker); - return circleMarker; - } - - /** - * Add {@link JLCircleMarker} to the map with {@link JLOptions#DEFAULT} - * options. Default value for radius is - * {@link JLProperties#DEFAULT_CIRCLE_MARKER_RADIUS} - * - * @see JLVectorLayer#addCircle(JLLatLng, int, JLOptions) - */ - @Override - public JLCircleMarker addCircleMarker(JLLatLng center) { - return addCircleMarker(center, - JLProperties.DEFAULT_CIRCLE_MARKER_RADIUS, JLOptions.DEFAULT); - } - - - /** - * Remove a {@link JLCircleMarker} from the map by id. - * - * @param id of Circle - * @return {@link Boolean#TRUE} if removed successfully - */ - @Override - public boolean removeCircleMarker(int id) { - String result = engine.executeScript( - String.format("removeCircleMarker(%d)", id)).toString(); - callbackHandler.remove(JLCircleMarker.class, id); - return Boolean.parseBoolean(result); - } - - private String convertJLLatLngToString(JLLatLng[] latLngs) { - StringBuilder sb = new StringBuilder(); - sb.append("["); - for (JLLatLng latLng : latLngs) { - sb.append(String.format("%s, ", latLng.toString())); - } - sb.append("]"); - return sb.toString(); - } - - private String convertJLLatLngToString(JLLatLng[][] latLngsList) { - StringBuilder sb = new StringBuilder(); - sb.append("["); - for (JLLatLng[] latLngs : latLngsList) { - sb.append(convertJLLatLngToString(latLngs)).append(","); - } - sb.append("]"); - return sb.toString(); - } - - private String convertJLLatLngToString(JLLatLng[][][] latLngList) { - StringBuilder sb = new StringBuilder(); - sb.append("["); - for (JLLatLng[][] latLng2DArray : latLngList) { - sb.append(convertJLLatLngToString(latLng2DArray)).append(","); - } - sb.append("]"); - return sb.toString(); - } - - private String convertColorToString(Color c) { - int r = (int) Math.round(c.getRed() * 255.0); - int g = (int) Math.round(c.getGreen() * 255.0); - int b = (int) Math.round(c.getBlue() * 255.0); - int a = (int) Math.round(c.getOpacity() * 255.0); - return String.format("#%02x%02x%02x%02x", r, g, b, a); - } -} diff --git a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletGeoJsonLayerInt.java b/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletGeoJsonLayerInt.java deleted file mode 100644 index afba5a4..0000000 --- a/src/main/java/io/github/makbn/jlmap/layer/leaflet/LeafletGeoJsonLayerInt.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.github.makbn.jlmap.layer.leaflet; - -import io.github.makbn.jlmap.exception.JLException; -import io.github.makbn.jlmap.geojson.JLGeoJsonObject; -import lombok.NonNull; - -import java.io.File; - -/** - * The {@code LeafletGeoJsonLayerInt} interface defines methods for adding - * and managing GeoJSON data layers in a Leaflet map. - *

- * Implementations of this interface should provide methods to add GeoJSON - * data from various sources, such as files, URLs, or raw content, as well - * as the ability to remove GeoJSON objects from the map. - * - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -public interface LeafletGeoJsonLayerInt { - - /** - * Adds a GeoJSON object from a file to the Leaflet map. - * - * @param file The {@link File} object representing the GeoJSON file to be - * added. - * @return The {@link JLGeoJsonObject} representing the added GeoJSON data. - * @throws JLException If there is an error while adding the GeoJSON data. - */ - JLGeoJsonObject addFromFile(@NonNull File file) throws JLException; - - /** - * Adds a GeoJSON object from a URL to the Leaflet map. - * - * @param url The URL of the GeoJSON data to be added. - * @return The {@link JLGeoJsonObject} representing the added GeoJSON data. - * @throws JLException If there is an error while adding the GeoJSON data. - */ - JLGeoJsonObject addFromUrl(@NonNull String url) throws JLException; - - /** - * Adds a GeoJSON object from raw content to the Leaflet map. - * - * @param content The raw GeoJSON content to be added. - * @return The {@link JLGeoJsonObject} representing the added GeoJSON data. - * @throws JLException If there is an error while adding the GeoJSON data. - */ - JLGeoJsonObject addFromContent(@NonNull String content) throws JLException; - - /** - * Removes a GeoJSON object from the Leaflet map. - * - * @param object The {@link JLGeoJsonObject} to be removed from the map. - * @return {@code true} if the removal was successful, {@code false} - * if the object was not found or could not be removed. - */ - boolean removeGeoJson(@NonNull JLGeoJsonObject object); - -} diff --git a/src/main/java/io/github/makbn/jlmap/listener/OnJLMapViewListener.java b/src/main/java/io/github/makbn/jlmap/listener/OnJLMapViewListener.java deleted file mode 100644 index f910e01..0000000 --- a/src/main/java/io/github/makbn/jlmap/listener/OnJLMapViewListener.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.github.makbn.jlmap.listener; - -import io.github.makbn.jlmap.JLMapView; -import io.github.makbn.jlmap.listener.event.Event; -import lombok.NonNull; - - -public interface OnJLMapViewListener { - - /** - * called after the map is fully loaded - * - * @param mapView loaded map - */ - void mapLoadedSuccessfully(@NonNull JLMapView mapView); - - /** - * called after the map got an exception on loading - */ - void mapFailed(); - - default void onAction(Event event) { - - } - - - enum Action { - /** - * zoom level changes continuously - */ - ZOOM, - /** - * zoom level stats to change - */ - ZOOM_START, - /** - * zoom leve changes end - */ - ZOOM_END, - - /** - * map center changes continuously - */ - MOVE, - /** - * user starts to move the map - */ - MOVE_START, - /** - * user ends to move the map - */ - MOVE_END, - /** - * user click on the map - */ - CLICK - - } -} diff --git a/src/main/java/io/github/makbn/jlmap/listener/OnJLObjectActionListener.java b/src/main/java/io/github/makbn/jlmap/listener/OnJLObjectActionListener.java deleted file mode 100644 index 33128ff..0000000 --- a/src/main/java/io/github/makbn/jlmap/listener/OnJLObjectActionListener.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.makbn.jlmap.listener; - -import io.github.makbn.jlmap.model.JLObject; -import lombok.Getter; - - -public abstract class OnJLObjectActionListener> { - - public abstract void click(T t, Action action); - - public abstract void move(T t, Action action); - - - @Getter - public enum Action { - /** - * Fired when the marker is moved via setLatLng or by dragging. - * Old and new coordinates are included in event arguments as oldLatLng, - * {@link io.github.makbn.jlmap.model.JLLatLng}. - */ - MOVE("move"), - MOVE_START("movestart"), - MOVE_END("moveend"), - /** - * Fired when the user clicks (or taps) the layer. - */ - CLICK("click"), - /** - * Fired when the user zooms. - */ - ZOOM("zoom"); - - final String jsEventName; - - Action(String name) { - this.jsEventName = name; - } - } -} diff --git a/src/main/java/io/github/makbn/jlmap/listener/event/ClickEvent.java b/src/main/java/io/github/makbn/jlmap/listener/event/ClickEvent.java deleted file mode 100644 index 7966626..0000000 --- a/src/main/java/io/github/makbn/jlmap/listener/event/ClickEvent.java +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.makbn.jlmap.listener.event; - -import io.github.makbn.jlmap.model.JLLatLng; - -public record ClickEvent(JLLatLng center) implements Event { -} diff --git a/src/main/java/io/github/makbn/jlmap/listener/event/Event.java b/src/main/java/io/github/makbn/jlmap/listener/event/Event.java deleted file mode 100644 index 789ec47..0000000 --- a/src/main/java/io/github/makbn/jlmap/listener/event/Event.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.makbn.jlmap.listener.event; - -public interface Event { -} diff --git a/src/main/java/io/github/makbn/jlmap/listener/event/MoveEvent.java b/src/main/java/io/github/makbn/jlmap/listener/event/MoveEvent.java deleted file mode 100644 index 2c5d079..0000000 --- a/src/main/java/io/github/makbn/jlmap/listener/event/MoveEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.makbn.jlmap.listener.event; - -import io.github.makbn.jlmap.listener.OnJLMapViewListener; -import io.github.makbn.jlmap.model.JLBounds; -import io.github.makbn.jlmap.model.JLLatLng; - -/** - * - * @param action movement action - * @param center coordinate of map - * @param bounds bounds of map - * @param zoomLevel zoom level of map - */ -public record MoveEvent(OnJLMapViewListener.Action action, JLLatLng center, - JLBounds bounds, int zoomLevel) implements Event { -} diff --git a/src/main/java/io/github/makbn/jlmap/listener/event/ZoomEvent.java b/src/main/java/io/github/makbn/jlmap/listener/event/ZoomEvent.java deleted file mode 100644 index 5cf48f1..0000000 --- a/src/main/java/io/github/makbn/jlmap/listener/event/ZoomEvent.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.makbn.jlmap.listener.event; - -import io.github.makbn.jlmap.listener.OnJLMapViewListener; - -public record ZoomEvent(OnJLMapViewListener.Action action, int zoomLevel) - implements Event { -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLCircle.java b/src/main/java/io/github/makbn/jlmap/model/JLCircle.java deleted file mode 100644 index 45a9e89..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLCircle.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.makbn.jlmap.model; - -import lombok.*; - -/** - * A class for drawing circle overlays on a map - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@Builder -@AllArgsConstructor -@ToString -public class JLCircle extends JLObject { - /** - * id of object! this is an internal id for JLMap Application and not - * related to Leaflet! - */ - protected int id; - /** - * Radius of the circle, in meters. - */ - private double radius; - /** - * Coordinates of the JLMarker on the map - */ - private JLLatLng latLng; - /** - * theming options for JLCircle. all options are not available! - */ - private JLOptions options; -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLCircleMarker.java b/src/main/java/io/github/makbn/jlmap/model/JLCircleMarker.java deleted file mode 100644 index 9dd8eac..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLCircleMarker.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.makbn.jlmap.model; - -import lombok.*; - -/** - * A circle of a fixed size with radius specified in pixels. - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@Builder -@AllArgsConstructor -@ToString -public class JLCircleMarker extends JLObject { - /** - * id of object! this is an internal id for JLMap Application and not - * related to Leaflet! - */ - protected int id; - /** - * Radius of the circle, in meters. - */ - private double radius; - /** - * Coordinates of the JLCircleMarker on the map - */ - private JLLatLng latLng; - /** - * theming options for JLCircleMarker. all options are not available! - */ - private JLOptions options; -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLMarker.java b/src/main/java/io/github/makbn/jlmap/model/JLMarker.java deleted file mode 100644 index 34ffd62..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLMarker.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.github.makbn.jlmap.model; - - -import io.github.makbn.jlmap.listener.OnJLObjectActionListener; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * JLMarker is used to display clickable/draggable icons on the map! - * - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Data -@Builder -@AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class JLMarker extends JLObject { - /** - * id of object! this is an internal id for JLMap Application and not - * related to Leaflet! - */ - protected int id; - /** - * optional text for showing on created JLMarker tooltip. - */ - private String text; - /** - * Coordinates of the JLMarker on the map - */ - private JLLatLng latLng; - - - @Override - public void update(Object... params) { - super.update(params); - if (params != null && params.length > 0 - && String.valueOf(params[0]).equals( - OnJLObjectActionListener.Action.MOVE_END.getJsEventName()) - && params[1] != null) { - latLng = (JLLatLng) params[1]; - } - } -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLMultiPolyline.java b/src/main/java/io/github/makbn/jlmap/model/JLMultiPolyline.java deleted file mode 100644 index 7dc52a5..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLMultiPolyline.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.github.makbn.jlmap.model; - -import lombok.*; - -/** - * A class for drawing polyline overlays on a map - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@AllArgsConstructor -@Builder -@ToString -public class JLMultiPolyline extends JLObject { - /** - * id of JLMultiPolyline! this is an internal id for JLMap Application - * and not related to Leaflet! - */ - private int id; - /** - * theming options for JLMultiPolyline. all options are not available! - */ - private JLOptions options; - /** - * The array of {@link io.github.makbn.jlmap.model.JLLatLng} points - * of JLMultiPolyline - */ - private JLLatLng[][] vertices; -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLObject.java b/src/main/java/io/github/makbn/jlmap/model/JLObject.java deleted file mode 100644 index 0c85e8c..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLObject.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.github.makbn.jlmap.model; - -import io.github.makbn.jlmap.listener.OnJLObjectActionListener; - -/** - * Represents basic object classes for interacting with Leaflet - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -public abstract class JLObject> { - private OnJLObjectActionListener listener; - - public OnJLObjectActionListener getOnActionListener() { - return listener; - } - - public void setOnActionListener(OnJLObjectActionListener listener) { - this.listener = listener; - } - - public abstract int getId(); - - public void update(Object... params) { - - } -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLOptions.java b/src/main/java/io/github/makbn/jlmap/model/JLOptions.java deleted file mode 100644 index 70ff0c3..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLOptions.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.github.makbn.jlmap.model; - -import javafx.scene.paint.Color; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; - -/** - * Optional value for theming objects inside the map! - * Note that all options are not available for all objects! - * Read more at Leaflet Official Docs. - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@Builder -@AllArgsConstructor -public class JLOptions { - - /** Default value for theming options. */ - public static final JLOptions DEFAULT = JLOptions.builder().build(); - - /** Stroke color. Default is {{@link Color#BLUE}} */ - @Builder.Default - private Color color = Color.BLUE; - - /** Fill color. Default is {{@link Color#BLUE}} */ - @Builder.Default - private Color fillColor = Color.BLUE; - - /** Stroke width in pixels. Default is 3 */ - @Builder.Default - private int weight = 3; - - /** - * Whether to draw stroke along the path. Set it to false for disabling - * borders on polygons or circles. - */ - @Builder.Default - private boolean stroke = true; - - /** Whether to fill the path with color. Set it to false fo disabling - * filling on polygons or circles. - */ - @Builder.Default - private boolean fill = true; - - /** Stroke opacity */ - @Builder.Default - private double opacity = 1.0; - - /** Fill opacity. */ - @Builder.Default - private double fillOpacity = 0.2; - - /** How much to simplify the polyline on each zoom level. - * greater value means better performance and smoother - * look, and smaller value means more accurate representation. - */ - @Builder.Default - private double smoothFactor = 1.0; - - /** Controls the presence of a close button in the popup. - */ - @Builder.Default - private boolean closeButton = true; - - /** Set it to false if you want to override the default behavior - * of the popup closing when another popup is opened. - */ - @Builder.Default - private boolean autoClose = true; - - /** Whether the marker is draggable with mouse/touch or not. - */ - @Builder.Default - private boolean draggable = false; - -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLPolygon.java b/src/main/java/io/github/makbn/jlmap/model/JLPolygon.java deleted file mode 100644 index 8852421..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLPolygon.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.github.makbn.jlmap.model; - -import lombok.*; - -/** - * A class for drawing polygon overlays on the map. - * Note that points you pass when creating a polygon shouldn't - * have an additional last point equal to the first one. - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@AllArgsConstructor -@Builder -@ToString -public class JLPolygon extends JLObject { - /** - * id of JLPolygon! this is an internal id for JLMap Application and not - * related to Leaflet! - */ - private int id; - /** - * theming options for JLMultiPolyline. all options are not available! - */ - private JLOptions options; - - /** - * The arrays of latlngs, with the first array representing the outer - * shape and the other arrays representing holes in the outer shape. - * Additionally, you can pass a multi-dimensional array to represent - * a MultiPolygon shape. - */ - private JLLatLng[][][] vertices; -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLPolyline.java b/src/main/java/io/github/makbn/jlmap/model/JLPolyline.java deleted file mode 100644 index 8501afa..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLPolyline.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.github.makbn.jlmap.model; - -import lombok.*; - -/** - * A class for drawing polyline overlays on the map. - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@AllArgsConstructor -@Builder -@ToString -public class JLPolyline extends JLObject { - /** - * id of JLPolyline! this is an internal id for JLMap Application and not - * related to Leaflet! - */ - private int id; - /** - * theming options for JLPolyline. all options are not available! - */ - private JLOptions options; - /** - * The array of {@link io.github.makbn.jlmap.model.JLLatLng} points of - * JLPolyline - */ - private JLLatLng[] vertices; -} diff --git a/src/main/java/io/github/makbn/jlmap/model/JLPopup.java b/src/main/java/io/github/makbn/jlmap/model/JLPopup.java deleted file mode 100644 index a8b4a41..0000000 --- a/src/main/java/io/github/makbn/jlmap/model/JLPopup.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.makbn.jlmap.model; - -import lombok.*; - -/** - * Used to open popups in certain places of the map. - * @author Mehdi Akbarian Rastaghi (@makbn) - */ -@Getter -@Setter -@AllArgsConstructor -@Builder -@ToString -public class JLPopup { - /** - * id of JLPopup! this is an internal id for JLMap Application and not - * related to Leaflet! - */ - private int id; - /** Content of the popup.*/ - private String text; - /** Coordinates of the popup on the map. */ - private JLLatLng latLng; - /** Theming options for JLPopup. all options are not available! */ - private JLOptions options; -} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java deleted file mode 100644 index 15dc809..0000000 --- a/src/main/java/module-info.java +++ /dev/null @@ -1,41 +0,0 @@ -module io.github.makbn.jlmap { - // JavaFX modules - requires javafx.controls; - requires javafx.base; - requires javafx.swing; - requires javafx.web; - requires javafx.graphics; - - // JDK modules - requires jdk.jsobject; - - // Logging - requires org.slf4j; - - // JSON processing - requires com.google.gson; - requires com.fasterxml.jackson.databind; - - // Annotations - requires static org.jetbrains.annotations; - requires static lombok; - - // Exports for public API - exports io.github.makbn.jlmap; - exports io.github.makbn.jlmap.demo; - exports io.github.makbn.jlmap.layer; - exports io.github.makbn.jlmap.layer.leaflet; - exports io.github.makbn.jlmap.listener; - exports io.github.makbn.jlmap.model; - exports io.github.makbn.jlmap.exception; - exports io.github.makbn.jlmap.geojson; - exports io.github.makbn.jlmap.engine; - - // Opens for reflection (if needed by frameworks) - opens io.github.makbn.jlmap to javafx.graphics; - opens io.github.makbn.jlmap.layer to javafx.graphics; - opens io.github.makbn.jlmap.model to javafx.graphics; - opens io.github.makbn.jlmap.geojson to javafx.graphics; - opens io.github.makbn.jlmap.engine to javafx.graphics; - opens io.github.makbn.jlmap.demo to javafx.graphics; -} \ No newline at end of file diff --git a/src/main/resources/.DS_Store b/src/main/resources/.DS_Store deleted file mode 100644 index c4d94a2..0000000 Binary files a/src/main/resources/.DS_Store and /dev/null differ diff --git a/src/main/resources/index.html b/src/main/resources/index.html deleted file mode 100755 index 82b839f..0000000 --- a/src/main/resources/index.html +++ /dev/null @@ -1,344 +0,0 @@ - - - Java - Leaflet - - - - - - - - -

-
-
-
-
- -
Java-Leaflet - | Map data © OpenStreetMap contributors, CC-BY-SA -
-
-
-
- - - \ No newline at end of file diff --git a/src/test/java/io/github/makbn/jlmap/ModuleSystemTest.java b/src/test/java/io/github/makbn/jlmap/ModuleSystemTest.java deleted file mode 100644 index 8b2c835..0000000 --- a/src/test/java/io/github/makbn/jlmap/ModuleSystemTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.github.makbn.jlmap; - -import io.github.makbn.jlmap.model.JLLatLng; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - -/** - * Test to verify that the Java Platform Module System is working correctly. - */ -class ModuleSystemTest { - - @Test - void testModuleSystemWorking() { - // Test that we can create basic objects from the module - JLLatLng latLng = JLLatLng.builder() - .lat(51.044) - .lng(114.07) - .build(); - - assertNotNull(latLng); - assertEquals(51.044, latLng.getLat()); - assertEquals(114.07, latLng.getLng()); - - // Test that we can access module properties - assertNotNull(JLProperties.MapType.OSM_MAPNIK); - assertNotNull(JLProperties.INIT_MIN_HEIGHT_STAGE); - assertNotNull(JLProperties.INIT_MIN_WIDTH_STAGE); - - System.out.println("✅ Module system is working correctly!"); - System.out.println("✅ Module: io.github.makbn.jlmap"); - System.out.println("✅ Java Version: " + System.getProperty("java.version")); - System.out.println("✅ Module Path: " + System.getProperty("java.module.path")); - } - - @Test - void testModuleInfoAccessible() { - // Test that module-info.java is properly processed - Module module = JLMapView.class.getModule(); - assertNotNull(module); - assertEquals("io.github.makbn.jlmap", module.getName()); - - System.out.println("✅ Module name: " + module.getName()); - System.out.println("✅ Module is named: " + module.isNamed()); - } -} \ No newline at end of file diff --git a/src/test/java/io/github/makbn/jlmap/model/JLBoundsTest.java b/src/test/java/io/github/makbn/jlmap/model/JLBoundsTest.java deleted file mode 100644 index 51d993e..0000000 --- a/src/test/java/io/github/makbn/jlmap/model/JLBoundsTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package io.github.makbn.jlmap.model; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.ArgumentsProvider; -import org.junit.jupiter.params.provider.ArgumentsSource; - -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -@Tag("JLBounds") -class JLBoundsTest implements ModelTest { - - public static class TestArgumentProvider implements ArgumentsProvider { - - private JLLatLng latLng(Double lat, Double lng) { - return JLLatLng.builder() - .lat(lat) - .lng(lng) - .build(); - } - - @Override - public Stream provideArguments(ExtensionContext extensionContext) { - if (extensionContext.getTags().contains("test_get_center")) { - return Stream.of( - Arguments.of(latLng(51.03, -124.48), latLng(58.23, -110.76), latLng(54.63, -117.62)), - Arguments.of(latLng(50.92, -114.12), latLng(51.16, -113.9), latLng(51.04, -114.01)) - ); - } else if (extensionContext.getTags().contains("test_get_direction")) { - return Stream.of( - Arguments.of(JLBounds.builder().southWest(latLng(51.03, -124.48)).northEast(latLng(58.23, -110.76)).build(), -124.48, 58.23, -110.76, 51.03), - Arguments.of(JLBounds.builder().southWest(latLng(50.92, -114.12)).northEast(latLng(51.16, -113.9)).build(), -114.12, 51.16, -113.9, 50.92) - ); - } - return Stream.empty(); - } - } - - @Test - @DisplayName("Test toString output format") - void testToString() { - JLBounds bounds = JLBounds.builder() - .northEast(JLLatLng.builder().lat(10).lng(10).build()) - .southWest(JLLatLng.builder().lat(40).lng(60).build()) - .build(); - - assertEquals("[[10.000000, 10.000000], [40.000000, 60.000000]]", bounds.toString()); - } - - @Test - @DisplayName("Test toBBoxString output format") - void testBBox() { - JLBounds bounds = JLBounds.builder() - .northEast(JLLatLng.builder().lat(10).lng(20).build()) - .southWest(JLLatLng.builder().lat(40).lng(60).build()) - .build(); - - assertEquals("60.000000,40.000000,20.000000,10.000000", bounds.toBBoxString()); - } - - @Tag("test_get_center") - @ParameterizedTest(name = "SW: {0}, NE: {1} Center: {2}") - @ArgumentsSource(TestArgumentProvider.class) - @DisplayName("Test getCenter method to find the center of a bound") - void testGetCenter(JLLatLng sw, JLLatLng ne, JLLatLng expectedCenter) { - JLLatLng calculatedCenter = JLBounds.builder() - .southWest(sw) - .northEast(ne) - .build() - .getCenter(); - - Assertions.assertTrue(calculatedCenter.distanceTo(expectedCenter) / 1000 < DISTANCE_ERROR_KM); - } - - @Tag("test_get_direction") - @ParameterizedTest(name = "Point: {0}") - @ArgumentsSource(TestArgumentProvider.class) - @DisplayName("Test get directions of a bound") - void testGetDirections(JLBounds bounds, double west, double north, double east, double south) { - Assertions.assertEquals(west, bounds.getWest()); - Assertions.assertEquals(north, bounds.getNorth()); - Assertions.assertEquals(east, bounds.getEast()); - Assertions.assertEquals(south, bounds.getSouth()); - } -} diff --git a/src/test/java/io/github/makbn/jlmap/model/JLLatLngTest.java b/src/test/java/io/github/makbn/jlmap/model/JLLatLngTest.java deleted file mode 100644 index acc2d09..0000000 --- a/src/test/java/io/github/makbn/jlmap/model/JLLatLngTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package io.github.makbn.jlmap.model; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - - -@Tag("JLLatLng") -class JLLatLngTest implements ModelTest { - - @Test - @DisplayName("Test the equals method for exact similar points") - void testEquals() { - JLLatLng pointA = JLLatLng.builder() - .lat(10) - .lng(20) - .build(); - - JLLatLng pointB = JLLatLng.builder() - .lat(10.000) - .lng(20.00) - .build(); - Assertions.assertEquals(pointA, pointB); - } - - @Test - @DisplayName("Test the equals method for non-similar points") - void testNotEquals() { - JLLatLng pointA = JLLatLng.builder() - .lat(10) - .lng(20) - .build(); - - JLLatLng pointB = JLLatLng.builder() - .lat(20) - .lng(10) - .build(); - - Assertions.assertNotEquals(pointA, pointB); - } - - @ParameterizedTest(name = "Point A(lat: {0} lng:{1}), Point B(lat: {2}, lng: {3}), Distance: {4}") - @DisplayName("Test the equals method with margin for similar points") - @CsvSource({ - "10, 20, 10.0001, 20", - "10, 20.0001, 10, 20", - "10.0001, 20.0001, 10, 20" - }) - void testNotEqualsWithError(double pointALat, double pointALng, double pointBLat, double pointBLng) { - JLLatLng pointA = JLLatLng.builder() - .lat(pointALat) - .lng(pointALng) - .build(); - - JLLatLng pointB = JLLatLng.builder() - .lat(pointBLat) - .lng(pointBLng) - .build(); - // max margin should be in meter, DISTANCE_ERROR is in Km - Assertions.assertTrue(pointA.equals(pointB, DISTANCE_ERROR_M)); - } - - @ParameterizedTest(name = "Point A(lat: {0} lng:{1}), Point B(lat: {2}, lng: {3}), Distance: {4}") - @DisplayName("Test distance calculation in different directions") - @CsvSource({ - "10, 20, 10, 50, 3282", - "50, 10, 20, 10, 3334", - "50, 80, 30, 10, 6113" - }) - void testDistanceCalculation_lng(double pointALat, double pointALng, double pointBLat, double pointBLng, int distance) { - JLLatLng pointA = JLLatLng.builder() - .lat(pointALat) - .lng(pointALng) - .build(); - - JLLatLng pointB = JLLatLng.builder() - .lat(pointBLat) - .lng(pointBLng) - .build(); - - Assertions.assertTrue(Math.abs(distance - Math.round(pointA.distanceTo(pointB) / 1000)) < DISTANCE_ERROR_KM); - } -} diff --git a/src/test/java/io/github/makbn/jlmap/model/JLMarkerTest.java b/src/test/java/io/github/makbn/jlmap/model/JLMarkerTest.java deleted file mode 100644 index 25ed275..0000000 --- a/src/test/java/io/github/makbn/jlmap/model/JLMarkerTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package io.github.makbn.jlmap.model; - -import io.github.makbn.jlmap.listener.OnJLObjectActionListener; -import org.junit.jupiter.api.*; - -import static org.junit.jupiter.api.Assertions.*; - -@Tag("JLMarker") -class JLMarkerTest implements ModelTest{ - private static final String MOVE_END_ACTION = OnJLObjectActionListener.Action.MOVE_END.getJsEventName(); - - private JLMarker jlMarker; - - @BeforeEach - void setUp() { - jlMarker = new JLMarker(0, "", null); - } - - @Test - @DisplayName("Test update by moveend action") - void testUpdateWithMoveEndEventAndNonNullLatLng() { - JLLatLng expectedLatLng = new JLLatLng(1.0, 2.0); - jlMarker.update(MOVE_END_ACTION, expectedLatLng); - assertEquals(expectedLatLng, jlMarker.getLatLng()); - } - - @Test - @DisplayName("Test update by moveend action with null point") - void testUpdateWithMoveEndEventAndNullLatLng() { - jlMarker.setLatLng(JLLatLng.builder().build()); - jlMarker.update(MOVE_END_ACTION, null); - assertNotNull(jlMarker.getLatLng()); - } - - @Test - @DisplayName("Test update by wrong action") - void testUpdateWithNonMoveEndEvent() { - JLLatLng originalLatLng = new JLLatLng(3.0, 4.0); - jlMarker.setLatLng(originalLatLng); - - jlMarker.update("click", new Object()); - assertEquals(originalLatLng, jlMarker.getLatLng()); - } - - @Test - @DisplayName("Test setId method") - void testSetAndGetId() { - int expectedId = 123; - jlMarker.setId(expectedId); - assertEquals(expectedId, jlMarker.getId()); - } - - @Test - @DisplayName("Test setText method") - void testSetAndGetText() { - String expectedText = "Marker Text"; - jlMarker.setText(expectedText); - assertEquals(expectedText, jlMarker.getText()); - } - - @Test - @DisplayName("Test setLatLng method") - void testSetAndGetLatLng() { - JLLatLng expectedLatLng = new JLLatLng(5.0, 6.0); - jlMarker.setLatLng(expectedLatLng); - assertEquals(expectedLatLng, jlMarker.getLatLng()); - } -} diff --git a/src/test/java/io/github/makbn/jlmap/model/ModelTest.java b/src/test/java/io/github/makbn/jlmap/model/ModelTest.java deleted file mode 100644 index 48c6ff3..0000000 --- a/src/test/java/io/github/makbn/jlmap/model/ModelTest.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.github.makbn.jlmap.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; - - -@Tag("model") -@DisplayName("Tests related to the model package") -public interface ModelTest { - float DISTANCE_ERROR_KM = 0.01F; - float DISTANCE_ERROR_M = 20F; -}