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: + + + + + + From Location Icon + + + + + + + + + To Location Icon + + + + + + +

+
+`; + +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; +} + +
+
+ SW Morrison at 18th +
+

+

+ Available bikes: 6 +
+
+ Available docks: 11 +
+

+

+ + Plan a trip: + + + + + + From Location Icon + + + + + + + + + To Location Icon + + + + + + +

+
+`; + +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; +} + +
+
+ W Burnside & SW 2nd +
+

+ + Stop ID: 9526 + + +

+

+ + Plan a trip: + + + + + + From Location Icon + + + + + + + + + To Location Icon + + + + + + +

+
+`; + +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; +} + +
+
+ W Burnside & SW 2nd +
+
+`; + 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"