Skip to content

Commit

Permalink
Single step view (#397)
Browse files Browse the repository at this point in the history
A usable view that allows you to step through each instruction step (for a bike leg) and each stop that a transit leg passes through.

Fixes #396
  • Loading branch information
graue authored Dec 18, 2024
1 parent 9e6d098 commit 3954bc1
Show file tree
Hide file tree
Showing 22 changed files with 775 additions and 209 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@radix-ui/react-radio-group": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle-group": "^1.0.4",
"@turf/bbox": "^7.1.0",
"@turf/bezier-spline": "^7.0.0",
"@turf/boolean-point-in-polygon": "^7.0.0",
"@turf/buffer": "^7.0.0",
Expand Down
187 changes: 156 additions & 31 deletions src/components/BikeHopperMap.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import classnames from 'classnames';
import type {
ExpressionFilterSpecification,
ExpressionSpecification,
Expand All @@ -19,6 +20,7 @@ import MapGL, {
} from 'react-map-gl/maplibre';
import type {
GeolocateResultEvent,
LngLatBoundsLike,
LngLatLike,
MapLayerMouseEvent,
MapLayerTouchEvent,
Expand All @@ -27,6 +29,11 @@ import type {
ViewStateChangeEvent,
} from 'react-map-gl/maplibre';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import turfBbox from '@turf/bbox';
import turfLength from '@turf/length';
import lineSliceAlong from '@turf/line-slice-along';
import { lineString } from '@turf/helpers';
import InstructionIcon from './InstructionIcon';
import {
routesToGeoJSON,
EMPTY_GEOJSON,
Expand Down Expand Up @@ -59,6 +66,9 @@ import {
} from '../lib/colors';
import { RouteResponsePath } from '../lib/BikeHopperClient';

import LogInIcon from 'iconoir/icons/log-in.svg?react';
import LogOutIcon from 'iconoir/icons/log-out.svg?react';

const _isTouch = 'ontouchstart' in window;

type Props = {
Expand Down Expand Up @@ -363,6 +373,8 @@ const BikeHopperMap = forwardRef(function BikeHopperMapInternal(
),
);

const prevViewingStep = usePrevious(viewingStep);

// Center viewport on points or routes
useLayoutEffect(() => {
const map = mapRef.current?.getMap();
Expand All @@ -375,7 +387,8 @@ const BikeHopperMap = forwardRef(function BikeHopperMapInternal(
routes && routeStatus === 'succeeded' && prevRouteStatus !== 'succeeded';
const newlyFetching =
routeStatus === 'fetching' && prevRouteStatus !== 'fetching';
if (!(haveNewRoutes || newlyFetching)) return;
const exitedSingleStep = Boolean(prevViewingStep && !viewingStep);
if (!(haveNewRoutes || newlyFetching || exitedSingleStep)) return;

// Start with the points themselves
let bbox: Bbox = [
Expand All @@ -386,7 +399,17 @@ const BikeHopperMap = forwardRef(function BikeHopperMapInternal(
];

// If we have routes, merge all route bounding boxes
const routeBboxes = (routes || []).map(
let routesToCenter: typeof routes = [];
if (routes) {
if (exitedSingleStep && activePath != null) {
// Center only the route you were just viewing in single-step mode.
routesToCenter = [routes[activePath]];
} else {
routesToCenter = routes;
}
}

const routeBboxes = routesToCenter.map(
(path: RouteResponsePath) => path.bbox,
);
bbox = routeBboxes.reduce(
Expand All @@ -399,22 +422,7 @@ const BikeHopperMap = forwardRef(function BikeHopperMapInternal(
bbox,
);

const padding = {
top: 40,
left: 40,
right: 40,
bottom: 40,
};
const clientRect = overlayEl.getBoundingClientRect();
padding.top += clientRect.top;
// When the bottom drawer first appears, it should be adjusted to this
// height. (That scroll can happen either before or after this code is
// executed.) Note that this sometimes leaves more space than needed
// because the bottom drawer's actual height may be less than the
// default height if there are only 1 or 2 routes. We might ideally
// prefer to make sure the scroll happened first, and then measure the
// bottom drawer.
padding.bottom += BOTTOM_DRAWER_DEFAULT_SCROLL + BOTTOM_DRAWER_MIN_HEIGHT;
const padding = getPaddingForMap(overlayEl);

// If we only have points, no route yet, then don't zoom if the current
// view already reasonably shows those points.
Expand Down Expand Up @@ -465,6 +473,9 @@ const BikeHopperMap = forwardRef(function BikeHopperMapInternal(
routeStatus,
prevRouteStatus,
isDragging,
activePath,
viewingStep,
prevViewingStep,
]);

// When viewing a specific step of a route, zoom to where it starts.
Expand All @@ -474,30 +485,122 @@ const BikeHopperMap = forwardRef(function BikeHopperMapInternal(
activePath == null ||
!viewingDetails ||
!viewingStep ||
!mapRef.current
!mapRef.current ||
!props.overlayRef.current
)
return;

const MAX_ZOOM = 18;
const map = mapRef.current.getMap();
const padding = getPaddingForMap(props.overlayRef.current);

const [legIdx, stepIdx] = viewingStep;

const leg = routes[activePath].legs[legIdx];
let stepLngLat;
if (leg.type === 'pt') {
// Leg is a transit leg; zoom to a transit stop
stepLngLat = leg.stops[stepIdx].geometry.coordinates;
const stepLngLat = leg.stops[stepIdx].geometry.coordinates;
map.easeTo({
center: stepLngLat as LngLatLike,
zoom: MAX_ZOOM,
});
} else {
// Leg is a bike leg (maybe we'll support walk in the future?);
// zoom to the start point of the given instruction
const stepStartPointIdx = leg.instructions[stepIdx].interval[0];
stepLngLat = leg.geometry.coordinates[stepStartPointIdx];
// Leg is a bike leg (maybe we'll support walk in the future?).

// Zoom to fit the start of this instruction step, as well as the first
// bit of the step:
const DISTANCE_TO_FIT = 0.1; // show up to 100m of the step.

const stepGeometry = lineString(
leg.geometry.coordinates.slice(
leg.instructions[stepIdx].interval[0],
leg.instructions[stepIdx].interval[1] + 1,
),
);

let legSegment = stepGeometry;
if (turfLength(stepGeometry) > DISTANCE_TO_FIT) {
legSegment = lineSliceAlong(stepGeometry, 0, DISTANCE_TO_FIT);
}

// We still want to center the first point on the leg, so mirror the
// leg around the first point.
const firstPointOnLeg = stepGeometry.geometry.coordinates[0];
const mirroredLegSegment = lineString(
legSegment.geometry.coordinates.map((point) => {
const xDiff = point[0] - firstPointOnLeg[0];
const yDiff = point[1] - firstPointOnLeg[1];
return [firstPointOnLeg[0] - xDiff, firstPointOnLeg[1] - yDiff];
}),
);

const camera = map.cameraForBounds(
turfBbox({
type: 'FeatureCollection',
features: [legSegment, mirroredLegSegment],
}) as LngLatBoundsLike,
{ padding },
);
if (!camera) return; // shouldn't happen in practice

map.easeTo({
center: camera.center,
zoom: Math.min(camera.zoom, MAX_ZOOM),
});
}
}, [
routes,
activePath,
viewingDetails,
viewingStep,
mapRef,
props.overlayRef,
]);

const map = mapRef.current.getMap();
map.easeTo({
center: stepLngLat as LngLatLike,
zoom: 18,
});
}, [routes, activePath, viewingDetails, viewingStep, mapRef]);
let viewingStepMarker: React.ReactNode | undefined;
if (routes && activePath && viewingStep) {
const viewingLeg = routes[activePath].legs[viewingStep[0]];
const iconClasses = 'bg-slate-100 text-slate-900 rounded-md shadow-md';

let coords: GeoJSON.Position | undefined;
let icon: React.ReactNode | undefined;

if (viewingLeg.type === 'pt') {
const stopIdx = viewingStep[1];
coords = viewingLeg.stops[stopIdx].geometry.coordinates;
const isBoard = stopIdx === 0;
const isAlight = stopIdx + 1 === viewingLeg.stops.length;
const IconComponent = isBoard ? LogInIcon : isAlight ? LogOutIcon : null;
if (IconComponent) {
icon = (
<IconComponent
width={32}
height={32}
className={classnames(iconClasses, 'p-1')}
/>
);
}
} else if (viewingLeg.type === 'bike2') {
const instruction = viewingLeg.instructions[viewingStep[1]];
coords = viewingLeg.geometry.coordinates[instruction.interval[0]];
icon = (
<InstructionIcon
sign={instruction.sign}
width="32px"
height="32px"
className={iconClasses}
/>
);
}

if (coords && icon) {
viewingStepMarker = (
<Marker longitude={coords[0]} latitude={coords[1]}>
{icon}
</Marker>
);
}
}

const features = useMemo(() => {
return routes ? routesToGeoJSON(routes, intl) : EMPTY_GEOJSON;
Expand Down Expand Up @@ -607,6 +710,7 @@ const BikeHopperMap = forwardRef(function BikeHopperMapInternal(
style={{ opacity: '70%' }}
/>
)}
{viewingStepMarker}
</MapGL>
<DropdownMenu.Root
open={Boolean(contextMenuAt)}
Expand Down Expand Up @@ -906,4 +1010,25 @@ function pathIndexIs(index: number | null): ExpressionFilterSpecification {
return index == null ? false : ['==', ['get', 'path_index'], index];
}

function getPaddingForMap(overlayEl: HTMLElement) {
const padding = {
top: 40,
left: 40,
right: 40,
bottom: 40,
};
const clientRect = overlayEl.getBoundingClientRect();
padding.top += clientRect.top;
// When the bottom drawer first appears, it should be adjusted to this
// height. (That scroll can happen either before or after this code is
// executed.) Note that this sometimes leaves more space than needed
// because the bottom drawer's actual height may be less than the
// default height if there are only 1 or 2 routes. We might ideally
// prefer to make sure the scroll happened first, and then measure the
// bottom drawer.
padding.bottom += BOTTOM_DRAWER_DEFAULT_SCROLL + BOTTOM_DRAWER_MIN_HEIGHT;

return padding;
}

export default BikeHopperMap;
70 changes: 70 additions & 0 deletions src/components/InstructionIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import InstructionSigns, { InstructionSign } from '../lib/InstructionSigns';

import MapsTurnBack from 'iconoir/icons/maps-turn-back.svg?react';
import LongArrowUpLeft from 'iconoir/icons/long-arrow-up-left.svg?react';
import LongArrowUpRight from 'iconoir/icons/long-arrow-up-right.svg?react';
import ArrowUp from 'iconoir/icons/arrow-up.svg?react';
import TriangleFlag from 'iconoir/icons/triangle-flag.svg?react';
import QuestionMarkCircle from 'iconoir/icons/help-circle.svg?react';
import ArrowTrCircle from 'iconoir/icons/arrow-up-right-circle.svg?react';

/*
* An icon to represent an instruction, such as "turn left."
* Returns a rendered SVG component which should be wrapped by <Icon>.
*
* NOTE: That's different from PlaceIcon, which does render the wrapping
* <Icon> for you.
*
* TODO: Find or design more specific icons for "slight" and "sharp" turns.
*/

export default function InstructionIcon({
sign,
width,
height,
fallback = QuestionMarkCircle,
className,
}: {
sign: InstructionSign;
width?: string | number | undefined;
height?: string | number | undefined;
fallback?: React.FunctionComponent | null;
className?: string;
}) {
let IconSvg;

switch (sign) {
case InstructionSigns.U_TURN_LEFT:
case InstructionSigns.U_TURN_RIGHT:
case InstructionSigns.U_TURN_UNKNOWN:
IconSvg = MapsTurnBack;
break;
case InstructionSigns.KEEP_LEFT:
case InstructionSigns.TURN_LEFT:
case InstructionSigns.TURN_SHARP_LEFT:
case InstructionSigns.TURN_SLIGHT_LEFT:
IconSvg = LongArrowUpLeft;
break;
case InstructionSigns.KEEP_RIGHT:
case InstructionSigns.TURN_RIGHT:
case InstructionSigns.TURN_SHARP_RIGHT:
case InstructionSigns.TURN_SLIGHT_RIGHT:
IconSvg = LongArrowUpRight;
break;
case InstructionSigns.CONTINUE_ON_STREET:
IconSvg = ArrowUp;
break;
case InstructionSigns.FINISH:
case InstructionSigns.REACHED_VIA:
IconSvg = TriangleFlag;
break;
case InstructionSigns.USE_ROUNDABOUT:
IconSvg = ArrowTrCircle;
break;
default:
if (!fallback) return null;
IconSvg = fallback;
}

return <IconSvg className={className} width={width} height={height} />;
}
8 changes: 0 additions & 8 deletions src/components/Itinerary.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.Itinerary {
padding: 32px 20px;
}

.Itinerary_overallTimeHeading {
font-size: 20px;
font-weight: bold;
Expand Down Expand Up @@ -55,7 +51,3 @@
.Itinerary_backIcon path {
stroke-width: 3px;
}

.Itinerary_timeline {
margin: 0;
}
Loading

0 comments on commit 3954bc1

Please sign in to comment.