From 16f755a209d4cd813cd53f9ab4a9d06ee781cb06 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Thu, 17 Feb 2022 11:37:55 +0100 Subject: [PATCH] Deploy prior to Final presentation (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First working version * Work done * Closes #185 * Track all changes * converting search results to geojson Co-authored-by: Benjamin Frost * adds markers for search results Co-authored-by: Benjamin Frost * added HPI geocoder * added reverse geolocation * doesnt show students for not logged in users as search results * Map 19/navigation start (#188) * implements navigation route, that takes two points and starts navigation. * navigation route uses the current position as starting point * adds test * linting * changed "context" in test * refactor: auto format scss * fix: detail and search page list height * test: add to_string tests * feat: add course search * fix: add informations to people factory * test: add and refactor search tests * run cucumber tests workflow on push (#246) * persist query * use local storage * empty * fix test * add test * add option for hamburger menu * add id * Fix routing popup showing when room popup is opened * First test for room popup * refactors first test for room popup * fix first test for room popup * fix room tests * fix room tests * made case sensitive tests * wip * fix room tests * wip * case insensitve search (maybe) ^^ * wip * case insensitive ? * rubocop friendly * refactoring, bugfixes and adding normal geocoding * fit map bounds to markers Co-authored-by: Laura Meister * fit map bounds to search result markers * fix selection of one room * Update features/scenarios/search/show_search_results.feature Co-authored-by: Cedric Lorenz <37962195+cedric-lorenz@users.noreply.github.com> * 111-related-matches (#247) * Return related searchable records * Sort more results Make related results less prioritized * Fix and refactor tests * Add tests and fix sorting * Use to_s instead of to_string * Update app/controllers/search_controller.rb Co-authored-by: elenagensch <68114173+elenagensch@users.noreply.github.com> * Update app/controllers/search_controller.rb Co-authored-by: elenagensch <68114173+elenagensch@users.noreply.github.com> * Update app/controllers/search_controller.rb Co-authored-by: elenagensch <68114173+elenagensch@users.noreply.github.com> * Update app/controllers/search_controller.rb Co-authored-by: elenagensch <68114173+elenagensch@users.noreply.github.com> * Update features/scenarios/search/search_for_related.feature Co-authored-by: elenagensch <68114173+elenagensch@users.noreply.github.com> * Correct keyword in cucumber test * Change comment in search controller * Adjust comment again * Linting issues * Make tests green * Refactor feature tests * Format code and change .rubocop.yml * Fix tests and tag priority sorting * Update searchable_record.rb * Fix tests from merge Co-authored-by: sm1lla Co-authored-by: Smilla Fox <51457307+sm1lla@users.noreply.github.com> Co-authored-by: elenagensch <68114173+elenagensch@users.noreply.github.com> * updates people model and changes prohibited keys to person view * updates migration * fix people show * fix people search test * update db * debug deployment * Redesign search page * fix search tests * removed token comment * Center the search vertically * inital webmanifest * rename variable and switch from layergroup to layer * correct indentation * start implementing Ajax approach * try to send remote call * fix search index * fix empty string bug * add new feature * add feature test for search result display * format js * Set force_ssl=true (#268) (#269) Co-authored-by: NikkelM Co-authored-by: Nick Bessin <68278780+SinNeax@users.noreply.github.com> Co-authored-by: NikkelM * fix missing import * fix bottombar options redirect * implement redirects in options page * implement pure ajax and change to only html for form * adopt search results partial to master state * 211-update chair ui (#254) * update chair ui * implemented requested changes * make placeholder chair image square Co-authored-by: Margarete * create detail page for courses (#255) Co-authored-by: Margarete * feat: added css for circular pictures * poi ui without tests * write tests for poi detail page * refactor: rubocop corrections * fix: variable assignment * implement redirects in not signed in options page * adds options page to navbar * changes options icon in navbar * add suggestion when there are no search results (#263) * add suggestion when there are no search results * do not show suggestions on first visit * update lockfiles * adopt new changes to search results on master * fix tests and start to adopt for map search * Closes 275 * fix: test poi name * add live search on map * add map marker refresh * make similar results only dependent on more results * fix: linting issues * Switch search bar positon on focus * fix: course picture * Fix zoom on 'Show on map' click * Fix popup not removing * fix linter issues * adopt search result structure of dev * add error message and make nice * Interface to select floor (#232) *added title and red box * multiple floor interfaces * fix(map): no overlaping layers when no room selected * Start working on search page * added loading search page according to entered query * Fix merge issues * 200 navigation button (#265) * add navigation route to button * navigate to center of mass when clicking navigate button in detail view * use other navigation button, fix tests * delete puts and fix redeclaration of variable * rename css class, fix failures on dev * change icon of navigation button * Removed force_ssl which was part of a fix for #249 but lead to the login not working anymore * delete unnessary files * fix indoor map being built before outdoor map Co-authored-by: Marie Fischer Co-authored-by: Judith <39854388+felix-20@users.noreply.github.com> Co-authored-by: NikkelM Co-authored-by: Paul Sieben <67124476+PaulVII@users.noreply.github.com> Co-authored-by: tfiedlerdev Co-authored-by: LeonHermann322 Co-authored-by: Benjamin Frost Co-authored-by: Tobias Sträubig Co-authored-by: treyfel <58570309+treyfel@users.noreply.github.com> Co-authored-by: rothaarlappen <37540536+rothaarlappen@users.noreply.github.com> Co-authored-by: Lukas Wenner Co-authored-by: cdfhalle <82583544+cdfhalle@users.noreply.github.com> Co-authored-by: elenagensch <68114173+elenagensch@users.noreply.github.com> Co-authored-by: Benjamin Frost Co-authored-by: Tom Richter Co-authored-by: MartinPreiss Co-authored-by: Leon Hermann <61618825+LeonHermann322@users.noreply.github.com> Co-authored-by: DevSchmidtchen Co-authored-by: Laura Meister Co-authored-by: MartinPreiss <68462093+MartinPreiss@users.noreply.github.com> Co-authored-by: Cedric Lorenz <37962195+cedric-lorenz@users.noreply.github.com> Co-authored-by: Abdullatif Ghajar <62988232+AbdullatifGhajar@users.noreply.github.com> Co-authored-by: sm1lla Co-authored-by: Smilla Fox <51457307+sm1lla@users.noreply.github.com> Co-authored-by: Kirill Postnov Co-authored-by: Tobias Sträubig <42702024+t-straeubig@users.noreply.github.com> Co-authored-by: Paul Sieben <67124476+PaulVII@users.noreply.github.com> Co-authored-by: Lucas Liebe Co-authored-by: Franz Sauerwald <15076254+FranzSw@users.noreply.github.com> Co-authored-by: Nick Bessin <68278780+SinNeax@users.noreply.github.com> Co-authored-by: lucasliebe <33379641+lucasliebe@users.noreply.github.com> Co-authored-by: richardschiemenz <61618635+richardschiemenz@users.noreply.github.com> Co-authored-by: Margarete Co-authored-by: cdfhalle Co-authored-by: Nikola Genchev <68203919+kolioOtSofia@users.noreply.github.com> Co-authored-by: Kirill Co-authored-by: 23mafi <68427675+23mafi@users.noreply.github.com> Co-authored-by: Marie Fischer Co-authored-by: Judith <39854388+felix-20@users.noreply.github.com> Co-authored-by: Margarete01 <57364741+Margarete01@users.noreply.github.com> --- .github/workflows/run_acceptance_tests.yml | 3 +- .gitignore | 2 + .rubocop.yml | 6 + Gemfile | 7 +- Gemfile.lock | 14 + app/assets/images/placeholder_chair.png | Bin 8390 -> 8030 bytes app/assets/images/placeholder_course.png | Bin 0 -> 10987 bytes app/assets/images/placeholder_poi.png | Bin 0 -> 11939 bytes app/assets/stylesheets/_colors.scss | 16 +- app/assets/stylesheets/_values.scss | 2 +- app/assets/stylesheets/application.scss | 26 +- app/assets/stylesheets/common.scss | 22 + .../stylesheets/components/_bottombar.scss | 26 +- .../stylesheets/components/_iconbutton.scss | 2 +- .../components/_map_navigation_popup.scss | 6 +- .../stylesheets/components/_map_popup.scss | 17 - .../stylesheets/components/_room_popup.scss | 17 + .../stylesheets/components/_titlebar.scss | 20 +- app/assets/stylesheets/data_problems.scss | 10 +- app/assets/stylesheets/map.scss | 69 +- app/assets/stylesheets/options.scss | 5 + app/assets/stylesheets/scaffolds.scss | 42 +- app/assets/stylesheets/search.scss | 41 +- app/controllers/map_controller.rb | 28 +- app/controllers/people_controller.rb | 2 +- .../point_of_interests_controller.rb | 68 + app/controllers/search_controller.rb | 71 +- app/javascript/OutdoorMap/geometry.js | 2 +- app/javascript/channels/consumer.js | 4 +- app/javascript/channels/index.js | 4 +- app/javascript/constants.js | 25 +- app/javascript/packs/application.js | 24 +- app/javascript/packs/form/image_field.js | 26 +- app/javascript/packs/indoor_map_builder.js | 209 +- app/javascript/packs/outdoor_map_builder.js | 518 +- app/javascript/packs/router.js | 30 +- app/javascript/packs/search.js | 85 +- app/javascript/packs/search_button.js | 6 + app/javascript/packs/selected_room_marker.js | 21 +- app/models/chair.rb | 6 +- app/models/course.rb | 22 +- app/models/course_time.rb | 5 + app/models/person.rb | 10 +- app/models/point_of_interest.rb | 7 + app/models/room.rb | 16 +- app/models/searchable_record.rb | 16 +- app/views/chairs/show.html.erb | 18 +- app/views/courses/show.html.erb | 94 +- app/views/layouts/_bottombar.html.erb | 11 +- app/views/layouts/_navbar.html.erb | 15 +- app/views/layouts/application.html.erb | 4 + app/views/layouts/fullpage.html.erb | 3 +- app/views/map/_room_popup.html.erb | 32 + app/views/map/index.html.erb | 93 +- app/views/options/_signed_in.html.erb | 89 +- app/views/options/_signed_out.html.erb | 10 +- app/views/options/index.html.erb | 2 +- app/views/partials/_iconbutton.html.erb | 2 +- app/views/partials/_map_js.html.erb | 15 + .../partials/_map_navigation_popup.html.erb | 15 +- app/views/partials/_map_popup.html.erb | 26 - app/views/partials/_picture_circle.erb | 3 + app/views/partials/_search_bar.html.erb | 25 +- app/views/partials/_search_results.html.erb | 66 + app/views/partials/_suggestions.html.erb | 5 + app/views/people/index.html.erb | 2 +- app/views/people/show.html.erb | 50 +- app/views/point_of_interests/index.html.erb | 23 + app/views/point_of_interests/show.html.erb | 26 + app/views/rooms/show.html.erb | 23 +- app/views/search/index.html.erb | 73 +- config/routes.rb | 7 + .../20220202143420_add_image_to_courses.rb | 5 + ...9135614_add_image_to_point_of_interests.rb | 5 + db/schema.rb | 4 +- db/seeds.rb | 19 +- .../search/persist_search_query.feature | 16 + .../search/search_case_insensitive.feature | 36 + .../scenarios/search/search_for_class.feature | 29 + .../search/search_for_related.feature | 28 + .../scenarios/search/search_for_rooms.feature | 25 + .../search/show_search_results.feature | 54 +- .../step_definitions/chair/chair_steps.rb | 9 + .../step_definitions/search/places_steps.rb | 13 + .../search/show_search_results_steps.rb | 8 + package-lock.json | 21075 ---------------- public/assets/images/people/.gitkeep | 0 public/images/platypus_centered.png | Bin 0 -> 77690 bytes public/manifest.json | 22 + spec/controllers/map_controller_spec.rb | 17 + spec/factories/courses.rb | 14 +- spec/factories/people.rb | 1 + spec/factories/point_of_interests.rb | 7 + spec/features/map/access_map_page_spec.rb | 6 +- .../search/access_search_page_spec.rb | 148 +- spec/models/chair_spec.rb | 4 + spec/models/course_spec.rb | 4 + spec/models/person_spec.rb | 4 + spec/models/room_spec.rb | 4 + spec/views/chairs/show.html.erb_spec.rb | 6 + spec/views/courses/edit.html.erb_spec.rb | 11 +- spec/views/courses/index.html.erb_spec.rb | 20 +- spec/views/courses/new.html.erb_spec.rb | 8 +- spec/views/courses/show.html.erb_spec.rb | 52 +- spec/views/people/show.html.erb_spec.rb | 8 + .../point_of_interests/index.html.erb_spec.rb | 13 + .../point_of_interests/show.html.erb_spec.rb | 29 + spec/views/rooms/show.html.erb_spec.rb | 8 + yarn.lock | 32 +- 109 files changed, 2102 insertions(+), 21932 deletions(-) create mode 100644 app/assets/images/placeholder_course.png create mode 100644 app/assets/images/placeholder_poi.png delete mode 100644 app/assets/stylesheets/components/_map_popup.scss create mode 100644 app/assets/stylesheets/components/_room_popup.scss create mode 100644 app/assets/stylesheets/options.scss create mode 100644 app/controllers/point_of_interests_controller.rb create mode 100644 app/javascript/packs/search_button.js create mode 100644 app/views/map/_room_popup.html.erb create mode 100644 app/views/partials/_map_js.html.erb delete mode 100644 app/views/partials/_map_popup.html.erb create mode 100644 app/views/partials/_picture_circle.erb create mode 100644 app/views/partials/_search_results.html.erb create mode 100644 app/views/partials/_suggestions.html.erb create mode 100644 app/views/point_of_interests/index.html.erb create mode 100644 app/views/point_of_interests/show.html.erb create mode 100644 db/migrate/20220202143420_add_image_to_courses.rb create mode 100644 db/migrate/20220209135614_add_image_to_point_of_interests.rb create mode 100644 features/scenarios/search/persist_search_query.feature create mode 100644 features/scenarios/search/search_case_insensitive.feature create mode 100644 features/scenarios/search/search_for_class.feature create mode 100644 features/scenarios/search/search_for_related.feature create mode 100644 features/scenarios/search/search_for_rooms.feature delete mode 100644 package-lock.json delete mode 100644 public/assets/images/people/.gitkeep create mode 100644 public/images/platypus_centered.png create mode 100644 public/manifest.json create mode 100644 spec/views/point_of_interests/index.html.erb_spec.rb create mode 100644 spec/views/point_of_interests/show.html.erb_spec.rb diff --git a/.github/workflows/run_acceptance_tests.yml b/.github/workflows/run_acceptance_tests.yml index 587b2926..d90d0288 100644 --- a/.github/workflows/run_acceptance_tests.yml +++ b/.github/workflows/run_acceptance_tests.yml @@ -1,12 +1,13 @@ name: Acceptance tests on: + push: # Allow manual triggering workflow_dispatch: jobs: # Name of the job - rspec-test: + cucumber-test: runs-on: ubuntu-latest # https://docs.github.com/en/actions/using-containerized-services/creating-postgresql-service-containers diff --git a/.gitignore b/.gitignore index 1bed3977..2d76f9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ er-diagram.png /yarn-error.log yarn-debug.log* .yarn-integrity + +.vscode diff --git a/.rubocop.yml b/.rubocop.yml index dd41a259..e07dc15f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ require: # opt-in to new rules (cops) AllCops: + TargetRubyVersion: 2.7 NewCops: enable # @@ -106,3 +107,8 @@ Metrics/AbcSize: # Okay to use has_and_belongs_to_many for many-to-many relationships Rails/HasAndBelongsToMany: Enabled: false + + +RSpec/FactoryBot/SyntaxMethods: + Enabled: false + diff --git a/Gemfile b/Gemfile index 8ef113a9..bfef6014 100644 --- a/Gemfile +++ b/Gemfile @@ -46,7 +46,7 @@ gem 'omniauth' # https://github.com/omniauth/omniauth gem 'omniauth_openid_connect' # https://github.com/m0n9oose/omniauth_openid_connect gem 'open_uri_redirections' - +gem 'jquery-rails' # # Gems that are loaded depending on the environment (development/test/production) # @@ -91,7 +91,7 @@ group :development do gem 'better_errors' # https://github.com/BetterErrors/better_errors # binding_of_caller is optional, but is necessary to use Better Errors' advanced features gem 'binding_of_caller' # https://github.com/banister/binding_of_caller - # Generate a diagram based on your application's Active Record models by calling 'bundle exec erd'. Dependency: graphviz + # Generate a diagram based on your application's models by calling 'bundle exec erd'. Dependency: graphviz gem 'rails-erd' # https://github.com/voormedia/rails-erd end @@ -117,3 +117,6 @@ end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] + +# Pass variables from controller to JS +gem 'gon' diff --git a/Gemfile.lock b/Gemfile.lock index 95c773d3..81fa23b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -177,6 +177,11 @@ GEM ffi (1.15.5-x64-mingw32) globalid (1.0.0) activesupport (>= 5.0) + gon (6.4.0) + actionpack (>= 3.0.20) + i18n (>= 0.7) + multi_json + request_store (>= 1.0) hashie (5.0.0) httpclient (2.8.3) i18n (1.8.11) @@ -185,6 +190,10 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.5.0) + jquery-rails (4.4.0) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) json-jwt (1.13.0) activesupport (>= 4.2) aes_key_wrap @@ -207,6 +216,7 @@ GEM mini_portile2 (2.7.1) minitest (5.15.0) msgpack (1.4.4) + multi_json (1.15.0) multi_test (0.1.2) nio4r (2.5.8) nokogiri (1.13.1) @@ -296,6 +306,8 @@ GEM rb-inotify (0.10.1) ffi (~> 1.0) regexp_parser (2.2.0) + request_store (1.5.1) + rack (>= 1.4) responders (3.0.1) actionpack (>= 5.0) railties (>= 5.0) @@ -450,7 +462,9 @@ DEPENDENCIES devise-i18n devise-i18n-bootstrap factory_bot_rails + gon jbuilder (~> 2.7) + jquery-rails listen (~> 3.3) omniauth omniauth_openid_connect diff --git a/app/assets/images/placeholder_chair.png b/app/assets/images/placeholder_chair.png index 8592c7f4a8b1ac422382dfa5d0c8e7a75ce492fb..a76864017af10ce8259a51fb85110ae2768c1c4b 100644 GIT binary patch literal 8030 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 KJ>s%z-M;{$Xg68_ literal 8390 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 diff --git a/app/assets/images/placeholder_course.png b/app/assets/images/placeholder_course.png new file mode 100644 index 0000000000000000000000000000000000000000..b2ac3ce4db382f17ed9b940ade621e81ec0ba5d2 GIT binary patch literal 10987 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 APyhe` literal 0 HcmV?d00001 diff --git a/app/assets/stylesheets/_colors.scss b/app/assets/stylesheets/_colors.scss index 734d083b..7e823008 100644 --- a/app/assets/stylesheets/_colors.scss +++ b/app/assets/stylesheets/_colors.scss @@ -1,11 +1,11 @@ -$red: #FD1015; -$blue: #167FFB; -$yellow: #FFC65A; -$orange: #E67E22; -$green: #1EDD88; -$gray: #0E0000; -$lightgray: #F4F4F4; -$mediumgray: #C4C4C4; +$red: #fd1015; +$blue: #167ffb; +$yellow: #ffc65a; +$orange: #e67e22; +$green: #1edd88; +$gray: #0e0000; +$lightgray: #f4f4f4; +$mediumgray: #c4c4c4; // See 'Auszug aus dem HPI Corporate Design Manual' // tints / shades generator: https://maketintsandshades.com/#b0063a,e2681e,f9a61b,007a9e diff --git a/app/assets/stylesheets/_values.scss b/app/assets/stylesheets/_values.scss index 8b70d9a8..5cab5378 100644 --- a/app/assets/stylesheets/_values.scss +++ b/app/assets/stylesheets/_values.scss @@ -1,3 +1,3 @@ // Navigation bars $navbar-height: 3.5rem; -$titlebar-height: 3rem; \ No newline at end of file +$titlebar-height: 3rem; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index b824056a..60c63d67 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -23,14 +23,14 @@ $font-size-base: 1rem; // Colors $body-color: $gray; -$primary: $hpi-red; -$success: $green; -$info: $yellow; -$danger: $red; -$warning: $orange; +$primary: $hpi-red; +$success: $green; +$info: $yellow; +$danger: $red; +$warning: $orange; // Input radius -$border-radius: 2px; +$border-radius: 2px; $border-radius-lg: 2px; $border-radius-sm: 2px; @@ -40,13 +40,13 @@ External libraries // Bootstrap @import "bootstrap/scss/bootstrap"; // FontAwesome -$fa-font-path: '@fortawesome/fontawesome-free/webfonts'; -@import '@fortawesome/fontawesome-free/scss/fontawesome'; -@import '@fortawesome/fontawesome-free/scss/solid'; -@import '@fortawesome/fontawesome-free/scss/regular'; -@import '@fortawesome/fontawesome-free/scss/brands'; -@import '@fortawesome/fontawesome-free/scss/v4-shims'; +$fa-font-path: "@fortawesome/fontawesome-free/webfonts"; +@import "@fortawesome/fontawesome-free/scss/fontawesome"; +@import "@fortawesome/fontawesome-free/scss/solid"; +@import "@fortawesome/fontawesome-free/scss/regular"; +@import "@fortawesome/fontawesome-free/scss/brands"; +@import "@fortawesome/fontawesome-free/scss/v4-shims"; // CSS partials @import "components/navbar"; -@import "components/bottombar"; \ No newline at end of file +@import "components/bottombar"; diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index 839f53b9..f3e6a301 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -17,6 +17,28 @@ background-color: $hpi-red-tint !important; } +.picture-circle-container { + position: relative; + max-width: 12em; + max-height: 12em; + + &::after { + content: ""; + display: block; + padding-bottom: 100%; + } +} + +.picture-circle { + background: #ddd; + object-fit: cover; + position: absolute; + width: 100%; + height: 100%; + aspect-ratio: 1; + border-radius: 50%; +} + .picture-rounded { max-width: 100%; max-height: 12em; diff --git a/app/assets/stylesheets/components/_bottombar.scss b/app/assets/stylesheets/components/_bottombar.scss index b337519e..7c458936 100644 --- a/app/assets/stylesheets/components/_bottombar.scss +++ b/app/assets/stylesheets/components/_bottombar.scss @@ -2,24 +2,24 @@ @import "colors"; .navbar-custom-bottom { - position: fixed; - bottom: 0; - display: none; - z-index: 1000; - background-color: $hpi-red; - height: $navbar-height; + position: fixed; + bottom: 0; + display: none; + z-index: 1000; + background-color: $hpi-red; + height: $navbar-height; - .active .nav-link { - color: white !important; - } + .active .nav-link { + color: white !important; + } } @media only screen and (max-width: 400px) { - .navbar-custom-bottom { - display: block; - } + .navbar-custom-bottom { + display: block; + } .container { padding-bottom: $navbar-height; } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/components/_iconbutton.scss b/app/assets/stylesheets/components/_iconbutton.scss index 2a8b0f45..5afd0f26 100644 --- a/app/assets/stylesheets/components/_iconbutton.scss +++ b/app/assets/stylesheets/components/_iconbutton.scss @@ -30,4 +30,4 @@ .iconbutton { margin-right: 0; } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/components/_map_navigation_popup.scss b/app/assets/stylesheets/components/_map_navigation_popup.scss index 9cb211eb..bc94ad8d 100644 --- a/app/assets/stylesheets/components/_map_navigation_popup.scss +++ b/app/assets/stylesheets/components/_map_navigation_popup.scss @@ -1,4 +1,4 @@ #routing-controller { - width: 100%; - padding: 5px; -} \ No newline at end of file + width: 100%; + padding: 5px; +} diff --git a/app/assets/stylesheets/components/_map_popup.scss b/app/assets/stylesheets/components/_map_popup.scss deleted file mode 100644 index 24375aa1..00000000 --- a/app/assets/stylesheets/components/_map_popup.scss +++ /dev/null @@ -1,17 +0,0 @@ -.map-popup { - width: 90%; - position: fixed !important; - bottom: 2rem; - left: 5%; - z-index: 9999; -} - -@media only screen and (max-width: 400px) { - .map-popup { - bottom: 5rem; - - .iconbuttons { - justify-content: space-between !important; - } - } -} \ No newline at end of file diff --git a/app/assets/stylesheets/components/_room_popup.scss b/app/assets/stylesheets/components/_room_popup.scss new file mode 100644 index 00000000..c3780ead --- /dev/null +++ b/app/assets/stylesheets/components/_room_popup.scss @@ -0,0 +1,17 @@ +.room-popup { + width: 90%; + position: fixed !important; + bottom: 2rem; + left: 5%; + z-index: 9999; +} + +@media only screen and (max-width: 400px) { + .room-popup { + bottom: 5rem; + + .iconbuttons { + justify-content: space-between !important; + } + } +} diff --git a/app/assets/stylesheets/components/_titlebar.scss b/app/assets/stylesheets/components/_titlebar.scss index db985f6f..d15cd62f 100644 --- a/app/assets/stylesheets/components/_titlebar.scss +++ b/app/assets/stylesheets/components/_titlebar.scss @@ -2,16 +2,16 @@ @import "colors"; .titlebar { - position: fixed; - top: 0; - display: none !important; - z-index: 1000; - background-color: $hpi-red; - height: $titlebar-height; + position: fixed; + top: 0; + display: none !important; + z-index: 1000; + background-color: $hpi-red; + height: $titlebar-height; } @media only screen and (max-width: 400px) { - .titlebar { - display: block !important; - } -} \ No newline at end of file + .titlebar { + display: block !important; + } +} diff --git a/app/assets/stylesheets/data_problems.scss b/app/assets/stylesheets/data_problems.scss index a089e4ae..09642f29 100644 --- a/app/assets/stylesheets/data_problems.scss +++ b/app/assets/stylesheets/data_problems.scss @@ -3,9 +3,9 @@ // You can use Sass (SCSS) here: https://sass-lang.com/ .buttons { - float: right; - - a { - text-decoration: none; - } + float: right; + + a { + text-decoration: none; + } } diff --git a/app/assets/stylesheets/map.scss b/app/assets/stylesheets/map.scss index a059eb60..4b6701b5 100644 --- a/app/assets/stylesheets/map.scss +++ b/app/assets/stylesheets/map.scss @@ -17,8 +17,8 @@ border: none !important; } .hpi-map { - width: 100%; - height: 100%; + width: 100%; + height: 100%; } .search-container { position: absolute; @@ -73,41 +73,42 @@ .leaflet-routing-geocoder-result { z-index: 10000 !important; } -div.leaflet-top.leaflet-left, div.leaflet-top.leaflet-right { +div.leaflet-top.leaflet-left, +div.leaflet-top.leaflet-right { margin-top: 50px; } @media only screen and (max-width: 640px) { - .leaflet-routing-container { - width: 100% !important; - height: 100% !important; - flex-flow: column !important; - flex-direction: column-reverse !important; - } - .leaflet-routing-collapse-btn { - display: none !important; - } - .routing-stop-button { - width: 100%; - } - #StopNavigation { - width: 100% !important; - } - .leaflet-routing-geocoder { - display: none !important; - } - .leaflet-routing-geocoders { - width: 100% !important; - } - .leaflet-routing-alternatives-container { - width: 85% !important; - max-height: 100px; - } - .leaflet-routing-alt { - max-height: 100px !important; - } - table { - max-height: 100px !important; - } + .leaflet-routing-container { + width: 100% !important; + height: 100% !important; + flex-flow: column !important; + flex-direction: column-reverse !important; + } + .leaflet-routing-collapse-btn { + display: none !important; + } + .routing-stop-button { + width: 100%; + } + #StopNavigation { + width: 100% !important; + } + .leaflet-routing-geocoder { + display: none !important; + } + .leaflet-routing-geocoders { + width: 100% !important; + } + .leaflet-routing-alternatives-container { + width: 85% !important; + max-height: 100px; + } + .leaflet-routing-alt { + max-height: 100px !important; + } + table { + max-height: 100px !important; + } } .leaflet { diff --git a/app/assets/stylesheets/options.scss b/app/assets/stylesheets/options.scss new file mode 100644 index 00000000..1d1dfe6a --- /dev/null +++ b/app/assets/stylesheets/options.scss @@ -0,0 +1,5 @@ +@import "colors"; +.simple-link:any-link { + color: $gray; + text-decoration: none; +} \ No newline at end of file diff --git a/app/assets/stylesheets/scaffolds.scss b/app/assets/stylesheets/scaffolds.scss index bafc3380..ed177975 100644 --- a/app/assets/stylesheets/scaffolds.scss +++ b/app/assets/stylesheets/scaffolds.scss @@ -2,46 +2,57 @@ body { background-color: #fff; color: #333; margin: 33px; - height: 100vh; } + height: 100vh; +} pre { background-color: #eee; padding: 10px; - font-size: 11px; } + font-size: 11px; +} a { - color: #000; } + color: #000; +} a:visited { - color: #666; } + color: #666; +} a:hover { - color: #fff; } + color: #fff; +} th { - padding-bottom: 5px; } + padding-bottom: 5px; +} td { - padding: 0 5px 7px; } + padding: 0 5px 7px; +} div.field, div.actions { - margin-bottom: 10px; } + margin-bottom: 10px; +} #notice { - color: green; } + color: green; +} .field_with_errors { padding: 2px; background-color: red; - display: table; } + display: table; +} #error_explanation { width: 450px; border: 2px solid red; padding: 7px 7px 0; margin-bottom: 20px; - background-color: #f0f0f0; } + background-color: #f0f0f0; +} #error_explanation h2 { text-align: left; @@ -50,11 +61,14 @@ div.actions { font-size: 12px; margin: -7px -7px 0; background-color: #c00; - color: #fff; } + color: #fff; +} #error_explanation ul li { font-size: 12px; - list-style: square; } + list-style: square; +} label { - display: block; } + display: block; +} diff --git a/app/assets/stylesheets/search.scss b/app/assets/stylesheets/search.scss index bd8baeab..45be41dd 100644 --- a/app/assets/stylesheets/search.scss +++ b/app/assets/stylesheets/search.scss @@ -1,17 +1,19 @@ // Place all the styles related to the search controller here. -@import '@fortawesome/fontawesome-free/scss/fontawesome.scss'; -@import '@fortawesome/fontawesome-free/scss/solid.scss'; -@import '@fortawesome/fontawesome-free/scss/brands.scss'; +@import "@fortawesome/fontawesome-free/scss/fontawesome.scss"; +@import "@fortawesome/fontawesome-free/scss/solid.scss"; +@import "@fortawesome/fontawesome-free/scss/brands.scss"; @import "colors"; +@import "values"; @media only screen and (min-width: 400px) { .search-item { margin-bottom: 0.5rem; border-radius: 1rem !important; - .col-2, .col { + .col-2, + .col { justify-content: center; } } @@ -25,17 +27,17 @@ margin-bottom: 0.1rem !important; } - .col-2, .col { + .col-2, + .col { justify-content: start; } - + .col-2 { padding: 0 !important; } } } - .search-item { border: none !important; @@ -43,7 +45,7 @@ @extend %fa-icon; @extend .fas; width: 1.25em; - text-align:center; + text-align: center; padding-right: 2px; color: $gray; @@ -53,13 +55,13 @@ } } - &.--room{ + &.--room { &:before { content: fa-content($fa-var-door-open); } } - &.--chair{ + &.--chair { &:before { content: fa-content($fa-var-users); } @@ -67,16 +69,16 @@ } } -.form-control{ +.form-control { border-radius: 20px !important; } -.form-control:focus{ +.form-control:focus { box-shadow: none !important; } -.cancel-input{ - background: #F4F4F4; +.cancel-input { + background: #f4f4f4; color: #444444; border: 0; border-radius: 20px; @@ -84,14 +86,17 @@ top: -3rem; float: right; margin-right: 5px; - visibility: hidden; } -.col{ - height: 38px; +/* This is to center the search vertically */ +.center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } -@media only screen and (max-width: 400px){ +@media only screen and (max-width: 400px) { .container.mt-5.mb-5 { margin-top: 1.5rem !important; } diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index cef00319..5925c68d 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -10,10 +10,31 @@ def index @buildings = Building.all @points_of_interest = PointOfInterest.all.map(&:to_geojson) @selected_room = Room.find(map_params[:room_id]) if map_params[:room_id].present? + + gon.selected_room_name = @selected_room ? @selected_room.full_name : nil + gon.coordinates = @coordinates + gon.points_of_interest = @points_of_interest end def map_params - params.permit(:room_id) + params.permit(:room_id, :query, :coordinate) + end + + def navigation + @buildings = Building.all + @points_of_interest = PointOfInterest.all.map(&:to_geojson) + @selected_room = Room.find(map_params[:room_id]) if map_params[:room_id].present? + + p1 = params[:coordinate].tr('p', '.') + long1, lat1 = p1.split(",") + + @coordinates = [{ lat: lat1.to_f, lng: long1.to_f }] + + gon.selected_room_name = @selected_room ? @selected_room.full_name : nil + gon.coordinates = @coordinates + gon.points_of_interest = @points_of_interest + + render action: "index" end def url @@ -45,4 +66,9 @@ def directions res = Net::HTTP.get_response(uri) render json: res.body end + + def room_popup + @selected_room = Room.find(map_params[:room_id]) if map_params[:room_id].present? + render "map/_room_popup" + end end diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index ebbb3b6e..61114e56 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -72,7 +72,7 @@ def set_person def person_params process_human_verified_attributes params.require(:person).permit(:first_name, :last_name, :title, :email, :status, :phone, :room, :website, - :image, :chair, + :image, :chair, :role, *Person.verification_attributes) end diff --git a/app/controllers/point_of_interests_controller.rb b/app/controllers/point_of_interests_controller.rb new file mode 100644 index 00000000..f97b65a4 --- /dev/null +++ b/app/controllers/point_of_interests_controller.rb @@ -0,0 +1,68 @@ +class PointOfInterestsController < ApplicationController + before_action :set_point_of_interest, only: %i[show edit update destroy] + + # GET /point_of_interests or /point_of_interests.json + def index + @point_of_interests = PointOfInterest.all + end + + # GET /point_of_interests/1 or /point_of_interests/1.json + def show; end + + # GET /point_of_interests/new + def new + @point_of_interest = PointOfInterest.new + end + + # GET /point_of_interests/1/edit + def edit; end + + # POST /point_of_interests or /point_of_interests.json + def create + @point_of_interest = PointOfInterest.new(point_of_interest_params) + + respond_to do |format| + if @point_of_interest.save + format.html { redirect_to @point_of_interest, notice: "PointOfInterest was successfully created." } + format.json { render :show, status: :created, location: @point_of_interest } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @point_of_interest.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /point_of_interests/1 or /point_of_interests/1.json + def update + respond_to do |format| + if @point_of_interest.update(point_of_interest_params) + format.html { redirect_to @point_of_interest, notice: "PointOfInterest was successfully updated." } + format.json { render :show, status: :ok, location: @point_of_interest } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @point_of_interest.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /point_of_interests/1 or /point_of_interests/1.json + def destroy + @point_of_interest.destroy + respond_to do |format| + format.html { redirect_to point_of_interests_url, notice: "PointOfInterest was successfully destroyed." } + format.json { head :no_content } + end + end + + private + + # Use callbacks to share common setup or constraints between actions. + def set_point_of_interest + @point_of_interest = PointOfInterest.find(params[:id]) + end + + # Only allow a list of trusted parameters through. + def point_of_interest_params + params.fetch(:point_of_interest, {}).permit(:name, :person_ids, :room_ids) + end +end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index a3bfaa57..9f314bf3 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -2,28 +2,37 @@ class SearchController < ApplicationController def index return if params[:query].nil? - @exact_results = add_results_for(params[:query]) - - words_in_query = params[:query].scan(/[A-Za-z0-9]+/) - - @more_results = words_in_query.flat_map { |word| add_results_for(word) } - @more_results = sort(@more_results, params[:query]) - @exact_results + unless params[:query].empty? + @exact_results = add_results_for(params[:query]) + @more_results = more_results(params[:query], @exact_results) + end - @params = params[:query] + @query = params[:query] + handle_ajax end helper_method :index private + def more_results(query, exact_results) + words_in_query = query.scan(/[A-Za-z0-9]+/) + + more_results = words_in_query.flat_map { |word| add_results_for(word) } + related_results = (exact_results + more_results).uniq.map(&:related_searchable_records).flatten + sort(more_results + related_results, related_results, query) - exact_results + end + def add_results_for(query) Person.search(query) + Room.search(query) + - Chair.search(query) + Chair.search(query) + + Course.search(query) end def sort_by_priority(results, query) - matching_tag_results = Room.search_by_tags(query) + words_in_query = query.scan(/[A-Za-z0-9]+/) + matching_tag_results = words_in_query.flat_map { |word| Room.search_by_tags(word) }.uniq results_without_tags = results - matching_tag_results prioritized_results = results @@ -35,14 +44,46 @@ def sort_by_priority(results, query) prioritized_results end - def sort_by_frequency(array) - array = array.tally - array = array.sort_by(&:second).reverse! - array.map(&:first) + def compare + lambda { |tuple1, tuple2| + if tuple1.second == tuple2.second + tuple1.third <=> tuple2.third + else + tuple2.second <=> tuple1.second + end + } + end + + def sort_by_frequency(primary_results, secondary_results) + # If the primary result frequency is the same the results are sorted by + # how often they appear in the related matches (secondary result frequency) in ascending order + primary_results_frequencies = primary_results.tally + secondary_results_frequencies = secondary_results.tally + + results = [] + primary_results_frequencies.each do |key, value| + if secondary_results_frequencies.key?(key) + results.push([key, value, secondary_results_frequencies[key]]) + else + results.push([key, value, 0]) + end + end + + results.sort(&compare).map(&:first) end - def sort(results, query) - results = sort_by_frequency(results) + def sort(more_results, related_results, query) + results = sort_by_frequency(more_results, related_results) sort_by_priority(results, query) end + + def handle_ajax + if params[:ajax].nil? + @full_render = true + elsif params[:ajax] == "search" + render json: { html: render_to_string(partial: "partials/search_results"), search: @query } + elsif params[:ajax] == "map" + render json: { html: render_to_string(partial: "partials/map_js"), search: @query } + end + end end diff --git a/app/javascript/OutdoorMap/geometry.js b/app/javascript/OutdoorMap/geometry.js index f29cdde2..cbb17a7a 100644 --- a/app/javascript/OutdoorMap/geometry.js +++ b/app/javascript/OutdoorMap/geometry.js @@ -949,7 +949,7 @@ export const buildings = JSON.parse(`[ { "type":"Feature", "properties":{ - "name":"Libary", + "name":"Library", "campus":"UP Campus Griebnitzsee", "offset": [-27, 0] }, diff --git a/app/javascript/channels/consumer.js b/app/javascript/channels/consumer.js index 8ec3aad3..b6a99de1 100644 --- a/app/javascript/channels/consumer.js +++ b/app/javascript/channels/consumer.js @@ -1,6 +1,6 @@ // Action Cable provides the framework to deal with WebSockets in Rails. // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. -import { createConsumer } from "@rails/actioncable" +import { createConsumer } from "@rails/actioncable"; -export default createConsumer() +export default createConsumer(); diff --git a/app/javascript/channels/index.js b/app/javascript/channels/index.js index 0cfcf749..fa2c1bcc 100644 --- a/app/javascript/channels/index.js +++ b/app/javascript/channels/index.js @@ -1,5 +1,5 @@ // Load all the channels within this directory and all subdirectories. // Channel files must be named *_channel.js. -const channels = require.context('.', true, /_channel\.js$/) -channels.keys().forEach(channels) +const channels = require.context(".", true, /_channel\.js$/); +channels.keys().forEach(channels); diff --git a/app/javascript/constants.js b/app/javascript/constants.js index 5ff84db9..22588fca 100644 --- a/app/javascript/constants.js +++ b/app/javascript/constants.js @@ -2,7 +2,7 @@ export const standardZoomLevel = 17; export const indoorZoomLevel = 19; export const leafletMapId = "map"; -const hpiRed = "#b0063a" +const hpiRed = "#b0063a"; export const UniPotsdamStyle = { fillColor: "Blue", @@ -45,11 +45,11 @@ export const PoIStyle = { }; export const HighlightedBuildingStyle = { - fillColor: hpiRed, - fillOpacity: 0.65, - color: hpiRed, - opacity: 0.3, -} + fillColor: hpiRed, + fillOpacity: 0.65, + color: hpiRed, + opacity: 0.3, +}; export const styleMap = { "UP Campus Griebnitzsee": UniPotsdamStyle, @@ -57,6 +57,17 @@ export const styleMap = { "Campus II": HPIStyle, "Campus III": HPIStyle, "Studentendorf Stahnsdorfer Straße": DormStyle, - "HighlightedBuilding": HighlightedBuildingStyle, + HighlightedBuilding: HighlightedBuildingStyle, default: UniPotsdamStyle, }; + +export const redMarkerIcon = new L.Icon({ + iconUrl: + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 7ff21332..162cab1f 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -3,23 +3,23 @@ // a relevant structure within app/javascript and only use these pack files to reference // that code so it'll be compiled. -import Rails from "@rails/ujs" -import Turbolinks from "turbolinks" -import * as ActiveStorage from "@rails/activestorage" -import "channels" +import Rails from "@rails/ujs"; +import Turbolinks from "turbolinks"; +import * as ActiveStorage from "@rails/activestorage"; +import "channels"; // Import Bootstrap in the webpack entry point file -import 'bootstrap'; +import "bootstrap"; // Fontawesome: https://fontawesome.com/ import "@fortawesome/fontawesome-free/js/all"; // Import leaflet -import L from 'leaflet'; -import 'leaflet.locatecontrol'; -import 'leaflet-routing-machine'; +import L from "leaflet"; +import "leaflet.locatecontrol"; +import "leaflet-routing-machine"; -window.L = L +window.L = L; -Rails.start() -Turbolinks.start() -ActiveStorage.start() +Rails.start(); +Turbolinks.start(); +ActiveStorage.start(); diff --git a/app/javascript/packs/form/image_field.js b/app/javascript/packs/form/image_field.js index 5ca907cd..c51c639a 100644 --- a/app/javascript/packs/form/image_field.js +++ b/app/javascript/packs/form/image_field.js @@ -1,20 +1,18 @@ - -$().ready( () => { +$().ready(() => { // Open picker on click $(".image-edit-btn").on("click", () => { $(".image-field").click(); - }) + }); // Update preview $(".image-field").on("change", (e) => { - console.log(e) - const file = e.target.files[0] - if(!file) return; - const reader = new FileReader(); - reader.onload = function (e) { - $(".image-preview img").attr('src', e.target.result); - } - reader.readAsDataURL(file); - $(".image-edit-btn").blur(); - }) -}) \ No newline at end of file + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = function (e) { + $(".image-preview img").attr("src", e.target.result); + }; + reader.readAsDataURL(file); + $(".image-edit-btn").blur(); + }); +}); diff --git a/app/javascript/packs/indoor_map_builder.js b/app/javascript/packs/indoor_map_builder.js index dc3d024f..45759527 100644 --- a/app/javascript/packs/indoor_map_builder.js +++ b/app/javascript/packs/indoor_map_builder.js @@ -1,10 +1,13 @@ -import { IndoorStyle } from '../constants'; +import { IndoorStyle } from '../constants' +import { startNavigation } from './outdoor_map_builder' +function buildRoomLayer (room) { + if (mymap == null) + throw Error('Map not initialized before buildRoomLayer was called.') -const buildRoomLayer = (room) => { const roomLayer = L.geoJSON(room.geoJson, { style: IndoorStyle, pane: 'rooms', - }); + }) const roomTooltip = L.tooltip({ permanent: true, @@ -12,54 +15,184 @@ const buildRoomLayer = (room) => { className: 'marker_label', offset: L.point(0, 0), direction: 'center', - }); - roomTooltip.setContent(room.fullName); + }) + roomTooltip.setContent(room.fullName) + roomLayer.bindTooltip(roomTooltip) + roomLayer.addEventListener('click', (event) => { + // TODO: fix routing error in console + showRoomPopup(room.id) + }) - roomLayer.bindPopup( - `${room.fullName}` - ); + layers[room.fullName] = roomLayer - roomLayer.bindTooltip(roomTooltip); - roomLayer.addEventListener('click', (event) => { - console.log(event); - }); + return roomLayer +} + +export function showRoomPopup(roomId){ + const popupRootNode = document.getElementById("popup_root") + if(popupRootNode.hasChildNodes){ + try{ + + const children = [...popupRootNode.childNodes] + console.log("Popup root node has child nodes. Removing them..", children) + if(children){ + children.forEach(c => popupRootNode.removeChild(c)) + } + } + catch(e){ + console.error("Failed removing child node", e) + } + } + const routingNode = document.getElementById('map-navigation-popup') + if(routingNode){ + routingNode.style.display = "none" + } - return L.layerGroup().addLayer(roomLayer); -}; + const element = document.createElement('div') + element.innerHTML = `
+
+ ⏳ Loading +
` + popupRootNode.appendChild(element) -const buildFloorLayer = (floor) => { - // Add FloorLayer to layers - const floorLayer = L.layerGroup(); - layers[floor.name] = floorLayer; + fetch('/map/room_popup/' + roomId) + .then(function (response) { + return response.text() + }) + .then(function (html) { + element.innerHTML = html + const navBtn = element.querySelector('#navigate_btn') + navBtn.onclick = function () { + console.log('Starting navigation', room) + popupRootNode.removeChild(element) + startNavigation() + } + }) + .catch(function (err) { + console.warn('Sum ting went wrong.', err) + element.innerHTML = 'Sum ting went wrong: \n' + err + }) +} +function buildFloorLayer (floor) { + // Add FloorLayer to layers + const floorLayer = L.layerGroup() + layers[floor.name] = floorLayer // Add all rooms floor.rooms.forEach((room) => { - const roomLayer = buildRoomLayer(room); - layers[floor.name].addLayer(roomLayer); - }); + const roomLayer = buildRoomLayer(room) + layers[floor.name].addLayer(roomLayer) + }) + return floorLayer +} - return floorLayer; -}; - -export const buildIndoorMap = () => { - console.log('[INDOOR] Indoor map start'); +export function buildIndoorMap(){ + console.log('[INDOOR] Indoor map start') if (mymap == null) { - console.error('Expected mymap, but "mymap" is null.'); + console.error('Expected mymap, but "mymap" is null.') } else if (window.floorsToBuild == null) { console.error( 'Expected to receive floors to build, but "floorsToBuild" is null.' - ); + ) } else { - mymap.createPane('rooms'); + mymap.createPane('rooms') - const floorLayers = {}; - window.floorsToBuild.forEach((floor) => { - floorLayers[floor.name] = buildFloorLayer(floor); - }); - L.control.layers(floorLayers, null).addTo(mymap); - } - console.log('[INDOOR] Indoor map done'); -}; + let floorLayers = {} + window.floorsToBuild.forEach((building) => { + building.floors.forEach((floor) => { + floorLayers[floor.name] = buildFloorLayer(floor) + }) + }) + // Needed for adding the red box + const temp = {} + let alreadyActivatedBuilding = [] + for (const key in floorLayers) { + // loop through the rooms of the current floor and check if the selected room is there by making an array of booleans + // needs to be an array of bools since window.floorsToBuild[counter].rooms is an object and not an array so I cannot just + // check if it includes a certain value + for (let i = 0; i < window.floorsToBuild.length; i++) { + window.floorsToBuild[i].floors.forEach((floor) => { + if (floor.name === key) { + const result = floor.rooms.map( + (room) => room.fullName === gon.selected_room_name + ) + if (gon.selected_room_name) { + // if there is a selected room then activate only the layer where this room is and make it red + if (result.includes(true)) { + temp[ + `${key}` + ] = floorLayers[key] + // activating the layer with the selected room by default + mymap.addLayer( + temp[ + `${key}` + ] + ) + } else { + temp[`${key}`] = floorLayers[key] + } + } else { + // if there is no selected room then activate one layer for every building + temp[`${key}`] = floorLayers[key] + if ( + !alreadyActivatedBuilding.includes( + window.floorsToBuild[i].building + ) + ) { + // if there is no floor activated for this building already, activate the current one + alreadyActivatedBuilding.push(window.floorsToBuild[i].building) + mymap.addLayer(temp[`${key}`]) + } + } + } + }) + } + } + floorLayers = temp + + // converts every string to snake case (that_is_this_case) + function snakeCase (string) { + return string + .replace(/\W+/g, ' ') + .split(/ |\B(?=[A-Z])/) + .map((word) => word.toLowerCase()) + .join('_') + } -buildIndoorMap(); + // creating a different controller for every building + for (let i = 0; i < window.floorsToBuild.length; i++) { + const tempFloors = [] + // getting all floor names of the current building + window.floorsToBuild[i].floors.forEach((floor) => { + tempFloors.push(floor.name) + }) + const tempLayers = [] + for (const key in floorLayers) { + var doc = new DOMParser().parseFromString(key, 'text/xml') + const result = doc.firstChild.innerHTML // taking the content of the tag + if (tempFloors.includes(result)) tempLayers[key] = floorLayers[key] + } + const container = L.control + .layers(tempLayers, null, { + collapsed: false, + }) + .addTo(mymap) + .getContainer() + + container.classList.add('buildings_control_container') + container.children[1].firstElementChild.setAttribute( + 'class', + `${snakeCase(window.floorsToBuild[i].building)}_controller` + ) // Needed for adding this Header + $(`
${window.floorsToBuild[i].building}
`).insertBefore( + `div.${snakeCase(window.floorsToBuild[i].building)}_controller` + ) + } + } + // hiding all contollers intially since the map is not zoomed in + document + .querySelectorAll('.buildings_control_container') + .forEach((el) => (el.style.display = 'none')) + console.log('[INDOOR] Indoor map done') +} diff --git a/app/javascript/packs/outdoor_map_builder.js b/app/javascript/packs/outdoor_map_builder.js index 381a00ae..63c3f2cc 100644 --- a/app/javascript/packs/outdoor_map_builder.js +++ b/app/javascript/packs/outdoor_map_builder.js @@ -5,20 +5,44 @@ import { PoIStyle, standardZoomLevel, styleMap, -} from '../constants'; -import { buildings } from '../OutdoorMap/geometry'; -var pointInPolygon = require('point-in-polygon') + redMarkerIcon +} from "../constants"; +import { buildings } from "../OutdoorMap/geometry"; +import { showRoomPopup, buildIndoorMap } from "./indoor_map_builder"; + +function buildSearchResultMarkers() { + if (layers["Search Results"]) { + layers["Search Results"].clearLayers(); + } + layers["Search Results"] = L.layerGroup().addTo(mymap); + for (const result of window.searchResults) { + const layer = L.geoJSON(result.geoJson); + const center = layer.getBounds().getCenter(); + + const marker = L.marker(center, { icon: redMarkerIcon }); + marker.addEventListener('click', (event) => { + showRoomPopup(result.id) + }); + layers["Search Results"].addLayer(marker); + } + if (window.searchResults?.length) { + const searchResultsMarkers = L.featureGroup(layers["Search Results"].getLayers()); + mymap.fitBounds(searchResultsMarkers.getBounds().pad(0.5)); + } +} + +var pointInPolygon = require("point-in-polygon"); -console.log('[MAP] Pre map init'); +console.log('[MAP] Pre map init') // If the map object still exists, initiate a new one // (relevant, when the user navigates back and the old page is cached) -if (window.mymap) window.mymap.remove(); +if (window.mymap) window.mymap.remove() // Set the leaflet map with center and zoom-level -window.mymap = L.map('map').setView([52.393, 13.129], standardZoomLevel); +window.mymap = L.map('map').setView([52.393, 13.129], standardZoomLevel) -console.log('[MAP] Map init done'); +console.log('[MAP] Map init done') // Tileserver to be used as background L.tileLayer( @@ -35,22 +59,22 @@ L.tileLayer( attribution: '© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors | Schnavigator', } -).addTo(mymap); +).addTo(mymap) -console.log('[MAP] Tile layer done'); +console.log('[MAP] Tile layer done') -L.control +var lc = L.control .locate({ locateOptions: { watch: true, enableHighAccuracy: true, }, }) - .addTo(mymap); + .addTo(mymap) -window.layers = {}; +window.layers = {} -mymap.createPane('buildings'); +mymap.createPane('buildings') let campusNames = [] @@ -59,173 +83,311 @@ let campusNames = [] for (const feature of buildings) { // If the current campus (=group of buildings) is unknown, create a layergroup for it if (!layers[feature.properties.campus]) { - layers[feature.properties.campus] = L.layerGroup().addTo(mymap); - campusNames.push(feature.properties.campus); + layers[feature.properties.campus] = L.layerGroup().addTo(mymap) + campusNames.push(feature.properties.campus) } // Determine Style (highlighting-colour) dependent of group - let layerStyle = styleMap[feature.properties.campus] ?? styleMap['default']; + let layerStyle = styleMap[feature.properties.campus] ?? styleMap['default'] // Add the building as a layer - const layer = L.geoJSON(feature, { style: layerStyle, pane: 'buildings' }); + const layer = L.geoJSON(feature, { style: layerStyle, pane: 'buildings' }) // Add a tooltip displaying the name of the building, taken from the GeoJSON layer.bindTooltip(feature.properties.name, { permanent: true, className: 'marker_label', offset: feature.properties.offset, direction: 'right', - }); + }) // Add the building to its campus layergroup - layers[feature.properties.campus].addLayer(layer); + layers[feature.properties.campus].addLayer(layer) } -layers['Points of Interest'] = L.layerGroup().addTo(mymap); -for (const feature of points_of_interest) { +layers["Points of Interest"] = L.layerGroup().addTo(mymap); + +for (const feature of gon.points_of_interest) { let layerStyle; switch (feature.properties.type) { case 'Entrance': - layerStyle = EntranceStyle; - break; + layerStyle = EntranceStyle + break default: - layerStyle = PoIStyle; + layerStyle = PoIStyle } - const layer = L.geoJSON(feature); + const layer = L.geoJSON(feature) layer.bindTooltip(feature.properties.name, { permanent: true, className: 'marker_label', offset: [-14, 0], direction: 'right', - }); - layer.bindPopup(feature.properties.description); - layers['Points of Interest'].addLayer(layer); + }) + layer.bindPopup(feature.properties.description) + layers['Points of Interest'].addLayer(layer) } +buildSearchResultMarkers() + +// hiding and showing the control for the floor selection +mymap.on('zoomend', function () { + if (mymap.getZoom() > indoorZoomLevel) { + document + .querySelectorAll('.buildings_control_container') + .forEach((el) => (el.style.display = 'block')) + } else { + document + .querySelectorAll('.buildings_control_container') + .forEach((el) => (el.style.display = 'none')) + } +}) + // make names disappear when zoomed out -var lastZoom; +var lastZoom mymap.on('zoomend', function () { - var zoom = mymap.getZoom(); - if ((zoom < standardZoomLevel || zoom > indoorZoomLevel) && - (!lastZoom || lastZoom >= standardZoomLevel || lastZoom <= indoorZoomLevel)) { - mymap.removeLayer(layers['Points of Interest']); + var zoom = mymap.getZoom() + if ( + (zoom < standardZoomLevel || zoom > indoorZoomLevel) && + (!lastZoom || lastZoom >= standardZoomLevel || lastZoom <= indoorZoomLevel) + ) { + mymap.removeLayer(layers['Points of Interest']) mymap.eachLayer(function (layer) { if (layer.getTooltip()) { - const tooltip = layer.getTooltip(); + const tooltip = layer.getTooltip() if (layer.options.pane === 'buildings') { - layer.closeTooltip(tooltip); + layer.closeTooltip(tooltip) if (zoom > indoorZoomLevel) { layer.setStyle({ ...layer.options.style, fillOpacity: 0.0, - }); + }) } } else if (zoom > indoorZoomLevel) { - layer.openTooltip(tooltip); - layer.setStyle(IndoorStyle); + layer.openTooltip(tooltip) + layer.setStyle(IndoorStyle) } } - }); - } else if (zoom >= standardZoomLevel && zoom <= indoorZoomLevel && - (!lastZoom || lastZoom < standardZoomLevel || lastZoom > indoorZoomLevel)) { - mymap.addLayer(layers['Points of Interest']); + }) + } else if ( + zoom >= standardZoomLevel && + zoom <= indoorZoomLevel && + (!lastZoom || lastZoom < standardZoomLevel || lastZoom > indoorZoomLevel) + ) { + mymap.addLayer(layers['Points of Interest']) mymap.eachLayer(function (layer) { if (layer.getTooltip()) { - const tooltip = layer.getTooltip(); + const tooltip = layer.getTooltip() if (layer.options.pane === 'buildings') { - layer.openTooltip(tooltip); + layer.openTooltip(tooltip) layer.setStyle({ ...layer.options.style, - }); + }) } else { - layer.closeTooltip(tooltip); + layer.closeTooltip(tooltip) layer.setStyle({ ...IndoorStyle, color: 'rgba(0,0,0,0)', - }); + }) } } - }); + }) } - lastZoom = zoom; - setStyleForHighlightedBuilding(); -}); + lastZoom = zoom + setStyleForHighlightedBuilding() +}) -L.control.layers(null, layers).addTo(mymap); +L.control.layers(null, layers).addTo(mymap) -console.log('[MAP] Layers built'); +console.log('[MAP] Layers built') -// TomTom Routing API-key: peRlaISfnHGUKWZpRw4O11yc3B4Ay2t5 -// mapbox API key sk.eyJ1IjoicHZpaSIsImEiOiJja3g1MnhkdGQxMTlzMm5xa3FpNzlrcHYxIn0.ZX0lMZW2IofVpmIJQtHUmA +function allBuildings() { + var results = [] + for (const campus of campusNames) { + let buildingsOfCampus = layers[campus] + results.push(...buildingsOfCampus.getLayers()) + } + return results +} -// mapbox token +function buildingsByQuery(query) { + return allBuildings().filter((buildingLayer) => { + // The HPIGeocoder and the highlighting of buildings rely on this fact, which happens to always be the case. + // Please inform someone if this warning occurs :) + if (buildingLayer.getLayers().length > 1) { + console.log( + 'WARNING: Not only one inner building layer in:', + buildingLayer + ) + } + const innerBuildingLayer = buildingLayer.getLayers()[0] + const buildingInfo = innerBuildingLayer.feature + return ( + buildingInfo.properties.name && + buildingInfo.properties.name.toLowerCase().indexOf(query.toLowerCase()) > + -1 + ) + }) +} -console.log(window.location.host + '/directions'); +function buildingAtLocation(location) { + // different representation of the position for the pointInPolygon-method + const locationArray = [location.lng, location.lat] + for (const buildingLayer of allBuildings()) { + const innerBuildingLayer = buildingLayer.getLayers()[0] + const buildingInfo = innerBuildingLayer.feature + const polygonCoordinates = buildingInfo.geometry.coordinates[0] + if (pointInPolygon(locationArray, polygonCoordinates)) return buildingLayer + // The next line ensures that the buildings are highlighted and properly geocoded, + // when they are being routed to. This is because we route to the center of the building. + if (innerBuildingLayer.getCenter().toBounds(1).contains(location)) + return buildingLayer + } + return undefined +} + +const originalGeocoder = new L.Control.Geocoder.nominatim() + +const HPIGeocoder = { + getHPISuggestions(query) { + return buildingsByQuery(query).map((buildingLayer) => { + const innerBuildingLayer = buildingLayer.getLayers()[0] + const buildingInfo = innerBuildingLayer.feature + return { + name: buildingInfo.properties.name, + center: innerBuildingLayer.getCenter(), + bbox: innerBuildingLayer.getBounds(), + } + }) + }, + + buildingInfoAtLocation(location) { + if (!location) return undefined + const buildingLayer = buildingAtLocation(location) + if (!buildingLayer) return undefined + const innerBuildingLayer = buildingLayer.getLayers()[0] + const buildingInfo = innerBuildingLayer.feature + return { + name: buildingInfo.properties.name, + center: innerBuildingLayer.getCenter(), + bbox: innerBuildingLayer.getBounds(), + } + }, + + /** + * Performs a geocoding query and returns the results to the callback in the provided context + * @param query the query + * @param cb the callback function + * @param context the this context in the callback + */ + geocode(query, cb, context) { + var that = this + // This construction is used to append further suggestions to the ones given by the originalGeocoder + var callbackProxy = function (originalSuggestions) { + var hpiSuggestions = that.getHPISuggestions(query) + cb.call(context, hpiSuggestions.concat(originalSuggestions)) + } + originalGeocoder.geocode(query, callbackProxy, context) + }, + + /** + * Performs a geocoding query suggestion (this happens while typing) and returns the results to the callback in the provided context + * @param query the query + * @param cb the callback function + * @param context the this context in the callback + */ + suggest(query, cb, context) { + cb.call(context, this.getHPISuggestions(query)) + }, + + /** + * Performs a reverse geocoding query and returns the results to the callback in the provided context + * @param location the coordinate to reverse geocode + * @param scale the map scale possibly used for reverse geocoding + * @param cb the callback function + * @param context the this context in the callback + */ + reverse(location, scale, cb, context) { + const buildingInfo = this.buildingInfoAtLocation(location) + + if (buildingInfo) { + cb.call(context, [buildingInfo]) + } else { + originalGeocoder.reverse(location, scale, cb, context) + } + }, +} // routingControl does everything related to navigation window.routingControl = L.Routing.control({ - // the router is responsible for calculating the route + // the router is responsible for calculating the route router: new Router({ serviceUrl: window.location.origin + '/directions', useHints: false, profile: 'walking', routingOptions: { - 'walkway_bias': 1, - 'walking_speed': 5 + walkway_bias: 1, + walking_speed: 5, + }, + geocoder: L.Control.Geocoder.nominatim(), + }), + // the plan is the window on the right-hand side of the map with the search-bar, stop-button and overview and steps of the current navigation + plan: L.Routing.plan([], { + createMarker: function (i, wp) { + return L.marker(wp.latLng, { + draggable: true, + icon: L.icon.glyph({ glyph: String.fromCharCode(65 + i) }), + }) }, + geocoder: HPIGeocoder, }), - // the plan is the window on the right-hand side of the map with the search-bar, stop-button and overview and steps of the current navigation - plan: L.Routing.plan([], { - createMarker: function(i, wp) { - return L.marker(wp.latLng, { - draggable: true, - icon: L.icon.glyph({ glyph: String.fromCharCode(65 + i) }) - }); - }, - geocoder: L.Control.Geocoder.nominatim() - }), - collapsible: true, - show: false, - routeWhileDragging: true, - autoRoute: false, + collapsible: true, + show: false, + routeWhileDragging: true, + autoRoute: false, lineOptions: { - styles: [{ color: 'blue' }] - } -}).addTo(mymap) -// when routing call happens, there will be the stop button in the navigation plan -.on('routingstart', (e)=>{ - console.log("routing start"); - document.getElementById('StopNavigation').style.display = 'block'; - document.getElementsByClassName('leaflet-routing-alternatives-container')[0].style.display = 'block'; - document.getElementById('mobile-view-welcome-routing-text').style.display = 'none'; - document.getElementsByClassName('leaflet-routing-geocoders')[0].style.width = '50%'; - if (document.getElementById('map-popup')) document.getElementById('map-popup').style.display = 'none'; - document.getElementById('map-navigation-popup').style.display = "block" + styles: [{ color: 'blue' }], + }, }) -.on('waypointschanged', (e)=>{ - console.log("waypointschanged"); - // we only highlight the destination of the current navigation route - changeHighlightedBuilding(routingControl.getWaypoints()[1].latLng); - - // this handler is called whenever the waypoints are changed in any way (search bar or clicking in the map) - routingControl.show() - // always calculate the route to show the 'A' marker if only one waypoint is set - routingControl.route() -}); + .addTo(mymap) + // when routing call happens, there will be the stop button in the navigation plan + .on('routingstart', (e) => { + document.getElementById('StopNavigation').style.display = 'block' + document.getElementsByClassName( + 'leaflet-routing-alternatives-container' + )[0].style.display = 'block' + document.getElementById('mobile-view-welcome-routing-text').style.display = + 'none' + document.getElementsByClassName( + 'leaflet-routing-geocoders' + )[0].style.width = '50%' + const roomPopup = document.getElementById('room_popup') + if (roomPopup) roomPopup.style.display = 'none' + document.getElementById('map-navigation-popup').style.display = 'block' + }) + .on('waypointschanged', (e) => { + // we only highlight the destination of the current navigation route + changeHighlightedBuilding(routingControl.getWaypoints()[1].latLng); + + // this handler is called whenever the waypoints are changed in any way (search bar or clicking in the map) + routingControl.show() + + // always calculate the route to show the 'A' marker if only one waypoint is set + routingControl.route(); + }) let highlightedBuilding = null // highlight the building and only show the outline if we are in the "Indoor-Mode" function setStyleForHighlightedBuilding() { - if(highlightedBuilding) { - highlightedBuilding.setStyle(styleMap["HighlightedBuilding"]); - var zoom = mymap.getZoom(); + if (highlightedBuilding) { + highlightedBuilding.setStyle(styleMap['HighlightedBuilding']) + var zoom = mymap.getZoom() if (zoom > indoorZoomLevel) { highlightedBuilding.setStyle({ fillOpacity: 0.0, - }); + }) } } } @@ -233,98 +395,92 @@ function setStyleForHighlightedBuilding() { function changeHighlightedBuilding(position) { // reset the style of the previously highlighted building, if available // make sure to respect the zoom level and only show the outline if we are in the "Indoor-Mode" - if(highlightedBuilding) { - const id = highlightedBuilding._leaflet_id-1; - highlightedBuilding.setStyle(styleMap[highlightedBuilding._layers[id].feature.properties.campus]); - var zoom = mymap.getZoom(); - if ((zoom < standardZoomLevel || zoom > indoorZoomLevel)) { - highlightedBuilding.setStyle({ - fillOpacity: 0.0, - }); - } + if (highlightedBuilding) { + const id = highlightedBuilding._leaflet_id - 1 + highlightedBuilding.setStyle( + styleMap[highlightedBuilding._layers[id].feature.properties.campus] + ) + var zoom = mymap.getZoom() + if (zoom < standardZoomLevel || zoom > indoorZoomLevel) { + highlightedBuilding.setStyle({ + fillOpacity: 0.0, + }) + } } // reset the highlighted building to be undefined - highlightedBuilding = null; + highlightedBuilding = null // if no new position was provided, do nothing - if(!position) { - return; - } - // different representation of the position for the pointInPolygon-method - position = [position.lng, position.lat]; - - // set the new style for the clicked destination - for (const campus of campusNames) { - let buildingsOfCampus = layers[campus]._layers; - - for (const id in buildingsOfCampus) { - const buildingId = Number(id) - const polygonCoordinates = buildingsOfCampus[buildingId]._layers[buildingId-1].feature.geometry.coordinates[0]; - - // if our point is within the building polygon, we change it's style and remember it as the currently highlighted building - if(pointInPolygon(position, polygonCoordinates)) { - highlightedBuilding = buildingsOfCampus[buildingId]; - setStyleForHighlightedBuilding() - return; - } - } + if (!position) { + return } + + const buildingLayer = buildingAtLocation(position) + highlightedBuilding = buildingLayer + setStyleForHighlightedBuilding() + return } -function buildNavigationButton(){ +function buildNavigationButton() { const el = document.createElement('div') - el.className = 'leaflet-navigation-button leaflet-control leaflet-control-layers'; + el.className = + 'leaflet-navigation-button leaflet-control leaflet-control-layers' el.id = 'leaflet-navigation-button' - el.innerHTML = ` - ` document.querySelector('.leaflet-right').appendChild(el) -}; -buildNavigationButton(); +} +buildNavigationButton() -document.getElementsByClassName('leaflet-routing-collapse-btn')[0].style.display = 'none' +document.getElementsByClassName( + 'leaflet-routing-collapse-btn' +)[0].style.display = 'none' // move rounting container to map-navigation-popup -let element = document.getElementsByClassName('leaflet-routing-container')[0]; -let parent = element.parentNode; -let targetDiv = document.getElementById('routing-controller'); -targetDiv.appendChild(element); - +let element = document.getElementsByClassName('leaflet-routing-container')[0] +let parent = element.parentNode +let targetDiv = document.getElementById('routing-controller') +targetDiv.appendChild(element) -function navigateTo(position) { +export function showMarker(position) { // .locate() function returns map, so chaining works - mymap.locate() - .off('locationfound') - .on('locationfound', function(e){ - routingControl.setWaypoints([e.latlng, position]) - }) - .off('locationerror') - .on('locationerror', function(e){ - console.log("location error. Use previous start point for navigation") - const previousStart = routingControl.getWaypoints()[0] - routingControl.setWaypoints([previousStart, position]) - }); + mymap + .locate() + .off('locationfound') + .on('locationfound', function (e) { + routingControl.setWaypoints([e.latlng, position]) + }) + .off('locationerror') + .on('locationerror', function (e) { + console.log('location error. Use previous start point for navigation') + const previousStart = routingControl.getWaypoints()[0] + routingControl.setWaypoints([previousStart, position]) + }) +} +export function startNavigation() { + routingControl.route() } function onMapClick(e) { - navigateTo(e.latlng) + showMarker(e.latlng) } // Build the stop buton and insert it into the routingControl-plan function buildStopButton() { - const el = document.createElement('div') - el.className = 'leaflet-routing-geocoder-stop'; - el.innerHTML = ` - ` - // Do not render the '+' button that can be used to add waypoints - document.querySelector('.leaflet-routing-add-waypoint').style.display = 'none' - // Add our Stop button to the routingControl-plan - document.querySelector('.leaflet-routing-geocoders').appendChild(el) -}; -buildStopButton(); + // Do not render the '+' button that can be used to add waypoints + document.querySelector('.leaflet-routing-add-waypoint').style.display = 'none' + // Add our Stop button to the routingControl-plan + document.querySelector('.leaflet-routing-geocoders').appendChild(el) +} +buildStopButton() + +mymap.on('click', onMapClick) -mymap.on('click', onMapClick); +function onLocationFound(e) { + routingControl.setWaypoints([ + e.latlng, + L.latLng(gon.coordinates[0].lat, gon.coordinates[0].lng), + ]); +} +if (gon.coordinates != null) { + mymap.on("locationfound", onLocationFound); + lc.start(); +} // Per default, we don't want the stop button to be shown, as there is no route -document.getElementById('StopNavigation').style.display = 'none'; + +document.getElementById("StopNavigation").style.display = "none"; + +window.buildSearchResultMarkers = buildSearchResultMarkers + +buildIndoorMap() \ No newline at end of file diff --git a/app/javascript/packs/router.js b/app/javascript/packs/router.js index e97e830a..200c47ef 100644 --- a/app/javascript/packs/router.js +++ b/app/javascript/packs/router.js @@ -1,14 +1,18 @@ Router = L.Routing.OSRMv1.extend({ - buildRouteUrl: function (waypoints, options) { - const waypointsCopy = waypoints.map(v => { - return { - latLng: { - lat: String(v.latLng.lat).replaceAll('.', 'p'), - lng: String(v.latLng.lng).replaceAll('.', 'p') - } - } - }); - url = L.Routing.OSRMv1.prototype.buildRouteUrl.call(this, waypointsCopy, options); - return url.replaceAll(',', '%2C') - } -}) \ No newline at end of file + buildRouteUrl: function (waypoints, options) { + const waypointsCopy = waypoints.map((v) => { + return { + latLng: { + lat: String(v.latLng.lat).replaceAll(".", "p"), + lng: String(v.latLng.lng).replaceAll(".", "p"), + }, + }; + }); + url = L.Routing.OSRMv1.prototype.buildRouteUrl.call( + this, + waypointsCopy, + options + ); + return url.replaceAll(",", "%2C"); + }, +}); diff --git a/app/javascript/packs/search.js b/app/javascript/packs/search.js index 7ce11ed7..8bc57b16 100644 --- a/app/javascript/packs/search.js +++ b/app/javascript/packs/search.js @@ -1,13 +1,88 @@ function textEntered(field) { - if (!field) { - field = document.getElementById("search"); - } + clearTimeout(this.timeout) + let search = true; + if (!field) { + field = document.getElementById("search"); + search = false; + } + localStorage.setItem('query', field.value ?? ''); + updateClearButtonStyle(field.value) + if (search) { + this.timeout = setTimeout(() => { + sendRequest(field); + }, 450) + } +} + +function updateClearButtonStyle(value){ const btn = document.getElementById("cancel"); - if (field.value === "") { + if (value === "") { btn.style.visibility = "hidden"; } else { btn.style.visibility = "visible"; } } -window.textEntered = textEntered \ No newline at end of file +function clearText(btn) { + let field = document.getElementById("search"); + field.value = ""; + localStorage.setItem('query', ''); + sendRequest(field); + btn.style.visibility = "hidden"; +} + +function toggleCenterClass(flag) { + let searchDiv = document.getElementById("search-div"); + let platypus = document.getElementById("platypus"); + + if (flag) { + searchDiv.classList.remove("center"); + platypus.style.display = "none"; + } else { + let search = document.getElementById("search"); + if (!search.value) { + searchDiv.classList.add("center"); + platypus.style.display = "block"; + } + } +} + +function sendRequest(field) { + let page = window.location.href.split("/")[3].split("?")[0] + $.ajax({ + url: "/search?ajax=" + page + "&query=" + encodeURIComponent(field.value), + type: "get", + beforeSend: function () { + }, + complete: function () { + }, + success: function (json) { + $("#results").html(json.html); + if (page === "map") { + window.buildSearchResultMarkers() + document.url = "/map?query=" + encodeURIComponent(json.search) + } else { + if (json.search === "") { + document.title = page.charAt(0).toUpperCase() + page.slice(1) + " –" + document.title.split("–")[1]; + document.url = "/" + page + } else { + document.title = "Results for " + json.search + " –" + document.title.split("–")[1]; + document.url = "/" + page + "?query=" + encodeURIComponent(json.search) + } + } + window.history.pushState({"html": document.html, "pageTitle": document.title}, "", document.url); + }, + error: function (xhr, ajaxOptions, thrownError) { + window.alert("Sorry, your request could not be processed, please contact an administrator if the problem persists.\n" + thrownError) + console.log("further Ajax Error Information:") + console.log(ajaxOptions) + console.log(xhr) + } + }); +} + +window.textEntered = textEntered; +window.toggleCenterClass = toggleCenterClass; +window.clearText = clearText; +window.sendRequest = sendRequest; + diff --git a/app/javascript/packs/search_button.js b/app/javascript/packs/search_button.js new file mode 100644 index 00000000..82d23829 --- /dev/null +++ b/app/javascript/packs/search_button.js @@ -0,0 +1,6 @@ +function redirectWithQuery(path) { + const query = localStorage.getItem('query'); + window.location.href = path + ((query) ? '?query=' + query : ''); + } + +window.redirectWithQuery = redirectWithQuery \ No newline at end of file diff --git a/app/javascript/packs/selected_room_marker.js b/app/javascript/packs/selected_room_marker.js index 1c66f7f1..9269b26c 100644 --- a/app/javascript/packs/selected_room_marker.js +++ b/app/javascript/packs/selected_room_marker.js @@ -1,15 +1,16 @@ -const {indoorZoomLevel} = require("../constants"); +const { indoorZoomLevel } = require("../constants"); // Timeout to make sure that init of other code is done // TODO: Find nicer solution (e.g. JS CustomEvent) setTimeout(() => { - console.log('[SELECTED_ROOM] Selected room start'); - const group = layers[`${selected_room_name}`]; - // TODO: Add message? - if(!group) return; + console.log('[SELECTED_ROOM] Selected room start'); + const room = layers[`${gon.selected_room_name}`]; + + // TODO: Add message? + if(!room) return; - const coordinates = group.getLayers()[0].getBounds().getCenter(); - L.marker(coordinates).addTo(mymap); - mymap.setView(coordinates, indoorZoomLevel); - console.log('[SELECTED_ROOM] Selected room done'); -}, 100); \ No newline at end of file + const coordinates = room.getBounds().getCenter(); + L.marker(coordinates).addTo(mymap); + mymap.setView(coordinates, indoorZoomLevel + 1); + console.log('[SELECTED_ROOM] Selected room done'); +}, 100); diff --git a/app/models/chair.rb b/app/models/chair.rb index d8646b7d..61e2d7d5 100644 --- a/app/models/chair.rb +++ b/app/models/chair.rb @@ -5,10 +5,14 @@ class Chair < SearchableRecord has_and_belongs_to_many :people has_and_belongs_to_many :rooms - def to_string + def to_s name end + def related_searchable_records + people + rooms + end + def self.searchable_attributes ["name"] end diff --git a/app/models/course.rb b/app/models/course.rb index b74e65d8..58915f02 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -1,7 +1,27 @@ -class Course < ApplicationRecord +# The model representing a course held at HPI +class Course < SearchableRecord validates :name, presence: true has_and_belongs_to_many :people has_many :course_times, dependent: :destroy belongs_to :room, optional: true + has_one_attached :image, dependent: :destroy + + PLACEHOLDER_IMAGE_LINK = "placeholder_course.png".freeze + + def displayed_tags + [module_category] + end + + def to_s + name + end + + def self.searchable_attributes + %w[name module_category] + end + + def image_or_placeholder + image.attached? ? image : PLACEHOLDER_IMAGE_LINK + end end diff --git a/app/models/course_time.rb b/app/models/course_time.rb index b0f0385d..4570d9b8 100644 --- a/app/models/course_time.rb +++ b/app/models/course_time.rb @@ -1,7 +1,12 @@ +# The model representing a time for a course class CourseTime < ApplicationRecord validates :weekday, presence: true validates :start_time, presence: true validates :end_time, presence: true belongs_to :course + + def full_time + "#{weekday}: #{start_time} - #{end_time}" + end end diff --git a/app/models/person.rb b/app/models/person.rb index 5186e340..96f44b27 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -24,10 +24,18 @@ def full_name "#{title} #{name}" end - def to_string + def to_s full_name end + def related_searchable_records + if room + chairs + [room] + else + chairs + end + end + def self.searchable_attributes %w[title first_name last_name status] end diff --git a/app/models/point_of_interest.rb b/app/models/point_of_interest.rb index f359f5f7..b2fbbb61 100644 --- a/app/models/point_of_interest.rb +++ b/app/models/point_of_interest.rb @@ -3,6 +3,13 @@ class PointOfInterest < ApplicationRecord validates :name, presence: true belongs_to :point + has_one_attached :image, dependent: :destroy + + PLACEHOLDER_IMAGE_LINK = "placeholder_poi.png".freeze + + def image_or_placeholder + image.attached? ? image : PLACEHOLDER_IMAGE_LINK + end def to_geojson { diff --git a/app/models/room.rb b/app/models/room.rb index f3ddd2ab..06caad52 100644 --- a/app/models/room.rb +++ b/app/models/room.rb @@ -25,16 +25,30 @@ def init self.outer_shape ||= Polyline.new # if no outer shape exists yet, create an empty one end - def to_string + def to_s name end + def related_searchable_records + people + chairs + end + def to_geojson walls.map(&:to_geojson) + points.map(&:to_geojson) + [ outer_shape.to_geojson.merge({ properties: { class: "outer-shape" } }) ] end + def to_navigation + # get coordinates of room and calculate the center of mass return this for navigation + geojson = to_geojson.first[:geometry] + coordinates = geojson[:type] == 'LineString' ? geojson[:coordinates] : geojson[:coordinates].first + coordinate = coordinates.transpose.map do |c| + c.sum / c.size + end + "#{coordinate.first.to_s.tr('.', 'p')},#{coordinate.second.to_s.tr('.', 'p')}" + end + def self.searchable_attributes %w[number full_name room_types.name tags.name floors.name] end diff --git a/app/models/searchable_record.rb b/app/models/searchable_record.rb index d731f0af..491acf0d 100644 --- a/app/models/searchable_record.rb +++ b/app/models/searchable_record.rb @@ -18,7 +18,7 @@ def image_or_placeholder image end - def to_string + def to_s raise 'This method should be overriden to display a string when searching' end @@ -26,14 +26,22 @@ def icon_class "search-item-icon --#{self.class.name.downcase}" end + def related_searchable_records + [] + end + + def self.search_string(query) + like_operator = ActiveRecord::Base.connection.adapter_name == "SQLite" ? "like" : "ilike" + attributes = searchable_attributes.map { |attribute| "#{attribute} " + like_operator + " '%#{query}%'" } + attributes.join(" or ") + end + def self.search(query) join = left_outer_joins(searchable_relations) if query.strip.casecmp(name).zero? join.group(:id) else - attributes = searchable_attributes.map { |attribute| "#{attribute} like '%#{query}%'" } - search_string = attributes.join(" or ") - join.where(search_string).group(:id) + join.where(search_string(query)).group(:id) end end end diff --git a/app/views/chairs/show.html.erb b/app/views/chairs/show.html.erb index 881f9b0f..de191e64 100644 --- a/app/views/chairs/show.html.erb +++ b/app/views/chairs/show.html.erb @@ -1,16 +1,16 @@ <% content_for :title, "Chair #{@chair.name}" %>
-
-
- <%= image_tag(@chair.image, class: "picture-rounded") %> -
-
-

- <%= @chair.name %> -

+
+
<%= render('partials/picture_circle', :locals => {:image => @chair.image}) %>
+
+

<%= @chair.name %>

+ Chair
- +
+ <%= link_to render('partials/iconbutton', :locals => {:text => 'Show on map', :icon_class => 'fa-map', :margin => 'me-4'}), map_path(:room_id => @chair.rooms[0]) %> + <%= link_to render('partials/iconbutton', :locals => {:text => 'Navigate', :icon_class => 'fa-location-arrow'}), root_path %> +
<% if @chair.people %>
people
diff --git a/app/views/courses/show.html.erb b/app/views/courses/show.html.erb index 8c6a0aa7..ba29085d 100644 --- a/app/views/courses/show.html.erb +++ b/app/views/courses/show.html.erb @@ -1,19 +1,81 @@ -

<%= notice %>

- -

- Name: - <%= @course.name %> -

- -

- Module category: - <%= @course.module_category %> -

- -

- Exam date: - <%= @course.exam_date %> -

+<% content_for :title, "#{@course.name}" %> +
+
+
+ <%= render('partials/picture_circle', :locals => {:image => @course.image_or_placeholder}) %> +
+
+

+ <%= @course.name %> +

+
+
+
+ <%= link_to render('partials/iconbutton', :locals => {:text => 'Show on map', :icon_class => 'fa-map', :margin => 'me-4'}), map_path(:room_id => @course.room.id) %> + <%= link_to render('partials/iconbutton', :locals => {:text => 'Navigate', :icon_class => 'fa-location-arrow'}), root_path %> +
+ <% if @course.people %> +
+
people
+
+ <% @course.people.each do |person| %> +
+ <%= link_to person_path(person), class: "btn btn-with-img" do %> + <%= image_tag(person.image_or_placeholder, class: "btn-img") %> + <%= person.full_name %> + <% end %> +
+ <% end %> +
+
+ <% end %> + <% if @course.room.present? %> +
+
+ room +
+
+
+ <%= link_to room_path(@course.room), class: "btn btn-with-img" do %> + <%= image_tag(@course.room.image, class: "btn-img") %> + <%= @course.room.name %> + <% end %> +
+
+
+ <% end %> +
+
+ module category +
+
+ <%= @course.module_category %> +
+
+ <% if @course.course_times.present? %> +
+
+ course times +
+
+ <% @course.course_times.each do |course_time| %> +
+ <%= course_time.full_time %> +
+ <% end %> +
+
+ <% end %> +
+
+ exam date +
+
+ <%= @course.exam_date %> +
+
+
<%= link_to 'Edit', edit_course_path(@course) %> | <%= link_to 'Back', courses_path %> + diff --git a/app/views/layouts/_bottombar.html.erb b/app/views/layouts/_bottombar.html.erb index 1516044e..55dcd30c 100644 --- a/app/views/layouts/_bottombar.html.erb +++ b/app/views/layouts/_bottombar.html.erb @@ -1,18 +1,19 @@ +<%= javascript_pack_tag 'search_button', 'data-turbolinks-track': 'reload' %>