digitransit-ui/app/util/mapIconUtils.js
2025-07-03 14:38:48 +03:00

964 lines
23 KiB
JavaScript

import memoize from 'lodash/memoize';
import getSelector from './get-selector';
import glfun from './glfun';
import { ParkTypes, TransportMode } from '../constants';
/**
* Corresponds to an arc forming a full circle (Math.PI * 2).
*/
const FULL_CIRCLE = Math.PI * 2;
/**
* Return icon style, width and height for stop icons
*
* @param {string} type one of 'stop', 'citybike', 'hybrid', 'scooter'
* @param {number} zoom
* @param {bool} isHighlighted
*/
export function getStopIconStyles(type, zoom, isHighlighted) {
const styles = {
stop: {
13: {
style: 'small',
width: 10,
height: 10,
},
14: {
style: 'large',
width: 16,
height: 22,
},
15: {
style: 'large',
width: 20,
height: 27,
},
16: {
style: 'large',
width: 24,
height: 33,
},
},
hybrid: {
13: {
style: 'small',
width: 10,
height: 10,
},
14: {
style: 'large',
width: 17,
height: 37,
},
15: {
style: 'large',
width: 21,
height: 45,
},
16: {
style: 'large',
width: 25,
height: 55,
},
},
citybike: {
13: {
style: 'small',
width: 10,
height: 10,
},
14: {
style: 'medium',
width: 16,
height: 22,
},
15: {
style: 'medium',
width: 20,
height: 27,
},
16: {
style: 'large',
width: 34,
height: 43,
},
},
scooter: {
13: {
style: 'small',
width: 10,
height: 10,
},
14: {
style: 'medium',
width: 16,
height: 22,
},
15: {
style: 'medium',
width: 20,
height: 27,
},
16: {
style: 'large',
width: 35,
height: 43,
},
},
};
if (!styles[type]) {
return null;
}
if (zoom < 16 && isHighlighted) {
// use bigger icon for highlighted stops always
return styles[type][15];
}
if (zoom < 13 && type !== 'citybike') {
return null;
}
if (zoom < 13) {
return styles[type][13];
}
if (zoom > 16) {
return styles[type][16];
}
return styles[type][zoom];
}
/**
* Get width and height for terminal icons
*
* @param {number} zoom
*/
export function getTerminalIconStyles(zoom) {
const styles = {
12: {
width: 12,
height: 12,
},
13: {
width: 16,
height: 16,
},
14: {
width: 20,
height: 20,
},
15: {
width: 24,
height: 24,
},
16: {
width: 30,
height: 30,
},
};
if (zoom < 12) {
return styles[12];
}
if (zoom > 16) {
return styles[16];
}
return styles[zoom];
}
export const getCaseRadius = memoize(
glfun({
base: 1.15,
stops: [
[11.9, 0],
[12, 1.5],
[22, 26],
],
}),
);
export const getStopRadius = memoize(
glfun({
base: 1.15,
stops: [
[11.9, 0],
[12, 1],
[22, 24],
],
}),
);
export const getHubRadius = memoize(
glfun({
base: 1.15,
stops: [
[14, 0],
[14.1, 2],
[22, 20],
],
}),
);
export const getMapIconScale = memoize(
glfun({
base: 1,
stops: [
[13, 0.8],
[20, 1.6],
],
}),
);
const getStyleOrDefault = (selector, defaultValue = {}) => {
const cssRule = selector && getSelector(selector.toLowerCase());
return (cssRule && cssRule.style) || defaultValue;
};
export const getColor = memoize(selector => getStyleOrDefault(selector).color);
export const getFill = memoize(selector => getStyleOrDefault(selector).fill);
export const getModeColor = mode => getColor(`.${mode}`);
function getImageFromSpriteSync(icon, width, height, fill) {
if (!document) {
return null;
}
const symbol = document.getElementById(icon);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', width);
svg.setAttribute('height', height);
const vb = symbol.viewBox.baseVal;
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.width} ${vb.height}`);
// TODO: Simplify after https://github.com/Financial-Times/polyfill-service/pull/722 is merged
Array.prototype.forEach.call(symbol.childNodes, node => {
const child = node.cloneNode(true);
if (node.style && !child.attributes.fill) {
child.style.fill = fill || window.getComputedStyle(node).color;
}
svg.appendChild(child);
});
if (fill) {
const elements = svg.getElementsByClassName('modeColor');
for (let i = 0; i < elements.length; i++) {
elements[i].setAttribute('fill', fill);
}
}
const image = new Image(width, height);
image.src = `data:image/svg+xml;base64,${btoa(
new XMLSerializer().serializeToString(svg),
)}`;
return image;
}
function getImageFromSpriteAsync(icon, width, height, fill) {
return new Promise(resolve => {
// TODO: check that icon exists using MutationObserver
const image = getImageFromSpriteSync(icon, width, height, fill);
image.onload = () => resolve(image);
});
}
const getImageFromSpriteCache = memoize(
getImageFromSpriteAsync,
(icon, w, h, fill) => `${icon}_${w}_${h}_${fill}`,
);
function drawIconImage(image, tile, geom, width, height) {
tile.ctx.drawImage(
image,
geom.x / tile.ratio - width / 2,
geom.y / tile.ratio - height / 2,
);
}
function calculateIconBadgePosition(
coord,
tile,
imageSize,
badgeSize,
scaleratio,
) {
return coord / tile.ratio - imageSize / 2 - badgeSize / 2 + 2 * scaleratio;
}
function drawIconImageBadge(
image,
tile,
geom,
imageSize,
badgeSize,
scaleratio,
) {
tile.ctx.drawImage(
image,
calculateIconBadgePosition(geom.x, tile, imageSize, badgeSize, scaleratio),
calculateIconBadgePosition(geom.y, tile, imageSize, badgeSize, scaleratio),
);
}
function drawTopRightCornerIconBadge(
image,
tile,
iconTopLeftCornerX,
iconTopLeftCornerY,
width,
badgeSize,
) {
const badgeX = iconTopLeftCornerX + width / 2; // badge left corner placed at the horizontal center of the icon
const badgeY = iconTopLeftCornerY - badgeSize / 3; // badge left corner placed a third above the icon
tile.ctx.drawImage(image, badgeX, badgeY);
}
function getSelectedIconCircleOffset(zoom, ratio) {
if (zoom > 15) {
return 94 / ratio;
}
return 78 / ratio;
}
function drawSelectionCircle(
tile,
x,
y,
radius,
largeStyle,
showAvailabilityBadge = false,
) {
const zoom = tile.coords.z - 1;
const selectedCircleOffset = getSelectedIconCircleOffset(zoom, tile.ratio);
let arc = FULL_CIRCLE;
if (showAvailabilityBadge) {
arc *= 3 / 4;
}
tile.ctx.beginPath();
// eslint-disable-next-line no-param-reassign
tile.ctx.lineWidth = 2;
if (largeStyle) {
tile.ctx.arc(
x + selectedCircleOffset,
y + 1.85 * selectedCircleOffset,
radius - 2,
0,
arc,
);
} else {
tile.ctx.arc(
x + selectedCircleOffset,
y + selectedCircleOffset,
radius + 2,
0,
arc,
);
}
tile.ctx.stroke();
}
/**
* Draw a small circle icon used for far away zoom level.
*/
function getSmallStopIcon(type, radius, color) {
// draw on a new offscreen canvas so that result can be cached
const canvas = document.createElement('canvas');
const width = radius * 2;
canvas.width = width;
canvas.height = width;
const x = width / 2;
const y = width / 2;
const ctx = canvas.getContext('2d');
// outer circle
ctx.beginPath();
ctx.fillStyle = '#fff';
ctx.arc(x, y, radius, 0, FULL_CIRCLE);
ctx.fill();
// inner circle
ctx.beginPath();
ctx.fillStyle = color;
ctx.arc(x, y, radius - 1, 0, FULL_CIRCLE);
ctx.fill();
return new Promise(r => {
r(canvas);
});
}
/**
* Draw a badge icon on top of the icon.
*/
function drawStopStatusBadge(
tile,
x,
y,
iconWidth,
stopOutOfService,
noServiceOnServiceDay,
) {
const badgeSize = iconWidth * 0.75; // badge size is 75% of the icon size
const badgeImageId = stopOutOfService
? `icon-icon_stop-closed-badge`
: `icon-icon_stop-temporarily-closed-badge`;
if (noServiceOnServiceDay || stopOutOfService) {
getImageFromSpriteCache(badgeImageId, badgeSize, badgeSize).then(
badgeImage => {
drawTopRightCornerIconBadge(
badgeImage,
tile,
x,
y,
iconWidth,
badgeSize,
);
},
);
}
}
const getMemoizedStopIcon = memoize(
getSmallStopIcon,
(type, radius, color, isHighlighted) =>
`${type}_${radius}_${color}_${isHighlighted}`,
);
/**
* Draw stop icon based on type.
* Determine size from zoom level.
*/
export function drawStopIcon(
tile,
geom,
type,
platformNumber,
isHighlighted,
isFerryTerminal,
modeIconColors,
stopOutOfService,
noServiceOnServiceDay,
) {
if (type === 'SUBWAY') {
return;
}
const mode = `mode-${type.toLowerCase()}`;
const color =
mode === 'mode-ferry' && !isFerryTerminal
? modeIconColors['mode-ferry-pier']
: modeIconColors[mode];
const zoom = tile.coords.z - 1;
const drawNumber = zoom >= 16;
const styles = getStopIconStyles('stop', zoom, isHighlighted);
if (!styles) {
return;
}
const { style } = styles;
let { width, height } = styles;
width *= tile.scaleratio;
height *= tile.scaleratio;
const radius = width / 2;
let x;
let y;
if (style === 'small') {
x = geom.x / tile.ratio - radius;
y = geom.y / tile.ratio - radius;
getMemoizedStopIcon(
isFerryTerminal ? 'FERRY_TERMINAL' : type,
radius,
color,
isHighlighted,
).then(image => {
tile.ctx.drawImage(image, x, y);
});
return;
}
if (style === 'large') {
x = geom.x / tile.ratio - width / 2;
y = geom.y / tile.ratio - height;
getImageFromSpriteCache(
!isFerryTerminal
? `icon-icon_stop_${type.toLowerCase()}`
: `icon-icon_${type.toLowerCase()}`,
width,
height,
color,
).then(image => {
tile.ctx.drawImage(image, x, y);
drawStopStatusBadge(
tile,
x,
y,
width,
stopOutOfService,
noServiceOnServiceDay,
);
if (drawNumber && platformNumber) {
x += radius;
y += radius;
tile.ctx.beginPath();
/* eslint-disable no-param-reassign */
tile.ctx.fillStyle = color;
tile.ctx.arc(x, y, radius - 1, 0, FULL_CIRCLE);
tile.ctx.fill();
tile.ctx.font = `${
12 * tile.scaleratio
}px Gotham XNarrow SSm A, Gotham XNarrow SSm B, Gotham Rounded A, Gotham Rounded B, Arial, sans-serif`;
tile.ctx.fillStyle = '#fff';
tile.ctx.textAlign = 'center';
tile.ctx.textBaseline = 'middle';
tile.ctx.fillText(platformNumber, x, y);
/* eslint-enable no-param-reassign */
}
});
if (isHighlighted) {
if (isFerryTerminal) {
getImageFromSpriteCache(
`icon-icon_station_highlight`,
width,
height,
).then(image => {
tile.ctx.drawImage(
image,
x - 4 / tile.scaleratio,
y - 4 / tile.scaleratio,
width + 8 / tile.scaleratio,
height + 8 / tile.scaleratio,
);
});
} else {
const selectedCircleOffset = getSelectedIconCircleOffset(
zoom,
tile.ratio,
);
tile.ctx.beginPath();
// eslint-disable-next-line no-param-reassign
tile.ctx.lineWidth = 2;
tile.ctx.arc(
x + selectedCircleOffset,
y + selectedCircleOffset,
radius + 2,
0,
FULL_CIRCLE,
);
tile.ctx.stroke();
}
}
}
}
/**
* Draw icon for hybrid stops, meaning BUS and TRAM stop in the same place.
* Determine icon size based on zoom level
*/
export function drawHybridStopIcon(
tile,
geom,
isHighlighted,
modeIconColors,
hasTrunkRoute = false,
) {
const zoom = tile.coords.z - 1;
const styles = getStopIconStyles('hybrid', zoom, isHighlighted);
if (!styles) {
return;
}
const { style } = styles;
let { width, height } = styles;
width *= tile.scaleratio;
height *= tile.scaleratio;
// only bus/tram hybrid exist
if (style === 'small') {
const radiusInner = 3;
const radiusOuter = 5;
const x = geom.x / tile.ratio;
const y = geom.y / tile.ratio;
// outer icon
/* eslint-disable no-param-reassign */
tile.ctx.beginPath();
tile.ctx.fillStyle = '#fff';
tile.ctx.arc(x, y, radiusOuter * tile.scaleratio, 0, FULL_CIRCLE);
tile.ctx.fill();
tile.ctx.beginPath();
tile.ctx.fillStyle = modeIconColors['mode-tram'];
tile.ctx.arc(x, y, (radiusOuter - 1) * tile.scaleratio, 0, FULL_CIRCLE);
tile.ctx.fill();
// inner icon
tile.ctx.beginPath();
tile.ctx.fillStyle = '#fff';
tile.ctx.arc(x, y, radiusInner * tile.scaleratio, 0, FULL_CIRCLE);
tile.ctx.fill();
tile.ctx.beginPath();
tile.ctx.fillStyle =
modeIconColors[hasTrunkRoute ? 'mode-bus-express' : 'mode-bus'];
tile.ctx.arc(x, y, (radiusInner - 0.5) * tile.scaleratio, 0, FULL_CIRCLE);
tile.ctx.fill();
/* eslint-enable no-param-reassign */
}
if (style === 'large') {
const x = geom.x / tile.ratio - width / 2;
const y = geom.y / tile.ratio - height;
getImageFromSpriteCache(
`icon-icon_map_hybrid_stop${hasTrunkRoute ? '_express' : ''}`,
width,
height,
).then(image => {
tile.ctx.drawImage(image, x, y);
if (isHighlighted) {
tile.ctx.beginPath();
// eslint-disable-next-line no-param-reassign
tile.ctx.lineWidth = 2;
if (zoom === 14 || zoom === 13) {
const xOff = 82; // Position in x-axis
const yOff = 230; // Position in y-axis
const radius = 11; // How large the arcs of the ellipse are (width of the ellipse)
const eHeight = 65; // How tall the ellipse is. Smaller value => taller ellipse.
tile.ctx.arc(
x + xOff / tile.ratio,
y + yOff / tile.ratio,
radius * tile.scaleratio,
0,
Math.PI,
);
tile.ctx.arc(
x + xOff / tile.ratio,
y + eHeight / tile.ratio,
radius * tile.scaleratio,
Math.PI,
0,
);
tile.ctx.arc(
x + xOff / tile.ratio,
y + yOff / tile.ratio,
radius * tile.scaleratio,
0,
Math.PI,
);
} else if (zoom === 15) {
tile.ctx.arc(
x + 81 / tile.ratio,
y + 213 / tile.ratio,
12 * tile.scaleratio,
0,
Math.PI,
);
tile.ctx.arc(
x + 81 / tile.ratio,
y + 75 / tile.ratio,
12 * tile.scaleratio,
Math.PI,
0,
);
tile.ctx.arc(
x + 81 / tile.ratio,
y + 213 / tile.ratio,
12 * tile.scaleratio,
0,
Math.PI,
);
} else {
tile.ctx.arc(
x + 97.2 / tile.ratio,
y + 273 / tile.ratio,
13.5 * tile.scaleratio,
0,
Math.PI,
);
tile.ctx.arc(
x + 97.2 / tile.ratio,
y + 88.5 / tile.ratio,
13.5 * tile.scaleratio,
Math.PI,
0,
);
tile.ctx.arc(
x + 97.2 / tile.ratio,
y + 273 / tile.ratio,
13.5 * tile.scaleratio,
0,
Math.PI,
);
}
tile.ctx.stroke();
}
});
}
}
/**
* Draws small bike rental station icon. Color can vary.
*/
export function drawSmallVehicleRentalMarker(tile, geom, iconColor, mode) {
const radius = mode !== TransportMode.Citybike ? 4 : 5;
const x = geom.x / tile.ratio - radius;
const y = geom.y / tile.ratio - radius;
getMemoizedStopIcon(mode, radius, iconColor).then(image => {
tile.ctx.drawImage(image, x, y);
});
}
/**
* Draw an icon for citybike stations, including indicator to show bike availability. Draw closed icon for closed stations
* Determine icon size based on zoom level
*/
export function drawCitybikeIcon(
tile,
geom,
operative,
available,
iconName,
showAvailability,
isHighlighted,
) {
const zoom = tile.coords.z - 1;
const styles = getStopIconStyles('citybike', zoom, isHighlighted);
const { style } = styles;
let { width, height } = styles;
width *= tile.scaleratio;
height *= tile.scaleratio;
if (!styles) {
return;
}
const radius = width / 2;
let x;
let y;
let color = 'green';
if (showAvailability) {
if (!available) {
color = 'red';
} else if (available <= 3) {
color = 'yellow';
}
}
if (style === 'medium') {
x = geom.x / tile.ratio - width / 2;
y = geom.y / tile.ratio - height;
let icon =
iconName.indexOf('scooter') > -1
? `${iconName}-lollipop`
: `${iconName}_station_${color}_small`;
if (!operative) {
icon = 'icon-icon_citybike_station_closed_small';
}
getImageFromSpriteCache(icon, width, height).then(image => {
tile.ctx.drawImage(image, x, y);
if (isHighlighted) {
drawSelectionCircle(tile, x, y, radius, false, false);
}
});
}
if (style === 'large') {
const smallCircleRadius = 11 * tile.scaleratio;
x = geom.x / tile.ratio - width + smallCircleRadius * 2;
y = geom.y / tile.ratio - height;
const iconX = x;
const iconY = y;
const showAvailabilityBadge =
showAvailability && (available || available === 0) && operative;
let icon =
iconName.indexOf('scooter') > -1
? `${iconName}-lollipop`
: `${iconName}_station_${color}_large`;
if (!operative) {
icon = 'icon-icon_citybike_station_closed_large';
}
getImageFromSpriteCache(icon, width, height).then(image => {
tile.ctx.drawImage(image, x, y);
x = x + width - smallCircleRadius;
y += smallCircleRadius;
if (showAvailabilityBadge) {
/* eslint-disable no-param-reassign */
tile.ctx.font = `${
10.8 * tile.scaleratio
}px Gotham XNarrow SSm A, Gotham XNarrow SSm B, Gotham Rounded A, Gotham Rounded B, Arial, sans-serif`;
tile.ctx.fillStyle = color === 'yellow' ? '#000' : '#fff';
tile.ctx.textAlign = 'center';
tile.ctx.textBaseline = 'middle';
tile.ctx.fillText(available, x, y);
/* eslint-enable no-param-reassign */
}
if (isHighlighted) {
drawSelectionCircle(tile, iconX, iconY, radius, true, true);
}
});
}
}
/**
* Draw an icon for rental vehicles.
* Determine icon size based on zoom level.
*/
export function drawScooterIcon(tile, geom, iconName, isHighlighted) {
const zoom = tile.coords.z - 1;
const styles = getStopIconStyles('scooter', zoom, isHighlighted);
const { style } = styles;
let { width, height } = styles;
width *= tile.scaleratio;
height *= tile.scaleratio;
if (!styles) {
return;
}
const radius = width / 2;
let x;
let y;
if (style === 'medium') {
x = geom.x / tile.ratio - width / 2;
y = geom.y / tile.ratio - height;
const icon = `${iconName}-lollipop`;
getImageFromSpriteCache(icon, width, height).then(image => {
tile.ctx.drawImage(image, x, y);
if (isHighlighted) {
drawSelectionCircle(tile, x, y, radius, false, false);
}
});
}
if (style === 'large') {
const icon = `${iconName}-lollipop-large`;
const smallCircleRadius = 11 * tile.scaleratio;
x = geom.x / tile.ratio - width + smallCircleRadius * 2;
y = geom.y / tile.ratio - height;
const iconX = x;
const iconY = y;
getImageFromSpriteCache(icon, width, height).then(image => {
tile.ctx.drawImage(image, x, y);
if (isHighlighted) {
drawSelectionCircle(tile, iconX, iconY, radius, true, false);
}
});
}
}
export function drawTerminalIcon(tile, geom, type, isHighlighted) {
const zoom = tile.coords.z - 1;
const styles = getTerminalIconStyles(zoom);
if (!styles) {
return;
}
let { width, height } = styles;
width *= tile.scaleratio;
height *= tile.scaleratio;
getImageFromSpriteCache(
`icon-icon_${type.split(',')[0].toLowerCase()}`,
width,
height,
).then(image => {
tile.ctx.drawImage(
image,
geom.x / tile.ratio - width / 2,
geom.y / tile.ratio - height / 2,
);
});
if (isHighlighted) {
getImageFromSpriteCache(`icon-icon_station_highlight`, width, height).then(
image => {
tile.ctx.drawImage(
image,
geom.x / tile.ratio - width / 2 - 4 / tile.scaleratio,
geom.y / tile.ratio - height / 2 - 4 / tile.scaleratio,
width + 8 / tile.scaleratio,
height + 8 / tile.scaleratio,
);
},
);
}
}
/**
* Draw icon for hybrid stations, meaning BUS and TRAM station in the same place.
*/
export function drawHybridStationIcon(tile, geom, isHighlighted) {
const zoom = tile.coords.z - 1;
const styles = getTerminalIconStyles(zoom);
if (!styles) {
return;
}
let { width, height } = styles;
width *= tile.scaleratio * 1.5;
height *= tile.scaleratio * 1.5;
// only bus/tram hybrid exist
getImageFromSpriteCache('icon-icon_map_hybrid_station', width, height).then(
image => {
tile.ctx.drawImage(
image,
geom.x / tile.ratio - width / 2,
geom.y / tile.ratio - height / 2,
);
},
);
if (isHighlighted) {
getImageFromSpriteCache(
'icon-icon_hybrid_station_highlight',
width,
height,
).then(image => {
tile.ctx.drawImage(
image,
geom.x / tile.ratio - width / 2 - 4 / tile.scaleratio,
geom.y / tile.ratio - height / 2 - 4 / tile.scaleratio,
width + 8 / tile.scaleratio,
height + 8 / tile.scaleratio,
);
});
}
}
export function drawParkAndRideIcon(
type,
tile,
geom,
width,
height,
isHighlighted = false,
) {
const img =
type === ParkTypes.Bicycle ? 'icon-icon_bike-park' : 'icon-icon_car-park';
getImageFromSpriteCache(img, width, height).then(image => {
drawIconImage(image, tile, geom, width, height);
});
if (isHighlighted) {
getImageFromSpriteCache(`icon-icon_station_highlight`, width, height).then(
image => {
tile.ctx.drawImage(
image,
geom.x / tile.ratio - width / 2 - 4 / tile.scaleratio,
geom.y / tile.ratio - height / 2 - 4 / tile.scaleratio,
width + 7.5 / tile.scaleratio,
height + 6 / tile.scaleratio,
);
},
);
}
}
export function drawAvailabilityBadge(
availability,
tile,
geom,
imageSize,
badgeSize,
scaleratio,
) {
if (
availability !== 'good' &&
availability !== 'poor' &&
availability !== 'no'
) {
throw Error("Supported badges are 'good', 'poor', and 'no'");
}
getImageFromSpriteCache(
`icon-icon_${availability}-availability`,
badgeSize,
badgeSize,
).then(image => {
drawIconImageBadge(image, tile, geom, imageSize, badgeSize, scaleratio);
});
}
export function drawIcon(icon, tile, geom, imageSize) {
return getImageFromSpriteCache(icon, imageSize, imageSize).then(image => {
drawIconImage(image, tile, geom, imageSize, imageSize);
});
}