1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Tag ta Ville</title> 7 <link rel="icon" type="image/svg+xml" href="assets/pin_3d_glossy_blue.svg"> 8
8<script src='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'></script>
8 9 <link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' /> 10
10<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
10 11
11<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
11 12 13 <!-- GoatCounter Analytics --> 14
14<script data-goatcounter="https://tagtaville.goatcounter.com/count" 15 async src="//gc.zgo.at/count.js"></script>
15 16 17 <!-- Google Fonts --> 18 <link rel="preconnect" href="https://fonts.googleapis.com"> 19 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 20 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 21 22 <style> 23 * { margin: 0; padding: 0; box-sizing: border-box; } 24 body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } 25 26 #map { width: 100vw; height: 100vh; } 27 28 .panel { 29 position: fixed; 30 background: white; 31 border-radius: 8px; 32 box-shadow: 0 2px 10px rgba(0,0,0,0.3); 33 } 34 35 /* Main Control Panel - Top Left */ 36 .main-panel { 37 top: 20px; 38 left: 20px; 39 width: 420px; 40 max-height: calc(100vh - 40px); 41 overflow-y: auto; 42 padding: 20px; 43 } 44 45 .main-panel h2 { 46 font-size: 22px; 47 margin-bottom: 20px; 48 color: #1a73e8; 49 font-weight: 700; 50 } 51 52 .step-section { 53 margin-bottom: 8px; 54 } 55 56 .step-header { 57 font-size: 17px; 58 font-weight: 600; 59 color: #1a73e8; 60 margin-bottom: 24px; 61 padding: 8px 12px; 62 background: #e8f0fe; 63 border-radius: 4px; 64 } 65 66 .step-label { 67 display: block; 68 font-size: 16px; 69 font-weight: 500; 70 color: #555; 71 margin-top: 8px; 72 margin-bottom: 8px; 73 } 74 75 .button-group { 76 display: inline-flex; 77 background: #e8f0fe; 78 border-radius: 8px; 79 padding: 4px; 80 margin-bottom: 8px; 81 } 82 83 .btn { 84 padding: 10px 24px; 85 border: none; 86 background: transparent; 87 color: #4285f4; 88 border-radius: 6px; 89 font-size: 16px; 90 font-weight: 500; 91 cursor: pointer; 92 transition: all 0.2s; 93 } 94 95 .btn:hover:not(.active) { background: rgba(255,255,255,0.5); } 96 .btn.active { 97 background: white; 98 color: #1a73e8; 99 box-shadow: 0 2px 4px rgba(0,0,0,0.1); 100 } 101 102 .address-input { 103 width: 100%; 104 padding: 10px; 105 border: 2px solid #ddd; 106 border-radius: 4px; 107 font-size: 16px; 108 margin-bottom: 8px; 109 } 110 111 .address-input:focus { outline: none; border-color: #4285f4; } 112 113 .address-suggestions { 114 background: white; 115 border: 2px solid #ddd; 116 border-top: none; 117 border-radius: 0 0 4px 4px; 118 max-height: 200px; 119 overflow-y: auto; 120 margin-bottom: 10px; 121 } 122 123 .address-suggestion { 124 padding: 10px; 125 cursor: pointer; 126 font-size: 15px; 127 color: #333; 128 border-bottom: 1px solid #f0f0f0; 129 } 130 131 .address-suggestion:hover { background: #f5f5f5; } 132 .address-suggestion:last-child { border-bottom: none; } 133 134 .confirm-btn, .next-btn, .submit-btn { 135 width: 100%; 136 padding: 12px; 137 background: #4285f4; 138 color: white; 139 border: none; 140 border-radius: 12px; 141 font-size: 16px; 142 font-weight: 600; 143 cursor: pointer; 144 transition: background 0.2s; 145 } 146 147 .confirm-btn:hover, .next-btn:hover { background: #357ae8; } 148 149 .confirm-btn.pulsate { 150 animation: pulsateButton 2s ease-in-out 3; 151 } 152 153 @keyframes pulsateButton { 154 0%, 100% { 155 transform: scale(1); 156 box-shadow: 0 2px 4px rgba(0,0,0,0.2); 157 } 158 50% { 159 transform: scale(1.05); 160 box-shadow: 0 4px 12px rgba(66, 133, 244, 0.6); 161 } 162 } 163 164 .next-btn { 165 background: #4285f4; 166 margin-top: 15px; 167 } 168 169 .next-btn:hover { background: #357ae8; } 170 171 .submit-btn { 172 background: #34a853; 173 font-size: 16px; 174 padding: 14px; 175 } 176 177 .submit-btn:hover { background: #2d9348; } 178 179 .clear-btn { 180 width: 100%; 181 padding: 12px; 182 background: white; 183 color: #d32f2f; 184 border: 2px solid #d32f2f; 185 border-radius: 12px; 186 font-size: 15px; 187 font-weight: 600; 188 cursor: pointer; 189 margin-top: 8px; 190 transition: all 0.2s; 191 } 192 193 .clear-btn:hover { 194 background: #fff5f5; 195 color: #d32f2f; 196 border-color: #d32f2f; 197 } 198 199 .instruction { 200 font-size: 15px; 201 color: #666; 202 padding: 10px; 203 background: #f5f5f5; 204 border-radius: 4px; 205 margin: 8px 0; 206 } 207 208 .selected-info { 209 background: #e8f0fe; 210 padding: 12px; 211 border-radius: 4px; 212 margin-bottom: 10px; 213 border-left: 4px solid #4285f4; 214 } 215 216 .selected-info div { 217 font-size: 16px; 218 color: #333; 219 margin-bottom: 4px; 220 } 221 222 .selected-info div:last-child { margin-bottom: 0; } 223 224 /* Tag Mode Selector (Red/Green) */ 225 .tag-mode-selector { 226 display: flex; 227 gap: 8px; 228 margin-bottom: 15px; 229 } 230 231 .tag-mode-btn { 232 flex: 1; 233 padding: 12px; 234 border: 2px solid #ddd; 235 border-radius: 8px; 236 font-size: 16px; 237 font-weight: 600; 238 cursor: pointer; 239 transition: all 0.2s; 240 background: white; 241 } 242 243 .tag-mode-btn.danger-mode { 244 color: #ea4335; 245 border-color: #ea4335; 246 } 247 248 .tag-mode-btn.danger-mode.active { 249 background: #ea4335; 250 color: white; 251 } 252 253 .tag-mode-btn.safe-mode { 254 color: #34a853; 255 border-color: #34a853; 256 } 257 258 .tag-mode-btn.safe-mode.active { 259 background: #34a853; 260 color: white; 261 } 262 263 /* Pulse animations for zone type buttons when not active */
264 @keyframes greenPulse { 265 0%, 100% { 266 transform: scale(1); 267 box-shadow: 0 0 0 0 rgba(52, 168, 83, 0.7); 268 } 269 50% { 270 transform: scale(1.05); 271 box-shadow: 0 0 0 8px rgba(52, 168, 83, 0); 272 } 273 } 274 275 @keyframes redPulse { 276 0%, 100% { 277 transform: scale(1); 278 box-shadow: 0 0 0 0 rgba(234, 67, 53, 0.7); 279 } 280 50% { 281 transform: scale(1.05); 282 box-shadow: 0 0 0 8px rgba(234, 67, 53, 0); 283 } 284 } 285 286 .tag-mode-btn.safe-mode.active { 287 animation: greenPulse 2s ease-in-out infinite; 288 } 289 290 .tag-mode-btn.danger-mode.active { 291 animation: redPulse 2s ease-in-out infinite; 292 } 293 294 /* Tool Selector (Cursor/Tag Can/Pressure Washer) */ 295 .tool-selector-row { 296 display: flex; 297 gap: 8px; 298 margin-bottom: 32px; 299 } 300 301 .tool-btn { 302 flex: 1; 303 padding: 14px 8px; 304 border: 2px solid #ddd; 305 border-radius: 8px; 306 font-size: 15px; 307 font-weight: 600; 308 cursor: pointer; 309 transition: all 0.2s; 310 background: white; 311 color: #666; 312 text-align: center; 313 line-height: 1.3; 314 } 315 316 #tool-cursor { 317 flex: 0.6; 318 font-size: 11px; 319 } 320 321 .tool-btn .cursor-icon { 322 font-size: 32px; 323 display: block; 324 } 325 326 .tool-btn:hover { background: #f5f5f5; } 327 328 .tool-btn.active { 329 background: #4285f4; 330 color: white; 331 border-color: #4285f4; 332 } 333 334 /* Pulse animation for paint button when not active */ 335 @keyframes paintButtonPulse { 336 0%, 100% { 337 transform: scale(1); 338 box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.7); 339 } 340 50% { 341 transform: scale(1.05); 342 box-shadow: 0 0 0 8px rgba(66, 133, 244, 0); 343 } 344 } 345 346 #tool-paint:not(.active) { 347 border-color: #4285f4; 348 color: #4285f4; 349 animation: paintButtonPulse 2s ease-in-out infinite; 350 } 351 352 .tool-emoji { 353 font-size: 20px; 354 display: block; 355 margin-bottom: 4px; 356 } 357 358 .spray-size-control { 359 margin-bottom: 20px; 360 } 361 362 .slider { 363 width: 100%; 364 height: 4px; 365 border-radius: 2px; 366 background: #ddd; 367 outline: none; 368 } 369 370 .toggle-label { 371 display: block; 372 font-size: 15px; 373 color: #555; 374 margin-bottom: 8px; 375 cursor: pointer; 376 } 377 378 .toggle-label input { margin-right: 8px; } 379 380 .paint-target-selector { 381 padding: 10px; 382 background: #f8f9fa; 383 border-radius: 4px; 384 margin-bottom: 12px; 385 } 386 387 .submit-info { 388 font-size: 16px; 389 color: #666; 390 padding: 12px; 391 background: #f8f9fa; 392 border-radius: 4px; 393 margin-bottom: 12px; 394 line-height: 1.5; 395 } 396 397 .reset-btn { 398 width: 100%; 399 padding: 10px; 400 background: #666; 401 color: white; 402 border: none; 403 border-radius: 4px; 404 font-size: 15px; 405 font-weight: 500; 406 cursor: pointer; 407 margin-top: 20px; 408 } 409 410 .reset-btn:hover { background: #555; } 411 412 .hidden { display: none !important; } 413 414 /* Info Panel - BOTTOM RIGHT */ 415 .info-panel { 416 bottom: 20px; 417 right: 20px; 418 padding: 15px; 419 font-size: 15px; 420 } 421 422 .info-row { margin-bottom: 5px; } 423 .info-label { font-weight: 600; color: #555; margin-right: 5px; } 424 .status { color: #4285f4; font-weight: 500; } 425 .status.loading { color: #fbbc04; } 426 427 /* <span data-en="View Controls" data-fr="Contrôles de vue">Contrôles de vue</span> - Top Right */ 428 .view-controls { 429 top: 20px; 430 right: 20px; 431 padding: 15px; 432 width: 200px; 433 } 434 435 .view-controls h3 { 436 font-size: 16px; 437 margin-bottom: 12px; 438 color: #333; 439 font-weight: 600; 440 } 441 442 .control-group { margin-bottom: 12px; } 443 444 .control-label { 445 display: block; 446 font-size: 17px; 447 font-weight: 500; 448 color: #555; 449 margin-bottom: 6px; 450 } 451 452 /* Cluster Items */ 453 .cluster-item { 454 background: #f8f9fa; 455 padding: 12px; 456 border-radius: 4px; 457 margin-bottom: 10px; 458 border-left: 4px solid; 459 cursor: pointer; 460 transition: all 0.2s; 461 } 462 463 .cluster-item:hover { 464 background: #e8f0fe; 465 } 466 467 .cluster-item.selected { 468 background: #e8f0fe; 469 } 470 471 .cluster-item.disliked.selected { 472 background: #fce8e6; 473 box-shadow: 0 0 0 3px #ea4335; 474 } 475 476 .cluster-item.liked.selected { 477 background: #e6f4ea; 478 box-shadow: 0 0 0 3px #34a853; 479 } 480 481 .cluster-item.disliked { border-color: #ea4335; } 482 .cluster-item.liked { border-color: #34a853; } 483 484 .cluster-header { 485 font-size: 15px; 486 font-weight: 600; 487 margin-bottom: 8px; 488 color: #333; 489 } 490 491 .cluster-input { 492 width: 100%; 493 padding: 8px; 494 border: 2px solid #ddd; 495 border-radius: 4px; 496 font-size: 15px; 497 margin-bottom: 8px; 498 } 499 500 .cluster-input:focus { 501 outline: none; 502 border-color: #4285f4; 503 } 504 505 .cluster-save-btn { 506 padding: 6px 12px; 507 background: #4285f4; 508 color: white; 509 border: none; 510 border-radius: 4px; 511 font-size: 12px; 512 font-weight: 500; 513 cursor: pointer; 514 } 515 516 .cluster-save-btn:hover { background: #357ae8; } 517 518 .cluster-save-btn.saved { 519 background: #34a853; 520 pointer-events: none; 521 } 522 523 .cluster-save-btn.saved:after { 524 content: " â"; 525 } 526 527 /* Welcome popup */ 528 .welcome-overlay { 529 position: fixed; 530 top: 0; 531 left: 0; 532 width: 100vw; 533 height: 100vh; 534 background: rgba(0, 0, 0, 0.5); 535 display: flex; 536 align-items: center; 537 justify-content: center; 538 z-index: 10000; 539 } 540 541 .welcome-popup { 542 position: relative; 543 background: white; 544 border-radius: 16px; 545 padding: 50px 58px; 546 max-width: 680px; 547 box-shadow: 0 20px 60px rgba(0,0,0,0.3); 548 text-align: center; 549 } 550 551 .welcome-logo { 552 width: 120px; 553 height: 120px; 554 margin: 0 auto 20px; 555 } 556 557 .welcome-title { 558 font-family: 'Inter', sans-serif; 559 font-size: 36px; 560 font-weight: 300; 561 color: #1a73e8; 562 margin-bottom: 38px; 563 letter-spacing: 1px; 564 } 565 566 .welcome-text { 567 font-size: 18px; 568 font-family: 'Inter', sans-serif; 569 color: #333; 570 line-height: 1.72; 571 margin-bottom: 21px; 572 font-weight: 400; 573 } 574 575 .welcome-text:first-of-type { 576 font-size: 19px; 577 font-weight: 600; 578 color: #1a73e8; 579 margin-bottom: 26px; 580 text-align: center; 581 } 582 583 .welcome-text:first-of-type strong { 584 color: #1a73e8; 585 } 586 587 .welcome-text:nth-of-type(4), .welcome-text:nth-of-type(5) { 588 font-size: 17px; 589 color: #555; 590 line-height: 1.68; 591 } 592 593 .welcome-text strong { 594 font-weight: 600; 595 color: #333; 596 } 597 598 .data-collapsible { 599 margin-top: 24px; 600 margin-bottom: 24px; 601 border: 1px solid #e8e8e8; 602 border-radius: 8px; 603 overflow: hidden; 604 } 605 606 .data-collapsible-header { 607 background: #f8f9fa; 608 padding: 14px 18px; 609 cursor: pointer; 610 font-size: 16px; 611 font-weight: 600; 612 color: #1a73e8; 613 display: flex; 614 align-items: center; 615 justify-content: space-between; 616 transition: background 0.2s; 617 font-family: 'Inter', sans-serif; 618 } 619 620 .data-collapsible-header:hover { 621 background: #f0f2f5; 622 } 623 624 .data-collapsible-arrow { 625 font-size: 16px; 626 transition: transform 0.3s; 627 } 628 629 .data-collapsible-header.open .data-collapsible-arrow { 630 transform: rotate(180deg); 631 } 632 633 .data-collapsible-content { 634 max-height: 0; 635 overflow: hidden; 636 transition: max-height 0.3s ease-out; 637 padding: 0 18px; 638 } 639 640 .data-collapsible-content.open { 641 max-height: 500px; 642 padding: 18px; 643 } 644 645 .data-collapsible-content p { 646 font-size: 17px; 647 color: #555; 648 line-height: 1.68; 649 margin-bottom: 16px; 650 font-family: 'Inter', sans-serif; 651 } 652 653 .data-collapsible-content p:last-child { 654 margin-bottom: 0; 655 } 656 657 .welcome-buttons { 658 display: flex; 659 gap: 12px; 660 justify-content: center; 661 margin-top: 34px; 662 } 663 664 .welcome-btn { 665 padding: 14px 32px; 666 border-radius: 8px; 667 font-size: 17px; 668 font-weight: 500; 669 cursor: pointer; 670 transition: all 0.2s; 671 border: none; 672 } 673 674 .welcome-btn.primary { 675 background: #4285f4; 676 color: white; 677 border: 2px solid #4285f4; 678 } 679 680 .welcome-btn.primary:hover { 681 background: #3367d6; 682 } 683 684 .welcome-btn.secondary { 685 background: white; 686 color: #4285f4; 687 border: 2px solid #4285f4; 688 } 689 690 .welcome-btn.secondary:hover { 691 background: #f0f7ff; 692 } 693 694 /* Tag checkboxes */ 695 .tag-checkboxes { 696 display: grid; 697 grid-template-columns: 1fr 1fr; 698 gap: 8px; 699 margin-bottom: 10px; 700 } 701 702 .tag-checkbox-label { 703 display: flex; 704 align-items: center; 705 padding: 8px; 706 background: #f8f9fa; 707 border-radius: 4px; 708 cursor: pointer; 709 font-size: 15px; 710 transition: all 0.2s; 711 } 712 713 .tag-checkbox-label:hover { 714 background: #e8f0fe; 715 } 716 717 .tag-checkbox-label input { 718 margin-right: 8px; 719 } 720 721 .cluster-comment {
722 width: 100%; 723 padding: 8px; 724 border: 2px solid #ddd; 725 border-radius: 4px; 726 font-size: 12px; 727 margin-bottom: 8px; 728 resize: vertical; 729 min-height: 50px; 730 font-family: inherit; 731 } 732 733 .cluster-comment:focus { 734 outline: none; 735 border-color: #4285f4; 736 } 737 738 /* On-screen navigation arrows (Step 1 & 2) */ 739 .map-nav-controls { 740 position: fixed; 741 bottom: 20px; 742 right: 20px; 743 display: none; 744 background: white; 745 padding: 10px; 746 border-radius: 8px; 747 box-shadow: 0 2px 8px rgba(0,0,0,0.15); 748 z-index: 100; 749 } 750 751 .map-nav-controls.active { display: block; } 752 753 .map-nav-grid { 754 display: grid; 755 grid-template-columns: repeat(3, 45px); 756 grid-template-rows: repeat(3, 45px); 757 gap: 6px; 758 margin-bottom: 8px; 759 } 760 761 .map-nav-btn { 762 background: #f5f5f5; 763 border: 2px solid #ddd; 764 border-radius: 6px; 765 cursor: pointer; 766 font-size: 18px; 767 font-weight: bold; 768 color: #555; 769 transition: all 0.2s; 770 display: flex; 771 align-items: center; 772 justify-content: center; 773 } 774 775 .map-nav-btn:hover { 776 background: #e0e0e0; 777 border-color: #999; 778 transform: scale(1.05); 779 } 780 781 .map-nav-btn:active { 782 transform: scale(0.95); 783 } 784 785 .map-nav-btn.active-press { 786 background: #4285f4; 787 color: white; 788 border-color: #4285f4; 789 transform: scale(0.95); 790 } 791 792 .map-nav-btn.rotate { 793 background: #4285f4; 794 color: white; 795 border-color: #4285f4; 796 font-size: 18px; 797 } 798 799 .map-nav-btn.rotate:hover { 800 801.map-nav-btn.zoom { 802 background: #34a853; 803 color: white; 804 border-color: #34a853; 805 font-size: 24px; 806} 807 808.map-nav-btn.zoom:hover { 809 background: #2d9348; 810} 811 background: #357ae8; 812 } 813 814 .map-nav-label { 815 font-size: 10px; 816 color: #666; 817 text-align: center; 818 font-weight: 500; 819 } 820 821 /* Back button */ 822 .back-btn { 823 width: 100%; 824 padding: 10px; 825 background: transparent; 826 color: #888; 827 border: none; 828 border-radius: 0; 829 font-size: 15px; 830 font-weight: 500; 831 cursor: pointer; 832 margin-top: 12px; 833 margin-bottom: 0; 834 display: flex; 835 align-items: center; 836 justify-content: center; 837 gap: 5px; 838 text-decoration: underline; 839 } 840 841 .back-btn:hover { 842 background: transparent; 843 color: #555; 844 } 845 846 /* Info button at bottom left */ 847 .info-button { 848 position: fixed; 849 bottom: 20px; 850 left: 20px; 851 width: 52px; 852 height: 52px; 853 background: white; 854 border: none; 855 border-radius: 50%; 856 color: #1a73e8; 857 font-size: 26px; 858 font-weight: 600; 859 font-style: italic; 860 cursor: pointer; 861 box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); 862 z-index: 1000; 863 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 864 display: flex; 865 align-items: center; 866 justify-content: center; 867 font-family: 'Georgia', 'Times New Roman', serif; 868 } 869 870 .info-button:hover { 871 background: #f8f9fa; 872 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); 873 transform: scale(1.08); 874 } 875 876 .info-button:active { 877 transform: scale(0.95); 878 } 879 880 .info-tooltip { 881 position: fixed; 882 bottom: 80px; 883 left: 20px; 884 max-width: 350px; 885 background: white; 886 border-radius: 12px; 887 padding: 20px; 888 box-shadow: 0 4px 20px rgba(0,0,0,0.2); 889 z-index: 999; 890 display: none; 891 font-family: 'Inter', sans-serif; 892 } 893 894 .info-tooltip.visible { 895 display: block; 896 animation: fadeIn 0.2s; 897 } 898 899 @keyframes fadeIn { 900 from { opacity: 0; transform: translateY(10px); } 901 to { opacity: 1; transform: translateY(0); } 902 } 903 904 .info-tooltip-header { 905 font-size: 17px; 906 font-weight: 600; 907 color: #1a73e8; 908 margin-bottom: 14px; 909 padding-bottom: 12px; 910 border-bottom: 2px solid #e8e8e8; 911 } 912 913 .info-tooltip-content { 914 font-size: 15px; 915 color: #444; 916 line-height: 1.7; 917 } 918 919 .info-tooltip-content p { 920 margin: 0; 921 text-align: left; 922 } 923 924 .info-tooltip-close { 925 position: absolute; 926 top: 12px; 927 right: 12px; 928 background: none; 929 border: none; 930 font-size: 20px; 931 color: #999; 932 cursor: pointer; 933 width: 24px; 934 height: 24px; 935 display: flex; 936 align-items: center; 937 justify-content: center; 938 border-radius: 50%; 939 transition: all 0.2s; 940 } 941 942 .info-tooltip-close:hover { 943 background: #f0f0f0; 944 color: #666; 945 } 946 947 /* Step 3 detailed tagging */ 948 .cluster-type-badge { 949 display: inline-block; 950 padding: 8px 16px; 951 border-radius: 20px; 952 font-size: 15px; 953 font-weight: 600; 954 margin-bottom: 12px; 955 } 956 957 .cluster-type-badge.disliked { 958 background: #fce8e6; 959 color: #ea4335; 960 border: 2px solid #ea4335; 961 } 962 963 .cluster-type-badge.liked { 964 background: #e6f4ea; 965 color: #34a853; 966 border: 2px solid #34a853; 967 } 968 969 .cluster-size { 970 font-size: 15px; 971 color: #666; 972 margin-bottom: 15px; 973 font-weight: 500; 974 } 975 976 .split-section { 977 margin-bottom: 12px; 978 border: 1px solid #e8e8e8; 979 border-radius: 6px; 980 overflow: hidden; 981 } 982 983 .split-section-header { 984 background: #fff8f0; 985 padding: 8px 12px; 986 cursor: pointer; 987 font-size: 14px; 988 font-weight: 600; 989 color: #ff9800; 990 display: flex; 991 align-items: center; 992 justify-content: space-between; 993 transition: background 0.2s; 994 } 995 996 .split-info-icon { 997 display: inline-flex; 998 align-items: center; 999 justify-content: center; 1000 width: 18px; 1001 height: 18px; 1002 border-radius: 50%; 1003 border: 1.5px solid #ff9800; 1004 color: #ff9800; 1005 font-size: 12px; 1006 font-weight: bold; 1007 margin-left: 8px; 1008 cursor: help; 1009 transition: all 0.2s; 1010 } 1011 1012 .split-info-icon:hover { 1013 background: #ff9800; 1014 color: white; 1015 transform: scale(1.1); 1016 } 1017 1018 .split-section-header:hover { 1019 background: #ffedd4; 1020 } 1021 1022 .split-section-arrow { 1023 font-size: 10px; 1024 transition: transform 0.3s; 1025 } 1026 1027 .split-section-header.open .split-section-arrow { 1028 transform: rotate(180deg); 1029 } 1030 1031 .split-section-content { 1032 max-height: 0; 1033 overflow: hidden; 1034 transition: max-height 0.3s ease-out; 1035 padding: 0 12px; 1036 } 1037 1038 .split-section-content.open { 1039 max-height: 200px; 1040 padding: 10px 12px; 1041 } 1042 1043 .split-cluster-btn { 1044 width: 100%; 1045 padding: 8px; 1046 background: white; 1047 border: 2px dashed #ff9800; 1048 border-radius: 6px; 1049 color: #ff9800; 1050 font-size: 14px; 1051 font-weight: 600; 1052 cursor: pointer; 1053 transition: all 0.2s; 1054 } 1055 1056 .split-cluster-btn:hover { 1057 background: #fff3e0; 1058 border-style: solid; 1059 transform: translateY(-1px); 1060 } 1061
1062 @keyframes fadeInOut { 1063 0% { opacity: 0; transform: translateY(-10px); } 1064 15% { opacity: 1; transform: translateY(0); } 1065 85% { opacity: 1; transform: translateY(0); } 1066 100% { opacity: 0; transform: translateY(-10px); } 1067 } 1068 1069 @keyframes pulse { 1070 0%, 100% { transform: scale(1); } 1071 50% { transform: scale(1.05); } 1072 } 1073 1074 /* Loading Spinner */ 1075 .loading-overlay { 1076 position: fixed; 1077 top: 0; 1078 left: 0; 1079 right: 0; 1080 bottom: 0; 1081 background: rgba(0, 0, 0, 0.5); 1082 display: flex; 1083 flex-direction: column; 1084 align-items: center; 1085 justify-content: center; 1086 z-index: 9999; 1087 } 1088 1089 .loading-overlay.hidden { 1090 display: none; 1091 } 1092 1093 .loading-spinner { 1094 width: 60px; 1095 height: 60px; 1096 border: 6px solid #f3f3f3; 1097 border-top: 6px solid #4285f4; 1098 border-radius: 50%; 1099 animation: spin 1s linear infinite; 1100 } 1101 1102 @keyframes spin { 1103 0% { transform: rotate(0deg); } 1104 100% { transform: rotate(360deg); } 1105 } 1106 1107 .loading-text { 1108 margin-top: 20px; 1109 color: white; 1110 font-size: 18px; 1111 font-weight: 600; 1112 } 1113 1114 /* Zoom Prompt */ 1115 .zoom-prompt { 1116 position: fixed; 1117 bottom: 30px; 1118 left: 50%; 1119 transform: translateX(-50%); 1120 background: rgba(66, 133, 244, 0.95); 1121 color: white; 1122 padding: 16px 32px; 1123 border-radius: 30px; 1124 font-size: 16px; 1125 font-weight: 600; 1126 box-shadow: 0 4px 20px rgba(0,0,0,0.3); 1127 z-index: 10001; 1128 animation: pulsatePrompt 2s ease-in-out infinite; 1129 display: none; 1130 backdrop-filter: blur(10px); 1131 } 1132 1133 .zoom-prompt.visible { 1134 display: block; 1135 } 1136 1137 @keyframes pulsatePrompt { 1138 0%, 100% { 1139 transform: translateX(-50%) scale(1); 1140 box-shadow: 0 4px 20px rgba(0,0,0,0.3); 1141 } 1142 50% { 1143 transform: translateX(-50%) scale(1.05); 1144 box-shadow: 0 6px 30px rgba(66, 133, 244, 0.5); 1145 } 1146 } 1147 1148 /* Progress Tracker */ 1149 .progress-tracker { 1150 position: absolute; 1151 top: 75px; 1152 right: 20px; 1153 background: white; 1154 padding: 16px; 1155 border-radius: 12px; 1156 box-shadow: 0 4px 12px rgba(0,0,0,0.15); 1157 min-width: 200px; 1158 z-index: 5; 1159 } 1160 1161 .progress-title { 1162 font-size: 12px; 1163 font-weight: 700; 1164 color: #666; 1165 text-transform: uppercase; 1166 letter-spacing: 0.5px; 1167 margin-bottom: 12px; 1168 } 1169 1170 .progress-step { 1171 display: flex; 1172 align-items: center; 1173 gap: 10px; 1174 margin-bottom: 10px; 1175 font-size: 15px; 1176 } 1177 1178 .progress-circle { 1179 width: 24px; 1180 height: 24px; 1181 border-radius: 50%; 1182 background: #e0e0e0; 1183 color: #999; 1184 display: flex; 1185 align-items: center; 1186 justify-content: center; 1187 font-size: 11px; 1188 font-weight: 700; 1189 flex-shrink: 0; 1190 } 1191 1192 .progress-step.active .progress-circle { 1193 background: #4285f4; 1194 color: white; 1195 } 1196 1197 .progress-step.completed .progress-circle { 1198 background: #34a853; 1199 color: white; 1200 } 1201 1202 .progress-step.completed .progress-circle::after { 1203 content: 'â'; 1204 } 1205 1206 .progress-step-text { 1207 color: #999; 1208 } 1209 1210 .progress-step.active .progress-step-text { 1211 color: #4285f4; 1212 font-weight: 600; 1213 } 1214 1215 .progress-step.completed .progress-step-text { 1216 color: #666; 1217 } 1218 1219 /* Tutorial Overlay */ 1220 .tutorial-overlay { 1221 position: fixed; 1222 top: 0; 1223 left: 0; 1224 right: 0; 1225 bottom: 0; 1226 background: rgba(0, 0, 0, 0.8); 1227 z-index: 10000; 1228 display: flex; 1229 align-items: center; 1230 justify-content: center; 1231 } 1232 1233 .tutorial-overlay.hidden { 1234 display: none; 1235 } 1236 1237 .tutorial-card { 1238 background: white; 1239 border-radius: 16px; 1240 padding: 32px 40px; 1241 max-width: 900px; 1242 box-shadow: 0 20px 60px rgba(0,0,0,0.3); 1243 animation: slideUp 0.3s ease-out; 1244 } 1245 1246 @keyframes slideUp { 1247 from { 1248 opacity: 0; 1249 transform: translateY(30px); 1250 } 1251 to { 1252 opacity: 1; 1253 transform: translateY(0); 1254 } 1255 } 1256 1257 .tutorial-step-content { 1258 text-align: center; 1259 } 1260 1261 .tutorial-icon { 1262 font-size: 64px; 1263 margin-bottom: 12px; 1264 } 1265 1266 .tutorial-icon:empty { 1267 display: none; 1268 } 1269 1270 .tutorial-title { 1271 font-size: 28px; 1272 font-weight: 700; 1273 color: #333; 1274 margin-bottom: 24px; 1275 } 1276 1277 .tutorial-description { 1278 font-size: 16px; 1279 color: #666; 1280 line-height: 1.6; 1281 margin-bottom: 32px; 1282 } 1283 1284 .tutorial-description p { 1285 margin: 12px 0; 1286 } 1287 1288 .tutorial-progress { 1289 display: flex; 1290 justify-content: center; 1291 gap: 8px; 1292 margin-bottom: 32px; 1293 } 1294 1295 .tutorial-progress-dot { 1296 width: 10px; 1297 height: 10px; 1298 border-radius: 50%; 1299 background: #ddd; 1300 } 1301 1302 .tutorial-progress-dot.active { 1303 background: #4285f4; 1304 width: 24px; 1305 border-radius: 5px; 1306 } 1307 1308 .tutorial-actions { 1309 display: flex; 1310 gap: 12px; 1311 justify-content: center; 1312 } 1313 1314 .tutorial-btn-skip { 1315 padding: 12px 24px; 1316 border: 2px solid #ddd; 1317 background: white; 1318 color: #666; 1319 border-radius: 8px; 1320 font-size: 15px; 1321 font-weight: 600; 1322 cursor: pointer; 1323 } 1324 1325 .tutorial-btn-next { 1326 padding: 12px 32px; 1327 border: none; 1328 background: #4285f4; 1329 color: white; 1330 border-radius: 8px; 1331 font-size: 15px; 1332 font-weight: 600; 1333 cursor: pointer; 1334 } 1335 1336 .tutorial-btn-skip:hover { 1337 background: #f5f5f5; 1338 } 1339 1340 .tutorial-btn-next:hover { 1341 background: #3367d6; 1342 } 1343 1344 .tag-category { 1345 margin-bottom: 15px; 1346 background: white; 1347 border-radius: 8px; 1348 border: 2px solid #e0e0e0; 1349 overflow: hidden; 1350 } 1351 1352 .tag-category-header { 1353 background: linear-gradient(to bottom, #f8f9fa, #f0f0f0); 1354 padding: 10px 15px; 1355 font-size: 16px; 1356 font-weight: 600; 1357 color: #333; 1358 border-bottom: 2px solid #e0e0e0; 1359 display: flex; 1360 align-items: center; 1361 justify-content: space-between; 1362 cursor: pointer; 1363 user-select: none; 1364 } 1365 1366 .tag-category-header:hover { 1367 background: linear-gradient(to bottom, #f0f0f0, #e8e8e8); 1368 } 1369 1370 .tag-category-title { 1371 display: flex; 1372 align-items: center; 1373 gap: 8px; 1374 } 1375 1376 .tag-category-icon { 1377 font-size: 16px; 1378 display: none; /* REMOVE EMOJIS */ 1379 } 1380 1381 .tag-category-arrow { 1382 font-size: 12px; 1383 transition: transform 0.3s; 1384 } 1385 1386 .tag-category.collapsed .tag-category-arrow { 1387 transform: rotate(-90deg); 1388 } 1389 1390 .tag-options-group { 1391 padding: 10px; 1392 display: block; 1393 } 1394 1395 .tag-category.collapsed .tag-options-group { 1396 display: none; 1397 } 1398 1399 .tag-section-label { 1400 font-size: 10px; 1401 font-weight: 600; 1402 color: #666; 1403 text-transform: uppercase; 1404 letter-spacing: 0.5px; 1405 margin: 8px 0 6px 4px; 1406 } 1407 1408 .tag-section-label.positive { color: #34a853; } 1409 .tag-section-label.negative { color: #ea4335; } 1410 1411 .tag-option-item { 1412 display: flex; 1413 align-items: center; 1414 padding: 10px 12px; 1415 margin-bottom: 5px; 1416 background: #fafafa; 1417 border: 2px solid #e8e8e8; 1418 border-radius: 6px; 1419 cursor: pointer; 1420 transition: all 0.2s; 1421 font-size: 16px; 1422 user-select: none; 1423 } 1424 1425 /* FIX 5: Color all positive tags green */ 1426 .tag-option-item.positive { 1427 background: rgba(52, 168, 83, 0.08); 1428 border-color: rgba(52, 168, 83, 0.25); 1429 } 1430 1431 /* FIX 5: Color all negative tags red */ 1432 .tag-option-item.negative { 1433 background: rgba(234, 67, 53, 0.08); 1434 border-color: rgba(234, 67, 53, 0.25); 1435 } 1436 1437 .tag-option-item:hover { 1438 background: #f0f7ff; 1439 border-color: #b3d9ff; 1440 transform: translateX(2px); 1441 } 1442 1443 .tag-option-item.selected { 1444 background: #e8f0fe; 1445 border-color: #4285f4; 1446 font-weight: 500; 1447 } 1448 1449 .tag-option-item.positive.selected { 1450 background: #e6f4ea; 1451 border-color: #34a853; 1452 color: #1e7e34; 1453 } 1454 1455 .tag-option-item.negative.selected { 1456 background: #fce8e6; 1457 border-color: #ea4335; 1458 color: #c5221f; 1459 } 1460 1461 .tag-option-checkbox { 1462 width: 18px; 1463 height: 18px; 1464 margin-right: 10px; 1465 cursor: pointer; 1466 flex-shrink: 0; 1467 } 1468 1469 .tag-option-label { 1470 flex: 1; 1471 } 1472 1473 .season-badge { 1474 display: inline-flex; 1475 align-items: center; 1476 justify-content: center; 1477 min-width: 60px; 1478 padding: 2px 8px; 1479 margin-right: 8px; 1480 background: #4285f4; 1481 color: white; 1482 border-radius: 12px; 1483 font-size: 10px; 1484 font-weight: 600; 1485 text-transform: uppercase; 1486 letter-spacing: 0.5px; 1487 } 1488 1489 .season-badge.spring { background: #34a853; } 1490 .season-badge.summer { background: #fbbc04; color: #333; } 1491 .season-badge.fall { background: #ea8600; } 1492 .season-badge.winter { background: #4285f4; } 1493 1494 .cluster-comment-full { 1495 width: 100%; 1496 padding: 10px; 1497 border: 2px solid #ddd; 1498 border-radius: 6px; 1499 font-size: 15px; 1500 margin: 12px 0; 1501 resize: vertical; 1502 min-height: 70px; 1503 font-family: inherit; 1504 } 1505 1506 .cluster-comment-full:focus { 1507 outline: none; 1508 border-color: #4285f4; 1509 background: #f9fbff; 1510 } 1511 1512 .cluster-nav-buttons { 1513 display: grid; 1514 grid-template-columns: 1fr 1fr; 1515 gap: 8px; 1516 margin-bottom: 12px; 1517 } 1518 1519 .nav-cluster-btn { 1520 padding: 10px; 1521 border: 2px solid #ddd; 1522 background: white; 1523 color: #666; 1524 border-radius: 6px; 1525 font-size: 15px; 1526 font-weight: 600; 1527 cursor: pointer; 1528 transition: all 0.2s; 1529 } 1530 1531 .nav-cluster-btn:hover:not(:disabled) { 1532 background: #f5f5f5; 1533 border-color: #999; 1534 transform: translateY(-1px); 1535 box-shadow: 0 2px 4px rgba(0,0,0,0.1); 1536 } 1537 1538 .nav-cluster-btn:disabled { 1539 opacity: 0.3;
1540 cursor: not-allowed; 1541 } 1542 1543 .nav-cluster-btn.primary { 1544 background: #4285f4; 1545 color: white; 1546 border-color: #4285f4; 1547 } 1548 1549 .nav-cluster-btn.primary:hover { 1550 background: #357ae8; 1551 } 1552 1553 .nav-cluster-btn.skip { 1554 color: #999; 1555 border-style: dashed; 1556 } 1557 1558 .nav-cluster-btn.skip:hover { 1559 color: #666; 1560 border-color: #666; 1561 } 1562 1563 1564 /* Custom Alert Popup */ 1565 .custom-alert-overlay { 1566 position: fixed; 1567 top: 0; 1568 left: 0; 1569 width: 100%; 1570 height: 100%; 1571 background: rgba(0, 0, 0, 0.5); 1572 display: flex; 1573 align-items: center; 1574 justify-content: center; 1575 z-index: 10000; 1576 animation: fadeIn 0.2s ease-in; 1577 } 1578 1579 @keyframes fadeIn { 1580 from { opacity: 0; } 1581 to { opacity: 1; } 1582 } 1583 1584 .custom-alert { 1585 background: white; 1586 border-radius: 12px; 1587 padding: 24px; 1588 max-width: 500px; 1589 width: 90%; 1590 max-height: 80vh; 1591 overflow-y: auto; 1592 box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); 1593 animation: slideIn 0.3s ease-out; 1594 } 1595 1596 @keyframes slideIn { 1597 from { 1598 transform: translateY(-50px); 1599 opacity: 0; 1600 } 1601 to { 1602 transform: translateY(0); 1603 opacity: 1; 1604 } 1605 } 1606 1607 .custom-alert h3 { 1608 margin: 0 0 16px 0; 1609 font-size: 20px; 1610 color: #333; 1611 } 1612 1613 .custom-alert-content { 1614 margin-bottom: 20px; 1615 font-size: 16px; 1616 line-height: 1.6; 1617 color: #555; 1618 } 1619 1620 .custom-alert-content p { 1621 margin-bottom: 15px; 1622 } 1623 1624 .custom-alert-content p:last-child { 1625 margin-bottom: 0; 1626 } 1627 1628 .custom-alert-button { 1629 width: 100%; 1630 padding: 12px; 1631 background: #4285f4; 1632 color: white; 1633 border: none; 1634 border-radius: 6px; 1635 font-size: 15px; 1636 font-weight: 500; 1637 cursor: pointer; 1638 transition: background 0.2s; 1639 } 1640 1641 .custom-alert-button:hover { 1642 background: #3367d6; 1643 } 1644 1645 /* Mosaic Visualization Styles */ 1646 .visualization-section { 1647 margin: 25px 0; 1648 text-align: center; 1649 } 1650 1651 .visualization-section h4 { 1652 margin-bottom: 15px; 1653 color: #333; 1654 font-size: 18px; 1655 font-weight: 600; 1656 } 1657 1658 .visualization-section canvas { 1659 border-radius: 8px; 1660 box-shadow: 0 4px 16px rgba(0,0,0,0.1); 1661 max-width: 100%; 1662 height: auto; 1663 } 1664 1665 .download-viz-btn { 1666 flex: 1; 1667 padding: 14px; 1668 background: white; 1669 color: #4285f4; 1670 border: 2px solid #4285f4; 1671 border-radius: 8px; 1672 font-size: 16px; 1673 font-weight: 600; 1674 cursor: pointer; 1675 transition: background 0.2s, transform 0.2s; 1676 } 1677 1678 .download-viz-btn:hover { 1679 background: #f0f7ff; 1680 } 1681 1682 .download-viz-btn.pulsate-btn { 1683 animation: pulsate 2s ease-in-out infinite; 1684 } 1685 1686 @keyframes pulsate { 1687 0%, 100% { 1688 transform: scale(1); 1689 box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.7); 1690 } 1691 50% { 1692 transform: scale(1.05); 1693 box-shadow: 0 0 20px 5px rgba(66, 133, 244, 0.3); 1694 } 1695 } 1696 1697 .buttons-row { 1698 display: flex; 1699 gap: 15px; 1700 margin-top: 15px; 1701 max-width: 600px; 1702 margin-left: auto; 1703 margin-right: auto; 1704 } 1705 1706 .submit-comment-btn { 1707 width: 100%; 1708 padding: 14px; 1709 background: #34a853; 1710 color: white; 1711 border: none; 1712 border-radius: 8px; 1713 font-size: 15px; 1714 font-weight: 600; 1715 cursor: pointer; 1716 transition: all 0.2s; 1717 margin-top: 12px; 1718 box-shadow: 0 2px 4px rgba(52, 168, 83, 0.2); 1719 } 1720 1721 .submit-comment-btn:hover { 1722 background: #2d9548; 1723 box-shadow: 0 4px 8px rgba(52, 168, 83, 0.3); 1724 transform: translateY(-1px); 1725 } 1726 1727 .stats-row { 1728 margin-bottom: 10px; 1729 font-size: 14px; 1730 } 1731 1732 .comment-section { 1733 margin-top: 30px; 1734 padding: 20px; 1735 background: #f8f9fa; 1736 border-radius: 12px; 1737 border: 1px solid #e0e0e0; 1738 } 1739 1740 .comment-section label { 1741 display: block; 1742 margin-bottom: 12px; 1743 font-weight: 600; 1744 font-size: 16px; 1745 color: #1a73e8; 1746 } 1747 1748 .comment-section textarea:focus { 1749 outline: none; 1750 border-color: #1a73e8; 1751 box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1); 1752 } 1753 1754 /* Language Toggle Slider */ 1755 .lang-toggle-container { 1756 position: absolute; 1757 top: 20px; 1758 right: 20px; 1759 z-index: 10; 1760 } 1761 1762 .lang-toggle { 1763 position: relative; 1764 display: inline-block; 1765 width: 80px; 1766 height: 36px; 1767 background: #e0e0e0; 1768 border-radius: 18px; 1769 cursor: pointer; 1770 transition: background 0.3s; 1771 } 1772 1773 .lang-toggle:hover { 1774 background: #d0d0d0; 1775 } 1776 1777 .lang-toggle-slider { 1778 position: absolute; 1779 top: 3px; 1780 left: 3px; 1781 width: 36px; 1782 height: 30px; 1783 background: white; 1784 border-radius: 15px; 1785 box-shadow: 0 2px 4px rgba(0,0,0,0.2); 1786 transition: transform 0.3s; 1787 display: flex; 1788 align-items: center; 1789 justify-content: center; 1790 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; 1791 font-weight: 700; 1792 font-size: 15px; 1793 color: #0d47a1; 1794 letter-spacing: 0.5px; 1795 -webkit-font-smoothing: subpixel-antialiased; 1796 text-rendering: geometricPrecision; 1797 } 1798 1799 .lang-toggle.en .lang-toggle-slider { 1800 transform: translateX(44px); 1801 } 1802 1803 .lang-toggle-labels { 1804 position: absolute; 1805 top: 0; 1806 left: 0; 1807 right: 0; 1808 bottom: 0; 1809 display: flex; 1810 align-items: center; 1811 justify-content: space-between; 1812 padding: 0 10px; 1813 pointer-events: none; 1814 } 1815 1816 .lang-toggle-label { 1817 font-size: 15px; 1818 font-weight: 600; 1819 color: #666; 1820 transition: color 0.3s, opacity 0.3s; 1821 -webkit-font-smoothing: antialiased; 1822 -moz-osx-font-smoothing: grayscale; 1823 } 1824 1825 /* Hide FR label when slider is on it (default state) */ 1826 .lang-toggle:not(.en) .lang-toggle-label.fr { 1827 opacity: 0; 1828 } 1829 1830 /* Hide EN label when slider is on it */ 1831 .lang-toggle.en .lang-toggle-label.en { 1832 opacity: 0; 1833 } 1834 1835 /* Show FR label when slider is NOT on it (EN selected) */ 1836 .lang-toggle.en .lang-toggle-label.fr { 1837 color: #999; 1838 opacity: 1; 1839 } 1840 1841 /* Show EN label when slider is NOT on it (FR selected) */ 1842 .lang-toggle:not(.en) .lang-toggle-label.en { 1843 color: #999; 1844 opacity: 1; 1845 } 1846 1847 /* Paint hint tooltip */ 1848 .paint-hint { 1849 display: none; 1850 position: absolute; 1851 top: -8px; 1852 right: 6px; 1853 background: #4285f4; 1854 color: white; 1855 padding: 6px 12px; 1856 border-radius: 6px; 1857 font-size: 11px; 1858 font-weight: 600; 1859 white-space: nowrap; 1860 z-index: 10; 1861 } 1862 1863
1864 @keyframes hintPulse { 1865 0%, 100% { opacity: 1; transform: scale(1); } 1866 50% { opacity: 0.85; transform: scale(1.05); } 1867 } 1868 1869 /* ========== MOBILE RESPONSIVE ========== */ 1870 /* Mobile-only segmented control */ 1871 .mobile-segmented-control { 1872 display: none; 1873 background: #e0e0e0; 1874 border-radius: 10px; 1875 padding: 4px; 1876 gap: 4px; 1877 margin-bottom: 12px; 1878 } 1879 1880 .mobile-segment { 1881 flex: 1; 1882 padding: 12px 8px; 1883 border-radius: 8px; 1884 font-size: 12px; 1885 font-weight: 600; 1886 text-align: center; 1887 cursor: pointer; 1888 transition: all 0.2s; 1889 background: transparent; 1890 color: #666; 1891 border: none; 1892 position: relative; 1893 } 1894 1895 .mobile-segment .segment-check { 1896 display: none; 1897 position: absolute; 1898 top: 4px; 1899 right: 6px; 1900 width: 18px; 1901 height: 18px; 1902 background: rgba(0,0,0,0.2); 1903 border-radius: 50%; 1904 font-size: 11px; 1905 line-height: 18px; 1906 text-align: center; 1907 } 1908 1909 .mobile-segment.active-positive { 1910 background: #34a853; 1911 color: white; 1912 box-shadow: 0 2px 4px rgba(0,0,0,0.2); 1913 } 1914 1915 .mobile-segment.active-positive .segment-check { 1916 display: block; 1917 background: rgba(0,0,0,0.15); 1918 } 1919 1920 .mobile-segment.active-negative { 1921 background: #ea4335; 1922 color: white; 1923 box-shadow: 0 2px 4px rgba(0,0,0,0.2); 1924 } 1925 1926 .mobile-segment.active-negative .segment-check { 1927 display: block; 1928 background: rgba(0,0,0,0.15); 1929 } 1930 1931 .mobile-segment.active-navigate { 1932 background: white; 1933 color: #1a73e8; 1934 box-shadow: 0 2px 4px rgba(0,0,0,0.15); 1935 } 1936 1937 .mobile-segment.active-navigate .segment-check { 1938 display: block; 1939 background: #1a73e8; 1940 color: white; 1941 } 1942 1943 /* Mobile-only paint toolbar (hidden on desktop) */ 1944 .mobile-paint-toolbar { 1945 display: none; 1946 flex-direction: column; 1947 margin-bottom: 10px; 1948 } 1949 1950 .mobile-paint-btn { 1951 flex: 1; 1952 padding: 10px 6px; 1953 border: 2px solid #ddd; 1954 border-radius: 8px; 1955 font-size: 13px; 1956 font-weight: 600; 1957 cursor: pointer; 1958 background: #f5f5f5; 1959 color: #333; 1960 text-align: center; 1961 } 1962 1963 .mobile-paint-btn.mobile-green.active { 1964 background: #34a853; 1965 color: white; 1966 border-color: #2d9348; 1967 } 1968 1969 .mobile-paint-btn.mobile-red.active { 1970 background: #ea4335; 1971 color: white; 1972 border-color: #d33426; 1973 } 1974 1975 .mobile-paint-btn.mobile-toggle { 1976 background: #4285f4; 1977 color: white; 1978 border-color: #3367d6; 1979 font-weight: 700; 1980 } 1981 1982 .mobile-paint-btn.mobile-toggle.mobile-pulse { 1983 animation: mobilePulse 1.5s ease-in-out infinite; 1984 } 1985 1986 @keyframes mobilePulse { 1987 0%, 100% { box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.5); } 1988 50% { box-shadow: 0 0 0 8px rgba(66, 133, 244, 0); } 1989 } 1990 1991 .mobile-paint-btn.mobile-toggle.painting { 1992 background: #ea4335; 1993 border-color: #d33426; 1994 animation: none; 1995 } 1996 1997 /* Toggle Switch for Mobile Paint */ 1998 .mobile-toggle-switch { 1999 position: relative; 2000 display: inline-block; 2001 width: 56px; 2002 height: 32px; 2003 flex-shrink: 0; 2004 } 2005 2006 .mobile-toggle-switch input { 2007 opacity: 0; 2008 width: 0; 2009 height: 0; 2010 } 2011 2012 .toggle-slider { 2013 position: absolute; 2014 cursor: pointer; 2015 top: 0; 2016 left: 0; 2017 right: 0; 2018 bottom: 0; 2019 background-color: #ccc; 2020 transition: .3s; 2021 border-radius: 32px; 2022 border: 2px solid #999; 2023 } 2024 2025 .toggle-slider:before { 2026 position: absolute; 2027 content: ""; 2028 height: 24px; 2029 width: 24px; 2030 left: 2px; 2031 bottom: 2px; 2032 background-color: white; 2033 transition: .3s; 2034 border-radius: 50%; 2035 } 2036 2037 .mobile-toggle-switch input:checked + .toggle-slider { 2038 background-color: #4285f4; 2039 border-color: #3367d6; 2040 } 2041 2042 .mobile-toggle-switch input:checked + .toggle-slider:before { 2043 transform: translateX(24px); 2044 } 2045 2046 .mobile-toggle-switch.mobile-pulse .toggle-slider { 2047 animation: mobilePulse 1.5s ease-in-out infinite; 2048 } 2049 2050 .mobile-paint-btn.mobile-erase { 2051 background: #f5f5f5; 2052 border-color: #ddd; 2053 } 2054 2055 .mobile-paint-btn.mobile-erase.active { 2056 background: #ff9800; 2057 color: white; 2058 border-color: #f57c00; 2059 } 2060 2061 .mobile-paint-btn.mobile-clear-all { 2062 background: #fff5f5; 2063 color: #d32f2f; 2064 border-color: #d32f2f; 2065 font-weight: 700; 2066 } 2067 2068 .mobile-paint-btn.mobile-clear-all:active { 2069 background: #d32f2f; 2070 color: white; 2071 } 2072 2073 /* Mobile Option F styles */ 2074 .mobile-option-f { 2075 display: none; 2076 } 2077 2078 .mobile-zone-btn { 2079 flex: 1; 2080 padding: 12px; 2081 border: 2px solid #ddd; 2082 border-radius: 8px; 2083 font-size: 15px; 2084 font-weight: 600; 2085 cursor: pointer; 2086 background: white; 2087 color: #666; 2088 transition: all 0.2s; 2089 } 2090 2091 .mobile-zone-btn.mobile-zone-positive { 2092 border-color: #34a853; 2093 color: #34a853; 2094 } 2095 2096 .mobile-zone-btn.mobile-zone-positive.active { 2097 background: #34a853; 2098 color: white; 2099 } 2100 2101 .mobile-zone-btn.mobile-zone-negative { 2102 border-color: #ea4335; 2103 color: #ea4335; 2104 } 2105 2106 .mobile-zone-btn.mobile-zone-negative.active { 2107 background: #ea4335; 2108 color: white; 2109 } 2110 2111 .mobile-peinturer-btn { 2112 width: 100%; 2113 padding: 14px; 2114 background: white; 2115 color: #1a73e8; 2116 border: 3px solid #1a73e8; 2117 border-radius: 12px; 2118 font-size: 16px; 2119 font-weight: 600; 2120 cursor: pointer; 2121 transition: all 0.2s; 2122 position: relative; 2123 } 2124 2125 .mobile-peinturer-btn.active { 2126 background: #1a73e8; 2127 color: white; 2128 } 2129 2130 .mobile-action-btn { 2131 flex: 1; 2132 padding: 10px 6px; 2133 background: white; 2134 color: #666; 2135 border: 2px solid #ddd; 2136 border-radius: 6px; 2137 font-size: 12px; 2138 font-weight: 600; 2139 cursor: pointer; 2140 transition: all 0.2s; 2141 position: relative; 2142 } 2143 2144 .mobile-action-btn:active { 2145 background: #f5f5f5; 2146 } 2147 2148 /* Mobile tool buttons (Naviguer, Peinturer, Effacer) */ 2149 .mobile-tool-btn { 2150 flex: 1; 2151 padding: 12px 8px; 2152 background: white; 2153 color: #666; 2154 border: 2px solid #ddd; 2155 border-radius: 8px; 2156 font-size: 14px; 2157 font-weight: 600; 2158 cursor: pointer; 2159 transition: all 0.2s; 2160 position: relative; 2161 } 2162 2163 .mobile-tool-btn.active { 2164 border-color: #1a73e8; 2165 background: #1a73e8; 2166 color: white; 2167 } 2168 2169 .mobile-tool-btn.mobile-peinturer { 2170 border-color: #1a73e8; 2171 color: #1a73e8; 2172 } 2173 2174 .mobile-tool-btn.mobile-peinturer.active { 2175 color: white; 2176 } 2177 2178 /* Mobile paint hint (on Peinturer button) */ 2179 .mobile-paint-hint { 2180 display: block; 2181 position: absolute; 2182 top: -8px; 2183 right: 6px; 2184 background: #4285f4; 2185 color: white; 2186 padding: 4px 8px; 2187 border-radius: 6px; 2188 font-size: 10px; 2189 font-weight: 600; 2190 white-space: nowrap; 2191 z-index: 10; 2192 animation: hintPulse 2s ease-in-out infinite; 2193 } 2194 2195 /* Mobile tool button checkmark */ 2196 .mobile-tool-checkmark { 2197 position: absolute; 2198 top: -8px; 2199 right: -8px; 2200 width: 22px; 2201 height: 22px; 2202 background: white; 2203 border: 2px solid #1a73e8; 2204 border-radius: 50%; 2205 display: none; 2206 align-items: center; 2207 justify-content: center; 2208 color: #1a73e8; 2209 font-size: 13px; 2210 font-weight: 700; 2211 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); 2212 } 2213 2214 .mobile-tool-btn.active .mobile-tool-checkmark { 2215 display: flex; 2216 } 2217 2218 /* Panel minimize button (mobile) */ 2219 .panel-minimize-btn { 2220 display: none; 2221 position: absolute; 2222 top: 12px; 2223 right: 16px; 2224 width: 32px; 2225 height: 32px; 2226 background: white; 2227 border: 2px solid #ddd; 2228 border-radius: 50%; 2229 cursor: pointer; 2230 align-items: center; 2231 justify-content: center; 2232 font-size: 18px; 2233 color: #666; 2234 z-index: 10; 2235 transition: all 0.2s; 2236 } 2237 2238 .panel-minimize-btn:hover { 2239 background: #f5f5f5; 2240 border-color: #1a73e8; 2241 color: #1a73e8; 2242 } 2243 2244 .mobile-clear-all-btn { 2245 width: 100%; 2246 padding: 8px; 2247 background: white; 2248 color: #ea4335; 2249 border: 2px solid #ea4335; 2250 border-radius: 8px; 2251 font-size: 12px; 2252 font-weight: 600; 2253 cursor: pointer; 2254 transition: all 0.2s; 2255 margin-top: 8px; 2256 } 2257 2258 /* Checkmark badge for mobile buttons */ 2259 .mobile-checkmark { 2260 position: absolute; 2261 top: -8px; 2262 right: -8px; 2263 width: 24px; 2264 height: 24px; 2265 background: white; 2266 border: 2px solid #1a73e8; 2267 border-radius: 50%; 2268 display: none; 2269 align-items: center; 2270 justify-content: center; 2271 color: #1a73e8; 2272 font-size: 14px; 2273 font-weight: 700; 2274 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); 2275 } 2276 2277 .mobile-peinturer-btn.active .mobile-checkmark, 2278 .mobile-action-btn.active .mobile-checkmark { 2279 display: flex; 2280 } 2281 2282 /* Bottom sheet handle */ 2283 .sheet-handle { 2284 display: none; 2285 width: 48px; 2286 height: 5px; 2287 background: #ddd; 2288 border-radius: 3px; 2289 margin: 8px auto 12px; 2290 } 2291 2292 @media (max-width: 768px) { 2293 /* Hide language toggle on mobile */ 2294 #lang-toggle { 2295 display: none !important; 2296 } 2297 2298 /* Hide desktop clear all button on mobile */ 2299 #clear-all-paint-btn { 2300 display: none !important; 2301 } 2302 2303 /* Bottom sheet handle visible on mobile */ 2304 .sheet-handle { 2305 display: block; 2306 } 2307 2308 /* Panel minimize button hidden on mobile */ 2309 .panel-minimize-btn { 2310 display: none !important; 2311 } 2312 2313 /* Main panel as compact bottom sheet */ 2314 .main-panel { 2315 top: auto; 2316 bottom: 0; 2317 left: 0; 2318 right: 0; 2319 width: 100%; 2320 height: 60vh; 2321 max-height: 60vh; 2322 padding: 0 12px 12px; 2323 border-radius: 20px 20px 0 0; 2324 overflow-y: auto; 2325 box-shadow: 0 -4px 20px rgba(0,0,0,0.15); 2326 transition: transform 0.3s ease; 2327 } 2328 2329 /* Step 1 specific: panel at 35vh (no scrolling) */ 2330 .main-panel:has(#step1:not(.hidden)) { 2331 height: 35vh; 2332 max-height: 35vh; 2333 overflow: hidden !important; 2334 } 2335 2336 /* Step 2 specific: panel at 44vh (no scrolling) */ 2337 .main-panel:has(#step2:not(.hidden)) { 2338 height: 44vh; 2339 max-height: 44vh; 2340 overflow: hidden !important; 2341 } 2342 2343 /* Reduce spacing in Step 2 on mobile */ 2344 #step2-paint .step-header { 2345 margin-bottom: 12px; 2346 padding: 6px 10px; 2347 font-size: 15px; 2348 } 2349 2350 .mobile-option-f >
2350 div:first-child { 2351 margin-bottom: 4px; 2352 } 2353 2354 /* Hide cluster badge and size on mobile Step 3 to save space */ 2355 .cluster-type-badge, 2356 .cluster-size { 2357 display: none !important; 2358 } 2359 2360 /* Panel minimized state */ 2361 .main-panel.minimized { 2362 transform: translateY(calc(100% - 60px)); 2363 } 2364 2365 .main-panel h2 { 2366 font-size: 15px; 2367 margin-bottom: 8px; 2368 } 2369 2370 .step-section { 2371 margin-bottom: 8px; 2372 } 2373 2374 /* Smaller buttons */ 2375 .btn, .confirm-btn, .next-btn, .back-btn, .submit-btn { 2376 min-height: 34px; 2377 font-size: 13px; 2378 padding: 6px 12px; 2379 } 2380 2381 .tool-btn { 2382 min-height: 34px; 2383 font-size: 12px; 2384 padding: 6px 10px; 2385 } 2386 2387 /* Language toggle */ 2388 .lang-toggle { 2389 top: 8px; 2390 right: 8px; 2391 font-size: 12px; 2392 padding: 4px 8px; 2393 } 2394 2395 /* Info button positioning for mobile - above bottom sheet */ 2396 .info-button { 2397 bottom: calc(45vh + 10px); 2398 left: 16px; 2399 width: 44px; 2400 height: 44px; 2401 font-size: 22px; 2402 } 2403 2404 .info-tooltip { 2405 bottom: calc(45vh + 65px); 2406 left: 16px; 2407 max-width: calc(100vw - 32px); 2408 } 2409 2410 /* Progress tracker - compact horizontal */ 2411 .progress-tracker { 2412 top: 8px; 2413 left: 8px; 2414 right: auto; 2415 padding: 6px 10px; 2416 font-size: 11px; 2417 display: flex !important; 2418 flex-direction: row; 2419 align-items: center; 2420 gap: 6px; 2421 min-width: auto; 2422 } 2423 2424 .progress-tracker .progress-title { 2425 display: none; 2426 } 2427 2428 .progress-tracker .progress-step { 2429 flex-direction: row; 2430 gap: 3px; 2431 } 2432 2433 .progress-step-text { 2434 display: none; 2435 } 2436 2437 .progress-circle { 2438 width: 20px; 2439 height: 20px; 2440 font-size: 10px; 2441 } 2442 2443 /* Hide map nav controls (arrow buttons) on mobile */ 2444 .map-nav-controls { 2445 display: none !important; 2446 } 2447 2448 /* Tutorial overlay */ 2449 .tutorial-card { 2450 margin: 10px; 2451 max-width: calc(100vw - 20px); 2452 padding: 16px; 2453 } 2454 2455 .tutorial-title { font-size: 18px; } 2456 .tutorial-body { font-size: 14px; } 2457 2458 /* Welcome popup */ 2459 .welcome-overlay { 2460 align-items: stretch; 2461 padding: 0; 2462 background: white; 2463 } 2464 2465 .welcome-popup { 2466 padding: 20px 20px 80px; 2467 max-width: 100vw; 2468 width: 100vw; 2469 height: 100vh; 2470 margin: 0; 2471 border-radius: 0; 2472 overflow-y: scroll; 2473 display: flex; 2474 flex-direction: column; 2475 justify-content: space-between; 2476 position: relative; 2477 -webkit-overflow-scrolling: touch; 2478 } 2479 2480 /* Visible scrollbar on mobile */ 2481 .welcome-popup::-webkit-scrollbar {
2482 width: 8px; 2483 display: block; 2484 } 2485 2486 .welcome-popup::-webkit-scrollbar-track { 2487 background: #f0f0f0; 2488 border-radius: 4px; 2489 } 2490 2491 .welcome-popup::-webkit-scrollbar-thumb { 2492 background: #1a73e8; 2493 border-radius: 4px; 2494 } 2495 2496 .welcome-popup::-webkit-scrollbar-thumb:hover { 2497 background: #1557b0; 2498 } 2499 2500 .welcome-popup .lang-toggle-container { 2501 position: static; 2502 display: flex; 2503 justify-content: center; 2504 margin-bottom: 20px; 2505 } 2506 2507 .welcome-overlay .popup { 2508 margin: 10px; 2509 max-width: calc(100vw - 20px); 2510 padding: 20px; 2511 } 2512 2513 .welcome-logo { 2514 width: 80px; 2515 height: 80px; 2516 } 2517 2518 .city-name { 2519 font-size: 30px !important; 2520 letter-spacing: 2px !important; 2521 } 2522 2523 .app-title { font-size: 24px !important; } 2524 2525 /* Tag options grid */ 2526 .tag-options { grid-template-columns: 1fr; } 2527 2528 /* Zoom prompt */ 2529 .zoom-prompt { 2530 bottom: calc(40vh + 10px); 2531 padding: 8px 16px; 2532 font-size: 12px; 2533 } 2534 2535 /* Smaller labels */ 2536 .cluster-size, .cluster-type-badge { font-size: 12px; } 2537 .step-header { font-size: 13px; padding: 5px 8px; } 2538 .step-label { font-size: 12px; } 2539 .control-label { font-size: 13px; } 2540 2541 /* Smaller canvas on mobile */ 2542 #mosaic-canvas { 2543 max-width: 90vw !important; 2544 } 2545 2546 /* Button groups */ 2547 .button-group { 2548 flex-direction: column; 2549 gap: 6px; 2550 } 2551 2552 .button-group .btn { width: 100%; } 2553 2554 /* Custom alert */ 2555 .custom-alert { 2556 width: 100vw; 2557 max-width: none; 2558 height: 100vh; 2559 max-height: 100vh; 2560 margin: 0; 2561 border-radius: 0; 2562 overflow-y: scroll !important; 2563 -webkit-overflow-scrolling: touch; 2564 padding: 80px 24px 60px !important; 2565 box-sizing: border-box; 2566 } 2567 2568 /* Visible scrollbar on mobile for submission screen */ 2569 .custom-alert::-webkit-scrollbar {
2570 width: 12px !important; 2571 display: block !important; 2572 } 2573 2574 .custom-alert::-webkit-scrollbar-track { 2575 background: #f0f0f0 !important; 2576 border-radius: 6px; 2577 } 2578 2579 .custom-alert::-webkit-scrollbar-thumb { 2580 background: #1a73e8 !important; 2581 border-radius: 6px; 2582 } 2583 2584 .custom-alert::-webkit-scrollbar-thumb:hover { 2585 background: #1557b0 !important; 2586 } 2587 2588 /* Slider */ 2589 input[type="range"] { width: 100%; } 2590 2591 /* === MOBILE SIMPLIFICATION === */ 2592 2593 /* Step 1: Hide method choice, show address only */ 2594 #step1 .step-label:first-of-type, 2595 #step1 .button-group { 2596 display: none !important; 2597 } 2598 2599 #address-section { 2600 display: block !important; 2601 } 2602 2603 #coords-display { 2604 display: none !important; 2605 } 2606 2607 /* Step 2: Hide size slider, paint targets, display options, original tool/color selectors */ 2608 #size-controls, 2609 .paint-target-selector, 2610 #display-options-header, 2611 #display-options-content, 2612 .tool-selector-row, 2613 .tag-mode-selector, 2614 #step2-paint > .step-label { 2615 display: none !important; 2616 } 2617 2618 /* Show mobile-only controls */ 2619 .mobile-option-f { 2620 display: block !important; 2621 } 2622 2623 /* Step 3: Hide split section on mobile */ 2624 .split-section { 2625 display: none !important; 2626 } 2627 } 2628 2629 @media (max-width: 480px) { 2630 .main-panel { 2631 max-height: 38vh; 2632 padding: 8px 10px; 2633 } 2634 2635 .main-panel h2 { font-size: 14px; } 2636 .step-header { font-size: 12px; padding: 4px 6px; } 2637 2638 .city-name { font-size: 24px !important; } 2639 .app-title { font-size: 20px !important; } 2640 .welcome-overlay .popup p { font-size: 13px; } 2641 } 2642 2643 </style> 2644</head> 2645<body> 2646 <!-- Progress Tracker --> 2647 <div class="progress-tracker" id="progress-tracker" style="display: none;"> 2648 <div class="progress-title"> 2649 <span data-en="Progress" data-fr="Progression">Progression</span> 2650 </div> 2651 <div class="progress-step" id="progress-step-1"> 2652 <div class="progress-circle">1</div> 2653 <div class="progress-step-text" data-en="Select neighbourhood" data-fr="Sélectionner son quartier">Sélectionner son quartier</div> 2654 </div> 2655 <div class="progress-step" id="progress-step-2"> 2656 <div class="progress-circle">2</div> 2657 <div class="progress-step-text" data-en="Paint" data-fr="Taguer">Taguer</div> 2658 </div> 2659 <div class="progress-step" id="progress-step-3"> 2660 <div class="progress-circle">3</div> 2661 <div class="progress-step-text" data-en="Label" data-fr="Ãtiqueter">Ãtiqueter</div> 2662 </div> 2663 <div class="progress-step" id="progress-step-4"> 2664 <div class="progress-circle">4</div> 2665 <div class="progress-step-text" data-en="Submit" data-fr="Soumettre">Soumettre</div> 2666 </div> 2667 </div> 2668 2669 <!-- Tutorial Overlay --> 2670 <div class="tutorial-overlay hidden" id="tutorial-overlay"> 2671 <div class="tutorial-card"> 2672 <div class="tutorial-step-content" id="tutorial-content"> 2673 <!-- Content will be dynamically inserted --> 2674 </div> 2675 </div> 2676 </div> 2677 2678 <!-- Loading Overlay --> 2679 <div class="loading-overlay hidden" id="loading-overlay"> 2680 <div class="loading-spinner"></div> 2681 <div class="loading-text"> 2682 <span data-en="Loading buildings..." data-fr="Chargement des bâtiments...">Chargement des bâtiments...</span> 2683 </div> 2684 </div> 2685 2686 <div style="position: fixed; top: 20px; right: 20px; z-index: 1000;"> 2687 <button id="lang-toggle" style="padding: 8px 16px; background: white; border: 2px solid #4285f4; border-radius: 20px; font-size: 14px; font-weight: 500; cursor: pointer; color: #4285f4; box-shadow: 0 2px 8px rgba(0,0,0,0.15);">FR / EN</button> 2688 </div> 2689 2690 2691<!-- Password Protection Screen --> 2692<div class="welcome-overlay" id="password-overlay" style="display: none;"> 2693 <div class="welcome-popup" style="max-width: 400px;"> 2694 <!-- Language Toggle --> 2695 <div class="lang-toggle-container"> 2696 <div class="lang-toggle" id="password-lang-toggle"> 2697 <div class="lang-toggle-slider">FR</div> 2698 <div class="lang-toggle-labels">
2699 <span class="lang-toggle-label fr">FR</span> 2700 <span class="lang-toggle-label en">EN</span> 2701 </div> 2702 </div> 2703 </div> 2704 2705 <div style="text-align: center; margin-bottom: 24px;"> 2706 <div style="width: 80px; height: 80px; margin: 0 auto 16px; background: #003366; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 40px;"> 2707 ð 2708 </div> 2709 <h2 style="margin: 0 0 12px 0; font-size: 24px; color: #333;" data-en="Access Required" data-fr="Accès requis">Accès requis</h2> 2710 <p style="margin: 0; color: #666; font-size: 14px;" data-en="Please enter the password to access the tool." data-fr="Veuillez entrer le mot de passe pour accéder à l'outil.">Veuillez entrer le mot de passe pour accéder à l'outil.</p> 2711 </div> 2712 <input type="password" id="password-input" style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 6px; margin-bottom: 16px; font-size: 15px; box-sizing: border-box;" placeholder="Mot de passe"> 2713 <button class="welcome-btn primary" id="password-submit" style="width: 100%;" data-en="Enter" data-fr="Entrer">Entrer</button> 2714 <p id="password-error" style="color: #ea4335; margin-top: 12px; display: none; font-size: 13px; text-align: center;" data-en="Incorrect password" data-fr="Mot de passe incorrect">Mot de passe incorrect</p> 2715 </div> 2716</div> 2717 2718<!-- Welcome Popup --> 2719<div class="welcome-overlay" id="welcome-overlay" style="display: none;"> 2720 <div class="welcome-popup"> 2721 <!-- Language Toggle --> 2722 <div class="lang-toggle-container"> 2723 <div class="lang-toggle" id="welcome-lang-toggle"> 2724 <div class="lang-toggle-slider">FR</div> 2725 <div class="lang-toggle-labels"> 2726 <span class="lang-toggle-label fr">FR</span> 2727 <span class="lang-toggle-label en">EN</span> 2728 </div> 2729 </div> 2730 </div> 2731 2732 <div style="text-align: center; margin-bottom: 10px;"> 2733 <div style="font-family: 'Inter', sans-serif; font-size: 50px; font-weight: 700; color: #4285f4; text-transform: uppercase; letter-spacing: 4px;"> 2734 MONTRÃAL 2735 </div> 2736 </div> 2737 2738 <h1 class="welcome-title">Tag ta Ville</h1> 2739 2740 <p class="welcome-text" data-en="<strong>You know your neighbourhood better than anyone.</strong>" data-fr="<strong>Vous connaissez votre quartier mieux que quiconque.</strong>"> 2741 <strong>Vous connaissez votre quartier mieux que quiconque.</strong> 2742 </p> 2743 <p class="welcome-text" data-en="This tool allows you to identify places that improve quality of life in your area, and those that harm it." data-fr="Cet outil vous permet d'identifier les endroits qui améliorent la qualité de vie dans votre secteur, et ceux qui y nuisent."> 2744 Cet outil vous permet d'identifier les endroits qui améliorent la qualité de vie dans votre secteur, et ceux qui y nuisent. 2745 </p> 2746 <p class="welcome-text" data-en="Your contribution helps researchers better understand how urban spaces are experienced in daily life, and how they can be improved. It's quick, anonymous, and based entirely on what you observe as you move through your neighbourhood." data-fr="Votre contribution aide les chercheurs à mieux comprendre comment les espaces urbains sont vécus au quotidien et comment ils peuvent être améliorés. C'est rapide, anonyme, et basé entièrement sur ce que vous observez en vous déplaçant dans votre quartier."> 2747 Votre contribution aide les chercheurs à mieux comprendre comment les espaces urbains sont vécus au quotidien et comment ils peuvent être améliorés. C'est rapide, anonyme, et basé entièrement sur ce que vous observez en vous déplaçant dans votre quartier. 2748 </p> 2749 2750 <div class="data-collapsible"> 2751 <div class="data-collapsible-header" id="data-collapsible-toggle"> 2752 <span data-en="What do we do with this data?" data-fr="Que faisons-nous avec ces données?">Que faisons-nous avec ces données?</span> 2753 <span class="data-collapsible-arrow">â¼</span> 2754 </div> 2755 <div class="data-collapsible-content" id="data-collapsible-content"> 2756 <p data-en="The aggregated data will be used for research purposes to better understand our urban spaces. The results will be shared with decision-makers to promote better-informed urban policies. The data will not be used for any commercial purpose." data-fr="Les données agrégées seront utilisées à des fins de recherche pour mieux comprendre nos espaces urbains. Les résultats de ces travaux seront partagés avec les décideurs afin de promouvoir des politiques urbaines mieux informées. Les données ne seront utilisées à aucune fin commerciale."> 2757 Les données agrégées seront utilisées à des fins de recherche pour mieux comprendre nos espaces urbains. Les résultats de ces travaux seront partagés avec les décideurs afin de promouvoir des politiques urbaines mieux informées. Les données ne seront utilisées à aucune fin commerciale. 2758 </p> 2759 <p data-en="This project is directed by Professor Evelyne Brie, from the Department of Political Science at the University of Montreal. To learn more, or to contact us, click on "About this project."" data-fr="Ce projet est dirigé par la professeure Evelyne Brie, du département de science politique de l'Université de Montréal. Pour en savoir plus, ou pour communiquer avec nous, cliquez sur « à propos de ce projet »."> 2760 Ce projet est dirigé par la professeure Evelyne Brie, du département de science politique de l'Université de Montréal. Pour en savoir plus, ou pour communiquer avec nous, cliquez sur « à propos de ce projet ». 2761 </p> 2762 </div> 2763 </div> 2764 2765 <div class="welcome-buttons"> 2766 <button class="welcome-btn secondary" id="more-info-btn" data-en="About this project" data-fr="à propos de ce projet">à propos de ce projet</button> 2767 <button class="welcome-btn primary" id="start-btn" data-en="Get Started" data-fr="Commencer">Commencer</button> 2768 </div> 2769 2770 <!-- Logos --> 2771 <div style="margin-top: 34px; padding-top: 26px; border-top: 1px solid #e8e8e8; display: flex; align-items: center; justify-content: center; gap: 32px; flex-wrap: wrap;"> 2772 <div style="background: white; padding: 8px 16px; border-radius: 6px;"> 2773 <img src="./assets/ivado-rgb_logo-full-degrade.png" alt="IVADO" style="height: 65px; object-fit: contain;"> 2774 </div> 2775 <img src="./assets/udem-logo.svg" alt="Université de Montréal" style="height: 50px; object-fit: contain;"> 2776 </div> 2777 </div> 2778</div> 2779 2780<div id="map"></div> 2781 2782<!-- Zoom Prompt --> 2783<div class="zoom-prompt" id="zoom-prompt"> 2784 <span data-en="Zoom in closer to see 3D buildings" data-fr="Zoomez pour voir les bâtiments en 3D">Zoomez pour voir les bâtiments en 3D</span> 2785</div> 2786 2787<!-- Info Button --> 2788<button class="info-button" id="info-button">i</button> 2789<div class="info-tooltip" id="info-tooltip"> 2790 <button class="info-tooltip-close" id="info-tooltip-close">Ã</button> 2791 <div class="info-tooltip-header" id="info-tooltip-header">Step 1</div> 2792 <div class="info-tooltip-content" id="info-tooltip-content"> 2793 <!-- Content will be dynamically updated --> 2794 </div> 2795</div> 2796 2797<!-- Main Control Panel - Top Left --> 2798<div class="panel main-panel"> 2799 <div class="sheet-handle"></div> 2800 <button class="panel-minimize-btn" id="panel-minimize-btn">â¼</button> 2801 2802 <!-- STEP 1: Select & Load --> 2803 <div class="step-section" id="step1"> 2804 <div class="step-header"><span data-en="Step 1: Select Your Neighbourhood" data-fr="Ãtape 1 : Sélectionnez votre quartier">Ãtape 1 : Sélectionnez votre quartier</span></div> 2805 2806 <span class="step-label"><span data-en="Selection Method" data-fr="Méthode de sélection">Méthode de sélection</span></span> 2807 <div class="button-group">
2808 <button class="btn active" id="btn-click"><span data-en="Click Map" data-fr="Cliquer sur la carte">Cliquer sur la carte</span></button> 2809 <button class="btn" id="btn-address"><span data-en="Enter Address" data-fr="Entrer une adresse">Entrer une adresse</span></button> 2810 </div> 2811 2812 <div class="hidden" id="address-section"> 2813 <input 2814 type="text" 2815 class="address-input" 2816 id="address-input" 2817 placeholder="Entrez une adresse à Montréal" 2818 autocomplete="off" 2819 /> 2820 <div class="address-suggestions hidden" id="address-suggestions"></div> 2821 </div> 2822 2823 2824 <div class="hidden" id="location-selected"> 2825 <div class="selected-info"> 2826 <div><strong data-fr="Quartier sélectionné (750 mètres)" data-en="Neighbourhood Selected (750 meters)">Quartier sélectionné (750 mètres)</strong></div> 2827 <div id="coords-display">Waiting...</div> 2828 </div> 2829 <button class="confirm-btn" id="confirm-btn" data-fr="Visualiser mon quartier" data-en="Visualize My Neighbourhood">Visualiser mon quartier</button> 2830 </div> 2831 </div> 2832 2833 <!-- STEP 2: Paint Areas (Combined Red/Green) --> 2834 <div class="step-section hidden" id="step2-paint"> 2835 <div class="step-header"><span data-en="Step 2: Tag Areas" data-fr="Ãtape 2 : Taguer les zones">Ãtape 2 : Taguer les zones</span></div> 2836 2837 <!-- Mobile-only segmented control --> 2838 <!-- Mobile: Zone selector + Tool buttons --> 2839 <div class="mobile-option-f" style="display: none;"> 2840 <div style="font-size: 12px; color: #666; margin-bottom: 6px; font-weight: 600;"> 2841 <span data-en="I want to tag:" data-fr="Je veux taguer:">Je veux taguer:</span> 2842 </div> 2843 <div style="display: flex; gap: 8px; margin-bottom: 12px;"> 2844 <button class="mobile-zone-btn mobile-zone-positive active" id="mobile-zone-positive"> 2845 <span data-en="Positive Zone" data-fr="Zone positive">Zone positive</span> 2846 </button> 2847 <button class="mobile-zone-btn mobile-zone-negative" id="mobile-zone-negative"> 2848 <span data-en="Negative Zone" data-fr="Zone négative">Zone négative</span> 2849 </button> 2850 </div> 2851 2852 <div style="display: flex; gap: 6px;"> 2853 <button class="mobile-tool-btn active" id="mobile-navigate-btn"> 2854 <span class="mobile-tool-checkmark">â</span> 2855 <span data-en="Navigate" data-fr="Naviguer">Naviguer</span> 2856 </button> 2857 <button class="mobile-tool-btn mobile-peinturer" id="mobile-peinturer-btn"> 2858 <span class="mobile-paint-hint" data-en="Click here!" data-fr="Cliquez ici !">Cliquez ici !</span> 2859 <span class="mobile-tool-checkmark">â</span> 2860 <span data-en="Paint" data-fr="Peinturer">Peinturer</span> 2861 </button> 2862 <button class="mobile-tool-btn" id="mobile-erase-btn"> 2863 <span class="mobile-tool-checkmark">â</span> 2864 <span data-en="Erase" data-fr="Effacer">Effacer</span> 2865 </button> 2866 </div> 2867 </div> 2868 2869 <span class="step-label"><span data-en="Select Tool" data-fr="Sélectionnez l'outil">
2869Sélectionnez l'outil</span></span> 2870 <div class="tool-selector-row"> 2871 <button class="tool-btn active" id="tool-cursor"> 2872 <span class="cursor-icon"> 2873 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 2874 <path d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/> 2875 </svg> 2876 </span> 2877 </button> 2878 <button class="tool-btn" id="tool-paint" style="position: relative;"> 2879 <div class="paint-hint" id="paint-hint-desktop" data-en="Click here!" data-fr="Cliquez ici !">Cliquez ici !</div> 2880 <span data-en="Tag Can" data-fr="Peinturer">Peinturer</span> 2881 </button> 2882 <button class="tool-btn" id="tool-erase"> 2883 <span data-en="Eraser" data-fr="Effacer">Effacer</span> 2884 </button> 2885 </div> 2886 2887 <div id="size-controls"> 2888 <div class="spray-size-control"> 2889 <label class="control-label"> 2890 <span id="tool-size-label" data-en="Tool Size" data-fr="Taille de l'outil">
2890Taille de l'outil</span>: <strong><span id="tool-size-value">10</span>m</strong> 2891 </label> 2892 <input type="range" min="2" max="20" value="10" class="slider" id="tool-size-slider" /> 2893 </div> 2894 </div> 2895 2896 <span class="step-label"><span data-en="Select Zone Type" data-fr="Sélectionnez le type de zone">Sélectionnez le type de zone</span></span> 2897 <div class="tag-mode-selector"> 2898 <button class="tag-mode-btn safe-mode active" id="paint-green"> 2899 <span data-en="Positive Zone" data-fr="Zone positive">Zone positive</span> 2900 </button> 2901 <button class="tag-mode-btn danger-mode" id="paint-red"> 2902 <span data-en="Negative Zone" data-fr="Zone négative">Zone négative</span> 2903 </button> 2904 </div> 2905 2906 <div class="paint-target-selector"> 2907 <label class="toggle-label"> 2908 <input type="checkbox" id="paint-buildings" checked /> 2909 <span data-en="Paint Buildings" data-fr="Peindre les bâtiments">Peindre les bâtiments</span> 2910 </label> 2911 <label class="toggle-label"> 2912 <input type="checkbox" id="paint-ground" checked /> 2913 <span data-en="Paint Ground" data-fr="Peindre le sol">Peindre le sol</span> 2914 </label> 2915 </div> 2916 2917 <!-- View Controls in Step 2 - Collapsible --> 2918 <div class="step-label collapsible-header" id="display-options-header" style="margin-top: 15px; cursor: pointer; user-select: none;"> 2919 <span data-en="Display Options" data-fr="Options d'affichage">Options d'affichage</span> 2920 <span style="float: right;">â¼</span> 2921 </div> 2922 <div class="paint-target-selector" id="display-options-content"> 2923 <label class="toggle-label"> 2924 <input type="checkbox" id="toggle-buildings" checked /> 2925 <span data-en="Show Buildings" data-fr="Afficher les bâtiments">Afficher les bâtiments</span> 2926 </label> 2927 <label class="toggle-label"> 2928 <input type="checkbox" id="toggle-satellite" /> 2929 <span data-en="Satellite View" data-fr="Vue satellite">Vue satellite</span> 2930 </label> 2931 </div> 2932 2933 <button class="clear-btn" id="clear-all-paint-btn"><span data-en="Clear All" data-fr="Tout effacer">Tout effacer</span></button> 2934 2935 <button class="next-btn" id="next-to-tag-clusters-btn"> 2936 <span data-en="Next: Label Tagged Areas â" data-fr="Suivant : Ãtiqueter les zones â">Suivant : Ãtiqueter les zones â</span> 2937 </button> 2938 2939 <button class="back-btn" id="back-from-step2"> 2940 <span data-en="â Back to Neighbourhood Selection" data-fr="â Retour à la sélection de quartier">â Retour à la sélection de quartier</span> 2941 </button> 2942 </div> 2943 2944 <!-- STEP 3: Tag Clusters (One at a time) --> 2945 <div class="step-section hidden" id="step3-tag-clusters"> 2946 <div class="step-header"> 2947 <span data-en="Step 3: Label area" data-fr="Ãtape 3: Ãtiqueter la zone">Ãtape 3: Ãtiqueter la zone</span> <span id="cluster-number">1</span> <span data-en="of" data-fr="de">de</span> <span id="cluster-total">5</span> 2948 </div> 2949 2950 <div class="cluster-type-badge" id="cluster-type-badge"> 2951 Disliked Area 2952 </div> 2953 2954 <div class="cluster-size" id="cluster-size"> 2955 97 voxels 2956 </div> 2957 2958 <div class="split-section"> 2959 <div class="split-section-header" id="split-section-toggle"> 2960 <div style="display: flex; align-items: center;"> 2961 <span data-en="Split this area into 2 areas" data-fr="Diviser cette zone en 2 zones">Diviser cette zone en 2 zones</span> 2962 <span class="split-info-icon" id="split-info-icon" title="Plus d'informations">i</span> 2963 </div> 2964 <span class="split-section-arrow">â¼</span> 2965 </div> 2966 <div class="split-section-content" id="split-section-content"> 2967 <button class="split-cluster-btn" id="split-cluster-btn"> 2968 <span data-en="Split Automatically" data-fr="Diviser automatiquement">Diviser automatiquement</span> 2969 </button> 2970 </div> 2971 </div> 2972 2973 <!-- Split success notification (hidden by default) --> 2974 <div class="split-success-msg hidden" id="split-success-msg" style="margin-bottom: 15px; padding: 12px; background: #4caf50; color: white; border-radius: 6px; font-weight: 600; text-align: center; animation: fadeInOut 2s;">
2975 <span data-en="â Automatically split!" data-fr="â Division automatique effectuée !">â Division automatique effectuée !</span> 2976 </div> 2977 2978 <div id="tag-instruction-text" style="margin-bottom: 20px; font-size: 14px; color: #333; font-weight: 400;"> 2979 <span data-en="Select all applicable attributes:" data-fr="Sélectionnez tous les attributs applicables:">Sélectionnez tous les attributs applicables:</span> 2980 </div> 2981 2982 <div id="tag-categories-container"></div> 2983 2984 <textarea 2985 class="cluster-comment-full" 2986 id="current-cluster-comment" 2987 data-placeholder-en="Additional comments..." 2988 data-placeholder-fr="Commentaires additionnels..." 2989 placeholder="Commentaires additionnels..."></textarea> 2990 2991 <div class="cluster-nav-buttons"> 2992 <button class="nav-cluster-btn" id="prev-cluster-btn" disabled> 2993 <span data-en="â Previous" data-fr="â Précédent">â Précédent</span> 2994 </button> 2995 <button class="nav-cluster-btn primary" id="next-cluster-btn"> 2996 <span data-en="Next â" data-fr="Suivant â">Suivant â</span> 2997 </button> 2998 </div> 2999 3000 <button class="back-btn" id="back-from-step3"> 3001 <span data-en="â Back to Painting" data-fr="â Retour à la peinture">â Retour à la peinture</span> 3002 </button> 3003 </div> 3004 3005 <!-- STEP 4: Submit --> 3006 <div class="step-section hidden" id="step4-submit"> 3007 <div class="step-header"><span data-en="Step 4: Submit Your Tags" data-fr="Ãtape 4 : Soumettre vos étiquettes">Ãtape 4 : Soumettre vos étiquettes</span></div> 3008 3009 <div class="submit-info"> 3010 <span data-en="Your tagged areas and labels will be saved for safety analysis and urban planning." data-fr="Vos zones étiquetées seront enregistrées pour l'analyse de la sécurité et la planification urbaine.">Vos zones étiquetées seront enregistrées pour l'analyse de la sécurité et la planification urbaine.</span> 3011 </div> 3012 3013 <button class="back-btn" id="back-from-step4" style="margin-bottom: 15px;"> 3014 <span data-en="â Back to Tags" data-fr="â Retour aux étiquettes">â Retour aux étiquettes</span> 3015 </button> 3016 3017 <button class="submit-btn" id="submit-btn"> 3018 <span data-en="Submit" data-fr="Soumettre">Soumettre</span> 3019 </button> 3020 </div> 3021 3022 <button class="reset-btn hidden" id="reset-btn"><span data-en="Reset & Start Over" data-fr="Réinitialiser">Réinitialiser</span></button> 3023</div> 3024 3025</div> 3026 3027<!-- On-Screen Map Navigation - Visible in Step 1 & 2 --> 3028<div class="map-nav-controls" id="map-nav-controls"> 3029 <div style="margin-bottom: 12px;"> 3030 <h3 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 600;"><span data-en="View Controls" data-fr="Contrôle de vue">Contrôle de vue</span></h3> 3031 <div class="control-group"> 3032 <label class="control-label" style="font-size: 12px;"> 3033 <span data-en="Viewing Angle:" data-fr="Angle de vue :">Angle de vue :</span> <span id="pitch-value">45</span>° 3034 </label> 3035 <input type="range" min="0" max="85" value="45" class="slider" id="pitch-slider" style="width: 100%;" /> 3036 </div> 3037 </div> 3038 3039 <div class="map-nav-grid"> 3040 <div></div> 3041 <button class="map-nav-btn" id="map-nav-up" title="Pan Up">â</button> 3042 <button class="map-nav-btn rotate" id="map-rotate-left" title="Rotate Left">â¶</button> 3043 <button class="map-nav-btn" id="map-nav-left" title="Pan Left">â</button> 3044 <button class="map-nav-btn zoom" id="map-zoom-in" title="Zoom In">+</button> 3045 <button class="map-nav-btn" id="map-nav-right" title="Pan Right">â</button> 3046 <button class="map-nav-btn rotate" id="map-rotate-right" title="Rotate Right">â·</button> 3047 <button class="map-nav-btn" id="map-nav-down" title="Pan Down">â</button> 3048 <button class="map-nav-btn zoom" id="map-zoom-out" title="Zoom Out">â</button> 3049 </div> 3050 <div class="map-nav-label"><span data-en="Navigate, rotate & zoom" data-fr="Naviguer, pivoter et zoomer">Naviguer, pivoter et zoomer</span></div> 3051</div> 3052
3053<script> 3054mapboxgl.accessToken = 'pk.eyJ1IjoiZXZlbHluZWJyaWUiLCJhIjoiY2themE5OGF2MDdxazJybG9oNzUyaXoxNSJ9.njPe2lcTp82DKjDeGkHaQA'; 3055 3056const MONTREAL_CENTER = [-73.5673, 45.5017]; 3057const MONTREAL_GRID_ROTATION = -33; 3058const RADIUS_KM = 0.75; // 750 meters 3059 3060let map, selectedPoint = null, selectionMode = 'click', centerMarker = null; 3061let allBuildingsData = null, dataLoaded = false; 3062let voxelsLoaded = false; 3063let currentStep = 1; // Track current step (1: select, 2: paint, 3: tag) 3064let currentPaintColor = 'liked'; // 'disliked' or 'liked' - DEFAULT TO LIKED 3065let currentTool = 'cursor'; 3066let clickMapMode = false; // Only true when 'Click Map' button is clicked // Start with cursor for free exploration // 'cursor', 'paint', 'erase' 3067let toolSize = 10; // DEFAULT 10M 3068let paintBuildings = true; 3069let paintGround = true; 3070let groundPaintMarkers = []; 3071let groundPaintIdCounter = 0; // Unique ID counter for ground paint markers 3072let dislikedVoxels = new Set(); 3073let likedVoxels = new Set(); 3074let allVoxelFeatures = []; 3075let voxelSpatialIndex = null; 3076let isDragging = false; 3077let clusterLabels = {}; 3078let clusterMarkers = []; 3079let splitDividerLines = []; // Store IDs of split divider line layers 3080let selectedClusterId = null; 3081let lastPaintTime = 0; 3082let paintQueue = []; 3083let updateScheduled = false; 3084let currentClusterIndex = 0; 3085let allClusters = []; 3086let selectedPaintHeight = 0; // Default to ground level 3087let isMapMoving = false; 3088let mouseMoveScheduled = false; 3089let lastMouseEvent = null; 3090let paintedOverlays = []; // Store painted overlay features 3091let overlayIdCounter = 0; 3092let paintedVoxelFeatures = []; // Single array of all painted voxel overlays 3093let paintedVoxelIndex = new Map(); // Map voxelIndex -> array index for fast lookup 3094let paintUpdatePending = false; 3095 3096let voxelOriginalHeights = new Map(); // Store original heights: voxelIndex -> {base, height} 3097 3098 3099 3100// Custom Alert System - Fix 6 3101function showCustomAlert(title, message) { 3102 const overlay = document.createElement('div'); 3103 overlay.className = 'custom-alert-overlay'; 3104 overlay.innerHTML = ` 3105 <div class="custom-alert"> 3106 <h3>${title}</h3> 3107 <div class="custom-alert-content">${message}</div> 3108 <button class="custom-alert-button" onclick="this.closest('.custom-alert-overlay').remove()"> 3109 OK 3110 </button> 3111 </div> 3112 `; 3113 document.body.appendChild(overlay); 3114 overlay.addEventListener('click', (e) => { 3115 if (e.target === overlay) overlay.remove(); 3116 }); 3117} 3118 3119function showSubmissionErrorAlert(title, message) { 3120 const lang = (typeof currentLang !== 'undefined') ? currentLang : 'fr'; 3121 const overlay = document.createElement('div'); 3122 overlay.className = 'custom-alert-overlay'; 3123 overlay.innerHTML = ` 3124 <div class="custom-alert"> 3125 <h3>${title}</h3> 3126 <div class="custom-alert-content">${message}</div> 3127 3128 <div style="margin-top: 24px; padding-top: 24px; border-top: 1px solid #e8e8e8;"> 3129 <label style="display: block; font-weight: 600; margin-bottom: 8px; color: #333;"> 3130 ${lang === 'fr' ? 'Commentaires ou suggestions?' : 'Any comments or suggestions?'} 3131 </label> 3132 <textarea id="error-feedback" 3133 style="width: 100%; min-height: 80px; padding: 10px; border: 2px solid #ddd; border-radius: 6px; font-family: inherit; font-size: 14px; resize: vertical;" 3134 placeholder="${lang === 'fr' ? 'Ãcrivez ici...' : 'Write here...'}"></textarea> 3135 <button id="submit-feedback-btn" class="custom-alert-button" 3136 style="margin-top: 10px; background: #34a853; width: auto; padding: 10px 24px; font-size: 14px;"> 3137 ${lang === 'fr' ? 'Envoyer le commentaire' : 'Submit Feedback'} 3138 </button> 3139 </div> 3140 3141 <button class="custom-alert-button" id="error-finish-btn" style="margin-top: 20px;"> 3142 ${lang === 'fr' ? 'Terminer' : 'Finish'} 3143 </button> 3144 </div> 3145 `; 3146 document.body.appendChild(overlay); 3147 3148 // Handle feedback submission 3149 overlay.querySelector('#submit-feedback-btn').addEventListener('click', async () => { 3150 const feedbackText = overlay.querySelector('#error-feedback').value.trim(); 3151 if (!feedbackText) { 3152 alert(lang === 'fr' ? 'Veuillez entrer un commentaire.' : 'Please enter a comment.'); 3153 return; 3154 } 3155 3156 const btn = overlay.querySelector('#submit-feedback-btn'); 3157 btn.disabled = true; 3158 btn.textContent = lang === 'fr' ? 'Envoi...' : 'Sending...'; 3159 3160 try { 3161 // Send feedback to backend 3162 await fetch('https://tag-ta-ville-production.up.railway.app/
3162api/feedback', { 3163 method: 'POST', 3164 headers: { 'Content-Type': 'application/json' }, 3165 body: JSON.stringify({ feedback: feedbackText, type: 'submission_error' }) 3166 }); 3167 3168 btn.textContent = lang === 'fr' ? 'â Envoyé' : 'â Sent'; 3169 btn.style.background = '#1e7e34'; 3170 overlay.querySelector('#error-feedback').value = ''; 3171 } catch (err) { 3172 console.error('Feedback submission error:', err); 3173 btn.textContent = lang === 'fr' ? 'Erreur - Réessayer' : 'Error - Retry'; 3174 btn.disabled = false; 3175 } 3176 }); 3177 3178 // Handle finish button - hard reset 3179 overlay.querySelector('#error-finish-btn').addEventListener('click', () => { 3180 overlay.remove(); 3181 // Perform hard reset 3182 location.reload(); 3183 }); 3184 3185 overlay.addEventListener('click', (e) => { 3186 if (e.target === overlay) { 3187 overlay.remove(); 3188 location.reload(); 3189 } 3190 }); 3191} 3192 3193function showCustomConfirm(title, message, onConfirm) { 3194 const overlay = document.createElement('div'); 3195 overlay.className = 'custom-alert-overlay'; 3196 const lang = (typeof currentLang !== 'undefined') ? currentLang : 'fr'; 3197 overlay.innerHTML = ` 3198 <div class="custom-alert"> 3199 <h3>${title}</h3> 3200 <div class="custom-alert-content">${message}</div> 3201 <div style="display: flex; gap: 10px; margin-top: 20px;"> 3202 <button class="custom-alert-button" style="background: #6c757d; flex: 1;" id="confirm-cancel"> 3203 ${lang === 'fr' ? 'Annuler' : 'Cancel'} 3204 </button> 3205 <button class="custom-alert-button" style="background: #1a73e8; flex: 1;" id="confirm-ok"> 3206 OK 3207 </button> 3208 </div> 3209 </div> 3210 `; 3211 document.body.appendChild(overlay); 3212 3213 overlay.querySelector('#confirm-ok').addEventListener('click', () => { 3214 overlay.remove(); 3215 onConfirm(true); 3216 }); 3217 3218 overlay.querySelector('#confirm-cancel').addEventListener('click', () => { 3219 overlay.remove(); 3220 onConfirm(false); 3221 }); 3222 3223 overlay.addEventListener('click', (e) => { 3224 if (e.target === overlay) { 3225 overlay.remove(); 3226 onConfirm(false); 3227 } 3228 }); 3229} 3230 3231// Tag options for Step 3 â aligned with French version 3232const TAG_OPTIONS = { 3233 "Safety": { 3234 positive: [ 3235 "Lighting & visibility", 3236 "Road signage", 3237 "Pedestrian & cycling amenities", 3238 "Motor vehicle speed" 3239 ], 3240 negative: [ 3241 "Lighting & visibility", 3242 "Road signage", 3243 "Pedestrian & cycling amenities", 3244 "Motor vehicle speed" 3245 ] 3246 }, 3247 3248 "Cleanliness & Maintenance": { 3249 positive: [ 3250 "Cleanliness of public spaces", 3251 "Condition of roadways & sidewalks", 3252 "Condition of street furniture & building facades", 3253 "Vegetation management" 3254 ], 3255 negative: [ 3256 "Cleanliness of public spaces", 3257 "Condition of roadways & sidewalks", 3258 "Condition of street furniture & building facades", 3259 "Vegetation management" 3260 ] 3261 }, 3262 3263 "Atmosphere": { 3264 positive: [ 3265 "Presence of green spaces", 3266 "Noise level", 3267 "Presence of art & culture", 3268 "Family suitability" 3269 ], 3270 negative: [ 3271 "Lack of green spaces", 3272 "Noise level", 3273 "Absence of art & culture", 3274 "Family unsuitability" 3275 ] 3276 }, 3277 3278 "Access & Services": { 3279 positive: [ 3280 "Access to public transit", 3281 "Proximity to essential services", 3282 "Universal accessibility" 3283 ], 3284 negative: [ 3285 "No access to public transit", 3286 "No proximity to essential services", 3287 "No universal accessibility" 3288 ] 3289 }, 3290 3291 "Seasonal Conditions": { 3292 positive: [ 3293 "SPRING No meltwater accumulation", 3294 "SUMMER Presence of shade", 3295 "SUMMER Access to water for cooling", 3296 "FALL Fallen leaves cleared", 3297 "WINTER Snow and ice cleared from public space", 3298 "WINTER Non-slippery surfaces" 3299 ], 3300 negative: [ 3301 "SPRING Meltwater accumulation", 3302 "SUMMER Lack of shade", 3303 "SUMMER No accessible water points", 3304 "FALL Accumulation of fallen leaves", 3305 "WINTER Snow or ice obstructing public space", 3306 "WINTER Slippery surfaces" 3307 ] 3308 } 3309}; 3310 3311 3312const TAG_OPTIONS_FR = { 3313 "Sécurité": { 3314 positive: [ 3315 "Ãclairage et visibilité", 3316 "Signalisation routière", 3317 "Aménagements pour piétons et cyclistes", 3318 "Vitesse des véhicules automobiles" 3319 ], 3320 negative: [ 3321 "Ãclairage et visibilité", 3322 "Signalisation routière", 3323 "Aménagements pour piétons et cyclistes", 3324 "Vitesse des véhicules automobiles" 3325 ] 3326 }, 3327 "Propreté et entretien": { 3328 positive: [ 3329 "Propreté des espaces publics", 3330 "Ãtat de la chaussée et des trottoirs", 3331 "Ãtat du mobilier urbain et des façades", 3332 "Gestion de la végétation" 3333 ], 3334 negative: [ 3335 "Propreté des espaces publics", 3336 "Ãtat de la chaussée et des trottoirs", 3337 "Ãtat du mobilier urbain et des façades", 3338 "Gestion de la végétation" 3339 ] 3340 }, 3341 "Atmosphère": { 3342 positive: [
3343 "Présence d'espaces verts", 3344 "Niveau sonore", 3345 "Présence d'art et de culture", 3346 "Adapté aux familles" 3347 ], 3348 negative: [ 3349 "Absence d'espaces verts", 3350 "Niveau sonore", 3351 "Absence d'art et de culture", 3352 "Inadapté aux familles" 3353 ] 3354 }, 3355 "Accès et services": { 3356 positive: [ 3357 "Accès au transport collectif", 3358 "Proximité des services essentiels", 3359 "Accessibilité universelle" 3360 ], 3361 negative: [ 3362 "Pas d'accès au transport collectif", 3363 "Pas de proximité des services essentiels", 3364 "Pas d'accessibilité universelle" 3365 ] 3366 }, 3367 "Conditions saisonnières": { 3368 positive: [ 3369 "PRINTEMPS Pas d'accumulation dâeau de fonte", 3370 "ÃTà Présence d'ombre", 3371 "ÃTà Accès à l'eau pour se rafraîchir", 3372 "AUTOMNE Feuilles mortes ramassées", 3373 "HIVER Neige et glace dégagées de l'espace public", 3374 "HIVER Surfaces non-glissantes" 3375 ], 3376 negative: [ 3377 "PRINTEMPS Accumulation dâeau de fonte", 3378 "ÃTà Absence d'ombre", 3379 "ÃTà Absence de points d'eau accessibles", 3380 "AUTOMNE Accumulation de feuilles mortes", 3381 "HIVER Neige ou glace obstruant l'espace public", 3382 "HIVER Surfaces glissantes" 3383 ] 3384 } 3385}; 3386 3387 3388// Performance Optimization - Fix 2 3389let updateBatchTimeout = null; 3390let lastUpdateHash = ''; 3391let eraseUpdatePending = false; 3392 3393function batchUpdateVoxels() { 3394 if (updateBatchTimeout) return; 3395 updateBatchTimeout = setTimeout(() => { 3396 updateVoxelColors(); 3397 updatePaintedVoxelsSource(); 3398 updateBatchTimeout = null; 3399 }, 16); // ~60fps 3400} 3401 3402function batchEraseUpdate() { 3403 if (eraseUpdatePending) return; 3404 eraseUpdatePending = true; 3405 requestAnimationFrame(() => { 3406 updateVoxelColors(); 3407 updatePaintedVoxelsSource(); 3408 eraseUpdatePending = false; 3409 }); 3410} 3411 3412// Welcome popup handlers 3413 3414// Wait for DOM to be fully loaded before adding event listeners 3415document.addEventListener('DOMContentLoaded', () => { 3416 3417// Show welcome overlay on page load (password protection removed) 3418document.getElementById('welcome-overlay').style.display = 'flex'; 3419 3420// Language system 3421let currentLang = 'fr'; // Default French 3422 3423function switchLanguage(lang) { 3424 currentLang = lang; 3425 document.querySelectorAll('[data-en]').forEach(el => { 3426 // Use innerHTML for elements that contain HTML tags like <strong> 3427 if (el.getAttribute('data-' + lang).includes('<')) { 3428 el.innerHTML = el.getAttribute('data-' + lang); 3429 } else { 3430 el.textContent = el.getAttribute('data-' + lang); 3431 } 3432 }); 3433 3434 // Update placeholders 3435 if (lang === 'fr') { 3436 document.getElementById('address-input').placeholder = 'Entrez une adresse à Montréal'; 3437 // document.getElementById('password-input').placeholder = 'Mot de passe'; 3438 } else { 3439 document.getElementById('address-input').placeholder = 'Enter an address in Montreal'; 3440 // document.getElementById('password-input').placeholder = 'Password'; 3441 } 3442 3443 // Update additional comments placeholder 3444 const commentTextarea = document.getElementById('current-cluster-comment'); 3445 if (commentTextarea) { 3446 commentTextarea.placeholder = lang === 'fr' 3447 ? 'Commentaires additionnels...' 3448 : 'Additional comments...'; 3449 } 3450 3451 // Re-render tag categories if in Step 3 3452 if (currentStep === 3 && allClusters && allClusters.length > 0 && typeof currentClusterIndex !== 'undefined') { 3453 const cluster = allClusters[currentClusterIndex]; 3454 if (cluster) { 3455 renderTagCategories(cluster); 3456 } 3457 } 3458 3459 // Update info tooltip if it's visible 3460 if (typeof updateInfoTooltip === 'function') { 3461 updateInfoTooltip(); 3462 } 3463} 3464 3465// Tutorial and Progress Tracker System 3466let currentTutorialStep = 0; 3467let tutorialCompleted = false; 3468 3469const tutorialSteps = [ 3470 { 3471 step: 1, 3472 iconFr: '<img src="assets/pin_3d_glossy_blue.svg" style="width: 80px; height: 120px; object-fit: contain;">', 3473 iconEn: '<img src="assets/pin_3d_glossy_blue.svg" style="width: 80px; height: 120px; object-fit: contain;">', 3474 titleFr: 'Ãtape 1: Sélectionnez un emplacement', 3475 titleEn: 'Step 1: Select a Location', 3476 descriptionFr: 'Cliquez sur la carte pour choisir le centre de votre quartier.<br>Un cercle de 750 mètres apparaîtra autour de ce point.', 3477 descriptionEn: 'Click on the map to choose the center of your neighbourhood.<br>A 750 meter circle will appear around this point.' 3478 }, 3479 { 3480 step: 2, 3481 iconFr: '', 3482 iconEn: '', 3483 titleFr: 'Ãtape 2: Taguez les zones de votre choix', 3484 titleEn: 'Step 2: Tag Areas of Your Choice', 3485 descriptionFr: 'Sélectionnez "Peinturer" pour taguer certaines zone
3485s dans votre quartier.<br><br>Indiquez en <strong style="color: #34a853;">vert</strong> celles qui sont positives, c\'est-à -dire qui <strong>améliorent votre qualité de vie</strong>.<br><br>Indiquez en <strong style="color: #ea4335;">rouge</strong> celles qui sont négatives, c\'est-à -dire qui <strong>diminuent votre qualité de vie</strong>.', 3486 descriptionEn: 'Use the paint tool to tag certain areas in your neighbourhood.<br><br>Indicate in <strong style="color: #34a853;">green</strong> those that are positive, i.e., that <strong>improve your quality of life</strong>.<br><br>Indicate in <strong style="color: #ea4335;">red</strong> those that are negative, i.e., that <strong>diminish your quality of life</strong>.' 3487 }, 3488 { 3489 step: 3, 3490 iconFr: '', 3491 iconEn: '', 3492 titleFr: 'Ãtape 3: Ãtiquetez les zones', 3493 titleEn: 'Step 3: Label Areas', 3494 descriptionFr: 'Sélectionnez les <strong>attributs</strong> qui caractérisent chacune des zones.<br><br>Vous pouvez également inclure des <strong>commentaires individualisés</strong>.', 3495 descriptionEn: 'Select the <strong>attributes</strong> that characterize each of the areas.<br><br>You can also include <strong>individualized comments</strong>.' 3496 }, 3497 { 3498 step: 4, 3499 iconFr: '', 3500 iconEn: '', 3501 titleFr: 'Ãtape 4: Confirmez et soumettez', 3502 titleEn: 'Step 4: Confirm and Submit', 3503 descriptionFr: 'En cliquant sur "soumettre", vous transmettrez votre évaluation.<br>Un numéro de confirmation vous sera donné.', 3504 descriptionEn: 'By clicking "submit", you will transmit your evaluation.<br>A confirmation number will be given to you.' 3505 } 3506]; 3507 3508function showTutorial(stepNumber) { 3509 console.log(`showTutorial called with step ${stepNumber}`); 3510 const overlay = document.getElementById('tutorial-overlay'); 3511 const content = document.getElementById('tutorial-content'); 3512 3513 if (stepNumber < 0 || stepNumber >= tutorialSteps.length) { 3514 console.log('Invalid step number, returning'); 3515 return; 3516 } 3517 3518 const step = tutorialSteps[stepNumber]; 3519 const isLastStep = stepNumber === tutorialSteps.length - 1; 3520 3521 const icon = currentLang === 'fr' ? step.iconFr : step.iconEn; 3522 const title = currentLang === 'fr' ? step.titleFr : step.titleEn; 3523 const description = currentLang === 'fr' ? step.descriptionFr : step.descriptionEn; 3524 3525 console.log(`Tutorial step ${stepNumber}: ${title}`); 3526 3527 content.innerHTML = ` 3528 <div class="tutorial-icon">${icon}</div> 3529 <div class="tutorial-title">${title}</div> 3530 <div class="tutorial-description">${description}</div> 3531 3532 <div class="tutorial-progress"> 3533 ${tutorialSteps.map((_, i) => ` 3534 <div class="tutorial-progress-dot ${i === stepNumber ? 'active' : ''}"></div> 3535 `).join('')} 3536 </div> 3537 3538 <div class="tutorial-actions"> 3539 ${stepNumber === 3 ? ` 3540 <button class="tutorial-btn-back" onclick="backToLabeling()" style="background: white; color: #666; border: 2px solid #ddd; padding: 12px 24px; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-right: 12px;"> 3541 ${currentLang === 'fr' ? 'â Retour' : 'â Back'} 3542 </button> 3543 ` : ''} 3544 <button class="tutorial-btn-next" onclick="nextTutorial()"> 3545 ${stepNumber === 3 ? (currentLang === 'fr' ? 'Soumettre' : 'Submit') : 3546 isLastStep ? (currentLang === 'fr' ? 'Commencer' : 'Start') : 3547 (currentLang === 'fr' ? 'Suivant â' : 'Next â')} 3548 </button> 3549 </div> 3550 `; 3551 3552 console.log('Removing hidden class from tutorial overlay'); 3553 overlay.classList.remove('hidden'); 3554 currentTutorialStep = stepNumber; 3555 updateZoomPrompt(); 3556} 3557 3558function nextTutorial() { 3559 closeTutorial(); 3560 3561 // Step 3 tutorial (submit confirmation) - show step 4 submit page 3562 if (currentTutorialStep === 3) { 3563 document.getElementById('step3-tag-clusters').classList.add('hidden'); 3564 document.getElementById('step4-submit').classList.remove('hidden'); 3565 3566 // Show main panel for step 4 3567 const mainPanel = document.querySelector('.main-panel'); 3568 if (mainPanel) mainPanel.style.display = 'block'; 3569 3570 currentStep = 4; 3571 updateProgress(4); 3572 updateInfoTooltip(); 3573 } 3574
3575 if (currentTutorialStep === tutorialSteps.length - 1) { 3576 tutorialCompleted = true; 3577 } 3578} 3579 3580function backToLabeling() { 3581 closeTutorial(); 3582 3583 // Show main panel and step 3 UI 3584 const mainPanel = document.querySelector('.main-panel'); 3585 if (mainPanel) mainPanel.style.display = 'block'; 3586 3587 document.getElementById('step3-tag-clusters').classList.remove('hidden'); 3588 3589 // Restore to last cluster 3590 if (allClusters.length > 0) { 3591 currentClusterIndex = allClusters.length - 1; 3592 showCluster(currentClusterIndex); 3593 } 3594 3595 // Reset progress to step 3 3596 updateProgress(3); 3597} 3598 3599function closeTutorial() { 3600 document.getElementById('tutorial-overlay').classList.add('hidden'); 3601 updateZoomPrompt(); 3602 3603 // Show paint hint after tutorial closes (only in step 2) 3604 if (currentStep === 2) { 3605 setTimeout(() => { 3606 const isMobile = window.innerWidth <= 768; 3607 const hint = document.getElementById(isMobile ? 'paint-hint-mobile' : 'paint-hint-desktop'); 3608 if (hint) hint.style.display = 'block'; 3609 }, 500); 3610 } 3611} 3612 3613function updateProgress(stepNumber) { 3614 // Update progress tracker (only 3 steps shown) 3615 for (let i = 1; i <= 3; i++) { 3616 const stepEl = document.getElementById(`progress-step-${i}`); 3617 if (stepEl) { 3618 stepEl.classList.remove('active', 'completed'); 3619 3620 if (i < stepNumber) { 3621 stepEl.classList.add('completed'); 3622 } else if (i === stepNumber) { 3623 stepEl.classList.add('active'); 3624 } 3625 } 3626 } 3627} 3628 3629// Info Button System 3630const infoButton = document.getElementById('info-button'); 3631const infoTooltip = document.getElementById('info-tooltip'); 3632const infoTooltipClose = document.getElementById('info-tooltip-close'); 3633const infoTooltipHeader = document.getElementById('info-tooltip-header'); 3634const infoTooltipContent = document.getElementById('info-tooltip-content'); 3635 3636const stepInstructions = { 3637 1: { 3638 titleFr: 'Ãtape 1 : Sélectionnez votre quartier', 3639 titleEn: 'Step 1: Select Your Neighbourhood', 3640 contentFr: '<p>Cliquez sur la carte pour choisir le centre de votre quartier, ou entrez une adresse dans la barre de recherche. Un cercle de 750 mètres apparaîtra autour de votre point. Cliquez ensuite sur "Visualiser mon quartier" pour continuer.</p>', 3641 contentEn: '<p>Click on the map to choose your neighbourhood center, or enter an address in the search bar. A 750 meter circle will appear around your point. Then click "Visualize My Neighbourhood" to continue.</p>' 3642 }, 3643 2: { 3644 titleFr: 'Ãtape 2 : Taguer les zones', 3645 titleEn: 'Step 2: Tag Areas', 3646 contentFr: '<p>Cliquez sur "Peinturer" pour activer l\'outil de peinture. Sélectionnez <strong style="color: #34a853;">Zone positive</strong> pour les endroits qui améliorent votre qualité de vie, ou <strong style="color: #ea4335;">Zone négative</strong> pour ceux qui la diminuent. Ajustez la taille de l\'outil selon vos besoins, ou utilisez l\'effaceur pour corriger vos tags.</p>', 3647 contentEn: '<p>Click "Paint" to activate the paint tool. Select <strong style="color: #34a853;">Positive Zone</strong> for places that improve your quality of life, or <strong style="color: #ea4335;">Negative Zone</strong> for those that diminish it. Adjust the tool size as needed, or use the eraser to correct your tags.</p>' 3648 }, 3649 3: { 3650 titleFr: 'Ãtape 3 : Ãtiqueter la zone', 3651 titleEn: 'Step 3: Label Area', 3652 contentFr: '<p>Sélectionnez les attributs qui caractérisent cette zone. Plusieurs attributs peuvent être sélectionnés. Ajoutez des commentaires additionnels si désiré, ou utilisez "Diviser" si la zone couvre plusieurs endroits distincts. Cliquez sur "Suivant" pour passer à la zone suivante.</p>', 3653 contentEn: '<p>Select the attributes that characterize this area. Multiple attributes can be selected. Add additional comments if desired, or use "Split" if the area covers multiple distinct locations. Click "Next" to move to the next area.</p>' 3654 }, 3655 4: { 3656 titleFr: 'Ãtape 4 : Soumettre vos étiquettes', 3657 titleEn: 'Step 4: Submit Your Tags', 3658 contentFr: '<p>Vérifiez votre travail sur la carte. Cliquez sur "Soumettre" pour envoyer vos données. Un numéro de confirmation vous sera fourni et vos données seront utilisées pour la recherche urbaine.</p>', 3659 contentEn: '<p>Review your work on the map. Click "Submit" to send your data. A confirmation number will be provided and your data will be used for urban research.</p>' 3660 } 3661}; 3662 3663function updateInfoTooltip() { 3664 const step = currentStep; 3665 const instructions = stepInstructions[step]; 3666 if (instructions) { 3667 infoTooltipHeader.textContent = currentLang === 'fr' ? instructions.titleFr : instructions.titleEn; 3668 infoTooltipContent.innerHTML = currentLang === 'fr' ? instructions.contentFr : instructions.contentEn; 3669 } 3670} 3671 3672infoButton.addEventListener('click', () => { 3673 updateInfoTooltip(); 3674 infoTooltip.classList.toggle('visible'); 3675}); 3676 3677infoTooltipClose.addEventListener('click', () => { 3678 infoTooltip.classList.remove('visible'); 3679}); 3680 3681// Close tooltip when clicking outside 3682document.addEventListener('click', (e) => { 3683 if (!infoButton.contains(e.target) && !infoTooltip.contains(e.target)) { 3684 infoTooltip.classList.remove('visible'); 3685 } 3686}); 3687 3688// Make functions global so they can be called from onclick 3689window.showTutorial = showTutorial; 3690window.nextTutorial = nextTutorial; 3691window.closeTutorial = closeTutorial; 3692window.backToLabeling = backToLabeling; 3693 3694 3695// Password popup language toggle 3696document.getElementById('password-lang-toggle').addEventListener('click', function() { 3697 const toggle = this; 3698 const slider = toggle.querySelector('.lang-toggle-slider'); 3699 3700 if (toggle.classList.contains('en')) { 3701 toggle.classList.remove('en'); 3702 slider.textContent = 'FR'; 3703 switchLanguage('fr'); 3704 } else { 3705 toggle.classList.add('en'); 3706 slider.textContent = 'EN'; 3707 switchLanguage('en'); 3708 } 3709}); 3710
3711// Welcome popup language toggle 3712document.getElementById('welcome-lang-toggle').addEventListener('click', function() { 3713 const toggle = this; 3714 const slider = toggle.querySelector('.lang-toggle-slider'); 3715 3716 if (toggle.classList.contains('en')) { 3717 // Switch to French 3718 toggle.classList.remove('en'); 3719 slider.textContent = 'FR'; 3720 switchLanguage('fr'); 3721 } else { 3722 // Switch to English 3723 toggle.classList.add('en'); 3724 slider.textContent = 'EN'; 3725 switchLanguage('en'); 3726 } 3727}); 3728 3729// Language toggle button 3730document.getElementById('lang-toggle').addEventListener('click', () => { 3731 currentLang = currentLang === 'fr' ? 'en' : 'fr'; 3732 switchLanguage(currentLang); 3733}); 3734 3735// Password protection - DISABLED 3736// const CORRECT_PASSWORD = 'ojiW6TK3rP*LiEf'; 3737// 3738// document.getElementById('password-submit').addEventListener('click', () => { 3739// const input = document.getElementById('password-input').value; 3740// if (input === CORRECT_PASSWORD) { 3741// document.getElementById('password-overlay').style.display = 'none'; 3742// document.getElementById('welcome-overlay').style.display = 'flex'; 3743// } else { 3744// document.getElementById('password-error').style.display = 'block'; 3745// } 3746// }); 3747// 3748// // Allow Enter key to submit password 3749// document.getElementById('password-input').addEventListener('keypress', (e) => { 3750// if (e.key === 'Enter') { 3751// document.getElementById('password-submit').click(); 3752// } 3753// }); 3754 3755// Data collapsible toggle 3756document.getElementById('data-collapsible-toggle').addEventListener('click', () => { 3757 const header = document.getElementById('data-collapsible-toggle'); 3758 const content = document.getElementById('data-collapsible-content'); 3759 3760 header.classList.toggle('open'); 3761 content.classList.toggle('open'); 3762}); 3763 3764document.getElementById('start-btn').addEventListener('click', () => { 3765 document.getElementById('welcome-overlay').style.display = 'none'; 3766 // Show map navigation controls 3767 document.getElementById('map-nav-controls').classList.add('active'); 3768 3769 // Show progress tracker 3770 document.getElementById('progress-tracker').style.display = 'block'; 3771 3772 // Show first tutorial after a brief delay to ensure welcome overlay is hidden 3773 setTimeout(() => { 3774 console.log('Showing tutorial step 1'); 3775 updateProgress(1); 3776 showTutorial(0); 3777 }, 300); 3778}); 3779 3780document.getElementById('more-info-btn').addEventListener('click', () => { 3781 // Show research info popup 3782 const title = currentLang === 'fr' ? 'à propos de ce projet' : 'About this project'; 3783 const message = currentLang === 'fr' 3784 ? 'Ce projet est dirigé par Evelyne Brie, professeure adjointe en science politique affiliée à IVADO à l\'Université de Montréal. Vous pouvez la contacter par courriel au <a href="mailto:[email protected]" style="color: #4285f4;">[email protected]</a>.<br><br>Ses recherches portent sur la cohésion sociale, les inégalités structurelles et la prise de décision en matière de politique urbaine. Tag ta Ville fait partie de ce travail et utilise la cartographie participative pour mieux comprendre comment les gens vivent la sécurité et le confort dans leurs environnements urbains quotidiens.<br><br>Ce projet a été autorisé par le bureau d\'éthique de la recherche de l\'Université de Montréal (#2026-8007).' 3785 : 'This project is directed by Evelyne Brie, Assistant Professor in Political Science affiliated with IVADO at the University of Montreal. You can reach her by email at <a href="mailto:[email protected]" style="color: #4285f4;">[email protected]</a>.<br><br>Her research focuses on social cohesion, inequalities, and decision-making in urban policy. Tag ta Ville is part of this work, using participatory mapping to better understand how people experience safety and comfort in their everyday urban environments.<br><br>This project has been approved by the University of Montreal\'s research ethics board (#2026-8007).'; 3786 showCustomAlert(title, message); 3787}); 3788 3789// Confirm button - load neighbourhood 3790document.getElementById('confirm-btn').addEventListener('click', async () => { 3791 console.log('=== CONFIRM BUTTON CLICKED ==='); 3792 console.log('selectedPoint:', selectedPoint); 3793 console.log('dataLoaded:', dataLoaded); 3794 console.log('allBuildingsData length:', allBuildingsData?.length); 3795 3796 if (!selectedPoint) { 3797 const title = currentLang === 'fr' ? 'Avis' : 'Notice'; 3798 const message = currentLang === 'fr' 3799 ? 'Veuillez d\'abord sélectionner un point sur la carte.' 3800 : 'Please first select a point on the map.'; 3801 showCustomAlert(title, message); 3802 return; 3803 } 3804 3805 if (!dataLoaded || !tileManifest) { 3806 const title = currentLang === 'fr' ? 'Avis' : 'Notice'; 3807 const message = currentLang === 'fr' 3808 ? 'Les données sont toujours en cours de chargement. Veuillez patienter un instant et réessayer.' 3809 : 'Data is still loading. Please wait a moment and try again.'; 3810 showCustomAlert(title, message); 3811 return; 3812 } 3813 3814 console.log('â Proceeding to Step 2...'); 3815 console.log('â Loading voxels and trees...'); 3816 3817 // Switch to Step 2 3818 const step1 = document.getElementById('step1'); 3819 const step2 = document.getElementById('step2-paint'); 3820 3821 if (step1) step1.classList.add('hidden'); 3822 if (step2) step2.classList.remove('hidden'); 3823 currentStep = 2; 3824 3825 // Update progress 3826 updateProgress(2); 3827 3828 // Update info tooltip for new step 3829 if (typeof updateInfoTooltip === 'function') updateInfoTooltip(); 3830 3831 const isMobile = window.innerWidth <= 768; 3832 3833 // Zoom in to ensure 3D buildings are visible 3834 console.log('ð Auto-zooming to selected point for 3D buildings...'); 3835 console.log('Current zoom:', map.getZoom(), 'Target zoom: 16.5'); 3836 map.flyTo({ 3837 center: selectedPoint, 3838 zoom: 16.5, 3839 pitch: isMobile ? 0 : 60, 3840 bearing: map.getBearing(), 3841 duration: 2000 3842 }); 3843 3844 // Mobile: lock zoom level 3845 if (isMobile) { 3846 map.setMinZoom(15.5);
3847 map.setMaxZoom(17.5); 3848 } 3849 3850 // Show tutorial after zoom animation starts 3851 setTimeout(() => { 3852 showTutorial(1); 3853 }, 500); 3854 3855 // Load the voxels and trees 3856 try { 3857 await loadVoxelsAndTrees(selectedPoint); 3858 console.log('â Voxels and trees loaded successfully'); 3859 } catch (error) { 3860 console.error('â Error loading voxels:', error); 3861 const title = currentLang === 'fr' ? 'Erreur' : 'Error'; 3862 const message = currentLang === 'fr' 3863 ? 'Ãchec du chargement des données du bâtiment. Veuillez réessayer.' 3864 : 'Failed to load building data. Please try again.'; 3865 showCustomAlert(title, message); 3866 } 3867}); 3868 3869// Map navigation controls 3870const MAP_PAN_DISTANCE = 100; 3871const MAP_ROTATE_ANGLE = 15; 3872 3873document.getElementById('map-nav-up').addEventListener('click', () => { 3874 map.panBy([0, -MAP_PAN_DISTANCE]); 3875}); 3876 3877document.getElementById('map-nav-down').addEventListener('click', () => { 3878 map.panBy([0, MAP_PAN_DISTANCE]); 3879}); 3880 3881document.getElementById('map-nav-left').addEventListener('click', () => { 3882 map.panBy([-MAP_PAN_DISTANCE, 0]); 3883}); 3884 3885document.getElementById('map-nav-right').addEventListener('click', () => { 3886 map.panBy([MAP_PAN_DISTANCE, 0]); 3887}); 3888 3889document.getElementById('map-rotate-left').addEventListener('click', () => { 3890 const current = map.getBearing(); 3891 map.easeTo({ bearing: current - MAP_ROTATE_ANGLE, duration: 300 }); 3892}); 3893 3894document.getElementById('map-rotate-right').addEventListener('click', () => { 3895 const current = map.getBearing(); 3896 map.easeTo({ bearing: current + MAP_ROTATE_ANGLE, duration: 300 }); 3897}); 3898 3899// Zoom controls 3900document.getElementById('map-zoom-in').addEventListener('click', () => { 3901 map.zoomIn({ duration: 300 }); 3902}); 3903 3904document.getElementById('map-zoom-out').addEventListener('click', () => { 3905 map.zoomOut({ duration: 300 }); 3906}); 3907 3908// Keyboard navigation with live visual feedback 3909document.addEventListener('keydown', (e) => { 3910 // Only respond to arrow keys when not typing in an input 3911 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; 3912 3913 let buttonId = null; 3914 3915 switch(e.key) { 3916 case 'ArrowUp': 3917 e.preventDefault(); 3918 map.panBy([0, -MAP_PAN_DISTANCE]); 3919 buttonId = 'map-nav-up'; 3920 break; 3921 case 'ArrowDown': 3922 e.preventDefault(); 3923 map.panBy([0, MAP_PAN_DISTANCE]); 3924 buttonId = 'map-nav-down'; 3925 break; 3926 case 'ArrowLeft': 3927 e.preventDefault(); 3928 map.panBy([-MAP_PAN_DISTANCE, 0]); 3929 buttonId = 'map-nav-left'; 3930 break; 3931 case 'ArrowRight': 3932 e.preventDefault(); 3933 map.panBy([MAP_PAN_DISTANCE, 0]); 3934 buttonId = 'map-nav-right'; 3935 break; 3936 } 3937 3938 // Highlight the button 3939 if (buttonId) { 3940 const button = document.getElementById(buttonId); 3941 if (button) { 3942 button.classList.add('active-press'); 3943 } 3944 } 3945}); 3946 3947document.addEventListener('keyup', (e) => { 3948 // Remove highlight when key is released 3949 const keyMap = { 3950 'ArrowUp': 'map-nav-up', 3951 'ArrowDown': 'map-nav-down', 3952 'ArrowLeft': 'map-nav-left', 3953 'ArrowRight': 'map-nav-right' 3954 }; 3955 3956 const buttonId = keyMap[e.key]; 3957 if (buttonId) { 3958 const button = document.getElementById(buttonId); 3959 if (button) { 3960 button.classList.remove('active-press'); 3961 } 3962 } 3963}); 3964 3965// Initialize map 3966const isMobile = window.innerWidth <= 768; 3967 3968map = new mapboxgl.Map({ 3969 container: 'map', 3970 style: 'mapbox://styles/mapbox/streets-v12', 3971 center: MONTREAL_CENTER, 3972 zoom: 12, 3973 pitch: isMobile ? 0 : 45, 3974 bearing: -17, 3975 antialias: true 3976}); 3977 3978map.on('load', () => { 3979 console.log('Map loaded'); 3980 3981 // Hide ALL building-related layers in the map style 3982 modifyBuildingLayers(); 3983 3984 loadAllData(); 3985}); 3986 3987// Modify building layers to show flat 2D at lower zoom, hide at higher zoom 3988function modifyBuildingLayers() { 3989 const style = map.getStyle(); 3990 if (style && style.layers) { 3991 style.layers.forEach(layer => { 3992 if (layer.id.includes('building') || 3993 (layer['source-layer'] && layer['source-layer'].includes('building')) || 3994 (layer.type === 'fill-extrusion' && !layer.id.includes('voxel') && !layer.id.includes('tree') && !layer.id.includes('painted'))) { 3995 try { 3996 if (map.getLayer(layer.id)) { 3997 // Show at low zoom (flat), hide at high zoom (where voxels appear) 3998 if (layer.type === 'fill-extrusion') { 3999 // Make buildings completely flat - just footprints 4000 map.setPaintProperty(layer.id, 'fill-extrusion-height', [ 4001 'interpolate', ['linear'], ['zoom'], 4002 12, 0.3, // Almost completely flat 4003 15, 0.3, // Still flat 4004 15.5, 0 // Invisible when voxels appear 4005 ]); 4006 map.setPaintProperty(layer.id, 'fill-extrusion-opacity', [ 4007 'interpolate', ['linear'], ['zoom'], 4008 12, 0.4, // Semi-transparent at low zoom 4009 15, 0.3, // Fading 4010 15.5, 0 // Invisible when voxels appear 4011 ]); 4012 // Set base height to 0 to ensure they're flat on the ground 4013 map.setPaintProperty(layer.id, 'fill-extrusion-base', 0); 4014 } else if (layer.type === 'fill') { 4015 // For 2D fill layers, just fade them out 4016 map.setPaintProperty(layer.id, 'fill-opacity', [ 4017 'interpolate', ['linear'], ['zoom'], 4018 12, 0.4, 4019 15, 0.2, 4020 15.5, 0 4021 ]); 4022 } 4023 } 4024 } catch(e) { 4025 // Layer doesn't support these properties or already modified 4026 } 4027 } 4028 }); 4029 } 4030} 4031
4032// Keep building layers modified even when style updates 4033map.on('styledata', () => { 4034 modifyBuildingLayers(); 4035}); 4036 4037map.on('sourcedata', (e) => { 4038 if (e.isSourceLoaded) { 4039 modifyBuildingLayers(); 4040 } 4041}); 4042 4043// Handle WebGL context lost (prevents crashes) 4044map.on('webglcontextlost', (e) => { 4045 console.warn('WebGL context lost - preventing default to allow recovery'); 4046 e.preventDefault(); 4047}); 4048 4049map.on('webglcontextrestored', () => { 4050 console.log('WebGL context restored'); 4051 // Reload data if needed 4052 if (voxelsLoaded && selectedPoint) { 4053 console.log('Reloading voxels after context restore...'); 4054 loadVoxelsAndTrees(selectedPoint); 4055 } 4056}); 4057 4058// Update pitch value live 4059map.on('pitch', () => { 4060 const currentPitch = Math.round(map.getPitch()); 4061 document.getElementById('pitch-value').textContent = currentPitch; 4062 document.getElementById('pitch-slider').value = currentPitch; 4063}); 4064 4065// Show/hide zoom prompt based on zoom level 4066function updateZoomPrompt() { 4067 const zoomPrompt = document.getElementById('zoom-prompt'); 4068 if (!zoomPrompt) return; 4069 4070 const currentZoom = map.getZoom(); 4071 const step2Panel = document.getElementById('step2-paint'); 4072 const step3Panel = document.getElementById('step3-tag-clusters'); 4073 const isStep2Visible = step2Panel && !step2Panel.classList.contains('hidden'); 4074 const isStep3Visible = step3Panel && !step3Panel.classList.contains('hidden'); 4075 const tutorialOverlay = document.getElementById('tutorial-overlay'); 4076 const isTutorialVisible = tutorialOverlay && !tutorialOverlay.classList.contains('hidden'); 4077 const loadingOverlay = document.getElementById('loading-overlay'); 4078 const isLoadingVisible = loadingOverlay && !loadingOverlay.classList.contains('hidden'); 4079 4080 // Show prompt in Step 2 or Step 3 when tutorial is NOT visible, loading is NOT visible, and zoom is too far out 4081 if (typeof currentStep !== 'undefined' && (currentStep === 2 || currentStep === 3) && (isStep2Visible || isStep3Visible) && !isTutorialVisible && !isLoadingVisible && currentZoom < 15.5) { 4082 zoomPrompt.classList.add('visible'); 4083 } else { 4084 zoomPrompt.classList.remove('visible'); 4085 } 4086} 4087 4088map.on('zoom', updateZoomPrompt); 4089map.on('zoomend', updateZoomPrompt); 4090 4091// Store manifest for lazy loading 4092let tileManifest = null; 4093let loadedTileCache = {}; 4094 4095// Load only manifest initially (lazy load tiles on demand) 4096async function loadAllData() { 4097 console.log('Loading tile manifest...'); 4098 4099 try { 4100 const cacheBuster = '?v=' + Date.now(); 4101 const response = await fetch('./data/tiles_merged/tile_manifest.json' + cacheBuster); 4102 tileManifest = await response.json(); 4103 4104 // Initialize empty array - tiles loaded on demand 4105 allBuildingsData = []; 4106 console.log(`â Manifest loaded: ${tileManifest.tiles.length} tiles available`); 4107 } catch (error) { 4108 console.error('Error loading manifest:', error); 4109 await new Promise(resolve => setTimeout(resolve, 1000)); 4110 try { 4111 const cacheBuster = '?v=' + Date.now(); 4112 const response = await fetch('./data/tiles_merged/tile_manifest.json' + cacheBuster); 4113 tileManifest = await response.json(); 4114 allBuildingsData = []; 4115 console.log(`â Manifest loaded on retry: ${tileManifest.tiles.length} tiles available`); 4116 } catch (retryError) { 4117 console.error('Failed to load manifest after retry:', retryError); 4118 } 4119 } 4120 4121 dataLoaded = true; 4122 console.log('â All data loaded successfully'); 4123} 4124 4125// Mode selection 4126document.getElementById('btn-click').addEventListener('click', () => { 4127 selectionMode = 'click'; 4128 document.getElementById('btn-click').classList.add('active'); 4129 document.getElementById('btn-address').classList.remove('active'); 4130 document.getElementById('address-section').classList.add('hidden'); 4131}); 4132 4133document.getElementById('btn-address').addEventListener('click', () => { 4134 selectionMode = 'address'; 4135 document.getElementById('btn-address').classList.add('active'); 4136 document.getElementById('btn-click').classList.remove('active'); 4137 document.getElementById('address-section').classList.remove('hidden'); 4138 document.getElementById('address-input').focus(); 4139}); 4140 4141// Mobile: auto-select address mode and update step 1 header 4142if (window.innerWidth <= 768) { 4143 document.getElementById('btn-address').click(); 4144 const step1Header = document.querySelector('#step1 .step-header span'); 4145 if (step1Header) { 4146 step1Header.setAttribute('data-fr', 'Ãtape 1 : Cliquez sur la carte ou entrez une adresse'); 4147 step1Header.setAttribute('data-en', 'Step 1: Click on map or enter an address'); 4148 step1Header.textContent = (typeof currentLang !== 'undefined' && currentLang === 'en') 4149 ? 'Step 1: Click on map or enter an address' 4150 : 'Ãtape 1 : Cliquez sur la carte ou entrez une adresse'; 4151 } 4152} 4153 4154// Address autocomplete 4155let addressTimeout; 4156document.getElementById('address-input').addEventListener('input', (e) => { 4157 const query = e.target.value.trim(); 4158 4159 if (query.length < 3) { 4160 document.getElementById('address-suggestions').classList.add('hidden'); 4161 return; 4162 } 4163 4164 clearTimeout(addressTimeout); 4165 addressTimeout = setTimeout(() => { 4166 searchAddresses(query); 4167 }, 300); 4168}); 4169 4170async function searchAddresses(query) { 4171 const searchText = query.includes('Montreal') ? query : `${query}, Montreal, Quebec`; 4172 const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(searchText)}.json?access_token=${mapboxgl.accessToken}&bbox=-73.98,45.40,-73.48,45.70&limit=5`; 4173 4174 try { 4175 const resp = await fetch(url); 4176 const data = await resp.json(); 4177 4178 if (data.features && data.features.length > 0) { 4179 const suggestionsDiv = document.getElementById('address-suggestions'); 4180 suggestionsDiv.innerHTML = ''; 4181
4182 data.features.forEach(feature => { 4183 const div = document.createElement('div'); 4184 div.className = 'address-suggestion'; 4185 div.textContent = feature.place_name; 4186 div.addEventListener('click', () => { 4187 const [lng, lat] = feature.center; 4188 document.getElementById('address-input').value = feature.place_name; 4189 suggestionsDiv.classList.add('hidden'); 4190 selectPoint({ lng, lat }); 4191 map.flyTo({ center: [lng, lat], zoom: 16, duration: 1500 }); 4192 }); 4193 suggestionsDiv.appendChild(div); 4194 }); 4195 4196 suggestionsDiv.classList.remove('hidden'); 4197 } 4198 } catch (error) { 4199 console.error('Geocoding error:', error); 4200 } 4201} 4202 4203// Mouse handlers 4204map.on('mousedown', (e) => { 4205 // Step 1: Click to select/move point 4206 if (!voxelsLoaded && selectionMode === 'click') { 4207 selectPoint(e.lngLat); 4208 return; 4209 } 4210 4211 // Step 2: Paint or erase 4212 if (voxelsLoaded && currentTool !== 'cursor') { 4213 isDragging = true; 4214 performAction([e.lngLat.lng, e.lngLat.lat]); 4215 } 4216}); 4217 4218map.on('mouseup', () => { 4219 isDragging = false; 4220}); 4221 4222map.on('mouseleave', () => { 4223 isDragging = false; 4224}); 4225 4226// Mobile: paint on touch (tap + drag) 4227if (window.innerWidth <= 768) { 4228 const canvas = map.getCanvas(); 4229 4230 canvas.addEventListener('touchstart', (e) => { 4231 if (e.touches.length === 1) { 4232 const touch = e.touches[0]; 4233 const rect = canvas.getBoundingClientRect(); 4234 const x = touch.clientX - rect.left; 4235 const y = touch.clientY - rect.top; 4236 const point = new mapboxgl.Point(x, y); 4237 const lngLat = map.unproject(point); 4238 4239 // Step 1: Touch to select location (works in both click and address modes) 4240 if (!voxelsLoaded) { 4241 e.preventDefault(); 4242 selectPoint(lngLat); 4243 return; 4244 } 4245 4246 // Step 2: Touch to paint or erase 4247 if (voxelsLoaded && currentTool !== 'cursor') { 4248 e.preventDefault(); 4249 isDragging = true; 4250 performAction([lngLat.lng, lngLat.lat], point); 4251 } 4252 } 4253 }, { passive: false }); 4254 4255 canvas.addEventListener('touchmove', (e) => { 4256 if (isDragging && voxelsLoaded && currentTool !== 'cursor' && e.touches.length === 1) { 4257 e.preventDefault(); 4258 const touch = e.touches[0]; 4259 const rect = canvas.getBoundingClientRect(); 4260 // Use canvas-relative coordinates for accurate unprojection 4261 const x = touch.clientX - rect.left; 4262 const y = touch.clientY - rect.top; 4263 const point = new mapboxgl.Point(x, y); 4264 const lngLat = map.unproject(point); 4265 const now = Date.now(); 4266 if (now - lastPaintTime > 150) { 4267 lastPaintTime = now; 4268 performAction([lngLat.lng, lngLat.lat], point); 4269 } 4270 } 4271 }, { passive: false }); 4272 4273 canvas.addEventListener('touchend', () => { 4274 isDragging = false; 4275 }); 4276} 4277 4278// Disable tool preview during map movement for better performance 4279map.on('movestart', () => { 4280 isMapMoving = true; 4281 // Hide tool preview during movement 4282 if (map.getSource('tool-preview')) { 4283 map.getSource('tool-preview').setData({ type: 'FeatureCollection', features: [] }); 4284 } 4285}); 4286 4287map.on('moveend', () => { 4288 isMapMoving = false; 4289 // Update preview once movement stops 4290 if (lastMouseEvent && currentTool !== 'cursor' && voxelsLoaded) { 4291 setTimeout(() => { 4292 updateToolPreview(lastMouseEvent); 4293 }, 50); 4294 } 4295}); 4296 4297// Throttled mousemove with requestAnimationFrame 4298map.on('mousemove', (e) => { 4299 lastMouseEvent = e; 4300 4301 // Skip updates during map movement for performance 4302 if (isMapMoving) return; 4303 4304 if (currentTool !== 'cursor' && voxelsLoaded && map.getSource('tool-preview')) { 4305 // Use requestAnimationFrame to throttle updates 4306 if (!mouseMoveScheduled) { 4307 mouseMoveScheduled = true; 4308 requestAnimationFrame(() => { 4309 mouseMoveScheduled = false; 4310 if (lastMouseEvent) { 4311 updateToolPreview(lastMouseEvent); 4312 } 4313 }); 4314 } 4315 4316 // Throttle painting during drag - only paint every 150ms for smoother performance 4317 if (isDragging && !isMapMoving) { 4318 const now = Date.now(); 4319 if (now - lastPaintTime > 150) {
4320 lastPaintTime = now; 4321 performAction([e.lngLat.lng, e.lngLat.lat], e.point); 4322 } 4323 } 4324 } 4325}); 4326 4327function updateToolPreview(e) { 4328 if (!map.getSource('tool-preview')) return; 4329 4330 const radiusKm = toolSize / 1000; 4331 const circle = turf.circle([e.lngLat.lng, e.lngLat.lat], radiusKm, { 4332 steps: 32, 4333 units: 'kilometers' 4334 }); 4335 4336 map.getSource('tool-preview').setData(circle); 4337 4338 let color = currentTool === 'erase' ? '#666666' : 4339 currentPaintColor === 'disliked' ? '#ff0000' : '#00ff00'; 4340 4341 map.setPaintProperty('tool-preview-fill', 'fill-color', color); 4342 map.setPaintProperty('tool-preview-line', 'line-color', color); 4343} 4344 4345function selectPoint(lngLat) { 4346 selectedPoint = lngLat; 4347 4348 if (centerMarker) centerMarker.remove(); 4349 4350 // Create custom pin marker element 4351 const el = document.createElement('div'); 4352 el.style.width = '60px'; 4353 el.style.height = '90px'; 4354 el.style.backgroundImage = 'url(assets/pin_3d_glossy_blue.svg)'; 4355 el.style.backgroundSize = 'contain'; 4356 el.style.backgroundRepeat = 'no-repeat'; 4357 el.style.backgroundPosition = 'center bottom'; 4358 el.style.cursor = 'pointer'; 4359 4360 centerMarker = new mapboxgl.Marker({ 4361 element: el, 4362 anchor: 'bottom' // Anchor at bottom of pin (tip of pole) 4363 }) 4364 .setLngLat([lngLat.lng, lngLat.lat]) 4365 .addTo(map); 4366 4367 drawRadiusCircle(lngLat); 4368 4369 document.getElementById('coords-display').textContent = 4370 `Lat: ${lngLat.lat.toFixed(4)}, Lng: ${lngLat.lng.toFixed(4)}`; 4371 4372 document.getElementById('location-selected').classList.remove('hidden'); 4373 4374 // Make confirm button pulsate when it appears 4375 const confirmBtn = document.getElementById('confirm-btn'); 4376 confirmBtn.classList.add('pulsate'); 4377 setTimeout(() => { 4378 confirmBtn.classList.remove('pulsate'); 4379 }, 6000); // Remove after 3 cycles (2s * 3 = 6s) 4380 4381} 4382 4383function drawRadiusCircle(center) { 4384 const circle = turf.circle([center.lng, center.lat], RADIUS_KM, { steps: 64, units: 'kilometers' }); 4385 4386 if (map.getSource('radius-circle')) { 4387 map.getSource('radius-circle').setData(circle); 4388 } else { 4389 map.addSource('radius-circle', { type: 'geojson', data: circle }); 4390 4391 // Only show the dashed line border, no fill 4392 map.addLayer({ 4393 id: 'radius-circle-line', 4394 type: 'line', 4395 source: 'radius-circle', 4396 paint: { 'line-color': '#4285f4', 'line-width': 3, 'line-dasharray': [2, 2] } 4397 }); 4398 } 4399} 4400 4401// Back button handlers 4402document.getElementById('back-from-step2').addEventListener('click', () => { 4403 const lang = (typeof currentLang !== 'undefined') ? currentLang : 'fr'; 4404 const title = lang === 'fr' ? 'Confirmation' : 'Confirmation'; 4405 const message = lang === 'fr' 4406 ? 'Revenir en arrière ? Cela effacera toutes vos données et vous permettra de sélectionner une nouvelle zone.' 4407 : 'Go back? This will clear all your data and let you select a new area.'; 4408 4409 showCustomConfirm(title, message, (confirmed) => { 4410 if (confirmed) { 4411 // Remove center marker 4412 if (centerMarker) { 4413 centerMarker.remove(); 4414 centerMarker = null; 4415 } 4416 4417 // Remove all map layers (voxels, trees, radius circle) 4418 if (map.getLayer('voxel-fill-2d')) map.removeLayer('voxel-fill-2d'); 4419 if (map.getLayer('voxel-fill')) map.removeLayer('voxel-fill'); 4420 if (map.getSource('voxel-cubes')) map.removeSource('voxel-cubes'); 4421 4422 if (map.getLayer('radius-circle-line')) { 4423 map.removeLayer('radius-circle-line'); 4424 map.removeSource('radius-circle'); 4425 } 4426 4427 // Clear paint 4428 dislikedVoxels.clear(); 4429 likedVoxels.clear(); 4430 4431 // Remove ground paint markers 4432 groundPaintMarkers.forEach(paintId => { 4433 if (map.getLayer(paintId)) { 4434 map.removeLayer(paintId); 4435 map.removeSource(paintId); 4436 } 4437 }); 4438 groundPaintMarkers = []; 4439 groundPaintIdCounter = 0; // Reset ground paint ID counter 4440 4441 // Remove overlays 4442 paintedOverlays.forEach(overlayId => { 4443 try { 4444 if (map.getLayer(overlayId)) map.removeLayer(overlayId); 4445 if (map.getSource(overlayId)) map.removeSource(overlayId); 4446 } catch(e) {} 4447 }); 4448 paintedOverlays = []; 4449 4450 // Remove cluster markers
4451 clusterMarkers.forEach(marker => marker.remove()); 4452 clusterMarkers = []; 4453 4454 // Remove tool preview 4455 removeToolPreview(); 4456 4457 // Reset voxel data 4458 voxelsLoaded = false; 4459 voxelSpatialIndex = null; 4460 allVoxelFeatures = []; 4461 4462 // Reset state variables 4463 currentTool = 'cursor'; 4464 currentPaintColor = 'liked'; 4465 isDragging = false; 4466 clusterLabels = {}; 4467 allClusters = []; 4468 selectedClusterId = null; 4469 paintedVoxelFeatures = []; 4470 paintedVoxelIndex = new Map(); 4471 window.paintLimitNotified = false; // Reset paint limit notification 4472 4473 // Remove selected point (wrapped in try-catch) 4474 try { 4475 if (typeof selectedPointMarker !== 'undefined' && selectedPointMarker) { 4476 selectedPointMarker.remove(); 4477 selectedPointMarker = null; 4478 } 4479 } catch(e) { 4480 console.log('selectedPointMarker cleanup skipped:', e); 4481 } 4482 4483 try { 4484 if (typeof selectedPoint !== 'undefined') { 4485 selectedPoint = null; 4486 } 4487 } catch(e) { 4488 console.log('selectedPoint cleanup skipped:', e); 4489 } 4490 4491 // Re-enable map interactions 4492 map.dragPan.enable(); 4493 map.scrollZoom.enable(); 4494 4495 // Hide Step 2 and other steps, Show Step 1 4496 document.getElementById('step2-paint').classList.add('hidden'); 4497 document.getElementById('step3-tag-clusters').classList.add('hidden'); 4498 document.getElementById('step4-submit').classList.add('hidden'); 4499 document.getElementById('step1').classList.remove('hidden'); 4500 4501 // Update progress when going back to step 1 4502 updateProgress(1); 4503 4504 // Reset Step 1 UI elements 4505 document.getElementById('location-selected').classList.add('hidden'); 4506 document.getElementById('address-suggestions').classList.add('hidden'); 4507 document.getElementById('address-input').value = ''; 4508 4509 // Clear clusters list 4510 document.getElementById('clusters-list').innerHTML = ''; 4511 4512 // Hide reset button 4513 document.getElementById('reset-btn').classList.add('hidden'); 4514 4515 // Reset tool selection 4516 document.getElementById('tool-cursor').classList.add('active'); 4517 document.getElementById('tool-paint').classList.remove('active'); 4518 document.getElementById('tool-erase').classList.remove('active'); 4519 document.getElementById('paint-green').classList.add('active'); 4520 document.getElementById('paint-red').classList.remove('active'); 4521 4522 // Hide map nav controls 4523 document.getElementById('map-nav-controls').classList.remove('active'); 4524 4525 // Fly back to Montreal center 4526 map.flyTo({ 4527 center: MONTREAL_CENTER, 4528 zoom: 12, 4529 pitch: window.innerWidth <= 768 ? 0 : 45, 4530 bearing: -17, 4531 duration: 1500 4532 }); 4533 } 4534 }); 4535}); 4536 4537document.getElementById('back-from-step3').addEventListener('click', () => { 4538 document.getElementById('step3-tag-clusters').classList.add('hidden'); 4539 document.getElementById('step2-paint').classList.remove('hidden'); 4540 4541 // Update step and progress when going back 4542 currentStep = 2; 4543 updateProgress(2); 4544 updateInfoTooltip(); 4545 4546 // Remove cluster markers 4547 clusterMarkers.forEach(marker => marker.remove()); 4548 clusterMarkers = []; 4549 4550 // Remove split divider lines 4551 splitDividerLines.forEach(lineId => { 4552 try { 4553 if (map.getLayer(lineId)) map.removeLayer(lineId); 4554 if (map.getSource(lineId)) map.removeSource(lineId); 4555 } catch(e) {} 4556 }); 4557 splitDividerLines = []; 4558 4559 // FIX: Re-enable paint tool when going back to step 2 4560 // Explicitly reset all tool states to ensure paint tool works properly 4561 4562 // Set tool state 4563 currentTool = 'paint'; 4564 4565 // Update desktop button states 4566 document.getElementById('tool-paint').classList.add('active'); 4567 document.getElementById('tool-cursor').classList.remove('active'); 4568 document.getElementById('tool-erase').classList.remove('active'); 4569 4570 // Update mobile button states 4571 if (window.innerWidth <= 768) { 4572 document.getElementById('mobile-peinturer-btn').classList.add('active'); 4573 document.getElementById('mobile-navigate-btn').classList.remove('active'); 4574 document.getElementById('mobile-erase-btn').classList.remove('active'); 4575 4576 // Reset camera pitch on mobile (overhead view for Step 2) 4577 map.setPitch(0); 4578 } 4579 4580 // Set map interaction states for paint tool 4581 map.getCanvas().style.cursor = 'crosshair'; 4582 map.dragPan.disable(); 4583 map.scrollZoom.enable(); 4584 map.doubleClickZoom.enable(); 4585 4586 // Create tool preview 4587 createToolPreview(); 4588}); 4589 4590document.getElementById('back-from-step4').addEventListener('click', () => { 4591 document.getElementById('step4-submit').classList.add('hidden'); 4592 document.getElementById('step3-tag-clusters').classList.remove('hidden'); 4593 4594 // Show main panel for step 3
4595 const mainPanel = document.querySelector('.main-panel'); 4596 if (mainPanel) mainPanel.style.display = 'block'; 4597 4598 // Update step and progress when going back to step 3 4599 currentStep = 3; 4600 updateProgress(3); 4601 updateInfoTooltip(); 4602 4603 // Go back to last cluster 4604 if (allClusters.length > 0) { 4605 currentClusterIndex = allClusters.length - 1; 4606 showCluster(currentClusterIndex); 4607 } 4608}); 4609 4610// Load tiles near a specific location (lazy loading for performance) 4611async function loadTilesNearLocation(center, radiusKm = 1.5) { 4612 if (!tileManifest) return []; 4613 4614 const TILE_SIZE = 0.02; // degrees 4615 const tilesToLoad = []; 4616 4617 // Calculate which tiles intersect with radius 4618 const minLon = center.lng - (radiusKm / 111); 4619 const maxLon = center.lng + (radiusKm / 111); 4620 const minLat = center.lat - (radiusKm / 111); 4621 const maxLat = center.lat + (radiusKm / 111); 4622 4623 // Find tiles in bounding box 4624 for (const tile of tileManifest.tiles) { 4625 const filename = tile.file; 4626 const match = filename.match(/tile_(-?\d+)_(-?\d+)\.geojson\.gz/); 4627 if (!match) continue; 4628 4629 const tileX = parseInt(match[1]); 4630 const tileY = parseInt(match[2]); 4631 4632 const tileLonMin = -73.98 + (tileX * TILE_SIZE); 4633 const tileLonMax = tileLonMin + TILE_SIZE; 4634 const tileLatMin = 45.40 + (tileY * TILE_SIZE); 4635 const tileLatMax = tileLatMin + TILE_SIZE; 4636 4637 // Check if tile intersects with search area 4638 if (tileLonMax >= minLon && tileLonMin <= maxLon && 4639 tileLatMax >= minLat && tileLatMin <= maxLat) { 4640 tilesToLoad.push(filename); 4641 } 4642 } 4643 4644 // Use cached tiles or load new ones 4645 const cachedTiles = tilesToLoad.filter(f => loadedTileCache[f]); 4646 const uncachedTiles = tilesToLoad.filter(f => !loadedTileCache[f]); 4647 4648 console.log(`Loading ${uncachedTiles.length} tiles (${cachedTiles.length} cached)...`); 4649 4650 // Load uncached tiles in smaller batches (max 5 concurrent) 4651 const batchSize = 5; 4652 const allFeatures = [...cachedTiles.flatMap(f => loadedTileCache[f])]; 4653 4654 for (let i = 0; i < uncachedTiles.length; i += batchSize) { 4655 const batch = uncachedTiles.slice(i, i + batchSize); 4656 const batchPromises = batch.map(async (filename) => { 4657 try { 4658 const resp = await fetch(`./data/tiles_merged/${filename}`); 4659 const blob = await resp.blob(); 4660 const buffer = await blob.arrayBuffer(); 4661 const decompressed = pako.ungzip(new Uint8Array(buffer), { to: 'string' }); 4662 const features = JSON.parse(decompressed).features; 4663 loadedTileCache[filename] = features; 4664 return features; 4665 } catch (e) { 4666 console.error(`Failed to load ${filename}:`, e); 4667 return []; 4668 } 4669 }); 4670 4671 const batchResults = await Promise.all(batchPromises); 4672 allFeatures.push(...batchResults.flat()); 4673 } 4674 4675 return allFeatures; 4676} 4677 4678async function loadVoxelsAndTrees(center) { 4679 // Show loading indicator 4680 document.getElementById('loading-overlay').classList.remove('hidden'); 4681 updateZoomPrompt(); 4682 console.log('â³ Loading data...'); 4683 4684 // Load only nearby tiles (performance optimization) 4685 const buildingsData = await loadTilesNearLocation(center, RADIUS_KM + 0.3); 4686 console.log(`â Loaded ${buildingsData.length} buildings from nearby tiles`); 4687 4688 const buildingsInRadius = buildingsData.filter(b => { 4689 try { 4690 const c = turf.center(b); 4691 const d = turf.distance([center.lng, center.lat], c.geometry.coordinates, { units: 'kilometers' }); 4692 return d <= RADIUS_KM; 4693 } catch (e) { return false; } 4694 }); 4695 4696 console.log(`Buildings in radius: ${buildingsInRadius.length}`); 4697 4698 // Process voxels immediately 4699 requestAnimationFrame(() => { 4700 processVoxels(buildingsInRadius, center); 4701 }); 4702 4703} 4704 4705function processVoxels(buildings, center) { 4706 const CUBE_SIZE = 5, centerLat = center.lat; 4707 const metersPerDegreeLat = 111320; 4708 const metersPerDegreeLon = 111320 * Math.cos(centerLat * Math.PI / 180); 4709 const cubeSizeDegLat = CUBE_SIZE / metersPerDegreeLat; 4710 const cubeSizeDegLon = CUBE_SIZE / metersPerDegreeLon; 4711 const rotationRad = (MONTREAL_GRID_ROTATION * Math.PI) / 180; 4712 4713 console.log(`â³ Processing ${buildings.length} buildings into voxels...`); 4714 4715 const allCubes = []; 4716 let skippedLOD = 0; 4717 const startTime = performance.now(); 4718
4719 buildings.forEach(building => { 4720 const rawHeight = building.properties?.height || 10; 4721 // Round height to nearest CUBE_SIZE to avoid thin partial layers 4722 const height = Math.round(rawHeight / CUBE_SIZE) * CUBE_SIZE; 4723 if (height < CUBE_SIZE) return; // Skip very short buildings 4724 4725 const geometry = building.geometry; 4726 if (!geometry) return; 4727 4728 try { 4729 let poly = geometry.type === 'Polygon' ? turf.polygon(geometry.coordinates) : turf.polygon(geometry.coordinates[0]); 4730 4731 // Shrink building footprint to create gaps between buildings 4732 try { 4733 poly = turf.buffer(poly, -1, { units: 'meters' }); 4734 if (!poly || poly.geometry.coordinates.length === 0) return; // Skip if buffer creates invalid geometry 4735 } catch (e) { 4736 return; // Skip buildings that can't be buffered 4737 } 4738 4739 const bbox = turf.bbox(poly); 4740 const [minLon, minLat, maxLon, maxLat] = bbox; 4741 const centerLon = (minLon + maxLon) / 2, centerLat = (minLat + maxLat) / 2; 4742 const numLayers = Math.ceil(height / CUBE_SIZE); 4743 4744 for (let layer = 0; layer < numLayers; layer++) { 4745 const baseHeight = layer * CUBE_SIZE; 4746 const topHeight = Math.min((layer + 1) * CUBE_SIZE, height); 4747 4748 // Add vertical gap between layers (0.3m gap) 4749 const verticalGap = 0.3; 4750 const adjustedBase = baseHeight + verticalGap; 4751 const adjustedTop = topHeight - verticalGap; 4752 4753 if (adjustedTop <= adjustedBase) continue; // Skip if gap makes layer too thin 4754 4755 // Uniform color for all buildings - cold bright grey 4756 let color = '#dce0e3'; 4757 4758 for (let lat = minLat; lat <= maxLat; lat += cubeSizeDegLat) { 4759 for (let lon = minLon; lon <= maxLon; lon += cubeSizeDegLon) { 4760 const gap = 0.05, gapLat = cubeSizeDegLat * gap, gapLon = cubeSizeDegLon * gap; 4761 4762 const localLon = lon - centerLon, localLat = lat - centerLat; 4763 const rotatedLon = localLon * Math.cos(rotationRad) - localLat * Math.sin(rotationRad); 4764 const rotatedLat = localLon * Math.sin(rotationRad) + localLat * Math.cos(rotationRad); 4765 const finalLon = rotatedLon + centerLon, finalLat = rotatedLat + centerLat; 4766 4767 const cubeSquare = turf.polygon([[ 4768 [finalLon + gapLon, finalLat + gapLat], 4769 [finalLon + cubeSizeDegLon - gapLon, finalLat + gapLat], 4770 [finalLon + cubeSizeDegLon - gapLon, finalLat + cubeSizeDegLat - gapLat], 4771 [finalLon + gapLon, finalLat + cubeSizeDegLat - gapLat], 4772 [finalLon + gapLon, finalLat + gapLat] 4773 ]]); 4774 4775 try { 4776 if (turf.booleanIntersects(cubeSquare, poly)) { 4777 allCubes.push({ 4778 type: 'Feature', 4779 properties: { height: adjustedTop, base: adjustedBase, color: color, originalColor: color }, 4780 geometry: cubeSquare.geometry 4781 }); 4782 } 4783 } catch (e) {} 4784 } 4785 } 4786 } 4787 } catch (e) {} 4788 }); 4789 4790 const processingTime = ((performance.now() - startTime) / 1000).toFixed(2); 4791 console.log(`â Generated ${allCubes.length} voxels in ${processingTime}s (${skippedLOD} distant buildings skipped)`); 4792 4793 // Warn if too many voxels (may cause performance issues) 4794 if (allCubes.length > 15000) { 4795 console.warn(`â ï¸ High voxel count (${allCubes.length}). This may cause performance issues or crashes when zooming.`); 4796 } 4797 4798 allVoxelFeatures = allCubes; 4799 voxelsLoaded = true; 4800 buildSpatialIndex(); 4801 4802 if (map.getSource('voxel-cubes')) { 4803 map.getSource('voxel-cubes').setData({ type: 'FeatureCollection', features: allCubes }); 4804 } else { 4805 map.addSource('voxel-cubes', { 4806 type: 'geojson', 4807 data: { type: 'FeatureCollection', features: allCubes }, 4808 tolerance: 2.0, 4809 buffer: 64, 4810 generateId: true 4811 }); 4812 4813 // Only 3D layer - no 2D layer to avoid disappearing buildings 4814 map.addLayer({ 4815 id: 'voxel-fill', 4816 type: 'fill-extrusion', 4817 source: 'voxel-cubes', 4818 minzoom: 15, 4819 maxzoom: 24, 4820 paint: { 4821 'fill-extrusion-color': ['get', 'color'], 4822 'fill-extrusion-height': [ 4823 'interpolate', ['linear'], ['zoom'], 4824 15, 0, 4825 15.5, ['get', 'height'] 4826 ], 4827 'fill-extrusion-base': ['get', 'base'], 4828 'fill-extrusion-opacity': [ 4829 'interpolate', ['linear'], ['zoom'], 4830 15, 0, 4831 15.5, 0.9 4832 ], 4833 'fill-extrusion-vertical-gradient': true 4834 } 4835 }); 4836 } 4837 4838 // Hide ALL building-related layers to ensure only voxels show 4839 modifyBuildingLayers(); 4840 4841 // Hide loading overlay 4842 document.getElementById('loading-overlay').classList.add('hidden'); 4843 updateZoomPrompt(); 4844} 4845 4846function buildSpatialIndex() { 4847 voxelSpatialIndex = new Map(); 4848 const gridSize = 0.001; 4849
4850 allVoxelFeatures.forEach((voxel, index) => { 4851 try { 4852 const center = turf.center(voxel); 4853 const [lon, lat] = center.geometry.coordinates; 4854 const gridX = Math.floor(lon / gridSize); 4855 const gridY = Math.floor(lat / gridSize); 4856 const key = `${gridX},${gridY}`; 4857 4858 if (!voxelSpatialIndex.has(key)) { 4859 voxelSpatialIndex.set(key, []); 4860 } 4861 voxelSpatialIndex.get(key).push(index); 4862 } catch (e) {} 4863 }); 4864 4865 console.log(`Spatial index: ${voxelSpatialIndex.size} cells`); 4866} 4867 4868 4869// Paint color selection 4870document.getElementById('paint-red').addEventListener('click', () => { 4871 currentPaintColor = 'disliked'; 4872 document.getElementById('paint-red').classList.add('active'); 4873 document.getElementById('paint-green').classList.remove('active'); 4874 4875 // Update preview color if tool is active 4876 if (currentTool === 'paint' && map.getSource('tool-preview')) { 4877 // Force preview update 4878 map.getCanvas().dispatchEvent(new MouseEvent('mousemove')); 4879 } 4880}); 4881 4882document.getElementById('paint-green').addEventListener('click', () => { 4883 currentPaintColor = 'liked'; 4884 document.getElementById('paint-green').classList.add('active'); 4885 document.getElementById('paint-red').classList.remove('active'); 4886 4887 // Update preview color if tool is active 4888 if (currentTool === 'paint' && map.getSource('tool-preview')) { 4889 // Force preview update 4890 map.getCanvas().dispatchEvent(new MouseEvent('mousemove')); 4891 } 4892}); 4893 4894// Display options collapsible 4895const displayOptionsHeader = document.getElementById('display-options-header'); 4896const displayOptionsContent = document.getElementById('display-options-content'); 4897 4898// Initialize as collapsed 4899if (displayOptionsContent) { 4900 displayOptionsContent.style.display = 'none'; 4901 const arrow = displayOptionsHeader.querySelector('span:last-child'); 4902 if (arrow) arrow.textContent = 'â¶'; 4903} 4904 4905displayOptionsHeader.addEventListener('click', () => { 4906 const arrow = displayOptionsHeader.querySelector('span:last-child'); 4907 4908 if (displayOptionsContent.style.display === 'none') { 4909 displayOptionsContent.style.display = 'block'; 4910 arrow.textContent = 'â¼'; 4911 } else { 4912 displayOptionsContent.style.display = 'none'; 4913 arrow.textContent = 'â¶'; 4914 } 4915}); 4916 4917// Tool selection 4918document.getElementById('tool-cursor').addEventListener('click', () => { 4919 currentTool = 'cursor'; 4920 document.getElementById('tool-cursor').classList.add('active'); 4921 document.getElementById('tool-paint').classList.remove('active'); 4922 document.getElementById('tool-erase').classList.remove('active'); 4923 map.getCanvas().style.cursor = ''; 4924 map.dragPan.enable(); 4925 map.scrollZoom.enable(); 4926 map.doubleClickZoom.enable(); 4927 removeToolPreview(); 4928}); 4929 4930document.getElementById('tool-paint').addEventListener('click', () => { 4931 // Hide paint hints 4932 const hintD = document.getElementById('paint-hint-desktop'); 4933 const hintM = document.getElementById('paint-hint-mobile'); 4934 if (hintD) hintD.style.display = 'none'; 4935 if (hintM) hintM.style.display = 'none'; 4936 currentTool = 'paint'; 4937 document.getElementById('tool-paint').classList.add('active'); 4938 document.getElementById('tool-cursor').classList.remove('active'); 4939 document.getElementById('tool-erase').classList.remove('active'); 4940 map.getCanvas().style.cursor = 'crosshair'; 4941 map.dragPan.disable(); 4942 map.scrollZoom.enable(); // KEEP ZOOM ENABLED 4943 map.doubleClickZoom.enable(); // KEEP DOUBLE-CLICK ZOOM 4944 createToolPreview(); 4945}); 4946 4947document.getElementById('tool-erase').addEventListener('click', () => { 4948 currentTool = 'erase'; 4949 document.getElementById('tool-erase').classList.add('active'); 4950 document.getElementById('tool-cursor').classList.remove('active'); 4951 document.getElementById('tool-paint').classList.remove('active'); 4952 map.getCanvas().style.cursor = 'crosshair'; 4953 map.dragPan.disable(); 4954 map.scrollZoom.enable(); // KEEP ZOOM ENABLED 4955 map.doubleClickZoom.enable(); // KEEP DOUBLE-CLICK ZOOM 4956 createToolPreview(); 4957}); 4958 4959// Tool size 4960document.getElementById('tool-size-slider').addEventListener('input', (e) => { 4961 toolSize = parseInt(e.target.value); 4962 document.getElementById('tool-size-value').textContent = toolSize; 4963}); 4964 4965// Paint targets 4966document.getElementById('paint-buildings').addEventListener('change', (e) => { 4967 paintBuildings = e.target.checked; 4968}); 4969 4970document.getElementById('paint-ground').addEventListener('change', (e) => { 4971 paintGround = e.target.checked; 4972}); 4973 4974// Mobile Option F logic 4975if (window.innerWidth <= 768) { 4976 const zonePositiveBtn = document.getElementById('mobile-zone-positive'); 4977 const zoneNegativeBtn = document.getElementById('mobile-zone-negative'); 4978 const peinturerBtn = document.getElementById('mobile-peinturer-btn'); 4979 const navigateBtn = document.getElementById('mobile-navigate-btn'); 4980 const eraseBtn = document.getElementById('mobile-erase-btn'); 4981 4982 let currentZoneType = 'positive'; // Default to positive zone 4983 4984 // Zone type selection 4985 zonePositiveBtn.addEventListener('click', () => { 4986 currentZoneType = 'positive'; 4987 zonePositiveBtn.classList.add('active'); 4988 zoneNegativeBtn.classList.remove('active'); 4989 document.getElementById('paint-green').click(); 4990 }); 4991 4992 zoneNegativeBtn.addEventListener('click', () => { 4993 currentZoneType = 'negative'; 4994 zoneNegativeBtn.classList.add('active'); 4995 zonePositiveBtn.classList.remove('active'); 4996 document.getElementById('paint-red').click(); 4997 }); 4998 4999 // Peinturer button 5000 peinturerBtn.addEventListener('click', () => { 5001 // Hide mobile paint hint 5002 const mobileHint = peinturerBtn.querySelector('.mobile-paint-hint'); 5003 if (mobileHint) mobileHint.style.display = 'none'; 5004 5005 // Set paint tool state directly 5006 currentTool = 'paint'; 5007 5008 // Update mobile button states 5009 peinturerBtn.classList.add('active'); 5010 navigateBtn.classList.remove('active'); 5011 eraseBtn.classList.remove('active'); 5012 5013 // Update desktop button states 5014 document.getElementById('tool-paint').classList.add('active'); 5015 document.getElementById('tool-cursor').classList.remove('active'); 5016 document.getElementById('tool-erase').classList.remove('active'); 5017 5018 // Set map interaction states 5019 map.getCanvas().style.cursor = 'crosshair'; 5020 map.dragPan.disable(); 5021 map.scrollZoom.enable(); 5022 map.doubleClickZoom.enable(); 5023 5024 // Create tool preview 5025 createToolPreview(); 5026 }); 5027 5028 // Navigate button 5029 navigateBtn.addEventListener('click', () => { 5030 // Set cursor tool state directly 5031 currentTool = 'cursor'; 5032 5033 // Update mobile button states 5034 navigateBtn.classList.add('active'); 5035 peinturerBtn.classList.remove('active'); 5036 eraseBtn.classList.remove('active'); 5037 5038 // Update desktop button states 5039 document.getElementById('tool-cursor').classList.add('active'); 5040 document.getElementById('tool-paint').classList.remove('active'); 5041 document.getElementById('tool-erase').classList.remove('active'); 5042 5043 // Set map interaction states 5044 map.getCanvas().style.cursor = ''; 5045 map.dragPan.enable(); 5046 map.scrollZoom.enable(); 5047 map.doubleClickZoom.enable(); 5048 5049 // Remove tool preview 5050 removeToolPreview(); 5051 }); 5052 5053 // Erase button 5054 eraseBtn.addEventListener('click', () => { 5055 // Set erase tool state directly 5056 currentTool = 'erase'; 5057 5058 // Update mobile button states 5059 eraseBtn.classList.add('active'); 5060 peinturerBtn.classList.remove('active'); 5061 navigateBtn.classList.remove('active'); 5062 5063 // Update desktop button states 5064 document.getElementById('tool-erase').classList.add('active'); 5065 document.getElementById('tool-cursor').classList.remove('active'); 5066 document.getElementById('tool-paint').classList.remove('active'); 5067 5068 // Set map interaction states 5069 map.getCanvas().style.cursor = 'crosshair'; 5070 map.dragPan.disable(); 5071 map.scrollZoom.enable(); 5072 map.doubleClickZoom.enable(); 5073 5074 // Create tool preview 5075 createToolPreview(); 5076 }); 5077 5078} 5079 5080// Mobile panel minimize/expand - DISABLED (panel always fixed) 5081// if (window.innerWidth <= 768) { 5082// const minimizeBtn = document.getElementById('panel-minimize-btn'); 5083// const mainPanel = document.querySelector('.main-panel'); 5084// let isPanelMinimized = false;
5085// 5086// if (minimizeBtn && mainPanel) { 5087// minimizeBtn.addEventListener('click', () => { 5088// isPanelMinimized = !isPanelMinimized; 5089// mainPanel.classList.toggle('minimized', isPanelMinimized); 5090// minimizeBtn.textContent = isPanelMinimized ? 'â²' : 'â¼'; 5091// }); 5092// } 5093// } 5094 5095function createToolPreview() { 5096 try { 5097 // Remove existing preview first to avoid conflicts 5098 removeToolPreview(); 5099 5100 map.addSource('tool-preview', { 5101 type: 'geojson', 5102 data: { type: 'FeatureCollection', features: [] } 5103 }); 5104 5105 map.addLayer({ 5106 id: 'tool-preview-fill', 5107 type: 'fill', 5108 source: 'tool-preview', 5109 paint: { 'fill-color': '#ff0000', 'fill-opacity': 0.15 } 5110 }); 5111 5112 map.addLayer({ 5113 id: 'tool-preview-line', 5114 type: 'line', 5115 source: 'tool-preview', 5116 paint: { 'line-color': '#ff0000', 'line-width': 2, 'line-dasharray': [3, 3] } 5117 }); 5118 } catch (error) { 5119 console.error('Error creating tool preview:', error); 5120 } 5121} 5122 5123function removeToolPreview() { 5124 try { 5125 if (map.getLayer('tool-preview-fill')) { 5126 map.removeLayer('tool-preview-fill'); 5127 } 5128 if (map.getLayer('tool-preview-line')) { 5129 map.removeLayer('tool-preview-line'); 5130 } 5131 if (map.getSource('tool-preview')) { 5132 map.removeSource('tool-preview'); 5133 } 5134 } catch (error) { 5135 console.error('Error removing tool preview:', error); 5136 } 5137} 5138 5139 5140// OPTIMIZED: Paint buildings - batch updates to single source 5141function paintBuildingsWithOverlay(clickPoint, radiusMeters, color, type) { 5142 const gridSize = 0.001; 5143 const radiusDeg = radiusMeters / 111320; 5144 const [clickLon, clickLat] = clickPoint; 5145 5146 const gridRadius = Math.ceil(radiusDeg / gridSize); 5147 const centerGridX = Math.floor(clickLon / gridSize); 5148 const centerGridY = Math.floor(clickLat / gridSize); 5149 5150 let updatedCount = 0; 5151 5152 for (let dx = -gridRadius; dx <= gridRadius; dx++) { 5153 for (let dy = -gridRadius; dy <= gridRadius; dy++) { 5154 const key = `${centerGridX + dx},${centerGridY + dy}`; 5155 const indices = voxelSpatialIndex.get(key); 5156 5157 if (indices) { 5158 indices.forEach(index => { 5159 const voxel = allVoxelFeatures[index]; 5160 try { 5161 if (!voxel._center) { 5162 const center = turf.center(voxel); 5163 voxel._center = center.geometry.coordinates; 5164 } 5165 5166 const dx = clickPoint[0] - voxel._center[0]; 5167 const dy = clickPoint[1] - voxel._center[1]; 5168 const distSquared = dx * dx + dy * dy; 5169 const radiusDegSquared = radiusDeg * radiusDeg; 5170 5171 if (distSquared < radiusDegSquared) { 5172 // Add to painted features 5173 addPaintedVoxel(voxel, color, type, index); 5174 updatedCount++; 5175 } 5176 } catch (e) {} 5177 }); 5178 } 5179 } 5180 } 5181 5182 // Batch update the map source (throttled) 5183 if (updatedCount > 0) { 5184 schedulePaintUpdate(); 5185 } 5186} 5187 5188// OPTIMIZED: Add voxel to painted features array (batched) 5189function addPaintedVoxel(voxel, color, type, voxelIndex) { 5190 const baseHeight = voxel.properties.base || 0; 5191 const topHeight = voxel.properties.height || 10; 5192 5193 if (topHeight <= baseHeight) return; 5194 5195 // Mark voxel as tagged (don't change color - use overlay only) 5196 voxel.properties.tagged = true; 5197 voxel.properties.tagType = type; 5198 5199 // Check if already painted using index map 5200 const existingIdx = paintedVoxelIndex.get(voxelIndex); 5201 5202 // Expand only slightly (1.1m) to cover the 1m gap, no more 5203 let expandedGeometry = voxel.geometry; 5204 try { 5205 const poly = turf.polygon(voxel.geometry.coordinates); 5206 const expanded = turf.buffer(poly, 1.1, { units: 'meters' }); 5207 if (expanded && expanded.geometry) { 5208 expandedGeometry = expanded.geometry; 5209 } 5210 } catch (e) { 5211 // If buffer fails, use original 5212 } 5213 5214 const overlayFeature = { 5215 type: 'Feature', 5216 geometry: expandedGeometry, 5217 properties: { 5218 color: color, 5219 type: type, 5220 base: baseHeight, // Use actual voxel base height 5221 height: topHeight + 0.5, // Slightly higher - minimal overlap with neighbors 5222 voxelIndex: voxelIndex 5223 } 5224 }; 5225 5226 if (existingIdx !== undefined) { 5227 // Update existing 5228 paintedVoxelFeatures[existingIdx] = overlayFeature; 5229 } else { 5230 // Add new - check mobile limit to prevent performance issues 5231 const isMobile = window.innerWidth <= 768; 5232 const maxFeatures = isMobile ? 1500 : 5000; 5233 5234 if (paintedVoxelFeatures.length >= maxFeatures) { 5235 // On mobile, limit total painted features to prevent memory/performance issues 5236 // Still allow updating existing voxels, just not adding new ones 5237 5238 // Show notification once when limit is reached 5239 if (!window.paintLimitNotified) { 5240 window.paintLimitNotified = true; 5241 const title = currentLang === 'fr' ? 'Limite atteinte' : 'Limit reached'; 5242 const message = currentLang === 'fr' 5243 ? 'Vous avez atteint la limite de peinture. Vous pouvez continuer à modifier les zones déjà peintes ou passer à l\'étape suivante.' 5244 : 'You have reached the painting limit. You can continue to modify already painted areas or proceed to the next step.'; 5245 setTimeout(() => showCustomAlert(title, message), 100); 5246 } 5247 return; 5248 } 5249 5250 const newIdx = paintedVoxelFeatures.length; 5251 paintedVoxelFeatures.push(overlayFeature); 5252 paintedVoxelIndex.set(voxelIndex, newIdx); 5253 } 5254 5255 // Track in appropriate set 5256 if (type === 'disliked') { 5257 dislikedVoxels.add(voxelIndex); 5258 likedVoxels.delete(voxelIndex); 5259 } else { 5260 likedVoxels.add(voxelIndex); 5261 dislikedVoxels.delete(voxelIndex); 5262 } 5263} 5264 5265// OPTIMIZED: Batch update painted voxels (throttled with RAF) 5266function schedulePaintUpdate() { 5267 if (paintUpdatePending) return; 5268 5269 paintUpdatePending = true; 5270 requestAnimationFrame(() => { 5271 paintUpdatePending = false;
5272 updatePaintedVoxelsSource(); 5273 }); 5274} 5275 5276function updatePaintedVoxelsSource() { 5277 // Create or update single source for all painted voxels 5278 if (!map.getSource('painted-voxels')) { 5279 map.addSource('painted-voxels', { 5280 type: 'geojson', 5281 data: { 5282 type: 'FeatureCollection', 5283 features: paintedVoxelFeatures 5284 } 5285 }); 5286 5287 // 3D layer only - simplified rendering 5288 map.addLayer({ 5289 id: 'painted-voxels-layer', 5290 type: 'fill-extrusion', 5291 source: 'painted-voxels', 5292 minzoom: 15, 5293 maxzoom: 24, 5294 paint: { 5295 'fill-extrusion-color': ['get', 'color'], 5296 'fill-extrusion-height': [ 5297 'interpolate', ['linear'], ['zoom'], 5298 15, 0, 5299 15.5, ['get', 'height'] 5300 ], 5301 'fill-extrusion-base': ['get', 'base'], 5302 'fill-extrusion-opacity': [ 5303 'interpolate', ['linear'], ['zoom'], 5304 15, 0, 5305 15.5, 1.0 5306 ], 5307 'fill-extrusion-vertical-gradient': false // Disable gradient for solid colors 5308 } 5309 }); 5310 5311 // Move painted layers to the very top 5312 if (map.getLayer('voxel-fill')) { 5313 map.moveLayer('painted-voxels-layer-2d'); 5314 map.moveLayer('painted-voxels-layer'); 5315 } 5316 } else { 5317 map.getSource('painted-voxels').setData({ 5318 type: 'FeatureCollection', 5319 features: paintedVoxelFeatures 5320 }); 5321 } 5322} 5323 5324 5325function performAction(clickPoint, pixelPoint) { 5326 try { 5327 if (currentTool === 'erase') { 5328 eraseAtPoint(clickPoint, toolSize); 5329 } else if (currentTool === 'paint') { 5330 const currentColor = currentPaintColor === 'disliked' ? '#ff0000' : '#00ff00'; 5331 5332 if (paintGround) { 5333 paintGroundCircle(clickPoint, toolSize, currentColor, currentPaintColor); 5334 } 5335 5336 if (paintBuildings && voxelSpatialIndex) { 5337 paintBuildingsWithOverlay(clickPoint, toolSize, currentColor, currentPaintColor); 5338 } 5339 } 5340 } catch (error) { 5341 console.error('Error in performAction:', error); 5342 console.log('Current tool:', currentTool, 'Paint ground:', paintGround, 'Paint buildings:', paintBuildings); 5343 } 5344} 5345 5346// OPTIMIZED: Update only specific voxels that were painted 5347function updatePaintedVoxels(indices) { 5348 if (!map.getSource('voxel-cubes')) return; 5349 5350 const source = map.getSource('voxel-cubes'); 5351 const currentData = source._data; 5352 5353 if (currentData && currentData.features) { 5354 indices.forEach(idx => { 5355 if (currentData.features[idx] && allVoxelFeatures[idx]) { 5356 currentData.features[idx].properties.color = allVoxelFeatures[idx].properties.color; 5357 currentData.features[idx].properties.tagged = allVoxelFeatures[idx].properties.tagged; 5358 currentData.features[idx].properties.tagType = allVoxelFeatures[idx].properties.tagType; 5359 } 5360 }); 5361 5362 // Much faster update - only changes painted voxels 5363 source.setData(currentData); 5364 } 5365} 5366 5367function fastSprayPaint(clickPoint, radiusMeters, color, type, pixelPoint) { 5368 const gridSize = 0.001; 5369 const radiusDeg = radiusMeters / 111320; 5370 const [clickLon, clickLat] = clickPoint; 5371 5372 // Use selected paint height from dropdown 5373 let targetHeight = selectedPaintHeight; 5374 5375 const gridRadius = Math.ceil(radiusDeg / gridSize); 5376 const centerGridX = Math.floor(clickLon / gridSize); 5377 const centerGridY = Math.floor(clickLat / gridSize); 5378 5379 const paintedIndices = []; // Return indices instead of count 5380 const targetSet = type === 'disliked' ? dislikedVoxels : likedVoxels; 5381 const radiusDegSquared = radiusDeg * radiusDeg; 5382 5383 for (let dx = -gridRadius; dx <= gridRadius; dx++) { 5384 for (let dy = -gridRadius; dy <= gridRadius; dy++) { 5385 const key = `${centerGridX + dx},${centerGridY + dy}`; 5386 const indices = voxelSpatialIndex.get(key); 5387 5388 if (indices) { 5389 indices.forEach(index => { 5390 const voxel = allVoxelFeatures[index]; 5391 try { 5392 if (!voxel._center) { 5393 const center = turf.center(voxel); 5394 voxel._center = center.geometry.coordinates; 5395 } 5396 5397 // FIX 3: 3D distance check to prevent stacking bug 5398 const clickHeight = clickPoint[2] || 0; 5399 const voxelHeight = voxel._center[2] || 0; 5400 5401 // Horizontal distance in degrees 5402 const dx = clickPoint[0] - voxel._center[0]; 5403 const dy = clickPoint[1] - voxel._center[1]; 5404 const distSquared2D = dx * dx + dy * dy; 5405 5406 // CRITICAL: Also check vertical distance (height) 5407 // Voxels are ~5-10m tall, so if height difference > toolSize, don't paint 5408 const heightDiff = Math.abs(clickHeight - voxelHeight); 5409 5410 // Only paint if within horizontal range AND height difference is small 5411 if (distSquared2D < radiusDegSquared && heightDiff < 15) { 5412 voxel.properties.tagged = true; 5413 voxel.properties.tagType = type; 5414 targetSet.add(index); 5415 paintedIndices.push(index); // Collect painted indices 5416 } 5417 } catch (e) {} 5418 }); 5419 } 5420 } 5421 } 5422 5423 return paintedIndices; 5424} 5425 5426function paintGroundCircle(center, radiusMeters, color, type) { 5427 const radiusKm = radiusMeters / 1000; 5428 const circle = turf.circle(center, radiusKm, { steps: 16, units: 'kilometers' }); // Reduced steps from 32 to 16 5429 5430 // Use unique counter instead of array length to avoid ID collisions after erasing 5431 const paintId = `ground-paint-${groundPaintIdCounter++}`; 5432 circle.properties = { color: color, type: type }; 5433 5434 map.addSource(paintId, { 5435 type: 'geojson', 5436 data: circle 5437 }); 5438 5439 // Insert ground paint BEFORE all voxel layers (at the bottom) 5440 const beforeLayer = map.getLayer('voxel-fill-2d') ? 'voxel-fill-2d' : 5441 map.getLayer('voxel-fill') ? 'voxel-fill' : undefined; 5442 5443 map.addLayer({ 5444 id: paintId, 5445 type: 'fill', 5446 source: paintId, 5447 paint: { 5448 'fill-color': color, 5449 'fill-opacity': 0.4 5450 } 5451 }, beforeLayer); 5452 5453 // Force immediate high-quality render 5454 map.triggerRepaint(); 5455 5456 groundPaintMarkers.push(paintId); 5457 5458 // Limit total ground markers to prevent performance issues 5459 if (groundPaintMarkers.length > 500) { 5460 const oldestId = groundPaintMarkers.shift(); 5461 if (map.getLayer(oldestId)) { 5462 map.removeLayer(oldestId); 5463 map.removeSource(oldestId); 5464 } 5465 } 5466} 5467 5468function eraseAtPoint(clickPoint, radiusMeters) { 5469 const radiusKm = radiusMeters / 1000; 5470 5471 if (paintGround) { 5472 groundPaintMarkers = groundPaintMarkers.filter(paintId => {
5473 try { 5474 const source = map.getSource(paintId); 5475 if (source && source._data && source._data.geometry) { 5476 const eraseCircle = turf.circle(clickPoint, radiusKm, { units: 'kilometers' }); 5477 const overlaps = turf.booleanOverlap(source._data, eraseCircle) || 5478 turf.booleanContains(eraseCircle, source._data) || 5479 turf.booleanWithin(source._data, eraseCircle); 5480 5481 if (overlaps) { 5482 if (map.getLayer(paintId)) { 5483 map.removeLayer(paintId); 5484 map.removeSource(paintId); 5485 } 5486 return false; 5487 } 5488 } 5489 } catch (e) {} 5490 return true; 5491 }); 5492 } 5493 5494 if (paintBuildings && voxelSpatialIndex) { 5495 const gridSize = 0.001; 5496 const radiusDeg = radiusMeters / 111320; 5497 const [clickLon, clickLat] = clickPoint; 5498 5499 const gridRadius = Math.ceil(radiusDeg / gridSize) + 2; // Extend search radius to catch buffered overlays 5500 const centerGridX = Math.floor(clickLon / gridSize); 5501 const centerGridY = Math.floor(clickLat / gridSize); 5502 5503 let erasedCount = 0; 5504 const eraseCircle = turf.circle(clickPoint, radiusKm, { units: 'kilometers', steps: 32 }); 5505 const toErase = []; // Collect indices to erase 5506 5507 for (let dx = -gridRadius; dx <= gridRadius; dx++) { 5508 for (let dy = -gridRadius; dy <= gridRadius; dy++) { 5509 const key = `${centerGridX + dx},${centerGridY + dy}`; 5510 const indices = voxelSpatialIndex.get(key); 5511 5512 if (indices) { 5513 indices.forEach(index => { 5514 const voxel = allVoxelFeatures[index]; 5515 5516 if (voxel.properties.tagged) { 5517 try { 5518 // Check if the PAINTED feature (which is buffered) overlaps with erase circle using index map 5519 const paintIdx = paintedVoxelIndex.get(index); 5520 if (paintIdx !== undefined) { 5521 const paintedFeature = paintedVoxelFeatures[paintIdx]; 5522 const paintedPoly = turf.polygon(paintedFeature.geometry.coordinates); 5523 5524 // Check for any overlap between the painted (buffered) geometry and erase circle 5525 const overlaps = turf.booleanOverlap(paintedPoly, eraseCircle) || 5526 turf.booleanContains(eraseCircle, paintedPoly) || 5527 turf.booleanWithin(paintedPoly, eraseCircle); 5528 5529 if (overlaps) { 5530 toErase.push(index); 5531 // Restore original properties 5532 voxel.properties.color = voxel.properties.originalColor; 5533 delete voxel.properties.tagged; 5534 delete voxel.properties.tagType; 5535 dislikedVoxels.delete(index); 5536 likedVoxels.delete(index); 5537 erasedCount++; 5538 } 5539 } 5540 } catch (e) { 5541 console.error('Error checking voxel for erase:', e); 5542 } 5543 } 5544 }); 5545 } 5546 } 5547 } 5548 5549 if (erasedCount > 0) { 5550 // Remove erased voxels from painted features and rebuild index 5551 paintedVoxelFeatures = paintedVoxelFeatures.filter(f => !toErase.includes(f.properties.voxelIndex)); 5552 5553 // Rebuild index map 5554 paintedVoxelIndex.clear(); 5555 paintedVoxelFeatures.forEach((feature, idx) => { 5556 paintedVoxelIndex.set(feature.properties.voxelIndex, idx); 5557 }); 5558 5559 // Force immediate update for erasing - only update painted layer 5560 updatePaintedVoxelsSource(); 5561 } 5562 } 5563} 5564 5565function updateVoxelColors() { 5566 // OPTIMIZED: Update colors directly in the existing data without reloading 5567 // This prevents voxels from disappearing and is much faster 5568 if (map.getSource('voxel-cubes')) { 5569 const source = map.getSource('voxel-cubes'); 5570 const currentData = source._data; 5571 5572 // Update colors in place 5573 if (currentData && currentData.features) { 5574 currentData.features.forEach((feature, idx) => { 5575 if (allVoxelFeatures[idx]) { 5576 feature.properties.color = allVoxelFeatures[idx].properties.color; 5577 feature.properties.tagged = allVoxelFeatures[idx].properties.tagged; 5578 feature.properties.tagType = allVoxelFeatures[idx].properties.tagType; 5579 } 5580 }); 5581 } 5582 5583 // Trigger re-render without full data reload 5584 source.setData(currentData); 5585 } 5586} 5587 5588// Clear all paint 5589document.getElementById('clear-all-paint-btn').addEventListener('click', () => { 5590 const lang = (typeof currentLang !== 'undefined') ? currentLang : 'fr'; 5591 const title = lang === 'fr' ? 'Confirmation' : 'Confirmation'; 5592 const message = lang === 'fr' ? 'Effacer toutes les zones peintes ?' : 'Clear all paint?'; 5593 5594 showCustomConfirm(title, message, (confirmed) => { 5595 if (confirmed) { 5596 dislikedVoxels.clear(); 5597 likedVoxels.clear(); 5598 5599 // Clear painted voxel features array and index 5600 paintedVoxelFeatures = []; 5601 paintedVoxelIndex.clear(); 5602
5603 allVoxelFeatures.forEach(voxel => { 5604 if (voxel.properties.tagged) { 5605 voxel.properties.color = voxel.properties.originalColor; 5606 delete voxel.properties.tagged; 5607 delete voxel.properties.tagType; 5608 } 5609 }); 5610 5611 // Update both sources immediately 5612 updatePaintedVoxelsSource(); 5613 updateVoxelColors(); 5614 5615 groundPaintMarkers.forEach(paintId => { 5616 if (map.getLayer(paintId)) { 5617 map.removeLayer(paintId); 5618 map.removeSource(paintId); 5619 } 5620 }); 5621 groundPaintMarkers = []; 5622 groundPaintIdCounter = 0; // Reset ground paint ID counter 5623 window.paintLimitNotified = false; // Reset paint limit notification 5624 5625 // Remove overlays 5626 paintedOverlays.forEach(overlayId => { 5627 try { 5628 if (map.getLayer(overlayId)) map.removeLayer(overlayId); 5629 if (map.getSource(overlayId)) map.removeSource(overlayId); 5630 } catch(e) {} 5631 }); 5632 paintedOverlays = []; 5633 5634 // Remove split divider lines 5635 splitDividerLines.forEach(lineId => { 5636 try { 5637 if (map.getLayer(lineId)) map.removeLayer(lineId); 5638 if (map.getSource(lineId)) map.removeSource(lineId); 5639 } catch(e) {} 5640 }); 5641 splitDividerLines = []; 5642 } 5643 }); 5644}); 5645 5646// Next to cluster tagging 5647document.getElementById('next-to-tag-clusters-btn').addEventListener('click', () => { 5648 // Check if there are any painted zones BEFORE transitioning to Step 3 5649 const hasPaintedVoxels = dislikedVoxels.size > 0 || likedVoxels.size > 0; 5650 const hasGroundPolygons = groundPaintMarkers.length > 0; 5651 5652 if (!hasPaintedVoxels && !hasGroundPolygons) { 5653 const title = currentLang === 'fr' ? 'Avis' : 'Notice'; 5654 const message = currentLang === 'fr' ? 'Aucune zone peinte trouvée.' : 'No painted areas found.'; 5655 showCustomAlert(title, message); 5656 return; // Stay on Step 2 5657 } 5658 5659 document.getElementById('step2-paint').classList.add('hidden'); 5660 document.getElementById('step3-tag-clusters').classList.remove('hidden'); 5661 5662 // Scroll panel to top on mobile 5663 const mainPanel = document.querySelector('.main-panel'); 5664 if (mainPanel) { 5665 mainPanel.scrollTop = 0; 5666 } 5667 5668 // Update step 5669 currentStep = 3; 5670 5671 // Update progress and show tutorial 5672 updateProgress(3); 5673 showTutorial(2); 5674 5675 // Update info tooltip for new step 5676 if (typeof updateInfoTooltip === 'function') updateInfoTooltip(); 5677 5678 // Reset tool to cursor 5679 if (currentTool !== 'cursor') { 5680 document.getElementById('tool-cursor').click(); 5681 } 5682 5683 detectAndDisplayClusters(); 5684}); 5685 5686// Improved cluster detection with edge-to-edge distance for ground circles 5687function detectAndDisplayClusters() { 5688 const CLUSTER_DISTANCE_KM = 0.020; // 20m edge-to-edge distance for merging clusters 5689 5690 console.log('Starting cluster detection...'); 5691 5692 const dislikedPoints = []; 5693 dislikedVoxels.forEach(idx => { 5694 const voxel = allVoxelFeatures[idx]; 5695 if (!voxel._center) { 5696 voxel._center = turf.center(voxel).geometry.coordinates; 5697 } 5698 dislikedPoints.push({ coords: voxel._center, type: 'disliked', idx, isVoxel: true }); 5699 }); 5700 5701 // FIX 1: Add ground polygons to clustering 5702 groundPaintMarkers.forEach(paintId => { 5703 const source = map.getSource(paintId); 5704 if (!source) return; 5705 const data = source._data; 5706 if (data.properties.type === 'disliked') { 5707 const center = turf.center(data).geometry.coordinates; 5708 const area = turf.area(data); 5709 const radius = Math.sqrt(area / Math.PI) / 1000; // radius in km 5710 dislikedPoints.push({ 5711 coords: center, 5712 type: 'disliked', 5713 idx: paintId, 5714 isVoxel: false, 5715 area: area, 5716 radius: radius, 5717 geometry: data.geometry 5718 }); 5719 } 5720 }); 5721 5722 const likedPoints = []; 5723 likedVoxels.forEach(idx => { 5724 const voxel = allVoxelFeatures[idx]; 5725 if (!voxel._center) { 5726 voxel._center = turf.center(voxel).geometry.coordinates; 5727 } 5728 likedPoints.push({ coords: voxel._center, type: 'liked', idx, isVoxel: true }); 5729 }); 5730 5731 // FIX 1: Add ground polygons to clustering
5732 groundPaintMarkers.forEach(paintId => { 5733 const source = map.getSource(paintId); 5734 if (!source) return; 5735 const data = source._data; 5736 if (data.properties.type === 'liked') { 5737 const center = turf.center(data).geometry.coordinates; 5738 const area = turf.area(data); 5739 const radius = Math.sqrt(area / Math.PI) / 1000; // radius in km 5740 likedPoints.push({ 5741 coords: center, 5742 type: 'liked', 5743 idx: paintId, 5744 isVoxel: false, 5745 area: area, 5746 radius: radius, 5747 geometry: data.geometry 5748 }); 5749 } 5750 }); 5751 5752 console.log(`Clustering ${dislikedPoints.length} disliked and ${likedPoints.length} liked points`); 5753 5754 const dislikedClusters = clusterPointsDBSCAN(dislikedPoints, CLUSTER_DISTANCE_KM); 5755 const likedClusters = clusterPointsDBSCAN(likedPoints, CLUSTER_DISTANCE_KM); 5756 5757 console.log(`Found ${dislikedClusters.length} disliked clusters and ${likedClusters.length} liked clusters`); 5758 5759 // Build allClusters array 5760 allClusters = []; 5761 5762 dislikedClusters.forEach((cluster, idx) => { 5763 const coords = cluster.map(p => p.coords); 5764 const avgLon = coords.reduce((sum, c) => sum + c[0], 0) / coords.length; 5765 const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length; 5766 5767 allClusters.push({ 5768 id: `disliked-${idx}`, 5769 type: 'disliked', 5770 label: `Disliked Area ${idx + 1}`, 5771 size: cluster.length, 5772 centroid: [avgLon, avgLat], 5773 points: cluster, 5774 splitCount: 0 // Track number of times this cluster has been split 5775 }); 5776 }); 5777 5778 likedClusters.forEach((cluster, idx) => { 5779 const coords = cluster.map(p => p.coords); 5780 const avgLon = coords.reduce((sum, c) => sum + c[0], 0) / coords.length; 5781 const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length; 5782 5783 allClusters.push({ 5784 id: `liked-${idx}`, 5785 type: 'liked', 5786 label: `Liked Area ${idx + 1}`, 5787 size: cluster.length, 5788 centroid: [avgLon, avgLat], 5789 points: cluster, 5790 splitCount: 0 // Track number of times this cluster has been split 5791 }); 5792 }); 5793 5794 if (allClusters.length === 0) { 5795 const title = currentLang === 'fr' ? 'Avis' : 'Notice'; 5796 const message = currentLang === 'fr' ? 'Aucune zone peinte trouvée.' : 'No painted areas found.'; 5797 showCustomAlert(title, message); 5798 document.getElementById('step3-tag-clusters').classList.add('hidden'); 5799 document.getElementById('step2-paint').classList.remove('hidden'); 5800 currentStep = 2; 5801 updateProgress(2); 5802 if (typeof updateInfoTooltip === 'function') updateInfoTooltip(); 5803 return; 5804 } 5805 5806 // Initialize navigation handlers 5807 initStep3Navigation(); 5808 5809 // Start with first cluster 5810 currentClusterIndex = 0; 5811 showCluster(0); 5812} 5813 5814// Optimized DBSCAN-like clustering with spatial indexing 5815function clusterPointsDBSCAN(points, maxDistance) { 5816 if (points.length === 0) return []; 5817 5818 // Build spatial index for fast neighbor lookup 5819 const spatialGrid = new Map(); 5820 const gridSize = maxDistance; // Grid cell size = cluster distance 5821 5822 points.forEach((point, idx) => { 5823 const [lon, lat] = point.coords; 5824 const gridX = Math.floor(lon / gridSize); 5825 const gridY = Math.floor(lat / gridSize); 5826 const key = `${gridX},${gridY}`; 5827 5828 if (!spatialGrid.has(key)) { 5829 spatialGrid.set(key, []); 5830 } 5831 spatialGrid.get(key).push({ ...point, originalIdx: idx }); 5832 }); 5833 5834 // DBSCAN clustering 5835 const clusters = []; 5836 const visited = new Set(); 5837 const clustered = new Set(); 5838 5839 function getNeighbors(pointIdx) { 5840 const point = points[pointIdx]; 5841 const [lon, lat] = point.coords; 5842 const gridX = Math.floor(lon / gridSize); 5843 const gridY = Math.floor(lat / gridSize); 5844 5845 const neighbors = []; 5846 5847 // Check surrounding cells (3x3 grid) 5848 for (let dx = -1; dx <= 1; dx++) { 5849 for (let dy = -1; dy <= 1; dy++) { 5850 const key = `${gridX + dx},${gridY + dy}`; 5851 const cellPoints = spatialGrid.get(key); 5852 5853 if (cellPoints) { 5854 cellPoints.forEach(cellPoint => { 5855 const centerDist = turf.distance(point.coords, cellPoint.coords, { units: 'kilometers' }); 5856 5857 // Calculate edge-to-edge distance for ground paint circles 5858 let effectiveDistance = centerDist; 5859 5860 // If both points are ground paint circles, subtract their radii 5861 if (point.isVoxel === false && point.radius && cellPoint.radius) { 5862 effectiveDistance = Math.max(0, centerDist - point.radius - cellPoint.radius); 5863 } 5864 // If one is a ground circle, subtract its radius 5865 else if (point.isVoxel === false && point.radius) { 5866 effectiveDistance = Math.max(0, centerDist - point.radius); 5867 } 5868 else if (cellPoint.radius && cellPoint.isVoxel === false) { 5869 effectiveDistance = Math.max(0, centerDist - cellPoint.radius); 5870 } 5871 5872 if (effectiveDistance <= maxDistance && cellPoint.originalIdx !== pointIdx) { 5873 neighbors.push(cellPoint.originalIdx); 5874 } 5875 }); 5876 } 5877 } 5878 } 5879 5880 return neighbors; 5881 } 5882 5883 function expandCluster(startIdx, neighbors, cluster) { 5884 cluster.push(points[startIdx]); 5885 clustered.add(startIdx); 5886 5887 const queue = [...neighbors]; 5888 const inQueue = new Set(neighbors); 5889 5890 while (queue.length > 0) { 5891 const currentIdx = queue.shift(); 5892 5893 if (visited.has(currentIdx)) continue; 5894 visited.add(currentIdx); 5895 5896 if (!clustered.has(currentIdx)) { 5897 cluster.push(points[currentIdx]); 5898 clustered.add(currentIdx); 5899 } 5900 5901 const currentNeighbors = getNeighbors(currentIdx); 5902
5903 currentNeighbors.forEach(neighborIdx => { 5904 if (!inQueue.has(neighborIdx) && !clustered.has(neighborIdx)) { 5905 queue.push(neighborIdx); 5906 inQueue.add(neighborIdx); 5907 } 5908 }); 5909 } 5910 } 5911 5912 // Process all points 5913 for (let i = 0; i < points.length; i++) { 5914 if (clustered.has(i)) continue; 5915 5916 visited.add(i); 5917 const neighbors = getNeighbors(i); 5918 5919 // Start new cluster 5920 const cluster = []; 5921 expandCluster(i, neighbors, cluster); 5922 5923 if (cluster.length > 0) { 5924 clusters.push(cluster); 5925 } 5926 } 5927 5928 return clusters; 5929} 5930 5931function showCluster(index) { 5932 const cluster = allClusters[index]; 5933 5934 // Scroll panel to top when showing new cluster 5935 const mainPanel = document.querySelector('.main-panel'); 5936 if (mainPanel) { 5937 mainPanel.scrollTop = 0; 5938 } 5939 5940 document.getElementById('cluster-number').textContent = index + 1; 5941 document.getElementById('cluster-total').textContent = allClusters.length; 5942 5943 const badge = document.getElementById('cluster-type-badge'); 5944 const badgeText = cluster.type === 'disliked' 5945 ? (currentLang === 'fr' ? 'Zone négative' : 'Disliked Area') 5946 : (currentLang === 'fr' ? 'Zone positive' : 'Liked Area'); 5947 badge.textContent = badgeText; 5948 badge.className = `cluster-type-badge ${cluster.type}`; 5949 5950 document.getElementById('cluster-size').textContent = `${cluster.size} voxels`; 5951 5952 // Calculate ground area from the cluster's ground paint points 5953 let groundArea = 0; 5954 let voxelCount = 0; 5955 const groundGeometries = []; 5956 5957 if (cluster.points && cluster.points.length > 0) { 5958 cluster.points.forEach(point => { 5959 if (point.isVoxel === false && point.geometry) { 5960 // Collect geometries for union 5961 groundGeometries.push(point.geometry); 5962 } else if (point.isVoxel === true) { 5963 voxelCount++; 5964 } 5965 }); 5966 } 5967 5968 // Calculate union to get actual overlaid area 5969 if (groundGeometries.length > 0) { 5970 console.log(`=== AREA CALCULATION for cluster ${cluster.id} ===`); 5971 console.log(`Ground geometries count: ${groundGeometries.length}`); 5972 5973 try { 5974 if (groundGeometries.length === 1) { 5975 // Single geometry 5976 groundArea = turf.area(groundGeometries[0]); 5977 console.log(`Single geometry area: ${Math.round(groundArea)} m²`); 5978 } else { 5979 // Properly union overlapping circles 5980 console.log('Starting union of overlapping circles...'); 5981 5982 let union = turf.feature(groundGeometries[0]); 5983 let successfulUnions = 1; 5984 5985 for (let i = 1; i < groundGeometries.length; i++) { 5986 try { 5987 const nextFeature = turf.feature(groundGeometries[i]); 5988 const result = turf.union(union, nextFeature); 5989 5990 if (result && result.geometry) { 5991 union = result; 5992 successfulUnions++; 5993 } else { 5994 console.warn(`Union ${i} returned null, skipping`); 5995 } 5996 } catch (unionError) { 5997 console.warn(`Union failed at geometry ${i}:`, unionError); 5998 } 5999 } 6000 6001 if (union && union.geometry) { 6002 groundArea = turf.area(union.geometry); 6003 console.log(`Successfully unioned ${successfulUnions}/${groundGeometries.length} geometries`); 6004 console.log(`Final union area: ${Math.round(groundArea)} m²`); 6005 6006 // Update cluster centroid to be center of the union 6007 const unionCentroid = turf.centroid(union); 6008 cluster.centroid = unionCentroid.geometry.coordinates; 6009 } else { 6010 // Fallback: use first circle only 6011 groundArea = turf.area(groundGeometries[0]); 6012 console.log(`Union failed, using first geometry: ${Math.round(groundArea)} m²`); 6013 } 6014 } 6015 } catch (error) { 6016 console.error('Error calculating ground area:', error); 6017 // Fallback to first geometry 6018 if (groundGeometries.length > 0) { 6019 groundArea = turf.area(groundGeometries[0]); 6020 console.log(`Error fallback area: ${Math.round(groundArea)} m²`); 6021 } 6022 } 6023 console.log(`=== END AREA CALCULATION ===`); 6024 } 6025 6026 // Update display with ground area if available 6027 const sizeText = currentLang === 'fr' 6028 ? `${voxelCount} voxels${groundArea > 0 ? ` ⢠${Math.round(groundArea)} m²` : ''}` 6029 : `${voxelCount} voxels${groundArea > 0 ? ` ⢠${Math.round(groundArea)} m²` : ''}`; 6030 document.getElementById('cluster-size').textContent = sizeText; 6031 6032 renderTagCategories(cluster); 6033 showClusterMarker(cluster.centroid, cluster.type); 6034 6035 document.getElementById('prev-cluster-btn').disabled = (index === 0); 6036 const nextBtn = document.getElementById('next-cluster-btn'); 6037 const nextText = currentLang === 'fr' ? 'Suivant â' : 'Next â'; 6038 const finishText = currentLang === 'fr' ? 'Terminer â' : 'Finish â';
6039 nextBtn.innerHTML = `<span data-en="${index === allClusters.length - 1 ? 'Finish â' : 'Next â'}" data-fr="${index === allClusters.length - 1 ? 'Terminer â' : 'Suivant â'}">${index === allClusters.length - 1 ? finishText : nextText}</span>`; 6040 6041 // Update split section visibility based on split status 6042 const splitSection = document.querySelector('.split-section'); 6043 const splitBtn = document.getElementById('split-cluster-btn'); 6044 6045 if (cluster.splitCount >= 1) { 6046 // Already split - show cancel split button in orange 6047 splitSection.style.display = 'block'; 6048 const cancelText = currentLang === 'fr' ? 'Annuler la division' : 'Cancel Split'; 6049 splitBtn.innerHTML = `<span data-en="Cancel Split" data-fr="Annuler la division">${cancelText}</span>`; 6050 splitBtn.dataset.action = 'cancel'; 6051 splitBtn.style.background = '#ff9800'; 6052 splitBtn.style.borderColor = '#ff9800'; 6053 splitBtn.style.color = 'white'; 6054 splitBtn.style.borderStyle = 'solid'; 6055 } else { 6056 // Not split - show split section 6057 splitSection.style.display = 'block'; 6058 const splitText = currentLang === 'fr' ? 'Diviser automatiquement' : 'Split Automatically'; 6059 splitBtn.innerHTML = `<span data-en="Split Automatically" data-fr="Diviser automatiquement">${splitText}</span>`; 6060 splitBtn.dataset.action = 'split'; 6061 splitBtn.style.background = '#fff3e0'; 6062 splitBtn.style.borderColor = '#ff9800'; 6063 splitBtn.style.color = '#ff9800'; 6064 splitBtn.style.borderStyle = 'dashed'; 6065 } 6066} 6067 6068function renderTagCategories(cluster) { 6069 const container = document.getElementById('tag-categories-container'); 6070 container.innerHTML = ''; 6071 6072 const savedData = clusterLabels[cluster.id] || { tags: [], comment: '', openCategory: null }; 6073 6074 // Show only positive for liked clusters, only negative for disliked 6075 const showPositive = (cluster.type === 'liked'); 6076 6077 // Update instruction text based on cluster type 6078 const instructionDiv = document.getElementById('tag-instruction-text'); 6079 if (showPositive) { 6080 const textFr = 'Sélectionnez tous les attributs applicables parmi la liste de <strong style="color: #34a853;">critères positifs</strong> suivants:'; 6081 const textEn = 'Select all applicable attributes from the following list of <strong style="color: #34a853;">positive criteria</strong>:'; 6082 instructionDiv.innerHTML = `<span>${currentLang === 'fr' ? textFr : textEn}</span>`; 6083 } else { 6084 const textFr = 'Sélectionnez tous les attributs applicables parmi la liste de <strong style="color: #ea4335;">critères négatifs</strong> suivants:'; 6085 const textEn = 'Select all applicable attributes from the following list of <strong style="color: #ea4335;">negative criteria</strong>:'; 6086 instructionDiv.innerHTML = `<span>${currentLang === 'fr' ? textFr : textEn}</span>`; 6087 } 6088 6089 // Use the correct language version 6090 const tagOptions = currentLang === 'fr' ? TAG_OPTIONS_FR : TAG_OPTIONS; 6091 6092 Object.entries(tagOptions).forEach(([category, options], index) => { 6093 const categoryDiv = document.createElement('div'); 6094 const isOpen = (savedData.openCategory === category); 6095 categoryDiv.className = isOpen ? 'tag-category' : 'tag-category collapsed'; 6096 categoryDiv.dataset.category = category; 6097 6098 let html = ` 6099 <div class="tag-category-header"> 6100 <div class="tag-category-title"> 6101 <span>${category}</span> 6102 </div> 6103 <span class="tag-category-arrow">â¼</span> 6104 </div> 6105 <div class="tag-options-group"> 6106 `; 6107 6108 // Show only relevant options based on cluster type 6109 const relevantOptions = showPositive ? options.positive : options.negative; 6110 const optionClass = showPositive ? 'positive' : 'negative'; 6111 6112 relevantOptions.forEach(option => { 6113 const isSelected = savedData.tags.includes(option); 6114 6115 // Check if this is a seasonal tag and extract season 6116 let displayText = option; 6117 let seasonBadge = ''; 6118 const seasonMatch = option.match(/^(WINTER|SPRING|SUMMER|FALL|HIVER|PRINTEMPS|ÃTÃ|AUTOMNE)\s+(.+)$/i); 6119 6120 if (seasonMatch) { 6121 const season = seasonMatch[1].toUpperCase(); 6122 displayText = seasonMatch[2]; 6123 6124 // Map season to badge class and reorder: Spring, Summer, Fall, Winter 6125 const seasonMap = { 6126 'SPRING': { class: 'spring', text: 'SPRING' }, 6127 'PRINTEMPS': { class: 'spring', text: 'PRINTEMPS' }, 6128 'SUMMER': { class: 'summer', text: 'SUMMER' }, 6129 'ÃTÃ': { class: 'summer', text: 'ÃTÃ' }, 6130 'FALL': { class: 'fall', text: 'FALL' }, 6131 'AUTOMNE': { class: 'fall', text: 'AUTOMNE' }, 6132 'WINTER': { class: 'winter', text: 'WINTER' }, 6133 'HIVER': { class: 'winter', text: 'HIVER' } 6134 }; 6135 6136 const seasonInfo = seasonMap[season]; 6137 if (seasonInfo) { 6138 seasonBadge = `<span class="season-badge ${seasonInfo.class}">${seasonInfo.text}</span>`;
6139 } 6140 } 6141 6142 html += ` 6143 <div class="tag-option-item ${optionClass} ${isSelected ? 'selected' : ''}" data-tag="${option}"> 6144 <input type="checkbox" class="tag-option-checkbox" ${isSelected ? 'checked' : ''} /> 6145 <span class="tag-option-label">${seasonBadge}${displayText}</span> 6146 </div> 6147 `; 6148 }); 6149 6150 html += '</div>'; 6151 categoryDiv.innerHTML = html; 6152 container.appendChild(categoryDiv); 6153 6154 // Add click handler for header to toggle 6155 const header = categoryDiv.querySelector('.tag-category-header'); 6156 header.addEventListener('click', () => { 6157 // Close all other categories 6158 container.querySelectorAll('.tag-category').forEach(cat => { 6159 if (cat !== categoryDiv) { 6160 cat.classList.add('collapsed'); 6161 } 6162 }); 6163 6164 // Toggle this one 6165 const wasCollapsed = categoryDiv.classList.contains('collapsed'); 6166 categoryDiv.classList.toggle('collapsed'); 6167 6168 // Save open category 6169 if (!categoryDiv.classList.contains('collapsed')) { 6170 savedData.openCategory = category; 6171 clusterLabels[cluster.id] = savedData; 6172 } 6173 }); 6174 }); 6175 6176 // Auto-save on change 6177 container.querySelectorAll('.tag-option-item').forEach(item => { 6178 item.addEventListener('click', (e) => { 6179 const checkbox = item.querySelector('input[type="checkbox"]'); 6180 if (e.target !== checkbox) { 6181 checkbox.checked = !checkbox.checked; 6182 } 6183 item.classList.toggle('selected', checkbox.checked); 6184 autoSaveCurrentCluster(); 6185 }); 6186 }); 6187 6188 document.getElementById('current-cluster-comment').value = savedData.comment || ''; 6189 6190 // Auto-save comment (remove old listener first to avoid duplicates) 6191 const commentBox = document.getElementById('current-cluster-comment'); 6192 const commentHandler = () => autoSaveCurrentCluster(); 6193 commentBox.removeEventListener('input', commentHandler); 6194 commentBox.addEventListener('input', commentHandler); 6195} 6196 6197function autoSaveCurrentCluster() { 6198 const cluster = allClusters[currentClusterIndex]; 6199 6200 const selectedTags = []; 6201 document.querySelectorAll('.tag-option-item input:checked').forEach(checkbox => { 6202 const item = checkbox.closest('.tag-option-item'); 6203 selectedTags.push(item.dataset.tag); 6204 }); 6205 6206 const comment = document.getElementById('current-cluster-comment').value.trim(); 6207 6208 // Find which category is open 6209 let openCategory = null; 6210 document.querySelectorAll('.tag-category').forEach(cat => { 6211 if (!cat.classList.contains('collapsed')) { 6212 openCategory = cat.dataset.category; 6213 } 6214 }); 6215 6216 clusterLabels[cluster.id] = { 6217 tags: selectedTags, 6218 comment: comment, 6219 openCategory: openCategory 6220 }; 6221} 6222 6223// Initialize Step 3 navigation (call once when Step 3 is first shown) 6224let step3NavigationInitialized = false; 6225 6226function initStep3Navigation() { 6227 if (step3NavigationInitialized) return; 6228 6229 document.getElementById('prev-cluster-btn').addEventListener('click', () => { 6230 currentClusterIndex--; 6231 showCluster(currentClusterIndex); 6232 }); 6233 6234 document.getElementById('next-cluster-btn').addEventListener('click', () => { 6235 if (currentClusterIndex < allClusters.length - 1) { 6236 currentClusterIndex++; 6237 showCluster(currentClusterIndex); 6238 } else { 6239 // Show custom confirmation before submitting 6240 const confirmTitle = currentLang === 'fr' 6241 ? 'Ãtes-vous prêt(e) à soumettre votre évaluation ?' 6242 : 'Are you ready to submit your assessment?'; 6243 6244 const confirmMessage = currentLang === 'fr' 6245 ? 'Vous avez étiqueté tous les clusters. Une fois soumis, vous ne pourrez pas modifier vos réponses.' 6246 : 'You have labeled all clusters. Once submitted, you cannot modify your responses.'; 6247 6248 showCustomConfirm(confirmTitle, confirmMessage, (confirmed) => { 6249 if (confirmed) { 6250 // Submit after confirmation 6251 updateProgress(4); 6252 6253 // Hide main panel 6254 const mainPanel = document.querySelector('.main-panel'); 6255 if (mainPanel) mainPanel.style.display = 'none'; 6256 6257 // Trigger submission directly 6258 document.getElementById('submit-btn').click(); 6259 } 6260 }); 6261 } 6262 }); 6263
6264 // Skip button removed - users must label all clusters 6265 // document.getElementById('skip-cluster-btn').addEventListener('click', () => { 6266 // const cluster = allClusters[currentClusterIndex]; 6267 // clusterLabels[cluster.id] = { tags: [], comment: '' }; 6268 // 6269 // if (currentClusterIndex < allClusters.length - 1) { 6270 // currentClusterIndex++; 6271 // showCluster(currentClusterIndex); 6272 // } else { 6273 // clusterMarkers.forEach(marker => marker.remove()); 6274 // clusterMarkers = []; 6275 // 6276 // // Remove split divider lines 6277 // splitDividerLines.forEach(lineId => { 6278 // try { 6279 // if (map.getLayer(lineId)) map.removeLayer(lineId); 6280 // if (map.getSource(lineId)) map.removeSource(lineId); 6281 // } catch(e) {} 6282 // }); 6283 // splitDividerLines = []; 6284 // 6285 // document.getElementById('step3-tag-clusters').classList.add('hidden'); 6286 // document.getElementById('step4-submit').classList.remove('hidden'); 6287 // 6288 // // Update progress and show tutorial 6289 // updateProgress(4); 6290 // showTutorial(3); 6291 // } 6292 // }); 6293 6294 step3NavigationInitialized = true; 6295 6296 // Split cluster functionality - direct split into 2 groups 6297 6298 // K-means clustering for exactly 2 clusters 6299 function kMeansSplit(points) { 6300 if (points.length < 2) return [points]; 6301 6302 // Find the two furthest points as initial centroids (poles) 6303 let maxDist = 0; 6304 let pole1Idx = 0; 6305 let pole2Idx = 1; 6306 6307 for (let i = 0; i < points.length; i++) { 6308 for (let j = i + 1; j < points.length; j++) { 6309 const dist = turf.distance(points[i].coords, points[j].coords); 6310 if (dist > maxDist) { 6311 maxDist = dist; 6312 pole1Idx = i; 6313 pole2Idx = j; 6314 } 6315 } 6316 } 6317 6318 let centroid1 = [...points[pole1Idx].coords]; 6319 let centroid2 = [...points[pole2Idx].coords]; 6320 6321 // Run k-means with fixed iterations 6322 const maxIterations = 10; 6323 6324 for (let iter = 0; iter < maxIterations; iter++) { 6325 // Assign points to nearest centroid 6326 const cluster1 = []; 6327 const cluster2 = []; 6328 6329 points.forEach(point => { 6330 const dist1 = turf.distance(point.coords, centroid1); 6331 const dist2 = turf.distance(point.coords, centroid2); 6332 6333 if (dist1 < dist2) { 6334 cluster1.push(point); 6335 } else { 6336 cluster2.push(point); 6337 } 6338 }); 6339 6340 // If one cluster is empty, force a split 6341 if (cluster1.length === 0 || cluster2.length === 0) { 6342 const mid = Math.floor(points.length / 2); 6343 return [points.slice(0, mid), points.slice(mid)]; 6344 } 6345 6346 // Recalculate centroids 6347 const newCentroid1 = [ 6348 cluster1.reduce((sum, p) => sum + p.coords[0], 0) / cluster1.length, 6349 cluster1.reduce((sum, p) => sum + p.coords[1], 0) / cluster1.length 6350 ]; 6351 const newCentroid2 = [ 6352 cluster2.reduce((sum, p) => sum + p.coords[0], 0) / cluster2.length, 6353 cluster2.reduce((sum, p) => sum + p.coords[1], 0) / cluster2.length 6354 ]; 6355 6356 // Check convergence 6357 const shift1 = turf.distance(centroid1, newCentroid1); 6358 const shift2 = turf.distance(centroid2, newCentroid2); 6359 6360 centroid1 = newCentroid1; 6361 centroid2 = newCentroid2; 6362 6363 // Early stop if converged (centroids barely moved) 6364 if (shift1 < 0.001 && shift2 < 0.001) { 6365 break; 6366 } 6367 } 6368 6369 // Final assignment 6370 const cluster1 = []; 6371 const cluster2 = []; 6372 6373 points.forEach(point => { 6374 const dist1 = turf.distance(point.coords, centroid1); 6375 const dist2 = turf.distance(point.coords, centroid2); 6376 6377 if (dist1 < dist2) { 6378 cluster1.push(point); 6379 } else { 6380 cluster2.push(point); 6381 } 6382 }); 6383 6384 return [cluster1, cluster2].filter(c => c.length > 0); 6385 } 6386 6387 // Split section toggle 6388 document.getElementById('split-section-toggle').addEventListener('click', () => { 6389 const header = document.getElementById('split-section-toggle'); 6390 const content = document.getElementById('split-section-content'); 6391 6392 header.classList.toggle('open'); 6393 content.classList.toggle('open'); 6394 }); 6395 6396 // Info icon for split section 6397 document.getElementById('split-info-icon').addEventListener('click', (e) => { 6398 e.stopPropagation(); // Prevent toggling the accordion 6399 6400 const lang = currentLang || 'fr'; 6401 const title = lang === 'fr' ? 'Division automatique' : 'Automatic Division'; 6402 const message = lang === 'fr' 6403 ? '<p><strong>Division automatique :</strong> Les zones sont automatiquement divisées en détectant les concentrations de marqueurs.</p><p><strong>Pour modifier la division :</strong> Retournez à l\'écran précédent et effacez les marqueurs entre les éléments que vous souhaitez séparer, ou ajoutez des marqueurs pour fusionner deux groupes en un seul.</p>' 6404 : '<p><strong>Automatic division:</strong>
6404 Areas are automatically divided by detecting marker concentrations.</p><p><strong>To modify the division:</strong> Go back to the previous screen and erase markers between items you want to separate, or add markers to merge two groups into one.</p>'; 6405 6406 showCustomAlert(title, message); 6407 }); 6408 6409 document.getElementById('split-cluster-btn').addEventListener('click', () => { 6410 const cluster = allClusters[currentClusterIndex]; 6411 if (!cluster || !cluster.points) return; 6412 6413 const btn = document.getElementById('split-cluster-btn'); 6414 const action = btn.dataset.action; 6415 6416 // Add pulse animation to button 6417 btn.style.animation = 'pulse 0.3s'; 6418 setTimeout(() => btn.style.animation = '', 300); 6419 6420 // If action is cancel, revert the split 6421 if (action === 'cancel') { 6422 console.log('=== CANCEL SPLIT ==='); 6423 console.log('Current cluster ID:', cluster.id); 6424 console.log('All clusters before:', allClusters.map(c => c.id)); 6425 6426 // Extract parent ID from current cluster 6427 const parentId = cluster.id.split('-split-')[0]; 6428 console.log('Parent ID:', parentId); 6429 6430 // Find ALL split siblings (including current cluster) 6431 const splitSiblings = allClusters.filter(c => c.id.startsWith(parentId + '-split-')); 6432 console.log('Split siblings found:', splitSiblings.map(s => s.id)); 6433 6434 if (splitSiblings.length > 0) { 6435 // Find the position of the first split sibling 6436 const firstSplitIndex = allClusters.findIndex(c => c.id.startsWith(parentId + '-split-')); 6437 console.log('First split index:', firstSplitIndex); 6438 6439 // Merge all split clusters back into original 6440 const allPoints = []; 6441 splitSiblings.forEach(sibling => { 6442 allPoints.push(...sibling.points); 6443 // Remove cluster labels 6444 if (clusterLabels[sibling.id]) { 6445 delete clusterLabels[sibling.id]; 6446 } 6447 }); 6448 6449 // Remove ALL split siblings from allClusters 6450 allClusters = allClusters.filter(c => !c.id.startsWith(parentId + '-split-')); 6451 console.log('Clusters after removal:', allClusters.map(c => c.id)); 6452 6453 // Remove split divider lines 6454 splitDividerLines.forEach(lineId => { 6455 if (map.getLayer(lineId)) map.removeLayer(lineId); 6456 if (map.getSource(lineId)) map.removeSource(lineId); 6457 }); 6458 splitDividerLines = []; 6459 6460 // Recreate original merged cluster 6461 const coords = allPoints.map(p => p.coords); 6462 const avgLon = coords.reduce((sum, c) => sum + c[0], 0) / coords.length; 6463 const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length; 6464 6465 const mergedCluster = { 6466 id: parentId, 6467 type: cluster.type, 6468 label: cluster.type === 'liked' ? 'Liked Area' : 'Disliked Area', 6469 size: allPoints.length, 6470 centroid: [avgLon, avgLat], 6471 points: allPoints, 6472 splitCount: 0 6473 }; 6474 6475 // Insert merged cluster at the position where splits were 6476 const insertIndex = Math.min(firstSplitIndex, allClusters.length); 6477 allClusters.splice(insertIndex, 0, mergedCluster); 6478 6479 // Update current index 6480 currentClusterIndex = insertIndex; 6481 6482 console.log('Merged cluster created:', mergedCluster.id); 6483 console.log('All clusters after merge:', allClusters.map(c => c.id)); 6484 console.log('Total clusters:', allClusters.length); 6485 console.log('Current index:', currentClusterIndex); 6486 6487 // Update display 6488 showCluster(currentClusterIndex); 6489 } 6490 return; 6491 } 6492 6493 // Split using k-means into 2 clusters 6494 const newClusters = kMeansSplit(cluster.points); 6495 6496 console.log(`Splitting cluster ${cluster.id} into ${newClusters.length} new clusters`); 6497 6498 // Draw dividing line between the two split clusters 6499 if (newClusters.length === 2) { 6500 const cluster1Points = newClusters[0]; 6501 const cluster2Points = newClusters[1]; 6502 6503 // Calculate centroids of each cluster 6504 const centroid1 = [ 6505 cluster1Points.reduce((sum, p) => sum + p.coords[0], 0) / cluster1Points.length, 6506 cluster1Points.reduce((sum, p) => sum + p.coords[1], 0) / cluster1Points.length 6507 ]; 6508 const centroid2 = [ 6509 cluster2Points.reduce((sum, p) => sum + p.coords[0], 0) / cluster2Points.length, 6510 cluster2Points.reduce((sum, p) => sum + p.coords[1], 0) / cluster2Points.length 6511 ]; 6512 6513 // Get all points from original cluster to find bounds 6514 const allPoints = [...cluster1Points, ...cluster2Points]; 6515 const allCoords = allPoints.map(p => p.coords); 6516 6517 // Find min/max to determine cluster bounds 6518 const lons = allCoords.map(c => c[0]); 6519 const lats = allCoords.map(c => c[1]); 6520 const minLon = Math.min(...lons); 6521 const maxLon = Math.max(...lons); 6522 const minLat = Math.min(...lats); 6523 const maxLat = Math.max(...lats); 6524 6525 // Calculate midpoint between centroids 6526 const midpoint = [ 6527 (centroid1[0] + centroid2[0]) / 2, 6528 (centroid1[1] + centroid2[1]) / 2 6529 ]; 6530 6531 // Calculate perpendicular line to the line connecting centroids 6532 const dx = centroid2[0] - centroid1[0]; 6533 const dy = centroid2[1] - centroid1[1]; 6534 6535 // Perpendicular vector (rotate 90 degrees) 6536 const perpDx = -dy; 6537 const perpDy = dx; 6538 6539 // Normalize 6540 const length = Math.sqrt(perpDx * perpDx + perpDy * perpDy);
6541 const normPerpDx = perpDx / length; 6542 const normPerpDy = perpDy / length; 6543 6544 // Calculate line length to span the cluster bounds 6545 const clusterWidth = Math.max(maxLon - minLon, maxLat - minLat); 6546 const lineExtent = clusterWidth * 0.6; // 60% of cluster width 6547 6548 // Create line endpoints that stay within cluster area 6549 const lineStart = [ 6550 midpoint[0] - normPerpDx * lineExtent, 6551 midpoint[1] - normPerpDy * lineExtent 6552 ]; 6553 const lineEnd = [ 6554 midpoint[0] + normPerpDx * lineExtent, 6555 midpoint[1] + normPerpDy * lineExtent 6556 ]; 6557 6558 // Use orange color matching the split button 6559 const lineColor = '#ff9800'; 6560 6561 // Create unique layer ID 6562 const lineLayerId = `split-divider-${Date.now()}`; 6563 splitDividerLines.push(lineLayerId); 6564 6565 // Add line to map 6566 map.addSource(lineLayerId, { 6567 type: 'geojson', 6568 data: { 6569 type: 'Feature', 6570 geometry: { 6571 type: 'LineString', 6572 coordinates: [lineStart, lineEnd] 6573 } 6574 } 6575 }); 6576 6577 map.addLayer({ 6578 id: lineLayerId, 6579 type: 'line', 6580 source: lineLayerId, 6581 paint: { 6582 'line-color': lineColor, 6583 'line-width': 8, 6584 'line-opacity': 1.0, 6585 'line-dasharray': [2, 2] // Dashed pattern 6586 } 6587 }); 6588 6589 console.log(`Drew dashed split divider line between clusters`); 6590 } 6591 6592 // Create new cluster objects 6593 const newClusterObjects = []; 6594 newClusters.forEach((clusterPoints, idx) => { 6595 const coords = clusterPoints.map(p => p.coords); 6596 const avgLon = coords.reduce((sum, c) => sum + c[0], 0) / coords.length; 6597 const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length; 6598 6599 newClusterObjects.push({ 6600 id: `${cluster.id}-split-${idx}`, 6601 type: cluster.type, 6602 label: `${cluster.label} (Split ${idx + 1})`, 6603 size: clusterPoints.length, 6604 centroid: [avgLon, avgLat], 6605 points: clusterPoints, 6606 splitCount: (cluster.splitCount || 0) + 1 // Inherit and increment split count 6607 }); 6608 }); 6609 6610 // Remove original cluster from allClusters 6611 allClusters.splice(currentClusterIndex, 1); 6612 6613 // Insert new clusters at the same position 6614 allClusters.splice(currentClusterIndex, 0, ...newClusterObjects); 6615 6616 console.log(`New total clusters: ${allClusters.length}`); 6617 6618 // Show success message 6619 const successMsg = document.getElementById('split-success-msg'); 6620 successMsg.classList.remove('hidden'); 6621 setTimeout(() => { 6622 successMsg.classList.add('hidden'); 6623 }, 2000); 6624 6625 // Update cluster counter in header 6626 document.getElementById('cluster-total').textContent = allClusters.length; 6627 6628 // Show the first of the new split clusters 6629 showCluster(currentClusterIndex); 6630 }); 6631} 6632 6633function showClusterMarker(coords, type) { 6634 // Remove existing cluster markers 6635 clusterMarkers.forEach(marker => marker.remove()); 6636 clusterMarkers = []; 6637 6638 // Create teardrop marker - MUCH BIGGER and positioned ABOVE cluster 6639 const el = document.createElement('div'); 6640 el.style.width = '90px'; 6641 el.style.height = '120px'; 6642 el.style.position = 'relative'; 6643 el.innerHTML = ` 6644 <svg width="90" height="120" viewBox="0 0 30 40" style="filter: drop-shadow(0 4px 8px rgba(0,0,0,0.4));"> 6645 <path d="M15,5 C10,5 5,10 5,15 C5,20 15,35 15,35 C15,35 25,20 25,15 C25,10 20,5 15,5 Z" 6646 fill="${type === 'disliked' ? '#ea4335' : '#34a853'}" 6647 stroke="white" 6648 stroke-width="2"/> 6649 <circle cx="15" cy="15" r="5" fill="white"/> 6650 </svg> 6651 `; 6652 6653 const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom', offset: [0, -150] }) 6654 .setLngLat(coords) 6655 .addTo(map); 6656 6657 clusterMarkers.push(marker); 6658 6659 // Fly to cluster with closer zoom 6660 // On mobile, zoom even closer to the cluster and account for panel at bottom 6661 const isMobile = window.innerWidth <= 768; 6662 const flyToOptions = { 6663 center: coords, 6664 zoom: isMobile ? 19.2 : 18.5, // Closer zoom on mobile 6665 pitch: 60, // Good 3D view 6666 duration: 1000 6667 }; 6668 6669 // On mobile, add padding to shift center upward (panel is 60vh at bottom) 6670 if (isMobile) { 6671 flyToOptions.padding = { top: 0, bottom: window.innerHeight * 0.35, left: 0, right: 0 }; 6672 } 6673 6674 map.flyTo(flyToOptions); 6675} 6676 6677// Next to submit (this button doesn't exist in the HTML, so commenting out) 6678// document.getElementById('next-to-submit-btn').addEventListener('click', () => {
6679// clusterMarkers.forEach(marker => marker.remove()); 6680// clusterMarkers = []; 6681// document.getElementById('step3-tag-clusters').classList.add('hidden'); 6682// document.getElementById('step4-submit').classList.remove('hidden'); 6683// }); 6684 6685// Submit to Backend 6686 6687// Step 4 back button 6688const step4BackBtn = document.getElementById('step4-back-btn'); 6689if (step4BackBtn) { 6690 step4BackBtn.addEventListener('click', () => { 6691 // Go back to Step 3 6692 document.getElementById('step-4').classList.remove('active'); 6693 document.getElementById('step-3').classList.add('active'); 6694 currentStep = 3; 6695 if (typeof updateInfoTooltip === 'function') updateInfoTooltip(); 6696 }); 6697} 6698 6699document.getElementById('submit-btn').addEventListener('click', async () => { 6700 const submitBtn = document.getElementById('submit-btn'); 6701 submitBtn.disabled = true; 6702 const submittingText = currentLang === 'fr' ? 'Envoi en cours...' : 'Submitting...'; 6703 submitBtn.innerHTML = `<span>${submittingText}</span>`; 6704 6705 // Show loading overlay 6706 const loadingOverlay = document.getElementById('loading-overlay'); 6707 const loadingText = loadingOverlay.querySelector('.loading-text span'); 6708 loadingText.textContent = currentLang === 'fr' ? 'Envoi en cours...' : 'Submitting...'; 6709 loadingOverlay.classList.remove('hidden'); 6710 6711 try { 6712 // Collect ground polygons with calculated areas 6713 const groundPolygons = groundPaintMarkers.map(paintId => { 6714 const source = map.getSource(paintId); 6715 if (!source) return null; 6716 6717 const data = source._data; 6718 6719 // Calculate area in square meters using Turf 6720 let area = 0; 6721 try { 6722 area = turf.area(data.geometry); 6723 } catch (e) { 6724 console.error('Error calculating ground polygon area:', e); 6725 } 6726 6727 return { 6728 type: data.properties.type, 6729 geometry: data.geometry, // GeoJSON polygon geometry 6730 center: data.geometry.coordinates[0][0], // First coordinate as approximate center 6731 color: data.properties.color, 6732 area: Math.round(area) // Area in square meters 6733 }; 6734 }).filter(Boolean); 6735 6736 // Prepare submission data in GeoJSON-compatible format 6737 console.log('ð DEBUG: selectedPoint at submission time:', selectedPoint); 6738 6739 const submissionData = { 6740 sessionTimestamp: new Date().toISOString(), 6741 userLocation: selectedPoint ? { 6742 lng: selectedPoint.lng || selectedPoint[0], 6743 lat: selectedPoint.lat || selectedPoint[1] 6744 } : null, 6745 radiusMeters: 1000, 6746 dislikedVoxels: Array.from(dislikedVoxels), 6747 likedVoxels: Array.from(likedVoxels), 6748 groundPolygons: groundPolygons, // NEW: Include ground-painted areas 6749 clusters: allClusters.map(cluster => { 6750 const labels = clusterLabels[cluster.id] || { tags: [], comment: '' }; 6751 6752 // Calculate centroid from cluster points 6753 // Each point in cluster.points is an object: { coords: [lng, lat, height], type, idx } 6754 let avgLng = 0, avgLat = 0, avgHeight = 0; 6755 cluster.points.forEach(pt => { 6756 avgLng += pt.coords[0]; 6757 avgLat += pt.coords[1]; 6758 avgHeight += pt.coords[2] || 0; 6759 }); 6760 const count = cluster.points.length; 6761 6762 const centroid = { 6763 lng: avgLng / count, 6764 lat: avgLat / count, 6765 height: avgHeight / count 6766 }; 6767 6768 // Calculate total ground area for this cluster by finding overlapping ground polygons 6769 let totalGroundArea = 0; 6770 const centroidPoint = turf.point([centroid.lng, centroid.lat]); 6771 6772 groundPolygons.forEach(polygon => { 6773 if (polygon && polygon.type === cluster.type) { 6774 try { 6775 // Check if the cluster centroid is within this ground polygon 6776 const isInside = turf.booleanPointInPolygon(centroidPoint, polygon.geometry); 6777 6778 if (isInside) { 6779 // Calculate area of this polygon in square meters 6780 const area = turf.area(polygon.geometry); 6781 totalGroundArea += area; 6782 } 6783 } catch (e) { 6784 console.error('Error calculating ground area:', e); 6785 } 6786 } 6787 }); 6788 6789 return { 6790 id: cluster.id, 6791 type: cluster.type, 6792 voxels: cluster.points.map(pt => ({ 6793 key: `${pt.coords[0].toFixed(6)}_${pt.coords[1].toFixed(6)}_${(pt.coords[2] || 0).toFixed(1)}`, 6794 lng: pt.coords[0], 6795 lat: pt.coords[1], 6796 height: pt.coords[2] || 0 6797 })), 6798 centroid: centroid, 6799 groundAreaM2: Math.round(totalGroundArea), 6800 tags: labels.tags, 6801 comment: labels.comment 6802 }; 6803 }) 6804 }; 6805 6806 // Send to backend with 30 second timeout 6807 const API_URL = 'https://tag-ta-ville-production.up.railway.app/
6807api/submissions'; 6808 6809 const controller = new AbortController(); 6810 const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout 6811 6812 const response = await fetch(API_URL, { 6813 method: 'POST', 6814 headers: { 6815 'Content-Type': 'application/json', 6816 }, 6817 body: JSON.stringify(submissionData), 6818 signal: controller.signal 6819 }); 6820 6821 clearTimeout(timeoutId); 6822 6823 const result = await response.json(); 6824 6825 if (result.success) { 6826 // Calculate ground area in m² for liked and disliked areas 6827 // Union overlapping polygons first to avoid double-counting 6828 let dislikedAreaM2 = 0; 6829 let likedAreaM2 = 0; 6830 6831 // Separate polygons by type 6832 const dislikedPolygons = groundPolygons.filter(p => p && p.type === 'disliked' && p.geometry); 6833 const likedPolygons = groundPolygons.filter(p => p && p.type === 'liked' && p.geometry); 6834 6835 // Union disliked polygons 6836 if (dislikedPolygons.length > 0) { 6837 try { 6838 let unionedDisliked = dislikedPolygons[0].geometry; 6839 for (let i = 1; i < dislikedPolygons.length; i++) { 6840 try { 6841 unionedDisliked = turf.union(turf.featureCollection([ 6842 turf.feature(unionedDisliked), 6843 turf.feature(dislikedPolygons[i].geometry) 6844 ])).geometry; 6845 } catch (e) { 6846 console.warn('Union failed, using individual area'); 6847 } 6848 } 6849 dislikedAreaM2 = turf.area(unionedDisliked); 6850 } catch (e) { 6851 // Fallback to sum if union fails 6852 dislikedPolygons.forEach(p => dislikedAreaM2 += turf.area(p.geometry)); 6853 } 6854 } 6855 6856 // Union liked polygons 6857 if (likedPolygons.length > 0) { 6858 try { 6859 let unionedLiked = likedPolygons[0].geometry; 6860 for (let i = 1; i < likedPolygons.length; i++) { 6861 try { 6862 unionedLiked = turf.union(turf.featureCollection([ 6863 turf.feature(unionedLiked), 6864 turf.feature(likedPolygons[i].geometry) 6865 ])).geometry; 6866 } catch (e) { 6867 console.warn('Union failed, using individual area'); 6868 } 6869 } 6870 likedAreaM2 = turf.area(unionedLiked); 6871 } catch (e) { 6872 // Fallback to sum if union fails 6873 likedPolygons.forEach(p => likedAreaM2 += turf.area(p.geometry)); 6874 } 6875 } 6876 6877 // Round to whole numbers 6878 dislikedAreaM2 = Math.round(dislikedAreaM2); 6879 likedAreaM2 = Math.round(likedAreaM2); 6880 6881 // Translations for success message 6882 const successTitle = currentLang === 'fr' ? 'Soumission réussie' : 'Submission Successful'; 6883 const submissionIdLabel = currentLang === 'fr' ? 'ID de soumission :' : 'Submission ID:'; 6884 const dislikedLabel = currentLang === 'fr' ? 'Zones non appréciées :' : 'Disliked areas:'; 6885 const likedLabel = currentLang === 'fr' ? 'Zones appréciées :' : 'Liked areas:'; 6886 const thankYouMsg = currentLang === 'fr' ? 'Merci pour votre contribution à ce projet de recherche!' : 'Thank you for your contribution to this research project!'; 6887 const commentsLabel = currentLang === 'fr' ? 'Commentaires ou suggestions?' : 'Comments or suggestions?'; 6888 const commentsPlaceholder = currentLang === 'fr' ? 'Ãcrivez ici...' : 'Write here...'; 6889 const okText = currentLang === 'fr' ? 'Terminer' : 'Finish'; 6890 const visualizationTitle = currentLang === 'fr' ? 'Votre évaluation' : 'Your Evaluation'; 6891 const downloadBtnText = currentLang === 'fr' ? 'Télécharger mon évaluation' : 'Download My Evaluation'; 6892 const shareBtnText = currentLang === 'fr' ? 'Partager avec des amis' : 'Share with Friends'; 6893 const submitCommentText = currentLang === 'fr' ? 'Envoyer le commentaire' : 'Submit Comment'; 6894 6895 // Hide loading overlay 6896 document.getElementById('loading-overlay').classList.add('hidden'); 6897 6898 const overlay = document.createElement('div'); 6899 overlay.className = 'custom-alert-overlay'; 6900 overlay.innerHTML = ` 6901 <div class="custom-alert" style="max-width: 900px;"> 6902 <h3>${successTitle}</h3> 6903 <div class="custom-alert-content"> 6904 <div style="margin-bottom: 15px; padding: 10px; background: #f0f7ff; border-radius: 6px;"> 6905 <strong>${submissionIdLabel}</strong><br>
6906 <span style="font-size: 11px; color: #666; word-break: break-all;">${result.submissionId}</span> 6907 </div> 6908 6909 <p style="margin-top: 10px; margin-bottom: 15px; color: #333; font-size: 15px;">${thankYouMsg}</p> 6910 6911 <div class="visualization-section"> 6912 <h4>${visualizationTitle}</h4> 6913 <canvas id="mosaic-canvas" width="1600" height="1600" style="max-width: 500px; width: 100%;"></canvas> 6914 <div class="buttons-row"> 6915 <button class="download-viz-btn" onclick="downloadCanvas()"> 6916 ${downloadBtnText} 6917 </button> 6918 <button class="download-viz-btn pulsate-btn" onclick="shareWithFriends()"> 6919 ${shareBtnText} 6920 </button> 6921 </div> 6922 </div> 6923 6924 <div class="comment-section"> 6925 <label>${commentsLabel}</label> 6926 <textarea 6927 id="submission-comments" 6928 placeholder="${commentsPlaceholder}" 6929 style="width: 100%; min-height: 100px; padding: 14px; border: 2px solid #e0e0e0; border-radius: 8px; font-family: inherit; font-size: 14px; resize: vertical; box-sizing: border-box; background: white; transition: border-color 0.2s;" 6930 ></textarea> 6931 <button id="submit-comment-btn" class="submit-comment-btn"> 6932 ${submitCommentText} 6933 </button> 6934 </div> 6935 </div> 6936 <button class="custom-alert-button" id="submission-ok-btn" style="margin-top: 30px;"> 6937 ${okText} 6938 </button> 6939 </div> 6940 `; 6941 document.body.appendChild(overlay); 6942 6943 // Draw mosaic visualization 6944 setTimeout(() => { 6945 const canvas = document.getElementById('mosaic-canvas'); 6946 if (!canvas) return; 6947 6948 const ctx = canvas.getContext('2d'); 6949 const size = 1600; 6950 const center = 800; 6951 const radius = 720; 6952 6953 function seededRandom(seed) { 6954 const x = Math.sin(seed++) * 10000; 6955 return x - Math.floor(x); 6956 } 6957 6958 // Helper to convert lng/lat to canvas pixels 6959 function lngLatToPixels(lng, lat) { 6960 // At Montreal (~45.5°N): 6961 // 1° latitude = 111.32km 6962 // 1° longitude = 111.32km * cos(45.5°) â 78.0km 6963 // For 750m to fill 720px radius: 6964 const latMetersPerDegree = 111320; 6965 const lngMetersPerDegree = 111320 * Math.cos(selectedPoint.lat * Math.PI / 180); 6966 const radiusMeters = 750; 6967 const radiusPixels = 720; 6968 6969 const scaleX = radiusPixels / (radiusMeters / lngMetersPerDegree); 6970 const scaleY = radiusPixels / (radiusMeters / latMetersPerDegree); 6971 6972 const x = center + (lng - selectedPoint.lng) * scaleX; 6973 const y = center - (lat - selectedPoint.lat) * scaleY; 6974 return { x, y }; 6975 } 6976 6977 // Deduplicate positions that are too close together 6978 function deduplicatePositions(positions, minDistance = 40) { 6979 const unique = []; 6980 positions.forEach(p => { 6981 const tooClose = unique.some(u => { 6982 const dist = Math.sqrt((p.x - u.x) ** 2 + (p.y - u.y) ** 2); 6983 return dist < minDistance; 6984 }); 6985 if (!tooClose) { 6986 unique.push(p); 6987 } 6988 }); 6989 return unique; 6990 } 6991 6992 // Convert voxel indices to coordinate arrays 6993 const canvasCenter = center; 6994 const redVoxelsRaw = Array.from(dislikedVoxels) 6995 .map(index => { 6996 const voxel = allVoxelFeatures[index]; 6997 if (!voxel) return null; 6998 const voxelCenter = turf.center(voxel); 6999 return lngLatToPixels(voxelCenter.geometry.coordinates[0], voxelCenter.geometry.coordinates[1]); 7000 }) 7001 .filter(p => { 7002 if (!p) return false; 7003 const dist = Math.sqrt((p.x - canvasCenter) ** 2 + (p.y - canvasCenter) ** 2); 7004 return dist < radius - 10; 7005 }); 7006 7007 const greenVoxelsRaw = Array.from(likedVoxels) 7008 .map(index => { 7009 const voxel = allVoxelFeatures[index]; 7010 if (!voxel) return null; 7011 const voxelCenter = turf.center(voxel); 7012 return lngLatToPixels(voxelCenter.geometry.coordinates[0], voxelCenter.geometry.coordinates[1]); 7013 }) 7014 .filter(p => { 7015 if (!p) return false;
7016 const dist = Math.sqrt((p.x - canvasCenter) ** 2 + (p.y - canvasCenter) ** 2); 7017 return dist < radius - 10; 7018 }); 7019 7020 // Add ground polygons to visualization 7021 const redGroundRaw = groundPolygons 7022 .filter(p => p && p.type === 'disliked' && p.geometry) 7023 .map(polygon => { 7024 const polyCenter = turf.center(polygon.geometry); 7025 return lngLatToPixels(polyCenter.geometry.coordinates[0], polyCenter.geometry.coordinates[1]); 7026 }) 7027 .filter(p => { 7028 if (!p) return false; 7029 const dist = Math.sqrt((p.x - canvasCenter) ** 2 + (p.y - canvasCenter) ** 2); 7030 return dist < radius - 10; 7031 }); 7032 7033 const greenGroundRaw = groundPolygons 7034 .filter(p => p && p.type === 'liked' && p.geometry) 7035 .map(polygon => { 7036 const polyCenter = turf.center(polygon.geometry); 7037 return lngLatToPixels(polyCenter.geometry.coordinates[0], polyCenter.geometry.coordinates[1]); 7038 }) 7039 .filter(p => { 7040 if (!p) return false; 7041 const dist = Math.sqrt((p.x - canvasCenter) ** 2 + (p.y - canvasCenter) ** 2); 7042 return dist < radius - 10; 7043 }); 7044 7045 // Combine voxels and ground polygons, then deduplicate 7046 const redVoxels = deduplicatePositions([...redVoxelsRaw, ...redGroundRaw]); 7047 const greenVoxels = deduplicatePositions([...greenVoxelsRaw, ...greenGroundRaw]); 7048 7049 // Background - blue 7050 ctx.fillStyle = '#4285f4'; 7051 ctx.fillRect(0, 0, size, size); 7052 7053 // Draw real map background inside circle using actual voxel data 7054 ctx.save(); 7055 ctx.beginPath(); 7056 ctx.arc(center, center, radius, 0, Math.PI * 2); 7057 ctx.clip(); 7058 7059 // Map base 7060 ctx.fillStyle = '#e8e8e8'; 7061 ctx.fillRect(0, 0, size, size); 7062 7063 // Draw actual buildings from voxel data 7064 ctx.fillStyle = '#d0d0d0'; 7065 ctx.strokeStyle = '#c0c0c0'; 7066 ctx.lineWidth = 1; 7067 7068 allVoxelFeatures.forEach(voxel => { 7069 if (!voxel || !voxel.geometry || !voxel.geometry.coordinates) return; 7070 7071 try { 7072 const coords = voxel.geometry.coordinates[0]; 7073 if (!coords || coords.length === 0) return; 7074 7075 ctx.beginPath(); 7076 coords.forEach((coord, i) => { 7077 const p = lngLatToPixels(coord[0], coord[1]); 7078 if (i === 0) { 7079 ctx.moveTo(p.x, p.y); 7080 } else { 7081 ctx.lineTo(p.x, p.y); 7082 } 7083 }); 7084 ctx.closePath(); 7085 ctx.fill(); 7086 ctx.stroke(); 7087 } catch (e) {} 7088 }); 7089 7090 ctx.restore(); 7091 7092 // Draw MOSAIC tiles for red voxels 7093 redVoxels.forEach(v => { 7094 const tileSize = 32 + seededRandom(v.x * v.y) * 20; 7095 ctx.save(); 7096 ctx.translate(v.x, v.y); 7097 ctx.rotate(seededRandom(v.x + v.y) * Math.PI * 2); 7098 7099 const shades = [ 7100 'rgba(234, 67, 53, 0.5)', 7101 'rgba(211, 52, 38, 0.5)', 7102 'rgba(197, 34, 31, 0.5)', 7103 'rgba(179, 20, 18, 0.5)' 7104 ]; 7105 const shade = shades[Math.floor(seededRandom(v.x * 2) * shades.length)]; 7106 7107 ctx.fillStyle = shade; 7108 ctx.fillRect(-tileSize/2, -tileSize/2, tileSize, tileSize); 7109 7110 ctx.strokeStyle = '#ffffff'; 7111 ctx.lineWidth = 2; 7112 ctx.strokeRect(-tileSize/2, -tileSize/2, tileSize, tileSize); 7113 7114 ctx.restore(); 7115 }); 7116 7117 // Draw MOSAIC tiles for green voxels 7118 greenVoxels.forEach(v => { 7119 const tileSize = 32 + seededRandom(v.x * v.y + 100) * 20; 7120 ctx.save(); 7121 ctx.translate(v.x, v.y); 7122 ctx.rotate(seededRandom(v.x + v.y + 100) * Math.PI * 2); 7123 7124 const shades = [ 7125 'rgba(52, 168, 83, 0.5)', 7126 'rgba(45, 149, 72, 0.5)', 7127 'rgba(15, 157, 88, 0.5)', 7128 'rgba(10, 128, 67, 0.5)' 7129 ]; 7130 const shade = shades[Math.floor(seededRandom(v.y * 2) * shades.length)]; 7131 7132 ctx.fillStyle = shade; 7133 ctx.fillRect(-tileSize/2, -tileSize/2, tileSize, tileSize); 7134 7135 ctx.strokeStyle = '#ffffff'; 7136 ctx.lineWidth = 2; 7137 ctx.strokeRect(-tileSize/2, -tileSize/2, tileSize, tileSize); 7138
7139 ctx.restore(); 7140 }); 7141 7142 // Circle border - white 7143 ctx.strokeStyle = 'white'; 7144 ctx.lineWidth = 20; 7145 ctx.beginPath(); 7146 ctx.arc(center, center, radius, 0, Math.PI * 2); 7147 ctx.stroke(); 7148 7149 // Draw pin icon in center 7150 drawPinIcon(center, center, 30); 7151 7152 // Labels - white and bold 7153 ctx.fillStyle = 'white'; 7154 ctx.font = 'bold 56px Arial'; 7155 7156 // "Tag Ta Ville" in top left 7157 ctx.textAlign = 'left'; 7158 ctx.textBaseline = 'top'; 7159 ctx.fillText('Tag Ta Ville', 40, 40); 7160 7161 // City name underneath 7162 ctx.font = 'bold 36px Arial'; 7163 ctx.fillText('Montréal', 40, 104); 7164 7165 // "tagtaville.ca" in top right 7166 ctx.font = 'bold 56px Arial'; 7167 ctx.textAlign = 'right'; 7168 ctx.fillText('tagtaville.ca', size - 40, 40); 7169 7170 // Submission ID in bottom right - FULL ID 7171 ctx.font = 'bold 20px monospace'; 7172 ctx.textAlign = 'right'; 7173 ctx.textBaseline = 'bottom'; 7174 ctx.fillText('ID: ' + result.submissionId, size - 40, size - 40); 7175 7176 // Draw both logos on bottom left (white inverted, side by side) 7177 const udemLogo = new Image(); 7178 const ivadoLogo = new Image(); 7179 let logosLoaded = 0; 7180 7181 udemLogo.onload = ivadoLogo.onload = function() { 7182 logosLoaded++; 7183 if (logosLoaded === 2) { 7184 ctx.save(); 7185 ctx.filter = 'brightness(0) invert(1)'; 7186 7187 // UdeM logo on left 7188 const udemHeight = 80; 7189 const udemWidth = udemLogo.width * (udemHeight / udemLogo.height); 7190 ctx.drawImage(udemLogo, 40, size - 50 - udemHeight, udemWidth, udemHeight); 7191 7192 // IVADO logo next to UdeM with 30px gap 7193 const ivadoHeight = 80; 7194 const ivadoWidth = ivadoLogo.width * (ivadoHeight / ivadoLogo.height); 7195 ctx.drawImage(ivadoLogo, 40 + udemWidth + 30, size - 50 - ivadoHeight, ivadoWidth, ivadoHeight); 7196 7197 ctx.restore(); 7198 } 7199 }; 7200 7201 udemLogo.src = 'assets/udem-logo.svg'; 7202 ivadoLogo.src = 'assets/ivado-rgb_logo-full-degrade.png'; 7203 7204 function drawSmallPin(x, y, scale) { 7205 ctx.save(); 7206 const s = scale / 30; 7207 ctx.translate(x, y); 7208 7209 // Pole 7210 const poleGradient = ctx.createLinearGradient(0, 16 * s, 0, 59 * s); 7211 poleGradient.addColorStop(0, '#1976d2'); 7212 poleGradient.addColorStop(1, '#0d47a1'); 7213 ctx.fillStyle = poleGradient; 7214 ctx.fillRect(-3 * s, 16 * s, 6 * s, 43 * s); 7215 7216 // Sphere 7217 const sphereGradient = ctx.createRadialGradient(0, -4 * s, 0, 0, -4 * s, 20 * s); 7218 sphereGradient.addColorStop(0, '#64b5f6'); 7219 sphereGradient.addColorStop(0.6, '#2196f3'); 7220 sphereGradient.addColorStop(1, '#1565c0'); 7221 ctx.fillStyle = sphereGradient; 7222 ctx.beginPath(); 7223 ctx.arc(0, -4 * s, 20 * s, 0, Math.PI * 2); 7224 ctx.fill(); 7225 7226 ctx.restore(); 7227 } 7228 7229 function drawPinIcon(x, y, scale) { 7230 ctx.save(); 7231 7232 const s = scale / 30; 7233 // Position so the BOTTOM of the pole is at (x, y) 7234 ctx.translate(x, y - 59 * s); 7235 7236 // Shadow 7237 ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; 7238 ctx.beginPath(); 7239 ctx.ellipse(0, 59 * s, 14 * s, 4 * s, 0, 0, Math.PI * 2); 7240 ctx.fill(); 7241 7242 // Pole 7243 const poleGradient = ctx.createLinearGradient(0, 16 * s, 0, 59 * s); 7244 poleGradient.addColorStop(0, '#1976d2'); 7245 poleGradient.addColorStop(1, '#0d47a1'); 7246 ctx.fillStyle = poleGradient; 7247 ctx.beginPath(); 7248 ctx.roundRect(-3 * s, 16 * s, 6 * s, 43 * s, 3 * s); 7249 ctx.fill(); 7250 7251 // Main sphere 7252 const sphereGradient = ctx.createRadialGradient(0, -4 * s, 0, 0, -4 * s, 20 * s); 7253 sphereGradient.addColorStop(0, '#64b5f6'); 7254 sphereGradient.addColorStop(0.6, '#2196f3'); 7255 sphereGradient.addColorStop(1, '#1565c0'); 7256 ctx.fillStyle = sphereGradient; 7257 ctx.beginPath(); 7258 ctx.arc(0, -4 * s, 20 * s, 0, Math.PI * 2); 7259 ctx.fill(); 7260 7261 // Highlight 7262 const highlightGradient = ctx.createRadialGradient(-6 * s, -10 * s, 0, -6 * s, -10 * s, 20 * s); 7263 highlightGradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); 7264 highlightGradient.addColorStop(0.3, 'rgba(255, 255, 255, 0.5)'); 7265 highlightGradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); 7266 ctx.fillStyle = highlightGradient; 7267 ctx.beginPath(); 7268 ctx.arc(0, -4 * s, 20 * s, 0, Math.PI * 2); 7269 ctx.fill(); 7270 7271 // White reflection 7272 ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; 7273 ctx.beginPath(); 7274 ctx.arc(-8 * s, -12 * s, 7 * s, 0, Math.PI * 2); 7275 ctx.fill(); 7276
7277 ctx.restore(); 7278 } 7279 }, 100); 7280 7281 // Make canvas clickable on mobile to save to photo library 7282 if (window.innerWidth <= 768) { 7283 setTimeout(() => { 7284 const canvas = document.getElementById('mosaic-canvas'); 7285 if (canvas) { 7286 canvas.style.cursor = 'pointer'; 7287 canvas.addEventListener('click', () => { 7288 downloadCanvas(); 7289 }); 7290 } 7291 }, 150); 7292 } 7293 7294 // Download function for mosaic 7295 window.downloadCanvas = async function() { 7296 const canvas = document.getElementById('mosaic-canvas'); 7297 7298 // On mobile, try to use Share API to save to photos 7299 if (window.innerWidth <= 768 && navigator.share && navigator.canShare) { 7300 try { 7301 const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); 7302 const file = new File([blob], 'tagtaville_' + result.submissionId + '.png', { type: 'image/png' }); 7303 7304 if (navigator.canShare({ files: [file] })) { 7305 await navigator.share({ 7306 files: [file], 7307 title: 'Mon évaluation Tag ta Ville', 7308 }); 7309 return; 7310 } 7311 } catch (err) { 7312 console.log('Share failed, falling back to download:', err); 7313 } 7314 } 7315 7316 // Fallback: regular download 7317 const link = document.createElement('a'); 7318 link.download = 'tagtaville_' + result.submissionId + '.png'; 7319 link.href = canvas.toDataURL('image/png'); 7320 link.click(); 7321 }; 7322 7323 // Share function 7324 window.shareWithFriends = async function() { 7325 const canvas = document.getElementById('mosaic-canvas'); 7326 const websiteUrl = 'https://tagtaville.ca'; 7327 7328 canvas.toBlob(async (blob) => { 7329 const file = new File([blob], 'tagtaville_' + result.submissionId + '.png', { type: 'image/png' }); 7330 7331 if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { 7332 try { 7333 await navigator.share({ 7334 title: 'Participe avec moi à Tag ta Ville!', 7335 text: "J'ai contribué à Tag ta Ville, un projet de recherche de l'Université de Montréal qui cartographie la qualité de vie dans nos quartiers. Découvre mon évaluation de mon secteur en pièce jointe.\n\nParticipe toi aussi: c'est rapide, gratuit et entièrement anonyme!", 7336 url: websiteUrl, 7337 files: [file] 7338 }); 7339 } catch (err) { 7340 if (err.name !== 'AbortError') { 7341 fallbackShare(); 7342 } 7343 } 7344 } else { 7345 fallbackShare(); 7346 } 7347 }, 'image/png'); 7348 7349 function fallbackShare() { 7350 downloadCanvas(); 7351 const shareText = "J'ai contribué à Tag ta Ville, un projet de recherche de l'Université de Montréal qui cartographie la qualité de vie dans nos quartiers. Découvre mon évaluation de mon secteur en pièce jointe.\n\nParticipe toi aussi: c'est rapide, gratuit et entièrement anonyme!\n\n" + websiteUrl; 7352 7353 if (navigator.clipboard) { 7354 navigator.clipboard.writeText(shareText).then(() => { 7355 alert("Image téléchargée et message copié dans le presse-papier!\n\nPartage-le avec tes amis!"); 7356 }).catch(() => { 7357 alert("Image téléchargée!\n\nPartage ce message avec tes amis:\n\n" + shareText); 7358 }); 7359 } else { 7360 alert("Image téléchargée!\n\nPartage ce message avec tes amis:\n\n" + shareText); 7361 } 7362 } 7363 }; 7364 7365 // Mark step 4 as completed in progress tracker 7366 const step4 = document.getElementById('progress-step-4'); 7367 if (step4) { 7368 step4.classList.remove('active'); 7369 step4.classList.add('completed'); 7370 } 7371 7372 // Handle comment submission 7373 document.getElementById('submit-comment-btn').addEventListener('click', async () => { 7374 const commentText = document.getElementById('submission-comments').value.trim(); 7375 if (!commentText) { 7376 alert(currentLang === 'fr' ? 'Veuillez entrer un commentaire.' : 'Please enter a comment.'); 7377 return; 7378 } 7379 7380 const btn = document.getElementById('submit-comment-btn'); 7381 btn.disabled = true; 7382 btn.textContent = currentLang === 'fr' ? 'Envoi...' : 'Sending...'; 7383 7384 try { 7385 await fetch('https://tag-ta-ville-production.up.railway.app/
7385api/feedback', { 7386 method: 'POST', 7387 headers: { 'Content-Type': 'application/json' }, 7388 body: JSON.stringify({ 7389 feedback: commentText, 7390 type: 'submission_comment', 7391 submissionId: result.submissionId 7392 }) 7393 }); 7394 7395 btn.textContent = currentLang === 'fr' ? 'â Envoyé' : 'â Sent'; 7396 btn.style.background = '#1e7e34'; 7397 document.getElementById('submission-comments').value = ''; 7398 } catch (err) { 7399 console.error('Comment submission error:', err); 7400 btn.textContent = currentLang === 'fr' ? 'Erreur - Réessayer' : 'Error - Retry'; 7401 btn.disabled = false; 7402 } 7403 }); 7404 7405 // Add event listener to finish button - hard reload 7406 document.getElementById('submission-ok-btn').addEventListener('click', () => { 7407 location.reload(); 7408 7409 // Remove center marker 7410 if (centerMarker) { 7411 centerMarker.remove(); 7412 centerMarker = null; 7413 } 7414 7415 // Remove all map layers (voxels, trees, radius circle) 7416 if (map.getLayer('voxel-fill-2d')) map.removeLayer('voxel-fill-2d'); 7417 if (map.getLayer('voxel-fill')) map.removeLayer('voxel-fill'); 7418 if (map.getSource('voxel-cubes')) map.removeSource('voxel-cubes'); 7419 7420 if (map.getLayer('tree-cubes-2d')) map.removeLayer('tree-cubes-2d'); 7421 if (map.getLayer('tree-cubes')) map.removeLayer('tree-cubes'); 7422 if (map.getSource('tree-cubes')) map.removeSource('tree-cubes'); 7423 if (map.getLayer('radius-circle-line')) { 7424 map.removeLayer('radius-circle-line'); 7425 map.removeSource('radius-circle'); 7426 } 7427 7428 // Remove painted voxels layers 7429 if (map.getLayer('painted-voxels-layer')) { 7430 map.removeLayer('painted-voxels-layer'); 7431 } 7432 if (map.getSource('painted-voxels')) { 7433 map.removeSource('painted-voxels'); 7434 } 7435 7436 // Remove ground paint markers 7437 groundPaintMarkers.forEach(paintId => { 7438 if (map.getLayer(paintId)) { 7439 map.removeLayer(paintId); 7440 map.removeSource(paintId); 7441 } 7442 }); 7443 groundPaintMarkers = []; 7444 7445 // Remove painted overlays 7446 paintedOverlays.forEach(overlayId => { 7447 try { 7448 if (map.getLayer(overlayId)) map.removeLayer(overlayId); 7449 if (map.getSource(overlayId)) map.removeSource(overlayId); 7450 } catch(e) {} 7451 }); 7452 paintedOverlays = []; 7453 7454 // Remove cluster markers 7455 clusterMarkers.forEach(marker => marker.remove()); 7456 clusterMarkers = []; 7457 7458 // Remove split divider lines 7459 splitDividerLines.forEach(lineId => { 7460 try { 7461 if (map.getLayer(lineId)) map.removeLayer(lineId); 7462 if (map.getSource(lineId)) map.removeSource(lineId); 7463 } catch(e) {} 7464 }); 7465 splitDividerLines = []; 7466 7467 // Remove tool preview 7468 removeToolPreview(); 7469 7470 // Reset voxel data 7471 voxelsLoaded = false; 7472 voxelSpatialIndex = null; 7473 allVoxelFeatures = []; 7474 dislikedVoxels.clear(); 7475 likedVoxels.clear(); 7476 paintedVoxelFeatures = []; 7477 paintedVoxelIndex.clear(); 7478 7479 // Reset state variables 7480 currentTool = 'cursor'; 7481 currentPaintColor = 'liked'; 7482 isDragging = false; 7483 clusterLabels = {}; 7484 allClusters = []; 7485 selectedClusterId = null; 7486 currentClusterIndex = 0; 7487 7488 // Clear selected point (wrapped in try-catch) 7489 try { 7490 if (typeof selectedPointMarker !== 'undefined' && selectedPointMarker) { 7491 selectedPointMarker.remove(); 7492 selectedPointMarker = null; 7493 } 7494 } catch(e) { 7495 console.log('selectedPointMarker cleanup skipped:', e); 7496 } 7497 7498 try { 7499 if (typeof selectedPoint !== 'undefined') { 7500 selectedPoint = null; 7501 } 7502 } catch(e) { 7503 console.log('selectedPoint cleanup skipped:', e); 7504 } 7505 7506 // Re-enable map interactions 7507 map.dragPan.enable(); 7508 map.scrollZoom.enable(); 7509 7510 // Hide all steps 7511 document.getElementById('step1').classList.add('hidden'); 7512 document.getElementById('step2-paint').classList.add('hidden'); 7513 document.getElementById('step3-tag-clusters').classList.add('hidden'); 7514 document.getElementById('step4-submit').classList.add('hidden'); 7515 7516 // Reset Step 1 UI elements 7517 document.getElementById('location-selected').classList.add('hidden'); 7518 document.getElementById('address-suggestions').classList.add('hidden'); 7519 document.getElementById('address-input').value = ''; 7520
7521 // Clear clusters list 7522 document.getElementById('clusters-list').innerHTML = ''; 7523 7524 // Hide reset button 7525 document.getElementById('reset-btn').classList.add('hidden'); 7526 7527 // Reset tool selection 7528 document.getElementById('tool-cursor').classList.add('active'); 7529 document.getElementById('tool-paint').classList.remove('active'); 7530 document.getElementById('tool-erase').classList.remove('active'); 7531 document.getElementById('paint-green').classList.add('active'); 7532 document.getElementById('paint-red').classList.remove('active'); 7533 7534 // Hide map nav controls and progress tracker 7535 document.getElementById('map-nav-controls').classList.remove('active'); 7536 document.getElementById('progress-tracker').style.display = 'none'; 7537 7538 // Show main panel again 7539 const mainPanel = document.querySelector('.main-panel'); 7540 if (mainPanel) mainPanel.style.display = 'block'; 7541 7542 // Reset progress 7543 updateProgress(1); 7544 7545 // Show welcome overlay for fresh start 7546 document.getElementById('welcome-overlay').style.display = 'flex'; 7547 7548 // Fly back to Montreal center 7549 map.flyTo({ 7550 center: MONTREAL_CENTER, 7551 zoom: 12, 7552 pitch: isMobile ? 0 : 45, 7553 bearing: -17, 7554 duration: 1500 7555 }); 7556 }); 7557 } else { 7558 throw new Error(result.error || 'Submission failed'); 7559 } 7560 7561 } catch (error) { 7562 // Hide loading overlay on error 7563 document.getElementById('loading-overlay').classList.add('hidden'); 7564 console.error('Submission error:', error); 7565 const lang = (typeof currentLang !== 'undefined') ? currentLang : 'fr'; 7566 const title = lang === 'fr' ? 'Erreur' : 'Error'; 7567 const message = lang === 'fr' 7568 ? `Erreur lors de la soumission des données :<br><br>${error.message}<br><br>Assurez-vous que le serveur backend est en cours d'exécution.` 7569 : `Error submitting data:<br><br>${error.message}<br><br>Please make sure the backend server is running.`; 7570 showSubmissionErrorAlert(title, message); 7571 } finally { 7572 submitBtn.disabled = false; 7573 const submitText = currentLang === 'fr' ? 'Soumettre' : 'Submit'; 7574 submitBtn.innerHTML = `<span data-en="Submit" data-fr="Soumettre">${submitText}</span>`; 7575 } 7576}); 7577 7578// View controls 7579const pitchSlider = document.getElementById('pitch-slider'); 7580if (pitchSlider) { 7581 pitchSlider.addEventListener('input', (e) => { 7582 const pitch = parseInt(e.target.value); 7583 document.getElementById('pitch-value').textContent = pitch; 7584 map.setPitch(pitch); 7585 console.log('Pitch set to:', pitch); 7586 }); 7587} else { 7588 console.error('pitch-slider element not found'); 7589} 7590 7591const toggleBuildings = document.getElementById('toggle-buildings'); 7592if (toggleBuildings) { 7593 toggleBuildings.addEventListener('change', (e) => { 7594 const visibility = e.target.checked ? 'visible' : 'none'; 7595 // Toggle both 2D and 3D layers 7596 if (map.getLayer('voxel-fill-2d')) { 7597 map.setLayoutProperty('voxel-fill-2d', 'visibility', visibility); 7598 } 7599 if (map.getLayer('voxel-fill')) { 7600 map.setLayoutProperty('voxel-fill', 'visibility', visibility); 7601 } 7602 }); 7603} else { 7604 console.error('toggle-buildings element not found'); 7605} 7606 7607 7608const toggleRadius = document.getElementById('toggle-radius'); 7609if (toggleRadius) { 7610 toggleRadius.addEventListener('change', (e) => { 7611 if (map.getLayer('radius-circle-line')) { 7612 const v = e.target.checked ? 'visible' : 'none'; 7613 map.setLayoutProperty('radius-circle-line', 'visibility', v); 7614 } 7615 }); 7616} 7617 7618// Satellite toggle 7619let satelliteLayerAdded = false; 7620 7621const toggleSatellite = document.getElementById('toggle-satellite'); 7622if (toggleSatellite) { 7623 toggleSatellite.addEventListener('change', (e) => { 7624 console.log('Toggle satellite:', e.target.checked); 7625 if (e.target.checked) { 7626 addSatelliteLayer(); 7627 } else { 7628 removeSatelliteLayer(); 7629 } 7630 }); 7631} else { 7632 console.error('toggle-satellite element not found'); 7633} 7634 7635function addSatelliteLayer() { 7636 if (!satelliteLayerAdded) { 7637 try { 7638 map.addSource('google-satellite', { 7639 type: 'raster', 7640 tiles: [ 7641 'https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', 7642 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}' 7643 ], 7644 tileSize: 256 7645 }); 7646 7647 // Insert before voxels if they exist, otherwise just add 7648 const beforeLayer = map.getLayer('voxel-fill') ? 'voxel-fill' : undefined; 7649 7650 map.addLayer({ 7651 id: 'google-satellite-layer', 7652 type: 'raster', 7653 source: 'google-satellite', 7654 paint: { 'raster-opacity': 0.6 } 7655 }, beforeLayer); 7656 7657 satelliteLayerAdded = true; 7658 console.log('â Satellite layer added'); 7659 } catch (error) { 7660 console.error('Error adding satellite layer:', error); 7661 } 7662 } 7663} 7664 7665function removeSatelliteLayer() { 7666 if (satelliteLayerAdded) { 7667 if (map.getLayer('google-satellite-layer')) { 7668 map.removeLayer('google-satellite-layer'); 7669 } 7670 if (map.getSource('google-satellite')) { 7671 map.removeSource('google-satellite'); 7672 } 7673 satelliteLayerAdded = false;
7674 } 7675} 7676 7677// Reset 7678document.getElementById('reset-btn').addEventListener('click', () => { 7679 if (centerMarker) { centerMarker.remove(); centerMarker = null; } 7680 7681 if (map.getLayer('voxel-fill-2d')) map.removeLayer('voxel-fill-2d'); 7682 if (map.getLayer('voxel-fill')) map.removeLayer('voxel-fill'); 7683 if (map.getSource('voxel-cubes')) map.removeSource('voxel-cubes'); 7684 7685 if (map.getLayer('tree-cubes-2d')) map.removeLayer('tree-cubes-2d'); 7686 if (map.getLayer('tree-cubes')) map.removeLayer('tree-cubes'); 7687 if (map.getSource('tree-cubes')) map.removeSource('tree-cubes'); 7688 if (map.getLayer('radius-circle-fill')) { 7689 map.removeLayer('radius-circle-fill'); 7690 map.removeLayer('radius-circle-line'); 7691 map.removeSource('radius-circle'); 7692 } 7693 7694 groundPaintMarkers.forEach(paintId => { 7695 if (map.getLayer(paintId)) { 7696 map.removeLayer(paintId); 7697 map.removeSource(paintId); 7698 } 7699 }); 7700 groundPaintMarkers = []; 7701 7702 clusterMarkers.forEach(marker => marker.remove()); 7703 clusterMarkers = []; 7704 7705 removeToolPreview(); 7706 7707 // Keep 2D buildings hidden - only show 3D voxels 7708 // if (map.getLayer('building')) map.setLayoutProperty('building', 'visibility', 'visible'); 7709 7710 voxelsLoaded = false; 7711 voxelSpatialIndex = null; 7712 currentTool = 'cursor'; 7713 currentPaintColor = 'liked'; 7714 dislikedVoxels.clear(); 7715 likedVoxels.clear(); 7716 allVoxelFeatures = []; 7717 isDragging = false; 7718 clusterLabels = {}; 7719 selectedClusterId = null; 7720 7721 map.dragPan.enable(); 7722 map.scrollZoom.enable(); 7723 7724 // Reset UI 7725 document.getElementById('step1').classList.remove('hidden'); 7726 document.getElementById('step2-paint').classList.add('hidden'); 7727 document.getElementById('step3-tag-clusters').classList.add('hidden'); 7728 document.getElementById('step4-submit').classList.add('hidden'); 7729 7730 document.getElementById('location-selected').classList.add('hidden'); 7731 document.getElementById('address-suggestions').classList.add('hidden'); 7732 document.getElementById('address-input').value = ''; 7733 7734 document.getElementById('clusters-list').innerHTML = ''; 7735 document.getElementById('reset-btn').classList.add('hidden'); 7736 7737 document.getElementById('tool-cursor').classList.add('active'); 7738 document.getElementById('tool-paint').classList.remove('active'); 7739 document.getElementById('tool-erase').classList.remove('active'); 7740 document.getElementById('paint-green').classList.add('active'); 7741 document.getElementById('paint-red').classList.remove('active'); 7742 7743 map.flyTo({ 7744 center: MONTREAL_CENTER, 7745 zoom: 12, 7746 pitch: 45, 7747 bearing: -17, 7748 duration: 1500 7749 }); 7750 7751}); 7752 7753 7754}); // End DOMContentLoaded 7755 7756</script>
7756 7757 7758</body> 7759</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.