Skip to content

Commit 67ac12d

Browse files
committed
feat: implement offroad detour visualization on the map
- Added a new pinkDetourLeaflet module to handle offroad detour rendering on the map. - Introduced a tooltip style for offroad routes and integrated it into the map rendering logic. - Updated MapPage and PinkLineMapPage to utilize the new detour visualization feature. - Enhanced route persistence logic to accommodate offroad detour data.
1 parent 3d6892f commit 67ac12d

11 files changed

Lines changed: 420 additions & 100 deletions

File tree

src/index.css

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,61 @@ a:hover {
159159
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
160160
}
161161

162+
.pink-offroad-junction-node {
163+
width: 22px;
164+
height: 22px;
165+
border-radius: 50%;
166+
background-color: #e53935;
167+
border: 2px solid white;
168+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
169+
box-sizing: border-box;
170+
transition:
171+
transform 0.12s ease,
172+
box-shadow 0.12s ease;
173+
}
174+
175+
.pink-offroad-junction-node:hover {
176+
transform: scale(1.06);
177+
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.35);
178+
}
179+
180+
.leaflet-tooltip.pink-offroad-route-tooltip {
181+
max-width: min(288px, calc(100vw - 20px)) !important;
182+
min-width: min(240px, calc(100vw - 24px)) !important;
183+
width: max-content !important;
184+
margin: 0 !important;
185+
padding: 0 !important;
186+
white-space: normal !important;
187+
word-break: normal;
188+
overflow-wrap: break-word;
189+
box-sizing: border-box !important;
190+
direction: rtl;
191+
text-align: right;
192+
background: rgba(255, 248, 248, 0.97) !important;
193+
border: 1px solid #c62828 !important;
194+
border-radius: 8px !important;
195+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18) !important;
196+
color: #1a1a1a !important;
197+
font-size: 12.5px !important;
198+
line-height: 1.45 !important;
199+
}
200+
201+
.pink-offroad-route-tooltip-inner {
202+
display: block;
203+
padding: 8px 12px 9px;
204+
box-sizing: border-box;
205+
min-width: 0;
206+
max-width: 100%;
207+
}
208+
209+
.pink-offroad-route-tooltip-line {
210+
display: block;
211+
}
212+
213+
.pink-offroad-route-tooltip-line + .pink-offroad-route-tooltip-line {
214+
margin-top: 0.4em;
215+
}
216+
162217
.memorial-marker {
163218
background: transparent;
164219
border: none;
@@ -989,7 +1044,9 @@ a:hover {
9891044
box-shadow: none;
9901045
line-height: 1;
9911046
font-family: inherit;
992-
transition: background-color 0.15s ease, border-color 0.15s ease;
1047+
transition:
1048+
background-color 0.15s ease,
1049+
border-color 0.15s ease;
9931050
border-radius: 8px;
9941051
caret-color: rgba(255, 255, 255, 0.98);
9951052
}
@@ -1026,7 +1083,9 @@ a:hover {
10261083
box-shadow: none;
10271084
line-height: 1.45;
10281085
font-family: inherit;
1029-
transition: background-color 0.15s ease, border-color 0.15s ease;
1086+
transition:
1087+
background-color 0.15s ease,
1088+
border-color 0.15s ease;
10301089
border-radius: 8px;
10311090
overflow-y: auto;
10321091
caret-color: rgba(255, 255, 255, 0.98);
@@ -1063,7 +1122,9 @@ a:hover {
10631122
font-weight: 600;
10641123
padding: 0 14px;
10651124
cursor: pointer;
1066-
transition: background-color 0.15s ease, border-color 0.15s ease;
1125+
transition:
1126+
background-color 0.15s ease,
1127+
border-color 0.15s ease;
10671128
border-radius: 8px;
10681129
min-width: 76px;
10691130
height: 38px;
@@ -1470,7 +1531,11 @@ a:hover {
14701531
}
14711532

14721533
.unified-submit-action-primary:disabled {
1473-
background: linear-gradient(135deg, rgba(79, 193, 119, 0.55), rgba(44, 169, 95, 0.55)) !important;
1534+
background: linear-gradient(
1535+
135deg,
1536+
rgba(79, 193, 119, 0.55),
1537+
rgba(44, 169, 95, 0.55)
1538+
) !important;
14741539
}
14751540

14761541
/* MapPage: submission batch selector + unified submit name (glass / RTL / form-safe) */
@@ -1701,8 +1766,16 @@ input.unified-submit-name-input,
17011766
align-items: center;
17021767
justify-content: center;
17031768
background:
1704-
radial-gradient(circle at 15% 20%, rgba(255, 255, 255, 0.09), transparent 36%),
1705-
radial-gradient(circle at 82% 80%, rgba(255, 255, 255, 0.06), transparent 40%),
1769+
radial-gradient(
1770+
circle at 15% 20%,
1771+
rgba(255, 255, 255, 0.09),
1772+
transparent 36%
1773+
),
1774+
radial-gradient(
1775+
circle at 82% 80%,
1776+
rgba(255, 255, 255, 0.06),
1777+
transparent 40%
1778+
),
17061779
linear-gradient(160deg, #0a0a0b 0%, #111214 48%, #090a0c 100%);
17071780
overflow: hidden;
17081781
}
@@ -1766,7 +1839,11 @@ input.unified-submit-name-input,
17661839
position: absolute;
17671840
inset: 21px;
17681841
border-radius: 999px;
1769-
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.07) 70%);
1842+
background: radial-gradient(
1843+
circle at 30% 30%,
1844+
rgba(255, 255, 255, 0.16),
1845+
rgba(255, 255, 255, 0.07) 70%
1846+
);
17701847
border: 1px solid rgba(255, 255, 255, 0.32);
17711848
box-shadow:
17721849
0 0 22px rgba(255, 255, 255, 0.2),
@@ -1780,7 +1857,8 @@ input.unified-submit-name-input,
17801857
width: 28px;
17811858
height: 28px;
17821859
object-fit: contain;
1783-
filter: grayscale(1) contrast(1.1) brightness(1.08) drop-shadow(0 0 6px rgba(255, 255, 255, 0.35));
1860+
filter: grayscale(1) contrast(1.1) brightness(1.08)
1861+
drop-shadow(0 0 6px rgba(255, 255, 255, 0.35));
17841862
animation: loader-logo-counter-rotate 1.6s linear infinite;
17851863
}
17861864

@@ -2047,7 +2125,9 @@ input.unified-submit-name-input,
20472125
left: 50%;
20482126
right: auto;
20492127
transform: translateX(-50%);
2050-
max-width: calc(100vw - var(--map-safe-left) - var(--map-safe-right) - 16px);
2128+
max-width: calc(
2129+
100vw - var(--map-safe-left) - var(--map-safe-right) - 16px
2130+
);
20512131
box-sizing: border-box;
20522132
}
20532133
}

src/map/pinkDetourDashStyle.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { PolylineOptions } from "leaflet";
2+
3+
export const pinkDetourGoogleDashedStyle: PolylineOptions = {
4+
color: "#FF69B4",
5+
weight: 5,
6+
opacity: 0.9,
7+
dashArray: "12 12",
8+
lineCap: "butt",
9+
lineJoin: "miter",
10+
};

src/map/pinkDetourLeaflet.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import L from "leaflet";
2+
import type { DetourPaintPiece } from "../utils/pinkLineRoute";
3+
4+
const PANE_OFFROAD_LINE = "pinkOffroadLinePane";
5+
6+
function ensureOffroadLinePane(map: L.Map) {
7+
if (!map.getPane(PANE_OFFROAD_LINE)) {
8+
const linePane = map.createPane(PANE_OFFROAD_LINE);
9+
linePane.style.zIndex = "550";
10+
linePane.style.pointerEvents = "auto";
11+
}
12+
}
13+
14+
const OFF_ROAD_LINE: L.PolylineOptions = {
15+
color: "#C62828",
16+
weight: 4,
17+
opacity: 0.95,
18+
dashArray: "6 10",
19+
lineCap: "round",
20+
pane: PANE_OFFROAD_LINE,
21+
};
22+
23+
function junctionDivIcon() {
24+
return L.divIcon({
25+
className: "pink-line-node-marker",
26+
html: '<div class="pink-offroad-junction-node" aria-hidden="true"></div>',
27+
iconSize: [22, 22],
28+
iconAnchor: [11, 11],
29+
});
30+
}
31+
32+
const OFF_ROAD_TOOLTIP_HTML = `<div class="pink-offroad-route-tooltip-inner" dir="rtl"><span class="pink-offroad-route-tooltip-line">אין כאן דרך רשמית ברשת הניווט.</span><span class="pink-offroad-route-tooltip-line">הקו האדום המקווקו מסמן הליכה ישירה לנקודה (ברגל).</span></div>`;
33+
34+
export function addDetourPaintToMap(
35+
map: L.Map,
36+
pieces: DetourPaintPiece[],
37+
dashedStyle: L.PolylineOptions,
38+
layersOut: L.Layer[]
39+
): void {
40+
ensureOffroadLinePane(map);
41+
42+
const offroadPieces: Extract<DetourPaintPiece, { kind: "offroad" }>[] = [];
43+
44+
for (const piece of pieces) {
45+
if (piece.kind === "road") {
46+
const pl = L.polyline(piece.points as L.LatLngExpression[], dashedStyle).addTo(map);
47+
layersOut.push(pl);
48+
} else {
49+
offroadPieces.push(piece);
50+
}
51+
}
52+
53+
for (const piece of offroadPieces) {
54+
const line = L.polyline(
55+
[piece.roadEnd, piece.target] as L.LatLngExpression[],
56+
OFF_ROAD_LINE
57+
).addTo(map);
58+
line.bindTooltip(OFF_ROAD_TOOLTIP_HTML, {
59+
sticky: true,
60+
direction: "top",
61+
className: "pink-offroad-route-tooltip",
62+
});
63+
line.on("mouseover", () => {
64+
line.setStyle({ weight: 7, opacity: 1, color: "#FF5252" });
65+
line.bringToFront();
66+
});
67+
line.on("mouseout", () => {
68+
line.setStyle(OFF_ROAD_LINE);
69+
});
70+
layersOut.push(line);
71+
}
72+
73+
for (const piece of offroadPieces) {
74+
const junction = L.marker(piece.roadEnd as L.LatLngExpression, {
75+
icon: junctionDivIcon(),
76+
keyboard: false,
77+
riseOnHover: true,
78+
}).addTo(map);
79+
junction.bindTooltip(OFF_ROAD_TOOLTIP_HTML, {
80+
sticky: true,
81+
direction: "top",
82+
className: "pink-offroad-route-tooltip",
83+
});
84+
layersOut.push(junction);
85+
}
86+
}

src/pages/MapPage/PinkLineMapPage.tsx

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
parseDefaultLinePaths,
1515
buildIntegratedRouteWithGoogleDetours,
1616
IntegratedRoute,
17+
flattenIntegratedRouteForPersistence,
1718
} from "../../utils/pinkLineRoute";
19+
import { addDetourPaintToMap } from "../../map/pinkDetourLeaflet";
20+
import { pinkDetourGoogleDashedStyle } from "../../map/pinkDetourDashStyle";
1821
import supabase from "../../supabase";
1922
import PinkLineNodeForm from "./PinkLineNodeForm";
2023
import PinkRouteFetchingBanner from "./PinkRouteFetchingBanner";
@@ -36,23 +39,7 @@ interface PinkLineNode {
3639
}
3740

3841
function flattenSegmentsForPersistence(route: IntegratedRoute): Array<[number, number]> {
39-
const source = route.dashed.length > 0 ? route.dashed : route.solid;
40-
const flattened: Array<[number, number]> = [];
41-
for (const segment of source) {
42-
if (flattened.length === 0) {
43-
flattened.push(...segment);
44-
continue;
45-
}
46-
if (segment.length === 0) continue;
47-
const [lastLat, lastLng] = flattened[flattened.length - 1];
48-
const [firstLat, firstLng] = segment[0];
49-
if (lastLat === firstLat && lastLng === firstLng) {
50-
flattened.push(...segment.slice(1));
51-
} else {
52-
flattened.push(...segment);
53-
}
54-
}
55-
return flattened;
42+
return flattenIntegratedRouteForPersistence(route);
5643
}
5744

5845
const PinkLineMapPage = () => {
@@ -63,7 +50,7 @@ const PinkLineMapPage = () => {
6350
const routeLineRef = useRef<L.Polyline | null>(null);
6451
const defaultLinePathsRef = useRef<[number, number][][]>([]);
6552
// Single list of all route polylines on the map (solid + dashed)
66-
const routeLayersRef = useRef<L.Polyline[]>([]);
53+
const routeLayersRef = useRef<L.Layer[]>([]);
6754
const [nodes, setNodes] = useState<PinkLineNode[]>([]);
6855
const [isSubmitting, setIsSubmitting] = useState(false);
6956
const [pendingNode, setPendingNode] = useState<{ lat: number; lng: number } | null>(null);
@@ -84,7 +71,11 @@ const PinkLineMapPage = () => {
8471
// Nuke every route polyline from the map. Called before every re-render.
8572
const clearAllRouteLayers = (map: L.Map) => {
8673
for (const layer of routeLayersRef.current) {
87-
try { map.removeLayer(layer); } catch (_) { /* already gone */ }
74+
try {
75+
map.removeLayer(layer);
76+
} catch (_) {
77+
/* already gone */
78+
}
8879
}
8980
routeLayersRef.current = [];
9081
if (routeLineRef.current) {
@@ -288,7 +279,7 @@ const PinkLineMapPage = () => {
288279
if (integratedRoute) {
289280
const { solid, dashed, removed } = integratedRoute;
290281
const solidStyle: L.PolylineOptions = { color: "#FF69B4", weight: 5, opacity: 0.9 };
291-
const dashedStyle: L.PolylineOptions = { color: "#FF69B4", weight: 5, opacity: 0.9, dashArray: "10, 10" };
282+
const dashedStyle = pinkDetourGoogleDashedStyle;
292283
const removedStyle: L.PolylineOptions = { color: "#FF69B4", weight: 5, opacity: 0.6 };
293284
const showUserDetours = nodes.length > 0;
294285

@@ -303,31 +294,26 @@ const PinkLineMapPage = () => {
303294
routeLayersRef.current.push(layer);
304295
}
305296
if (showUserDetours) {
306-
for (const pts of dashed) {
307-
const layer = L.polyline(pts as L.LatLngExpression[], dashedStyle).addTo(map);
308-
routeLayersRef.current.push(layer);
297+
if (integratedRoute.detourPaint && integratedRoute.detourPaint.length > 0) {
298+
addDetourPaintToMap(map, integratedRoute.detourPaint, dashedStyle, routeLayersRef.current);
299+
} else {
300+
for (const pts of dashed) {
301+
const layer = L.polyline(pts as L.LatLngExpression[], dashedStyle).addTo(map);
302+
routeLayersRef.current.push(layer);
303+
}
309304
}
310305
}
311306
}
312307

313-
// Step 4: Double-check — count pink layers actually on the map
314-
let pinkLayerCount = 0;
308+
const refSet = new Set(routeLayersRef.current);
309+
let attached = 0;
315310
map.eachLayer((layer) => {
316-
if (layer instanceof L.Polyline && !(layer instanceof L.Polygon)) {
317-
pinkLayerCount++;
318-
}
311+
if (refSet.has(layer)) attached++;
319312
});
320-
const expectedCount = routeLayersRef.current.length + (routeLineRef.current ? 1 : 0);
321-
if (pinkLayerCount !== expectedCount) {
322-
console.warn(`[PinkLine] LAYER MISMATCH: ${pinkLayerCount} polylines on map, expected ${expectedCount}. Forcing cleanup.`);
323-
map.eachLayer((layer) => {
324-
if (layer instanceof L.Polyline && !(layer instanceof L.Polygon) &&
325-
!routeLayersRef.current.includes(layer as L.Polyline) &&
326-
layer !== routeLineRef.current) {
327-
map.removeLayer(layer);
328-
console.warn(`[PinkLine] Removed orphan polyline layer`);
329-
}
330-
});
313+
if (attached !== refSet.size) {
314+
console.warn(
315+
`[PinkLine] LAYER MISMATCH: ${attached} managed layers still on map, expected ${refSet.size}.`
316+
);
331317
}
332318

333319
if (nodes.length === 0) return;

0 commit comments

Comments
 (0)