diff --git a/__snapshots__/storybook.test.ts.snap b/__snapshots__/storybook.test.ts.snap
index 8976fc1a1..74791fcd6 100644
--- a/__snapshots__/storybook.test.ts.snap
+++ b/__snapshots__/storybook.test.ts.snap
@@ -179119,6 +179119,595 @@ exports[`Storyshots LocationIcon To 2`] = `
`;
+exports[`Storyshots Map Popup Floating Vehicle Entity 1`] = `
+
+
+
+`;
+
+exports[`Storyshots Map Popup Floating Vehicle Entity 2`] = `
+.c0 {
+ font-size: 12px;
+ line-height: 1.5;
+ min-width: 250px;
+}
+
+.c2 {
+ margin-top: 6px;
+}
+
+.c1 {
+ font-size: 18px;
+ font-weight: 500;
+ margin-bottom: 6px;
+}
+
+.c5 {
+ display: inline-block;
+ vertical-align: middle;
+ overflow: hidden;
+}
+
+.c6 {
+ color: #333;
+}
+
+.c8 {
+ color: #f44256;
+}
+
+.c4:first-of-type {
+ border-left: none;
+}
+
+.c3 > * {
+ padding-left: 0.4em;
+ border-left: 1px solid black;
+}
+
+.c7 {
+ background: none;
+ border: none;
+ color: navy;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ padding-left: 0.2em;
+}
+
+.c7:hover {
+ -webkit-text-decoration: underline;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+
+
+ Free-floating bike: 0541 BIKETOWN
+
+
+
+ Plan a trip:
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Storyshots Map Popup Station Entity 1`] = `
+
+
+
+`;
+
+exports[`Storyshots Map Popup Station Entity 2`] = `
+.c0 {
+ font-size: 12px;
+ line-height: 1.5;
+ min-width: 250px;
+}
+
+.c2 {
+ margin-top: 6px;
+}
+
+.c1 {
+ font-size: 18px;
+ font-weight: 500;
+ margin-bottom: 6px;
+}
+
+.c5 {
+ display: inline-block;
+ vertical-align: middle;
+ overflow: hidden;
+}
+
+.c6 {
+ color: #333;
+}
+
+.c8 {
+ color: #f44256;
+}
+
+.c4:first-of-type {
+ border-left: none;
+}
+
+.c3 > * {
+ padding-left: 0.4em;
+ border-left: 1px solid black;
+}
+
+.c7 {
+ background: none;
+ border: none;
+ color: navy;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ padding-left: 0.2em;
+}
+
+.c7:hover {
+ -webkit-text-decoration: underline;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+
+
+
+
+ Available bikes: 6
+
+
+ Available docks: 11
+
+
+
+
+ Plan a trip:
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Storyshots Map Popup Stop Entity 1`] = `
+
+
+
+`;
+
+exports[`Storyshots Map Popup Stop Entity 2`] = `
+.c0 {
+ font-size: 12px;
+ line-height: 1.5;
+ min-width: 250px;
+}
+
+.c2 {
+ margin-top: 6px;
+}
+
+.c1 {
+ font-size: 18px;
+ font-weight: 500;
+ margin-bottom: 6px;
+}
+
+.c6 {
+ display: inline-block;
+ vertical-align: middle;
+ overflow: hidden;
+}
+
+.c7 {
+ color: #333;
+}
+
+.c9 {
+ color: #f44256;
+}
+
+.c5:first-of-type {
+ border-left: none;
+}
+
+.c4 > * {
+ padding-left: 0.4em;
+ border-left: 1px solid black;
+}
+
+.c8 {
+ background: none;
+ border: none;
+ color: navy;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ padding-left: 0.2em;
+}
+
+.c8:hover {
+ -webkit-text-decoration: underline;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.c3 {
+ background: none;
+ border-bottom: none;
+ border-left: 1px solid #000;
+ border-right: none;
+ border-top: none;
+ color: #008;
+ font-family: inherit;
+ margin-left: 5px;
+ padding-top: 0;
+}
+
+
+
+
+
+ Stop ID: 9526
+
+
+
+
+
+ Plan a trip:
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Storyshots Map Popup Stop Entity No Handlers 1`] = `
+
+
+
+`;
+
+exports[`Storyshots Map Popup Stop Entity No Handlers 2`] = `
+.c0 {
+ font-size: 12px;
+ line-height: 1.5;
+ min-width: 250px;
+}
+
+.c1 {
+ font-size: 18px;
+ font-weight: 500;
+ margin-bottom: 6px;
+}
+
+
+
+
+`;
+
exports[`Storyshots ParkAndRideOverlay Default 1`] = `
child?.props?.id !== undefined)
+ .flat(10)
+ .filter(
+ child =>
+ child?.props?.id !== undefined &&
+ // Some sources will not have layers as children, and should be ignored
+ // from the list.
+ child?.props?.alwaysShow !== true
+ )
.map(child => {
const { id: layerId, name, visible } = child.props;
return { id: layerId, name, visible };
@@ -203,7 +209,7 @@ const BaseMap = ({
)}
{Array.isArray(children)
? children
- .flat()
+ .flat(10)
.filter(child => !hiddenLayers.includes(child?.props?.id))
: children}
diff --git a/packages/map-popup/i18n/en-US.yml b/packages/map-popup/i18n/en-US.yml
new file mode 100644
index 000000000..64033f922
--- /dev/null
+++ b/packages/map-popup/i18n/en-US.yml
@@ -0,0 +1,23 @@
+# Default messages for the MapPopup component.
+# To use from a react-intl application:
+# - merge the content of this file into the messages object
+# that has your other localized strings,
+# - flatten the ids, i.e. convert a structure such as
+# otpUi > MapPopup > stopViewer
+# into "otpUi.MapPopup.stopViewer" (see TripDetail story for an example),
+# - pass the resulting object to the messages prop of IntlProvider.
+#
+# The meaning of the pseudo used in the strings below is as follows:
+# - : The link to the Dietary Guidelines for Americans will surround the text enclosed by the tag.
+# - : The enclosed text will be rendered as strong (bold) text (same meaning as in HTML).
+
+otpUi:
+ MapPopup:
+ availableBikes: "Available bikes: {value}"
+ availableDocks: "Available docks: {value}"
+ floatingBike: "Free-floating bike: {name}"
+ floatingCar: "{company} {name}" # as in "CarCompany Veh1234a"
+ floatingEScooter: "E-scooter: {name}"
+
+ stopId: "Stop ID: {stopId}"
+ stopViewer: Stop Viewer
diff --git a/packages/map-popup/i18n/es.yml b/packages/map-popup/i18n/es.yml
new file mode 100644
index 000000000..fa9d81edc
--- /dev/null
+++ b/packages/map-popup/i18n/es.yml
@@ -0,0 +1,10 @@
+otpUi:
+ MapPopup:
+ availableBikes: "Bicicletas disponibles: {value}"
+ availableDocks: "Estaciones de carga disponibles: {value}"
+ floatingBike: "Bicicleta flotante: {name}"
+ floatingCar: "{company} {name}"
+ floatingEScooter: "{company} E-scooter"
+
+ stopId: ID de Parada {stopId}
+ stopViewer: Visor de paradas
diff --git a/packages/map-popup/i18n/fr.yml b/packages/map-popup/i18n/fr.yml
new file mode 100644
index 000000000..50b79bf3f
--- /dev/null
+++ b/packages/map-popup/i18n/fr.yml
@@ -0,0 +1,10 @@
+otpUi:
+ MapPopup:
+ availableBikes: "Vélos disponibles : {value}"
+ availableDocks: "Bornes disponibles : {value}"
+ floatingBike: "Vélo flottant : {name}"
+ floatingCar: "{company} {name}" # as in "CarCompany Veh1234a"
+ floatingEScooter: "Trottinette {company}"
+
+ stopId: Arrêt n°{stopId}
+ stopViewer: Info arrêt
diff --git a/packages/map-popup/i18n/ko.yml b/packages/map-popup/i18n/ko.yml
new file mode 100644
index 000000000..33e97bd02
--- /dev/null
+++ b/packages/map-popup/i18n/ko.yml
@@ -0,0 +1,10 @@
+otpUi:
+ MapPopup:
+ floatingEScooter: "{company} 전기 스쿠터"
+ availableDocks: "사용 가능한 독: {value}"
+ availableBikes: "사용 가능한 자전거: {value}"
+ floatingCar: "{company} {name}"
+ floatingBike: "프리 플로팅 자전거: {name}"
+
+ stopViewer: 뷰어 중지하기
+ stopId: "중지 ID: {stopId}"
diff --git a/packages/map-popup/i18n/vi.yml b/packages/map-popup/i18n/vi.yml
new file mode 100644
index 000000000..29ae48c6e
--- /dev/null
+++ b/packages/map-popup/i18n/vi.yml
@@ -0,0 +1,9 @@
+otpUi:
+ MapPopup:
+ availableBikes: "Xe đạp có sẵn: {value}"
+ availableDocks: "Đế sạc có sẵn: {value}"
+ floatingBike: "Xe đạp nổi tự do: {name}"
+ floatingCar: "{company} {name}"
+ floatingEScooter: "{company} Xe tay ga điện"
+ stopViewer: Trình xem trạm dừng
+ stopId: "ID điểm dừng: {stopId}"
diff --git a/packages/map-popup/i18n/zh_Hans.yml b/packages/map-popup/i18n/zh_Hans.yml
new file mode 100644
index 000000000..4944eb7d7
--- /dev/null
+++ b/packages/map-popup/i18n/zh_Hans.yml
@@ -0,0 +1,10 @@
+otpUi:
+ MapPopup:
+ availableBikes: "可用的自行车: {value}"
+ availableDocks: "可用的充电座: {value}"
+ floatingBike: "自由浮动的自行车: {name}"
+ floatingCar: "{company} {name}" # as in "CarCompany Veh1234a"
+ floatingEScooter: "{company} 电动滑板车"
+
+ stopId: 车站查看器
+ stopViewer: "车站 ID: {stopId}"
diff --git a/packages/map-popup/package.json b/packages/map-popup/package.json
new file mode 100644
index 000000000..d077488ea
--- /dev/null
+++ b/packages/map-popup/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@opentripplanner/map-popup",
+ "version": "0.0.1",
+ "description": "A component for displaying map popup contents",
+ "main": "lib/index.js",
+ "module": "esm/index.js",
+ "types": "lib/index.d.js",
+ "repository": "https://github.com/opentripplanner/otp-ui.git",
+ "homepage": "https://github.com/opentripplanner/otp-ui#readme",
+ "author": "Evan Siroky",
+ "license": "MIT",
+ "private": false,
+ "dependencies": {
+ "@opentripplanner/base-map": "^3.0.7",
+ "@opentripplanner/core-utils": "^8.0.0",
+ "@opentripplanner/from-to-location-picker": "^2.1.5",
+ "flat": "^5.0.2"
+ },
+ "devDependencies": {
+ "@opentripplanner/types": "^4.0.4"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0",
+ "react-intl": "^5.24.6",
+ "styled-components": "^5.3.0"
+ },
+ "bugs": {
+ "url": "https://github.com/opentripplanner/otp-ui/issues"
+ },
+ "scripts": {
+ "tsc": "tsc"
+ }
+}
diff --git a/packages/map-popup/src/MapPopup.story.tsx b/packages/map-popup/src/MapPopup.story.tsx
new file mode 100644
index 000000000..81d07c8cf
--- /dev/null
+++ b/packages/map-popup/src/MapPopup.story.tsx
@@ -0,0 +1,83 @@
+import React from "react";
+import { action } from "@storybook/addon-actions";
+import MapPopupContents from "./index";
+
+export default {
+ title: "Map Popup"
+};
+
+const STOP = {
+ flex: false,
+ id: "9526",
+ lat: 45.523009,
+ lon: -122.672529,
+ name: "W Burnside & SW 2nd"
+};
+
+const STATION = {
+ "stroke-width": 2,
+ allowDropoff: true,
+ allowPickup: true,
+ bikesAvailable: 6,
+ color: "#f00",
+ id: '"hub_1580"',
+ isCarStation: false,
+ isFloatingBike: false,
+ name: "SW Morrison at 18th",
+ networks: ["BIKETOWN"],
+ realTimeData: true,
+ spacesAvailable: 11,
+ x: -122.6896771788597,
+ y: 45.5219604810172
+};
+
+const FLOATING_VEHICLE = {
+ "stroke-width": 1,
+ allowDropoff: false,
+ allowPickup: true,
+ bikesAvailable: 1,
+ color: "#f00",
+ id: '"bike_6861"',
+ isCarStation: false,
+ isFloatingBike: true,
+ name: "0541 BIKETOWN",
+ networks: ["BIKETOWN"],
+ realTimeData: true,
+ spacesAvailable: 0,
+ x: -122.70486,
+ y: 45.525486666666666
+};
+
+export const StopEntity = (): JSX.Element => {
+ return (
+
+ );
+};
+
+export const StopEntityNoHandlers = (): JSX.Element => {
+ return ;
+};
+
+export const StationEntity = (): JSX.Element => {
+ return (
+
+ );
+};
+
+export const FloatingVehicleEntity = (): JSX.Element => {
+ return (
+
+ );
+};
diff --git a/packages/map-popup/src/__unpublished__/messages.d.ts b/packages/map-popup/src/__unpublished__/messages.d.ts
new file mode 100644
index 000000000..68a12e9ec
--- /dev/null
+++ b/packages/map-popup/src/__unpublished__/messages.d.ts
@@ -0,0 +1,2 @@
+// Generic module definition for all YAML file imports.
+declare module "*.yml";
diff --git a/packages/map-popup/src/index.tsx b/packages/map-popup/src/index.tsx
new file mode 100644
index 000000000..d365e7d5e
--- /dev/null
+++ b/packages/map-popup/src/index.tsx
@@ -0,0 +1,136 @@
+import React from "react";
+
+import { Styled as BaseMapStyled } from "@opentripplanner/base-map";
+import FromToLocationPicker from "@opentripplanner/from-to-location-picker";
+// eslint-disable-next-line prettier/prettier
+import type { Company, ConfiguredCompany, Location, Station, Stop } from "@opentripplanner/types";
+
+import { FormattedMessage, useIntl } from "react-intl";
+import { flatten } from "flat";
+import * as S from "./styled";
+
+// Load the default messages.
+import defaultEnglishMessages from "../i18n/en-US.yml";
+import { makeDefaultGetEntityName } from "./util";
+
+// HACK: We should flatten the messages loaded above because
+// the YAML loaders behave differently between webpack and our version of jest:
+// - the yaml loader for webpack returns a nested object,
+// - the yaml loader for jest returns messages with flattened ids.
+export const defaultMessages: { [key: string]: string } = flatten(defaultEnglishMessages);
+
+
+const generateLocation = (entity: Entity, name: string) => {
+ // @ts-expect-error some of these values may be null, but that's ok
+ const { lon: entityLon, lat: entityLat, x, y } = entity
+
+ const lat = entityLat || x
+ const lon = entityLon || y
+ if (!lat || !lon) return null
+
+ return { lat, lon, name };
+}
+
+const StationHubDetails = ({ station }: { station: Station }) => {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+const StopDetails = ({ id, setViewedStop }: { id: string, setViewedStop: ({ stopId }: { stopId: string }) => void; }) => {
+ return (
+
+
+
+
+ setViewedStop({ stopId: id })}>
+
+
+
+ )
+}
+
+type Entity = Stop | Station
+type Props = {
+ configCompanies?: ConfiguredCompany[];
+ entity: Entity
+ getEntityName?: (entity: Entity, configCompanies: Company[],) => string;
+ setLocation?: ({ location, locationType }: { location: Location, locationType: string }) => void;
+ setViewedStop?: ({ stopId }: { stopId: string }) => void;
+};
+
+/**
+ * Renders a map popup for a stop, scooter, or shared bike
+ */
+export function MapPopup({ configCompanies, entity, getEntityName, setLocation, setViewedStop, }: Props): JSX.Element {
+ const intl = useIntl()
+ if (!entity) return <>>
+
+ const getNameFunc = getEntityName || makeDefaultGetEntityName(intl, defaultMessages);
+ const name = getNameFunc(entity, configCompanies);
+
+
+ const bikesAvailablePresent = "bikesAvailable" in entity
+ const entityIsStationHub = bikesAvailablePresent && entity?.bikesAvailable !== undefined && !entity?.isFloatingBike;
+ // @ts-expect-error ts doesn't understand entityIsStop
+ const stopId = !bikesAvailablePresent && entity?.code || entity.id.split(":")[1] || entity.id
+
+ return (
+
+ {name}
+ {/* render dock info if it is available */}
+ {entityIsStationHub && }
+
+ {/* render stop viewer link if available */}
+ {setViewedStop && !bikesAvailablePresent && }
+
+ {/* The "Set as [from/to]" ButtonGroup */}
+ {setLocation && (
+
+
+
+ )}
+
+ );
+}
+
+export default MapPopup;
+
+// Rename styled components for export
+export { S as Styled };
diff --git a/packages/map-popup/src/styled.ts b/packages/map-popup/src/styled.ts
new file mode 100644
index 000000000..d9fa591d8
--- /dev/null
+++ b/packages/map-popup/src/styled.ts
@@ -0,0 +1,14 @@
+import styled from "styled-components";
+
+/* eslint-disable-next-line import/prefer-default-export */
+export const ViewStopButton = styled.button`
+ background: none;
+ border-bottom: none;
+ border-left: 1px solid #000;
+ border-right: none;
+ border-top: none;
+ color: #008;
+ font-family: inherit;
+ margin-left: 5px;
+ padding-top: 0;
+`;
diff --git a/packages/map-popup/src/util.ts b/packages/map-popup/src/util.ts
new file mode 100644
index 000000000..e0c8f4d36
--- /dev/null
+++ b/packages/map-popup/src/util.ts
@@ -0,0 +1,63 @@
+import { Company, Station, Stop } from "@opentripplanner/types";
+import { IntlShape } from "react-intl";
+import coreUtils from "@opentripplanner/core-utils";
+
+// eslint-disable-next-line import/prefer-default-export
+export function makeDefaultGetEntityName(
+ intl: IntlShape,
+ defaultEnglishMessages: { [key: string]: string }
+) {
+ return function defaultGetEntityName(
+ entity: Station | Stop,
+ configCompanies: Company[]
+ ): string | null {
+ const stationNetworks =
+ "networks" in entity &&
+ (coreUtils.itinerary.getCompaniesLabelFromNetworks(
+ entity?.networks || [],
+ configCompanies
+ ) ||
+ entity?.networks?.[0]);
+ let stationName: string | null = entity.name || entity.id;
+ // If the station name or id is a giant UUID (with more than 3 "-" characters)
+ // best not to show that at all. The company name will still be shown.
+ if ((stationName.match(/-/g) || []).length > 3) {
+ stationName = null;
+ }
+
+ if ("isFloatingBike" in entity && entity.isFloatingBike) {
+ stationName = intl.formatMessage(
+ {
+ defaultMessage: defaultEnglishMessages["otpUi.MapPopup.floatingBike"],
+ description: "Popup title for a free-floating bike",
+ id: "otpUi.MapPopup.floatingBike"
+ },
+ { name: stationName || stationNetworks }
+ );
+ } else if ("isFloatingCar" in entity && entity.isFloatingCar) {
+ stationName = intl.formatMessage(
+ {
+ defaultMessage: defaultEnglishMessages["otpUi.MapPopup.floatingCar"],
+ description: "Popup title for a free-floating car",
+ id: "otpUi.MapPopup.floatingCar"
+ },
+ {
+ company: stationNetworks,
+ name: stationName
+ }
+ );
+ } else if ("isFloatingVehicle" in entity && entity.isFloatingVehicle) {
+ // assumes that all floating vehicles are E-scooters
+ stationName = intl.formatMessage(
+ {
+ defaultMessage:
+ defaultEnglishMessages["otpUi.MapPopup.floatingEScooter"],
+ description: "Popup title for a free-floating e-scooter",
+ id: "otpUi.MapPopup.floatingEScooter"
+ },
+ { name: stationName || stationNetworks }
+ );
+ }
+ return stationName;
+ };
+}
diff --git a/packages/map-popup/tsconfig.json b/packages/map-popup/tsconfig.json
new file mode 100644
index 000000000..8fd2e05dd
--- /dev/null
+++ b/packages/map-popup/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "./lib",
+ "rootDir": "./src",
+ "target": "ES2015",
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*"],
+ "references": [
+ {
+ "path": "../types"
+ }
+ ]
+}
diff --git a/yarn.lock b/yarn.lock
index b6b2f022a..74d41bb33 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2832,6 +2832,33 @@
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca"
integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==
+"@opentripplanner/core-utils@^7.0.10", "@opentripplanner/core-utils@^7.0.5", "@opentripplanner/core-utils@^7.0.9":
+ version "7.0.10"
+ resolved "https://registry.yarnpkg.com/@opentripplanner/core-utils/-/core-utils-7.0.10.tgz#697aa2309cee35716bcc3f38311739a75353172c"
+ integrity sha512-dDioXTuhP+Slg7GRWsLayDgwx0XaLQVDP7NtRGHBQ+pEFjgm66Xq4SkmyV+8UCi1uIDp7v49nTU3gBmnsywicQ==
+ dependencies:
+ "@mapbox/polyline" "^1.1.0"
+ "@opentripplanner/geocoder" "^1.3.2"
+ "@styled-icons/foundation" "^10.34.0"
+ "@turf/along" "^6.0.1"
+ bowser "^2.7.0"
+ chroma-js "^2.4.2"
+ date-fns "^2.28.0"
+ date-fns-tz "^1.2.2"
+ lodash.clonedeep "^4.5.0"
+ lodash.isequal "^4.5.0"
+ qs "^6.9.1"
+
+"@opentripplanner/geocoder@1.3.3":
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/@opentripplanner/geocoder/-/geocoder-1.3.3.tgz#7ba1f90e59a30d0306ebe641e8394cb60bd1a1d0"
+ integrity sha512-fBTdLg75OZ1Xsr3l1/XJIYlS+IDVPqm2k8fDwLYOFm+NzBo97ZZIdOkOt1ujCg0uJEv+W1KUR1WqOHwaJ0xk4Q==
+ dependencies:
+ "@conveyal/geocoder-arcgis-geojson" "^0.0.3"
+ "@conveyal/lonlat" "^1.4.1"
+ isomorphic-mapzen-search "^1.6.1"
+ lodash.memoize "^4.1.2"
+
"@pmmmwh/react-refresh-webpack-plugin@^0.5.1":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.4.tgz#df0d0d855fc527db48aac93c218a0bf4ada41f99"