diff --git a/public/roadNetwork.json b/public/roadNetwork.json new file mode 100644 index 0000000..93d53cc --- /dev/null +++ b/public/roadNetwork.json @@ -0,0 +1,1594 @@ +[ + { + "id": 1, + "x": -4.61, + "y": 7.13, + "z": -2.77, + "connections": [ + 2, + 7 + ] + }, + { + "id": 2, + "x": -5.61, + "y": 7.08, + "z": -7.84, + "connections": [ + 1, + 3 + ] + }, + { + "id": 3, + "x": -2.36, + "y": 6.96, + "z": -13.75, + "connections": [ + 2, + 4 + ] + }, + { + "id": 4, + "x": -3.35, + "y": 6.48, + "z": -22.71, + "connections": [ + 3, + 5, + 69, + 157 + ] + }, + { + "id": 5, + "x": -11.24, + "y": 6.45, + "z": -20.45, + "connections": [ + 4, + 6 + ] + }, + { + "id": 6, + "x": -19.8, + "y": 6.46, + "z": -12.16, + "connections": [ + 5, + 7, + 8, + 53 + ] + }, + { + "id": 7, + "x": -10.38, + "y": 6.99, + "z": -7.51, + "connections": [ + 6, + 1 + ] + }, + { + "id": 8, + "x": -29.38, + "y": 5.69, + "z": -14.1, + "connections": [ + 6, + 9, + 15 + ] + }, + { + "id": 9, + "x": -34.88, + "y": 5.18, + "z": -14.73, + "connections": [ + 8, + 10, + 11 + ] + }, + { + "id": 10, + "x": -43.64, + "y": 4.25, + "z": -16.72, + "connections": [ + 9 + ] + }, + { + "id": 11, + "x": -34.62, + "y": 4.86, + "z": -21.94, + "connections": [ + 9, + 12 + ] + }, + { + "id": 12, + "x": -39.38, + "y": 3.98, + "z": -29.69, + "connections": [ + 11, + 13 + ] + }, + { + "id": 13, + "x": -46.05, + "y": 3.15, + "z": -34.04, + "connections": [ + 12, + 14 + ] + }, + { + "id": 14, + "x": -55.16, + "y": 2.32, + "z": -36.22, + "connections": [ + 13 + ] + }, + { + "id": 15, + "x": -35.85, + "y": 5.37, + "z": -3.43, + "connections": [ + 8, + 16, + 18 + ] + }, + { + "id": 16, + "x": -37.61, + "y": 5.17, + "z": 5.14, + "connections": [ + 15, + 17 + ] + }, + { + "id": 17, + "x": -44.96, + "y": 4.35, + "z": 8.67, + "connections": [ + 16 + ] + }, + { + "id": 18, + "x": -43.46, + "y": 4.57, + "z": -4.93, + "connections": [ + 15, + 19 + ] + }, + { + "id": 19, + "x": -52.58, + "y": 3.6, + "z": -5.75, + "connections": [ + 18, + 20, + 23 + ] + }, + { + "id": 20, + "x": -58.7, + "y": 3.01, + "z": -0.58, + "connections": [ + 19, + 21 + ] + }, + { + "id": 21, + "x": -64.82, + "y": 2.4, + "z": 5, + "connections": [ + 20, + 22 + ] + }, + { + "id": 22, + "x": -70.26, + "y": 1.95, + "z": 4.73, + "connections": [ + 21 + ] + }, + { + "id": 23, + "x": -60.47, + "y": 2.73, + "z": -11.6, + "connections": [ + 19, + 24, + 25 + ] + }, + { + "id": 24, + "x": -64.69, + "y": 2.24, + "z": -16.49, + "connections": [ + 23 + ] + }, + { + "id": 25, + "x": -66.78, + "y": 2.17, + "z": -10.64, + "connections": [ + 23, + 26 + ] + }, + { + "id": 26, + "x": -76.23, + "y": 1.39, + "z": -15.3, + "connections": [ + 25, + 27, + 29, + 40 + ] + }, + { + "id": 27, + "x": -82.06, + "y": 0.97, + "z": -20.09, + "connections": [ + 26, + 28 + ] + }, + { + "id": 28, + "x": -89.41, + "y": 0.54, + "z": -26.27, + "connections": [ + 27 + ] + }, + { + "id": 29, + "x": -82.06, + "y": 1.08, + "z": -11.22, + "connections": [ + 26, + 30, + 32 + ] + }, + { + "id": 30, + "x": -88.59, + "y": 0.74, + "z": -2.59, + "connections": [ + 29, + 31 + ] + }, + { + "id": 31, + "x": -95.82, + "y": 0.45, + "z": 1.02, + "connections": [ + 30 + ] + }, + { + "id": 32, + "x": -90.92, + "y": 0.58, + "z": -14.14, + "connections": [ + 29, + 33 + ] + }, + { + "id": 33, + "x": -97.45, + "y": 0.31, + "z": -17.87, + "connections": [ + 32, + 34, + 36 + ] + }, + { + "id": 34, + "x": -102.82, + "y": 0.14, + "z": -24.4, + "connections": [ + 33, + 35 + ] + }, + { + "id": 35, + "x": -108.07, + "y": 0, + "z": -31.52, + "connections": [ + 34 + ] + }, + { + "id": 36, + "x": -102.94, + "y": 0.15, + "z": -14.96, + "connections": [ + 33, + 37 + ] + }, + { + "id": 37, + "x": -107.37, + "y": 0.11, + "z": -9.01, + "connections": [ + 36, + 38 + ] + }, + { + "id": 38, + "x": -110.75, + "y": 0.08, + "z": -2.59, + "connections": [ + 37, + 39 + ] + }, + { + "id": 39, + "x": -116.58, + "y": 0, + "z": -0.38, + "connections": [ + 38 + ] + }, + { + "id": 40, + "x": -76.23, + "y": 1.27, + "z": -22.89, + "connections": [ + 26, + 41 + ] + }, + { + "id": 41, + "x": -76.81, + "y": 1.09, + "z": -29.53, + "connections": [ + 40, + 42, + 47 + ] + }, + { + "id": 42, + "x": -73.43, + "y": 1.08, + "z": -37.23, + "connections": [ + 41, + 43 + ] + }, + { + "id": 43, + "x": -71.91, + "y": 1.02, + "z": -42.83, + "connections": [ + 42, + 44 + ] + }, + { + "id": 44, + "x": -72.61, + "y": 0.77, + "z": -49.48, + "connections": [ + 43, + 45 + ] + }, + { + "id": 45, + "x": -77.51, + "y": 0.43, + "z": -56.36, + "connections": [ + 44 + ] + }, + { + "id": 46, + "x": -64.5, + "y": 2.03, + "z": -25.33, + "connections": [] + }, + { + "id": 47, + "x": -82.6, + "y": 0.64, + "z": -38.22, + "connections": [ + 41, + 48 + ] + }, + { + "id": 48, + "x": -87.73, + "y": 0.32, + "z": -45.38, + "connections": [ + 47, + 49 + ] + }, + { + "id": 49, + "x": -88.14, + "y": 0.17, + "z": -54.34, + "connections": [ + 48, + 50 + ] + }, + { + "id": 50, + "x": -89.12, + "y": 0.09, + "z": -59.31, + "connections": [ + 49, + 51 + ] + }, + { + "id": 51, + "x": -92.7, + "y": 0.01, + "z": -63.96, + "connections": [ + 50 + ] + }, + { + "id": 52, + "x": -70.7, + "y": 1.34, + "z": -34.23, + "connections": [] + }, + { + "id": 53, + "x": -23.13, + "y": 6.44, + "z": -4.02, + "connections": [ + 6, + 54 + ] + }, + { + "id": 54, + "x": -23.62, + "y": 6.39, + "z": 5.67, + "connections": [ + 53, + 55 + ] + }, + { + "id": 55, + "x": -18.16, + "y": 6.37, + "z": 16.59, + "connections": [ + 54, + 56, + 59, + 156 + ] + }, + { + "id": 56, + "x": -14.25, + "y": 6.77, + "z": 11.21, + "connections": [ + 55, + 57 + ] + }, + { + "id": 57, + "x": -13.93, + "y": 6.9, + "z": 6.81, + "connections": [ + 56, + 58 + ] + }, + { + "id": 58, + "x": -11, + "y": 7.03, + "z": 3.23, + "connections": [ + 57 + ] + }, + { + "id": 59, + "x": -10.67, + "y": 6.41, + "z": 21.39, + "connections": [ + 55, + 60 + ] + }, + { + "id": 60, + "x": -1.71, + "y": 6.38, + "z": 24.33, + "connections": [ + 59, + 61 + ] + }, + { + "id": 61, + "x": 8.8, + "y": 6.36, + "z": 23.1, + "connections": [ + 60, + 62 + ] + }, + { + "id": 62, + "x": 14.42, + "y": 6.42, + "z": 18.95, + "connections": [ + 61, + 63, + 64, + 108 + ] + }, + { + "id": 63, + "x": 2.44, + "y": 7.13, + "z": 3.31, + "connections": [ + 62 + ] + }, + { + "id": 64, + "x": 20.12, + "y": 6.43, + "z": 12.52, + "connections": [ + 62, + 65 + ] + }, + { + "id": 65, + "x": 22.48, + "y": 6.49, + "z": 3.88, + "connections": [ + 64, + 66 + ] + }, + { + "id": 66, + "x": 21.34, + "y": 6.52, + "z": -6.79, + "connections": [ + 65, + 67 + ] + }, + { + "id": 67, + "x": 18.25, + "y": 6.5, + "z": -13.47, + "connections": [ + 66, + 68 + ] + }, + { + "id": 68, + "x": 15.23, + "y": 6.54, + "z": -15.99, + "connections": [ + 67, + 69, + 70, + 71 + ] + }, + { + "id": 69, + "x": 5.78, + "y": 6.52, + "z": -21.53, + "connections": [ + 68, + 4 + ] + }, + { + "id": 70, + "x": 11.08, + "y": 6.96, + "z": -8.17, + "connections": [ + 68 + ] + }, + { + "id": 71, + "x": 19.39, + "y": 6.07, + "z": -20.63, + "connections": [ + 68, + 72, + 91 + ] + }, + { + "id": 72, + "x": 21.18, + "y": 5.69, + "z": -24.87, + "connections": [ + 71, + 73, + 74 + ] + }, + { + "id": 73, + "x": 22.57, + "y": 4.58, + "z": -37.32, + "connections": [ + 72 + ] + }, + { + "id": 74, + "x": 28.76, + "y": 4.96, + "z": -27.8, + "connections": [ + 72, + 75, + 76 + ] + }, + { + "id": 75, + "x": 36.01, + "y": 4.45, + "z": -26.82, + "connections": [ + 74 + ] + }, + { + "id": 76, + "x": 39.1, + "y": 3.59, + "z": -35.78, + "connections": [ + 74, + 77, + 78 + ] + }, + { + "id": 77, + "x": 51.07, + "y": 2.37, + "z": -40.58, + "connections": [ + 76 + ] + }, + { + "id": 78, + "x": 39.26, + "y": 2.89, + "z": -45.14, + "connections": [ + 76, + 79, + 81 + ] + }, + { + "id": 79, + "x": 37.55, + "y": 2.11, + "z": -57.04, + "connections": [ + 78 + ] + }, + { + "id": 80, + "x": 44.58, + "y": 2.21, + "z": -50.17, + "connections": [] + }, + { + "id": 81, + "x": 47.25, + "y": 1.81, + "z": -54.26, + "connections": [ + 78, + 82 + ] + }, + { + "id": 82, + "x": 60.2, + "y": 0.9, + "z": -60.53, + "connections": [ + 81, + 83, + 84 + ] + }, + { + "id": 83, + "x": 75.6, + "y": 0.3, + "z": -63.79, + "connections": [ + 82 + ] + }, + { + "id": 84, + "x": 71.69, + "y": 0.73, + "z": -52.06, + "connections": [ + 82, + 85, + 86 + ] + }, + { + "id": 85, + "x": 84.72, + "y": 0.41, + "z": -45.14, + "connections": [ + 84 + ] + }, + { + "id": 86, + "x": 72.83, + "y": 0.94, + "z": -43.18, + "connections": [ + 84, + 87 + ] + }, + { + "id": 87, + "x": 82.52, + "y": 0.79, + "z": -29.01, + "connections": [ + 86, + 88 + ] + }, + { + "id": 88, + "x": 92.95, + "y": 0.47, + "z": -18.1, + "connections": [ + 87, + 89 + ] + }, + { + "id": 89, + "x": 100.77, + "y": 0.21, + "z": -16.55, + "connections": [ + 88 + ] + }, + { + "id": 90, + "x": 80.1, + "y": 0.92, + "z": -28.51, + "connections": [] + }, + { + "id": 91, + "x": 31.21, + "y": 5.49, + "z": -15.42, + "connections": [ + 71, + 92, + 102, + 103 + ] + }, + { + "id": 92, + "x": 48.06, + "y": 3.69, + "z": -19.81, + "connections": [ + 91, + 93, + 95 + ] + }, + { + "id": 93, + "x": 59.71, + "y": 2.45, + "z": -24.13, + "connections": [ + 92 + ] + }, + { + "id": 94, + "x": 56.18, + "y": 3.03, + "z": -15.85, + "connections": [] + }, + { + "id": 95, + "x": 60.85, + "y": 2.78, + "z": -3.28, + "connections": [ + 92, + 96, + 97 + ] + }, + { + "id": 96, + "x": 68.43, + "y": 2.1, + "z": -2.47, + "connections": [ + 95 + ] + }, + { + "id": 97, + "x": 68.51, + "y": 2.04, + "z": 9.18, + "connections": [ + 95, + 98, + 99 + ] + }, + { + "id": 98, + "x": 67.86, + "y": 1.98, + "z": 16.59, + "connections": [ + 97 + ] + }, + { + "id": 99, + "x": 91.56, + "y": 0.56, + "z": 13.82, + "connections": [ + 97, + 100 + ] + }, + { + "id": 100, + "x": 97.1, + "y": 0.31, + "z": 18.46, + "connections": [ + 99 + ] + }, + { + "id": 101, + "x": 64.61, + "y": 2.41, + "z": 6.58, + "connections": [] + }, + { + "id": 102, + "x": 43.42, + "y": 4.52, + "z": -8.25, + "connections": [ + 91 + ] + }, + { + "id": 103, + "x": 32.83, + "y": 5.63, + "z": -5.24, + "connections": [ + 91, + 104 + ] + }, + { + "id": 104, + "x": 32.75, + "y": 5.62, + "z": 6.74, + "connections": [ + 103, + 105 + ] + }, + { + "id": 105, + "x": 32.51, + "y": 5.42, + "z": 14.31, + "connections": [ + 104, + 106 + ] + }, + { + "id": 106, + "x": 39.02, + "y": 4.65, + "z": 17.98, + "connections": [ + 105, + 107 + ] + }, + { + "id": 107, + "x": 44.48, + "y": 4.26, + "z": 14.31, + "connections": [ + 106 + ] + }, + { + "id": 108, + "x": 19.31, + "y": 5.65, + "z": 26.84, + "connections": [ + 62, + 109, + 131 + ] + }, + { + "id": 109, + "x": 22.49, + "y": 5.23, + "z": 29.86, + "connections": [ + 108, + 110, + 127 + ] + }, + { + "id": 110, + "x": 32.91, + "y": 3.51, + "z": 42.4, + "connections": [ + 109, + 111, + 122 + ] + }, + { + "id": 111, + "x": 39.1, + "y": 2.75, + "z": 47.21, + "connections": [ + 110, + 112 + ] + }, + { + "id": 112, + "x": 55.64, + "y": 1.54, + "z": 51.12, + "connections": [ + 111, + 113, + 115, + 117 + ] + }, + { + "id": 113, + "x": 61.66, + "y": 1.26, + "z": 49.98, + "connections": [ + 112, + 114 + ] + }, + { + "id": 114, + "x": 70.14, + "y": 0.98, + "z": 46.23, + "connections": [ + 113 + ] + }, + { + "id": 115, + "x": 56.45, + "y": 1.31, + "z": 54.86, + "connections": [ + 112 + ] + }, + { + "id": 116, + "x": 54.87, + "y": 1.31, + "z": 56.41, + "connections": [] + }, + { + "id": 117, + "x": 61.42, + "y": 0.86, + "z": 60.39, + "connections": [ + 112, + 118 + ] + }, + { + "id": 118, + "x": 60.85, + "y": 0.54, + "z": 70.01, + "connections": [ + 117, + 119 + ] + }, + { + "id": 119, + "x": 56.7, + "y": 0.37, + "z": 78.56, + "connections": [ + 118, + 120 + ] + }, + { + "id": 120, + "x": 57.11, + "y": 0.24, + "z": 83.2, + "connections": [ + 119 + ] + }, + { + "id": 121, + "x": 43.87, + "y": 1.27, + "z": 66.11, + "connections": [] + }, + { + "id": 122, + "x": 31.12, + "y": 2.64, + "z": 54.13, + "connections": [ + 110, + 123, + 124 + ] + }, + { + "id": 123, + "x": 25.5, + "y": 2.37, + "z": 60.15, + "connections": [ + 122 + ] + }, + { + "id": 124, + "x": 32.1, + "y": 1.84, + "z": 64.06, + "connections": [ + 122, + 125 + ] + }, + { + "id": 125, + "x": 26.07, + "y": 1.22, + "z": 75.79, + "connections": [ + 124, + 126 + ] + }, + { + "id": 126, + "x": 26.07, + "y": 1, + "z": 79.54, + "connections": [ + 125 + ] + }, + { + "id": 127, + "x": 31.39, + "y": 4.75, + "z": 27.95, + "connections": [ + 109, + 128 + ] + }, + { + "id": 128, + "x": 41.12, + "y": 4.07, + "z": 25.58, + "connections": [ + 127, + 129 + ] + }, + { + "id": 129, + "x": 46.63, + "y": 3.54, + "z": 26.12, + "connections": [ + 128, + 130 + ] + }, + { + "id": 130, + "x": 53.28, + "y": 2.65, + "z": 32.44, + "connections": [ + 129 + ] + }, + { + "id": 131, + "x": 19.13, + "y": 4.92, + "z": 35.57, + "connections": [ + 108, + 132 + ] + }, + { + "id": 132, + "x": 16.75, + "y": 4.05, + "z": 45.62, + "connections": [ + 131, + 133 + ] + }, + { + "id": 133, + "x": 11.18, + "y": 3.33, + "z": 54.32, + "connections": [ + 132, + 134, + 143 + ] + }, + { + "id": 134, + "x": 7.83, + "y": 2.97, + "z": 58.54, + "connections": [ + 133, + 135, + 139 + ] + }, + { + "id": 135, + "x": 2.7, + "y": 2.73, + "z": 61.4, + "connections": [ + 134, + 136 + ] + }, + { + "id": 136, + "x": -2.22, + "y": 2.81, + "z": 60.59, + "connections": [ + 135, + 137 + ] + }, + { + "id": 137, + "x": -2.98, + "y": 3.58, + "z": 52.97, + "connections": [ + 136 + ] + }, + { + "id": 139, + "x": 4.05, + "y": 1.58, + "z": 74.79, + "connections": [ + 134, + 140 + ] + }, + { + "id": 140, + "x": -0.33, + "y": 1.21, + "z": 80.47, + "connections": [ + 139, + 141 + ] + }, + { + "id": 141, + "x": -3.24, + "y": 1.24, + "z": 79.76, + "connections": [ + 140, + 142 + ] + }, + { + "id": 142, + "x": -3.41, + "y": 1.73, + "z": 73.01, + "connections": [ + 141 + ] + }, + { + "id": 143, + "x": 11.78, + "y": 0.74, + "z": 87.87, + "connections": [ + 133, + 145, + 149 + ] + }, + { + "id": 144, + "x": 15.36, + "y": 0.75, + "z": 87.1, + "connections": [] + }, + { + "id": 145, + "x": 7.89, + "y": 0.44, + "z": 94.98, + "connections": [ + 143, + 146 + ] + }, + { + "id": 146, + "x": 4.48, + "y": 0.31, + "z": 98.94, + "connections": [ + 145, + 147 + ] + }, + { + "id": 147, + "x": -2.22, + "y": 0.27, + "z": 100.07, + "connections": [ + 146, + 148 + ] + }, + { + "id": 148, + "x": -3.03, + "y": 0.47, + "z": 94.56, + "connections": [ + 147 + ] + }, + { + "id": 149, + "x": 11.78, + "y": 0.29, + "z": 98.56, + "connections": [ + 143, + 150 + ] + }, + { + "id": 150, + "x": 9.61, + "y": 0.05, + "z": 108.88, + "connections": [ + 149, + 151 + ] + }, + { + "id": 151, + "x": 5.08, + "y": 0, + "z": 117.04, + "connections": [ + 150, + 152 + ] + }, + { + "id": 152, + "x": -0.49, + "y": 0, + "z": 119.26, + "connections": [ + 151, + 153 + ] + }, + { + "id": 153, + "x": -5.3, + "y": 0, + "z": 116.93, + "connections": [ + 152 + ] + }, + { + "id": 154, + "x": 17.24, + "y": 0, + "z": 117.06, + "connections": [] + }, + { + "id": 156, + "x": -45.93, + "y": 2.77, + "z": 40.28, + "connections": [ + 55 + ] + }, + { + "id": 157, + "x": -7.58, + "y": 5.97, + "z": -28.56, + "connections": [ + 4, + 158 + ] + }, + { + "id": 158, + "x": -17.19, + "y": 2.57, + "z": -60.82, + "connections": [ + 157, + 159, + 162 + ] + }, + { + "id": 159, + "x": -2.45, + "y": 2.37, + "z": -65.38, + "connections": [ + 158, + 160 + ] + }, + { + "id": 160, + "x": 7.4, + "y": 2.46, + "z": -63.99, + "connections": [ + 159, + 161 + ] + }, + { + "id": 161, + "x": 24.59, + "y": 2.71, + "z": -56.66, + "connections": [ + 160 + ] + }, + { + "id": 162, + "x": -31.53, + "y": 2.44, + "z": -56.42, + "connections": [ + 158, + 163 + ] + }, + { + "id": 163, + "x": -39.67, + "y": 2.4, + "z": -51.61, + "connections": [ + 162, + 164 + ] + }, + { + "id": 164, + "x": -49.61, + "y": 2.28, + "z": -44.28, + "connections": [ + 163 + ] + } +] \ No newline at end of file diff --git a/src/pages/waypoint/page.tsx b/src/pages/waypoint/page.tsx new file mode 100644 index 0000000..11ee4dd --- /dev/null +++ b/src/pages/waypoint/page.tsx @@ -0,0 +1,1084 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { useGLTF, OrthographicCamera, MapControls, Line } from '@react-three/drei'; +import * as THREE from 'three'; +import { + Trash2, + Link2, + Download, + Clipboard, + Info, + MapPin, + Map as MapIcon +} from 'lucide-react'; + +// ========================================== +// 1. Waypoint Interfaces +// ========================================== + +export interface Waypoint { + id: number; + x: number; + y: number; // height (Raycasted from terrain) + z: number; + connections: number[]; +} + +// ========================================== +// 2. Editor Scene Manager Component +// ========================================== + +interface EditorSceneProps { + waypoints: Waypoint[]; + selectedId: number | null; + hoveredNodeId: number | null; + setHoveredNodeId: (id: number | null) => void; + setDragStartNodeId: (id: number | null) => void; + dragStartNodeId: number | null; + hoverPointRef: React.MutableRefObject; + handleTerrainClick: (point: THREE.Vector3) => void; + handleSelectNode: (id: number) => void; + selectedConnection: { idA: number; idB: number } | null; + setSelectedConnection: (conn: { idA: number; idB: number } | null) => void; + hoveredConnection: { idA: number; idB: number } | null; + setHoveredConnection: (conn: { idA: number; idB: number } | null) => void; +} + +const EditorScene: React.FC = ({ + waypoints, + selectedId, + hoveredNodeId, + setHoveredNodeId, + setDragStartNodeId, + dragStartNodeId, + hoverPointRef, + handleTerrainClick, + handleSelectNode, + selectedConnection, + setSelectedConnection, + hoveredConnection, + setHoveredConnection, +}) => { + const { scene } = useGLTF('/models/terrain/terrain.glb'); + const { raycaster, pointer, camera } = useThree(); + const groupRef = useRef(null); + const rubberLineRef = useRef(null); + + // Mirror reactive props inside Refs to guarantee useFrame loop never closes over stale state + const hoveredNodeIdRef = useRef(null); + const dragStartNodeIdRef = useRef(null); + const waypointsRef = useRef([]); + + useEffect(() => { + hoveredNodeIdRef.current = hoveredNodeId; + }, [hoveredNodeId]); + + useEffect(() => { + dragStartNodeIdRef.current = dragStartNodeId; + }, [dragStartNodeId]); + + useEffect(() => { + waypointsRef.current = waypoints; + }, [waypoints]); + + // Continuously raycast from mouse position to terrain and waypoints to detect hovers during drag + useFrame(() => { + if (!groupRef.current) return; + + raycaster.setFromCamera(pointer, camera); + const intersects = raycaster.intersectObjects(groupRef.current.children, true); + + // Find waypoint sphere hover (only trigger React state update if hovered ID changes) + const sphereIntersect = intersects.find((item) => item.object.name && item.object.name.startsWith('waypoint-')); + if (sphereIntersect) { + const nodeId = Number(sphereIntersect.object.name.replace('waypoint-', '')); + if (hoveredNodeIdRef.current !== nodeId) { + setHoveredNodeId(nodeId); + } + } else { + if (hoveredNodeIdRef.current !== null) { + setHoveredNodeId(null); + } + } + + // Find terrain mesh hover + const terrainIntersect = intersects.find((item) => item.object.name && !item.object.name.startsWith('waypoint-')); + const activeTerrainIntersect = terrainIntersect || intersects.find((item) => !item.object.name); + + if (activeTerrainIntersect && activeTerrainIntersect.point) { + const point = activeTerrainIntersect.point; + hoverPointRef.current = point.clone(); + + // 1. Bypass React state: Update HTML Floating Panel directly for 0ms lag + const coordsPanel = document.getElementById('coords-panel'); + if (coordsPanel) { + coordsPanel.innerText = `X: ${point.x.toFixed(2)} | Y (Raycast): ${point.y.toFixed(2)} | Z: ${point.z.toFixed(2)}`; + } + + // 2. Bypass React state: Update pink rubber band line dynamically in WebGL + const activeDragId = dragStartNodeIdRef.current; + if (activeDragId !== null && rubberLineRef.current) { + rubberLineRef.current.visible = true; + const startNode = waypointsRef.current.find((w) => w.id === activeDragId); + if (startNode) { + rubberLineRef.current.geometry.setFromPoints([ + new THREE.Vector3(startNode.x, startNode.y + 0.4, startNode.z), + new THREE.Vector3(point.x, point.y + 0.4, point.z) + ]); + } + } else if (rubberLineRef.current) { + rubberLineRef.current.visible = false; + } + } else { + if (rubberLineRef.current) { + rubberLineRef.current.visible = false; + } + } + }); + + return ( + + {/* 1. Terrain Mesh (Raycasted for adding/dragging) */} + { + e.stopPropagation(); + // Only click-to-create a new node if they are not actively dragging a link + if (dragStartNodeId === null && e.point) { + handleTerrainClick(e.point); + } + }} + /> + + {/* 2. Drag Rubber Band Preview Line (WebGL optimized) */} + + + + + + {/* 3. Render Established Connections */} + + + {/* 4. Render Waypoint Node Spheres */} + + + ); +}; + +// ========================================== +// 3. Grid Visualizer & Helpers +// ========================================== + +interface WaypointMarkersProps { + waypoints: Waypoint[]; + selectedId: number | null; + onSelect: (id: number) => void; + hoveredNodeId: number | null; + setHoveredNodeId: (id: number | null) => void; + setDragStartNodeId: (id: number | null) => void; +} + +const WaypointMarkers: React.FC = ({ + waypoints, + selectedId, + onSelect, + hoveredNodeId, + setHoveredNodeId, + setDragStartNodeId, +}) => { + return ( + + {waypoints.map((wp) => { + const isSelected = wp.id === selectedId; + const isHovered = wp.id === hoveredNodeId; + + let color = '#3b82f6'; // Standard blue + let scale = 1.0; + + if (isSelected) { + color = '#ff0055'; // Pink-red for selected + scale = 1.5; + } else if (isHovered) { + color = '#60a5fa'; // Bright blue for hovered + scale = 1.25; + } + + return ( + { + e.stopPropagation(); + setHoveredNodeId(wp.id); + }} + onPointerOut={() => { + setHoveredNodeId(null); + }} + onPointerDown={(e: any) => { + e.stopPropagation(); + if (e.button === 0) { + // Left click start drag link connection + setDragStartNodeId(wp.id); + } else if (e.button === 2) { + // Right click select waypoint + onSelect(wp.id); + } + }} + > + {/* Core Marker Node */} + + + + + + {/* Ring indicator */} + + + + + + ); + })} + + ); +}; + +interface ConnectionLinesProps { + waypoints: Waypoint[]; + selectedConnection: { idA: number; idB: number } | null; + setSelectedConnection: (conn: { idA: number; idB: number } | null) => void; + hoveredConnection: { idA: number; idB: number } | null; + setHoveredConnection: (conn: { idA: number; idB: number } | null) => void; +} + +const ConnectionLines: React.FC = ({ + waypoints, + selectedConnection, + setSelectedConnection, + hoveredConnection, + setHoveredConnection, +}) => { + // Generate pairs of lines + const lines = React.useMemo(() => { + const list: [THREE.Vector3, THREE.Vector3, number, number][] = []; + const drawn = new Set(); + + waypoints.forEach((wp) => { + wp.connections.forEach((connId) => { + const other = waypoints.find((w) => w.id === connId); + if (other) { + const key = wp.id < other.id ? `${wp.id}-${other.id}` : `${other.id}-${wp.id}`; + if (!drawn.has(key)) { + drawn.add(key); + list.push([ + new THREE.Vector3(wp.x, wp.y + 0.4, wp.z), + new THREE.Vector3(other.x, other.y + 0.4, other.z), + wp.id, + other.id + ]); + } + } + }); + }); + return list; + }, [waypoints]); + + return ( + + {lines.map(([start, end, idA, idB]) => { + const isSelected = selectedConnection && ( + (selectedConnection.idA === idA && selectedConnection.idB === idB) || + (selectedConnection.idA === idB && selectedConnection.idB === idA) + ); + const isHovered = hoveredConnection && ( + (hoveredConnection.idA === idA && hoveredConnection.idB === idB) || + (hoveredConnection.idA === idB && hoveredConnection.idB === idA) + ); + + let color = '#10b981'; // Emerald green + let lineWidth = 3; + + if (isSelected) { + color = '#f59e0b'; // Amber yellow for selected connection + lineWidth = 5.0; + } else if (isHovered) { + color = '#60a5fa'; // Bright blue for hovered connection + lineWidth = 4.5; + } + + return ( + { + e.stopPropagation(); + setHoveredConnection({ idA, idB }); + }} + onPointerOut={(e) => { + e.stopPropagation(); + setHoveredConnection(null); + }} + onClick={(e) => { + e.stopPropagation(); + console.log(`[Lien 3D] Sélectionné: Point ${idA} <-> Point ${idB}`); + setSelectedConnection({ idA, idB }); + }} + /> + ); + })} + + ); +}; + +// ========================================== +// 4. Main Waypoint Editor Page Component +// ========================================== + +export const WaypointEditorPage: React.FC = () => { + const [waypoints, setWaypoints] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + + // Selection / Hover states for 3D paths/connections + const [selectedConnection, setSelectedConnection] = useState<{ idA: number; idB: number } | null>(null); + const [hoveredConnection, setHoveredConnection] = useState<{ idA: number; idB: number } | null>(null); + + // Helper function to handle connection selection and reset node selection + const handleSelectConnection = (conn: { idA: number; idB: number } | null) => { + if (conn) { + console.log(`[Sélection] Liaison active sélectionnée : Point ${conn.idA} <-> Point ${conn.idB}`); + setSelectedId(null); // Clear selected node + } + setSelectedConnection(conn); + }; + + // Mutable ref for high frequency raycast updates to bypass React rendering loop + const hoverPointRef = useRef(null); + + // Connection / Drag states + const [dragStartNodeId, setDragStartNodeId] = useState(null); + const [isConnectingMode, setIsConnectingMode] = useState(false); + const [activeConnectionStartId, setActiveConnectionStartId] = useState(null); + + // Load from localstorage on mount + useEffect(() => { + console.log("[Initialisation] Chargement des waypoints depuis localStorage..."); + const saved = localStorage.getItem('la-fabrik-waypoints'); + if (saved) { + try { + const list = JSON.parse(saved); + console.log(`[Initialisation] ${list.length} waypoints chargés avec succès !`); + setWaypoints(list); + } catch (e) { + console.error('[Initialisation] Erreur de parsing du stockage local', e); + } + } else { + console.log("[Initialisation] Aucun point enregistré en localStorage. Démarrage à vide."); + } + }, []); + + // Save to localstorage when waypoints change + const saveWaypoints = (list: Waypoint[]) => { + setWaypoints(list); + localStorage.setItem('la-fabrik-waypoints', JSON.stringify(list)); + }; + + // Delete a specific connection (break the link) + const deleteSelectedConnection = (idA: number, idB: number) => { + console.log(`[Liaisons] Suppression définitive du lien : Point ${idA} <-> Point ${idB}`); + setWaypoints((currentWaypoints) => { + const updatedList = currentWaypoints.map((wp) => { + if (wp.id === idA) { + return { ...wp, connections: wp.connections.filter((cId) => cId !== idB) }; + } + if (wp.id === idB) { + return { ...wp, connections: wp.connections.filter((cId) => cId !== idA) }; + } + return wp; + }); + localStorage.setItem('la-fabrik-waypoints', JSON.stringify(updatedList)); + return updatedList; + }); + setSelectedConnection(null); + }; + + // Listen for global keyboard shortcuts (e.g. Delete node or connection) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const activeEl = document.activeElement; + if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) { + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + console.log(`[Hotkey] Touche '${e.key}' détectée.`); + if (selectedId !== null) { + console.log(`[Hotkey] Touche de suppression activée sur le Point sélectionné : ID = ${selectedId}`); + handleDeleteNode(selectedId); + } else if (selectedConnection !== null) { + console.log(`[Hotkey] Touche de suppression activée sur la Liaison sélectionnée : ${selectedConnection.idA} <-> ${selectedConnection.idB}`); + deleteSelectedConnection(selectedConnection.idA, selectedConnection.idB); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [selectedId, selectedConnection, waypoints]); + + // Add a new waypoint + const handleTerrainClick = (point: THREE.Vector3) => { + if (isConnectingMode) { + console.log("[Mode Connexion] Clic sur terrain vide. Annulation du mode liaison."); + setIsConnectingMode(false); + setActiveConnectionStartId(null); + return; + } + + setWaypoints((currentWaypoints) => { + const nextId = currentWaypoints.length > 0 ? Math.max(...currentWaypoints.map((w) => w.id)) + 1 : 1; + const newWp: Waypoint = { + id: nextId, + x: Number(point.x.toFixed(2)), + y: Number(point.y.toFixed(2)), + z: Number(point.z.toFixed(2)), + connections: [], + }; + + console.log(`[Création] Nouveau Point déposé : ID = ${nextId} | Coordonnées : (${newWp.x}, ${newWp.y}, ${newWp.z})`); + const newList = [...currentWaypoints, newWp]; + localStorage.setItem('la-fabrik-waypoints', JSON.stringify(newList)); + setTimeout(() => { + setSelectedConnection(null); + setSelectedId(nextId); + }, 0); + return newList; + }); + }; + + // Select node or handle connections (toggles connections if they already exist) + const handleSelectNode = (id: number) => { + setSelectedConnection(null); // Reset connection selection + + if (isConnectingMode && activeConnectionStartId !== null) { + if (activeConnectionStartId === id) { + console.log("[Mode Connexion] Tentative de liaison sur soi-même. Annulation."); + setIsConnectingMode(false); + setActiveConnectionStartId(null); + return; + } + + console.log(`[Mode Connexion] Création manuelle d'un lien : Point ${activeConnectionStartId} <-> Point ${id}`); + toggleConnection(activeConnectionStartId, id); + setIsConnectingMode(false); + setActiveConnectionStartId(null); + setSelectedId(id); + } else { + console.log(`[Sélection] Point sélectionné : ID = ${id}`); + setSelectedId(id); + } + }; + + // Toggle connection between two waypoint IDs (using functional state to prevent stale closures) + const toggleConnection = (idA: number, idB: number) => { + console.log(`[Liaisons] toggleConnection(Point ${idA}, Point ${idB})`); + setWaypoints((currentWaypoints) => { + const updatedList = currentWaypoints.map((wp) => { + if (wp.id === idA) { + const alreadyLinked = wp.connections.includes(idB); + console.log(`[Liaisons] Point ${idA} : ${alreadyLinked ? 'Suppression' : 'Ajout'} de la liaison vers Point ${idB}`); + const conns = alreadyLinked + ? wp.connections.filter((cId) => cId !== idB) + : [...wp.connections, idB]; + return { ...wp, connections: conns }; + } + if (wp.id === idB) { + const alreadyLinked = wp.connections.includes(idA); + console.log(`[Liaisons] Point ${idB} : ${alreadyLinked ? 'Suppression' : 'Ajout'} de la liaison vers Point ${idA}`); + const conns = alreadyLinked + ? wp.connections.filter((cId) => cId !== idA) + : [...wp.connections, idA]; + return { ...wp, connections: conns }; + } + return wp; + }); + localStorage.setItem('la-fabrik-waypoints', JSON.stringify(updatedList)); + return updatedList; + }); + }; + + // Global pointer up handler for completing link drags (releases on empty space create & connect a node) + const handleGlobalPointerUp = () => { + if (dragStartNodeId !== null) { + if (hoveredNodeId !== null && hoveredNodeId !== dragStartNodeId) { + console.log(`[Drag&Drop] Relâchement sur le Point existant : ID = ${hoveredNodeId}. Création/Toggling du lien.`); + toggleConnection(dragStartNodeId, hoveredNodeId); + } else if (hoverPointRef.current !== null) { + const point = hoverPointRef.current; + setWaypoints((currentWaypoints) => { + const nextId = currentWaypoints.length > 0 ? Math.max(...currentWaypoints.map((w) => w.id)) + 1 : 1; + const newWp: Waypoint = { + id: nextId, + x: Number(point.x.toFixed(2)), + y: Number(point.y.toFixed(2)), + z: Number(point.z.toFixed(2)), + connections: [dragStartNodeId], + }; + + console.log(`[Drag&Drop] Relâchement sur zone vide. Création automatique du Point ${nextId} aux coordonnées (${newWp.x}, ${newWp.y}, ${newWp.z}) et liaison mutuelle avec le Point ${dragStartNodeId}`); + + const updatedList = currentWaypoints.map((wp) => { + if (wp.id === dragStartNodeId) { + return { + ...wp, + connections: wp.connections.includes(nextId) ? wp.connections : [...wp.connections, nextId], + }; + } + return wp; + }); + + const finalList = [...updatedList, newWp]; + localStorage.setItem('la-fabrik-waypoints', JSON.stringify(finalList)); + setTimeout(() => { + setSelectedConnection(null); + setSelectedId(nextId); + }, 0); + return finalList; + }); + } else { + setSelectedId(dragStartNodeId); + } + setDragStartNodeId(null); + } + }; + + // Delete current selected node + const handleDeleteNode = (id: number) => { + console.log(`[Suppression] Action de suppression définitive du Point : ID = ${id}`); + setWaypoints((currentWaypoints) => { + const updatedList = currentWaypoints + .filter((wp) => wp.id !== id) + .map((wp) => ({ + ...wp, + connections: wp.connections.filter((cId) => cId !== id), + })); + console.log(`[Suppression] Point ${id} supprimé. ${updatedList.length} points restants.`); + localStorage.setItem('la-fabrik-waypoints', JSON.stringify(updatedList)); + return updatedList; + }); + setSelectedId((currentSelected) => (currentSelected === id ? null : currentSelected)); + }; + + // Connect Mode Trigger + const startConnecting = (id: number) => { + console.log(`[Mode Connexion] Démarrage mode connexion manuelle depuis Point ID = ${id}`); + setIsConnectingMode(true); + setActiveConnectionStartId(id); + }; + + // Clear all waypoints + const handleClearAll = () => { + if (window.confirm('Voulez-vous vraiment TOUT supprimer ? Cette action est irréversible.')) { + console.log("[Action] Suppression complète et définitive de tous les points de la carte."); + saveWaypoints([]); + setSelectedId(null); + setSelectedConnection(null); + } + }; + + // Copy network JSON to clipboard + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(JSON.stringify(waypoints, null, 2)); + alert('JSON copié dans le presse-papier !'); + }; + + // Download network JSON file + const handleDownload = () => { + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(waypoints, null, 2)); + const downloadAnchor = document.createElement('a'); + downloadAnchor.setAttribute("href", dataStr); + downloadAnchor.setAttribute("download", "roadNetwork.json"); + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + downloadAnchor.remove(); + }; + + const selectedNode = waypoints.find((w) => w.id === selectedId); + + return ( +
+ {/* 1. Header Navigation */} +
+
+ +

La Fabrik — Waypoint Network Editor

+
+
+ + +
+
+ +
+ {/* 2. Left sidebar: Nodes manager */} + + + {/* 3. Three.js Canvas */} +
e.preventDefault()} + > + {/* Active Hover point details (DOM-optimized to prevent high-frequency React renders) */} +
+ Survolez le terrain... +
+ + {isConnectingMode && ( +
+ Mode Connexion Actif : Cliquez sur le deuxième waypoint pour lier le point {activeConnectionStartId}. +
+ )} + + {dragStartNodeId !== null && ( +
+ Relier le point {dragStartNodeId}... Glissez et relâchez sur un autre point. +
+ )} + + + {/* Top-down isometric / orthographic camera */} + + + + + + {/* Locked Orbit/Map controls (Locked rotation for strict top-down, disabled during link dragging to prevent panning) */} + + + {/* Load Terrain, Track hover & draw drag previews/spheres cleanly using full-rate raycasting scene */} + + +
+
+
+ ); +}; + +// ========================================== +// Styles (Premium Dark Glassmorphism) +// ========================================== + +const styles = { + container: { + width: '100vw', + height: '100vh', + display: 'flex', + flexDirection: 'column', + backgroundColor: '#0f172a', // deep slate + color: '#f8fafc', + fontFamily: 'system-ui, -apple-system, sans-serif', + overflow: 'hidden', + } as React.CSSProperties, + header: { + height: '64px', + backgroundColor: 'rgba(30, 41, 59, 0.75)', + borderBottom: '1px solid rgba(255, 255, 255, 0.08)', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '0 24px', + backdropFilter: 'blur(12px)', + zIndex: 10, + } as React.CSSProperties, + logoGroup: { + display: 'flex', + alignItems: 'center', + gap: '12px', + } as React.CSSProperties, + logoIcon: { + color: '#3b82f6', + }, + logoText: { + fontSize: '18px', + fontWeight: 600, + letterSpacing: '-0.02em', + margin: 0, + } as React.CSSProperties, + headerControls: { + display: 'flex', + gap: '12px', + } as React.CSSProperties, + primaryButton: { + backgroundColor: '#3b82f6', + color: '#ffffff', + border: 'none', + borderRadius: '8px', + padding: '8px 16px', + fontSize: '14px', + fontWeight: 500, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '8px', + boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)', + transition: 'all 0.2s', + } as React.CSSProperties, + secondaryButton: { + backgroundColor: 'rgba(255, 255, 255, 0.05)', + color: '#f8fafc', + border: '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: '8px', + padding: '8px 16px', + fontSize: '14px', + fontWeight: 500, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '8px', + transition: 'all 0.2s', + } as React.CSSProperties, + mainArea: { + flex: 1, + display: 'flex', + overflow: 'hidden', + position: 'relative', + } as React.CSSProperties, + sidebar: { + width: '360px', + backgroundColor: 'rgba(15, 23, 42, 0.85)', + borderRight: '1px solid rgba(255, 255, 255, 0.08)', + display: 'flex', + flexDirection: 'column', + padding: '20px', + backdropFilter: 'blur(16px)', + zIndex: 5, + } as React.CSSProperties, + sidebarHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '16px', + } as React.CSSProperties, + sidebarTitle: { + fontSize: '16px', + fontWeight: 600, + margin: 0, + } as React.CSSProperties, + clearButton: { + backgroundColor: 'transparent', + color: '#ef4444', + border: 'none', + cursor: 'pointer', + fontSize: '12px', + display: 'flex', + alignItems: 'center', + gap: '4px', + } as React.CSSProperties, + nodesList: { + flex: 1, + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + gap: '8px', + paddingRight: '4px', + marginBottom: '20px', + } as React.CSSProperties, + emptyState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '200px', + color: '#64748b', + textAlign: 'center', + padding: '0 20px', + } as React.CSSProperties, + emptyIcon: { + marginBottom: '12px', + color: '#475569', + }, + emptyText: { + fontSize: '13px', + lineHeight: '1.5', + margin: 0, + } as React.CSSProperties, + nodeItem: (isSelected: boolean): React.CSSProperties => ({ + padding: '12px', + borderRadius: '10px', + border: `1px solid ${isSelected ? 'rgba(59, 130, 246, 0.4)' : 'rgba(255, 255, 255, 0.05)'}`, + backgroundColor: isSelected ? 'rgba(59, 130, 246, 0.12)' : 'rgba(255, 255, 255, 0.02)', + cursor: 'pointer', + transition: 'all 0.2s', + display: 'flex', + flexDirection: 'column', + gap: '4px', + }), + nodeInfo: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + } as React.CSSProperties, + nodeBadge: { + fontSize: '12px', + fontWeight: 600, + color: '#3b82f6', + } as React.CSSProperties, + nodeCoords: { + fontSize: '11px', + color: '#94a3b8', + } as React.CSSProperties, + nodeSubinfo: { + fontSize: '11px', + color: '#64748b', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + } as React.CSSProperties, + detailsCard: { + backgroundColor: 'rgba(255, 255, 255, 0.03)', + border: '1px solid rgba(255, 255, 255, 0.06)', + borderRadius: '12px', + padding: '16px', + display: 'flex', + flexDirection: 'column', + gap: '12px', + } as React.CSSProperties, + detailsTitle: { + fontSize: '14px', + fontWeight: 600, + margin: 0, + } as React.CSSProperties, + detailsGrid: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + fontSize: '13px', + } as React.CSSProperties, + detailsRow: { + display: 'flex', + justifyContent: 'space-between', + borderBottom: '1px solid rgba(255, 255, 255, 0.04)', + paddingBottom: '4px', + } as React.CSSProperties, + detailsActions: { + display: 'flex', + gap: '10px', + marginTop: '6px', + } as React.CSSProperties, + connectButton: (isActive: boolean): React.CSSProperties => ({ + flex: 1, + backgroundColor: isActive ? '#ff0055' : 'rgba(16, 185, 129, 0.15)', + color: isActive ? '#ffffff' : '#10b981', + border: `1px solid ${isActive ? '#ff0055' : 'rgba(16, 185, 129, 0.3)'}`, + borderRadius: '8px', + padding: '8px 12px', + fontSize: '13px', + fontWeight: 500, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '6px', + boxShadow: isActive ? '0 4px 12px rgba(255, 0, 85, 0.3)' : 'none', + transition: 'all 0.2s', + }), + deleteButton: { + backgroundColor: 'rgba(239, 68, 68, 0.15)', + color: '#ef4444', + border: '1px solid rgba(239, 68, 68, 0.3)', + borderRadius: '8px', + padding: '8px 12px', + fontSize: '13px', + fontWeight: 500, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '6px', + transition: 'all 0.2s', + } as React.CSSProperties, + canvasContainer: { + flex: 1, + position: 'relative', + } as React.CSSProperties, + coordsFloating: { + position: 'absolute', + top: '16px', + left: '16px', + backgroundColor: 'rgba(15, 23, 42, 0.85)', + border: '1px solid rgba(255, 255, 255, 0.08)', + borderRadius: '8px', + padding: '8px 14px', + fontSize: '12px', + color: '#94a3b8', + backdropFilter: 'blur(8px)', + pointerEvents: 'none', + zIndex: 1, + } as React.CSSProperties, + connectingBanner: { + position: 'absolute', + top: '16px', + left: '50%', + transform: 'translateX(-50%)', + backgroundColor: '#ff0055', + color: '#ffffff', + borderRadius: '8px', + padding: '10px 20px', + fontSize: '13px', + fontWeight: 500, + boxShadow: '0 4px 20px rgba(255, 0, 85, 0.4)', + display: 'flex', + alignItems: 'center', + gap: '8px', + pointerEvents: 'none', + zIndex: 1, + } as React.CSSProperties, +}; diff --git a/src/pathfinding/AStar.ts b/src/pathfinding/AStar.ts new file mode 100644 index 0000000..a3ea918 --- /dev/null +++ b/src/pathfinding/AStar.ts @@ -0,0 +1,122 @@ +import { Grid } from './Grid'; +import type { GridNode, Position } from './types'; + +/** + * Calculates the octile heuristic distance between two nodes. + * Ideal for 8-directional grid movement. + */ +function getOctileDistance(nodeA: GridNode, nodeB: GridNode): number { + const dx = Math.abs(nodeA.x - nodeB.x); + const dy = Math.abs(nodeA.y - nodeB.y); + + const D = 1; // Orthogonal movement cost + const D2 = 1.414; // Diagonal movement cost (approx Math.sqrt(2)) + + return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy); +} + +/** + * Finds the shortest path between start and end positions on the grid. + * Returns an array of Positions representing the path, or an empty array if no path is found. + */ +export function findPath( + grid: Grid, + startPos: Position, + endPos: Position, + allowDiagonals: boolean = true +): Position[] { + grid.reset(); + + const startNode = grid.getNode(Math.floor(startPos.x), Math.floor(startPos.y)); + const endNode = grid.getNode(Math.floor(endPos.x), Math.floor(endPos.y)); + + if (!startNode || !endNode) { + return []; + } + + // If the destination node itself is blocked, we try to find the nearest walkable neighbor + if (!endNode.walkable) { + const endNeighbors = grid.getNeighbors(endNode, allowDiagonals); + if (endNeighbors.length === 0) { + return []; + } + // Set destination to the closest walkable neighbor + let closestNeighbor = endNeighbors[0]!; + let minDist = getOctileDistance(startNode, closestNeighbor); + for (let i = 1; i < endNeighbors.length; i++) { + const neighbor = endNeighbors[i]!; + const dist = getOctileDistance(startNode, neighbor); + if (dist < minDist) { + minDist = dist; + closestNeighbor = neighbor; + } + } + // Reroute to that walkable neighbor + return findPath(grid, startPos, { x: closestNeighbor.x, y: closestNeighbor.y }, allowDiagonals); + } + + const openSet: GridNode[] = [startNode]; + const closedSet = new Set(); + + startNode.g = 0; + startNode.h = getOctileDistance(startNode, endNode); + startNode.f = startNode.h; + + while (openSet.length > 0) { + // Find the node in openSet with the lowest f value + let lowIndex = 0; + for (let i = 1; i < openSet.length; i++) { + const node = openSet[i]!; + const lowNode = openSet[lowIndex]!; + if (node.f < lowNode.f) { + lowIndex = i; + } + } + + const currentNode = openSet[lowIndex]!; + + // Check if we reached the destination + if (currentNode === endNode) { + const path: Position[] = []; + let temp: GridNode | null = currentNode; + while (temp !== null) { + path.push({ x: temp.x, y: temp.y }); + temp = temp.parent; + } + return path.reverse(); + } + + // Remove currentNode from openSet and add to closedSet + openSet.splice(lowIndex, 1); + closedSet.add(currentNode); + + const neighbors = grid.getNeighbors(currentNode, allowDiagonals); + + for (const neighbor of neighbors) { + if (closedSet.has(neighbor)) { + continue; + } + + // Calculate cost to move to this neighbor (1 for orthogonal, 1.414 for diagonal) + const isDiagonal = neighbor.x !== currentNode.x && neighbor.y !== currentNode.y; + const moveCost = isDiagonal ? 1.414 : 1; + const tentativeG = currentNode.g + moveCost; + + let neighborInOpenSet = openSet.includes(neighbor); + + if (!neighborInOpenSet || tentativeG < neighbor.g) { + neighbor.parent = currentNode; + neighbor.g = tentativeG; + neighbor.h = getOctileDistance(neighbor, endNode); + neighbor.f = neighbor.g + neighbor.h; + + if (!neighborInOpenSet) { + openSet.push(neighbor); + } + } + } + } + + // Return empty if no path is found + return []; +} diff --git a/src/pathfinding/GPSMinimap.tsx b/src/pathfinding/GPSMinimap.tsx new file mode 100644 index 0000000..20c7496 --- /dev/null +++ b/src/pathfinding/GPSMinimap.tsx @@ -0,0 +1,207 @@ +import React, { useRef, useEffect, useState, useMemo } from 'react'; +import * as THREE from 'three'; +import { useGPS } from './useGPS'; +import type { WorldBounds } from './useGPS'; + +// ========================================== +// 1. Premium 2D HUD GPS Overlay Component +// ========================================== + +export interface GPSMinimapHUDProps { + bwMaskUrl: string; + colorMapUrl: string; + gridWidth: number; + gridHeight: number; + worldBounds: WorldBounds; + playerPos: { x: number; z: number }; + destPos?: { x: number; z: number }; + size?: number; // Size of HUD in pixels +} + +/** + * A beautiful, glassmorphic 2D HUD overlay that renders the GPS Minimap + * in the corner of the screen. + */ +export const GPSMinimapHUD: React.FC = ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + playerPos, + destPos, + size = 200, +}) => { + const canvasRef = useRef(null); + + const gpsOptions = useMemo(() => ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + }), [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds]); + + const { calculateWorldPath, renderGPSToCanvas, loading, error } = useGPS(gpsOptions); + + useEffect(() => { + if (loading || error || !canvasRef.current) return; + + // Calculate A* path in world coordinates + const path = destPos ? calculateWorldPath(playerPos, destPos) : []; + + // Render path onto HUD canvas + renderGPSToCanvas(canvasRef.current, path, playerPos, destPos, { + pathColor: '#3b82f6', // Premium vibrant blue + pathWidth: 5, + playerColor: '#ef4444', // Hot red for player + playerSize: 6, + destColor: '#10b981', // Emerald green for destination + destSize: 6, + }); + }, [playerPos, destPos, loading, error, calculateWorldPath, renderGPSToCanvas]); + + return ( +
+ {loading &&
Initializing GPS...
} + {error &&
GPS Error: {error}
} + + {!loading && !error && ( + + )} +
+ ); +}; + +// ========================================== +// 2. 3D Handlebar Screen Mesh Component (R3F) +// ========================================== + +export interface GPSBikeScreenProps { + bwMaskUrl: string; + colorMapUrl: string; + gridWidth: number; + gridHeight: number; + worldBounds: WorldBounds; + playerPos: { x: number; z: number }; + destPos?: { x: number; z: number }; + width?: number; // 3D Plane Width + height?: number; // 3D Plane Height +} + +/** + * A Three.js 3D plane mesh that renders the GPS dynamically as a CanvasTexture. + * This can be directly attached to the bike's handlebars in your 3D world. + */ +export const GPSBikeScreen: React.FC = ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + playerPos, + destPos, + width = 0.4, + height = 0.4, +}) => { + // Offscreen canvas to render the GPS texture onto + const [offscreenCanvas] = useState(() => { + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 512; + return canvas; + }); + + const textureRef = useRef(null); + + const gpsOptions = useMemo(() => ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + }), [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds]); + + const { calculateWorldPath, renderGPSToCanvas, loading } = useGPS(gpsOptions); + + useEffect(() => { + if (loading) return; + + // Calculate A* path + const path = destPos ? calculateWorldPath(playerPos, destPos) : []; + + // Render path onto our offscreen canvas + renderGPSToCanvas(offscreenCanvas, path, playerPos, destPos, { + pathColor: '#60a5fa', // Bright neon blue + pathWidth: 8, + playerColor: '#ff0055', // Neon pink-red for bike + playerSize: 10, + destColor: '#00ffcc', // Vibrant cyan for target + destSize: 10, + }); + + // Notify Three.js that the texture needs an update + if (textureRef.current) { + textureRef.current.needsUpdate = true; + } + }, [playerPos, destPos, loading, calculateWorldPath, renderGPSToCanvas, offscreenCanvas]); + + return ( + + + + + + + ); +}; + +// ========================================== +// Styles for HUD (Premium Glassmorphism) +// ========================================== + +const hudStyles = { + container: (size: number): React.CSSProperties => ({ + position: 'absolute', + bottom: '24px', + right: '24px', + width: `${size}px`, + height: `${size}px`, + borderRadius: '24px', + overflow: 'hidden', + border: '1px solid rgba(255, 255, 255, 0.15)', + boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.37), 0 0 15px rgba(59, 130, 246, 0.2)', + backdropFilter: 'blur(8px)', + WebkitBackdropFilter: 'blur(8px)', + background: 'rgba(15, 23, 42, 0.6)', // Sleek dark slate + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + pointerEvents: 'none', + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + }), + canvas: (size: number): React.CSSProperties => ({ + width: `${size}px`, + height: `${size}px`, + display: 'block', + }), + statusText: { + color: '#94a3b8', + fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: '12px', + fontWeight: 500, + letterSpacing: '0.05em', + } as React.CSSProperties, +}; diff --git a/src/pathfinding/Grid.ts b/src/pathfinding/Grid.ts new file mode 100644 index 0000000..69f2198 --- /dev/null +++ b/src/pathfinding/Grid.ts @@ -0,0 +1,100 @@ +import type { GridNode } from './types'; + +export class Grid { + public width: number; + public height: number; + private nodes: GridNode[][]; + + constructor(walkableMatrix: boolean[][]) { + this.height = walkableMatrix.length; + this.width = this.height > 0 ? (walkableMatrix[0]?.length ?? 0) : 0; + this.nodes = []; + + for (let y = 0; y < this.height; y++) { + const row: GridNode[] = []; + const sourceRow = walkableMatrix[y]; + for (let x = 0; x < this.width; x++) { + row.push({ + x, + y, + walkable: sourceRow ? (sourceRow[x] ?? false) : false, + g: 0, + h: 0, + f: 0, + parent: null, + }); + } + this.nodes.push(row); + } + } + + public getNode(x: number, y: number): GridNode | null { + if (x >= 0 && x < this.width && y >= 0 && y < this.height) { + const row = this.nodes[y]; + return row ? (row[x] ?? null) : null; + } + return null; + } + + /** + * Resets g, h, f values and parents for all nodes in the grid, + * preparing it for a new A* calculation. + */ + public reset(): void { + for (let y = 0; y < this.height; y++) { + const row = this.nodes[y]; + if (!row) continue; + for (let x = 0; x < this.width; x++) { + const node = row[x]; + if (!node) continue; + node.g = 0; + node.h = 0; + node.f = 0; + node.parent = null; + } + } + } + + /** + * Retrieves neighboring nodes. Supports 8-directional movement. + */ + public getNeighbors(node: GridNode, allowDiagonals: boolean = true): GridNode[] { + const neighbors: GridNode[] = []; + const { x, y } = node; + + // Relative coordinates of 8 neighbors + const directions = [ + { dx: 0, dy: -1, isDiagonal: false }, // N + { dx: 1, dy: 0, isDiagonal: false }, // E + { dx: 0, dy: 1, isDiagonal: false }, // S + { dx: -1, dy: 0, isDiagonal: false }, // W + ]; + + if (allowDiagonals) { + directions.push( + { dx: 1, dy: -1, isDiagonal: true }, // NE + { dx: 1, dy: 1, isDiagonal: true }, // SE + { dx: -1, dy: 1, isDiagonal: true }, // SW + { dx: -1, dy: -1, isDiagonal: true } // NW + ); + } + + for (const dir of directions) { + const neighbor = this.getNode(x + dir.dx, y + dir.dy); + if (neighbor && neighbor.walkable) { + // Prevent corner cutting if both orthogonal neighbors are blocked + if (dir.isDiagonal) { + const ortho1 = this.getNode(x + dir.dx, y); + const ortho2 = this.getNode(x, y + dir.dy); + const isBlocked = (!ortho1 || !ortho1.walkable) && (!ortho2 || !ortho2.walkable); + if (isBlocked) { + continue; // Skip this diagonal neighbor to avoid squeezing through corners + } + } + neighbors.push(neighbor); + } + } + + return neighbors; + } +} diff --git a/src/pathfinding/ImageToGrid.ts b/src/pathfinding/ImageToGrid.ts new file mode 100644 index 0000000..b8370cf --- /dev/null +++ b/src/pathfinding/ImageToGrid.ts @@ -0,0 +1,75 @@ +import { Grid } from './Grid'; + +/** + * Loads an image from a URL. + */ +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; // Enable CORS just in case + img.onload = () => resolve(img); + img.onerror = (err) => reject(new Error(`Failed to load image at ${url}: ${err}`)); + img.src = url; + }); +} + +/** + * Loads a B&W image and scales it to gridWidth x gridHeight. + * Higher dimensions = higher accuracy but slower pathfinding. + * Lower dimensions = extremely fast pathfinding. + * + * Walkable roads should be white (or light gray). Non-walkable areas should be black. + * + * @param imageUrl The path or URL of the B&W navigation mask. + * @param gridWidth The target width of our A* pathfinding grid. + * @param gridHeight The target height of our A* pathfinding grid. + * @param threshold Brightness threshold (0-255) above which a pixel is considered walkable (default: 128). + */ +export async function createGridFromImage( + imageUrl: string, + gridWidth: number, + gridHeight: number, + threshold: number = 128 +): Promise { + const img = await loadImage(imageUrl); + + // Create an offscreen canvas to scale and analyze the image + const canvas = document.createElement('canvas'); + canvas.width = gridWidth; + canvas.height = gridHeight; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Could not get 2D context for offscreen canvas'); + } + + // Draw and scale the image onto the canvas + ctx.drawImage(img, 0, 0, gridWidth, gridHeight); + + // Retrieve pixel data + const imgData = ctx.getImageData(0, 0, gridWidth, gridHeight); + const data = imgData.data; + + // Initialize a 2D boolean matrix representing the walkable grid + const walkableMatrix: boolean[][] = []; + + for (let y = 0; y < gridHeight; y++) { + const row: boolean[] = []; + for (let x = 0; x < gridWidth; x++) { + // Each pixel has 4 channels: R, G, B, A + const index = (y * gridWidth + x) * 4; + const r = data[index] ?? 0; + const g = data[index + 1] ?? 0; + const b = data[index + 2] ?? 0; + + // Calculate brightness (standard grayscale weighting) + const brightness = 0.299 * r + 0.587 * g + 0.114 * b; + + // If bright enough, it is a road (walkable) + row.push(brightness >= threshold); + } + walkableMatrix.push(row); + } + + return new Grid(walkableMatrix); +} diff --git a/src/pathfinding/WaypointAStar.ts b/src/pathfinding/WaypointAStar.ts new file mode 100644 index 0000000..ac46bb4 --- /dev/null +++ b/src/pathfinding/WaypointAStar.ts @@ -0,0 +1,145 @@ +import type { Waypoint, WaypointNode } from './types'; + +/** + * Calculates Euclidean 3D distance between two points. + */ +function getDistance3D( + posA: { x: number; y: number; z: number }, + posB: { x: number; y: number; z: number } +): number { + return Math.sqrt( + Math.pow(posA.x - posB.x, 2) + + Math.pow(posA.y - posB.y, 2) + + Math.pow(posA.z - posB.z, 2) + ); +} + +/** + * Finds the closest Waypoint in a list to a target 3D world position. + */ +export function findClosestWaypoint( + waypoints: Waypoint[], + pos: { x: number; y: number; z: number } +): Waypoint | null { + if (waypoints.length === 0) return null; + + let closest = waypoints[0]!; + let minDist = getDistance3D(closest, pos); + + for (let i = 1; i < waypoints.length; i++) { + const wp = waypoints[i]!; + const dist = getDistance3D(wp, pos); + if (dist < minDist) { + minDist = dist; + closest = wp; + } + } + + return closest; +} + +/** + * Runs A* pathfinding on a network of 3D Waypoints. + * + * @param waypoints List of all waypoints in the road network. + * @param startWorldPos Player's current 3D world position. + * @param endWorldPos Targeted 3D world destination. + * @returns Array of Waypoints representing the path from start to end, or empty array if none found. + */ +export function findWaypointPath( + waypoints: Waypoint[], + startWorldPos: { x: number; y: number; z: number }, + endWorldPos: { x: number; y: number; z: number } +): Waypoint[] { + if (waypoints.length === 0) return []; + + // 1. Find the closest starting and ending waypoints in the network + const startWp = findClosestWaypoint(waypoints, startWorldPos); + const endWp = findClosestWaypoint(waypoints, endWorldPos); + + if (!startWp || !endWp) return []; + if (startWp.id === endWp.id) return [startWp]; + + // 2. Map all waypoints to A* search nodes + const nodeMap = new Map(); + waypoints.forEach((wp) => { + nodeMap.set(wp.id, { + ...wp, + g: Infinity, + h: Infinity, + f: Infinity, + parent: null, + }); + }); + + const startNode = nodeMap.get(startWp.id)!; + const endNode = nodeMap.get(endWp.id)!; + + // 3. Initialize open and closed sets + const openSet: WaypointNode[] = [startNode]; + const closedSet = new Set(); // Set of waypoint IDs + + startNode.g = 0; + startNode.h = getDistance3D(startNode, endNode); + startNode.f = startNode.h; + + while (openSet.length > 0) { + // Find node with lowest f score + let lowIndex = 0; + for (let i = 1; i < openSet.length; i++) { + const node = openSet[i]!; + const lowNode = openSet[lowIndex]!; + if (node.f < lowNode.f) { + lowIndex = i; + } + } + + const currentNode = openSet[lowIndex]!; + + // Reached destination! Reconstruct the path + if (currentNode.id === endNode.id) { + const path: Waypoint[] = []; + let temp: WaypointNode | null = currentNode; + while (temp !== null) { + // Find corresponding raw Waypoint + const rawWp = waypoints.find((w) => w.id === temp!.id); + if (rawWp) { + path.push(rawWp); + } + temp = temp.parent; + } + return path.reverse(); + } + + // Move from open to closed set + openSet.splice(lowIndex, 1); + closedSet.add(currentNode.id); + + // Process neighbors + for (const neighborId of currentNode.connections) { + if (closedSet.has(neighborId)) continue; + + const neighborNode = nodeMap.get(neighborId); + if (!neighborNode) continue; + + // Distance from currentNode to neighbor is physical 3D distance + const tentativeG = currentNode.g + getDistance3D(currentNode, neighborNode); + + let neighborInOpenSet = openSet.some((node) => node.id === neighborId); + + if (!neighborInOpenSet || tentativeG < neighborNode.g) { + neighborNode.parent = currentNode; + neighborNode.g = tentativeG; + neighborNode.h = getDistance3D(neighborNode, endNode); + neighborNode.f = neighborNode.g + neighborNode.h; + + if (!neighborInOpenSet) { + openSet.push(neighborNode); + } + } + } + } + + // No path found + return []; +} diff --git a/src/pathfinding/index.ts b/src/pathfinding/index.ts new file mode 100644 index 0000000..3960665 --- /dev/null +++ b/src/pathfinding/index.ts @@ -0,0 +1,10 @@ +export * from './types'; +export * from './Grid'; +export * from './AStar'; +export * from './ImageToGrid'; +export * from './useGPS'; +export * from './GPSMinimap'; +export * from './WaypointAStar'; +export * from './useWaypointGPS'; + + diff --git a/src/pathfinding/types.ts b/src/pathfinding/types.ts new file mode 100644 index 0000000..640b5ff --- /dev/null +++ b/src/pathfinding/types.ts @@ -0,0 +1,40 @@ +export interface Position { + x: number; + y: number; +} + +export interface GridNode { + x: number; + y: number; + walkable: boolean; + g: number; + h: number; + f: number; + parent: GridNode | null; +} + +export interface GridSize { + width: number; + height: number; +} + +export interface Waypoint { + id: number; + x: number; + y: number; + z: number; + connections: number[]; +} + +export interface WaypointNode { + id: number; + x: number; + y: number; + z: number; + connections: number[]; + g: number; + h: number; + f: number; + parent: WaypointNode | null; +} + diff --git a/src/pathfinding/useGPS.ts b/src/pathfinding/useGPS.ts new file mode 100644 index 0000000..11be545 --- /dev/null +++ b/src/pathfinding/useGPS.ts @@ -0,0 +1,243 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Grid } from './Grid'; +import { createGridFromImage } from './ImageToGrid'; +import { findPath } from './AStar'; +import type { Position } from './types'; + +export interface WorldBounds { + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +export interface UseGPSOptions { + bwMaskUrl: string; + colorMapUrl: string; + gridWidth: number; // The "width of the array pathfinding" (resolution scaling) + gridHeight: number; // The "height of the array pathfinding" + worldBounds: WorldBounds; + allowDiagonals?: boolean; +} + +export function useGPS({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + allowDiagonals = true, +}: UseGPSOptions) { + const [grid, setGrid] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Cache the images so they don't reload every frame + const colorMapImgRef = useRef(null); + + // Initialize the pathfinding grid + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + async function initGrid() { + try { + const pathfindingGrid = await createGridFromImage(bwMaskUrl, gridWidth, gridHeight); + + // Pre-load color map image for canvas drawing + const colorMapImg = new Image(); + colorMapImg.crossOrigin = 'anonymous'; + await new Promise((resolve, reject) => { + colorMapImg.onload = resolve; + colorMapImg.onerror = reject; + colorMapImg.src = colorMapUrl; + }); + + if (active) { + setGrid(pathfindingGrid); + colorMapImgRef.current = colorMapImg; + setLoading(false); + } + } catch (err: any) { + if (active) { + setError(err.message || 'Failed to initialize GPS system'); + setLoading(false); + } + } + } + + initGrid(); + + return () => { + active = false; + }; + }, [bwMaskUrl, colorMapUrl, gridWidth, gridHeight]); + + /** + * Translates 3D World coordinates (X, Z) into 2D Grid coordinates (col, row) + */ + const worldToGrid = useCallback( + (worldX: number, worldZ: number): Position => { + const { minX, maxX, minZ, maxZ } = worldBounds; + + // Calculate percentages across the bounds + const pctX = (worldX - minX) / (maxX - minX); + const pctZ = (worldZ - minZ) / (maxZ - minZ); + + // Map to grid dimensions + const gridX = Math.max(0, Math.min(gridWidth - 1, Math.floor(pctX * gridWidth))); + const gridY = Math.max(0, Math.min(gridHeight - 1, Math.floor(pctZ * gridHeight))); + + return { x: gridX, y: gridY }; + }, + [worldBounds, gridWidth, gridHeight] + ); + + /** + * Translates 2D Grid coordinates (col, row) back into 3D World coordinates (X, Z) + */ + const gridToWorld = useCallback( + (gridX: number, gridY: number): { x: number; z: number } => { + const { minX, maxX, minZ, maxZ } = worldBounds; + + const pctX = gridX / gridWidth; + const pctZ = gridY / gridHeight; + + const worldX = minX + pctX * (maxX - minX); + const worldZ = minZ + pctZ * (maxZ - minZ); + + return { x: worldX, z: worldZ }; + }, + [worldBounds, gridWidth, gridHeight] + ); + + /** + * Runs the A* calculation using 3D world coordinates. + * Returns path in 3D world space. + */ + const calculateWorldPath = useCallback( + (startWorld: { x: number; z: number }, endWorld: { x: number; z: number }): { x: number; z: number }[] => { + if (!grid) return []; + + const startGrid = worldToGrid(startWorld.x, startWorld.z); + const endGrid = worldToGrid(endWorld.x, endWorld.z); + + const gridPath = findPath(grid, startGrid, endGrid, allowDiagonals); + + // Convert path coordinates back to 3D space + return gridPath.map((node) => gridToWorld(node.x, node.y)); + }, + [grid, worldToGrid, gridToWorld, allowDiagonals] + ); + + /** + * Updates an HTML5 `` element with the background color map, + * a path line, and the player/destination indicators. + */ + const renderGPSToCanvas = useCallback( + ( + canvas: HTMLCanvasElement, + path: { x: number; z: number }[], + playerWorldPos?: { x: number; z: number }, + destWorldPos?: { x: number; z: number }, + options: { + pathColor?: string; + pathWidth?: number; + playerColor?: string; + playerSize?: number; + destColor?: string; + destSize?: number; + } = {} + ) => { + const ctx = canvas.getContext('2d'); + if (!ctx || !colorMapImgRef.current) return; + + const { + pathColor = '#3b82f6', // Premium blue + pathWidth = 6, + playerColor = '#ef4444', // Red dot for player + playerSize = 8, + destColor = '#10b981', // Green dot for flag + destSize = 8, + } = options; + + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + + // 1. Draw background color map + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight); + + // Helper: translate world coordinates to Canvas pixels + const worldToCanvas = (wx: number, wz: number): Position => { + const { minX, maxX, minZ, maxZ } = worldBounds; + const px = ((wx - minX) / (maxX - minX)) * canvasWidth; + const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight; + return { x: px, y: py }; + }; + + // 2. Draw A* Path Line + if (path.length > 1) { + ctx.beginPath(); + const startNode = path[0]!; + const startPt = worldToCanvas(startNode.x, startNode.z); + ctx.moveTo(startPt.x, startPt.y); + + for (let i = 1; i < path.length; i++) { + const node = path[i]!; + const pt = worldToCanvas(node.x, node.z); + ctx.lineTo(pt.x, pt.y); + } + + ctx.strokeStyle = pathColor; + ctx.lineWidth = pathWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Add a soft glow effect for premium feel + ctx.shadowBlur = 8; + ctx.shadowColor = pathColor; + ctx.stroke(); + + // Reset shadow for subsequent drawings + ctx.shadowBlur = 0; + } + + // 3. Draw Destination Indicator + if (destWorldPos) { + const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z); + ctx.beginPath(); + ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI); + ctx.fillStyle = destColor; + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + + // 4. Draw Player Indicator + if (playerWorldPos) { + const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z); + ctx.beginPath(); + ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI); + ctx.fillStyle = playerColor; + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + }, + [worldBounds] + ); + + return { + grid, + loading, + error, + calculateWorldPath, + renderGPSToCanvas, + worldToGrid, + gridToWorld, + }; +} diff --git a/src/pathfinding/useWaypointGPS.ts b/src/pathfinding/useWaypointGPS.ts new file mode 100644 index 0000000..9a363d2 --- /dev/null +++ b/src/pathfinding/useWaypointGPS.ts @@ -0,0 +1,212 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { findWaypointPath } from './WaypointAStar'; +import type { Waypoint } from './types'; +import type { WorldBounds } from './useGPS'; + +export interface UseWaypointGPSOptions { + roadNetworkUrl: string; // URL/Path to roadNetwork.json + colorMapUrl: string; // URL/Path to color_map.png + worldBounds: WorldBounds; +} + +export function useWaypointGPS({ + roadNetworkUrl, + colorMapUrl, + worldBounds, +}: UseWaypointGPSOptions) { + const [waypoints, setWaypoints] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const colorMapImgRef = useRef(null); + + // Load waypoint list and background color map image + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + async function initGPS() { + try { + // 1. Fetch the road network JSON + const response = await fetch(roadNetworkUrl); + if (!response.ok) { + throw new Error(`Failed to load road network from ${roadNetworkUrl}`); + } + const data: Waypoint[] = await response.json(); + + // 2. Pre-load the color map image + const colorMapImg = new Image(); + colorMapImg.crossOrigin = 'anonymous'; + await new Promise((resolve, reject) => { + colorMapImg.onload = resolve; + colorMapImg.onerror = reject; + colorMapImg.src = colorMapUrl; + }); + + if (active) { + setWaypoints(data); + colorMapImgRef.current = colorMapImg; + setLoading(false); + } + } catch (err: any) { + if (active) { + setError(err.message || 'Failed to initialize Waypoint GPS'); + setLoading(false); + } + } + } + + initGPS(); + + return () => { + active = false; + }; + }, [roadNetworkUrl, colorMapUrl]); + + /** + * Calculates the shortest path between start and end world points. + */ + const calculateRoute = useCallback( + ( + startWorld: { x: number; y: number; z: number }, + endWorld: { x: number; y: number; z: number } + ): Waypoint[] => { + if (waypoints.length === 0) return []; + return findWaypointPath(waypoints, startWorld, endWorld); + }, + [waypoints] + ); + + /** + * Renders the road network path, player position, and waypoint target onto a canvas. + */ + const renderGPSToCanvas = useCallback( + ( + canvas: HTMLCanvasElement, + path: Waypoint[], + playerWorldPos?: { x: number; y: number; z: number }, + destWorldPos?: { x: number; y: number; z: number }, + options: { + pathColor?: string; + pathWidth?: number; + playerColor?: string; + playerSize?: number; + destColor?: string; + destSize?: number; + showAllWaypoints?: boolean; // Debug mode + } = {} + ) => { + const ctx = canvas.getContext('2d'); + if (!ctx || !colorMapImgRef.current) return; + + const { + pathColor = '#10b981', // Premium emerald green + pathWidth = 6, + playerColor = '#ff0055', // Neon pink-red for bike + playerSize = 8, + destColor = '#00ffcc', // Neon cyan for target + destSize = 8, + showAllWaypoints = false, + } = options; + + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + + // 1. Draw color map background + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight); + + // Helper: translate world coordinates (X, Z) to Canvas pixels (x, y) + const worldToCanvas = (wx: number, wz: number) => { + const { minX, maxX, minZ, maxZ } = worldBounds; + const px = ((wx - minX) / (maxX - minX)) * canvasWidth; + const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight; + return { x: px, y: py }; + }; + + // 2. [Debug] Draw all network connections + if (showAllWaypoints && waypoints.length > 0) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 1.5; + const drawn = new Set(); + + waypoints.forEach((wp) => { + const startPt = worldToCanvas(wp.x, wp.z); + wp.connections.forEach((connId) => { + const other = waypoints.find((w) => w.id === connId); + if (other) { + const key = wp.id < other.id ? `${wp.id}-${other.id}` : `${other.id}-${wp.id}`; + if (!drawn.has(key)) { + drawn.add(key); + const endPt = worldToCanvas(other.x, other.z); + ctx.beginPath(); + ctx.moveTo(startPt.x, startPt.y); + ctx.lineTo(endPt.x, endPt.y); + ctx.stroke(); + } + } + }); + }); + } + + // 3. Draw calculated A* path line + if (path.length > 1) { + ctx.beginPath(); + const startNode = path[0]!; + const startPt = worldToCanvas(startNode.x, startNode.z); + ctx.moveTo(startPt.x, startPt.y); + + for (let i = 1; i < path.length; i++) { + const node = path[i]!; + const pt = worldToCanvas(node.x, node.z); + ctx.lineTo(pt.x, pt.y); + } + + ctx.strokeStyle = pathColor; + ctx.lineWidth = pathWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Add soft premium path glow + ctx.shadowBlur = 8; + ctx.shadowColor = pathColor; + ctx.stroke(); + ctx.shadowBlur = 0; // Reset + } + + // 4. Draw Destination target + if (destWorldPos) { + const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z); + ctx.beginPath(); + ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI); + ctx.fillStyle = destColor; + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + + // 5. Draw Player / Bike + if (playerWorldPos) { + const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z); + ctx.beginPath(); + ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI); + ctx.fillStyle = playerColor; + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + }, + [worldBounds, waypoints] + ); + + return { + waypoints, + loading, + error, + calculateRoute, + renderGPSToCanvas, + }; +} diff --git a/src/router.tsx b/src/router.tsx index 97fb9da..91a9b1d 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,6 +6,7 @@ import { } from "@tanstack/react-router"; import { HomePage } from "@/pages/page"; import { EditorPage } from "@/pages/editor/page"; +import { WaypointEditorPage } from "@/pages/waypoint/page"; import { DocsAnimationRoute, DocsAudioRoute, @@ -43,6 +44,12 @@ const editorRoute = createRoute({ component: EditorPage, }); +const waypointRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/waypoint", + component: WaypointEditorPage, +}); + const docsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/docs", @@ -78,6 +85,7 @@ const docsChildRoutes = [ const routeTree = rootRoute.addChildren([ indexRoute, editorRoute, + waypointRoute, docsRoute.addChildren(docsChildRoutes), ]);