diff --git a/ui/public/img/route_vector_icon.png b/ui/public/img/route_vector_icon.png new file mode 100644 index 0000000000..8b331f20d4 Binary files /dev/null and b/ui/public/img/route_vector_icon.png differ diff --git a/ui/src/components/map/DrawControl.tsx b/ui/src/components/map/DrawControl.tsx index 0e0cd17905..4c9adac169 100644 --- a/ui/src/components/map/DrawControl.tsx +++ b/ui/src/components/map/DrawControl.tsx @@ -8,6 +8,7 @@ import { } from 'react'; import { ControlPosition, IControl, useControl } from 'react-map-gl/maplibre'; import { styles } from './routes/editorStyles'; +import { joreDrawModes } from './utils/drawModeUtils'; type DrawControlProps = ConstructorParameters[0] & { readonly position?: ControlPosition; @@ -32,7 +33,12 @@ const DrawControlComponent: ForwardRefRenderFunction< > = (props, ref) => { const { onCreate, onModeChange, onUpdate, position } = props; const drawRef = useControl( - () => new MapboxDraw({ styles, ...props }) as MapLibreMapboxDraw, + () => + new MapboxDraw({ + styles, + ...props, + modes: joreDrawModes, + }) as MapLibreMapboxDraw, ({ map }) => { map.on('draw.create', onCreate); map.on('draw.update', onUpdate); diff --git a/ui/src/components/map/routes/RouteGeometryLayer.tsx b/ui/src/components/map/routes/RouteGeometryLayer.tsx index bfe5d5a6a8..a0e72901dd 100644 --- a/ui/src/components/map/routes/RouteGeometryLayer.tsx +++ b/ui/src/components/map/routes/RouteGeometryLayer.tsx @@ -1,6 +1,8 @@ import { FC } from 'react'; +import { useMap } from 'react-map-gl/maplibre'; import { theme } from '../../../generated/theme'; import { ArrowLayout, ArrowPaint, ArrowRenderLayer } from './ArrowRenderLayer'; +import { ACTIVE_LINE_STROKE_ID } from './editorStyles'; import { LinePaint, LineRenderLayer } from './LineRenderLayer'; import { NEW_ROUTE_ARROWS_ID, @@ -25,7 +27,12 @@ export const RouteGeometryLayer: FC = ({ defaultColor = colors.routes.bus, isHighlighted, }) => { - const beforeId = isHighlighted ? undefined : 'route_base'; + const { current: map } = useMap(); + const hasActiveLineStrokeLayer = !!map?.getLayer(ACTIVE_LINE_STROKE_ID); + const beforeId = + isHighlighted && hasActiveLineStrokeLayer + ? ACTIVE_LINE_STROKE_ID + : 'route_base'; const color = isHighlighted ? colors.selectedMapItem : defaultColor; diff --git a/ui/src/components/map/routes/Routes.tsx b/ui/src/components/map/routes/Routes.tsx index 26c8b1dd49..8eb141fb64 100644 --- a/ui/src/components/map/routes/Routes.tsx +++ b/ui/src/components/map/routes/Routes.tsx @@ -5,6 +5,7 @@ import { useAppSelector } from '../../../hooks'; import { Visible } from '../../../layoutComponents'; import { Mode, + selectEditedRouteData, selectHasDraftRouteGeometry, selectMapRouteEditor, selectSelectedRouteId, @@ -29,10 +30,14 @@ const RoutesImpl: ForwardRefRenderFunction = ( const hasDraftRouteGeometry = useAppSelector(selectHasDraftRouteGeometry); const { drawingMode, creatingNewRoute } = useAppSelector(selectMapRouteEditor); + const { id: editedRouteId } = useAppSelector(selectEditedRouteData); + const isEditingExistingRoute = drawingMode === Mode.Edit && !creatingNewRoute; - const renderedRouteIds = selectedRouteId - ? uniq([...displayedRouteIds, selectedRouteId]) - : displayedRouteIds; + const renderedRouteIds = uniq([ + ...displayedRouteIds, + ...(selectedRouteId ? [selectedRouteId] : []), + ...(isEditingExistingRoute && editedRouteId ? [editedRouteId] : []), + ]); return ( <> @@ -54,8 +59,9 @@ const RoutesImpl: ForwardRefRenderFunction = ( key={item} routeId={item} isSelected={ - selectedRouteId === item && - ((drawingMode === Mode.Edit && !creatingNewRoute) || + (isEditingExistingRoute && editedRouteId === item) || + (!isEditingExistingRoute && + selectedRouteId === item && !hasDraftRouteGeometry) } /> diff --git a/ui/src/components/map/routes/editorStyles.ts b/ui/src/components/map/routes/editorStyles.ts index 55e074ed96..b87a29d1ee 100644 --- a/ui/src/components/map/routes/editorStyles.ts +++ b/ui/src/components/map/routes/editorStyles.ts @@ -1,5 +1,4 @@ -const FEATURE_INACTIVE_COLOR = '#696969'; // Dark grey -const FEATURE_ACTIVE_COLOR = FEATURE_INACTIVE_COLOR; +import { theme } from '../../../generated/theme'; export const ACTIVE_LINE_STROKE_ID = 'active-line-stroke'; @@ -13,57 +12,31 @@ export const styles = [ 'line-join': 'round', }, paint: { - 'line-color': FEATURE_INACTIVE_COLOR, - 'line-dasharray': [1.2, 0.8], - 'line-width': 8, + 'line-color': theme.colors.hslDark80, + 'line-dasharray': [1, 0.5], + 'line-width': 2, }, }, { id: 'gl-draw-polygon-midpoint', - type: 'circle', + type: 'symbol', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], - paint: { - 'circle-radius': 8, - 'circle-color': FEATURE_INACTIVE_COLOR, - }, - }, - { - id: 'gl-draw-line-vertex-halo-inactive', - type: 'circle', - filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point']], - paint: { - 'circle-radius': 12, - 'circle-color': FEATURE_INACTIVE_COLOR, + layout: { + 'icon-image': 'route_vector_icon', + 'icon-size': ['interpolate', ['linear'], ['zoom'], 8, 0.11, 16, 0.2], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, }, }, { id: 'gl-draw-line-vertex-inactive', - type: 'circle', - filter: [ - 'all', - ['==', 'meta', 'vertex'], - ['==', '$type', 'Point'], - ['!=', 'mode', 'static'], - ['!=', 'mode', 'simple_select'], - ], - paint: { - 'circle-radius': 9, - 'circle-color': 'white', - }, - }, - { - id: 'gl-draw-line-vertex-active', - type: 'circle', - filter: [ - 'all', - ['==', 'meta', 'vertex'], - ['==', '$type', 'Point'], - ['!=', 'mode', 'static'], - ['==', 'active', 'true'], - ], - paint: { - 'circle-radius': 4, - 'circle-color': FEATURE_ACTIVE_COLOR, + type: 'symbol', + filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point']], + layout: { + 'icon-image': 'route_vector_icon', + 'icon-size': ['interpolate', ['linear'], ['zoom'], 8, 0.17, 16, 0.27], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, }, }, ]; diff --git a/ui/src/components/map/utils/drawModeUtils.ts b/ui/src/components/map/utils/drawModeUtils.ts new file mode 100644 index 0000000000..50cd659a57 --- /dev/null +++ b/ui/src/components/map/utils/drawModeUtils.ts @@ -0,0 +1,81 @@ +import MapboxDraw, { + DrawCustomMode, + DrawCustomModeThis, + MapMouseEvent, + MapTouchEvent, + MapboxDrawOptions, +} from '@mapbox/mapbox-gl-draw'; + +const defaultModes = MapboxDraw.modes; +const directSelectMode = defaultModes.direct_select; +const simpleSelectMode = defaultModes.simple_select; + +type ObjectLikeState = { [key: string]: unknown }; + +// Change from simple_select to direct_select on feature click, to immediately show vertices with mid-points. +// Default behavior is to first show vertices, then it requires another click to enter direct_select and show mid-points. +// Internally onTap = onClick = function implementation()... by default. +function onSimpleModeClick( + this: DrawCustomModeThis & DrawCustomMode, + state: ObjectLikeState, + e: MapMouseEvent | MapTouchEvent, +) { + const isFeature = MapboxDraw.lib.CommonSelectors.isFeature(e); + const featureId = e.featureTarget?.properties?.id; + + if (isFeature && featureId) { + this.changeMode('direct_select', { featureId }); + } else { + simpleSelectMode.onClick?.call(this, state, e as MapMouseEvent); + } +} + +// Disable vertex selection on click, enforce drag-only behavior. +// This avoids reloading the edited line when clicking vertices. +// Internally onTouchStart = onMouseDown = function implementation()... by default. +function onDirectModeMouseDown( + this: DrawCustomModeThis & DrawCustomMode, + state: ObjectLikeState, + e: MapMouseEvent | MapTouchEvent, +) { + const isVertex = MapboxDraw.lib.CommonSelectors.isVertex(e); + + // This replaces onVertex code path from: + // https://github.com/mapbox/mapbox-gl-draw/blob/eb42344e32ec884c6f15fe483ad1c9311c309a36/src/modes/direct_select.js#L51 + if (isVertex) { + // Drag-only behavior: keep one vertex as drag target, skip click-selection visuals. + const coordPath = e.featureTarget.properties?.coord_path; + if (coordPath) { + state.selectedCoordPaths = [coordPath]; + + // This calls a private function from the internal implementation from: + // https://github.com/mapbox/mapbox-gl-draw/blob/eb42344e32ec884c6f15fe483ad1c9311c309a36/src/modes/direct_select.js#L30 + if ( + 'startDragging' in directSelectMode && + typeof directSelectMode.startDragging === 'function' + ) { + directSelectMode.startDragging.call(this, state, e); + } else { + throw new Error( + 'mapbox-gl-draw DirectDrawMode internal implementation changed! Expected to find function startDragging, but it does not exist!', + ); + } + } + } else { + directSelectMode.onMouseDown?.call(this, state, e as MapMouseEvent); + } +} + +export const joreDrawModes: MapboxDrawOptions['modes'] = { + ...defaultModes, + simple_select: { + ...simpleSelectMode, + onClick: onSimpleModeClick, + onTap: onSimpleModeClick, + }, + direct_select: { + ...directSelectMode, + onMouseDown: onDirectModeMouseDown, + onTouchStart: onDirectModeMouseDown, + }, +}; diff --git a/ui/src/utils/map/mapUtils.ts b/ui/src/utils/map/mapUtils.ts index 89b095de70..746f116e6c 100644 --- a/ui/src/utils/map/mapUtils.ts +++ b/ui/src/utils/map/mapUtils.ts @@ -6,6 +6,17 @@ import { MapInstance, MapRef } from 'react-map-gl/maplibre'; import { isRouteGeometryLayer } from '../../components/map/routes/utils'; import { theme } from '../../generated/theme'; +type ImageAsset = { name: string; fileUrl: string; sdf: boolean }; + +const imageAssets: ReadonlyArray = [ + { name: 'arrow', fileUrl: '/img/arrow-right.png', sdf: true }, + { + name: 'route_vector_icon', + fileUrl: '/img/route_vector_icon.png', + sdf: false, + }, +]; + export const removeLayer = (map: MapInstance, id: string) => { if (map.getLayer(id)) { map.removeLayer(id); @@ -28,10 +39,6 @@ export const loadMapAssets = (mapRef: RefObject) => { return; } - const imageAssets: { name: string; fileUrl: string }[] = [ - { name: 'arrow', fileUrl: '/img/arrow-right.png' }, - ]; - imageAssets.forEach(async (asset) => { if (map.hasImage(asset.name)) { return; @@ -42,9 +49,7 @@ export const loadMapAssets = (mapRef: RefObject) => { // we need to check it again here because this code is inside event callback // which could be called multiple times before the map actually has the image if (!map.hasImage(asset.name)) { - // Enable Signed Distance Fields (sdf) to make enable icon coloring. - // https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/ - map.addImage(asset.name, response.data, { sdf: true }); + map.addImage(asset.name, response.data, { sdf: asset.sdf }); } }); };